@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
1,067 lines (1,007 loc) • 56.9 kB
JavaScript
/**
* LXD module for managing LXD virtual machines as K3s nodes.
* @module src/cli/lxd.js
* @namespace UnderpostLxd
*
*/
import { getNpmRootPath } from '../server/conf.js';
import { pbcopy, shellExec } from '../server/process.js';
import walk from 'ignore-walk';
import fs from 'fs-extra';
import { loggerFactory } from '../server/logger.js';
import Underpost from '../index.js';
const logger = loggerFactory(import.meta);
const ENGINE_ROOT_IN_VM = '/home/dd/engine';
const ENGINE_ROOT_ON_HOST = '/home/dd/engine';
const ADMIN_PROFILE = 'admin-profile';
const BRIDGE_NETWORK = 'lxdbr0';
const BRIDGE_SUBNET_PREFIX = '10.250.250';
class UnderpostLxd {
static _project = '';
static _lxcCmd() {
return UnderpostLxd._project ? `lxc --project ${UnderpostLxd._project}` : 'lxc';
}
static API = {
/**
* @method callback
* @description Main entry point for all LXD CLI operations.
* @param {string} [vmId=''] - Positional VM identifier for boolean
* vm lifecycle flags.
* @param {object} options
* @param {boolean} [options.init=false] - Initialize LXD via preseed.
* @param {boolean} [options.reset=false] - Host-safe teardown of VMs, proxy
* devices, admin-profile, and lxdbr0. Does NOT touch the LXD snap or
* storage pools.
* @param {boolean} [options.purge=false] - Gracefully shut the LXD daemon
* down (60s timeout) and remove the LXD snap. Combine with `--reset` to
* wipe per-VM state first. Without `--reset`, snap removal alone wipes
* everything.
* @param {boolean} [options.shutdown=false] - Pre-host-reboot procedure:
* gracefully stop every VM and the LXD daemon. Run before `reboot` /
* `poweroff` to keep the host bootable.
* @param {boolean} [options.restore=false] - Symmetric to `--shutdown`:
* starts the LXD daemon (`snap start lxd`), waits for it to become
* responsive, then starts every VM that exists. VMs created with
* `admin-profile` have `boot.autostart=false`, so this is the explicit
* "bring the lab back online" command.
* @param {boolean} [options.dev=false] - Use local paths instead of npm global.
* @param {boolean} [options.install=false] - Install LXD snap.
* @param {boolean} [options.createVirtualNetwork=false] - Create lxdbr0 as a LXD-managed
* bridge with NAT, but with DHCP/DNS off and dnsmasq neutralized (raw.dnsmasq=port=0) so
* it coexists with MAAS. The managed subnet enables static NIC IPs for `--expose` proxies.
* @param {string} [options.ipv4Address='10.250.250.1/24'] - Managed gateway address/CIDR
* for lxdbr0 (LXD assigns this to the bridge and masquerades VM egress).
* @param {boolean} [options.createAdminProfile=false] - Create admin-profile for VMs.
* @param {boolean} [options.control=false] - Initialize VM as K3s control plane.
* @param {boolean} [options.worker=false] - Initialize VM as K3s worker.
* @param {boolean} [options.vmInit=false] - Bring the VM identified by
* `vmId` up as a K3s node end-to-end.
* @param {boolean} [options.vmDelete=false] - Safely stop and delete the
* VM identified by `vmId`.
* @param {boolean} [options.vmCreate=false] - Surface the launch command
* for the VM identified by `vmId`.
* @param {boolean} [options.vmInfo=false] - Inspect the VM identified by
* `vmId`.
* @param {string} [options.rootSize=''] - Root disk size in GiB for new VMs.
* @param {string} [options.joinNode=''] - Join format: 'workerName,controlName'.
* @param {string} [options.expose=''] - Expose VM ports to host: 'vmName:port1,port2'.
* @param {string} [options.nodePort=''] - Custom VM-side (connect) port for `--expose`. When set, the
* host listens on each requested port but the proxy connects to this port inside the VM (e.g. expose
* host 27017 → VM NodePort 32017). Defaults to the same port on both sides.
* @param {string} [options.deleteExpose=''] - Remove exposed ports: 'vmName:port1,port2'.
* @param {boolean} [options.vmTest=false] - Run connectivity and health
* checks on the VM identified by `vmId`.
* @param {boolean} [options.vmSyncEngine=false] - Re-copy the host engine
* source into the VM identified by `vmId`, overriding whatever is
* currently there. Equivalent to step 2 of `--vm-init` in isolation.
* @param {boolean} [options.copy=false] - For two-phase flows that surface a
* command for the user to execute (e.g. `--create-admin-profile` phase 1):
* when set, copy the command to the clipboard. When unset, print it to
* the terminal so the user can read it directly.
* @param {string} [options.maasProject=''] - LXD project managed by MAAS
* (e.g. 'k3s-cluster'). When set, all lxc commands target this project so
* MAAS can enumerate the VMs in its machines UI.
* @param {boolean} [options.moveToProject=false] - Stop the VM identified
* by `vmId`, move it from the default project to `maasProject`, then start
* it so MAAS picks it up. Requires `--maas-project`.
* @memberof UnderpostLxd
*/
async callback(
vmId = '',
options = {
init: false,
reset: false,
purge: false,
shutdown: false,
restore: false,
dev: false,
install: false,
createVirtualNetwork: false,
ipv4Address: '10.250.250.1/24',
createAdminProfile: false,
control: false,
worker: false,
vmInit: false,
vmDelete: false,
vmCreate: false,
vmInfo: false,
rootSize: '',
joinNode: '',
expose: '',
nodePort: '',
deleteExpose: '',
vmTest: false,
vmSyncEngine: false,
copy: false,
maasProject: '',
moveToProject: false,
},
) {
const npmRoot = getNpmRootPath();
const underpostRoot = options?.dev === true ? '.' : `${npmRoot}/underpost`;
const currentVmId = vmId ? String(vmId).trim() : '';
const vmCreate = options.vmCreate === true;
const vmDelete = options.vmDelete === true;
const vmInfo = options.vmInfo === true;
const vmInit = options.vmInit === true;
const vmTest = options.vmTest === true;
const vmSyncEngine = options.vmSyncEngine === true;
UnderpostLxd._project = options.maasProject ? String(options.maasProject).trim() : '';
// =====================================================================
// SHUTDOWN: graceful pre-host-reboot procedure
// =====================================================================
if (options.shutdown === true) {
UnderpostLxd._gracefulShutdownAll();
return;
}
// =====================================================================
// RESTORE: symmetric counterpart to --shutdown
// =====================================================================
if (options.restore === true) {
UnderpostLxd._restoreAll();
return;
}
// =====================================================================
// RESET / PURGE: host-safe teardown variants
// --reset wipes VMs, proxy devices, admin-profile, lxdbr0
// --purge gracefully stops the daemon, then snap remove --purge
// --reset --purge both, in order
// =====================================================================
if (options.reset === true) {
UnderpostLxd._safeReset();
}
if (options.purge === true) {
UnderpostLxd._safePurge();
}
if (options.reset === true || options.purge === true) return;
// =====================================================================
// INSTALL (idempotent: skip if already installed)
// =====================================================================
if (options.install === true) {
if (UnderpostLxd._snapInstalled('lxd')) {
logger.info('LXD snap is already installed; skipping.');
} else {
shellExec(`sudo snap install lxd`);
}
}
// =====================================================================
// INIT (LXD preseed)
// =====================================================================
if (options.init === true) {
shellExec(`sudo systemctl start snap.lxd.daemon`);
shellExec(`sudo systemctl status snap.lxd.daemon`);
if (UnderpostLxd._lxdInitialized()) {
logger.info('LXD is already initialized (storage pool present); skipping preseed.');
} else {
const lxdPreseedContent = fs
.readFileSync(`${underpostRoot}/manifests/lxd/lxd-preseed.yaml`, 'utf8')
.replaceAll(`127.0.0.1`, Underpost.dns.getLocalIPv4Address());
shellExec(`echo "${lxdPreseedContent}" | lxd init --preseed`);
}
shellExec(`${UnderpostLxd._lxcCmd()} cluster list`);
}
// =====================================================================
// CREATE VIRTUAL NETWORK
//
// LXD-managed bridge so its native features work — the host is the
// gateway (10.250.250.1), LXD masquerades VM egress (`ipv4.nat`), and the
// managed subnet lets instance NICs carry a static `ipv4.address`, which
// NAT-mode proxy devices (`--expose`) require.
//
// MAAS harmony: LXD spawns a dnsmasq for any managed subnet, and on this
// host that dnsmasq cannot bind :53/:67 (MAAS's named/dhcpd already own
// them), so a default managed bridge dies with "Address already in use".
// We neutralize dnsmasq instead of dropping the subnet:
// - ipv4.dhcp=false → no DHCP, no :67 bind (MAAS owns DHCP).
// - dns.mode=none + raw.dnsmasq=port=0 → no DNS, no :53 bind. dns.mode
// alone does not force port=0 in dnsmasq, hence the explicit raw line.
// dnsmasq then starts but opens no listening sockets — no collision.
//
// Settings are applied inline at create time so dnsmasq is neutralized on
// its first spawn (not after a racing default-config start). Idempotent:
// reconcile when the network already exists.
// =====================================================================
if (options.createVirtualNetwork === true) {
const gatewayCidr = options.ipv4Address ? options.ipv4Address : '10.250.250.1/24';
// Order matters for the reconcile path: neutralize dnsmasq (no DHCP, no
// DNS listener) BEFORE assigning ipv4.address, or setting the subnet on
// an existing bridge spawns a default dnsmasq that collides with MAAS
// before raw.dnsmasq lands. On a fresh inline create this is atomic.
const bridgeSettings = {
'ipv4.dhcp': 'false',
'dns.mode': 'none',
'raw.dnsmasq': 'port=0',
'ipv6.address': 'none',
'ipv4.address': gatewayCidr,
'ipv4.nat': 'true',
'ipv4.firewall': 'true',
};
if (UnderpostLxd._networkExists(BRIDGE_NETWORK)) {
logger.info(`Network '${BRIDGE_NETWORK}' already exists; reconciling managed bridge settings.`);
for (const [key, value] of Object.entries(bridgeSettings)) {
shellExec(`${UnderpostLxd._lxcCmd()} network set ${BRIDGE_NETWORK} ${key} "${value}"`);
}
} else {
const inlineConfig = Object.entries(bridgeSettings)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
shellExec(`${UnderpostLxd._lxcCmd()} network create ${BRIDGE_NETWORK} ${inlineConfig}`);
}
UnderpostLxd._ensureBridgeInTrustedZone(BRIDGE_NETWORK);
UnderpostLxd._ensureBridgeForwardingAccept(BRIDGE_NETWORK);
}
// =====================================================================
// CREATE ADMIN PROFILE (two-phase to sidestep `lxc profile create` hangs)
//
// Phase 1 (profile absent): copy `lxc profile create admin-profile` to
// the clipboard and exit. The user runs it themselves in their shell.
// Phase 2 (profile present): load the YAML into the existing profile.
//
// Driven by an explicit pre-condition check; no shell command runs that
// could hang waiting on stdin/tty.
// =====================================================================
if (options.createAdminProfile === true) {
if (!UnderpostLxd._profileExists(ADMIN_PROFILE)) {
const createCmd = `lxc profile create ${ADMIN_PROFILE}`;
if (options.copy === true) {
logger.warn(
`Profile '${ADMIN_PROFILE}' does not exist. The create command has been copied to your clipboard — run it, then re-run this command to load the YAML.`,
);
pbcopy(createCmd);
} else {
logger.warn(
`Profile '${ADMIN_PROFILE}' does not exist. Run the command below in your shell, then re-run this command to load the YAML. (Pass --copy to put it on the clipboard instead.)`,
);
console.log(`\n ${createCmd}\n`);
}
} else {
shellExec(
`cat ${underpostRoot}/manifests/lxd/lxd-admin-profile.yaml | ${UnderpostLxd._lxcCmd()} profile edit ${ADMIN_PROFILE}`,
);
shellExec(`${UnderpostLxd._lxcCmd()} profile show ${ADMIN_PROFILE}`);
}
}
// =====================================================================
// DELETE VM (idempotent via pre-condition checks; no silent errors)
// =====================================================================
if (vmDelete) {
if (!currentVmId) {
throw new Error(`--vm-delete requires the [vm-id] command argument.`);
}
UnderpostLxd._safeDeleteVm(currentVmId);
}
// =====================================================================
// MOVE VM TO PROJECT (stop + cross-project move + start for MAAS)
// =====================================================================
if (options.moveToProject === true) {
if (!currentVmId) {
throw new Error(`--move-to-project requires the [vm-id] command argument.`);
}
if (!UnderpostLxd._project) {
throw new Error(`--move-to-project requires --maas-project <projectName>.`);
}
const rawState = shellExec(`lxc list ${currentVmId} --format json`, { stdout: true }).trim();
const arr = JSON.parse(rawState || '[]');
const inst = Array.isArray(arr) ? arr.find((i) => i?.name === currentVmId) : null;
if (!inst) throw new Error(`VM '${currentVmId}' not found in the default project.`);
// Ensure every profile the VM references exists in the target project.
// `lxc move` across projects fails with "Profile not found" if the
// target project does not have the same profiles as the source.
//
// Two-phase pattern (mirrors --create-admin-profile): `lxc profile create`
// can hang waiting on stdin/tty, so we NEVER run it programmatically.
// Phase 1 (profile absent in target): surface the create command and exit.
// Phase 2 (profile present in target): sync the YAML from the default project.
const vmProfiles = Array.isArray(inst.profiles) ? inst.profiles : [];
const targetProfilesRaw = shellExec(`${UnderpostLxd._lxcCmd()} profile list --format json`, {
stdout: true,
}).trim();
const targetProfiles = JSON.parse(targetProfilesRaw || '[]');
const targetProfileNames = Array.isArray(targetProfiles) ? targetProfiles.map((p) => p?.name) : [];
for (const profileName of vmProfiles) {
if (profileName === 'default') continue; // every project already has 'default'
if (!targetProfileNames.includes(profileName)) {
const createCmd = `lxc --project ${UnderpostLxd._project} profile create ${profileName}`;
if (options.copy === true) {
logger.warn(
`Profile '${profileName}' not found in project '${UnderpostLxd._project}'. The create command has been copied to your clipboard — run it, then re-run --move-to-project.`,
);
pbcopy(createCmd);
} else {
logger.warn(
`Profile '${profileName}' not found in project '${UnderpostLxd._project}'. Run the command below in your shell, then re-run --move-to-project. (Pass --copy to put it on the clipboard instead.)`,
);
console.log(`\n ${createCmd}\n`);
}
return;
}
// Phase 2: profile exists in target — sync YAML from default project.
// Explicitly use --project default on the source side so the read is
// unambiguous regardless of any active project context.
logger.info(`Syncing profile '${profileName}' YAML into project '${UnderpostLxd._project}'...`);
shellExec(
`lxc --project default profile show ${profileName} | ${UnderpostLxd._lxcCmd()} profile edit ${profileName}`,
);
logger.info(` Profile '${profileName}' synced.`);
}
if (inst.status === 'Running' || inst.status === 'Frozen') {
logger.info(`Stopping VM '${currentVmId}' before cross-project move...`);
shellExec(`lxc stop ${currentVmId} --timeout 60`);
}
logger.info(`Moving VM '${currentVmId}' to project '${UnderpostLxd._project}'...`);
shellExec(`lxc move ${currentVmId} ${currentVmId} --target-project ${UnderpostLxd._project}`);
logger.info(`VM '${currentVmId}' is now in project '${UnderpostLxd._project}'. Starting...`);
shellExec(`${UnderpostLxd._lxcCmd()} start ${currentVmId}`);
logger.info(`VM '${currentVmId}' started in project '${UnderpostLxd._project}'.`);
return;
}
// =====================================================================
// CREATE VM (surface the launch command for the user to run)
//
// Default: print to terminal. With `--copy`: copy to clipboard.
// Same two-phase pattern as `--create-admin-profile`: the CLI never runs
// `lxc launch` itself (it can hang on first image fetch or AppArmor
// negotiation in some snap setups), so the user always invokes it.
// =====================================================================
if (vmCreate) {
if (!currentVmId) {
throw new Error(`--vm-create requires the [vm-id] command argument.`);
}
const vmName = currentVmId;
const launchCmd = `${UnderpostLxd._lxcCmd()} launch images:rockylinux/9 ${
vmName
} --vm --target lxd-node1 -c limits.cpu=2 -c limits.memory=4GB --profile ${ADMIN_PROFILE} -d root,size=${
options.rootSize ? options.rootSize + 'GiB' : '32GiB'
}`;
if (options.copy === true) {
logger.info(`Launch command for VM '${vmName}' copied to clipboard. Run it in your shell.`);
pbcopy(launchCmd);
} else {
logger.info(
`Run the launch command below in your shell to create VM '${vmName}'. (Pass --copy to put it on the clipboard instead.)`,
);
console.log(`\n ${launchCmd}\n`);
}
}
// =====================================================================
// INIT VM (OS setup + engine bootstrap + K3s role)
// =====================================================================
if (vmInit) {
if (!currentVmId) {
throw new Error(`--vm-init requires the [vm-id] command argument.`);
}
const vmName = currentVmId;
if (!UnderpostLxd._vmExists(vmName)) {
throw new Error(`VM '${vmName}' does not exist. Create it first with 'underpost lxd ${vmName} --vm-create'.`);
}
const lxdSetupPath = `${underpostRoot}/scripts/lxd-vm-setup.sh`;
const k3sSetupPath = `${underpostRoot}/scripts/k3s-node-setup.sh`;
const fallbackIp = UnderpostLxd._allocateFallbackIp(vmName);
logger.info(`[${vmName}] Step 1/3: OS base setup (DHCP fallback IP: ${fallbackIp}/24)...`);
shellExec(
`cat ${lxdSetupPath} | ${UnderpostLxd._lxcCmd()} exec ${vmName} --env LXD_FALLBACK_IPV4_CIDR=${fallbackIp}/24 --env LXD_NODE_NAME=${vmName} -- bash`,
);
logger.info(`[${vmName}] Step 2/3: Bootstrapping engine source into VM...`);
await UnderpostLxd._bootstrapEngineSource(vmName);
// Step 3: K3s role setup, driven by the local engine source.
logger.info(`[${vmName}] Step 3/3: K3s role setup...`);
const baseArgs = `--engine-root=${ENGINE_ROOT_IN_VM}`;
if (options.worker === true) {
if (!options.joinNode) {
throw new Error(
`--vm-init --worker requires --join-node <controlVmName>. A worker is meaningless without a control plane to join; the script would only fail after npm install completes.`,
);
}
const controlNode = options.joinNode.includes(',') ? options.joinNode.split(',').pop() : options.joinNode;
const { ip: controlPlaneIp, token: k3sToken } = UnderpostLxd._readControlPlaneJoinInfo(controlNode);
logger.info(`[${vmName}] Joining control plane ${controlNode} (${controlPlaneIp})`);
shellExec(
`cat ${k3sSetupPath} | ${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -s -- ${baseArgs} --worker --control-ip=${controlPlaneIp} --token=${k3sToken}`,
);
UnderpostLxd._labelWorkerNodeRole(controlNode, vmName);
} else {
shellExec(
`cat ${k3sSetupPath} | ${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -s -- ${baseArgs} --control`,
);
}
logger.info(`[${vmName}] Init complete. Engine mirrored at ${ENGINE_ROOT_IN_VM}.`);
}
// =====================================================================
// STANDALONE JOIN
// =====================================================================
if (options.joinNode && !vmInit) {
const [workerNode, controlNode] = options.joinNode.split(',');
if (!workerNode || !controlNode) {
throw new Error(`--join-node standalone requires 'workerName,controlName' format.`);
}
if (!UnderpostLxd._vmExists(workerNode)) {
throw new Error(`Worker VM '${workerNode}' does not exist.`);
}
const { ip: controlPlaneIp, token: k3sToken } = UnderpostLxd._readControlPlaneJoinInfo(controlNode);
const k3sSetupPath = `${underpostRoot}/scripts/k3s-node-setup.sh`;
logger.info(`Joining K3s worker ${workerNode} to control plane ${controlNode} (${controlPlaneIp})`);
shellExec(
`cat ${k3sSetupPath} | ${UnderpostLxd._lxcCmd()} exec ${workerNode} -- bash -s -- --engine-root=${ENGINE_ROOT_IN_VM} --worker --control-ip=${controlPlaneIp} --token=${k3sToken}`,
);
UnderpostLxd._labelWorkerNodeRole(controlNode, workerNode);
logger.info(`Worker ${workerNode} joined successfully.`);
}
// =====================================================================
// INFO VM
// =====================================================================
if (vmInfo) {
if (!currentVmId) {
throw new Error(`--vm-info requires the [vm-id] command argument.`);
}
const vmName = currentVmId;
shellExec(`${UnderpostLxd._lxcCmd()} config show ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} info --show-log ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} info ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} list ${vmName}`);
}
// =====================================================================
// EXPOSE (host LAN port -> VM NodePort via LXD NAT-mode proxy device)
//
// NAT-mode proxy on a VM requires the host to be the gateway (it is) and a
// static ipv4.address on the instance NIC. _ensureNicStaticIpv4 pins that
// (with security.ipv4_filtering so it's accepted on the DHCP-less bridge),
// then the proxy device forwards listen=host -> connect=VM, preserving the
// client address via NAT. When exposing known service ports (MongoDB 27017,
// Valkey 6379), also persist the host-side runtime env so `node src/server`
// on the physical host dials the LXD proxy instead of localhost defaults.
// =====================================================================
if (options.expose) {
const [vmName, ports] = options.expose.split(':');
const protocols = ['tcp'];
const hostIp = Underpost.dns.getLocalIPv4Address();
const exposedHostPorts = ports
.split(',')
.map((port) => port.trim())
.filter((port) => port.length > 0);
const vmIp = UnderpostLxd._vmIpv4(vmName);
if (!vmIp) {
throw new Error(`Could not resolve VM IP for ${vmName}. Cannot expose ports.`);
}
UnderpostLxd._ensureNicStaticIpv4(vmName, vmIp);
for (const port of exposedHostPorts) {
const connectPort = options.nodePort ? options.nodePort : port;
for (const protocol of protocols) {
const deviceName = `${vmName}-${protocol}-port-${port}`;
if (UnderpostLxd._vmHasDevice(vmName, deviceName)) {
shellExec(`${UnderpostLxd._lxcCmd()} config device remove ${vmName} ${deviceName}`);
}
shellExec(
`${UnderpostLxd._lxcCmd()} config device add ${vmName} ${deviceName} proxy listen=${protocol}:${hostIp}:${port} connect=${protocol}:${vmIp}:${connectPort} nat=true`,
);
logger.info(`Exposed ${protocol}:${hostIp}:${port} -> ${vmIp}:${connectPort} on ${vmName}`);
}
}
if (exposedHostPorts.includes('27017') || exposedHostPorts.includes('6379')) {
Underpost.cluster.syncServiceConnectionEnv({
serviceHost: hostIp,
mongodb: exposedHostPorts.includes('27017'),
valkey: exposedHostPorts.includes('6379'),
options,
});
}
}
// =====================================================================
// DELETE EXPOSE
// =====================================================================
if (options.deleteExpose) {
const [vmName, ports] = options.deleteExpose.split(':');
const protocols = ['tcp'];
for (const port of ports.split(',')) {
for (const protocol of protocols) {
const deviceName = `${vmName}-${protocol}-port-${port}`;
if (UnderpostLxd._vmHasDevice(vmName, deviceName)) {
shellExec(`${UnderpostLxd._lxcCmd()} config device remove ${vmName} ${deviceName}`);
} else {
logger.info(`Device ${deviceName} not present on ${vmName}; skipping.`);
}
}
}
}
// =====================================================================
// SYNC ENGINE (re-copy host engine source into VM)
// =====================================================================
if (vmSyncEngine) {
if (!currentVmId) {
throw new Error(`--vm-sync-engine requires the [vm-id] command argument.`);
}
const vmName = currentVmId;
if (!UnderpostLxd._vmExists(vmName)) {
throw new Error(`VM '${vmName}' does not exist.`);
}
logger.info(`[${vmName}] Syncing engine source from host...`);
await UnderpostLxd._bootstrapEngineSource(vmName);
UnderpostLxd._execVmNodeCommand(vmName, `cd ${ENGINE_ROOT_IN_VM} && npm install`, { requireNpm: true });
logger.info(`[${vmName}] Engine source sync complete.`);
return;
}
// =====================================================================
// TEST (connectivity and health checks)
// =====================================================================
if (vmTest) {
if (!currentVmId) {
throw new Error(`--vm-test requires the [vm-id] command argument.`);
}
const vmName = currentVmId;
const vmIp = UnderpostLxd._vmIpv4(vmName);
logger.info(`VM ${vmName} IPv4: ${vmIp || 'none'}`);
const httpStatus = shellExec(
`${UnderpostLxd._lxcCmd()} exec ${vmName} -- curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://google.com`,
{ stdout: true },
).trim();
logger.info(`VM ${vmName} HTTP connectivity: ${httpStatus}`);
logger.info(`Health report for VM: ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} list ${vmName} --format json`);
shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -c 'top -bn1 | grep "Cpu(s)"'`);
shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -c 'free -m'`);
shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -c 'df -h /'`);
shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -c 'ip a'`);
shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -c 'cat /etc/resolv.conf'`);
shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -c 'sudo k3s kubectl get nodes'`);
}
},
};
// =====================================================================
// PRIVATE HELPERS — lookups that legitimately tolerate "absent" return
// values do so via list-style commands that always exit 0, not by
// suppressing error signals from destructive commands.
// =====================================================================
/**
* Lists all LXD VM (virtual-machine) instance names. Returns [] when no VMs.
* `lxc list --format json` always exits 0; an empty cluster yields `[]`.
* @returns {string[]}
* @private
*/
static _listVms() {
const raw = shellExec(
`${UnderpostLxd._lxcCmd()} list --format json | jq -r '.[] | select(.type=="virtual-machine") | .name // empty'`,
{
stdout: true,
},
).trim();
if (!raw) return [];
return raw.split('\n').filter((n) => n.length > 0);
}
/**
* Returns the named VM's status string (e.g. 'Running', 'Stopped', 'Frozen')
* or `null` if the VM does not exist. Never throws on absence.
* @param {string} vmName
* @returns {string|null}
* @private
*/
static _vmState(vmName) {
const raw = shellExec(`${UnderpostLxd._lxcCmd()} list ${vmName} --format json`, { stdout: true }).trim();
if (!raw) return null;
const arr = JSON.parse(raw);
const inst = Array.isArray(arr) ? arr.find((i) => i?.name === vmName) : null;
return inst ? inst.status || 'Unknown' : null;
}
/**
* @param {string} vmName
* @returns {boolean}
* @private
*/
static _vmExists(vmName) {
return UnderpostLxd._vmState(vmName) !== null;
}
/**
* Resolves the VM's primary IPv4, preferring the guest interface that owns
* the default route. This avoids selecting K3s bridge/CNI addresses like
* 10.42.0.1 after the control plane comes up.
* @param {string} vmName
* @returns {string}
* @private
*/
static _vmIpv4(vmName) {
const defaultRoute = shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- ip -4 -o route show default`, {
stdout: true,
}).trim();
const defaultRouteTokens = defaultRoute ? defaultRoute.split(/\s+/) : [];
const devIndex = defaultRouteTokens.indexOf('dev');
const defaultIface = devIndex >= 0 ? defaultRouteTokens[devIndex + 1] || '' : '';
if (defaultIface) {
const defaultIfaceAddr = shellExec(
`${UnderpostLxd._lxcCmd()} exec ${vmName} -- ip -4 -o addr show dev ${defaultIface} scope global`,
{
stdout: true,
},
).trim();
const routeScopedIp = defaultIfaceAddr.match(/\binet\s+([0-9.]+)\//)?.[1] || '';
if (routeScopedIp) return routeScopedIp;
}
return shellExec(
`${UnderpostLxd._lxcCmd()} list ${vmName} --format json | jq -r '[.[0].state.network | to_entries[] | select(.key!="lo") | .value.addresses[]? | select(.family=="inet" and .scope=="global") | .address | select(test("^10\\.42\\.|^10\\.43\\.|^169\\.254\\.") | not)] | .[0] // empty'`,
{ stdout: true },
).trim();
}
/**
* Pins the VM's lxdbr0 NIC to a static `ipv4.address` equal to `vmIp`, which
* NAT-mode proxy devices require. `security.ipv4_filtering=true` is set in the
* same call: lxdbr0 runs no DHCP, and LXD only permits a static NIC address
* on a DHCP-less managed bridge when filtering is enabled (it also anti-spoofs
* the VM to that IP). The eth0 NIC comes from admin-profile, so override it on
* first touch and set thereafter. Same IP as the current lease -> no disruption.
* @param {string} vmName
* @param {string} vmIp
* @private
*/
static _ensureNicStaticIpv4(vmName, vmIp) {
const nic = 'eth0';
const raw = shellExec(`${UnderpostLxd._lxcCmd()} list ${vmName} --format json`, { stdout: true }).trim();
const arr = JSON.parse(raw || '[]');
const inst = Array.isArray(arr) ? arr.find((i) => i?.name === vmName) : null;
const instanceDevices = inst?.devices || {};
const currentStatic = instanceDevices[nic]?.['ipv4.address'] || '';
const hasLocalNic = !!instanceDevices[nic];
const verb = hasLocalNic ? 'set' : 'override';
if (currentStatic !== vmIp) {
shellExec(
`${UnderpostLxd._lxcCmd()} config device ${verb} ${vmName} ${nic} ipv4.address=${vmIp} security.ipv4_filtering=true`,
);
logger.info(` Pinned ${vmName} NIC ${nic} to static ${vmIp} (required for NAT proxy on VMs).`);
} else {
shellExec(`${UnderpostLxd._lxcCmd()} config device set ${vmName} ${nic} security.ipv4_filtering=true`, {
silentOnError: true,
});
logger.info(` NIC ${nic} on ${vmName} already pinned to static ${vmIp}.`);
}
}
/**
* Returns true if a named device is currently attached (expanded) to the VM.
* @param {string} vmName
* @param {string} deviceName
* @returns {boolean}
* @private
*/
static _vmHasDevice(vmName, deviceName) {
if (!UnderpostLxd._vmExists(vmName)) return false;
const raw = shellExec(`${UnderpostLxd._lxcCmd()} list ${vmName} --format json`, { stdout: true }).trim();
const arr = JSON.parse(raw || '[]');
const inst = Array.isArray(arr) ? arr.find((i) => i?.name === vmName) : null;
if (!inst) return false;
return Object.prototype.hasOwnProperty.call(inst.expanded_devices || {}, deviceName);
}
/**
* @param {string} name
* @returns {boolean}
* @private
*/
static _profileExists(name) {
const raw = shellExec(`${UnderpostLxd._lxcCmd()} profile list --format json`, { stdout: true }).trim();
const arr = JSON.parse(raw || '[]');
return Array.isArray(arr) && arr.some((p) => p?.name === name);
}
/**
* @param {string} name
* @returns {boolean}
* @private
*/
static _networkExists(name) {
const raw = shellExec(`${UnderpostLxd._lxcCmd()} network list --format json`, { stdout: true }).trim();
const arr = JSON.parse(raw || '[]');
return Array.isArray(arr) && arr.some((n) => n?.name === name);
}
/**
* True once `lxd init --preseed` has bootstrapped this daemon. Detected by the
* presence of any storage pool: the preseed creates `local`, and a fresh
* daemon has none. `lxc storage list` exits 0 with `[]` before init, so this
* never throws on a not-yet-initialized host.
* @returns {boolean}
* @private
*/
static _lxdInitialized() {
const raw = shellExec(`${UnderpostLxd._lxcCmd()} storage list --format json`, {
stdout: true,
silentOnError: true,
}).trim();
if (!raw) return false;
let arr;
try {
arr = JSON.parse(raw);
} catch {
return false;
}
return Array.isArray(arr) && arr.length > 0;
}
/**
* Adds the bridge to the firewalld `trusted` zone so VM<->host and VM
* outbound traffic isn't dropped by the host firewall. Idempotent and
* resilient: re-adding an already-trusted interface is a no-op, and hosts
* without firewalld are skipped rather than aborting bridge creation.
* @param {string} bridge
* @private
*/
static _ensureBridgeInTrustedZone(bridge) {
const hasFirewalld = shellExec(`command -v firewall-cmd`, { stdout: true, silentOnError: true }).trim();
if (!hasFirewalld) {
logger.info(`firewall-cmd not found; skipping trusted-zone binding for ${bridge}.`);
return;
}
shellExec(`sudo firewall-cmd --permanent --zone=trusted --add-interface=${bridge}`, { silentOnError: true });
shellExec(`sudo firewall-cmd --reload`, { silentOnError: true });
}
/**
* Explicitly accepts forwarded traffic to/from the plain bridge in the
* iptables FORWARD chain. A LXD-managed bridge inserts these itself
* (`ipv4.firewall=true`); a plain bridge does not, so on a host where Docker
* has set the FORWARD policy to DROP, VM<->VM traffic on lxdbr0 (e.g. a k3s
* worker dialing the control plane API) is silently dropped once br_netfilter
* routes bridged frames through netfilter. Rules are prepended (position 1) so
* they win over Docker's DROP, and guarded by `-C` so re-runs don't duplicate.
* Not persisted across reboots/iptables flush by design — re-run
* `--create-virtual-network`, consistent with the rest of this lab flow.
* @param {string} bridge
* @private
*/
static _ensureBridgeForwardingAccept(bridge) {
const hasIptables = shellExec(`command -v iptables`, { stdout: true, silentOnError: true }).trim();
if (!hasIptables) {
logger.info(`iptables not found; skipping FORWARD accept rules for ${bridge}.`);
return;
}
for (const dir of ['-i', '-o']) {
const present = shellExec(`sudo iptables -C FORWARD ${dir} ${bridge} -j ACCEPT`, { silentOnError: true });
if (present.code !== 0) {
shellExec(`sudo iptables -I FORWARD 1 ${dir} ${bridge} -j ACCEPT`);
}
}
logger.info(`Ensured FORWARD ACCEPT for ${bridge} (counters Docker/default DROP for VM<->VM traffic).`);
}
/**
* Returns true if a snap with the given name is installed. `snap list` exits
* 0 with the full installed-snap table; we grep for an exact-name row.
* @param {string} name
* @returns {boolean}
* @private
*/
static _snapInstalled(name) {
const raw = shellExec(`snap list`, { stdout: true });
return raw.split('\n').some((line) => new RegExp(`^${name}\\s`).test(line));
}
/**
* Single-quotes a shell argument for safe `bash -lc '...'` usage.
* @param {string} value
* @returns {string}
* @private
*/
static _shellSingleQuote(value) {
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
}
/**
* Runs a command inside a VM with Node/NPM restored from the NVM install
* that k3s-node-setup.sh lays down. Non-login `bash -c` shells do not keep
* that PATH, so resolve it explicitly here.
* @param {string} vmName
* @param {string} command
* @param {object} [options]
* @param {boolean} [options.requireNpm=false]
* @param {number} [options.timeoutSeconds=0]
* @private
*/
static _execVmNodeCommand(vmName, command, options = { requireNpm: false, timeoutSeconds: 0 }) {
const { requireNpm = false, timeoutSeconds = 0 } = options;
const runtimeBootstrap = [
'export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"',
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"',
`if ! command -v node >/dev/null 2>&1${requireNpm ? ' || ! command -v npm >/dev/null 2>&1' : ''}; then latest_nvm_bin="$(ls -d "$NVM_DIR"/versions/node/*/bin 2>/dev/null | sort -V | tail -n 1)"; if [ -n "$latest_nvm_bin" ]; then export PATH="$latest_nvm_bin:$PATH"; fi; fi`,
'command -v node >/dev/null 2>&1 || { echo "ERROR: node not found in PATH or NVM_DIR=$NVM_DIR" >&2; exit 127; }',
requireNpm
? 'command -v npm >/dev/null 2>&1 || { echo "ERROR: npm not found in PATH or NVM_DIR=$NVM_DIR" >&2; exit 127; }'
: '',
command,
]
.filter(Boolean)
.join(' && ');
const lxcExecCmd = `${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -lc ${UnderpostLxd._shellSingleQuote(runtimeBootstrap)}`;
return shellExec(timeoutSeconds > 0 ? `timeout ${timeoutSeconds} ${lxcExecCmd}` : lxcExecCmd);
}
/**
* Deterministic per-VM IPv4 in the lxdbr0 /24, used as the static fallback
* inside lxd-vm-setup.sh when DHCP is unavailable. The previous shared
* `10.250.250.100/24` fallback caused IP collisions across VMs and broke
* worker→control K3s joins (the worker dialed its own NIC). Offset is the
* sum of vmName char codes mod 253, +2 — avoids .0, .1 (gateway), .255.
* @param {string} vmName
* @returns {string}
* @private
*/
static _allocateFallbackIp(vmName) {
let sum = 0;
for (let i = 0; i < vmName.length; i++) sum += vmName.charCodeAt(i);
return `${BRIDGE_SUBNET_PREFIX}.${(sum % 253) + 2}`;
}
/**
* Reads the K3s join info (control plane IPv4 + node token) from the control
* VM. The control must already be running — VMs created with `admin-profile`
* have `boot.autostart=false`, so after a host reboot bring it up explicitly
* ('lxc start <control>' or 'node bin lxd --restore') before joining a worker.
* Throws if either value is missing — callers depend on both.
* @param {string} controlNode
* @returns {{ip: string, token: string}}
* @private
*/
static _readControlPlaneJoinInfo(controlNode) {
const state = UnderpostLxd._vmState(controlNode);
if (state === null) {
throw new Error(`Control node VM '${controlNode}' does not exist.`);
}
if (state !== 'Running') {
throw new Error(
`Control node VM '${controlNode}' is ${state}. Start it first ('lxc start ${controlNode}' or 'node bin lxd --restore'), then re-run the worker join.`,
);
}
const token = shellExec(
`${UnderpostLxd._lxcCmd()} exec ${controlNode} -- bash -c 'sudo cat /var/lib/rancher/k3s/server/node-token'`,
{
stdout: true,
},
).trim();
const ip = UnderpostLxd._vmIpv4(controlNode);
if (!ip || !token) {
throw new Error(`Could not read join info from control node '${controlNode}' (ip='${ip}', token='${token}').`);
}
return { ip, token };
}
/**
* Applies the `node-role.kubernetes.io/worker` label to a freshly joined
* worker. A K3s agent cannot self-apply `node-role.kubernetes.io/*` labels
* (the NodeRestriction admission plugin rejects them), so the label must be
* set from the control plane after the worker registers — otherwise the node
* shows ROLES `<none>`. The K3s node name defaults to the VM hostname, which
* LXD sets to the instance name, so `workerName` is the VM name. Waits up to
* 60s for the node to appear before labeling.
* @param {string} controlNode
* @param {string} workerName
* @private
*/
static _labelWorkerNodeRole(controlNode, workerName) {
logger.info(`Labeling worker '${workerName}' as node-role.kubernetes.io/worker (from control '${controlNode}')...`);
shellExec(
`${UnderpostLxd._lxcCmd()} exec ${controlNode} -- bash -c 'for i in $(seq 1 30); do if sudo k3s kubectl get node ${workerName} >/dev/null 2>&1; then sudo k3s kubectl label node ${workerName} node-role.kubernetes.io/worker=worker --overwrite && exit 0; fi; sleep 2; done; echo "WARN: worker ${workerName} did not register within 60s; role label not applied." >&2'`,
);
}
/**
* Enumerates and removes every device of `type: proxy` attached to a VM
* (the `--expose` NAT proxy devices). Naming-agnostic. Skips if the VM is
* already gone; otherwise every `lxc config device remove` propagates errors.
* @param {string} vmName
* @private
*/
static _removeProxyDevices(vmName) {
if (!UnderpostLxd._vmExists(vmName)) {
logger.info(` Skipping proxy cleanup: VM '${vmName}' is already gone.`);
return;
}
logger.info(` Removing proxy devices from ${vmName}...`);
const raw = shellExec(`${UnderpostLxd._lxcCmd()} list ${vmName} --format json`, { stdout: true }).trim();
const arr = JSON.parse(raw || '[]');
const inst = Array.isArray(arr) ? arr.find((i) => i?.name === vmName) : null;
const expandedDevices = inst?.expanded_devices || {};
const proxyNames = Object.entries(expandedDevices)
.filter(([, dev]) => dev?.type === 'proxy')
.map(([name]) => name);
if (proxyNames.length === 0) {
logger.info(` No proxy devices found on ${vmName}.`);
return;
}
for (const name of proxyNames) {
logger.info(` Removing device: ${name}`);
shellExec(`${UnderpostLxd._lxcCmd()} config device remove ${vmName} ${name}`);
}
}
/**
* Delegates K3s teardown inside a running VM to the centralized
* `safeResetK3s` in src/cli/cluster.js via `lxc exec`. No-op when K3s or the
* engine mirror is missing. Bounded by `timeout 300`.
* @param {string} vmName
* @param {'drain'|'full'} resetMode - `drain` preserves K3s for next boot
* (`--shutdown`); `full` uninstalls (`--vm-delete` / `--reset` / `--purge`).
* @private
*/
static _resetK3sInVm(vmName, resetMode) {
if (UnderpostLxd._vmState(vmName) !== 'Running') return;
const m = resetMode === 'drain' ? 'drain' : 'full';
const probe = `if test -x /usr/local/bin/k3s && test -d ${ENGINE_ROOT_IN_VM}/bin; then echo yes; else echo no; fi`;
const probeOut = shellExec(`${UnderpostLxd._lxcCmd()} exec ${vmName} -- bash -c '${probe}'`, {
stdout: true,
}).trim();
if (probeOut !== 'yes') {
logger.info(` [${vmName}] No K3s+engine detected (probe=${probeOut}); skipping K3s reset.`);
return;
}
logger.info(` [${vmName}] Resetting K3s (resetMode=${m}) via 'node bin cluster --reset --k3s --reset-mode=${m}'`);
UnderpostLxd._execVmNodeCommand(
vmName,
`cd ${ENGINE_ROOT_IN_VM} && node bin cluster --dev --reset --k3s --reset-mode=${m}`,
{ timeoutSeconds: 300 },
);
}
/**
* Safely deletes a single VM. Pre-conditions gate every step; absence is a
* no-op, but unexpected failures propagate.
*
* 1. If VM is absent → log and return.
* 2. Remove every proxy device (clears iptables NAT before the VM goes away).
* 3. If state is Running/Frozen → graceful stop with 30 s timeout.
* 4. Delete the VM.
*
* @param {string} vmName
* @private
*/
static _safeDeleteVm(vmName) {
const state = UnderpostLxd._vmState(vmName);
if (state === null) {
logger.info(`VM '${vmName}' does not exist. Nothing to do.`);
return;
}
logger.info(`Deleting VM '${vmName}' (current state: ${state})...`);
UnderpostLxd._removeProxyDevices(vmName);
if (state === 'Running' || state === 'Frozen') {
UnderpostLxd._resetK3sInVm(vmName, 'full');
logger.info(` Stopping VM: ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} stop ${vmName} --timeout 60`);
}
logger.info(` Deleting VM: ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} delete ${vmName}`);
logger.info(`VM ${vmName} deleted.`);
}
/**
* Host-safe reset. Wipes per-VM state and the network/profile this CLI owns.
* Leaves the LXD snap and storage pools intact so the host stays bootable
* even if the daemon has internal issues. Use `--purge` for snap removal.
*
* Phase 1: Remove proxy devices from every VM (clears iptables NAT rules).
* Phase 2: Stop running VMs gracefully (30 s timeout each).
* Phase 3: Delete every VM.
* Phase 4: Drop `admin-profile` and the `lxdbr0` network if they exist.
*
* @private
*/
static _safeReset() {
logger.info('=== LXD RESET (host-safe) ===');
const vmList = UnderpostLxd._listVms();
logger.info(`Phase 1/4: Removing proxy devices from ${vmList.length} VM(s)...`);
for (const vmName of vmList) {
UnderpostLxd._removeProxyDevices(vmName);
}
logger.info('Phase 2/4: Full K3s teardown + stopping running VMs gracefully...');
for (const vmName of vmList) {
const state = UnderpostLxd._vmState(vmName);
if (state === 'Running' || state === 'Frozen') {
UnderpostLxd._resetK3sInVm(vmName, 'full');
logger.info(` Stopping VM: ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} stop ${vmName} --timeout 60`);
} else if (state !== null) {
logger.info(` VM ${vmName} already in state: ${state}`);
}
}
logger.info('Phase 3/4: Deleting all VMs...');
for (const vmName of vmList) {
if (UnderpostLxd._vmExists(vmName)) {
logger.info(` Deleting VM: ${vmName}`);
shellExec(`${UnderpostLxd._lxcCmd()} delete ${vmName}`);
}
}
logger.info(`Phase 4/4: Removing ${ADMIN_PROFILE} and ${BRIDGE_NETWORK} if present...`);
if (UnderpostLxd._profileExists(ADMIN_PROFILE)) {
shellExec(`${UnderpostLxd._lxcCmd()} profile delete ${ADMIN_PROFILE}`);
}
if (UnderpostLxd._networkExists(BRIDGE_NETWORK)) {
shellExec(`${UnderpostLxd._lxcCmd()} network delete ${BRIDGE_NETWORK}`);
}
logger.info('=== LXD RESET COMPLETE ===');
logger.info('Snap and storage pools were NOT touched. Use --purge to remove the LXD snap.');
}
/**
* Removes the LXD snap. ALWAYS preceded by `lxd shutdown --timeout 60` so
* the daemon flushes the ZFS pool cleanly. Without that flush, removing the
* snap while VMs are running and the pool is dirty has historically left the
* host unbootable. This is the safe variant.
*
* @private
*/
static _