603 lines
33 KiB
TypeScript
603 lines
33 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* ansible-structural-smoke — verify the Ansible playbook at
|
|
* ops/ansible/ is internally consistent.
|
|
*
|
|
* Scenarios:
|
|
* 1. every role declared in playbook.yml exists on disk with
|
|
* a non-empty tasks/main.yml
|
|
* 2. every optional-sidecar role (matrix_bot, host_monitor,
|
|
* smartctl_monitor, fail2ban_monitor, mdadm_monitor) is
|
|
* gated on its enable_* flag in group_vars/all.yml so it
|
|
* defaults to OFF
|
|
* 3. the standard role set (base, hardening, tls, postgres,
|
|
* morphit, bunkerweb) is all present in playbook.yml —
|
|
* catches a careless removal
|
|
* 4. each optional sidecar role's handlers/main.yml uses
|
|
* capitalized handler names (matches ansible-lint
|
|
* name[casing] expectations)
|
|
* 5. collections/requirements.yml declares the three
|
|
* collections that the playbook actually uses
|
|
* (community.general, community.postgresql, community.docker)
|
|
*/
|
|
|
|
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const REPO_ROOT = join(import.meta.dirname, '..', '..', '..');
|
|
const ANSIBLE_ROOT = join(REPO_ROOT, 'ops', 'ansible');
|
|
const PLAYBOOK = join(ANSIBLE_ROOT, 'playbook.yml');
|
|
const GROUP_VARS = join(ANSIBLE_ROOT, 'group_vars', 'all.yml');
|
|
const COLLECTIONS_REQ = join(ANSIBLE_ROOT, 'collections', 'requirements.yml');
|
|
const ROLES_DIR = join(ANSIBLE_ROOT, 'roles');
|
|
|
|
const REQUIRED_BASE_ROLES = [
|
|
'vendor',
|
|
'base',
|
|
'hardening',
|
|
'tls',
|
|
'postgres',
|
|
'morphit',
|
|
'bunkerweb'
|
|
];
|
|
const OPTIONAL_SIDECAR_ROLES = [
|
|
'matrix_bot',
|
|
'host_monitor',
|
|
'smartctl_monitor',
|
|
'fail2ban_monitor',
|
|
'mdadm_monitor',
|
|
'dmesg_monitor',
|
|
'trivy_monitor',
|
|
'postfix_monitor',
|
|
'certbot_monitor',
|
|
'apt_monitor',
|
|
'compose_monitor',
|
|
'systemd_monitor',
|
|
'journald_monitor'
|
|
];
|
|
const REQUIRED_COLLECTIONS = [
|
|
'community.general',
|
|
'community.postgresql',
|
|
'community.docker'
|
|
];
|
|
|
|
function readFile(path: string): string {
|
|
if (!existsSync(path)) {
|
|
throw new Error(`required file missing: ${path}`);
|
|
}
|
|
return readFileSync(path, 'utf-8');
|
|
}
|
|
|
|
interface ScenarioResult {
|
|
readonly name: string;
|
|
readonly ok: boolean;
|
|
readonly detail?: string;
|
|
}
|
|
|
|
const results: ScenarioResult[] = [];
|
|
|
|
// ─── Scenario 1: every declared role exists with tasks/main.yml ──
|
|
function collectDeclaredRoles(): string[] {
|
|
const src = readFile(PLAYBOOK);
|
|
const re = /^\s*-\s*role:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/gm;
|
|
const names: string[] = [];
|
|
for (const m of src.matchAll(re)) {
|
|
names.push(m[1]);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
const declaredRoles = collectDeclaredRoles();
|
|
for (const role of declaredRoles) {
|
|
const dir = join(ROLES_DIR, role);
|
|
const tasksMain = join(dir, 'tasks', 'main.yml');
|
|
let ok = false;
|
|
let detail: string | undefined;
|
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
detail = `directory not found at roles/${role}/`;
|
|
} else if (!existsSync(tasksMain) || !statSync(tasksMain).isFile()) {
|
|
detail = `roles/${role}/tasks/main.yml is missing`;
|
|
} else if (statSync(tasksMain).size === 0) {
|
|
detail = `roles/${role}/tasks/main.yml is empty`;
|
|
} else {
|
|
ok = true;
|
|
}
|
|
results.push({
|
|
name: `declared role "${role}" exists with non-empty tasks/main.yml`,
|
|
ok,
|
|
detail
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 2: every optional sidecar gated on enable_* default false ──
|
|
const allYaml = readFile(GROUP_VARS);
|
|
for (const role of OPTIONAL_SIDECAR_ROLES) {
|
|
const flag = `enable_${role}`;
|
|
// Look for `enable_<role>: false` line (or true — we tolerate;
|
|
// the test is presence-of-the-flag, not the default value,
|
|
// since operators can change all.yml).
|
|
const presentInAll = new RegExp(`^${flag}:\\s*(true|false)`, 'm').test(allYaml);
|
|
const gatedInPlaybook = readFile(PLAYBOOK).includes(
|
|
`when: ${flag} | default(false)`
|
|
);
|
|
results.push({
|
|
name: `optional role "${role}" has enable_${role} in group_vars/all.yml`,
|
|
ok: presentInAll,
|
|
detail: presentInAll ? undefined : `${flag} not found in group_vars/all.yml`
|
|
});
|
|
results.push({
|
|
name: `optional role "${role}" is gated on enable_${role} | default(false) in playbook.yml`,
|
|
ok: gatedInPlaybook,
|
|
detail: gatedInPlaybook
|
|
? undefined
|
|
: `playbook.yml does not gate "${role}" on "${flag} | default(false)"`
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 3: standard role set is present ──
|
|
for (const role of REQUIRED_BASE_ROLES) {
|
|
const present = declaredRoles.includes(role);
|
|
results.push({
|
|
name: `standard role "${role}" is declared in playbook.yml`,
|
|
ok: present,
|
|
detail: present ? undefined : `"${role}" missing from playbook.yml`
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 4: handler-name capitalization in sidecar roles ──
|
|
for (const role of OPTIONAL_SIDECAR_ROLES) {
|
|
const handlersPath = join(ROLES_DIR, role, 'handlers', 'main.yml');
|
|
if (!existsSync(handlersPath)) {
|
|
// mdadm_monitor's role has no env file so might have only
|
|
// the Reload-systemd handler; that still requires a handlers/main.yml.
|
|
results.push({
|
|
name: `role "${role}" has handlers/main.yml`,
|
|
ok: false,
|
|
detail: `${handlersPath} missing`
|
|
});
|
|
continue;
|
|
}
|
|
const handlers = readFileSync(handlersPath, 'utf-8');
|
|
// Find `- name: …` lines under handlers/. Each name's first
|
|
// content-word must be capitalized.
|
|
const lowerCaseFinds: string[] = [];
|
|
for (const m of handlers.matchAll(/^\s*-\s*name:\s*([a-z])/gm)) {
|
|
lowerCaseFinds.push(m[0].trim());
|
|
}
|
|
results.push({
|
|
name: `role "${role}" handler names start with uppercase`,
|
|
ok: lowerCaseFinds.length === 0,
|
|
detail: lowerCaseFinds.length === 0
|
|
? undefined
|
|
: `lowercase handler name(s): ${lowerCaseFinds.join('; ')}`
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 5: collections/requirements.yml declares needed colls ──
|
|
let collReqSrc = '';
|
|
let collReqExists = existsSync(COLLECTIONS_REQ);
|
|
if (collReqExists) {
|
|
collReqSrc = readFileSync(COLLECTIONS_REQ, 'utf-8');
|
|
}
|
|
results.push({
|
|
name: `collections/requirements.yml exists`,
|
|
ok: collReqExists,
|
|
detail: collReqExists ? undefined : `missing at ${COLLECTIONS_REQ}`
|
|
});
|
|
for (const coll of REQUIRED_COLLECTIONS) {
|
|
const present = collReqSrc.includes(`name: ${coll}`);
|
|
results.push({
|
|
name: `collections/requirements.yml declares "${coll}"`,
|
|
ok: present,
|
|
detail: present ? undefined : `"${coll}" not found in requirements.yml`
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 6: no orphaned role directories ──
|
|
// (roles on disk that aren't declared in playbook.yml — could be
|
|
// stale leftovers).
|
|
const onDiskRoles = readdirSync(ROLES_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name);
|
|
const declaredSet = new Set(declaredRoles);
|
|
const orphans = onDiskRoles.filter((r) => !declaredSet.has(r));
|
|
results.push({
|
|
name: 'no orphan role directories on disk',
|
|
ok: orphans.length === 0,
|
|
detail:
|
|
orphans.length === 0
|
|
? undefined
|
|
: `orphan role dirs: ${orphans.join(', ')}`
|
|
});
|
|
|
|
// ─── Scenario 7: every system user's LITERAL primary group is created ──
|
|
// A `ansible.builtin.user` task with `group: <literal>` fails at RUNTIME with
|
|
// "Group <name> does not exist" unless a `ansible.builtin.group` task creates
|
|
// it first. cp634: morphit-mcp had the user but not the group — the sandbox
|
|
// can't catch this class of bug because it never creates real system accounts,
|
|
// so this static pairing check stands in for it.
|
|
const baseTasksSrc = readFileSync(join(ROLES_DIR, 'base', 'tasks', 'main.yml'), 'utf-8');
|
|
const baseBlocks = baseTasksSrc.split(/\n- /);
|
|
const strip = (s: string): string => s.trim().replace(/^["']|["']$/g, '');
|
|
const createdGroups = new Set<string>();
|
|
for (const b of baseBlocks) {
|
|
if (!/ansible\.builtin\.group:/.test(b)) continue;
|
|
const m = b.match(/\n\s*name:\s*(.+)/);
|
|
if (m) createdGroups.add(strip(m[1]));
|
|
}
|
|
const literalUserGroups: string[] = [];
|
|
for (const b of baseBlocks) {
|
|
if (!/ansible\.builtin\.user:/.test(b)) continue;
|
|
const m = b.match(/\n\s*group:\s*(.+)/); // primary group (not the plural `groups:`)
|
|
if (m) {
|
|
const g = strip(m[1]);
|
|
if (!g.includes('{')) literalUserGroups.push(g); // skip {{ var }} groups
|
|
}
|
|
}
|
|
const uncreatedGroups = literalUserGroups.filter((g) => !createdGroups.has(g));
|
|
results.push({
|
|
name: 'every system user\'s literal primary group is created by a group task (no runtime "Group X does not exist")',
|
|
ok: uncreatedGroups.length === 0,
|
|
detail: uncreatedGroups.length === 0 ? undefined : `user primary group(s) never created: ${uncreatedGroups.join(', ')}`
|
|
});
|
|
|
|
// ─── Scenario 8: no BARE connection-var interpolation in any role file ──
|
|
// `{{ ansible_user }}` (and other connection vars) are UNDEFINED on a local
|
|
// install and crash the render — even inside a `#`-commented line, because
|
|
// Jinja still evaluates `{{ }}` regardless of config-comment syntax. cp633
|
|
// hit this in the connection-safety assert; cp635 hit it in the hardening sshd
|
|
// template (a scan of only when:/assert: missed it). Every interpolation of a
|
|
// connection var in a role's tasks/templates/handlers/vars must be
|
|
// `| default(...)`-guarded.
|
|
const connVarRe = /\{\{[^}]*\b(ansible_user|ansible_host|ansible_port|ansible_ssh_host|ansible_ssh_user|ansible_ssh_port)\b[^}]*\}\}/g;
|
|
const allRoleFiles = readdirSync(ROLES_DIR, { recursive: true })
|
|
.filter((f): f is string => typeof f === 'string' && (f.endsWith('.yml') || f.endsWith('.j2')))
|
|
.map((f) => join(ROLES_DIR, f));
|
|
const bareConnVars: string[] = [];
|
|
for (const f of allRoleFiles) {
|
|
const fsrc = readFileSync(f, 'utf-8');
|
|
for (const m of fsrc.matchAll(connVarRe)) {
|
|
if (!/\|\s*default/.test(m[0])) bareConnVars.push(`${f.replace(REPO_ROOT + '/', '')} → ${m[0]}`);
|
|
}
|
|
}
|
|
results.push({
|
|
name: 'no bare (unguarded) connection-var interpolation in roles — templates included (undefined on local install)',
|
|
ok: bareConnVars.length === 0,
|
|
detail: bareConnVars.length === 0 ? undefined : `bare: ${bareConnVars.join(' | ')}`
|
|
});
|
|
|
|
// ─── Scenario 9: SSH hardening is gated on an SSH server being present ──
|
|
// openssh-server owns /etc/ssh/sshd_config.d + /etc/ssh/sshd_config + the `sshd`
|
|
// validate binary. A home desktop node the operator runs and administers
|
|
// locally often has NO SSH server, so writing the hardening there crashed
|
|
// ("Destination directory /etc/ssh/sshd_config.d does not exist"). Fixed by
|
|
// probing /etc/ssh/sshd_config (which exists iff openssh-server is installed)
|
|
// and gating every SSH task on it — we deliberately do NOT force-install an SSH
|
|
// server. Guard: the probe must exist AND every task in ssh.yml that writes
|
|
// under /etc/ssh must be `when: <probe>.stat.exists`-gated so this cannot regress.
|
|
{
|
|
const sshSrc = readFileSync(join(ROLES_DIR, 'hardening', 'tasks', 'ssh.yml'), 'utf-8');
|
|
const hasProbe =
|
|
/ansible\.builtin\.stat:[\s\S]{0,160}?path:\s*\/etc\/ssh\/sshd_config\b[\s\S]{0,160}?register:\s*\w+/.test(sshSrc);
|
|
const ungated: string[] = [];
|
|
for (const blk of sshSrc.split(/\n(?=- name:)/)) {
|
|
// The presence probe itself READS /etc/ssh/sshd_config (via stat) — it is
|
|
// the thing the gate depends on, so it must not be treated as a write.
|
|
const isProbe = /ansible\.builtin\.stat:/.test(blk);
|
|
const writesToSsh = !isProbe && /(dest|path):\s*\/etc\/ssh\/sshd_config/.test(blk);
|
|
if (writesToSsh && !/when:[^\n]*stat\.exists/.test(blk)) {
|
|
const nameM = /- name:\s*(.+)/.exec(blk);
|
|
ungated.push(nameM ? nameM[1].trim() : '(unnamed task)');
|
|
}
|
|
}
|
|
results.push({
|
|
name: 'hardening SSH tasks are gated on openssh-server presence (no crash on a node without SSH)',
|
|
ok: hasProbe && ungated.length === 0,
|
|
detail: !hasProbe
|
|
? 'ssh.yml is missing the /etc/ssh/sshd_config stat presence-probe'
|
|
: ungated.length > 0
|
|
? `ungated /etc/ssh write task(s): ${ungated.join(', ')}`
|
|
: undefined
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 10: every drop-in write into a package .d/ dir ensures the dir ──
|
|
// TWO separate releases were lost to this exact class — a hardening drop-in
|
|
// written into a package-owned .d/ directory the package did NOT create
|
|
// (openssh-server's /etc/ssh/sshd_config.d; libpam-pwquality's
|
|
// /etc/security/pwquality.conf.d). We do NOT trust a package to have made its
|
|
// own drop-in dir: every write into an `/etc/**/*.d/` directory must have a
|
|
// matching `file: state=directory` ensure somewhere in the role set, or be one
|
|
// of a tiny allowlist of always-present OS drop-in dirs. Sandbox playbook runs
|
|
// can't catch this (they stub the dirs) — only this static guard can.
|
|
{
|
|
// Every dir any role explicitly ensures (literal /etc paths only).
|
|
const ensuredDirs = new Set<string>();
|
|
for (const f of allRoleFiles) {
|
|
if (f.endsWith('.j2')) continue;
|
|
const src = readFileSync(f, 'utf-8');
|
|
for (const blk of src.split(/\n(?=\s*- )/)) {
|
|
if (!/state:\s*directory/.test(blk)) continue;
|
|
const m = /\bpath:\s*['"]?(\/etc\/[^\s'"]+)/.exec(blk);
|
|
if (m) ensuredDirs.add(m[1].replace(/\/$/, ''));
|
|
}
|
|
}
|
|
// Always-present OS drop-in dirs no package/role needs to create.
|
|
const alwaysPresent = new Set(['/etc/apt/apt.conf.d', '/etc/sysctl.d', '/etc/systemd/system']);
|
|
const unEnsuredDropins: string[] = [];
|
|
for (const f of allRoleFiles) {
|
|
if (f.endsWith('.j2')) continue;
|
|
const src = readFileSync(f, 'utf-8');
|
|
for (const m of src.matchAll(/\b(?:dest|path):\s*['"]?(\/etc\/[^\s'"]+)/g)) {
|
|
const dest = m[1].replace(/['"]$/, '');
|
|
if (dest.includes('{{')) continue; // templated — skip
|
|
const parent = dest.replace(/\/[^/]+$/, '');
|
|
if (!/\.d$/.test(parent)) continue; // only *.d drop-in dirs
|
|
if (alwaysPresent.has(parent) || ensuredDirs.has(parent)) continue;
|
|
unEnsuredDropins.push(`${f.replace(REPO_ROOT + '/', '')} → ${dest} (dir ${parent} not ensured)`);
|
|
}
|
|
}
|
|
results.push({
|
|
name: 'every drop-in write into an /etc/**/*.d dir has a state=directory ensure (no "directory does not exist" on install)',
|
|
ok: unEnsuredDropins.length === 0,
|
|
detail: unEnsuredDropins.length === 0 ? undefined : unEnsuredDropins.join(' | ')
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 11: first-online deferred-completion subsystem is fully wired ──
|
|
// The offline appliance depends on this: the install finishes offline, then the
|
|
// network-dependent tail (real TLS cert, Blurt RPC connect, opt-in on-chain
|
|
// registration) is completed by morphit-first-online the moment the box first
|
|
// sees the internet. Verify the script + units exist AND are actually deployed
|
|
// + enabled by the morphit role (a shipped-but-unwired unit would silently never
|
|
// run — exactly the class of gap that has bitten this install before).
|
|
{
|
|
const fo: string[] = [];
|
|
const script = join(REPO_ROOT, 'ops', 'first-online', 'morphit-first-online.sh');
|
|
const svc = join(REPO_ROOT, 'ops', 'systemd', 'morphit-first-online.service');
|
|
const tmr = join(REPO_ROOT, 'ops', 'systemd', 'morphit-first-online.timer');
|
|
if (!existsSync(script)) fo.push('ops/first-online/morphit-first-online.sh missing');
|
|
if (!existsSync(svc)) fo.push('morphit-first-online.service missing');
|
|
if (!existsSync(tmr)) fo.push('morphit-first-online.timer missing');
|
|
const morphitTasks = existsSync(join(ROLES_DIR, 'morphit', 'tasks', 'main.yml'))
|
|
? readFileSync(join(ROLES_DIR, 'morphit', 'tasks', 'main.yml'), 'utf-8')
|
|
: '';
|
|
if (!/morphit-first-online\.service/.test(morphitTasks) || !/morphit-first-online\.timer/.test(morphitTasks))
|
|
fo.push('first-online units not in the morphit role systemd install loop');
|
|
if (!/morphit-first-online\.sh/.test(morphitTasks)) fo.push('first-online script not deployed by the morphit role');
|
|
if (!/first-online\.env/.test(morphitTasks)) fo.push('first-online.env not deployed by the morphit role');
|
|
if (!/morphit-first-online\.timer/.test(morphitTasks) || !/state:\s*started/.test(morphitTasks))
|
|
fo.push('first-online timer not enabled+started');
|
|
const envTmpl = join(ROLES_DIR, 'morphit', 'templates', 'first-online.env.j2');
|
|
if (!existsSync(envTmpl)) fo.push('first-online.env.j2 template missing');
|
|
else if (!/MORPHIT_AUTO_REGISTER/.test(readFileSync(envTmpl, 'utf-8'))) fo.push('first-online.env.j2 missing MORPHIT_AUTO_REGISTER');
|
|
// The service must be triggered by the network coming up.
|
|
if (existsSync(svc) && !/WantedBy=network-online\.target/.test(readFileSync(svc, 'utf-8')))
|
|
fo.push('first-online.service not WantedBy=network-online.target');
|
|
results.push({
|
|
name: 'first-online deferred-completion subsystem is wired (script + units deployed + enabled by the morphit role)',
|
|
ok: fo.length === 0,
|
|
detail: fo.length === 0 ? undefined : fo.join(' | ')
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 12: offline-appliance bundle wiring (dormant online, offline when bundled) ──
|
|
// Everything that makes apt / Docker / Kubo / Node install with NO internet when a
|
|
// self-contained bundle is present — each gated on the bundle so an ordinary
|
|
// online install is untouched. A gap here means "install completely offline"
|
|
// silently doesn't, or (worse) the online path breaks.
|
|
{
|
|
const ob: string[] = [];
|
|
const R = (p: string): string => join(ROLES_DIR, p);
|
|
const readIf = (p: string): string => (existsSync(p) ? readFileSync(p, 'utf-8') : '');
|
|
const vendor = readIf(R('vendor/tasks/main.yml'));
|
|
if (!vendor) ob.push('vendor role missing');
|
|
else {
|
|
if (!/vendor\/apt\/Packages\.gz/.test(vendor)) ob.push('vendor role does not detect vendor/apt');
|
|
if (!/apt\.conf\.d\/99-morphit-offline\.conf/.test(vendor)) ob.push('vendor role does not write the reversible apt override');
|
|
if (!/when:\s*morphit_vendor_apt\.stat\.exists/.test(vendor)) ob.push('vendor role apt override not gated on the bundle');
|
|
// Regression (air-gapped install died at `base: apt update`): the vendor
|
|
// role runs FIRST, before the morphit role copies the tree to
|
|
// morphit_repo_path (/opt/morphit), so it MUST look for the bundle in the
|
|
// extraction dir (morphit_local_source_path) — /opt/morphit is empty then.
|
|
if (!/morphit_local_source_path\s*\}\}\/vendor\/apt/.test(vendor)) ob.push('vendor role must reference the bundle via morphit_local_source_path (the extraction dir, present when vendor runs first)');
|
|
if (/morphit_repo_path\s*\}\}\/vendor\/apt/.test(vendor)) ob.push('vendor role must NOT use morphit_repo_path for the bundle (/opt/morphit is empty until the morphit role copies later)');
|
|
}
|
|
const pb = existsSync(PLAYBOOK) ? readFileSync(PLAYBOOK, 'utf-8') : '';
|
|
if (!/role:\s*vendor/.test(pb)) ob.push('vendor role not wired into the playbook');
|
|
else if (pb.indexOf('role: vendor') > pb.indexOf('role: base')) ob.push('vendor role must run BEFORE base (apt redirected before any install)');
|
|
const bw = readIf(R('bunkerweb/tasks/main.yml'));
|
|
if (!/vendor\/docker/.test(bw) || !/docker load/.test(bw)) ob.push('bunkerweb role does not load bundled Docker images');
|
|
const bobSh = existsSync(join(REPO_ROOT, 'scripts', 'build-offline-bundle.sh')) ? readFileSync(join(REPO_ROOT, 'scripts', 'build-offline-bundle.sh'), 'utf-8') : '';
|
|
const bobCmd = bobSh.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
|
// The bundle must save EXACTLY the docker images a guided install needs offline:
|
|
// every `image:` the bunkerweb compose pins (via group_vars) AND the frontend
|
|
// Dockerfile's FROM base (compose builds it with --build). A wrong tag or a
|
|
// missing image makes `docker compose up` pull from Docker Hub and die offline.
|
|
const compose = readIf(R('bunkerweb/templates/docker-compose.yml.j2'));
|
|
for (const mm of compose.matchAll(/image:\s*\{\{\s*(\w+)\s*\}\}/g))
|
|
if (!new RegExp(`\\^${mm[1]}:`).test(bobCmd)) ob.push(`build-offline-bundle.sh does not read the compose image ${mm[1]} from group_vars to save it — offline docker compose up would pull it`);
|
|
if (/image:\s*\{\{/.test(compose) && /bunkerity\/bunkerweb:latest/.test(bobCmd)) ob.push('build-offline-bundle.sh saves bunkerity/bunkerweb:latest but compose pins a version — tag mismatch forces an offline pull');
|
|
if (existsSync(join(REPO_ROOT, 'ops', 'bunkerweb', 'frontend', 'Dockerfile')) && !/frontend\/Dockerfile/.test(bobCmd)) ob.push('build-offline-bundle.sh does not bundle the frontend Dockerfile FROM base image — docker compose up --build would pull it offline');
|
|
// Offline install must NOT fetch Docker's repo key from the internet — docker-ce
|
|
// and friends are in the bundled apt closure. The vendor role sets the fact.
|
|
const vend = readIf(R('vendor/tasks/main.yml'));
|
|
if (!/morphit_offline_install:\s*true/.test(vend)) ob.push('vendor role does not set morphit_offline_install when a bundle is present');
|
|
if (/download\.docker\.com/.test(bw) && !/not morphit_offline_install/.test(bw)) ob.push('bunkerweb fetches the Docker repo key unconditionally — must skip on an offline install (docker-ce is bundled)');
|
|
const ipfs = readIf(R('ipfs/tasks/main.yml'));
|
|
if (!/vendor\/kubo/.test(ipfs)) ob.push('ipfs role does not use a bundled Kubo when present');
|
|
// IPFS is release-hosting — a network job like certbot/register, which the
|
|
// appliance defers. Its daemon must NOT hard-fail an offline Phase-1 install:
|
|
// the start is best-effort (failed_when: false) with the daemon left enabled, and
|
|
// the unit is offline-safe (network.target not network-online; --migrate=false so
|
|
// it never tries to fetch a migration with no network).
|
|
const ipfsHandlers = readIf(R('ipfs/handlers/main.yml'));
|
|
const ipfsHandlersCmd = ipfsHandlers.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
|
const ipfsCmd = ipfs.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
|
if (!/state:\s*restarted[\s\S]{0,120}failed_when:\s*false/.test(ipfsHandlersCmd)) ob.push('ipfs Restart handler is not best-effort (failed_when: false) — an offline daemon start would fail the whole install');
|
|
if (!/name:\s*ipfs[\s\S]{0,120}state:\s*started[\s\S]{0,120}failed_when:\s*false/.test(ipfsCmd)) ob.push('ipfs "start the daemon" task is not best-effort — an offline Phase-1 box would fail here (the last role)');
|
|
const ipfsUnit = readIf(join(ROLES_DIR, 'ipfs', 'templates', 'ipfs.service.j2'));
|
|
const ipfsUnitCmd = ipfsUnit.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
|
if (/(After|Wants)=network-online\.target/.test(ipfsUnitCmd)) ob.push('ipfs.service requires network-online.target — stalls the daemon on an air-gapped box; use network.target');
|
|
if (/--migrate=true/.test(ipfsUnitCmd)) ob.push('ipfs.service uses --migrate=true — a migration can only be fetched over the network; use --migrate=false (pinned single-version Kubo never migrates)');
|
|
const nodejs = readIf(R('morphit/tasks/nodejs.yml'));
|
|
if (!/morphit_node_have/.test(nodejs)) ob.push('nodejs role does not skip NodeSource when Node is already present');
|
|
// Offline install would die at `npm install` unless the prebuilt node_modules
|
|
// is COPIED into place: the tar-pipe excludes node_modules for online installs
|
|
// but must INCLUDE it when the source carries the bundle marker.
|
|
const cb = readIf(R('morphit/tasks/clone_and_build.yml'));
|
|
if (!/morphit_source_bundle_marker/.test(cb)) ob.push('clone_and_build does not detect a bundled node_modules in the SOURCE (offline install would run npm install → hit the registry)');
|
|
if (!/morphit_source_bundle_marker\.stat\.exists[\s\S]*?--exclude=node_modules/.test(cb)) ob.push('clone_and_build unconditionally excludes node_modules — must keep it for an offline bundle');
|
|
// The copy MUST NOT strip node_modules/*/dist (root or nested): GNU tar lets `*`
|
|
// cross `/`, so anchored excludes need --no-wildcards-match-slash and there must
|
|
// be NO bare dist/build exclude (which would match node_modules/vite/dist etc.).
|
|
// Check the COMMAND only — comments here mention the bad pattern as a warning.
|
|
const cbCmd = cb.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
|
if (!/--no-wildcards-match-slash/.test(cbCmd)) ob.push('clone_and_build tar lacks --no-wildcards-match-slash — its ./apps/*/dist excludes will also strip node_modules/*/dist (offline build breaks with "Cannot find module …/dist/…")');
|
|
if (/--exclude=dist\b/.test(cbCmd) || /--exclude=build\b/.test(cbCmd)) ob.push('clone_and_build has an UNANCHORED --exclude=dist/build — strips every package dist in node_modules; anchor to ./apps/*/dist + add --no-wildcards-match-slash');
|
|
// The npm build + verify run as the nologin service user; their npm cache must
|
|
// live in the repo (writable, re-created each run), not the service user's
|
|
// $HOME/.npm — that home is not guaranteed writable and broke `npm exec` EACCES.
|
|
if ((cbCmd.match(/npm_config_cache:\s*"\{\{ morphit_repo_path \}\}\/\.npm-cache"/g) || []).length < 2) ob.push('clone_and_build npm build/verify do not pin npm_config_cache into the repo — npm falls back to $HOME/.npm (offline install fails EACCES)');
|
|
// And base must make the service user actually own its home (create_home does not
|
|
// re-chown a pre-existing dir), or $HOME/.npm + Ansible become-temp are unwritable.
|
|
const baseMain = readIf(R('base/tasks/main.yml'));
|
|
if (!(/path:\s*"\{\{ morphit_service_home \}\}"[\s\S]{0,240}recurse:\s*true/.test(baseMain) && /morphit_service_home[\s\S]{0,240}owner:\s*"\{\{ morphit_service_user \}\}"/.test(baseMain))) ob.push('base does not recursively chown the service home to the service user — a root-owned/re-used home fails the offline install (npm exec EACCES)');
|
|
// morphit-mcp is created ONLY by the morphit role (gated on mcp_enabled, isolated —
|
|
// NOT in the service group). base must NOT also define it: two definitions with
|
|
// different homes force a `usermod` every converge that fails once the service is
|
|
// running ("user morphit-mcp is currently used by process …"), and base's variant
|
|
// wrongly put it in the service group, breaking the MCP's isolation.
|
|
if (/name:\s*morphit-mcp\b/.test(baseMain)) ob.push('base role defines a morphit-mcp user/group — it must be the morphit role ONLY (conflicting homes force a usermod that fails on re-run; service-group membership breaks MCP isolation)');
|
|
const foPath = join(REPO_ROOT, 'ops', 'first-online', 'morphit-first-online.sh');
|
|
const fo = existsSync(foPath) ? readFileSync(foPath, 'utf-8') : '';
|
|
if (!/99-morphit-offline\.conf/.test(fo)) ob.push('first-online does not restore normal apt (remove the offline override) when online');
|
|
if (!existsSync(join(REPO_ROOT, 'scripts', 'build-offline-bundle.sh'))) ob.push('scripts/build-offline-bundle.sh (the bundle recipe) missing');
|
|
// The MCP deploy (on by default) runs `npm install` in a separate tree; offline
|
|
// that must use the bundled npm cache, which the build ships via `npm ci --cache`.
|
|
if (!/npm ci --cache "\$\{VENDOR\}\/npm-cache"/.test(bobSh)) ob.push('build-offline-bundle.sh does not ship an npm cache (npm ci --cache vendor/npm-cache) for the offline MCP deploy');
|
|
if (/--exclude='?\.\/apps\/\*\/dist'?/.test(bobCmd) && !/--no-wildcards-match-slash/.test(bobCmd)) ob.push('build-offline-bundle.sh packaging tar lacks --no-wildcards-match-slash — its ./apps/*/dist exclude also strips nested apps/*/node_modules/*/dist from the bundle');
|
|
const dmSh = existsSync(join(REPO_ROOT, 'ops', 'scripts', 'deploy-mcp.sh')) ? readFileSync(join(REPO_ROOT, 'ops', 'scripts', 'deploy-mcp.sh'), 'utf-8') : '';
|
|
if (/npm install/.test(dmSh) && !/--offline --cache "\$REPO_DIR\/vendor\/npm-cache"/.test(dmSh)) ob.push('deploy-mcp.sh npm install is not offline-safe against the bundled npm cache');
|
|
// npm ci caches tarballs but NOT the packuments a fresh `npm install` needs to
|
|
// resolve the MCP deploy's rewritten package.json — so the build must WARM the
|
|
// cache by running deploy-mcp online once (MORPHIT_MCP_CACHE_WARM=1), and
|
|
// deploy-mcp must honour that override to force its online branch.
|
|
if (!/MORPHIT_MCP_CACHE_WARM=1[\s\S]{0,200}deploy-mcp\.sh/.test(bobCmd)) ob.push('build-offline-bundle.sh does not warm the npm cache for the offline MCP deploy (offline npm install would fail ENOTCACHED on the SDK packument)');
|
|
if (/vendor\/npm-cache/.test(dmSh) && !/MORPHIT_MCP_CACHE_WARM:-/.test(dmSh)) ob.push('deploy-mcp.sh does not honour MORPHIT_MCP_CACHE_WARM — the build cannot warm the cache online');
|
|
results.push({
|
|
name: 'offline-appliance bundle wiring (apt/docker/kubo/node install offline when bundled; dormant online; apt restored when online)',
|
|
ok: ob.length === 0,
|
|
detail: ob.length === 0 ? undefined : ob.join(' | ')
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 13: roles that run BEFORE the morphit copy must read repo files
|
|
// from the SOURCE dir, not the not-yet-populated /opt/morphit ──
|
|
// Regression: a guided home install died at `ddns: Install the DDNS updater
|
|
// script` — it copied from a HARDCODED /opt/morphit/ops/ddns/... that is empty
|
|
// until the morphit role (which runs LATER) copies the extracted tree there.
|
|
// Same class as the vendor apt bug. morphit_source_dir resolves to the
|
|
// extraction dir on a guided install and /opt/morphit on a manual one, so a
|
|
// pre-copy role reads real files either way.
|
|
{
|
|
const ob: string[] = [];
|
|
const R = (p: string): string => join(ROLES_DIR, p);
|
|
const readIf = (p: string): string => (existsSync(p) ? readFileSync(p, 'utf-8') : '');
|
|
const gv = readIf(join(ANSIBLE_ROOT, 'group_vars', 'all.yml'));
|
|
if (!/morphit_source_dir\s*:/.test(gv)) ob.push('morphit_source_dir not defined in group_vars/all.yml');
|
|
const pb = existsSync(PLAYBOOK) ? readFileSync(PLAYBOOK, 'utf-8') : '';
|
|
const idxMorphit = pb.indexOf('role: morphit');
|
|
const before = (role: string): boolean => {
|
|
const i = pb.indexOf(`role: ${role}`);
|
|
return i >= 0 && idxMorphit >= 0 && i < idxMorphit;
|
|
};
|
|
// ddns updater script — no hardcoded /opt/morphit src; must use the source var.
|
|
const ddns = readIf(R('ddns/tasks/main.yml'));
|
|
if (before('ddns') && /src:\s*\/opt\/morphit\//.test(ddns)) ob.push('ddns copies from a HARDCODED /opt/morphit source (empty until the morphit copy) — use morphit_source_dir');
|
|
if (before('ddns') && /morphit-ddns-update\.sh/.test(ddns) && !/morphit_source_dir|morphit_local_source_path/.test(ddns)) ob.push('ddns updater-script src must use morphit_source_dir');
|
|
// postgres init.sql — must read from the source dir, not morphit_repo_path.
|
|
const pg = readIf(R('postgres/tasks/main.yml'));
|
|
if (before('postgres') && /-f \{\{\s*morphit_repo_path\s*\}\}\/ops\/postgres\/init\.sql/.test(pg)) ob.push('postgres reads init.sql from morphit_repo_path (/opt/morphit, empty until the copy) — use morphit_source_dir');
|
|
results.push({
|
|
name: 'pre-copy roles read repo files from the source dir, not the empty /opt/morphit (ddns + postgres)',
|
|
ok: ob.length === 0,
|
|
detail: ob.length === 0 ? undefined : ob.join(' | ')
|
|
});
|
|
}
|
|
|
|
// ─── Scenario 14: the offline bundle's apt closure covers every package the
|
|
// default-enabled roles install ──
|
|
// Regression: the hand-maintained PKGS list in build-offline-bundle.sh silently
|
|
// drifted from the roles (missing postgresql, build-essential, chrony, docker-
|
|
// buildx-plugin + 13 more). On a used test box those happen to be pre-installed
|
|
// so the gap hides; a fresh minimal appliance dies at the first apt install with
|
|
// no network. This diffs PKGS against what the roles actually apt-install so the
|
|
// list can never silently fall behind again.
|
|
{
|
|
const ob: string[] = [];
|
|
const R = (p: string): string => join(ROLES_DIR, p);
|
|
const bundleSh = join(REPO_ROOT, 'scripts', 'build-offline-bundle.sh');
|
|
const shTxt = existsSync(bundleSh) ? readFileSync(bundleSh, 'utf-8') : '';
|
|
const m = shTxt.match(/PKGS="([\s\S]*?)"/);
|
|
const pkgs = new Set(
|
|
(m ? m[1] : '').replace(/\\/g, ' ').split(/\s+/).map((s) => s.trim()).filter(Boolean)
|
|
);
|
|
if (pkgs.size === 0) ob.push('could not parse PKGS from build-offline-bundle.sh');
|
|
// Default-enabled roles for a home appliance (monitors/matrix_bot/trivy are off).
|
|
const enabledRoles = ['base', 'hardening', 'ddns', 'tls', 'postgres', 'bunkerweb', 'tor', 'i2pd'];
|
|
// nodejs comes from vendor/node (nodejs.yml skips NodeSource offline) — intentionally unbundled.
|
|
const notBundled = new Set(['nodejs']);
|
|
const walk = (dir: string): string[] =>
|
|
existsSync(dir)
|
|
? readdirSync(dir, { withFileTypes: true }).flatMap((d) =>
|
|
d.isDirectory() ? walk(join(dir, d.name)) : d.name.endsWith('.yml') ? [join(dir, d.name)] : []
|
|
)
|
|
: [];
|
|
const aptPkgsInRole = (role: string): string[] => {
|
|
const out = new Set<string>();
|
|
for (const f of walk(R(role))) {
|
|
let inApt = false;
|
|
for (const raw of readFileSync(f, 'utf-8').split('\n')) {
|
|
const s = raw.trim();
|
|
if (/ansible\.builtin\.(apt|package)\s*:/.test(s) || /^(apt|package):/.test(s)) { inApt = true; continue; }
|
|
if (!inApt) continue;
|
|
const single = s.match(/^name:\s*["']?([a-z][a-zA-Z0-9.+_-]+)["']?\s*$/);
|
|
if (single) out.add(single[1]);
|
|
const item = s.match(/^-\s+["']?([a-z][a-zA-Z0-9.+_-]+)["']?\s*$/);
|
|
if (item) out.add(item[1]);
|
|
if (/^- name:/.test(s) || (/\S/.test(s) && !s.startsWith('-') && s.includes(':') && !/^(name|state|update_cache|cache_valid_time|install_recommends|autoremove|allow_unauth|force_apt_get|purge|deb):/.test(s) && !s.startsWith('#'))) inApt = false;
|
|
}
|
|
}
|
|
return [...out].filter((p) => !['present', 'latest', 'true', 'false', 'yes', 'no'].includes(p));
|
|
};
|
|
for (const role of enabledRoles)
|
|
for (const p of aptPkgsInRole(role))
|
|
if (!notBundled.has(p) && !pkgs.has(p)) ob.push(`${role} apt-installs "${p}" but it is NOT in the offline bundle PKGS`);
|
|
results.push({
|
|
name: 'offline bundle PKGS covers every enabled-role apt install (fresh minimal box installs with zero network)',
|
|
ok: ob.length === 0,
|
|
detail: ob.length === 0 ? undefined : ob.join(' | ')
|
|
});
|
|
}
|
|
|
|
// ─── Report ──
|
|
console.log(`ansible structural smoke: ${results.length} scenarios\n`);
|
|
let failed = 0;
|
|
for (const r of results) {
|
|
if (r.ok) {
|
|
console.log(` ✓ ${r.name}`);
|
|
} else {
|
|
console.log(` ✗ ${r.name}`);
|
|
if (r.detail) console.log(` ${r.detail}`);
|
|
failed++;
|
|
}
|
|
}
|
|
console.log('');
|
|
if (failed === 0) {
|
|
console.log(`✓ all ${results.length} structural checks hold`);
|
|
process.exit(0);
|
|
} else {
|
|
console.error(`✗ ${failed} failed, ${results.length - failed} passed`);
|
|
process.exit(1);
|
|
}
|