@underpostnet/underpost 2.99.1 → 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.
Files changed (42) hide show
  1. package/.env.development +0 -3
  2. package/.env.production +1 -3
  3. package/.env.test +0 -3
  4. package/LICENSE +1 -1
  5. package/README.md +30 -30
  6. package/baremetal/commission-workflows.json +52 -0
  7. package/bin/deploy.js +101 -47
  8. package/cli.md +47 -43
  9. package/examples/static-page/README.md +55 -378
  10. package/examples/static-page/ssr-components/CustomPage.js +1 -13
  11. package/jsconfig.json +4 -2
  12. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  13. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  14. package/manifests/deployment/playwright/deployment.yaml +52 -0
  15. package/package.json +2 -2
  16. package/scripts/disk-devices.sh +13 -0
  17. package/scripts/rocky-pwa.sh +2 -2
  18. package/scripts/ssl.sh +12 -6
  19. package/src/api/user/user.model.js +1 -0
  20. package/src/cli/baremetal.js +576 -176
  21. package/src/cli/cloud-init.js +97 -79
  22. package/src/cli/deploy.js +6 -24
  23. package/src/cli/env.js +4 -1
  24. package/src/cli/image.js +7 -40
  25. package/src/cli/index.js +37 -7
  26. package/src/cli/repository.js +3 -1
  27. package/src/cli/run.js +109 -92
  28. package/src/cli/secrets.js +0 -34
  29. package/src/cli/static.js +0 -26
  30. package/src/cli/test.js +13 -1
  31. package/src/client/components/core/Polyhedron.js +896 -7
  32. package/src/client/components/core/Translate.js +4 -0
  33. package/src/client/services/default/default.management.js +12 -2
  34. package/src/index.js +27 -1
  35. package/src/runtime/express/Express.js +3 -3
  36. package/src/server/conf.js +6 -4
  37. package/src/server/logger.js +33 -31
  38. package/src/server/process.js +27 -2
  39. package/src/server/proxy.js +4 -6
  40. package/src/server/tls.js +30 -25
  41. package/examples/static-page/QUICK-REFERENCE.md +0 -481
  42. package/examples/static-page/STATIC-GENERATOR-GUIDE.md +0 -757
