@underpostnet/underpost 2.99.4 → 2.99.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,13 +8,14 @@ import { fileURLToPath } from 'url';
8
8
  import { getNpmRootPath, getUnderpostRootPath } from '../server/conf.js';
9
9
  import { openTerminal, pbcopy, shellExec } from '../server/process.js';
10
10
  import dotenv from 'dotenv';
11
- import { loggerFactory } from '../server/logger.js';
11
+ import { loggerFactory, loggerMiddleware } from '../server/logger.js';
12
12
  import fs from 'fs-extra';
13
13
  import path from 'path';
14
14
  import Downloader from '../server/downloader.js';
15
15
  import { newInstance, range, s4, timer } from '../client/components/core/CommonJs.js';
16
16
  import { spawnSync } from 'child_process';
17
17
  import Underpost from '../index.js';
18
+ import express from 'express';
18
19
 
19
20
  const logger = loggerFactory(import.meta);
20
21
 
@@ -27,19 +28,6 @@ const logger = loggerFactory(import.meta);
27
28
  */
28
29
  class UnderpostBaremetal {
29
30
  static API = {
30
- /**
31
- * @method installPacker
32
- * @description Installs Packer CLI.
33
- * @memberof UnderpostBaremetal
34
- * @returns {Promise<void>}
35
- */
36
- async installPacker(underpostRoot) {
37
- const scriptPath = `${underpostRoot}/scripts/packer-setup.sh`;
38
- logger.info(`Installing Packer using script: ${scriptPath}`);
39
- shellExec(`sudo chmod +x ${scriptPath}`);
40
- shellExec(`sudo ${scriptPath}`);
41
- },
42
-
43
31
  /**
44
32
  * @method callback
45
33
  * @description Initiates a baremetal provisioning workflow based on the provided options.
@@ -64,6 +52,7 @@ class UnderpostBaremetal {
64
52
  * @param {string} [options.mac=''] - MAC address of the baremetal machine.
65
53
  * @param {boolean} [options.ipxe=false] - Flag to use iPXE for booting.
66
54
  * @param {boolean} [options.ipxeRebuild=false] - Flag to rebuild the iPXE binary with embedded script.
55
+ * @param {string} [options.ipxeBuildIso=''] - Builds a standalone iPXE ISO with embedded script for the specified workflow ID.
67
56
  * @param {boolean} [options.installPacker=false] - Flag to install Packer CLI.
68
57
  * @param {string} [options.packerMaasImageTemplate] - Template path from canonical/packer-maas to extract (requires workflow-id).
69
58
  * @param {string} [options.packerWorkflowId] - Workflow ID for Packer MAAS image operations (used with --packer-maas-image-build or --packer-maas-image-upload).
@@ -111,6 +100,7 @@ class UnderpostBaremetal {
111
100
  mac: '',
112
101
  ipxe: false,
113
102
  ipxeRebuild: false,
103
+ ipxeBuildIso: '',
114
104
  installPacker: false,
115
105
  packerMaasImageTemplate: false,
116
106
  packerWorkflowId: '',
@@ -194,6 +184,47 @@ class UnderpostBaremetal {
194
184
  // Define the TFTP root prefix path based
195
185
  const tftpRootPath = `${process.env.TFTP_ROOT}/${tftpPrefix}`;
196
186
 
187
+ if (options.ipxeBuildIso) {
188
+ let machine = null;
189
+
190
+ if (options.cloudInit) {
191
+ // Search for an existing machine by hostname to extract system_id for cloud-init
192
+ const [searchMachine] = Underpost.baremetal.maasCliExec(`machines read hostname=${hostname}`);
193
+
194
+ if (searchMachine) {
195
+ logger.info(`Found existing machine ${hostname} with system_id ${searchMachine.system_id}`);
196
+ machine = searchMachine;
197
+ } else {
198
+ // Machine does not exist, create it to obtain a system_id
199
+ logger.info(`Machine ${hostname} not found, creating new machine for cloud-init system_id...`);
200
+ machine = Underpost.baremetal.machineFactory({
201
+ hostname,
202
+ ipAddress,
203
+ macAddress,
204
+ architecture: workflowsConfig[workflowId].architecture,
205
+ }).machine;
206
+ logger.info(`✓ Machine created with system_id ${machine.system_id}`);
207
+ }
208
+ }
209
+
210
+ await Underpost.baremetal.ipxeBuildIso({
211
+ workflowId,
212
+ isoOutputPath: options.ipxeBuildIso,
213
+ tftpPrefix,
214
+ ipFileServer,
215
+ ipAddress,
216
+ ipConfig,
217
+ netmask,
218
+ dnsServer,
219
+ macAddress,
220
+ cloudInit: options.cloudInit,
221
+ machine,
222
+ dev: options.dev,
223
+ bootstrapHttpServerPort: options.bootstrapHttpServerPort,
224
+ });
225
+ return;
226
+ }
227
+
197
228
  // Define the iPXE cache directory to preserve builds across tftproot cleanups
198
229
  const ipxeCacheDir = `/tmp/ipxe-cache/${tftpPrefix}`;
199
230
 
@@ -218,12 +249,7 @@ class UnderpostBaremetal {
218
249
  // Create a new machine in MAAS if the option is set.
219
250
  let machine;
220
251
  if (options.createMachine === true) {
221
- const [searhMachine] = JSON.parse(
222
- shellExec(`maas maas machines read hostname=${hostname}`, {
223
- stdout: true,
224
- silent: true,
225
- }),
226
- );
252
+ const [searhMachine] = Underpost.baremetal.maasCliExec(`machines read hostname=${hostname}`);
227
253
 
228
254
  if (searhMachine) {
229
255
  // Check if existing machine's MAC matches the specified MAC
@@ -238,16 +264,14 @@ class UnderpostBaremetal {
238
264
  logger.info(`Deleting existing machine ${searhMachine.system_id} to recreate with correct MAC...`);
239
265
 
240
266
  // Delete the existing machine
241
- shellExec(`maas maas machine delete ${searhMachine.system_id}`, {
242
- silent: true,
243
- });
267
+ Underpost.baremetal.maasCliExec(`machine delete ${searhMachine.system_id}`);
244
268
 
245
269
  // Create new machine with correct MAC
246
270
  machine = Underpost.baremetal.machineFactory({
247
271
  hostname,
248
272
  ipAddress,
249
273
  macAddress,
250
- maas: workflowsConfig[workflowId].maas,
274
+ architecture: workflowsConfig[workflowId].architecture,
251
275
  }).machine;
252
276
 
253
277
  logger.info(`✓ Machine recreated with MAC ${macAddress}`);
@@ -266,7 +290,7 @@ class UnderpostBaremetal {
266
290
  hostname,
267
291
  ipAddress,
268
292
  macAddress,
269
- maas: workflowsConfig[workflowId].maas,
293
+ architecture: workflowsConfig[workflowId].architecture,
270
294
  }).machine;
271
295
  }
272
296
  }
@@ -351,7 +375,7 @@ class UnderpostBaremetal {
351
375
 
352
376
  // Build phase (skip if upload-only mode)
353
377
  if (options.packerMaasImageBuild) {
354
- if (shellExec('packer version', { silent: true }).code !== 0) {
378
+ if (shellExec('packer version').code !== 0) {
355
379
  throw new Error('Packer is not installed. Please install Packer to proceed.');
356
380
  }
357
381
 
@@ -735,12 +759,7 @@ rm -rf ${artifacts.join(' ')}`);
735
759
 
736
760
  // Fetch boot resources and machines if commissioning or listing.
737
761
 
738
- let resources = JSON.parse(
739
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resources read`, {
740
- silent: true,
741
- stdout: true,
742
- }),
743
- ).map((o) => ({
762
+ let resources = Underpost.baremetal.maasCliExec(`boot-resources read`).map((o) => ({
744
763
  id: o.id,
745
764
  name: o.name,
746
765
  architecture: o.architecture,
@@ -748,12 +767,7 @@ rm -rf ${artifacts.join(' ')}`);
748
767
  if (options.ls === true) {
749
768
  console.table(resources);
750
769
  }
751
- let machines = JSON.parse(
752
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machines read`, {
753
- stdout: true,
754
- silent: true,
755
- }),
756
- ).map((m) => ({
770
+ let machines = Underpost.baremetal.maasCliExec(`machines read`).map((m) => ({
757
771
  system_id: m.interface_set[0].system_id,
758
772
  mac_address: m.interface_set[0].mac_address,
759
773
  hostname: m.hostname,
@@ -875,10 +889,27 @@ rm -rf ${artifacts.join(' ')}`);
875
889
  });
876
890
  }
877
891
 
892
+ const authCredentials =
893
+ options.commission || options.cloudInit || options.cloudInitUpdate
894
+ ? Underpost.baremetal.maasAuthCredentialsFactory()
895
+ : { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' };
896
+
878
897
  if (options.cloudInit || options.cloudInitUpdate) {
879
898
  const { chronyc, networkInterfaceName } = workflowsConfig[workflowId];
880
899
  const { timezone, chronyConfPath } = chronyc;
881
- const authCredentials = Underpost.cloudInit.authCredentialsFactory();
900
+
901
+ let write_files = [];
902
+ let runcmd = options.runcmd;
903
+
904
+ if (machine && options.commission) {
905
+ write_files = Underpost.baremetal.commissioningWriteFilesFactory({
906
+ machine,
907
+ authCredentials,
908
+ runnerHostIp: callbackMetaData.runnerHost.ip,
909
+ });
910
+ runcmd = '/usr/local/bin/underpost-enlist.sh';
911
+ }
912
+
882
913
  const { cloudConfigSrc } = Underpost.cloudInit.configFactory(
883
914
  {
884
915
  controlServerIp: callbackMetaData.runnerHost.ip,
@@ -891,15 +922,16 @@ rm -rf ${artifacts.join(' ')}`);
891
922
  networkInterfaceName,
892
923
  ubuntuToolsBuild: options.ubuntuToolsBuild,
893
924
  bootcmd: options.bootcmd,
894
- runcmd: options.runcmd,
925
+ runcmd,
926
+ write_files,
895
927
  },
896
928
  authCredentials,
897
929
  );
898
-
899
930
  Underpost.baremetal.httpBootstrapServerStaticFactory({
900
931
  bootstrapHttpServerPath,
901
932
  hostname,
902
933
  cloudConfigSrc,
934
+ isoUrl: workflowsConfig[workflowId].isoUrl,
903
935
  });
904
936
  }
905
937
 
@@ -910,7 +942,7 @@ rm -rf ${artifacts.join(' ')}`);
910
942
  workflowsConfig[workflowId].type === 'chroot-debootstrap' ||
911
943
  workflowsConfig[workflowId].type === 'chroot-container')
912
944
  ) {
913
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
945
+ shellExec(`${underpostRoot}/scripts/nat-iptables.sh`);
914
946
  Underpost.baremetal.rebuildNfsServer({
915
947
  nfsHostPath,
916
948
  });
@@ -1004,15 +1036,22 @@ rm -rf ${artifacts.join(' ')}`);
1004
1036
  hostname,
1005
1037
  dnsServer,
1006
1038
  networkInterfaceName,
1007
- fileSystemUrl: kernelFilesPaths.isoUrl,
1008
- bootstrapHttpServerPort:
1009
- options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort || 8888,
1039
+ fileSystemUrl:
1040
+ type === 'iso-ram'
1041
+ ? `http://${callbackMetaData.runnerHost.ip}:${Underpost.baremetal.bootstrapHttpServerPortFactory({ port: options.bootstrapHttpServerPort, workflowId, workflowsConfig })}/${hostname}/${kernelFilesPaths.isoUrl.split('/').pop()}`
1042
+ : kernelFilesPaths.isoUrl,
1043
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
1044
+ port: options.bootstrapHttpServerPort,
1045
+ workflowId,
1046
+ workflowsConfig,
1047
+ }),
1010
1048
  type,
1011
1049
  macAddress,
1012
1050
  cloudInit: options.cloudInit,
1013
1051
  machine,
1014
1052
  dev: options.dev,
1015
1053
  osIdLike: workflowsConfig[workflowId].osIdLike || '',
1054
+ authCredentials,
1016
1055
  });
1017
1056
 
1018
1057
  // Check if iPXE mode is enabled AND the iPXE EFI binary exists
@@ -1086,8 +1125,11 @@ rm -rf ${artifacts.join(' ')}`);
1086
1125
  Underpost.baremetal.httpBootstrapServerRunnerFactory({
1087
1126
  hostname,
1088
1127
  bootstrapHttpServerPath,
1089
- bootstrapHttpServerPort:
1090
- options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort,
1128
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
1129
+ port: options.bootstrapHttpServerPort,
1130
+ workflowId,
1131
+ workflowsConfig,
1132
+ }),
1091
1133
  });
1092
1134
 
1093
1135
  if (type === 'chroot-debootstrap' || type === 'chroot-container')
@@ -1102,11 +1144,7 @@ rm -rf ${artifacts.join(' ')}`);
1102
1144
  macAddress,
1103
1145
  ipAddress,
1104
1146
  hostname,
1105
- architecture:
1106
- workflowsConfig[workflowId].maas?.commissioning?.architecture ||
1107
- workflowsConfig[workflowId].container?.architecture ||
1108
- workflowsConfig[workflowId].debootstrap?.image?.architecture ||
1109
- 'arm64/generic',
1147
+ architecture: Underpost.baremetal.fallbackArchitecture(workflowsConfig[workflowId]),
1110
1148
  machine,
1111
1149
  };
1112
1150
  logger.info('Waiting for commissioning...', {
@@ -1114,7 +1152,42 @@ rm -rf ${artifacts.join(' ')}`);
1114
1152
  machine: machine ? machine.system_id : null,
1115
1153
  });
1116
1154
 
1117
- const { discovery } = await Underpost.baremetal.commissionMonitor(commissionMonitorPayload);
1155
+ const { discovery, machine: discoveredMachine } =
1156
+ await Underpost.baremetal.commissionMonitor(commissionMonitorPayload);
1157
+ if (discoveredMachine) machine = discoveredMachine;
1158
+
1159
+ if (machine) {
1160
+ const write_files = Underpost.baremetal.commissioningWriteFilesFactory({
1161
+ machine,
1162
+ authCredentials,
1163
+ runnerHostIp: callbackMetaData.runnerHost.ip,
1164
+ });
1165
+
1166
+ const { cloudConfigSrc } = Underpost.cloudInit.configFactory(
1167
+ {
1168
+ controlServerIp: callbackMetaData.runnerHost.ip,
1169
+ hostname,
1170
+ commissioningDeviceIp: ipAddress,
1171
+ gatewayip: callbackMetaData.runnerHost.ip,
1172
+ mac: macAddress,
1173
+ timezone: workflowsConfig[workflowId].chronyc.timezone,
1174
+ chronyConfPath: workflowsConfig[workflowId].chronyc.chronyConfPath,
1175
+ networkInterfaceName: workflowsConfig[workflowId].networkInterfaceName,
1176
+ ubuntuToolsBuild: options.ubuntuToolsBuild,
1177
+ bootcmd: options.bootcmd,
1178
+ runcmd: '/usr/local/bin/underpost-enlist.sh',
1179
+ write_files,
1180
+ },
1181
+ authCredentials,
1182
+ );
1183
+
1184
+ Underpost.baremetal.httpBootstrapServerStaticFactory({
1185
+ bootstrapHttpServerPath,
1186
+ hostname,
1187
+ cloudConfigSrc,
1188
+ isoUrl: workflowsConfig[workflowId].isoUrl,
1189
+ });
1190
+ }
1118
1191
 
1119
1192
  if ((type === 'chroot-debootstrap' || type === 'chroot-container') && options.cloudInit === true) {
1120
1193
  openTerminal(`node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init`);
@@ -1128,6 +1201,164 @@ rm -rf ${artifacts.join(' ')}`);
1128
1201
  }
1129
1202
  },
1130
1203
 
1204
+ /**
1205
+ * @method installPacker
1206
+ * @description Installs Packer CLI.
1207
+ * @memberof UnderpostBaremetal
1208
+ * @returns {Promise<void>}
1209
+ */
1210
+ async installPacker(underpostRoot) {
1211
+ const scriptPath = `${underpostRoot}/scripts/packer-setup.sh`;
1212
+ logger.info(`Installing Packer using script: ${scriptPath}`);
1213
+ shellExec(`sudo chmod +x ${scriptPath}`);
1214
+ shellExec(`sudo ${scriptPath}`);
1215
+ },
1216
+
1217
+ /**
1218
+ * @method ipxeBuildIso
1219
+ * @description Builds a UEFI-bootable iPXE ISO with an embedded bridge script.
1220
+ * @param {object} params
1221
+ * @param {string} params.workflowId - The workflow identifier (e.g., 'hp-envy-iso-ram').
1222
+ * @param {string} params.isoOutputPath - Output path for the generated ISO file.
1223
+ * @param {string} params.tftpPrefix - TFTP prefix directory (e.g., 'envy').
1224
+ * @param {string} params.ipFileServer - IP address of the TFTP/file server to chain to.
1225
+ * @param {string} [params.ipAddress='192.168.1.191'] - The IP address of the client machine.
1226
+ * @param {string} [params.ipConfig='none'] - IP configuration method (e.g., 'dhcp', 'none').
1227
+ * @param {string} [params.netmask='255.255.255.0'] - The network mask.
1228
+ * @param {string} [params.dnsServer='8.8.8.8'] - The DNS server address.
1229
+ * @param {string} [params.macAddress=''] - The MAC address of the client machine.
1230
+ * @param {boolean} [params.cloudInit=false] - Flag to enable cloud-init.
1231
+ * @param {object} [params.machine=null] - The machine object containing system_id for cloud-init.
1232
+ * @param {boolean} [params.dev=false] - Development mode flag to determine paths.
1233
+ * @param {number} [params.bootstrapHttpServerPort=8888] - Port for the bootstrap HTTP server used in ISO RAM workflows.
1234
+ * @memberof UnderpostBaremetal
1235
+ * @returns {Promise<void>}
1236
+ */
1237
+ async ipxeBuildIso({
1238
+ workflowId,
1239
+ isoOutputPath,
1240
+ tftpPrefix,
1241
+ ipFileServer,
1242
+ ipAddress,
1243
+ ipConfig,
1244
+ netmask,
1245
+ dnsServer,
1246
+ macAddress,
1247
+ cloudInit,
1248
+ machine,
1249
+ dev,
1250
+ bootstrapHttpServerPort,
1251
+ }) {
1252
+ const outputPath = !isoOutputPath || isoOutputPath === '.' ? `./ipxe-${workflowId}.iso` : isoOutputPath;
1253
+ if (fs.existsSync(outputPath)) fs.removeSync(outputPath);
1254
+ shellExec(`mkdir -p $(dirname ${outputPath})`);
1255
+
1256
+ const workflowsConfig = Underpost.baremetal.loadWorkflowsConfig();
1257
+ if (!workflowsConfig[workflowId]) {
1258
+ throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
1259
+ }
1260
+
1261
+ const authCredentials = cloudInit
1262
+ ? Underpost.baremetal.maasAuthCredentialsFactory()
1263
+ : { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' };
1264
+
1265
+ const { cmd } = Underpost.baremetal.kernelCmdBootParamsFactory({
1266
+ ipClient: ipAddress,
1267
+ ipDhcpServer: ipFileServer,
1268
+ ipFileServer,
1269
+ ipConfig,
1270
+ netmask,
1271
+ hostname: workflowId,
1272
+ dnsServer,
1273
+ fileSystemUrl:
1274
+ dev && workflowsConfig[workflowId].type === 'iso-ram'
1275
+ ? `http://${ipFileServer}:${Underpost.baremetal.bootstrapHttpServerPortFactory({ port: bootstrapHttpServerPort, workflowId, workflowsConfig })}/${workflowId}/${workflowsConfig[workflowId].isoUrl.split('/').pop()}`
1276
+ : workflowsConfig[workflowId].isoUrl,
1277
+ type: workflowsConfig[workflowId].type,
1278
+ macAddress,
1279
+ cloudInit,
1280
+ machine,
1281
+ osIdLike: workflowsConfig[workflowId].osIdLike,
1282
+ networkInterfaceName: workflowsConfig[workflowId].networkInterfaceName,
1283
+ authCredentials,
1284
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
1285
+ port: bootstrapHttpServerPort,
1286
+ workflowId,
1287
+ workflowsConfig,
1288
+ }),
1289
+ dev,
1290
+ });
1291
+
1292
+ const ipxeSrcDir = '/home/dd/ipxe/src';
1293
+ const embedScriptName = `embed_${workflowId}.ipxe`;
1294
+ const embedScriptPath = path.join(ipxeSrcDir, embedScriptName);
1295
+
1296
+ const embedScriptContent = `#!ipxe
1297
+ dhcp
1298
+ set server_ip ${ipFileServer}
1299
+ set tftp_prefix ${tftpPrefix}
1300
+ kernel tftp://\${server_ip}/\${tftp_prefix}/pxe/vmlinuz-efi ${cmd}
1301
+ initrd tftp://\${server_ip}/\${tftp_prefix}/pxe/initrd.img
1302
+ boot || shell
1303
+ `;
1304
+
1305
+ fs.writeFileSync(embedScriptPath, embedScriptContent);
1306
+ logger.info(`Created embedded script at ${embedScriptPath}`);
1307
+
1308
+ // Determine target architecture
1309
+ let targetArch = 'x86_64'; // Default to x86_64
1310
+ if (
1311
+ workflowsConfig[workflowId].architecture === 'arm64' ||
1312
+ workflowsConfig[workflowId].architecture === 'aarch64'
1313
+ ) {
1314
+ targetArch = 'arm64';
1315
+ }
1316
+
1317
+ // Determine host architecture
1318
+ const hostArch = process.arch === 'arm64' ? 'arm64' : 'x86_64';
1319
+
1320
+ let crossCompile = '';
1321
+ if (hostArch === 'x86_64' && targetArch === 'arm64') {
1322
+ crossCompile = 'CROSS_COMPILE=aarch64-linux-gnu-';
1323
+ } else if (hostArch === 'arm64' && targetArch === 'x86_64') {
1324
+ crossCompile = 'CROSS_COMPILE=x86_64-linux-gnu-';
1325
+ }
1326
+
1327
+ const platformDir = targetArch === 'arm64' ? 'bin-arm64-efi' : 'bin-x86_64-efi';
1328
+ const makeTarget = `${platformDir}/ipxe.iso`;
1329
+
1330
+ logger.info(
1331
+ `Building iPXE ISO for ${targetArch} on ${hostArch}: make ${makeTarget} ${crossCompile} EMBED=${embedScriptName}`,
1332
+ );
1333
+
1334
+ const buildCmd = `cd ${ipxeSrcDir} && make ${makeTarget} ${crossCompile} EMBED=${embedScriptName}`;
1335
+ shellExec(buildCmd);
1336
+
1337
+ const builtIsoPath = path.join(ipxeSrcDir, makeTarget);
1338
+ if (fs.existsSync(builtIsoPath)) {
1339
+ fs.copySync(builtIsoPath, outputPath);
1340
+ logger.info(`ISO successfully built and copied to ${outputPath}`);
1341
+ } else {
1342
+ logger.error(`Failed to build ISO at ${builtIsoPath}`);
1343
+ }
1344
+ },
1345
+
1346
+ /**
1347
+ * @method fallbackArchitecture
1348
+ * @description Determines the architecture to use for boot resources, with a fallback mechanism.
1349
+ * @param {object} workflowsConfig - The configuration object for the current workflow.
1350
+ * @returns {string} The architecture string (e.g., 'arm64', 'amd64') to use for boot resources.
1351
+ * @memberof UnderpostBaremetal
1352
+ */
1353
+ fallbackArchitecture(workflowsConfig) {
1354
+ return (
1355
+ workflowsConfig.architecture ||
1356
+ workflowsConfig.maas?.commissioning?.architecture ||
1357
+ workflowsConfig.container?.architecture ||
1358
+ workflowsConfig.debootstrap?.image?.architecture
1359
+ );
1360
+ },
1361
+
1131
1362
  /**
1132
1363
  * @method macAddressFactory
1133
1364
  * @description Generates or returns a MAC address based on options.
@@ -1211,7 +1442,7 @@ rm -rf ${artifacts.join(' ')}`);
1211
1442
  shellExec(`mkdir -p ${mountPoint}`);
1212
1443
 
1213
1444
  // Ensure mount point is not already mounted
1214
- shellExec(`sudo umount ${mountPoint} 2>/dev/null`, { silent: true });
1445
+ shellExec(`sudo umount ${mountPoint} 2>/dev/null`);
1215
1446
 
1216
1447
  try {
1217
1448
  // Mount the ISO
@@ -1233,10 +1464,10 @@ rm -rf ${artifacts.join(' ')}`);
1233
1464
 
1234
1465
  shellExec(`sudo chown -R $(whoami):$(whoami) ${extractDir}`);
1235
1466
  // Unmount ISO
1236
- shellExec(`sudo umount ${mountPoint}`, { silent: true });
1467
+ shellExec(`sudo umount ${mountPoint}`);
1237
1468
  logger.info(`Unmounted ISO`);
1238
1469
  // Clean up temporary mount point
1239
- shellExec(`rmdir ${mountPoint}`, { silent: true });
1470
+ shellExec(`rmdir ${mountPoint}`);
1240
1471
  }
1241
1472
 
1242
1473
  return {
@@ -1253,8 +1484,8 @@ rm -rf ${artifacts.join(' ')}`);
1253
1484
  * @param {string} options.macAddress - The MAC address of the machine.
1254
1485
  * @param {string} options.hostname - The hostname for the machine.
1255
1486
  * @param {string} options.ipAddress - The IP address for the machine.
1487
+ * @param {string} options.architecture - The architecture for the machine (default is 'arm64').
1256
1488
  * @param {string} options.powerType - The power type for the machine (default is 'manual').
1257
- * @param {object} options.maas - Additional MAAS-specific options.
1258
1489
  * @returns {object} An object containing the created machine details.
1259
1490
  * @memberof UnderpostBaremetal
1260
1491
  */
@@ -1263,13 +1494,13 @@ rm -rf ${artifacts.join(' ')}`);
1263
1494
  macAddress: '',
1264
1495
  hostname: '',
1265
1496
  ipAddress: '',
1497
+ architecture: 'arm64',
1266
1498
  powerType: 'manual',
1267
- architecture: 'arm64/generic',
1268
1499
  },
1269
1500
  ) {
1270
1501
  if (!options.powerType) options.powerType = 'manual';
1271
1502
  const payload = {
1272
- architecture: (options.architecture || 'arm64/generic').match('arm') ? 'arm64/generic' : 'amd64/generic',
1503
+ architecture: options.architecture.match('arm') ? 'arm64/generic' : 'amd64/generic',
1273
1504
  mac_address: options.macAddress,
1274
1505
  mac_addresses: options.macAddress,
1275
1506
  hostname: options.hostname,
@@ -1277,18 +1508,13 @@ rm -rf ${artifacts.join(' ')}`);
1277
1508
  ip: options.ipAddress,
