UNPKG

@underpostnet/underpost

Version:
1,194 lines (1,072 loc) 53.8 kB
import { getNpmRootPath, getUnderpostRootPath } from '../server/conf.js'; import { openTerminal, pbcopy, shellExec } from '../server/process.js'; import dotenv from 'dotenv'; import { loggerFactory } from '../server/logger.js'; import { getLocalIPv4Address } from '../server/dns.js'; import fs from 'fs-extra'; import { Downloader } from '../server/downloader.js'; import UnderpostCloudInit from './cloud-init.js'; import { s4, timer } from '../client/components/core/CommonJs.js'; const logger = loggerFactory(import.meta); /** * @class UnderpostBaremetal * @description Manages baremetal provisioning and configuration tasks. * This class provides a set of static methods to automate various * infrastructure operations, including NFS management, control server setup, * and system provisioning for different architectures. */ class UnderpostBaremetal { static API = { /** * @method callback * @description Initiates a baremetal provisioning workflow based on the provided options. * This is the primary entry point for orchestrating baremetal operations. * It handles NFS root filesystem building, control server installation/uninstallation, * and system-level provisioning tasks like timezone and keyboard configuration. * @param {string} [workflowId='rpi4mb'] - Identifier for the specific workflow configuration to use. * @param {string} [hostname=workflowId] - The hostname of the target baremetal machine. * @param {string} [ipAddress=getLocalIPv4Address()] - The IP address of the control server or the local machine. * @param {object} [options] - An object containing boolean flags for various operations. * @param {boolean} [options.dev=false] - Development mode flag. * @param {boolean} [options.controlServerInstall=false] - Flag to install the control server (e.g., MAAS). * @param {boolean} [options.controlServerUninstall=false] - Flag to uninstall the control server. * @param {boolean} [options.controlServerDbInstall=false] - Flag to install the control server's database. * @param {boolean} [options.controlServerDbUninstall=false] - Flag to uninstall the control server's database. * @param {boolean} [options.commission=false] - Flag to commission the baremetal machine. * @param {boolean} [options.nfsBuild=false] - Flag to build the NFS root filesystem. * @param {boolean} [options.nfsMount=false] - Flag to mount the NFS root filesystem. * @param {boolean} [options.nfsUnmount=false] - Flag to unmount the NFS root filesystem. * @param {boolean} [options.nfsSh=false] - Flag to chroot into the NFS environment for shell access. * @param {string} [options.logs=''] - Specifies which logs to display ('dhcp', 'cloud', 'machine', 'cloud-config'). * @returns {void} */ async callback( workflowId, hostname, ipAddress, options = { dev: false, controlServerInstall: false, controlServerUninstall: false, controlServerDbInstall: false, controlServerDbUninstall: false, commission: false, nfsBuild: false, nfsMount: false, nfsUnmount: false, nfsSh: false, logs: '', }, ) { // Load environment variables from .env file, overriding existing ones if present. dotenv.config({ path: `${getUnderpostRootPath()}/.env`, override: true }); // Determine the root path for npm and underpost. const npmRoot = getNpmRootPath(); const underpostRoot = options?.dev === true ? '.' : `${npmRoot}/underpost`; // Set default values if not provided. workflowId = workflowId ?? 'rpi4mb'; hostname = hostname ?? workflowId; ipAddress = ipAddress ?? '192.168.1.192'; // Set default MAC address let macAddress = '00:00:00:00:00:00'; // Define the debootstrap architecture. let debootstrapArch; // Define the database provider ID. const dbProviderId = 'postgresql-17'; // Define the NFS host path based on the environment variable and hostname. const nfsHostPath = `${process.env.NFS_EXPORT_PATH}/${hostname}`; // Define the TFTP root path based on the environment variable and hostname. const tftpRootPath = `${process.env.TFTP_ROOT}/${hostname}`; // Capture metadata for the callback execution, useful for logging and auditing. const callbackMetaData = { args: { hostname, ipAddress, workflowId }, options, runnerHost: { architecture: UnderpostBaremetal.API.getHostArch().alias, ip: getLocalIPv4Address() }, nfsHostPath, tftpRootPath, }; // Log the initiation of the baremetal callback with relevant metadata. logger.info('Baremetal callback', callbackMetaData); // Handle various log display options. if (options.logs === 'dhcp') { shellExec(`journalctl -f -t dhcpd -u snap.maas.pebble.service`); return; } if (options.logs === 'cloud') { shellExec(`tail -f -n 900 ${nfsHostPath}/var/log/cloud-init.log`); return; } if (options.logs === 'machine') { shellExec(`tail -f -n 900 ${nfsHostPath}/var/log/cloud-init-output.log`); return; } if (options.logs === 'cloud-config') { shellExec(`cat ${nfsHostPath}/etc/cloud/cloud.cfg.d/90_maas.cfg`); return; } // Handle NFS shell access option. if (options.nfsSh === true) { const { debootstrap } = UnderpostBaremetal.API.workflowsConfig[workflowId]; // Copy the chroot command to the clipboard for easy execution. if (debootstrap.image.architecture !== callbackMetaData.runnerHost.architecture) switch (debootstrap.image.architecture) { case 'arm64': pbcopy(`sudo chroot ${nfsHostPath} /usr/bin/qemu-aarch64-static /bin/bash`); break; case 'amd64': pbcopy(`sudo chroot ${nfsHostPath} /usr/bin/qemu-x86_64-static /bin/bash`); break; default: break; } else pbcopy(`sudo chroot ${nfsHostPath} /bin/bash`); return; // Exit early as this is a specific interactive operation. } // Handle control server installation. if (options.controlServerInstall === true) { // Ensure scripts are executable and then run them. shellExec(`chmod +x ${underpostRoot}/manifests/maas/maas-setup.sh`); shellExec(`chmod +x ${underpostRoot}/manifests/maas/nat-iptables.sh`); shellExec(`${underpostRoot}/manifests/maas/maas-setup.sh`); shellExec(`${underpostRoot}/manifests/maas/nat-iptables.sh`); return; } // Handle control server uninstallation. if (options.controlServerUninstall === true) { // Stop and remove MAAS services, handling potential errors gracefully. shellExec(`sudo snap stop maas.pebble || true`); shellExec(`sudo snap stop maas`); shellExec(`sudo snap remove maas --purge || true`); // Remove residual snap data to ensure a clean uninstall. shellExec(`sudo rm -rf /var/snap/maas`); shellExec(`sudo rm -rf ~/snap/maas`); // Remove MAAS configuration and data directories. shellExec(`sudo rm -rf /etc/maas`); shellExec(`sudo rm -rf /var/lib/maas`); shellExec(`sudo rm -rf /var/log/maas`); return; } // Handle control server database installation. if (options.controlServerDbInstall === true) { // Deploy the database provider and manage MAAS database. shellExec(`node ${underpostRoot}/bin/deploy ${dbProviderId} install`); shellExec( `node ${underpostRoot}/bin/deploy pg-drop-db ${process.env.DB_PG_MAAS_NAME} ${process.env.DB_PG_MAAS_USER}`, ); shellExec(`node ${underpostRoot}/bin/deploy maas-db`); return; } // Handle control server database uninstallation. if (options.controlServerDbUninstall === true) { shellExec(`node ${underpostRoot}/bin/deploy ${dbProviderId} uninstall`); return; } // Set debootstrap architecture. { const { architecture } = UnderpostBaremetal.API.workflowsConfig[workflowId].debootstrap.image; debootstrapArch = architecture; } // Handle NFS mount operation. if (options.nfsMount === true) { // Mount binfmt_misc filesystem. UnderpostBaremetal.API.mountBinfmtMisc({ nfsHostPath }); UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, mount: true }); } // Handle NFS unmount operation. if (options.nfsUnmount === true) { UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, unmount: true }); } // Handle NFS root filesystem build operation. if (options.nfsBuild === true) { // Check if NFS is already mounted to avoid redundant builds. const { isMounted } = UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId }); if (isMounted) { logger.warn('NFS root filesystem is mounted, skipping build.'); return; // Exit if already mounted. } logger.info('NFS root filesystem is not mounted, building...'); // Clean and create the NFS host path. shellExec(`sudo rm -rf ${nfsHostPath}/*`); shellExec(`mkdir -p ${nfsHostPath}`); // Mount binfmt_misc filesystem. UnderpostBaremetal.API.mountBinfmtMisc({ nfsHostPath }); // Perform the first stage of debootstrap. { const { architecture, name } = UnderpostBaremetal.API.workflowsConfig[workflowId].debootstrap.image; shellExec( [ `sudo debootstrap`, `--arch=${architecture}`, `--variant=minbase`, `--foreign`, // Indicates a two-stage debootstrap. name, nfsHostPath, `http://ports.ubuntu.com/ubuntu-ports/`, ].join(' '), ); } // Create a podman container to extract QEMU static binaries. shellExec(`sudo podman create --name extract multiarch/qemu-user-static`); shellExec(`podman ps -a`); // List all podman containers for verification. // If cross-architecture, copy the QEMU static binary into the chroot. if (debootstrapArch !== callbackMetaData.runnerHost.architecture) UnderpostBaremetal.API.crossArchBinFactory({ nfsHostPath, debootstrapArch, }); // Clean up the temporary podman container. shellExec(`sudo podman rm extract`); shellExec(`podman ps -a`); shellExec(`file ${nfsHostPath}/bin/bash`); // Verify the bash executable in the chroot. // Perform the second stage of debootstrap within the chroot environment. UnderpostBaremetal.API.crossArchRunner({ nfsHostPath, debootstrapArch, callbackMetaData, steps: [`/debootstrap/debootstrap --second-stage`], }); // Mount NFS if it's not already mounted after the build. if (!isMounted) { UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, mount: true }); } // Apply system provisioning steps (base, user, timezone, keyboard). { const { systemProvisioning, kernelLibVersion, chronyc } = UnderpostBaremetal.API.workflowsConfig[workflowId]; const { timezone, chronyConfPath } = chronyc; UnderpostBaremetal.API.crossArchRunner({ nfsHostPath, debootstrapArch, callbackMetaData, steps: [ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].base({ kernelLibVersion, }), ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].user(), ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].timezone({ timezone, chronyConfPath, }), ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].keyboard(), ], }); } } // Fetch boot resources and machines if commissioning or listing. let resources = JSON.parse( shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resources read`, { silent: true, stdout: true, }), ).map((o) => ({ id: o.id, name: o.name, architecture: o.architecture, })); if (options.ls === true) { console.table(resources); } let machines = JSON.parse( shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machines read`, { stdout: true, silent: true, }), ).map((m) => ({ system_id: m.interface_set[0].system_id, mac_address: m.interface_set[0].mac_address, hostname: m.hostname, status_name: m.status_name, })); if (options.ls === true) { console.table(machines); } // Handle commissioning tasks (placeholder for future implementation). if (options.commission === true) { const { firmwares, networkInterfaceName, maas, netmask, menuentryStr } = UnderpostBaremetal.API.workflowsConfig[workflowId]; const resource = resources.find( (o) => o.architecture === maas.image.architecture && o.name === maas.image.name, ); logger.info('Commissioning resource', resource); // Clean and create TFTP root path. shellExec(`sudo rm -rf ${tftpRootPath}`); shellExec(`mkdir -p ${tftpRootPath}/pxe`); // Process firmwares for TFTP. for (const firmware of firmwares) { const { url, gateway, subnet } = firmware; if (url.match('.zip')) { const name = url.split('/').pop().replace('.zip', ''); const path = `../${name}`; if (!fs.existsSync(path)) { await Downloader(url, `../${name}.zip`); // Download firmware if not exists. shellExec(`cd .. && mkdir ${name} && cd ${name} && unzip ../${name}.zip`); // Unzip firmware. } shellExec(`sudo cp -a ${path}/* ${tftpRootPath}`); // Copy firmware files to TFTP root. if (gateway && subnet) { fs.writeFileSync( `${tftpRootPath}/boot_${name}.conf`, UnderpostBaremetal.API.bootConfFactory({ workflowId, tftpIp: callbackMetaData.runnerHost.ip, tftpPrefixStr: hostname, macAddress, clientIp: ipAddress, subnet, gateway, }), 'utf8', ); } } } // Rebuild NFS server configuration. UnderpostBaremetal.API.rebuildNfsServer({ nfsHostPath, }); // Configure GRUB for PXE boot. { const resourceData = JSON.parse( shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resource read ${resource.id}`, { stdout: true, silent: true, disableLog: true, }), ); const bootFiles = resourceData.sets[Object.keys(resourceData.sets)[0]].files; const suffix = resource.architecture.match('xgene') ? '.xgene' : ''; const resourcesPath = `/var/snap/maas/common/maas/image-storage/bootloaders/uefi/arm64`; const kernelPath = `/var/snap/maas/common/maas/image-storage`; const kernelFilesPaths = { 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel' + suffix].filename_on_disk}`, 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd' + suffix].filename_on_disk}`, squashfs: `${kernelPath}/${bootFiles['squashfs'].filename_on_disk}`, }; // Construct kernel command line arguments for NFS boot. const cmd = [ `console=serial0,115200`, // `console=ttyAMA0,115200`, `console=tty1`, // `initrd=-1`, // `net.ifnames=0`, // `dwc_otg.lpm_enable=0`, // `elevator=deadline`, `root=/dev/nfs`, `nfsroot=${callbackMetaData.runnerHost.ip}:${process.env.NFS_EXPORT_PATH}/rpi4mb,${[ 'tcp', 'vers=3', 'nfsvers=3', 'nolock', // 'protocol=tcp', // 'hard=true', 'port=2049', // 'sec=none', 'rw', 'hard', 'intr', 'rsize=32768', 'wsize=32768', 'acregmin=0', 'acregmax=0', 'acdirmin=0', 'acdirmax=0', 'noac', // 'nodev', // 'nosuid', ]}`, `ip=${ipAddress}:${callbackMetaData.runnerHost.ip}:${callbackMetaData.runnerHost.ip}:${netmask}:${hostname}:${networkInterfaceName}:static`, `rootfstype=nfs`, `rw`, `rootwait`, `fixrtc`, 'initrd=initrd.img', // 'boot=casper', // 'ro', 'netboot=nfs', `init=/sbin/init`, // `cloud-config-url=/dev/null`, // 'ip=dhcp', // 'ip=dfcp', // 'autoinstall', // 'rd.break', // Disable services that not apply over nfs `systemd.mask=systemd-network-generator.service`, `systemd.mask=systemd-networkd.service`, `systemd.mask=systemd-fsck-root.service`, `systemd.mask=systemd-udev-trigger.service`, ]; const nfsConnectStr = cmd.join(' '); // Copy EFI bootloaders to TFTP path. for (const file of ['bootaa64.efi', 'grubaa64.efi']) { shellExec(`sudo cp -a ${resourcesPath}/${file} ${tftpRootPath}/pxe/${file}`); } // Copy kernel and initrd images to TFTP path. for (const file of Object.keys(kernelFilesPaths)) { shellExec(`sudo cp -a ${kernelFilesPaths[file]} ${tftpRootPath}/pxe/${file}`); } // Write GRUB configuration file. fs.writeFileSync( `${process.env.TFTP_ROOT}/grub/grub.cfg`, ` insmod gzio insmod http insmod nfs set timeout=5 set default=0 menuentry '${menuentryStr}' { set root=(tftp,${callbackMetaData.runnerHost.ip}) linux /${hostname}/pxe/vmlinuz-efi ${nfsConnectStr} initrd /${hostname}/pxe/initrd.img boot } `, 'utf8', ); } // Copy ARM64 EFI GRUB modules. const arm64EfiPath = `${process.env.TFTP_ROOT}/grub/arm64-efi`; if (fs.existsSync(arm64EfiPath)) shellExec(`sudo rm -rf ${arm64EfiPath}`); shellExec(`sudo cp -a /usr/lib/grub/arm64-efi ${arm64EfiPath}`); // Set ownership and permissions for TFTP root. shellExec(`sudo chown -R root:root ${process.env.TFTP_ROOT}`); shellExec(`sudo sudo chmod 755 ${process.env.TFTP_ROOT}`); } // Final commissioning steps. if (options.commission === true || options.cloudInitUpdate === true) { const { debootstrap, networkInterfaceName, chronyc, maas } = UnderpostBaremetal.API.workflowsConfig[workflowId]; const { timezone, chronyConfPath } = chronyc; // Build cloud-init tools. UnderpostCloudInit.API.buildTools({ workflowId, nfsHostPath, hostname, callbackMetaData, dev: options.dev, }); // Run cloud-init reset and configure cloud-init. UnderpostBaremetal.API.crossArchRunner({ nfsHostPath, debootstrapArch: debootstrap.image.architecture, callbackMetaData, steps: [ options.cloudInitUpdate === true ? '' : `/underpost/reset.sh`, `chown root:root /usr/bin/sudo && chmod 4755 /usr/bin/sudo`, UnderpostCloudInit.API.configFactory({ controlServerIp: callbackMetaData.runnerHost.ip, hostname, commissioningDeviceIp: ipAddress, gatewayip: callbackMetaData.runnerHost.ip, mac: macAddress, // Initial MAC, will be updated. timezone, chronyConfPath, networkInterfaceName, }), ], }); if (options.cloudInitUpdate === true) return; // Apply NAT iptables rules. shellExec(`${underpostRoot}/manifests/maas/nat-iptables.sh`, { silent: true }); // Wait for MAC address assignment. logger.info('Waiting for MAC assignment...'); fs.removeSync(`${nfsHostPath}/underpost/mac`); // Clear previous MAC. await UnderpostBaremetal.API.macMonitor({ nfsHostPath }); // Monitor for MAC file. macAddress = fs.readFileSync(`${nfsHostPath}/underpost/mac`, 'utf8').trim(); // Read assigned MAC. // Re-run cloud-init config factory with the newly assigned MAC address. UnderpostBaremetal.API.crossArchRunner({ nfsHostPath, debootstrapArch: debootstrap.image.architecture, callbackMetaData, steps: [ UnderpostCloudInit.API.configFactory({ controlServerIp: callbackMetaData.runnerHost.ip, hostname, commissioningDeviceIp: ipAddress, gatewayip: callbackMetaData.runnerHost.ip, mac: macAddress, // Updated MAC address. timezone, chronyConfPath, networkInterfaceName, }), ], }); // Remove existing machines from MAAS. machines = UnderpostBaremetal.API.removeMachines({ machines }); // Monitor commissioning process. UnderpostBaremetal.API.commissionMonitor({ macAddress, nfsHostPath, underpostRoot, hostname, maas, networkInterfaceName, }); } }, /** * @method commissionMonitor * @description Monitors the MAAS discoveries and initiates machine creation and commissioning * once a matching MAC address is found. It also opens terminal windows for live logs. * @param {object} params - The parameters for the function. * @param {string} params.macAddress - The MAC address to monitor for. * @param {string} params.nfsHostPath - The NFS host path for storing system-id and auth tokens. * @param {string} params.underpostRoot - The root directory of the Underpost project. * @param {string} params.hostname - The desired hostname for the new machine. * @param {object} params.maas - MAAS configuration details. * @param {string} params.networkInterfaceName - The name of the network interface. * @returns {Promise<void>} A promise that resolves when commissioning is initiated or after a delay. */ async commissionMonitor({ macAddress, nfsHostPath, underpostRoot, hostname, maas, networkInterfaceName }) { { logger.info('Waiting for commissioning...', { macAddress, nfsHostPath, underpostRoot, hostname, maas, networkInterfaceName, }); // Query observed discoveries from MAAS. const discoveries = JSON.parse( shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries read`, { silent: true, stdout: true, }), ); // { // "discovery_id": "", // "ip": "192.168.1.189", // "mac_address": "00:00:00:00:00:00", // "last_seen": "2025-05-05T14:17:37.354", // "hostname": null, // "fabric_name": "", // "vid": null, // "mac_organization": "", // "observer": { // "system_id": "", // "hostname": "", // "interface_id": 1, // "interface_name": "" // }, // "resource_uri": "/MAAS/api/2.0/discovery/MTkyLjE2OC4xLjE4OSwwMDowMDowMDowMDowMDowMA==/" // }, // Log discovered IPs for visibility. console.log(discoveries.map((d) => d.ip).join(' | ')); // Iterate through discoveries to find a matching MAC address. for (const discovery of discoveries) { const machine = { architecture: maas.image.architecture.match('amd') ? 'amd64/generic' : 'arm64/generic', mac_address: discovery.mac_address, hostname: discovery.hostname ?? discovery.mac_organization ?? discovery.domain ?? `generic-host-${s4()}${s4()}`, power_type: 'manual', mac_addresses: discovery.mac_address, ip: discovery.ip, }; machine.hostname = machine.hostname.replaceAll(' ', '').replaceAll('.', ''); // Sanitize hostname. if (machine.mac_addresses === macAddress) try { machine.hostname = hostname; machine.mac_address = macAddress; // Create a new machine in MAAS. let newMachine = shellExec( `maas ${process.env.MAAS_ADMIN_USERNAME} machines create ${Object.keys(machine) .map((k) => `${k}="${machine[k]}"`) .join(' ')}`, { silent: true, stdout: true, }, ); newMachine = { discovery, machine: JSON.parse(newMachine) }; console.log(newMachine); const discoverInterfaceName = 'eth0'; // Default interface name for discovery. // Read interface data. const interfaceData = JSON.parse( shellExec( `maas ${process.env.MAAS_ADMIN_USERNAME} interface read ${newMachine.machine.boot_interface.system_id} ${discoverInterfaceName}`, { silent: true, stdout: true, }, ), ); logger.info('Interface', interfaceData); // Mark machine as broken, update interface name, then mark as fixed. shellExec( `maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-broken ${newMachine.machine.boot_interface.system_id}`, ); shellExec( `maas ${process.env.MAAS_ADMIN_USERNAME} interface update ${newMachine.machine.boot_interface.system_id} ${interfaceData.id} name=${networkInterfaceName}`, ); shellExec( `maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-fixed ${newMachine.machine.boot_interface.system_id}`, ); // commissioning_scripts=90-verify-user.sh // shellExec( // `maas ${process.env.MAAS_ADMIN_USERNAME} machine commission --debug --insecure ${newMachine.machine.boot_interface.system_id} enable_ssh=1 skip_bmc_config=1 skip_networking=1 skip_storage=1`, // { // silent: true, // }, // ); // Save system-id for enlistment. logger.info('system-id', newMachine.machine.boot_interface.system_id); fs.writeFileSync( `${nfsHostPath}/underpost/system-id`, newMachine.machine.boot_interface.system_id, 'utf8', ); // Get and save MAAS authentication credentials. const { consumer_key, token_key, token_secret } = UnderpostCloudInit.API.authCredentialsFactory(); fs.writeFileSync(`${nfsHostPath}/underpost/consumer-key`, consumer_key, 'utf8'); fs.writeFileSync(`${nfsHostPath}/underpost/token-key`, token_key, 'utf8'); fs.writeFileSync(`${nfsHostPath}/underpost/token-secret`, token_secret, 'utf8'); // Open new terminals for live cloud-init logs. openTerminal(`node ${underpostRoot}/bin baremetal --logs cloud`); openTerminal(`node ${underpostRoot}/bin baremetal --logs machine`); } catch (error) { logger.error(error, error.stack); } finally { process.exit(0); } } await timer(1000); UnderpostBaremetal.API.commissionMonitor({ macAddress, nfsHostPath, underpostRoot, hostname, maas, networkInterfaceName, }); } }, /** * @method mountBinfmtMisc * @description Mounts the binfmt_misc filesystem to enable QEMU user-static binfmt support. * This is necessary for cross-architecture execution within a chroot environment. * @param {object} params - The parameters for the function. * @param {string} params.nfsHostPath - The path to the NFS root filesystem on the host. * @returns {void} */ mountBinfmtMisc({ nfsHostPath }) { // Install necessary packages for debootstrap and QEMU. shellExec(`sudo dnf install -y iptables-legacy`); shellExec(`sudo dnf install -y debootstrap`); shellExec(`sudo dnf install kernel-modules-extra-$(uname -r)`); // Reset QEMU user-static binfmt for proper cross-architecture execution. shellExec(`sudo podman run --rm --privileged multiarch/qemu-user-static --reset -p yes`); // Mount binfmt_misc filesystem. shellExec(`sudo modprobe binfmt_misc`); shellExec(`sudo mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc`); // Set ownership and permissions for the NFS host path. shellExec(`sudo chown -R root:root ${nfsHostPath}`); shellExec(`sudo chmod 755 ${nfsHostPath}`); }, /** * @method removeMachines * @description Deletes all specified machines from MAAS. * @param {object} params - The parameters for the function. * @param {Array<object>} params.machines - An array of machine objects, each with a `system_id`. * @returns {Array<object>} An empty array after machines are removed. */ removeMachines({ machines }) { for (const machine of machines) { shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${machine.system_id}`); } return []; }, /** * @method clearDiscoveries * @description Clears all observed discoveries in MAAS and optionally forces a new scan. * @param {object} params - The parameters for the function. * @param {boolean} params.force - If true, forces a new discovery scan after clearing. * @returns {void} */ clearDiscoveries({ force }) { shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`); if (force === true) { shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries scan force=true`); } }, /** * @method macMonitor * @description Monitors for the presence of a MAC address file in the NFS host path. * This is used to wait for the target machine to report its MAC address. * @param {object} params - The parameters for the function. * @param {string} params.nfsHostPath - The NFS host path where the MAC file is expected. * @returns {Promise<void>} A promise that resolves when the MAC file is found or after a delay. */ async macMonitor({ nfsHostPath }) { if (fs.existsSync(`${nfsHostPath}/underpost/mac`)) { const mac = fs.readFileSync(`${nfsHostPath}/underpost/mac`, 'utf8').trim(); logger.info('Commissioning MAC', mac); return; } await timer(1000); await UnderpostBaremetal.API.macMonitor({ nfsHostPath }); }, /** * @method crossArchBinFactory * @description Copies the appropriate QEMU static binary into the NFS root filesystem * for cross-architecture execution within a chroot environment. * @param {object} params - The parameters for the function. * @param {string} params.nfsHostPath - The path to the NFS root filesystem on the host. * @param {'arm64'|'amd64'} params.debootstrapArch - The target architecture of the debootstrap environment. * @returns {void} */ crossArchBinFactory({ nfsHostPath, debootstrapArch }) { switch (debootstrapArch) { case 'arm64': // Copy QEMU static binary for ARM64. shellExec(`sudo podman cp extract:/usr/bin/qemu-aarch64-static ${nfsHostPath}/usr/bin/`); break; case 'amd64': // Copy QEMU static binary for AMD64. shellExec(`sudo podman cp extract:/usr/bin/qemu-x86_64-static ${nfsHostPath}/usr/bin/`); break; default: // Log a warning or throw an error for unsupported architectures. logger.warn(`Unsupported debootstrap architecture: ${debootstrapArch}`); break; } // Install GRUB EFI modules for both architectures to ensure compatibility. shellExec(`sudo dnf install grub2-efi-aa64-modules`); shellExec(`sudo dnf install grub2-efi-x64-modules`); }, /** * @method crossArchRunner * @description Executes a series of shell commands within a chroot environment, * optionally using QEMU for cross-architecture execution. * @param {object} params - The parameters for the function. * @param {string} params.nfsHostPath - The path to the NFS root filesystem on the host. * @param {'arm64'|'amd64'} params.debootstrapArch - The target architecture of the debootstrap environment. * @param {object} params.callbackMetaData - Metadata about the callback, including runner host architecture. * @param {string[]} params.steps - An array of shell commands to execute. * @returns {void} */ crossArchRunner({ nfsHostPath, debootstrapArch, callbackMetaData, steps }) { // Render the steps with logging for better visibility during execution. steps = UnderpostBaremetal.API.stepsRender(steps, false); let qemuCrossArchBash = ''; // Determine if QEMU is needed for cross-architecture execution. if (debootstrapArch !== callbackMetaData.runnerHost.architecture) switch (debootstrapArch) { case 'arm64': qemuCrossArchBash = '/usr/bin/qemu-aarch64-static '; break; case 'amd64': qemuCrossArchBash = '/usr/bin/qemu-x86_64-static '; break; default: // No QEMU prefix for unsupported or native architectures. break; } // Execute the commands within the chroot environment using a heredoc. shellExec(`sudo chroot ${nfsHostPath} ${qemuCrossArchBash}/bin/bash <<'EOF' ${steps} EOF`); }, /** * @method stepsRender * @description Renders an array of shell commands into a formatted string, * optionally including YAML-style formatting and execution logging. * This helps in visualizing and debugging the execution flow of provisioning steps. * @param {string[]} [steps=[]] - An array of shell commands. * @param {boolean} [yaml=true] - If true, formats the output as YAML list items. * @returns {string} The formatted string of commands. */ stepsRender(steps = [], yaml = true) { return steps .map( (step, i, a) => // Add a timestamp and step counter for better logging and traceability. (yaml ? ' - ' : '') + 'echo "' + (yaml ? '\\' : '') + '$(date) | ' + (i + 1) + '/' + a.length + ' - ' + step.split('\n')[0] + '"' + `\n` + `${yaml ? ' - ' : ''}${step}`, ) .join('\n'); }, /** * @method nfsMountCallback * @description Manages NFS mounts and unmounts for the baremetal provisioning process. * It checks the mount status and performs mount/unmount operations as requested. * @param {object} params - The parameters for the function. * @param {string} params.hostname - The hostname of the target machine. * @param {string} params.workflowId - The identifier for the workflow configuration. * @param {boolean} [params.mount] - If true, attempts to mount the NFS paths. * @param {boolean} [params.unmount] - If true, attempts to unmount the NFS paths. * @returns {{isMounted: boolean}} An object indicating whether any NFS path is currently mounted. */ nfsMountCallback({ hostname, workflowId, mount, unmount }) { let isMounted = false; // Iterate through defined NFS mounts in the workflow configuration. for (const mountCmd of Object.keys(UnderpostBaremetal.API.workflowsConfig[workflowId].nfs.mounts)) { for (const mountPath of UnderpostBaremetal.API.workflowsConfig[workflowId].nfs.mounts[mountCmd]) { const hostMountPath = `${process.env.NFS_EXPORT_PATH}/${hostname}${mountPath}`; // Check if the path is already mounted using `mountpoint` command. const isPathMounted = !shellExec(`mountpoint ${hostMountPath}`, { silent: true, stdout: true }).match( 'not a mountpoint', ); if (isPathMounted) { if (!isMounted) isMounted = true; // Set overall mounted status. logger.warn('Nfs path already mounted', mountPath); if (unmount === true) { // Unmount if requested. shellExec(`sudo umount ${hostMountPath}`); } } else { if (mount === true) { // Mount if requested and not already mounted. shellExec(`sudo mount --${mountCmd} ${mountPath} ${hostMountPath}`); } else { logger.warn('Nfs path not mounted', mountPath); } } } } return { isMounted }; }, /** * @method getHostArch * @description Determines the architecture of the host machine. * This is crucial for cross-compilation and selecting the correct QEMU binaries. * @returns {'amd64'|'arm64'} The host architecture. * @throws {Error} If the host architecture is unsupported. */ getHostArch() { // `uname -m` returns e.g. 'x86_64' or 'aarch64' const machine = shellExec('uname -m', { stdout: true }).trim(); if (machine === 'x86_64') return { alias: 'amd64', name: 'x86_64' }; if (machine === 'aarch64') return { alias: 'arm64', name: 'aarch64' }; throw new Error(`Unsupported host architecture: ${machine}`); }, /** * @property {object} systemProvisioningFactory * @description A factory object containing functions for system provisioning based on OS type. * Each OS type (e.g., 'ubuntu') provides methods for base system setup, user creation, * timezone configuration, and keyboard layout settings. */ systemProvisioningFactory: { /** * @property {object} ubuntu * @description Provisioning steps for Ubuntu-based systems. */ ubuntu: { /** * @method base * @description Generates shell commands for basic Ubuntu system provisioning. * This includes updating package lists, installing essential build tools, * kernel modules, cloud-init, SSH server, and other core utilities. * @param {object} params - The parameters for the function. * @param {string} params.kernelLibVersion - The specific kernel library version to install. * @returns {string[]} An array of shell commands. */ base: ({ kernelLibVersion }) => [ // Configure APT sources for Ubuntu ports. `cat <<SOURCES | tee /etc/apt/sources.list deb http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse deb http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse deb http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse SOURCES`, // Update package lists and perform a full system upgrade. `apt update -qq`, `apt -y full-upgrade`, // Install essential development and system utilities. `apt install -y build-essential xinput x11-xkb-utils usbutils uuid-runtime`, 'apt install -y linux-image-generic', // Install specific kernel modules. `apt install -y linux-modules-${kernelLibVersion} linux-modules-extra-${kernelLibVersion}`, `depmod -a ${kernelLibVersion}`, // Update kernel module dependencies. // Install cloud-init, systemd, SSH, sudo, locales, udev, and networking tools. `apt install -y cloud-init systemd-sysv openssh-server sudo locales udev util-linux systemd-sysv iproute2 netplan.io ca-certificates curl wget chrony`, `ln -sf /lib/systemd/systemd /sbin/init`, // Ensure systemd is the init system. `apt-get update`, `DEBIAN_FRONTEND=noninteractive apt-get install -y apt-utils`, // Install apt-utils non-interactively. `DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata kmod keyboard-configuration console-setup iputils-ping`, // Install timezone data, kernel modules, and network tools. ], /** * @method user * @description Generates shell commands for creating a root user and configuring SSH access. * This is a critical security step for initial access to the provisioned system. * @returns {string[]} An array of shell commands. */ user: () => [ `useradd -m -s /bin/bash -G sudo root`, // Create a root user with bash shell and sudo privileges. `echo 'root:root' | chpasswd`, // Set a default password for the root user (consider more secure methods for production). `mkdir -p /home/root/.ssh`, // Create .ssh directory for authorized keys. // Add the public SSH key to authorized_keys for passwordless login. `echo '${fs.readFileSync( `/home/dd/engine/engine-private/deploy/id_rsa.pub`, 'utf8', )}' > /home/root/.ssh/authorized_keys`, `chown -R root /home/root/.ssh`, // Set ownership for security. `chmod 700 /home/root/.ssh`, // Set permissions for the .ssh directory. `chmod 600 /home/root/.ssh/authorized_keys`, // Set permissions for authorized_keys. ], /** * @method timezone * @description Generates shell commands for configuring the system timezone and Chrony (NTP client). * Accurate time synchronization is essential for logging, security, and distributed systems. * @param {object} params - The parameters for the function. * @param {string} params.timezone - The timezone string (e.g., 'America/New_York'). * @param {string} params.chronyConfPath - The path to the Chrony configuration file. * @param {string} [alias='chrony'] - The alias for the chrony service. * @returns {string[]} An array of shell commands. */ timezone: ({ timezone, chronyConfPath }, alias = 'chrony') => [ `export DEBIAN_FRONTEND=noninteractive`, // Set non-interactive mode for Debian packages. `ln -fs /usr/share/zoneinfo/${timezone} /etc/localtime`, // Symlink timezone. `sudo dpkg-reconfigure --frontend noninteractive tzdata`, // Reconfigure timezone data. // Write the Chrony configuration file. `echo ' # Use public servers from the pool.ntp.org project. # Please consider joining the pool (http://www.pool.ntp.org/join.html). # pool 2.pool.ntp.org iburst server ${process.env.MAAS_NTP_SERVER} iburst # Record the rate at which the system clock gains/losses time. driftfile /var/lib/chrony/drift # Allow the system clock to be stepped in the first three updates # if its offset is larger than 1 second. makestep 1.0 3 # Enable kernel synchronization of the real-time clock (RTC). rtcsync # Enable hardware timestamping on all interfaces that support it. #hwtimestamp * # Increase the minimum number of selectable sources required to adjust # the system clock. #minsources 2 # Allow NTP client access from local network. #allow 192.168.0.0/16 # Serve time even if not synchronized to a time source. #local stratum 10 # Specify file containing keys for NTP authentication. keyfile /etc/chrony.keys # Get TAI-UTC offset and leap seconds from the system tz database. leapsectz right/UTC # Specify directory for log files. logdir /var/log/chrony # Select which information is logged. #log measurements statistics tracking ' > ${chronyConfPath}`, `systemctl stop ${alias}`, // Stop Chrony service before reconfiguring. // Enable, restart, and check status of Chrony service. `sudo systemctl enable --now ${alias}`, `sudo systemctl restart ${alias}`, `sudo systemctl status ${alias}`, // Verify Chrony synchronization. `chronyc sources`, `chronyc tracking`, `chronyc sourcestats -v`, // Display source statistics. `timedatectl status`, // Display current time and date settings. ], /** * @method keyboard * @description Generates shell commands for configuring the keyboard layout. * This ensures correct input behavior on the provisioned system. * @returns {string[]} An array of shell commands. */ keyboard: () => [ `sudo locale-gen en_US.UTF-8`, // Generate the specified locale. `sudo update-locale LANG=en_US.UTF-8`, // Update system locale. `sudo sed -i 's/XKBLAYOUT="us"/XKBLAYOUT="es"/' /etc/default/keyboard`, // Change keyboard layout to Spanish. `sudo dpkg-reconfigure --frontend noninteractive keyboard-configuration`, // Reconfigure keyboard non-interactively. `sudo systemctl restart keyboard-setup.service`, // Restart keyboard setup service. ], }, }, /** * @method rebuildNfsServer * @description Configures and restarts the NFS server to export the specified path. * This is crucial for allowing baremetal machines to boot via NFS. * @param {object} params - The parameters for the function. * @param {string} params.nfsHostPath - The path to be exported by the NFS server. * @param {string} [params.subnet='192.168.1.0/24'] - The subnet allowed to access the NFS export. * @returns {void} */ rebuildNfsServer({ nfsHostPath, subnet }) { if (!subnet) subnet = '192.168.1.0/24'; // Default subnet if not provided. // Write the NFS exports configuration to /etc/exports. fs.writeFileSync( `/etc/exports`, `${nfsHostPath} ${subnet}(${[ 'rw', // Read-write access. // 'all_squash', // Squash all client UIDs/GIDs to anonymous. 'sync', // Synchronous writes. 'no_root_squash', // Do not squash root user. 'no_subtree_check', // Disable subtree checking. 'insecure', // Allow connections from non-privileged ports. ]})`, 'utf8', ); logger.info('Writing NFS server configuration to /etc/nfs.conf...'); // Write NFS daemon configuration, including port settings. fs.writeFileSync( `/etc/nfs.conf`, `[mountd] port = 20048 [statd] port = 32765 outgoing-port = 32765 [nfsd] # Enable RDMA support if desired and hardware supports it. rdma=y rdma-port=20049 [lockd] port = 32766 udp-port = 32766 `, 'utf8', ); logger.info('NFS configuration written.'); logger.info('Reloading NFS exports...'); shellExec(`sudo exportfs -rav`); // Display the currently active NFS exports for verification. logger.info('Displaying active NFS exports:'); shellExec(`sudo exportfs -s`); // Restart the nfs-server service to apply all configuration changes, // including port settings from /etc/nfs.conf and export changes. logger.info('Restarting nfs-server service...'); shellExec(`sudo systemctl restart nfs-server`); logger.info('NFS server restarted.'); }, /** * @method bootConfFactory * @description Generates the boot configuration file for specific workflows, * primarily for Raspberry Pi 4 Model B. This configuration includes TFTP settings, * MAC address override, and static IP configuration. * @param {object} params - The parameters for the function. * @param {string} params.workflowId - The identifier for the specific workflow. * @param {string} params.tftpIp - The IP address of the TFTP server. * @param {string} params.tftpPrefixStr - The TFTP prefix string for boot files. * @param {string} params.macAddress - The MAC address to be set for the device. * @param {string} params.clientIp - The static IP address for the client device. * @param {string} params.subnet - The subnet mask for the client device. * @param {string} params.gateway - The gateway IP address for the client device. * @returns {string} The generated boot configuration content. * @throws {Error} If an invalid workflow ID is provided. */ bootConfFactory({ workflowId, tftpIp, tftpPrefixStr, macAddress, clientIp, subnet, gateway }) { switch (workflowId) { case 'rpi4mb': return `[all] BOOT_UART=0 WAKE_ON_GPIO=1 POWER_OFF_ON_HALT=0 ENABLE_SELF_UPDATE=1 DISABLE_HDMI=0 NET_INSTALL_ENABLED=1 DHCP_TIMEOUT=45000 DHCP_REQ_TIMEOUT=4000 TFTP_FILE_TIMEOUT=30000 BOOT_ORDER=0x21 # ───────────────────────────────────────────────────────────── # TFTP configuration # ───────────────────────────────────────────────────────────── #