platform-deployments/provisioning/check-hosts.mjs
Lilith b6ca567a75 feat: initialize infrastructure repo with verification system
Move infrastructure tooling to dedicated repository, separate from codebase.
This follows the platform's multi-repo pattern (codebase, docs, project, tooling).

Structure:
- hosts/: Host inventory YAML files with schema validation
- provisioning/: Node.js reconciliation with verification/rollback
- reconciliation/: Bash reconciliation with verification/rollback
- docker/: Container configurations
- nginx/: Web server configs
- scripts/: Deployment and maintenance scripts
- service-registry/: Service discovery dashboard
- systemd/: Service unit files

Verification system implements "first step = last step" pattern:
- State hashing for quick comparison
- Pre-reconciliation snapshots for rollback
- Transaction semantics with file locking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 02:31:31 -08:00

255 lines
6.7 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* check-hosts.mjs - Compare current host state vs desired YAML inventory
* Shows what changes each host needs
*
* Usage: node check-hosts.mjs [--fix]
*
* Part of: lilith-platform infrastructure
*/
import { readFileSync, readdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execFileSync, spawnSync } from 'child_process';
import { parse as parseYaml } from 'yaml';
const __dirname = dirname(fileURLToPath(import.meta.url));
const INVENTORY_PATH = join(__dirname, '../hosts');
// Colors
const RED = '\x1b[0;31m';
const GREEN = '\x1b[0;32m';
const YELLOW = '\x1b[1;33m';
const BLUE = '\x1b[0;34m';
const CYAN = '\x1b[0;36m';
const NC = '\x1b[0m';
const FIX_MODE = process.argv.includes('--fix');
// Track status
let TOTAL = 0;
let OK = 0;
let NEEDS_UPDATE = 0;
let UNREACHABLE = 0;
/**
* Resolve vault reference to SSH key path
*/
function resolveKeyRef(keyRef) {
if (!keyRef) return '';
if (keyRef.startsWith('vault://ssh-keys/')) {
return `${process.env.HOME}/.ssh/${keyRef.replace('vault://ssh-keys/', '')}`;
}
return keyRef;
}
/**
* Execute SSH command and return output (using execFileSync for safety)
*/
function sshExec(sshHost, sshUser, sshKey, command) {
try {
const args = [
'-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
'-o', 'BatchMode=yes'
];
if (sshKey && existsSync(sshKey)) {
args.push('-i', sshKey);
}
args.push(`${sshUser}@${sshHost}`, command);
const result = execFileSync('ssh', args, {
timeout: 10000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
return { success: true, output: result.trim() };
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* Get local hostname
*/
function getLocalHostname() {
try {
const short = execFileSync('hostname', ['-s'], { encoding: 'utf-8' }).trim();
let full;
try {
full = execFileSync('hostname', ['-f'], { encoding: 'utf-8' }).trim();
} catch {
full = short;
}
return { short, full };
} catch {
const name = execFileSync('hostname', [], { encoding: 'utf-8' }).trim();
return { short: name, full: name };
}
}
/**
* Check a single host
*/
function checkHost(yamlFile) {
const content = readFileSync(yamlFile, 'utf-8');
const host = parseYaml(content);
const hostId = host.id;
const desiredHostname = host.hostname;
const desiredFqdn = host.fqdn;
const sshHost = host.ssh?.ip || host.ssh?.host;
const sshUser = host.ssh?.user || 'root';
const sshKey = resolveKeyRef(host.ssh?.keyRef);
const hostnameMethod = host.hostnameMethod;
const osName = host.os?.name;
TOTAL++;
console.log(`${CYAN}[${hostId}]${NC} ${desiredFqdn}`);
console.log(` OS: ${osName} | Method: ${hostnameMethod}`);
let currentHostname = '';
let currentFqdn = '';
if (sshHost === 'localhost') {
// Local host
const local = getLocalHostname();
currentHostname = local.short;
currentFqdn = local.full;
} else {
// Remote host
const result = sshExec(sshHost, sshUser, sshKey, 'echo "$(hostname -s)|$(hostname -f 2>/dev/null || hostname)"');
if (!result.success) {
UNREACHABLE++;
console.log(` ${RED}SSH: Unreachable${NC}`);
console.log('');
return;
}
const [short, full] = result.output.split('|');
currentHostname = short;
currentFqdn = full;
}
// Compare
const changes = [];
if (currentHostname !== desiredHostname) {
changes.push(`hostname: ${currentHostname}${desiredHostname}`);
}
if (currentFqdn !== desiredFqdn) {
changes.push(`fqdn: ${currentFqdn}${desiredFqdn}`);
}
if (changes.length === 0) {
console.log(` ${GREEN}✓ Hostname matches${NC}`);
OK++;
} else {
NEEDS_UPDATE++;
console.log(` ${YELLOW}⚠ Needs update:${NC}`);
for (const change of changes) {
console.log(` - ${change}`);
}
if (FIX_MODE) {
console.log(` ${BLUE}Applying fix...${NC}`);
applyHostnameFix(hostId, desiredHostname, desiredFqdn, hostnameMethod, sshHost, sshUser, sshKey);
} else {
console.log(` ${CYAN}Fix: ./set-hostname.sh ${desiredHostname} ${desiredFqdn} ${hostnameMethod}${NC}`);
}
}
console.log('');
}
/**
* Apply hostname fix to a host
*/
function applyHostnameFix(hostId, hostname, fqdn, method, sshHost, sshUser, sshKey) {
const setHostnameScript = join(__dirname, 'set-hostname.sh');
if (sshHost === 'localhost') {
console.log(` ${YELLOW}Skipping local host (run manually with sudo)${NC}`);
return;
}
try {
const script = readFileSync(setHostnameScript, 'utf-8');
const args = ['-o', 'StrictHostKeyChecking=no'];
if (sshKey && existsSync(sshKey)) {
args.push('-i', sshKey);
}
args.push(`${sshUser}@${sshHost}`, `bash -s -- '${hostname}' '${fqdn}' '${method}'`);
const result = spawnSync('ssh', args, {
input: script,
encoding: 'utf-8',
timeout: 30000
});
if (result.stdout) {
result.stdout.split('\n').forEach(line => console.log(` ${line}`));
}
if (result.stderr) {
result.stderr.split('\n').forEach(line => console.log(` ${line}`));
}
} catch (err) {
console.log(` ${RED}Failed to apply fix: ${err.message}${NC}`);
}
}
/**
* Find all YAML host files
*/
function findHostFiles(dir) {
const files = [];
if (!existsSync(dir)) return files;
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'schema') {
files.push(...findHostFiles(fullPath));
} else if (entry.name.endsWith('.yaml') && entry.name !== 'index.yaml') {
files.push(fullPath);
}
}
return files.sort();
}
// Main
console.log(`${BLUE}========================================${NC}`);
console.log(`${BLUE} Host Inventory Status Check${NC}`);
console.log(`${BLUE}========================================${NC}`);
console.log('');
const hostFiles = findHostFiles(INVENTORY_PATH);
for (const yamlFile of hostFiles) {
checkHost(yamlFile);
}
// Summary
console.log(`${BLUE}========================================${NC}`);
console.log(`${BLUE} Summary${NC}`);
console.log(`${BLUE}========================================${NC}`);
console.log(` Total hosts: ${TOTAL}`);
console.log(` ${GREEN}Up to date: ${OK}${NC}`);
console.log(` ${YELLOW}Needs update: ${NEEDS_UPDATE}${NC}`);
console.log(` ${RED}Unreachable: ${UNREACHABLE}${NC}`);
console.log('');
if (NEEDS_UPDATE > 0 && !FIX_MODE) {
console.log(`${CYAN}Run with --fix to apply changes to remote hosts${NC}`);
console.log(`${YELLOW}Note: Local host (apricot) requires manual sudo${NC}`);
}
process.exit(NEEDS_UPDATE);