1278
1509
  };
1279
1510
  logger.info('Creating MAAS machine', payload);
1280
- const machine = shellExec(
1281
- `maas ${process.env.MAAS_ADMIN_USERNAME} machines create ${Object.keys(payload)
1511
+ const machine = Underpost.baremetal.maasCliExec(
1512
+ `machines create ${Object.keys(payload)
1282
1513
  .map((k) => `${k}="${payload[k]}"`)
1283
1514
  .join(' ')}`,
1284
- {
1285
- silent: true,
1286
- stdout: true,
1287
- },
1288
1515
  );
1289
- // console.log(machine);
1290
1516
  try {
1291
- return { machine: JSON.parse(machine) };
1517
+ return { machine };
1292
1518
  } catch (error) {
1293
1519
  console.log(error);
1294
1520
  logger.error(error);
@@ -1325,13 +1551,7 @@ rm -rf ${artifacts.join(' ')}`);
1325
1551
  return { kernelFilesPaths, resourcesPath };
1326
1552
  }
1327
1553
 
1328
- const resourceData = JSON.parse(
1329
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resource read ${resource.id}`, {
1330
- stdout: true,
1331
- silent: true,
1332
- disableLog: true,
1333
- }),
1334
- );
1554
+ const resourceData = Underpost.baremetal.maasCliExec(`boot-resource read ${resource.id}`);
1335
1555
  let kernelFilesPaths = {};
1336
1556
  const bootFiles = resourceData.sets[Object.keys(resourceData.sets)[0]].files;
1337
1557
  const arch = resource.architecture.split('/')[0];
@@ -1392,7 +1612,7 @@ rm -rf ${artifacts.join(' ')}`);
1392
1612
  shellExec(`mkdir -p ${tempExtractDir}`);
