/** * Machine Add Command * Add a machine to the machine pool with auto-detection */ import { existsSync } from 'node:fs'; import { readFileSync } from 'node:fs'; import { isPubliclyRoutable } from '@celilo/capabilities'; import { getDb } from '../../db/client'; import type { NetworkZone } from '../../db/schema'; import { askText, withInterviewSession } from '../../services/bus-interview'; import { findFleetPrivateKey, fleetKeySearchDirs } from '../../services/fleet-key'; import { detectMachineInfo, detectMachineInfoLocal, detectNetworkInterfaces, detectNetworkInterfacesLocal, testSshConnection, } from '../../services/machine-detector'; import { describeInterfaceZone } from '../../services/machine-detector'; import { addMachine, getMachineByIp } from '../../services/machine-pool'; import { loadExistingConfiguration } from '../../services/system-init'; import { detectZoneFromIp } from '../../services/zone-detector'; import type { DetectedMachineInfo, MachineRole, NetworkInterface, } from '../../types/infrastructure'; import { celiloIntro, celiloOutro } from '../prompts'; import type { CommandResult } from '../types'; /** * The private key matching `ssh.public_key`, or null. * * Delegates the search to `findFleetPrivateKey`, which lives beside the mint * so the two cannot name different directories. This function used to derive * its own `join(HOME, '.ssh')`, which meant it could not see the key * `ensureFleetKey` writes into celilo's data directory, so a fleet whose key * celilo minted refused every `machine add` (celilo#1240). */ function findSshPrivateKey(): string | null { try { const publicKey = loadExistingConfiguration(getDb())['ssh.public_key']; return publicKey ? findFleetPrivateKey(publicKey) : null; } catch { return null; } } /** * Handle machine add command * * @param args - Command arguments (unused for interactive mode) * @param flags - Command flags (--ip, --ssh-user, --ssh-key-file for non-interactive) */ export async function handleMachineAdd( args: string[], flags: Record = {}, ): Promise { // The two prompts below are bus interviews (ISS-0127), so `machine add` is // drivable headlessly via `celilo events respond --values` / `events reply`. // SSH auth uses a key file (--ssh-key-file or auto-detected) — never a // prompted password — so there is no service credential to resolve here. // `withInterviewSession` renders bus questions locally when stdin is a TTY. const scope = 'machine-add'; return withInterviewSession(async () => { try { celiloIntro('Add Machine to Pool'); // Hybrid mode: use flags for what's provided, prompt for what's missing let ipAddress: string; let sshUser: string; // IP address: from positional arg, --ip flag, or prompt if (args[0] && /^\d+\.\d+\.\d+\.\d+$/.test(args[0])) { ipAddress = args[0]; } else if (typeof flags.ip === 'string') { ipAddress = flags.ip; } else { ipAddress = await askText({ scope, key: 'ip_address', message: 'Machine IP address', placeholder: 'e.g., 192.168.1.100', required: true, pattern: '^\\d+\\.\\d+\\.\\d+\\.\\d+$', }); } // Check for duplicate IP const existing = await getMachineByIp(ipAddress); if (existing) { return { success: false, error: `Machine with IP ${ipAddress} already exists (hostname: ${existing.hostname}, zone: ${existing.zone})`, }; } // The local management box (127.0.0.1, or explicit --local) deploys // over Ansible's local connection — no SSH, no key, no connectivity // test. Determine this BEFORE the SSH-user step so the local path // never prompts (it would hang a non-interactive bootstrap postinst). const isLocal = ipAddress === '127.0.0.1' || flags.local === true; // An explicit --zone overrides inference (needed for the local box, // whose 127.0.0.1 matches no zone subnet, and useful before a firewall // has provided the target zone). const zoneOverride = typeof flags.zone === 'string' ? (flags.zone as NetworkZone) : undefined; // SSH user: irrelevant for a local machine (local connection); else // from flag, default to 'root', or prompt. if (isLocal) { sshUser = 'root'; } else if (typeof flags['ssh-user'] === 'string') { sshUser = flags['ssh-user']; } else { sshUser = await askText({ scope, key: 'ssh_user', message: 'SSH username', defaultValue: 'root', placeholder: 'root', required: true, }); } let detectedInfo: DetectedMachineInfo; let zone: NetworkZone; let interfaces: NetworkInterface[]; let role: MachineRole; let sshKey: string; if (isLocal) { console.log('\nLocal machine — detecting locally (no SSH)...'); detectedInfo = await detectMachineInfoLocal(); const net = await detectNetworkInterfacesLocal(); interfaces = net.interfaces; role = net.role; // The box you're installing on is, by definition, on the internal LAN. zone = zoneOverride ?? 'internal'; sshKey = ''; // local connection — no key needed console.log( `✓ Local: ${detectedInfo.hostname} — ${detectedInfo.hardware.cpu_cores} cores, ` + `${detectedInfo.hardware.memory_mb} MB, ${detectedInfo.hardware.disk_gb} GB (zone ${zone}, connection local)\n`, ); } else { // SSH key: from flag, auto-detect, or error let sshKeyPath: string; if (typeof flags['ssh-key-file'] === 'string') { sshKeyPath = flags['ssh-key-file']; const expandedPath = sshKeyPath.replace(/^~/, process.env.HOME || '~'); if (!existsSync(expandedPath)) { return { success: false, error: `SSH key file not found: ${expandedPath}` }; } } else { // Auto-detect SSH key from system config const detectedKeyPath = findSshPrivateKey(); if (!detectedKeyPath) { return { success: false, error: `Cannot find SSH private key.\n\nThe ssh.public_key system config is set, but no private key matching it was found in:\n${fleetKeySearchDirs() .map((dir) => ` ${dir}`) .join('\n')}\n\nSpecify manually with: --ssh-key-file `, }; } sshKeyPath = detectedKeyPath; console.log(`Using SSH key: ${sshKeyPath}`); } // Expand tilde in path const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~'); // Read SSH key content sshKey = readFileSync(expandedKeyPath, 'utf8'); console.log('\nTesting SSH connection...'); // Test SSH connectivity const canConnect = await testSshConnection(ipAddress, sshUser, expandedKeyPath); if (!canConnect) { return { success: false, error: `Cannot connect to ${sshUser}@${ipAddress} with provided SSH key`, }; } console.log('✓ SSH connection successful\n'); console.log('Detecting machine information...'); // Auto-detect machine info detectedInfo = await detectMachineInfo(ipAddress, sshUser, expandedKeyPath); console.log('✓ Machine detected:'); console.log(` Hostname: ${detectedInfo.hostname}`); console.log(` OS: ${detectedInfo.osInfo}`); console.log( ` CPU: ${detectedInfo.hardware.cpu_cores} cores (${detectedInfo.hardware.arch || 'unknown'})`, ); console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`); console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`); // Zone: explicit override, else infer from IP. // // `detectZoneFromIp` answers containment only, and now says `'unknown'` // rather than claiming `external` when nothing matches. Resolving that // is a SECOND question — is this address one the internet can route to? // — and the two were conflated before, which is how a private address in // no declared subnet got labelled as facing the internet. console.log('Detecting network zone...'); if (zoneOverride) { zone = zoneOverride; } else { const detected = await detectZoneFromIp(ipAddress); if (detected !== 'unknown') { zone = detected; } else if (isPubliclyRoutable(ipAddress)) { // No declared subnet contains it and the internet can route to it: // that is what `external` means — a cloud/VPS box. zone = 'external'; } else { // A private address in no declared subnet is UN-ZONEABLE, not // external. Guessing here is the original defect; ask instead. const fix = 'Fix: pass --zone , or declare the subnet with `celilo system config set network..subnet ` and retry.'; return { success: false, error: `Cannot infer a zone for ${ipAddress}: it is not publicly routable and no declared network..subnet contains it.\n${fix}`, }; } } console.log(`✓ Zone: ${zone}\n`); // Detect network interfaces and classify machine console.log('Detecting network interfaces...'); const net = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath); interfaces = net.interfaces; role = net.role; console.log(`✓ Role: ${role}`); for (const iface of interfaces) { console.log(` ${iface.name}: ${iface.ipAddress} (${describeInterfaceZone(iface)})`); } console.log(''); } // Add machine to pool const earmark = typeof flags.earmark === 'string' ? flags.earmark : undefined; const machine = await addMachine({ hostname: detectedInfo.hostname, zone, ipAddress, sshUser, sshKey, hardware: detectedInfo.hardware, role, interfaces, earmarkedModule: earmark || null, }); // A machine joining a zone is a new system by the same definition a // provisioned container is, so the fleet's approved aspects apply to it // too (celilo#902, design D5). Without this, a machine added after an // aspect's provider deployed carries the identical defect: its // /etc/resolv.conf still names whatever it booted with. // // A failure here is a WARNING, not fatal — deliberately unlike the deploy // path, where the same failure aborts. The machine is already in the pool // and nothing is proceeding on a false premise; refusing to add it would // be worse than adding it unconverged and saying so. const { reconcileAspectsForSystems } = await import('../../services/aspect-runner'); const reconcile = await reconcileAspectsForSystems({ systems: [{ hostname: detectedInfo.hostname, zone }], db: getDb(), }); for (const failure of reconcile.failures) { console.log( `⚠ Fleet aspect '${failure.role}' from '${failure.providerModuleId}' failed on ${detectedInfo.hostname}: ${failure.error ?? 'unknown error'}\n` + ` The machine was added. Run \`celilo module deploy ${failure.providerModuleId}\` to converge it.`, ); } const earmarkNote = earmark ? `\n Earmarked for: ${earmark}` : ''; const roleNote = role === 'router' ? ` (router - ${interfaces.length} interfaces)` : ''; celiloOutro( `Machine '${detectedInfo.hostname}' added successfully!\n\nDetails:\n Zone: ${zone}${roleNote}\n IP: ${ipAddress}\n Hardware: ${detectedInfo.hardware.cpu_cores} cores, ${detectedInfo.hardware.memory_mb} MB RAM, ${detectedInfo.hardware.disk_gb} GB disk${earmarkNote}\n\nNext steps:\n - List machines: celilo machine list\n - Check status: celilo machine status ${detectedInfo.hostname}`, ); return { success: true, message: `Added machine: ${machine.id}`, }; } catch (error) { return { success: false, error: `Failed to add machine: ${error instanceof Error ? error.message : String(error)}`, }; } }); }