@@ -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.
@@ -47,13 +35,13 @@ class UnderpostBaremetal {
47
35
  * It handles NFS root filesystem building, control server installation/uninstallation,
48
36
  * and system-level provisioning tasks like timezone and keyboard configuration.
49
37
  * @param {string} [workflowId='rpi4mb'] - Identifier for the specific workflow configuration to use.
50
- * @param {string} [ipAddress=getLocalIPv4Address()] - The IP address of the control server or the local machine.
51
- * @param {string} [hostname=workflowId] - The hostname of the target baremetal machine.
52
- * @param {string} [ipFileServer=getLocalIPv4Address()] - The IP address of the file server (NFS/TFTP).
53
- * @param {string} [ipConfig=''] - IP configuration string for the baremetal machine.
54
- * @param {string} [netmask=''] - Netmask of network
55
- * @param {string} [dnsServer=''] - DNS server IP address.
56
38
  * @param {object} [options] - An object containing boolean flags for various operations.
39
+ * @param {string} [options.ipAddress=getLocalIPv4Address()] - The IP address of the control server or the local machine.
40
+ * @param {string} [options.hostname=workflowId] - The hostname of the target baremetal machine.
41
+ * @param {string} [options.ipFileServer=getLocalIPv4Address()] - The IP address of the file server (NFS/TFTP).
42
+ * @param {string} [options.ipConfig=''] - IP configuration string for the baremetal machine.
43
+ * @param {string} [options.netmask=''] - Netmask of network
44
+ * @param {string} [options.dnsServer=''] - DNS server IP address.
57
45
  * @param {boolean} [options.dev=false] - Development mode flag.
58
46
  * @param {boolean} [options.controlServerInstall=false] - Flag to install the control server (e.g., MAAS).
59
47
  * @param {boolean} [options.controlServerUninstall=false] - Flag to uninstall the control server.
@@ -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).
@@ -94,13 +83,13 @@ class UnderpostBaremetal {
94
83
  */
95
84
  async callback(
96
85
  workflowId,
97
- ipAddress,
98
- hostname,
99
- ipFileServer,
100
- ipConfig,
101
- netmask,
102
- dnsServer,
103
86
  options = {
87
+ ipAddress: undefined,
88
+ hostname: undefined,
89
+ ipFileServer: undefined,
90
+ ipConfig: undefined,
91
+ netmask: undefined,
92
+ dnsServer: undefined,
104
93
  dev: false,
105
94
  controlServerInstall: false,
106
95
  controlServerUninstall: false,
@@ -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: '',
@@ -139,6 +129,8 @@ class UnderpostBaremetal {
139
129
  logs: '',
140
130
  },
141
131
  ) {
132
+ let { ipAddress, hostname, ipFileServer, ipConfig, netmask, dnsServer } = options;
133
+
142
134
  // Load environment variables from .env file, overriding existing ones if present.
143
135
  dotenv.config({ path: `${getUnderpostRootPath()}/.env`, override: true });
144
136
 
@@ -192,6 +184,47 @@ class UnderpostBaremetal {
192
184
  // Define the TFTP root prefix path based
193
185
  const tftpRootPath = `${process.env.TFTP_ROOT}/${tftpPrefix}`;
194
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
+
195
228
  // Define the iPXE cache directory to preserve builds across tftproot cleanups
196
229
  const ipxeCacheDir = `/tmp/ipxe-cache/${tftpPrefix}`;
197
230
 
@@ -216,12 +249,7 @@ class UnderpostBaremetal {
216
249
  // Create a new machine in MAAS if the option is set.
217
250
  let machine;
218
251
  if (options.createMachine === true) {
219
- const [searhMachine] = JSON.parse(
220
- shellExec(`maas maas machines read hostname=${hostname}`, {
221
- stdout: true,
222
- silent: true,
223
- }),
224
- );
252
+ const [searhMachine] = Underpost.baremetal.maasCliExec(`machines read hostname=${hostname}`);
225
253
 
226
254
  if (searhMachine) {
227
255
  // Check if existing machine's MAC matches the specified MAC
@@ -236,16 +264,14 @@ class UnderpostBaremetal {
236
264
  logger.info(`Deleting existing machine ${searhMachine.system_id} to recreate with correct MAC...`);
237
265
 
238
266
  // Delete the existing machine
239
- shellExec(`maas maas machine delete ${searhMachine.system_id}`, {
240
- silent: true,
241
- });
267
+ Underpost.baremetal.maasCliExec(`machine delete ${searhMachine.system_id}`);
242
268
 
243
269
  // Create new machine with correct MAC
244
270
  machine = Underpost.baremetal.machineFactory({
245
271
  hostname,
246
272
  ipAddress,
247
273
  macAddress,
248
- maas: workflowsConfig[workflowId].maas,
274
+ architecture: workflowsConfig[workflowId].architecture,
249
275
  }).machine;
250
276
 
251
277
  logger.info(`✓ Machine recreated with MAC ${macAddress}`);
@@ -264,7 +290,7 @@ class UnderpostBaremetal {
264
290
  hostname,
265
291
  ipAddress,
266
292
  macAddress,
267
- maas: workflowsConfig[workflowId].maas,
293
+ architecture: workflowsConfig[workflowId].architecture,
268
294
  }).machine;
269
295
  }
270
296
  }
@@ -349,7 +375,7 @@ class UnderpostBaremetal {
349
375
 
350
376
  // Build phase (skip if upload-only mode)
351
377
  if (options.packerMaasImageBuild) {
352
- if (shellExec('packer version', { silent: true }).code !== 0) {
378
+ if (shellExec('packer version').code !== 0) {
353
379
  throw new Error('Packer is not installed. Please install Packer to proceed.');
354
380
  }
355
381
 
@@ -733,12 +759,7 @@ rm -rf ${artifacts.join(' ')}`);
733
759
 
734
760
  // Fetch boot resources and machines if commissioning or listing.
735
761
 
736
- let resources = JSON.parse(
737
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resources read`, {
738
- silent: true,
739
- stdout: true,
740
- }),
741
- ).map((o) => ({
762
+ let resources = Underpost.baremetal.maasCliExec(`boot-resources read`).map((o) => ({
742
763
  id: o.id,
743
764
  name: o.name,
744
765
  architecture: o.architecture,
@@ -746,12 +767,7 @@ rm -rf ${artifacts.join(' ')}`);
746
767
  if (options.ls === true) {
747
768
  console.table(resources);
748
769
  }
749
- let machines = JSON.parse(
750
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machines read`, {
751
- stdout: true,
752
- silent: true,
753
- }),
754
- ).map((m) => ({
770
+ let machines = Underpost.baremetal.maasCliExec(`machines read`).map((m) => ({
755
771
  system_id: m.interface_set[0].system_id,
756
772
  mac_address: m.interface_set[0].mac_address,
757
773
  hostname: m.hostname,
@@ -873,10 +889,27 @@ rm -rf ${artifacts.join(' ')}`);
873
889
  });
874
890
  }
875
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
+
876
897
  if (options.cloudInit || options.cloudInitUpdate) {
877
898
  const { chronyc, networkInterfaceName } = workflowsConfig[workflowId];
878
899
  const { timezone, chronyConfPath } = chronyc;
879
- 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
+
880
913
  const { cloudConfigSrc } = Underpost.cloudInit.configFactory(
881
914
  {
882
915
  controlServerIp: callbackMetaData.runnerHost.ip,
@@ -889,15 +922,16 @@ rm -rf ${artifacts.join(' ')}`);
889
922
  networkInterfaceName,
890
923
  ubuntuToolsBuild: options.ubuntuToolsBuild,
891
924
  bootcmd: options.bootcmd,
892
- runcmd: options.runcmd,
925
+ runcmd,
926
+ write_files,
893
927
  },
894
928
  authCredentials,
895
929
  );
896
-
897
930
  Underpost.baremetal.httpBootstrapServerStaticFactory({
898
931
  bootstrapHttpServerPath,
899
932
  hostname,
900
933
  cloudConfigSrc,
934
+ isoUrl: workflowsConfig[workflowId].isoUrl,
901
935
  });
902
936
  }
903
937
 
@@ -908,7 +942,7 @@ rm -rf ${artifacts.join(' ')}`);
908
942
  workflowsConfig[workflowId].type === 'chroot-debootstrap' ||
909
943
  workflowsConfig[workflowId].type === 'chroot-container')
910
944
  ) {
911
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
945
+ shellExec(`${underpostRoot}/scripts/nat-iptables.sh`);
912
946
  Underpost.baremetal.rebuildNfsServer({
913
947
  nfsHostPath,
914
948
  });
@@ -1002,15 +1036,22 @@ rm -rf ${artifacts.join(' ')}`);
1002
1036
  hostname,
1003
1037
  dnsServer,
1004
1038
  networkInterfaceName,
1005
- fileSystemUrl: kernelFilesPaths.isoUrl,
1006
- bootstrapHttpServerPort:
1007
- 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
+ }),
1008
1048
  type,
1009
1049
  macAddress,
1010
1050
  cloudInit: options.cloudInit,
1011
1051
  machine,
1012
1052
  dev: options.dev,
1013
1053
  osIdLike: workflowsConfig[workflowId].osIdLike || '',
1054
+ authCredentials,
1014
1055
  });
1015
1056
 
1016
1057
  // Check if iPXE mode is enabled AND the iPXE EFI binary exists
@@ -1084,8 +1125,11 @@ rm -rf ${artifacts.join(' ')}`);
1084
1125
  Underpost.baremetal.httpBootstrapServerRunnerFactory({
1085
1126
  hostname,
1086
1127
  bootstrapHttpServerPath,
1087
- bootstrapHttpServerPort:
1088
- options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort,
1128
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
1129
+ port: options.bootstrapHttpServerPort,
1130
+ workflowId,
1131
+ workflowsConfig,
1132
+ }),
1089
1133
  });
1090
1134
 
1091
1135
  if (type === 'chroot-debootstrap' || type === 'chroot-container')
@@ -1100,11 +1144,7 @@ rm -rf ${artifacts.join(' ')}`);
1100
1144
  macAddress,
1101
1145
  ipAddress,
1102
1146
  hostname,
1103
- architecture:
1104
- workflowsConfig[workflowId].maas?.commissioning?.architecture ||
1105
- workflowsConfig[workflowId].container?.architecture ||
1106
- workflowsConfig[workflowId].debootstrap?.image?.architecture ||
1107
- 'arm64/generic',
1147
+ architecture: Underpost.baremetal.fallbackArchitecture(workflowsConfig[workflowId]),
1108
1148
  machine,
1109
1149
  };
1110
1150
  logger.info('Waiting for commissioning...', {
@@ -1112,7 +1152,42 @@ rm -rf ${artifacts.join(' ')}`);
1112
1152
  machine: machine ? machine.system_id : null,
1113
1153
  });
1114
1154
 
1115
- 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
+ }
1116
1191
 
1117
1192
  if ((type === 'chroot-debootstrap' || type === 'chroot-container') && options.cloudInit === true) {
1118
1193
  openTerminal(`node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init`);
@@ -1126,6 +1201,164 @@ rm -rf ${artifacts.join(' ')}`);
1126
1201
  }
1127
1202
  },
1128
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
+
1129
1362
  /**
1130
1363
  * @method macAddressFactory
1131
1364
  * @description Generates or returns a MAC address based on options.
@@ -1209,7 +1442,7 @@ rm -rf ${artifacts.join(' ')}`);
1209
1442
  shellExec(`mkdir -p ${mountPoint}`);
1210
1443
 
1211
1444
  // Ensure mount point is not already mounted
1212
- shellExec(`sudo umount ${mountPoint} 2>/dev/null`, { silent: true });
1445
+ shellExec(`sudo umount ${mountPoint} 2>/dev/null`);
1213
1446
 
1214
1447
  try {
1215
1448
  // Mount the ISO
@@ -1231,10 +1464,10 @@ rm -rf ${artifacts.join(' ')}`);
1231
1464
 
1232
1465
  shellExec(`sudo chown -R $(whoami):$(whoami) ${extractDir}`);
1233
1466
  // Unmount ISO
1234
- shellExec(`sudo umount ${mountPoint}`, { silent: true });
1467
+ shellExec(`sudo umount ${mountPoint}`);
1235
1468
  logger.info(`Unmounted ISO`);
1236
1469
  // Clean up temporary mount point
1237
- shellExec(`rmdir ${mountPoint}`, { silent: true });
1470
+ shellExec(`rmdir ${mountPoint}`);
1238
1471
  }
1239
1472
 
1240
1473
  return {
@@ -1251,8 +1484,8 @@ rm -rf ${artifacts.join(' ')}`);
1251
1484
  * @param {string} options.macAddress - The MAC address of the machine.
1252
1485
  * @param {string} options.hostname - The hostname for the machine.
1253
1486
  * @param {string} options.ipAddress - The IP address for the machine.
1487
+ * @param {string} options.architecture - The architecture for the machine (default is 'arm64').
1254
1488
  * @param {string} options.powerType - The power type for the machine (default is 'manual').
1255
- * @param {object} options.maas - Additional MAAS-specific options.
1256
1489
  * @returns {object} An object containing the created machine details.
1257
1490
  * @memberof UnderpostBaremetal
1258
1491
  */
@@ -1261,13 +1494,13 @@ rm -rf ${artifacts.join(' ')}`);
1261
1494
  macAddress: '',
1262
1495
  hostname: '',
1263
1496
  ipAddress: '',
1497
+ architecture: 'arm64',
1264
1498
  powerType: 'manual',
1265
- architecture: 'arm64/generic',
1266
1499
  },
1267
1500
  ) {
1268
1501
  if (!options.powerType) options.powerType = 'manual';
1269
1502
  const payload = {
1270
- architecture: (options.architecture || 'arm64/generic').match('arm') ? 'arm64/generic' : 'amd64/generic',
1503
+ architecture: options.architecture.match('arm') ? 'arm64/generic' : 'amd64/generic',
1271
1504
  mac_address: options.macAddress,
1272
1505
  mac_addresses: options.macAddress,
1273
1506
  hostname: options.hostname,
@@ -1275,18 +1508,13 @@ rm -rf ${artifacts.join(' ')}`);
1275
1508
  ip: options.ipAddress,
1276
1509
  };
1277
1510
  logger.info('Creating MAAS machine', payload);
1278
- const machine = shellExec(
1279
- `maas ${process.env.MAAS_ADMIN_USERNAME} machines create ${Object.keys(payload)
1511
+ const machine = Underpost.baremetal.maasCliExec(
1512
+ `machines create ${Object.keys(payload)
1280
1513
  .map((k) => `${k}="${payload[k]}"`)
1281
1514
  .join(' ')}`,
1282
- {
1283
- silent: true,
1284
- stdout: true,
1285
- },
1286
1515
  );
1287
- // console.log(machine);
1288
1516
  try {
1289
- return { machine: JSON.parse(machine) };
1517
+ return { machine };
1290
1518
  } catch (error) {
1291
1519
  console.log(error);
1292
1520
  logger.error(error);
@@ -1323,13 +1551,7 @@ rm -rf ${artifacts.join(' ')}`);
1323
1551
  return { kernelFilesPaths, resourcesPath };
1324
1552
  }
1325
1553
 
1326
- const resourceData = JSON.parse(
1327
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resource read ${resource.id}`, {
1328
- stdout: true,
1329
- silent: true,
1330
- disableLog: true,
1331
- }),
1332
- );
1554
+ const resourceData = Underpost.baremetal.maasCliExec(`boot-resource read ${resource.id}`);
1333
1555
  let kernelFilesPaths = {};
1334
1556
  const bootFiles = resourceData.sets[Object.keys(resourceData.sets)[0]].files;
1335
1557
  const arch = resource.architecture.split('/')[0];
@@ -1390,7 +1612,7 @@ rm -rf ${artifacts.join(' ')}`);
1390
1612
  shellExec(`mkdir -p ${tempExtractDir}`);
1391
1613
 
1392
1614
  // List files in archive to find kernel and initrd
1393
- const tarList = shellExec(`tar -tf ${rootArchivePath}`, { silent: true }).stdout.split('\n');
1615
+ const tarList = shellExec(`tar -tf ${rootArchivePath}`).stdout.split('\n');
1394
1616
 
1395
1617
  // Look for boot/vmlinuz* and boot/initrd* (handling potential leading ./)
1396
1618
  // Skip rescue, kdump, and other special images
@@ -1522,7 +1744,7 @@ rm -rf ${artifacts.join(' ')}`);
1522
1744
  */
1523
1745
  removeDiscoveredMachines() {
1524
1746
  logger.info('Removing all discovered machines from MAAS...');
1525
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
1747
+ Underpost.baremetal.maasCliExec(`discoveries clear all=true`);
1526
1748
  },
1527
1749
 
1528
1750
  /**
@@ -1854,6 +2076,73 @@ shell
1854
2076
  };
1855
2077
  },
1856
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
+
1857
2146
  /**
1858
2147
  * @method httpBootstrapServerStaticFactory
1859
2148
  * @description Creates static files for the bootstrap HTTP server including cloud-init configuration.
@@ -1863,6 +2152,7 @@ shell
1863
2152
  * @param {string} params.cloudConfigSrc - The cloud-init configuration YAML source.
1864
2153
  * @param {object} [params.metadata] - Optional metadata to include in meta-data file.
1865
2154
  * @param {string} [params.vendorData] - Optional vendor-data content (default: empty string).
2155
+ * @param {string} [params.isoUrl] - Optional ISO URL to cache and serve.
1866
2156
  * @memberof UnderpostBaremetal
1867
2157
  * @returns {void}
1868
2158
  */
@@ -1872,16 +2162,13 @@ shell
1872
2162
  cloudConfigSrc,
1873
2163
  metadata = {},
1874
2164
  vendorData = '',
2165
+ isoUrl = '',
1875
2166
  }) {
1876
2167
  // Create directory structure
1877
2168
  shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
1878
2169
 
1879
2170
  // Write user-data file
1880
- fs.writeFileSync(
1881
- `${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`,
1882
- `#cloud-config\n${cloudConfigSrc}`,
1883
- 'utf8',
1884
- );
2171
+ fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`, cloudConfigSrc, 'utf8');
1885
2172
 
1886
2173
  // Write meta-data file
1887
2174
  const metaDataContent = `instance-id: ${metadata.instanceId || hostname}\nlocal-hostname: ${metadata.localHostname || hostname}`;
@@ -1891,6 +2178,22 @@ shell
1891
2178
  fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/vendor-data`, vendorData, 'utf8');