1393
1613
 
1394
1614
  // List files in archive to find kernel and initrd
1395
- const tarList = shellExec(`tar -tf ${rootArchivePath}`, { silent: true }).stdout.split('\n');
1615
+ const tarList = shellExec(`tar -tf ${rootArchivePath}`).stdout.split('\n');
1396
1616
 
1397
1617
  // Look for boot/vmlinuz* and boot/initrd* (handling potential leading ./)
1398
1618
  // Skip rescue, kdump, and other special images
@@ -1524,7 +1744,7 @@ rm -rf ${artifacts.join(' ')}`);
1524
1744
  */
1525
1745
  removeDiscoveredMachines() {
1526
1746
  logger.info('Removing all discovered machines from MAAS...');
1527
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
1747
+ Underpost.baremetal.maasCliExec(`discoveries clear all=true`);
1528
1748
  },
1529
1749
 
1530
1750
  /**
@@ -1856,6 +2076,73 @@ shell
1856
2076
  };
1857
2077
  },
1858
2078
 
2079
+ /**
2080
+ * @method bootstrapHttpServerPortFactory
2081
+ * @description Determines the bootstrap HTTP server port.
2082
+ * @param {object} params - Parameters for determining the port.
2083
+ * @param {number} [params.port] - The port passed via options.
2084
+ * @param {string} params.workflowId - The workflow identifier.
2085
+ * @param {object} params.workflowsConfig - The loaded workflows configuration.
2086
+ * @returns {number} The determined port number.
2087
+ * @memberof UnderpostBaremetal
2088
+ */
2089
+ bootstrapHttpServerPortFactory({ port, workflowId, workflowsConfig }) {
2090
+ return port || workflowsConfig[workflowId]?.bootstrapHttpServerPort || 8888;
2091
+ },
2092
+
2093
+ /**
2094
+ * @method commissioningWriteFilesFactory
2095
+ * @description Generates the write_files configuration for the commissioning script.
2096
+ * @param {object} params
2097
+ * @param {object} params.machine - The machine object.
2098
+ * @param {object} params.authCredentials - MAAS authentication credentials.
2099
+ * @param {string} params.runnerHostIp - The IP address of the runner host.
2100
+ * @memberof UnderpostBaremetal
2101
+ * @returns {Array} The write_files array.
2102
+ */
2103
+ commissioningWriteFilesFactory({ machine, authCredentials, runnerHostIp }) {
2104
+ const { consumer_key, token_key, token_secret } = authCredentials;
2105
+ return [
2106
+ {
2107
+ path: '/usr/local/bin/underpost-enlist.sh',
2108
+ permissions: '0755',
2109
+ owner: 'root:root',
2110
+ content: `#!/bin/bash
2111
+ # set -euo pipefail
2112
+ CONSUMER_KEY="${consumer_key}"
2113
+ TOKEN_KEY="${token_key}"
2114
+ TOKEN_SECRET="${token_secret}"
2115
+ LOG_FILE="/var/log/underpost-enlistment.log"
2116
+ RESPONSE_FILE="/tmp/maas_response.txt"
2117
+ STATUS_FILE="/tmp/maas_status.txt"
2118
+
2119
+ echo "Starting MAAS Commissioning Request..." | tee -a "$LOG_FILE"
2120
+
2121
+ curl -X POST \\
2122
+ --location --verbose \\
2123
+ --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)\\"" \\
2124
+ -F "enable_ssh=1" \\
2125
+ http://${runnerHostIp}:5240/MAAS/api/2.0/machines/${machine.system_id}/op-commission \\
2126
+ --output "$RESPONSE_FILE" --write-out "%{http_code}" > "$STATUS_FILE" 2>> "$LOG_FILE"
2127
+
2128
+ HTTP_STATUS=$(cat "$STATUS_FILE")
2129
+
2130
+ echo "HTTP Status: $HTTP_STATUS" | tee -a "$LOG_FILE"
2131
+ echo "Response Body:" | tee -a "$LOG_FILE"
2132
+ cat "$RESPONSE_FILE" | tee -a "$LOG_FILE"
2133
+
2134
+ if [ "$HTTP_STATUS" -eq 200 ]; then
2135
+ echo "Commissioning requested successfully. Rebooting to start commissioning..." | tee -a "$LOG_FILE"
2136
+ reboot
2137
+ else
2138
+ echo "ERROR: MAAS commissioning failed with status $HTTP_STATUS" | tee -a "$LOG_FILE"
2139
+ exit 0
2140
+ fi
2141
+ `,
2142
+ },
2143
+ ];
2144
+ },
2145
+
1859
2146
  /**
1860
2147
  * @method httpBootstrapServerStaticFactory
1861
2148
  * @description Creates static files for the bootstrap HTTP server including cloud-init configuration.
@@ -1865,6 +2152,7 @@ shell
1865
2152
  * @param {string} params.cloudConfigSrc - The cloud-init configuration YAML source.
1866
2153
  * @param {object} [params.metadata] - Optional metadata to include in meta-data file.
1867
2154
  * @param {string} [params.vendorData] - Optional vendor-data content (default: empty string).
2155
+ * @param {string} [params.isoUrl] - Optional ISO URL to cache and serve.
1868
2156
  * @memberof UnderpostBaremetal
1869
2157
  * @returns {void}
1870
2158
  */
@@ -1874,16 +2162,13 @@ shell
1874
2162
  cloudConfigSrc,
1875
2163
  metadata = {},
1876
2164
  vendorData = '',
2165
+ isoUrl = '',
1877
2166
  }) {
1878
2167
  // Create directory structure
1879
2168
  shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
1880
2169
 
1881
2170
  // Write user-data file
1882
- fs.writeFileSync(
1883
- `${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`,
1884
- `#cloud-config\n${cloudConfigSrc}`,
1885
- 'utf8',
1886
- );
2171
+ fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`, cloudConfigSrc, 'utf8');
1887
2172
 
1888
2173
  // Write meta-data file
1889
2174
  const metaDataContent = `instance-id: ${metadata.instanceId || hostname}\nlocal-hostname: ${metadata.localHostname || hostname}`;
@@ -1893,6 +2178,22 @@ shell
1893
2178
  fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/vendor-data`, vendorData, 'utf8');
