@underpostnet/underpost
Version:
538 lines (481 loc) • 19.2 kB
JavaScript
import dotenv from 'dotenv';
import { shellExec } from '../server/process.js';
import fs from 'fs-extra';
import UnderpostBaremetal from './baremetal.js';
import { loggerFactory } from '../server/logger.js';
import { getNpmRootPath } from '../server/conf.js';
dotenv.config();
const logger = loggerFactory(import.meta);
/**
* @class UnderpostCloudInit
* @description Manages the generation and deployment of cloud-init configuration files
* and associated scripts for baremetal provisioning. This class provides methods
* to build various shell scripts and a cloud-init configuration file tailored
* for MAAS (Metal as a Service) integration.
*/
class UnderpostCloudInit {
static API = {
/**
* @method buildTools
* @description Builds and writes various shell scripts and configuration files
* to the NFS host path, which are then used by the target baremetal machine
* during the cloud-init process.
* @param {object} params - The parameters for building the tools.
* @param {string} params.workflowId - The identifier for the specific workflow configuration.
* @param {string} params.nfsHostPath - The base path on the NFS host where tools will be written.
* @param {string} params.hostname - The hostname of the target baremetal machine.
* @param {object} params.callbackMetaData - Metadata about the callback, used for dynamic configuration.
* @param {boolean} params.dev - Development mode flag.
* @returns {void}
*/
buildTools({ workflowId, nfsHostPath, hostname, callbackMetaData, dev }) {
// Destructure workflow configuration for easier access.
const { systemProvisioning, chronyc, networkInterfaceName, debootstrap } =
UnderpostBaremetal.API.workflowsConfig[workflowId];
const { timezone, chronyConfPath } = chronyc;
// Define the specific directory for underpost tools within the NFS host path.
const nfsHostToolsPath = `${nfsHostPath}/underpost`;
// Determine the root path for npm and underpost based on development mode.
const npmRoot = getNpmRootPath();
const underpostRoot = dev === true ? '.' : `${npmRoot}/underpost`;
// Use a switch statement to handle different system provisioning types.
switch (systemProvisioning) {
case 'ubuntu': {
// Ensure the target directory for tools is clean and exists.
if (fs.existsSync(`${nfsHostToolsPath}`)) fs.removeSync(`${nfsHostToolsPath}`);
fs.mkdirSync(`${nfsHostToolsPath}`, { recursive: true });
// Build and write the date configuration script.
logger.info('Build', `${nfsHostToolsPath}/date.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/date.sh`,
UnderpostBaremetal.API.stepsRender(
UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].timezone({
timezone,
chronyConfPath,
}),
false,
),
'utf8',
);
// Build and write the keyboard configuration script.
logger.info('Build', `${nfsHostToolsPath}/keyboard.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/keyboard.sh`,
UnderpostBaremetal.API.stepsRender(
UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].keyboard(),
false,
),
'utf8',
);
// Build and write the hosts file configuration script.
logger.info('Build', `${nfsHostToolsPath}/host.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/host.sh`,
`echo -e "127.0.0.1 localhost\n127.0.1.1 ${hostname}" | tee -a /etc/hosts`,
'utf8',
);
// Build and write the DNS configuration script.
logger.info('Build', `${nfsHostToolsPath}/dns.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/dns.sh`,
`rm /etc/resolv.conf
echo 'nameserver 8.8.8.8' > /run/systemd/resolve/stub-resolv.conf
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf`,
'utf8',
);
// Build and write the main startup script for cloud-init.
logger.info('Build', `${nfsHostToolsPath}/start.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/start.sh`,
`#!/bin/bash
set -x
# sudo cloud-init --all-stages
${UnderpostBaremetal.API.stepsRender(
[
`/underpost/date.sh`,
`sleep 3`,
`/underpost/reset.sh`,
`sleep 3`,
`cloud-init init --local`,
`sleep 3`,
`cloud-init init`,
`sleep 3`,
`cloud-init modules --mode=config`,
`sleep 3`,
`cloud-init modules --mode=final`,
`sleep 3`,
`/underpost/enlistment.sh`,
],
false,
)}`,
'utf8',
);
// Build and write the cloud-init reset script.
logger.info('Build', `${nfsHostToolsPath}/reset.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/reset.sh`,
`sudo cloud-init clean --seed --configs all --machine-id # --logs
sudo rm -rf /var/lib/cloud/*
echo '' > /var/log/cloud-init.log
echo '' > /var/log/cloud-init-output.log`,
'utf8',
);
// Build and write the cloud-init help script.
logger.info('Build', `${nfsHostToolsPath}/help.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/help.sh`,
`echo "=== Cloud init utils ==="
echo "sudo cloud-init --all-stages"
echo "sudo cloud-init clean --logs --seed --configs all --machine-id --reboot"
echo "sudo cloud-init init --local"
echo "sudo cloud-init init"
echo "sudo cloud-init modules --mode=config"
echo "sudo cloud-init modules --mode=final"`,
'utf8',
);
// Build and write the test script for verifying configuration.
logger.info('Build', `${nfsHostToolsPath}/test.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/test.sh`,
`echo -e "\n=== Current date/time ==="
date '+%Y-%m-%d %H:%M:%S'
echo -e "\n=== Keyboard layout ==="
cat /etc/default/keyboard
echo -e "\n=== Registered users ==="
cut -d: -f1 /etc/passwd`,
'utf8',
);
// Build and write the shutdown script.
logger.info('Build', `${nfsHostToolsPath}/shutdown.sh`);
fs.writeFileSync(`${nfsHostToolsPath}/shutdown.sh`, `sudo shutdown -h now`, 'utf8');
// Build and write the MAC address retrieval script.
logger.info('Build', `${nfsHostToolsPath}/mac.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/mac.sh`,
`echo "$(cat /sys/class/net/${networkInterfaceName}/address)" > /underpost/mac`,
'utf8',
);
// Copy the device scan script from manifests.
logger.info('Build', `${nfsHostToolsPath}/device_scan.sh`);
fs.copySync(`${underpostRoot}/manifests/maas/device-scan.sh`, `${nfsHostToolsPath}/device_scan.sh`);
// Build and write the config path script.
logger.info('Build', `${nfsHostToolsPath}/config-path.sh`);
fs.writeFileSync(`${nfsHostToolsPath}/config-path.sh`, `echo "/etc/cloud/cloud.cfg.d/90_maas.cfg"`, 'utf8');
// Build and write the MAAS enlistment script.
logger.info('Build', `${nfsHostToolsPath}/enlistment.sh`);
fs.writeFileSync(
`${nfsHostToolsPath}/enlistment.sh`,
`#!/bin/bash
set -x
# ------------------------------------------------------------
# Step: Commission a machine in MAAS using OAuth1 authentication
# ------------------------------------------------------------
MACHINE_ID=$(cat /underpost/system-id)
CONSUMER_KEY=$(cat /underpost/consumer-key)
TOKEN_KEY=$(cat /underpost/token-key)
TOKEN_SECRET=$(cat /underpost/token-secret)
echo ">>> Starting MAAS machine commissioning for system_id: $MACHINE_ID …"
curl -X POST \\
--fail --location --verbose --include --raw --trace-ascii /dev/stdout\\
--header "Authorization:\\
OAuth oauth_version=1.0,\\
oauth_signature_method=PLAINTEXT,\\
oauth_consumer_key=$CONSUMER_KEY,\\
oauth_token=$TOKEN_KEY,\\
oauth_signature=&$TOKEN_SECRET,\\
oauth_nonce=$(uuidgen),\\
oauth_timestamp=$(date +%s)"\\
-F "commissioning_scripts=20-maas-01-install-lldpd"\\
-F "enable_ssh=1"\\
-F "skip_bmc_config=1"\\
-F "skip_networking=1"\\
-F "skip_storage=1"\\
-F "testing_scripts=none"\\
http://${callbackMetaData.runnerHost.ip}:5240/MAAS/api/2.0/machines/$MACHINE_ID/op-commission \\
2>&1 | tee /underpost/enlistment.log || echo "ERROR: MAAS commissioning returned code $?"`,
'utf8',
);
// Import SSH keys for root user.
logger.info('Import ssh keys');
shellExec(`sudo rm -rf ${nfsHostPath}/root/.ssh`);
shellExec(`sudo rm -rf ${nfsHostPath}/home/root/.ssh`); // Ensure home root .ssh is also clean.
logger.info('Copy', `/root/.ssh -> ${nfsHostPath}/root/.ssh`);
fs.copySync(`/root/.ssh`, `${nfsHostPath}/root/.ssh`);
// Enable execution permissions for all generated scripts and run a test.
logger.info('Enable tools execution and test');
UnderpostBaremetal.API.crossArchRunner({
nfsHostPath,
debootstrapArch: debootstrap.image.architecture,
callbackMetaData,
steps: [
`chmod +x /underpost/date.sh`,
`chmod +x /underpost/keyboard.sh`,
`chmod +x /underpost/dns.sh`,
`chmod +x /underpost/help.sh`,
`chmod +x /underpost/config-path.sh`,
`chmod +x /underpost/host.sh`,
`chmod +x /underpost/test.sh`,
`chmod +x /underpost/start.sh`,
`chmod +x /underpost/reset.sh`,
`chmod +x /underpost/shutdown.sh`,
`chmod +x /underpost/device_scan.sh`,
`chmod +x /underpost/mac.sh`,
`chmod +x /underpost/enlistment.sh`,
`sudo chmod 700 ~/.ssh/`, // Set secure permissions for .ssh directory.
`sudo chmod 600 ~/.ssh/authorized_keys`, // Set secure permissions for authorized_keys.
`sudo chmod 644 ~/.ssh/known_hosts`, // Set permissions for known_hosts.
`sudo chmod 600 ~/.ssh/id_rsa`, // Set secure permissions for private key.
`sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`, // Set secure permissions for host key.
`chown -R root:root ~/.ssh`, // Ensure root owns the .ssh directory.
`/underpost/test.sh`, // Run the test script to verify setup.
],
});
break;
}
default:
// Throw an error if an unsupported system provisioning type is provided.
throw new Error('Invalid system provisioning: ' + systemProvisioning);
}
},
/**
* @method configFactory
* @description Generates the cloud-init configuration file (`90_maas.cfg`)
* for MAAS integration. This configuration includes hostname, network settings,
* user accounts, SSH keys, timezone, NTP, and various cloud-init modules.
* @param {object} params - The parameters for generating the configuration.
* @param {string} params.controlServerIp - The IP address of the MAAS control server.
* @param {string} params.hostname - The hostname of the target baremetal machine.
* @param {string} params.commissioningDeviceIp - The IP address to assign to the commissioning device.
* @param {string} params.gatewayip - The gateway IP address for the network.
* @param {boolean} params.auth - Flag indicating whether to include MAAS authentication credentials.
* @param {string} params.mac - The MAC address of the network interface.
* @param {string} params.timezone - The timezone to set for the machine.
* @param {string} params.chronyConfPath - The path to the Chrony configuration file.
* @param {string} params.networkInterfaceName - The name of the primary network interface.
* @param {object} [authCredentials={}] - Optional MAAS authentication credentials.
* @param {string} [path='/etc/cloud/cloud.cfg.d/90_maas.cfg'] - The target path for the cloud-init configuration file.
* @returns {string} The generated cloud-init configuration content.
*/
configFactory(
{
controlServerIp,
hostname,
commissioningDeviceIp,
gatewayip,
auth,
mac,
timezone,
chronyConfPath,
networkInterfaceName,
},
authCredentials = { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' },
path = '/etc/cloud/cloud.cfg.d/90_maas.cfg',
) {
const { consumer_key, consumer_secret, token_key, token_secret } = authCredentials;
// Configure cloud-init for MAAS using a heredoc string.
return `cat <<EOF_MAAS_CFG > ${path}
#cloud-config
hostname: ${hostname}
fqdn: ${hostname}.maas
# prefer_fqdn_over_hostname: true
# metadata_url: http://${controlServerIp}:5240/MAAS/metadata
# metadata_url: http://${controlServerIp}:5248/MAAS/metadata
# Check:
# /MAAS/metadata/latest/enlist-preseed/?op=get_enlist_preseed
# Debug:
# https://maas.io/docs/how-to-use-logging
datasource_list: [ MAAS ]
datasource:
MAAS:
metadata_url: http://${controlServerIp}:5240/MAAS/metadata/
${
// Conditionally include authentication details if 'auth' flag is true.
!auth
? ''
: `consumer_key: ${consumer_key}
consumer_secret: ${consumer_secret}
token_key: ${token_key}
token_secret: ${token_secret}`
}
users:
- name: ${process.env.MAAS_ADMIN_USERNAME}
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
shell: /bin/bash
lock_passwd: false
groups: sudo,users,admin,wheel,lxd
plain_text_passwd: '${process.env.MAAS_ADMIN_USERNAME}'
ssh_authorized_keys:
- ${fs.readFileSync(`/home/dd/engine/engine-private/deploy/id_rsa.pub`, 'utf8')}
# manage_resolv_conf: true
# resolv_conf:
# nameservers: [8.8.8.8]
# keyboard:
# layout: es
# check timedatectl on hostname
# timezone: America/Santiago
timezone: ${timezone}
ntp:
enabled: true
servers:
- ${process.env.MAAS_NTP_SERVER}
ntp_client: chrony
config:
confpath: ${chronyConfPath}
# ssh:
# allow-pw: false
# install-server: true
# ssh_pwauth: false
package_update: true
package_upgrade: true
packages:
- git
- htop
- snapd
- chrony
- lldpd
- lshw
resize_rootfs: false
growpart:
mode: "off"
network:
version: 2
ethernets:
${networkInterfaceName}:
match:
macaddress: "${mac}"
mtu: 1500
set-name: ${networkInterfaceName}
dhcp4: false
addresses:
- ${commissioningDeviceIp}/24
routes:
- to: default
via: ${gatewayip}
# gateway4: ${gatewayip}
nameservers:
addresses:
- ${process.env.MAAS_DNS}
final_message: "====== Cloud init finished ======"
# power_state:
# mode: reboot
# message: Rebooting after initial setup
# timeout: 30
# condition: True
bootcmd:
- echo "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"
- echo "Init bootcmd"
- echo "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"
${UnderpostBaremetal.API.stepsRender(
[`/underpost/dns.sh`, `/underpost/host.sh`, `/underpost/mac.sh`, `cat /underpost/mac`],
true,
)}
runcmd:
- echo "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"
- echo "Init runcmd"
- echo "- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"
# If this is set, 'root' will not be able to ssh in and they
# will get a message to login instead as the default $user
disable_root: true
# This will cause the set+update hostname module to not operate (if true)
preserve_hostname: false
# The modules that run in the 'init' stage
cloud_init_modules:
- migrator
- seed_random
- bootcmd
- write-files
- growpart
- resizefs
- set_hostname
- update_hostname
- update_etc_hosts
- ca-certs
- rsyslog
- users-groups
- ssh
# The modules that run in the 'config' stage
cloud_config_modules:
# Emit the cloud config ready event
# this can be used by upstart jobs for 'start on cloud-config'.
- emit_upstart
- disk_setup
- mounts
- ssh-import-id
- locale
- set-passwords
- grub-dpkg
- apt-pipelining
- apt-configure
- package-update-upgrade-install
- landscape
- timezone
- puppet
- chef
- salt-minion
- mcollective
- disable-ec2-metadata
- runcmd
- byobu
- ssh-import-id
- ntp
# phone_home:
# url: "http://${controlServerIp}:5240/MAAS/metadata/v1/?op=phone_home"
# post: all
# tries: 3
# The modules that run in the 'final' stage
cloud_final_modules:
- rightscale_userdata
- scripts-vendor
- scripts-per-once
- scripts-per-boot
# - scripts-per-instance
- scripts-user
- ssh-authkey-fingerprints
- keys-to-console
# - phone-home
- final-message
- power-state-change
EOF_MAAS_CFG`;
},
/**
* @method authCredentialsFactory
* @description Retrieves MAAS API key credentials from the MAAS CLI.
* This method parses the output of `maas apikey` to extract the consumer key,
* consumer secret, token key, and token secret.
* @returns {object} An object containing the MAAS authentication credentials.
* @throws {Error} If the MAAS API key format is invalid.
*/
authCredentialsFactory() {
// Expected formats:
// <consumer_key>:<consumer_token>:<secret> (older format)
// <consumer_key>:<consumer_secret>:<token_key>:<token_secret> (newer format)
// Commands used to generate API keys:
// maas apikey --with-names --username ${process.env.MAAS_ADMIN_USERNAME}
// maas ${process.env.MAAS_ADMIN_USERNAME} account create-authorisation-token
// maas apikey --generate --username ${process.env.MAAS_ADMIN_USERNAME}
// Reference: https://github.com/CanonicalLtd/maas-docs/issues/647
const parts = shellExec(`maas apikey --with-names --username ${process.env.MAAS_ADMIN_USERNAME}`, {
stdout: true,
})
.trim()
.split(`\n`)[0] // Take only the first line of output.
.split(':'); // Split by colon to get individual parts.
let consumer_key, consumer_secret, token_key, token_secret;
// Determine the format of the API key and assign parts accordingly.
if (parts.length === 4) {
[consumer_key, consumer_secret, token_key, token_secret] = parts;
} else if (parts.length === 3) {
// Handle older 3-part format, setting consumer_secret as empty.
[consumer_key, token_key, token_secret] = parts;
consumer_secret = '""';
token_secret = token_secret.split(' MAAS consumer')[0].trim(); // Clean up token secret.
} else {
// Throw an error if the format is not recognized.
throw new Error('Invalid token format');
}
logger.info('Maas api token generated', { consumer_key, consumer_secret, token_key, token_secret });
return { consumer_key, consumer_secret, token_key, token_secret };
},
};
}
export default UnderpostCloudInit;