1892
2179
 
1893
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
+ }
1894
2197
  },
1895
2198
 
1896
2199
  /**
@@ -1911,14 +2214,17 @@ shell
1911
2214
  const hostname = options.hostname || 'localhost';
1912
2215
 
1913
2216
  shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
2217
+ shellExec(`node bin run kill ${port}`);
1914
2218
 
1915
- // Kill any existing HTTP server
1916
- shellExec(`sudo pkill -f 'python3 -m http.server ${port}'`, { silent: true });
2219
+ const app = express();
1917
2220
 
1918
- shellExec(
1919
- `cd ${bootstrapHttpServerPath} && nohup python3 -m http.server ${port} --bind 0.0.0.0 > /tmp/http-boot-server.log 2>&1 &`,
1920
- { silent: true, async: true },
1921
- );
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
+ });
1922
2228
 
1923
2229
  // Configure iptables to allow incoming LAN connections
1924
2230
  shellExec(
@@ -1961,7 +2267,7 @@ shell
1961
2267
  // GRUB on ARM64 often crashes with synchronous exception (0x200) if handling large compressed kernels directly.
1962
2268
  if (file === 'vmlinuz-efi') {
1963
2269
  const kernelDest = `${tftpRootPath}/pxe/${file}`;
1964
- const fileType = shellExec(`file ${kernelDest}`, { silent: true }).stdout;
2270
+ const fileType = shellExec(`file ${kernelDest}`).stdout;
1965
2271
 
1966
2272
  // Handle gzip compressed kernels
1967
2273
  if (fileType.includes('gzip compressed data')) {
@@ -1999,8 +2305,14 @@ shell
1999
2305
  * @param {string} options.macAddress - The MAC address of the client.
2000
2306
  * @param {boolean} options.cloudInit - Whether to include cloud-init parameters.
2001
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).
2002
2309
  * @param {boolean} [options.dev=false] - Whether to enable dev mode with dracut debugging parameters.
2003
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.
2004
2316
  * @returns {object} An object containing the constructed command line string.
2005
2317
  * @memberof UnderpostBaremetal
2006
2318
  */