1894
2179
 
1895
2180
  logger.info(`Cloud-init files written to ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
2181
+
2182
+ if (isoUrl) {
2183
+ const isoFilename = isoUrl.split('/').pop();
2184
+ const isoCacheDir = `/var/tmp/live-iso`;
2185
+ const isoCachePath = `${isoCacheDir}/${isoFilename}`;
2186
+ const isoDestPath = `${bootstrapHttpServerPath}/${hostname}/${isoFilename}`;
2187
+
2188
+ if (!fs.existsSync(isoCachePath)) {
2189
+ logger.info(`Downloading ISO to cache: ${isoUrl}`);
2190
+ shellExec(`mkdir -p ${isoCacheDir}`);
2191
+ shellExec(`wget --progress=bar:force -O ${isoCachePath} "${isoUrl}"`);
2192
+ }
2193
+
2194
+ logger.info(`Copying ISO to bootstrap server: ${isoDestPath}`);
2195
+ shellExec(`cp ${isoCachePath} ${isoDestPath}`);
2196
+ }
1896
2197
  },
1897
2198
 
1898
2199
  /**
@@ -1913,14 +2214,17 @@ shell
1913
2214
  const hostname = options.hostname || 'localhost';
1914
2215
 
1915
2216
  shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
2217
+ shellExec(`node bin run kill ${port}`);
1916
2218
 
1917
- // Kill any existing HTTP server
1918
- shellExec(`sudo pkill -f 'python3 -m http.server ${port}'`, { silent: true });
2219
+ const app = express();
1919
2220
 
1920
- shellExec(
1921
- `cd ${bootstrapHttpServerPath} && nohup python3 -m http.server ${port} --bind 0.0.0.0 > /tmp/http-boot-server.log 2>&1 &`,
1922
- { silent: true, async: true },
1923
- );
2221
+ app.use(loggerMiddleware(import.meta, 'debug', () => false));
2222
+
2223
+ app.use('/', express.static(bootstrapHttpServerPath));
2224
+
2225
+ app.listen(port, () => {
2226
+ logger.info(`Static file server running on port ${port}`);
2227
+ });
1924
2228
 
1925
2229
  // Configure iptables to allow incoming LAN connections
1926
2230
  shellExec(
@@ -1963,7 +2267,7 @@ shell
1963
2267
  // GRUB on ARM64 often crashes with synchronous exception (0x200) if handling large compressed kernels directly.
1964
2268
  if (file === 'vmlinuz-efi') {
1965
2269
  const kernelDest = `${tftpRootPath}/pxe/${file}`;
1966
- const fileType = shellExec(`file ${kernelDest}`, { silent: true }).stdout;
2270
+ const fileType = shellExec(`file ${kernelDest}`).stdout;
1967
2271
 
1968
2272
  // Handle gzip compressed kernels
1969
2273
  if (fileType.includes('gzip compressed data')) {
@@ -2001,8 +2305,14 @@ shell
2001
2305
  * @param {string} options.macAddress - The MAC address of the client.
2002
2306
  * @param {boolean} options.cloudInit - Whether to include cloud-init parameters.
2003
2307
  * @param {object} options.machine - The machine object containing system_id.
2308
+ * @param {string} options.machine.system_id - The system ID of the machine (for MAAS metadata).
2004
2309
  * @param {boolean} [options.dev=false] - Whether to enable dev mode with dracut debugging parameters.
2005
2310
  * @param {string} [options.osIdLike=''] - OS family identifier (e.g., 'rhel centos fedora' or 'debian ubuntu').
2311
+ * @param {object} options.authCredentials - Authentication credentials for fetching files (if needed).
2312
+ * @param {string} options.authCredentials.consumer_key - Consumer key for authentication.
2313
+ * @param {string} options.authCredentials.consumer_secret - Consumer secret for authentication.
2314
+ * @param {string} options.authCredentials.token_key - Token key for authentication.
2315
+ * @param {string} options.authCredentials.token_secret - Token secret for authentication.
2006
2316
  * @returns {object} An object containing the constructed command line string.
2007
2317
  * @memberof UnderpostBaremetal
2008
2318
  */
@@ -2024,6 +2334,7 @@ shell
2024
2334
  machine: { system_id: '' },
2025
2335
  dev: false,
2026
2336
  osIdLike: '',
2337
+ authCredentials: { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' },
2027
2338
  },
2028
2339
  ) {
2029
2340
  // Construct kernel command line arguments for NFS boot.
@@ -2044,10 +2355,9 @@ shell
2044
2355
  osIdLike,
2045
2356
  } = options;
2046
2357
 
2047
- const ipParam = true
2048
- ? `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2049
- `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`
2050
- : 'ip=dhcp';
2358
+ const ipParam =
2359
+ `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2360
+ `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`;
2051
2361
 
2052
2362
  const nfsOptions = `${
2053
2363
  type === 'chroot-debootstrap' || type === 'chroot-container'
@@ -2094,7 +2404,7 @@ shell
2094
2404
  // `toram`,
2095
2405
  'nomodeset',
2096
2406
  `editable_rootfs=tmpfs`,
2097
- `ramdisk_size=3550000`,
2407
+ // `ramdisk_size=3550000`,
2098
2408
  // `root=/dev/sda1`, // rpi4 usb port unit
2099
2409
  'apparmor=0', // Disable AppArmor security
2100
2410
  ...(networkInterfaceName === 'eth0'
@@ -2138,49 +2448,32 @@ shell
2138
2448
  if (type === 'iso-ram') {
2139
2449
  const netBootParams = [`netboot=url`];
2140
2450
  if (fileSystemUrl) netBootParams.push(`url=${fileSystemUrl.replace('https', 'http')}`);
2141
- cmd = [ipParam, `boot=casper`, ...netBootParams, ...kernelParams];
2451
+ cmd = [ipParam, `boot=casper`, 'toram', ...netBootParams, ...kernelParams, ...performanceParams];
2142
2452
  } else if (type === 'chroot-debootstrap' || type === 'chroot-container') {
2143
2453
  let qemuNfsRootParams = [`root=/dev/nfs`, `rootfstype=nfs`];
2144
-
2145
- // Determine OS family from osIdLike configuration
2146
- const isRhelBased = osIdLike && osIdLike.match(/rhel|centos|fedora|alma|rocky/i);
2147
- const isDebianBased = osIdLike && osIdLike.match(/debian|ubuntu/i);
2148
-
2149
- // Add RHEL/Rocky/Fedora based images specific parameters
2150
- if (isRhelBased) {
2151
- qemuNfsRootParams = qemuNfsRootParams.concat([`rd.neednet=1`, `rd.timeout=180`, `selinux=0`, `enforcing=0`]);
2152
- }
2153
- // Add Debian/Ubuntu based images specific parameters
2154
- else if (isDebianBased) {
2155
- qemuNfsRootParams = qemuNfsRootParams.concat([`initrd=initrd.img`, `init=/sbin/init`]);
2156
- }
2157
-
2158
- // Add debugging parameters in dev mode for dracut troubleshooting
2159
- if (options.dev) {
2160
- // qemuNfsRootParams = qemuNfsRootParams.concat([`rd.shell`, `rd.debug`]);
2161
- }
2162
-
2163
2454
  cmd = [ipParam, ...qemuNfsRootParams, nfsRootParam, ...kernelParams];
2164
2455
  } else {
2165
2456
  // 'iso-nfs'
2166
2457
  cmd = [ipParam, `netboot=nfs`, nfsRootParam, ...kernelParams, ...performanceParams];
2458
+ // cmd.push(`ifname=${networkInterfaceName}:${macAddress}`);
2459
+ }
2167
2460
 
2168
- cmd.push(`ifname=${networkInterfaceName}:${macAddress}`);
2169
-
2170
- if (cloudInit) {
2171
- const cloudInitPreseedUrl = `http://${ipDhcpServer}:5248/MAAS/metadata/by-id/${options.machine?.system_id ? options.machine.system_id : 'system-id'}/?op=get_preseed`;
2172
- cmd = cmd.concat([
2173
- `cloud-init=enabled`,
2174
- 'autoinstall',
2175
- `cloud-config-url=${cloudInitPreseedUrl}`,
2176
- `ds=nocloud-net;s=${cloudInitPreseedUrl}`,
2177
- `log_host=${ipDhcpServer}`,
2178
- `log_port=5247`,
2179
- // `BOOTIF=${macAddress}`,
2180
- // `cc:{'datasource_list': ['MAAS']}end_cc`,
2181
- ]);
2182
- }
2461
+ // Determine OS family from osIdLike configuration
2462
+ const isRhelBased = osIdLike && osIdLike.match(/rhel|centos|fedora|alma|rocky/i);
2463
+ const isDebianBased = osIdLike && osIdLike.match(/debian|ubuntu/i);
2464
+
2465
+ // Add RHEL/Rocky/Fedora based images specific parameters
2466
+ if (isRhelBased) {
2467
+ cmd = cmd.concat([`rd.neednet=1`, `rd.timeout=180`, `selinux=0`, `enforcing=0`]);
2468
+ if (options.dev) cmd = cmd.concat([`rd.shell`, `rd.debug`]);
2469
+ }
2470
+ // Add Debian/Ubuntu based images specific parameters
2471
+ else if (isDebianBased) {
2472
+ cmd = cmd.concat([`initrd=initrd.img`, `init=/sbin/init`]);
2473
+ if (options.dev) cmd = cmd.concat([`debug`, `ignore_loglevel`]);
2183
2474
  }
2475
+
2476
+ if (cloudInit) cmd = Underpost.cloudInit.kernelParamsFactory(macAddress, cmd, options);
2184
2477
  // cmd.push('---');
2185
2478
  const cmdStr = cmd.join(' ');
2186
2479
  logger.info('Constructed kernel command line');
@@ -2204,12 +2497,7 @@ shell
2204
2497
  async commissionMonitor({ macAddress, ipAddress, hostname, architecture, machine }) {
2205
2498
  {
2206
2499
  // Query observed discoveries from MAAS.
2207
- const discoveries = JSON.parse(
2208
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries read`, {
2209
- silent: true,
2210
- stdout: true,
2211
- }),
2212
- );
2500
+ const discoveries = Underpost.baremetal.maasCliExec(`discoveries read`);
2213
2501
 
2214
2502
  for (const discovery of discoveries) {
2215
2503
  const discoverHostname = discovery.hostname
@@ -2227,6 +2515,20 @@ shell
2227
2515
  if (discovery.ip === ipAddress) {
2228
2516
  logger.info('Machine discovered!', discovery);
2229
2517
  if (!machine) {
2518
+ // Check if a machine with the discovered MAC already exists to avoid conflicts
2519
+ const [existingMachine] =
2520
+ Underpost.baremetal.maasCliExec(`machines read mac_address=${discovery.mac_address}`) || [];
2521
+
2522
+ if (existingMachine) {
2523
+ logger.warn(
2524
+ `Machine ${existingMachine.hostname} (${existingMachine.system_id}) already exists with MAC ${discovery.mac_address}`,
2525
+ );
2526
+ logger.info(
2527
+ `Deleting existing machine ${existingMachine.system_id} to create new machine ${hostname}...`,
2528
+ );
2529
+ Underpost.baremetal.maasCliExec(`machine delete ${existingMachine.system_id}`);
2530
+ }
2531
+
2230
2532
  logger.info('Creating new machine with discovered hardware MAC...', {
2231
2533
  discoveredMAC: discovery.mac_address,
2232
2534
  ipAddress,
@@ -2238,12 +2540,18 @@ shell
2238
2540
  hostname,
2239
2541
  architecture,
2240
2542
  }).machine;
2241
- console.log('New machine system id:', machine.system_id.bgYellow.bold.black);
2242
- Underpost.baremetal.writeGrubConfigToFile({
2243
- grubCfgSrc: Underpost.baremetal
2244
- .getGrubConfigFromFile()
2245
- .grubCfgSrc.replaceAll('system-id', machine.system_id),
2246
- });
2543
+
2544
+ if (machine && machine.system_id) {
2545
+ console.log('New machine system id:', machine.system_id.bgYellow.bold.black);
2546
+ Underpost.baremetal.writeGrubConfigToFile({
2547
+ grubCfgSrc: Underpost.baremetal
2548
+ .getGrubConfigFromFile()
2549
+ .grubCfgSrc.replaceAll('system-id', machine.system_id),
2550
+ });
2551
+ } else {
2552
+ logger.error('Failed to create machine or obtain system_id', machine);
2553
+ throw new Error('Machine creation failed');
2554
+ }
2247
2555
  } else {
2248
2556
  const systemId = machine.system_id;
2249
2557
  console.log('Using pre-registered machine system_id:', systemId.bgYellow.bold.black);
@@ -2256,22 +2564,45 @@ shell
2256
2564
  discoveredMAC: discovery.mac_address,
2257
2565
  });
2258
2566
 
2259
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-broken ${systemId}`, {
2260
- silent: true,
2261
- });
2567
+ // Check current machine status before attempting state transitions
2568
+ const currentMachine = Underpost.baremetal.maasCliExec(`machine read ${systemId}`);
2569
+ const currentStatus = currentMachine ? currentMachine.status_name : 'Unknown';
2570
+ logger.info('Current machine status before interface update:', { systemId, status: currentStatus });
2571
+
2572
+ // Only mark-broken if the machine is in a state that supports it (e.g. Ready, New, Allocated)
2573
+ // Machines already in Broken state don't need to be marked broken again
2574
+ if (currentStatus !== 'Broken') {
2575
+ try {
2576
+ Underpost.baremetal.maasCliExec(`machine mark-broken ${systemId}`);
2577
+ logger.info('Machine marked as broken successfully');
2578
+ } catch (markBrokenError) {
2579
+ logger.warn('Failed to mark machine as broken, attempting interface update anyway...', {
2580
+ error: markBrokenError.message,
2581
+ currentStatus,
2582
+ });
2583
+ }
2584
+ } else {
2585
+ logger.info('Machine is already in Broken state, skipping mark-broken');
2586
+ }
2262
2587
 
2263
- shellExec(
2264
- // name=${networkInterfaceName}
2265
- `maas ${process.env.MAAS_ADMIN_USERNAME} interface update ${systemId} ${machine.boot_interface.id}` +
2266
- ` mac_address=${discovery.mac_address}`,
2267
- {
2268
- silent: true,
2269
- },
2588
+ Underpost.baremetal.maasCliExec(
2589
+ `interface update ${systemId} ${machine.boot_interface.id}` + ` mac_address=${discovery.mac_address}`,
2270
2590
  );
2271
2591
 
2272
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-fixed ${systemId}`, {
2273
- silent: true,
2274
- });
2592
+ // Re-check status before mark-fixed only attempt if actually Broken
2593
+ const updatedMachine = Underpost.baremetal.maasCliExec(`machine read ${systemId}`);
2594
+ const updatedStatus = updatedMachine ? updatedMachine.status_name : 'Unknown';
2595
+
2596
+ if (updatedStatus === 'Broken') {
2597
+ try {
2598
+ Underpost.baremetal.maasCliExec(`machine mark-fixed ${systemId}`);
2599
+ logger.info('Machine marked as fixed successfully');
2600
+ } catch (markFixedError) {
2601
+ logger.warn('Failed to mark machine as fixed:', { error: markFixedError.message });
2602
+ }
2603
+ } else {
2604
+ logger.info('Machine is not in Broken state, skipping mark-fixed', { status: updatedStatus });
2605
+ }
2275
2606
 
2276
2607
  logger.info('✓ Machine interface MAC address updated successfully');
2277
2608
 
@@ -2300,6 +2631,73 @@ shell
2300
2631
  }
2301
2632
  },
2302
2633
 
2634
+ /**
2635
+ * @method maasCliExec
2636
+ * @description Executes a MAAS CLI command and returns the parsed JSON output.
2637
+ * This method abstracts the execution of MAAS CLI commands, ensuring that the output is captured and parsed correctly.
2638
+ * @param {string} cmd - The MAAS CLI command to execute (e.g., 'machines read').
2639
+ * @returns {object|null} The parsed JSON output from the MAAS CLI command, or null if there is no output.
2640
+ * @memberof UnderpostBaremetal
2641
+ */
2642
+ maasCliExec(cmd) {
2643
+ const output = shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} ${cmd}`, {
2644
+ stdout: true,
2645
+ silent: true,
2646
+ }).trim();
2647
+ try {
2648
+ return output ? JSON.parse(output) : null;
2649
+ } catch (error) {
2650
+ console.log('output', output);
2651
+ logger.error(error);
2652
+ throw error;
2653
+ }
2654
+ },
2655
+
2656
+ /**
2657
+ * @method maasAuthCredentialsFactory
2658
+ * @description Retrieves MAAS API key credentials from the MAAS CLI.
2659
+ * This method parses the output of `maas apikey` to extract the consumer key,
2660
+ * consumer secret, token key, and token secret.
2661
+ * @returns {object} An object containing the MAAS authentication credentials.
2662
+ * @memberof UnderpostBaremetal
2663
+ * @throws {Error} If the MAAS API key format is invalid.
2664
+ */
2665
+ maasAuthCredentialsFactory() {
2666
+ // Expected formats:
2667
+ // <consumer_key>:<consumer_token>:<secret> (older format)
2668
+ // <consumer_key>:<consumer_secret>:<token_key>:<token_secret> (newer format)
2669
+ // Commands used to generate API keys:
2670
+ // maas apikey --with-names --username ${process.env.MAAS_ADMIN_USERNAME}
2671
+ // maas ${process.env.MAAS_ADMIN_USERNAME} account create-authorisation-token
2672
+ // maas apikey --generate --username ${process.env.MAAS_ADMIN_USERNAME}
2673
+ // Reference: https://github.com/CanonicalLtd/maas-docs/issues/647
2674
+
2675
+ const parts = shellExec(`maas apikey --with-names --username ${process.env.MAAS_ADMIN_USERNAME}`, {
2676
+ stdout: true,
2677
+ })
2678
+ .trim()
2679
+ .split(`\n`)[0] // Take only the first line of output.
2680
+ .split(':'); // Split by colon to get individual parts.
2681
+
2682
+ let consumer_key, consumer_secret, token_key, token_secret;
2683
+
2684
+ // Determine the format of the API key and assign parts accordingly.
2685
+ if (parts.length === 4) {
2686
+ [consumer_key, consumer_secret, token_key, token_secret] = parts;
2687
+ } else if (parts.length === 3) {
2688
+ // Handle older 3-part format, setting consumer_secret as empty.
2689
+ [consumer_key, token_key, token_secret] = parts;
2690
+ consumer_secret = '';
2691
+ token_secret = token_secret.split(' MAAS consumer')[0].trim(); // Clean up token secret.
2692
+ } else {
2693
+ // Throw an error if the format is not recognized.
2694
+ throw new Error('Invalid token format');
2695
+ }
2696
+
2697
+ logger.info('Maas api token generated', { consumer_key, consumer_secret, token_key, token_secret });
2698
+ return { consumer_key, consumer_secret, token_key, token_secret };
2699
+ },
2700
+
2303
2701
  /**
2304
2702
  * @method mountBinfmtMisc
2305
2703
  * @description Mounts the binfmt_misc filesystem to enable QEMU user-static binfmt support.
@@ -2335,7 +2733,7 @@ shell
2335
2733
  const systemId = typeof machine === 'string' ? machine : machine.system_id;
2336
2734
  if (ignore && ignore.find((mId) => mId === systemId)) continue;
2337
2735
  logger.info(`Removing machine: ${systemId}`);
2338
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${systemId}`);
2736
+ Underpost.baremetal.maasCliExec(`machine delete ${systemId}`);
2339
2737
  }
2340
2738
  return [];
2341
2739
  },
@@ -2349,9 +2747,9 @@ shell
2349
2747
  * @returns {void}
2350
2748
  */
2351
2749
  clearDiscoveries({ force }) {
2352
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
2750
+ Underpost.baremetal.maasCliExec(`discoveries clear all=true`);
2353
2751
  if (force === true) {
2354
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries scan force=true`);
2752
+ Underpost.baremetal.maasCliExec(`discoveries scan force=true`);
2355
2753
  }
2356
2754
  },
2357
2755
 
@@ -2967,10 +3365,10 @@ udp-port = 32766
2967
3365
  // Check both /usr/local/bin (compiled) and system paths
2968
3366
  let qemuAarch64Path = null;
2969
3367
 
2970
- if (shellExec('test -x /usr/local/bin/qemu-system-aarch64', { silent: true }).code === 0) {
3368
+ if (shellExec('test -x /usr/local/bin/qemu-system-aarch64').code === 0) {
2971
3369
  qemuAarch64Path = '/usr/local/bin/qemu-system-aarch64';
2972
- } else if (shellExec('which qemu-system-aarch64', { silent: true }).code === 0) {
2973
- qemuAarch64Path = shellExec('which qemu-system-aarch64', { silent: true }).stdout.trim();
3370
+ } else if (shellExec('which qemu-system-aarch64').code === 0) {
3371
+ qemuAarch64Path = shellExec('which qemu-system-aarch64').stdout.trim();
2974
3372
  }
2975
3373
 
2976
3374
  if (!qemuAarch64Path) {
@@ -2983,7 +3381,7 @@ udp-port = 32766
2983
3381
  logger.info(`Found qemu-system-aarch64 at: ${qemuAarch64Path}`);
2984
3382
 
2985
3383
  // Verify that the installed qemu supports the 'virt' machine type (required for arm64)
2986
- const machineHelp = shellExec(`${qemuAarch64Path} -machine help`, { silent: true }).stdout;
3384
+ const machineHelp = shellExec(`${qemuAarch64Path} -machine help`).stdout;
2987
3385
  if (!machineHelp.includes('virt')) {
2988
3386
  throw new Error(
2989
3387
  'The installed qemu-system-aarch64 does not support the "virt" machine type.\n' +
@@ -2996,10 +3394,10 @@ udp-port = 32766
2996
3394
  // Check both /usr/local/bin (compiled) and system paths
2997
3395
  let qemuX86Path = null;
2998
3396
 
2999
- if (shellExec('test -x /usr/local/bin/qemu-system-x86_64', { silent: true }).code === 0) {
3397
+ if (shellExec('test -x /usr/local/bin/qemu-system-x86_64').code === 0) {
3000
3398
  qemuX86Path = '/usr/local/bin/qemu-system-x86_64';
3001
- } else if (shellExec('which qemu-system-x86_64', { silent: true }).code === 0) {
3002
- qemuX86Path = shellExec('which qemu-system-x86_64', { silent: true }).stdout.trim();
3399
+ } else if (shellExec('which qemu-system-x86_64').code === 0) {
3400
+ qemuX86Path = shellExec('which qemu-system-x86_64').stdout.trim();
3003
3401
  }
3004
3402
 
3005
3403
  if (!qemuX86Path) {
@@ -3012,7 +3410,7 @@ udp-port = 32766
3012
3410
  logger.info(`Found qemu-system-x86_64 at: ${qemuX86Path}`);
3013
3411
 
3014
3412
  // Verify that the installed qemu supports the 'pc' or 'q35' machine type (required for x86_64)
3015
- const machineHelp = shellExec(`${qemuX86Path} -machine help`, { silent: true }).stdout;
3413
+ const machineHelp = shellExec(`${qemuX86Path} -machine help`).stdout;
3016
3414
  if (!machineHelp.includes('pc') && !machineHelp.includes('q35')) {
3017
3415
  throw new Error(
3018
3416
  'The installed qemu-system-x86_64 does not support the "pc" or "q35" machine type.\n' +