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>
255 lines
6.7 KiB
JavaScript
Executable file
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);
|