@@ -2022,6 +2334,7 @@ shell
2022
2334
  machine: { system_id: '' },
2023
2335
  dev: false,
2024
2336
  osIdLike: '',
2337
+ authCredentials: { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' },
2025
2338
  },
2026
2339
  ) {
2027
2340
  // Construct kernel command line arguments for NFS boot.
@@ -2042,10 +2355,9 @@ shell
2042
2355
  osIdLike,
2043
2356
  } = options;
2044
2357
 
2045
- const ipParam = true
2046
- ? `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2047
- `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`
2048
- : 'ip=dhcp';
2358
+ const ipParam =
2359
+ `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2360
+ `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`;
2049
2361
 
2050
2362
  const nfsOptions = `${
2051
2363
  type === 'chroot-debootstrap' || type === 'chroot-container'
@@ -2092,7 +2404,7 @@ shell
2092
2404
  // `toram`,
2093
2405
  'nomodeset',
2094
2406
  `editable_rootfs=tmpfs`,
2095
- `ramdisk_size=3550000`,
2407
+ // `ramdisk_size=3550000`,
2096
2408
  // `root=/dev/sda1`, // rpi4 usb port unit
2097
2409
  'apparmor=0', // Disable AppArmor security
2098
2410
  ...(networkInterfaceName === 'eth0'
@@ -2136,49 +2448,32 @@ shell
2136
2448
  if (type === 'iso-ram') {
2137
2449
  const netBootParams = [`netboot=url`];
2138
2450
  if (fileSystemUrl) netBootParams.push(`url=${fileSystemUrl.replace('https', 'http')}`);
2139
- cmd = [ipParam, `boot=casper`, ...netBootParams, ...kernelParams];
2451
+ cmd = [ipParam, `boot=casper`, 'toram', ...netBootParams, ...kernelParams, ...performanceParams];
2140
2452
  } else if (type === 'chroot-debootstrap' || type === 'chroot-container') {
2141
2453
  let qemuNfsRootParams = [`root=/dev/nfs`, `rootfstype=nfs`];
2142
-
2143
- // Determine OS family from osIdLike configuration
2144
- const isRhelBased = osIdLike && osIdLike.match(/rhel|centos|fedora|alma|rocky/i);
2145
- const isDebianBased = osIdLike && osIdLike.match(/debian|ubuntu/i);
2146
-
2147
- // Add RHEL/Rocky/Fedora based images specific parameters
2148
- if (isRhelBased) {
2149
- qemuNfsRootParams = qemuNfsRootParams.concat([`rd.neednet=1`, `rd.timeout=180`, `selinux=0`, `enforcing=0`]);
2150
- }
2151
- // Add Debian/Ubuntu based images specific parameters
2152
- else if (isDebianBased) {
2153
- qemuNfsRootParams = qemuNfsRootParams.concat([`initrd=initrd.img`, `init=/sbin/init`]);
2154
- }
2155
-
2156
- // Add debugging parameters in dev mode for dracut troubleshooting
2157
- if (options.dev) {
2158
- // qemuNfsRootParams = qemuNfsRootParams.concat([`rd.shell`, `rd.debug`]);
2159
- }
2160
-
2161
2454
  cmd = [ipParam, ...qemuNfsRootParams, nfsRootParam, ...kernelParams];
2162
2455
  } else {
2163
2456
  // 'iso-nfs'
2164
2457
  cmd = [ipParam, `netboot=nfs`, nfsRootParam, ...kernelParams, ...performanceParams];
2458
+ // cmd.push(`ifname=${networkInterfaceName}:${macAddress}`);
2459
+ }
2165
2460
 
2166
- cmd.push(`ifname=${networkInterfaceName}:${macAddress}`);
2167
-
2168
- if (cloudInit) {
2169
- const cloudInitPreseedUrl = `http://${ipDhcpServer}:5248/MAAS/metadata/by-id/${options.machine?.system_id ? options.machine.system_id : 'system-id'}/?op=get_preseed`;
2170
- cmd = cmd.concat([
2171
- `cloud-init=enabled`,
2172
- 'autoinstall',
2173
- `cloud-config-url=${cloudInitPreseedUrl}`,
2174
- `ds=nocloud-net;s=${cloudInitPreseedUrl}`,
2175
- `log_host=${ipDhcpServer}`,
2176
- `log_port=5247`,
2177
- // `BOOTIF=${macAddress}`,
2178
- // `cc:{'datasource_list': ['MAAS']}end_cc`,
2179
- ]);
2180
- }
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`]);
2181
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`]);
2474
+ }
2475
+
2476
+ if (cloudInit) cmd = Underpost.cloudInit.kernelParamsFactory(macAddress, cmd, options);
2182
2477
  // cmd.push('---');
2183
2478
  const cmdStr = cmd.join(' ');
2184
2479
  logger.info('Constructed kernel command line');
@@ -2202,12 +2497,7 @@ shell
2202
2497
  async commissionMonitor({ macAddress, ipAddress, hostname, architecture, machine }) {
2203
2498
  {
2204
2499
  // Query observed discoveries from MAAS.
2205
- const discoveries = JSON.parse(
2206
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries read`, {
2207
- silent: true,
2208
- stdout: true,
2209
- }),
2210
- );
2500
+ const discoveries = Underpost.baremetal.maasCliExec(`discoveries read`);
2211
2501
 
2212
2502
  for (const discovery of discoveries) {
2213
2503
  const discoverHostname = discovery.hostname
@@ -2225,6 +2515,20 @@ shell
2225
2515
  if (discovery.ip === ipAddress) {
2226
2516
  logger.info('Machine discovered!', discovery);
2227
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
+
2228
2532
  logger.info('Creating new machine with discovered hardware MAC...', {
2229
2533
  discoveredMAC: discovery.mac_address,
2230
2534
  ipAddress,
@@ -2236,12 +2540,18 @@ shell
2236
2540
  hostname,
2237
2541
  architecture,
2238
2542
  }).machine;
2239
- console.log('New machine system id:', machine.system_id.bgYellow.bold.black);
2240
- Underpost.baremetal.writeGrubConfigToFile({
2241
- grubCfgSrc: Underpost.baremetal
2242
- .getGrubConfigFromFile()
2243
- .grubCfgSrc.replaceAll('system-id', machine.system_id),
2244
- });
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
+ }
2245
2555
  } else {
2246
2556
  const systemId = machine.system_id;
2247
2557
  console.log('Using pre-registered machine system_id:', systemId.bgYellow.bold.black);
@@ -2254,22 +2564,45 @@ shell
2254
2564
  discoveredMAC: discovery.mac_address,
2255
2565
  });
2256
2566
 
2257
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-broken ${systemId}`, {
2258
- silent: true,
2259
- });
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
+ }
2260
2587
 
2261
- shellExec(
2262
- // name=${networkInterfaceName}
2263
- `maas ${process.env.MAAS_ADMIN_USERNAME} interface update ${systemId} ${machine.boot_interface.id}` +
2264
- ` mac_address=${discovery.mac_address}`,
2265
- {
2266
- silent: true,
2267
- },
2588
+ Underpost.baremetal.maasCliExec(
2589
+ `interface update ${systemId} ${machine.boot_interface.id}` + ` mac_address=${discovery.mac_address}`,
2268
2590
  );
2269
2591
 
2270
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-fixed ${systemId}`, {
2271
- silent: true,
2272
- });
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
+ }
2273
2606
 
2274
2607
  logger.info('✓ Machine interface MAC address updated successfully');
2275
2608
 
@@ -2298,6 +2631,73 @@ shell
2298
2631
  }
2299
2632
  },
2300
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
+
2301
2701
  /**
2302
2702
  * @method mountBinfmtMisc
2303
2703
  * @description Mounts the binfmt_misc filesystem to enable QEMU user-static binfmt support.
@@ -2333,7 +2733,7 @@ shell
2333
2733
  const systemId = typeof machine === 'string' ? machine : machine.system_id;
2334
2734
  if (ignore && ignore.find((mId) => mId === systemId)) continue;
2335
2735
  logger.info(`Removing machine: ${systemId}`);
2336
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${systemId}`);
2736
+ Underpost.baremetal.maasCliExec(`machine delete ${systemId}`);
2337
2737
  }
2338
2738
  return [];
2339
2739
  },
@@ -2347,9 +2747,9 @@ shell
2347
2747
  * @returns {void}
2348
2748
  */
2349
2749
  clearDiscoveries({ force }) {
2350
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
2750
+ Underpost.baremetal.maasCliExec(`discoveries clear all=true`);
2351
2751
  if (force === true) {
2352
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries scan force=true`);
2752
+ Underpost.baremetal.maasCliExec(`discoveries scan force=true`);
2353
2753
  }
2354
2754
  },
2355
2755
 
@@ -2965,10 +3365,10 @@ udp-port = 32766
2965
3365
  // Check both /usr/local/bin (compiled) and system paths
2966
3366
  let qemuAarch64Path = null;
2967
3367
 
2968
- 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) {
2969
3369
  qemuAarch64Path = '/usr/local/bin/qemu-system-aarch64';
2970
- } else if (shellExec('which qemu-system-aarch64', { silent: true }).code === 0) {
2971
- 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();
2972
3372
  }
2973
3373
 
2974
3374
  if (!qemuAarch64Path) {
@@ -2981,7 +3381,7 @@ udp-port = 32766
2981
3381
  logger.info(`Found qemu-system-aarch64 at: ${qemuAarch64Path}`);
2982
3382
 
2983
3383
  // Verify that the installed qemu supports the 'virt' machine type (required for arm64)
2984
- const machineHelp = shellExec(`${qemuAarch64Path} -machine help`, { silent: true }).stdout;
3384
+ const machineHelp = shellExec(`${qemuAarch64Path} -machine help`).stdout;
2985
3385
  if (!machineHelp.includes('virt')) {
2986
3386
  throw new Error(
2987
3387
  'The installed qemu-system-aarch64 does not support the "virt" machine type.\n' +
@@ -2994,10 +3394,10 @@ udp-port = 32766
2994
3394
  // Check both /usr/local/bin (compiled) and system paths
2995
3395
  let qemuX86Path = null;
2996
3396
 
2997
- 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) {
2998
3398
  qemuX86Path = '/usr/local/bin/qemu-system-x86_64';
2999
- } else if (shellExec('which qemu-system-x86_64', { silent: true }).code === 0) {
3000
- 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();
3001
3401
  }
3002
3402
 
3003
3403
  if (!qemuX86Path) {
@@ -3010,7 +3410,7 @@ udp-port = 32766
3010
3410
  logger.info(`Found qemu-system-x86_64 at: ${qemuX86Path}`);
3011
3411
 
3012
3412
  // Verify that the installed qemu supports the 'pc' or 'q35' machine type (required for x86_64)
3013
- const machineHelp = shellExec(`${qemuX86Path} -machine help`, { silent: true }).stdout;
3413
+ const machineHelp = shellExec(`${qemuX86Path} -machine help`).stdout;
3014
3414
  if (!machineHelp.includes('pc') && !machineHelp.includes('q35')) {
3015
3415
  throw new Error(
3016
3416
  'The installed qemu-system-x86_64 does not support the "pc" or "q35" machine type.\n' +