underpost 2.99.4 → 2.99.6

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 (38) hide show
  1. package/.env.development +0 -3
  2. package/.env.production +1 -3
  3. package/.env.test +0 -3
  4. package/README.md +3 -3
  5. package/baremetal/commission-workflows.json +93 -4
  6. package/bin/deploy.js +56 -45
  7. package/cli.md +45 -28
  8. package/examples/static-page/README.md +101 -357
  9. package/examples/static-page/ssr-components/CustomPage.js +1 -13
  10. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +40 -0
  11. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +40 -0
  12. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  13. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  14. package/package.json +3 -4
  15. package/scripts/disk-devices.sh +13 -0
  16. package/scripts/maas-setup.sh +13 -9
  17. package/scripts/rocky-kickstart.sh +294 -0
  18. package/src/cli/baremetal.js +657 -263
  19. package/src/cli/cloud-init.js +120 -120
  20. package/src/cli/env.js +4 -1
  21. package/src/cli/image.js +4 -37
  22. package/src/cli/index.js +56 -11
  23. package/src/cli/kickstart.js +149 -0
  24. package/src/cli/repository.js +3 -1
  25. package/src/cli/run.js +56 -10
  26. package/src/cli/secrets.js +0 -34
  27. package/src/cli/static.js +23 -23
  28. package/src/client/components/core/Docs.js +22 -3
  29. package/src/index.js +30 -5
  30. package/src/server/backup.js +11 -4
  31. package/src/server/client-build-docs.js +1 -1
  32. package/src/server/conf.js +0 -22
  33. package/src/server/cron.js +339 -130
  34. package/src/server/dns.js +10 -0
  35. package/src/server/logger.js +22 -27
  36. package/src/server/tls.js +14 -14
  37. package/examples/static-page/QUICK-REFERENCE.md +0 -481
  38. package/examples/static-page/STATIC-GENERATOR-GUIDE.md +0 -757
@@ -6,15 +6,16 @@
6
6
 
7
7
  import { fileURLToPath } from 'url';
8
8
  import { getNpmRootPath, getUnderpostRootPath } from '../server/conf.js';
9
- import { openTerminal, pbcopy, shellExec } from '../server/process.js';
9
+ import { 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).
@@ -76,6 +65,7 @@ class UnderpostBaremetal {
76
65
  * @param {boolean} [options.commission=false] - Flag to commission the baremetal machine.
77
66
  * @param {number} [options.bootstrapHttpServerPort=8888] - Port for the bootstrap HTTP server.
78
67
  * @param {string} [options.bootstrapHttpServerPath='./public/localhost'] - Path for the bootstrap HTTP server files.
68
+ * @param {boolean} [options.bootstrapHttpServerRun=false] - Flag to start the bootstrap HTTP server.
79
69
  * @param {string} [options.isoUrl=''] - Uses a custom ISO URL for baremetal machine commissioning.
80
70
  * @param {boolean} [options.ubuntuToolsBuild=false] - Builds ubuntu tools for chroot environment.
81
71
  * @param {boolean} [options.ubuntuToolsTest=false] - Tests ubuntu tools in chroot environment.
@@ -86,6 +76,7 @@ class UnderpostBaremetal {
86
76
  * @param {boolean} [options.nfsBuild=false] - Flag to build the NFS root filesystem.
87
77
  * @param {boolean} [options.nfsBuildServer=false] - Flag to build the NFS server components.
88
78
  * @param {boolean} [options.nfsMount=false] - Flag to mount the NFS root filesystem.
79
+ * @param {boolean} [options.nfsReset=false] - Flag to reset the NFS environment by unmounting and cleaning the host path.
89
80
  * @param {boolean} [options.nfsUnmount=false] - Flag to unmount the NFS root filesystem.
90
81
  * @param {boolean} [options.nfsSh=false] - Flag to chroot into the NFS environment for shell access.
91
82
  * @param {string} [options.logs=''] - Specifies which logs to display ('dhcp', 'cloud', 'machine', 'cloud-config').
@@ -111,6 +102,7 @@ class UnderpostBaremetal {
111
102
  mac: '',
112
103
  ipxe: false,
113
104
  ipxeRebuild: false,
105
+ ipxeBuildIso: '',
114
106
  installPacker: false,
115
107
  packerMaasImageTemplate: false,
116
108
  packerWorkflowId: '',
@@ -124,6 +116,7 @@ class UnderpostBaremetal {
124
116
  commission: false,
125
117
  bootstrapHttpServerPort: 8888,
126
118
  bootstrapHttpServerPath: './public/localhost',
119
+ bootstrapHttpServerRun: false,
127
120
  isoUrl: '',
128
121
  ubuntuToolsBuild: false,
129
122
  ubuntuToolsTest: false,
@@ -134,6 +127,7 @@ class UnderpostBaremetal {
134
127
  nfsBuild: false,
135
128
  nfsBuildServer: false,
136
129
  nfsMount: false,
130
+ nfsReset: false,
137
131
  nfsUnmount: false,
138
132
  nfsSh: false,
139
133
  logs: '',
@@ -218,12 +212,7 @@ class UnderpostBaremetal {
218
212
  // Create a new machine in MAAS if the option is set.
219
213
  let machine;
220
214
  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
- );
215
+ const [searhMachine] = Underpost.baremetal.maasCliExec(`machines read hostname=${hostname}`);
227
216
 
228
217
  if (searhMachine) {
229
218
  // Check if existing machine's MAC matches the specified MAC
@@ -238,16 +227,14 @@ class UnderpostBaremetal {
238
227
  logger.info(`Deleting existing machine ${searhMachine.system_id} to recreate with correct MAC...`);
239
228
 
240
229
  // Delete the existing machine
241
- shellExec(`maas maas machine delete ${searhMachine.system_id}`, {
242
- silent: true,
243
- });
230
+ Underpost.baremetal.maasCliExec(`machine delete ${searhMachine.system_id}`);
244
231
 
245
232
  // Create new machine with correct MAC
246
233
  machine = Underpost.baremetal.machineFactory({
247
234
  hostname,
248
235
  ipAddress,
249
236
  macAddress,
250
- maas: workflowsConfig[workflowId].maas,
237
+ architecture: workflowsConfig[workflowId].architecture,
251
238
  }).machine;
252
239
 
253
240
  logger.info(`✓ Machine recreated with MAC ${macAddress}`);
@@ -266,12 +253,33 @@ class UnderpostBaremetal {
266
253
  hostname,
267
254
  ipAddress,
268
255
  macAddress,
269
- maas: workflowsConfig[workflowId].maas,
256
+ architecture: workflowsConfig[workflowId].architecture,
270
257
  }).machine;
271
258
  }
272
259
  }
273
260
  }
274
261
 
262
+ if (options.ipxeBuildIso)
263
+ return await Underpost.baremetal.ipxeBuildIso({
264
+ workflowId,
265
+ isoOutputPath: options.ipxeBuildIso,
266
+ tftpPrefix,
267
+ ipFileServer,
268
+ ipAddress,
269
+ ipConfig,
270
+ netmask,
271
+ dnsServer,
272
+ macAddress,
273
+ cloudInit: options.cloudInit,
274
+ dev: options.dev,
275
+ forceRebuild: options.ipxeRebuild,
276
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
277
+ port: options.bootstrapHttpServerPort,
278
+ workflowId,
279
+ workflowsConfig,
280
+ }),
281
+ });
282
+
275
283
  if (options.installPacker) {
276
284
  await Underpost.baremetal.installPacker(underpostRoot);
277
285
  return;
@@ -351,7 +359,7 @@ class UnderpostBaremetal {
351
359
 
352
360
  // Build phase (skip if upload-only mode)
353
361
  if (options.packerMaasImageBuild) {
354
- if (shellExec('packer version', { silent: true }).code !== 0) {
362
+ if (shellExec('packer version').code !== 0) {
355
363
  throw new Error('Packer is not installed. Please install Packer to proceed.');
356
364
  }
357
365
 
@@ -414,29 +422,9 @@ rm -rf ${artifacts.join(' ')}`);
414
422
 
415
423
  logger.info(`Uploading image to MAAS...`);
416
424
 
417
- // Detect MAAS profile from 'maas list' output
418
- let maasProfile = process.env.MAAS_ADMIN_USERNAME;
419
- if (!maasProfile) {
420
- const profileList = shellExec('maas list', { silent: true, stdout: true });
421
- if (profileList) {
422
- const firstLine = profileList.trim().split('\n')[0];
423
- const match = firstLine.match(/^(\S+)\s+http/);
424
- if (match) {
425
- maasProfile = match[1];
426
- logger.info(`Detected MAAS profile: ${maasProfile}`);
427
- }
428
- }
429
- }
430
-
431
- if (!maasProfile) {
432
- throw new Error(
433
- 'MAAS profile not found. Please run "maas login" first or set MAAS_ADMIN_USERNAME environment variable.',
434
- );
435
- }
436
-
437
425
  // Use the upload script to avoid MAAS CLI bugs
438
426
  const uploadScript = `${underpostRoot}/scripts/maas-upload-boot-resource.sh`;
439
- const uploadCmd = `${uploadScript} ${maasProfile} "${workflow.maas.name}" "${workflow.maas.title}" "${workflow.maas.architecture}" "${workflow.maas.base_image}" "${workflow.maas.filetype}" "${tarballPath}"`;
427
+ const uploadCmd = `${uploadScript} ${process.env.MAAS_ADMIN_USERNAME} "${workflow.maas.name}" "${workflow.maas.title}" "${workflow.maas.architecture}" "${workflow.maas.base_image}" "${workflow.maas.filetype}" "${tarballPath}"`;
440
428
 
441
429
  logger.info(`Uploading to MAAS using: ${uploadScript}`);
442
430
  const uploadResult = shellExec(uploadCmd);
@@ -679,7 +667,11 @@ rm -rf ${artifacts.join(' ')}`);
679
667
  bootstrapArch,
680
668
  callbackMetaData,
681
669
  steps: [
682
- `dnf install -y --allowerasing ${allPackages.join(' ')} 2>/dev/null || yum install -y --allowerasing ${allPackages.join(' ')} 2>/dev/null || echo "Package install completed"`,
670
+ `dnf install -y --allowerasing ${allPackages.join(
671
+ ' ',
672
+ )} 2>/dev/null || yum install -y --allowerasing ${allPackages.join(
673
+ ' ',
674
+ )} 2>/dev/null || echo "Package install completed"`,
683
675
  `dnf clean all`,
684
676
  `echo "=== Installed packages verification ==="`,
685
677
  `rpm -qa | grep -E "dracut|kernel|nfs" | sort`,
@@ -735,12 +727,7 @@ rm -rf ${artifacts.join(' ')}`);
735
727
 
736
728
  // Fetch boot resources and machines if commissioning or listing.
737
729
 
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) => ({
730
+ let resources = Underpost.baremetal.maasCliExec(`boot-resources read`).map((o) => ({
744
731
  id: o.id,
745
732
  name: o.name,
746
733
  architecture: o.architecture,
@@ -748,12 +735,7 @@ rm -rf ${artifacts.join(' ')}`);
748
735
  if (options.ls === true) {
749
736
  console.table(resources);
750
737
  }
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) => ({
738
+ let machines = Underpost.baremetal.maasCliExec(`machines read`).map((m) => ({
757
739
  system_id: m.interface_set[0].system_id,
758
740
  mac_address: m.interface_set[0].mac_address,
759
741
  hostname: m.hostname,
@@ -819,7 +801,6 @@ rm -rf ${artifacts.join(' ')}`);
819
801
  `chmod +x /underpost/shutdown.sh`,
820
802
  `chmod +x /underpost/device_scan.sh`,
821
803
  `chmod +x /underpost/mac.sh`,
822
- `chmod +x /underpost/enlistment.sh`,
823
804
  `sudo chmod 700 ~/.ssh/`, // Set secure permissions for .ssh directory.
824
805
  `sudo chmod 600 ~/.ssh/authorized_keys`, // Set secure permissions for authorized_keys.
825
806
  `sudo chmod 644 ~/.ssh/known_hosts`, // Set permissions for known_hosts.
@@ -875,11 +856,31 @@ rm -rf ${artifacts.join(' ')}`);
875
856
  });
876
857
  }
877
858
 
859
+ // Generate MAAS authentication credentials
860
+ const authCredentials =
861
+ options.commission || options.cloudInit || options.cloudInitUpdate
862
+ ? Underpost.baremetal.maasAuthCredentialsFactory()
863
+ : { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' };
864
+
865
+ // Generate cloud-init configuration if needed for commissioning or cloud-init update workflows.
866
+ let cloudConfigSrc = '';
878
867
  if (options.cloudInit || options.cloudInitUpdate) {
879
868
  const { chronyc, networkInterfaceName } = workflowsConfig[workflowId];
880
869
  const { timezone, chronyConfPath } = chronyc;
881
- const authCredentials = Underpost.cloudInit.authCredentialsFactory();
882
- const { cloudConfigSrc } = Underpost.cloudInit.configFactory(
870
+
871
+ let write_files = [];
872
+ let runcmd = options.runcmd;
873
+
874
+ if (machine && options.commission) {
875
+ write_files = Underpost.baremetal.commissioningWriteFilesFactory({
876
+ machine,
877
+ authCredentials,
878
+ runnerHostIp: callbackMetaData.runnerHost.ip,
879
+ });
880
+ runcmd = '/usr/local/bin/underpost-enlist.sh';
881
+ }
882
+
883
+ cloudConfigSrc = Underpost.cloudInit.configFactory(
883
884
  {
884
885
  controlServerIp: callbackMetaData.runnerHost.ip,
885
886
  hostname,
@@ -891,15 +892,48 @@ rm -rf ${artifacts.join(' ')}`);
891
892
  networkInterfaceName,
892
893
  ubuntuToolsBuild: options.ubuntuToolsBuild,
893
894
  bootcmd: options.bootcmd,
894
- runcmd: options.runcmd,
895
+ runcmd,
896
+ write_files,
895
897
  },
896
898
  authCredentials,
897
- );
899
+ ).cloudConfigSrc;
900
+ }
901
+
902
+ // Rocky/RHEL Kickstart generation
903
+ let kickstartSrc = '';
904
+ if (Underpost.baremetal.getFamilyBaseOs(workflowsConfig[workflowId].osIdLike).isRhelBased) {
905
+ kickstartSrc = Underpost.kickstart.kickstartFactory({
906
+ lang: 'en_US.UTF-8',
907
+ keyboard: workflowsConfig[workflowId].keyboard?.layout,
908
+ timezone: workflowsConfig[workflowId].chronyc?.timezone,
909
+ rootPassword: process.env.MAAS_ADMIN_PASS,
910
+ authorizedKeys: fs.readFileSync('/home/dd/engine/engine-private/deploy/id_rsa.pub', 'utf8').trim(),
911
+ });
912
+ }
898
913
 
914
+ // Build and optionally run the HTTP bootstrap server to serve cloud-init, kickstart, and ISO resources for commissioning and provisioning.
915
+ if (cloudConfigSrc || kickstartSrc || workflowsConfig[workflowId].isoUrl)
899
916
  Underpost.baremetal.httpBootstrapServerStaticFactory({
900
917
  bootstrapHttpServerPath,
901
918
  hostname,
902
919
  cloudConfigSrc,
920
+ kickstartSrc,
921
+ isoUrl: workflowsConfig[workflowId].isoUrl,
922
+ });
923
+
924
+ // Set up iptables rules for NAT and port forwarding to enable network connectivity for the baremetal machines.
925
+ shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
926
+
927
+ // Start HTTP bootstrap server if commissioning or if ISO URL is used (for ISO-based workflows).
928
+ if (options.bootstrapHttpServerRun || options.commission) {
929
+ Underpost.baremetal.httpBootstrapServerRunnerFactory({
930
+ hostname,
931
+ bootstrapHttpServerPath,
932
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
933
+ port: options.bootstrapHttpServerPort,
934
+ workflowId,
935
+ workflowsConfig,
936
+ }),
903
937
  });
904
938
  }
905
939
 
@@ -909,12 +943,12 @@ rm -rf ${artifacts.join(' ')}`);
909
943
  (workflowsConfig[workflowId].type === 'iso-nfs' ||
910
944
  workflowsConfig[workflowId].type === 'chroot-debootstrap' ||
911
945
  workflowsConfig[workflowId].type === 'chroot-container')
912
- ) {
913
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
946
+ )
914
947
  Underpost.baremetal.rebuildNfsServer({
915
948
  nfsHostPath,
949
+ nfsReset: options.nfsReset,
916
950
  });
917
- }
951
+
918
952
  // Handle commissioning tasks
919
953
  if (options.commission === true) {
920
954
  let { firmwares, networkInterfaceName, maas, menuentryStr, type } = workflowsConfig[workflowId];
@@ -929,9 +963,11 @@ rm -rf ${artifacts.join(' ')}`);
929
963
  );
930
964
  logger.info('Commissioning resource', resource);
931
965
 
932
- if (type === 'iso-nfs') {
966
+ if (
967
+ Underpost.baremetal.getFamilyBaseOs(workflowsConfig[workflowId].osIdLike).isDebianBased &&
968
+ (type === 'iso-nfs' || type === 'chroot-debootstrap' || type === 'chroot-container')
969
+ ) {
933
970
  // Prepare NFS casper path if using NFS boot.
934
- shellExec(`sudo rm -rf ${nfsHostPath}`);
935
971
  shellExec(`mkdir -p ${nfsHostPath}/casper`);
936
972
  }
937
973
 
@@ -1004,15 +1040,26 @@ rm -rf ${artifacts.join(' ')}`);
1004
1040
  hostname,
1005
1041
  dnsServer,
1006
1042
  networkInterfaceName,
1007
- fileSystemUrl: kernelFilesPaths.isoUrl,
1008
- bootstrapHttpServerPort:
1009
- options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort || 8888,
1043
+ fileSystemUrl:
1044
+ type === 'iso-ram'
1045
+ ? `http://${callbackMetaData.runnerHost.ip}:${Underpost.baremetal.bootstrapHttpServerPortFactory({
1046
+ port: options.bootstrapHttpServerPort,
1047
+ workflowId,
1048
+ workflowsConfig,
1049
+ })}/${hostname}/${kernelFilesPaths.isoUrl.split('/').pop()}`
1050
+ : kernelFilesPaths.isoUrl,
1051
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
1052
+ port: options.bootstrapHttpServerPort,
1053
+ workflowId,
1054
+ workflowsConfig,
1055
+ }),
1010
1056
  type,
1011
1057
  macAddress,
1012
1058
  cloudInit: options.cloudInit,
1013
- machine,
1014
1059
  dev: options.dev,
1015
1060
  osIdLike: workflowsConfig[workflowId].osIdLike || '',
1061
+ authCredentials,
1062
+ architecture: workflowsConfig[workflowId].architecture,
1016
1063
  });
1017
1064
 
1018
1065
  // Check if iPXE mode is enabled AND the iPXE EFI binary exists
@@ -1083,13 +1130,6 @@ rm -rf ${artifacts.join(' ')}`);
1083
1130
  shellExec(`sudo chown -R $(whoami):$(whoami) ${process.env.TFTP_ROOT}`);
1084
1131
  shellExec(`sudo sudo chmod 755 ${process.env.TFTP_ROOT}`);
1085
1132
 
1086
- Underpost.baremetal.httpBootstrapServerRunnerFactory({
1087
- hostname,
1088
- bootstrapHttpServerPath,
1089
- bootstrapHttpServerPort:
1090
- options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort,
1091
- });
1092
-
1093
1133
  if (type === 'chroot-debootstrap' || type === 'chroot-container')
1094
1134
  await Underpost.baremetal.nfsMountCallback({
1095
1135
  hostname,
@@ -1102,11 +1142,7 @@ rm -rf ${artifacts.join(' ')}`);
1102
1142
  macAddress,
1103
1143
  ipAddress,
1104
1144
  hostname,
1105
- architecture:
1106
- workflowsConfig[workflowId].maas?.commissioning?.architecture ||
1107
- workflowsConfig[workflowId].container?.architecture ||
1108
- workflowsConfig[workflowId].debootstrap?.image?.architecture ||
1109
- 'arm64/generic',
1145
+ architecture: Underpost.baremetal.fallbackArchitecture(workflowsConfig[workflowId]),
1110
1146
  machine,
1111
1147
  };
1112
1148
  logger.info('Waiting for commissioning...', {
@@ -1114,20 +1150,177 @@ rm -rf ${artifacts.join(' ')}`);
1114
1150
  machine: machine ? machine.system_id : null,
1115
1151
  });
1116
1152
 
1117
- const { discovery } = await Underpost.baremetal.commissionMonitor(commissionMonitorPayload);
1153
+ const { discovery, machine: discoveredMachine } =
1154
+ await Underpost.baremetal.commissionMonitor(commissionMonitorPayload);
1155
+ if (discoveredMachine) machine = discoveredMachine;
1156
+ }
1157
+ },
1118
1158
 
1119
- if ((type === 'chroot-debootstrap' || type === 'chroot-container') && options.cloudInit === true) {
1120
- openTerminal(`node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init`);
1121
- openTerminal(
1122
- `node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init-machine`,
1123
- );
1124
- shellExec(
1125
- `node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init-config`,
1126
- );
1159
+ /**
1160
+ * @method installPacker
1161
+ * @description Installs Packer CLI.
1162
+ * @memberof UnderpostBaremetal
1163
+ * @returns {Promise<void>}
1164
+ */
1165
+ async installPacker(underpostRoot) {
1166
+ const scriptPath = `${underpostRoot}/scripts/packer-setup.sh`;
1167
+ logger.info(`Installing Packer using script: ${scriptPath}`);
1168
+ shellExec(`sudo chmod +x ${scriptPath}`);
1169
+ shellExec(`sudo ${scriptPath}`);
1170
+ },
1171
+
1172
+ /**
1173
+ * @method ipxeBuildIso
1174
+ * @description Builds a UEFI-bootable iPXE ISO with an embedded bridge script.
1175
+ * @param {object} params
1176
+ * @param {string} params.workflowId - The workflow identifier (e.g., 'hp-envy-iso-ram').
1177
+ * @param {string} params.isoOutputPath - Output path for the generated ISO file.
1178
+ * @param {string} params.tftpPrefix - TFTP prefix directory (e.g., 'envy').
1179
+ * @param {string} params.ipFileServer - IP address of the TFTP/file server to chain to.
1180
+ * @param {string} [params.ipAddress='192.168.1.191'] - The IP address of the client machine.
1181
+ * @param {string} [params.ipConfig='none'] - IP configuration method (e.g., 'dhcp', 'none').
1182
+ * @param {string} [params.netmask='255.255.255.0'] - The network mask.
1183
+ * @param {string} [params.dnsServer='8.8.8.8'] - The DNS server address.
1184
+ * @param {string} [params.macAddress=''] - The MAC address of the client machine.
1185
+ * @param {boolean} [params.cloudInit=false] - Flag to enable cloud-init.
1186
+ * @param {boolean} [params.dev=false] - Development mode flag to determine paths.
1187
+ * @param {boolean} [params.forceRebuild=false] - Force a complete iPXE rebuild. Without this, reuses existing ISO.
1188
+ * @param {number} [params.bootstrapHttpServerPort=8888] - Port for the bootstrap HTTP server used in ISO RAM workflows.
1189
+ * @memberof UnderpostBaremetal
1190
+ * @returns {Promise<void>}
1191
+ */
1192
+ async ipxeBuildIso({
1193
+ workflowId,
1194
+ isoOutputPath,
1195
+ tftpPrefix,
1196
+ ipFileServer,
1197
+ ipAddress,
1198
+ ipConfig,
1199
+ netmask,
1200
+ dnsServer,
1201
+ macAddress,
1202
+ cloudInit,
1203
+ dev,
1204
+ forceRebuild = false,
1205
+ bootstrapHttpServerPort,
1206
+ }) {
1207
+ const outputPath = !isoOutputPath || isoOutputPath === '.' ? `./ipxe-${workflowId}.iso` : isoOutputPath;
1208
+ shellExec(`mkdir -p $(dirname ${outputPath})`);
1209
+
1210
+ const workflowsConfig = Underpost.baremetal.loadWorkflowsConfig();
1211
+ if (!workflowsConfig[workflowId]) {
1212
+ throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
1213
+ }
1214
+
1215
+ const authCredentials = cloudInit
1216
+ ? Underpost.baremetal.maasAuthCredentialsFactory()
1217
+ : { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' };
1218
+
1219
+ const { cmd } = Underpost.baremetal.kernelCmdBootParamsFactory({
1220
+ ipClient: ipAddress,
1221
+ ipDhcpServer: ipFileServer,
1222
+ ipFileServer,
1223
+ ipConfig,
1224
+ netmask,
1225
+ hostname: workflowId,
1226
+ dnsServer,
1227
+ fileSystemUrl:
1228
+ dev && workflowsConfig[workflowId].type === 'iso-ram'
1229
+ ? `http://${ipFileServer}:${Underpost.baremetal.bootstrapHttpServerPortFactory({
1230
+ port: bootstrapHttpServerPort,
1231
+ workflowId,
1232
+ workflowsConfig,
1233
+ })}/${workflowId}/${workflowsConfig[workflowId].isoUrl.split('/').pop()}`
1234
+ : workflowsConfig[workflowId].isoUrl,
1235
+ type: workflowsConfig[workflowId].type,
1236
+ architecture: workflowsConfig[workflowId].architecture,
1237
+ macAddress,
1238
+ cloudInit,
1239
+ osIdLike: workflowsConfig[workflowId].osIdLike,
1240
+ networkInterfaceName: workflowsConfig[workflowId].networkInterfaceName,
1241
+ authCredentials,
1242
+ bootstrapHttpServerPort: Underpost.baremetal.bootstrapHttpServerPortFactory({
1243
+ port: bootstrapHttpServerPort,
1244
+ workflowId,
1245
+ workflowsConfig,
1246
+ }),
1247
+ dev,
1248
+ });
1249
+
1250
+ const ipxeSrcDir = '/home/dd/ipxe/src';
1251
+ const embedScriptName = `embed_${workflowId}.ipxe`;
1252
+ const embedScriptPath = path.join(ipxeSrcDir, embedScriptName);
1253
+
1254
+ const embedScriptContent = Underpost.baremetal.ipxeScriptFactory({
1255
+ maasIp: ipFileServer,
1256
+ tftpPrefix,
1257
+ kernelCmd: cmd,
1258
+ minimal: true,
1259
+ });
1260
+
1261
+ fs.writeFileSync(embedScriptPath, embedScriptContent);
1262
+ logger.info(`Created embedded script at ${embedScriptPath}`);
1263
+
1264
+ // Determine target architecture
1265
+ let targetArch = 'x86_64';
1266
+ if (
1267
+ workflowsConfig[workflowId].architecture === 'arm64' ||
1268
+ workflowsConfig[workflowId].architecture === 'aarch64'
1269
+ ) {
1270
+ targetArch = 'arm64';
1271
+ }
1272
+
1273
+ const platformDir = targetArch === 'arm64' ? 'bin-arm64-efi' : 'bin-x86_64-efi';
1274
+ const makeTarget = `${platformDir}/ipxe.iso`;
1275
+ const builtIsoPath = path.join(ipxeSrcDir, makeTarget);
1276
+
1277
+ if (!forceRebuild && fs.existsSync(builtIsoPath)) {
1278
+ fs.copySync(builtIsoPath, outputPath);
1279
+ logger.info(`Reusing existing iPXE ISO: ${builtIsoPath} -> ${outputPath}`);
1280
+ } else {
1281
+ logger.info(`Rebuild: cleaning iPXE build artifacts...`);
1282
+ shellExec(`cd ${ipxeSrcDir} && make clean`);
1283
+
1284
+ const hostArch = process.arch === 'arm64' ? 'arm64' : 'x86_64';
1285
+ let crossCompile = '';
1286
+ if (hostArch === 'x86_64' && targetArch === 'arm64') {
1287
+ crossCompile = 'CROSS_COMPILE=aarch64-linux-gnu-';
1288
+ } else if (hostArch === 'arm64' && targetArch === 'x86_64') {
1289
+ crossCompile = 'CROSS_COMPILE=x86_64-linux-gnu-';
1290
+ }
1291
+
1292
+ logger.info(
1293
+ `Building iPXE ISO for ${targetArch} on ${hostArch}: make ${makeTarget} ${crossCompile} EMBED=${embedScriptName}`,
1294
+ );
1295
+
1296
+ const buildCmd = `cd ${ipxeSrcDir} && make ${makeTarget} ${crossCompile} EMBED=${embedScriptName}`;
1297
+ shellExec(buildCmd);
1298
+
1299
+ if (fs.existsSync(builtIsoPath)) {
1300
+ fs.copySync(builtIsoPath, outputPath);
1301
+ logger.info(`ISO successfully built and copied to ${outputPath}`);
1302
+ } else {
1303
+ logger.error(`Failed to build ISO at ${builtIsoPath}`);
1127
1304
  }
1128
1305
  }
1129
1306
  },
1130
1307
 
1308
+ /**
1309
+ * @method fallbackArchitecture
1310
+ * @description Determines the architecture to use for boot resources, with a fallback mechanism.
1311
+ * @param {object} workflowsConfig - The configuration object for the current workflow.
1312
+ * @returns {string} The architecture string (e.g., 'arm64', 'amd64') to use for boot resources.
1313
+ * @memberof UnderpostBaremetal
1314
+ */
1315
+ fallbackArchitecture(workflowsConfig) {
1316
+ return (
1317
+ workflowsConfig.architecture ||
1318
+ workflowsConfig.maas?.commissioning?.architecture ||
1319
+ workflowsConfig.container?.architecture ||
1320
+ workflowsConfig.debootstrap?.image?.architecture
1321
+ );
1322
+ },
1323
+
1131
1324
  /**
1132
1325
  * @method macAddressFactory
1133
1326
  * @description Generates or returns a MAC address based on options.
@@ -1179,8 +1372,7 @@ rm -rf ${artifacts.join(' ')}`);
1179
1372
  const isoFilename = isoUrl.split('/').pop();
1180
1373
 
1181
1374
  // Determine OS family from osIdLike
1182
- const isDebianBased = osIdLike && osIdLike.match(/debian|ubuntu/i);
1183
- const isRhelBased = osIdLike && osIdLike.match(/rhel|centos|fedora|alma|rocky/i);
1375
+ const { isDebianBased, isRhelBased } = Underpost.baremetal.getFamilyBaseOs(osIdLike);
1184
1376
 
1185
1377
  // Set extraction directory based on OS family
1186
1378
  const extractDirName = isDebianBased ? 'casper' : 'iso-extract';
@@ -1206,16 +1398,16 @@ rm -rf ${artifacts.join(' ')}`);
1206
1398
  logger.info(`Downloaded ISO to ${isoPath} (${(stats.size / 1024 / 1024 / 1024).toFixed(2)} GB)`);
1207
1399
  }
1208
1400
 
1209
- // Mount ISO and extract boot files
1401
+ // ISO Logic
1210
1402
  const mountPoint = `${nfsHostPath}/mnt-iso-${arch}`;
1211
1403
  shellExec(`mkdir -p ${mountPoint}`);
1212
1404
 
1213
1405
  // Ensure mount point is not already mounted
1214
- shellExec(`sudo umount ${mountPoint} 2>/dev/null`, { silent: true });
1406
+ shellExec(`sudo umount ${mountPoint} 2>/dev/null`);
1215
1407
 
1216
1408
  try {
1217
1409
  // Mount the ISO
1218
- shellExec(`sudo mount -o loop,ro ${isoPath} ${mountPoint}`, { silent: false });
1410
+ shellExec(`sudo mount -o loop,ro ${isoPath} ${mountPoint}`);
1219
1411
  logger.info(`Mounted ISO at ${mountPoint}`);
1220
1412
 
1221
1413
  // Distribution-specific extraction logic
@@ -1227,16 +1419,25 @@ rm -rf ${artifacts.join(' ')}`);
1227
1419
  logger.info(`Checking casper directory contents...`);
1228
1420
  shellExec(`ls -la ${mountPoint}/casper/ 2>/dev/null || echo "casper directory not found"`);
1229
1421
  shellExec(`sudo cp -a ${mountPoint}/casper/* ${extractDir}/`);
1422
+ } else if (isRhelBased) {
1423
+ // RHEL/Rocky: Extract from images/pxeboot directory
1424
+ const pxebootDir = `${mountPoint}/images/pxeboot`;
1425
+ if (!fs.existsSync(pxebootDir)) {
1426
+ throw new Error(`Failed to mount ISO or images/pxeboot directory not found: ${isoPath}`);
1427
+ }
1428
+ logger.info(`Extracting kernel and initrd from ${pxebootDir}...`);
1429
+ shellExec(`sudo cp -a ${pxebootDir}/vmlinuz ${extractDir}/vmlinuz`);
1430
+ shellExec(`sudo cp -a ${pxebootDir}/initrd.img ${extractDir}/initrd`);
1230
1431
  }
1231
1432
  } finally {
1232
1433
  shellExec(`ls -la ${mountPoint}/`);
1233
1434
 
1234
1435
  shellExec(`sudo chown -R $(whoami):$(whoami) ${extractDir}`);
1235
1436
  // Unmount ISO
1236
- shellExec(`sudo umount ${mountPoint}`, { silent: true });
1437
+ shellExec(`sudo umount ${mountPoint}`);
1237
1438
  logger.info(`Unmounted ISO`);
1238
1439
  // Clean up temporary mount point
1239
- shellExec(`rmdir ${mountPoint}`, { silent: true });
1440
+ shellExec(`rmdir ${mountPoint}`);
1240
1441
  }
1241
1442
 
1242
1443
  return {
@@ -1253,8 +1454,8 @@ rm -rf ${artifacts.join(' ')}`);
1253
1454
  * @param {string} options.macAddress - The MAC address of the machine.
1254
1455
  * @param {string} options.hostname - The hostname for the machine.
1255
1456
  * @param {string} options.ipAddress - The IP address for the machine.
1457
+ * @param {string} options.architecture - The architecture for the machine (default is 'arm64').
1256
1458
  * @param {string} options.powerType - The power type for the machine (default is 'manual').
1257
- * @param {object} options.maas - Additional MAAS-specific options.
1258
1459
  * @returns {object} An object containing the created machine details.
1259
1460
  * @memberof UnderpostBaremetal
1260
1461
  */
@@ -1263,13 +1464,13 @@ rm -rf ${artifacts.join(' ')}`);
1263
1464
  macAddress: '',
1264
1465
  hostname: '',
1265
1466
  ipAddress: '',
1467
+ architecture: 'arm64',
1266
1468
  powerType: 'manual',
1267
- architecture: 'arm64/generic',
1268
1469
  },
1269
1470
  ) {
1270
1471
  if (!options.powerType) options.powerType = 'manual';
1271
1472
  const payload = {
1272
- architecture: (options.architecture || 'arm64/generic').match('arm') ? 'arm64/generic' : 'amd64/generic',
1473
+ architecture: options.architecture.match('arm') ? 'arm64/generic' : 'amd64/generic',
1273
1474
  mac_address: options.macAddress,
1274
1475
  mac_addresses: options.macAddress,
1275
1476
  hostname: options.hostname,
@@ -1277,18 +1478,13 @@ rm -rf ${artifacts.join(' ')}`);
1277
1478
  ip: options.ipAddress,
1278
1479
  };
1279
1480
  logger.info('Creating MAAS machine', payload);
1280
- const machine = shellExec(
1281
- `maas ${process.env.MAAS_ADMIN_USERNAME} machines create ${Object.keys(payload)
1481
+ const machine = Underpost.baremetal.maasCliExec(
1482
+ `machines create ${Object.keys(payload)
1282
1483
  .map((k) => `${k}="${payload[k]}"`)
1283
1484
  .join(' ')}`,
1284
- {
1285
- silent: true,
1286
- stdout: true,
1287
- },
1288
1485
  );
1289
- // console.log(machine);
1290
1486
  try {
1291
- return { machine: JSON.parse(machine) };
1487
+ return { machine };
1292
1488
  } catch (error) {
1293
1489
  console.log(error);
1294
1490
  logger.error(error);
@@ -1296,6 +1492,20 @@ rm -rf ${artifacts.join(' ')}`);
1296
1492
  }
1297
1493
  },
1298
1494
 
1495
+ /**
1496
+ * @method getFamilyBaseOs
1497
+ * @description Determines if the OS belongs to Debian-based or RHEL-based family based on osIdLike string.
1498
+ * @param {string} osIdLike - The os_id_like string from MAAS boot resource or workflow configuration.
1499
+ * @returns {object} An object with boolean properties isDebianBased and isRhelBased indicating the OS family.
1500
+ * @memberof UnderpostBaremetal
1501
+ */
1502
+ getFamilyBaseOs(osIdLike = '') {
1503
+ return {
1504
+ isDebianBased: osIdLike.match(/debian|ubuntu/i),
1505
+ isRhelBased: osIdLike.match(/rhel|centos|fedora|alma|rocky/i),
1506
+ };
1507
+ },
1508
+
1299
1509
  /**
1300
1510
  * @method kernelFactory
1301
1511
  * @description Retrieves kernel, initrd, and root filesystem paths from a MAAS boot resource.
@@ -1312,8 +1522,10 @@ rm -rf ${artifacts.join(' ')}`);
1312
1522
  // For disk-based commissioning (casper/iso), use live ISO files
1313
1523
  if (type === 'iso-ram' || type === 'iso-nfs') {
1314
1524
  logger.info('Using live ISO for boot (disk-based commissioning)');
1315
- const arch = resource.architecture.split('/')[0];
1316
1525
  const workflowsConfig = Underpost.baremetal.loadWorkflowsConfig();
1526
+ const arch = resource?.architecture
1527
+ ? resource.architecture.split('/')[0]
1528
+ : workflowsConfig[workflowId].architecture;
1317
1529
  const kernelFilesPaths = Underpost.baremetal.downloadISO({
1318
1530
  resource,
1319
1531
  architecture: arch,
@@ -1325,13 +1537,7 @@ rm -rf ${artifacts.join(' ')}`);
1325
1537
  return { kernelFilesPaths, resourcesPath };
1326
1538
  }
1327
1539
 
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
- );
1540
+ const resourceData = Underpost.baremetal.maasCliExec(`boot-resource read ${resource.id}`);
1335
1541
  let kernelFilesPaths = {};
1336
1542
  const bootFiles = resourceData.sets[Object.keys(resourceData.sets)[0]].files;
1337
1543
  const arch = resource.architecture.split('/')[0];
@@ -1392,7 +1598,7 @@ rm -rf ${artifacts.join(' ')}`);
1392
1598
  shellExec(`mkdir -p ${tempExtractDir}`);
1393
1599
 
1394
1600
  // List files in archive to find kernel and initrd
1395
- const tarList = shellExec(`tar -tf ${rootArchivePath}`, { silent: true }).stdout.split('\n');
1601
+ const tarList = shellExec(`tar -tf ${rootArchivePath}`).stdout.split('\n');
1396
1602
 
1397
1603
  // Look for boot/vmlinuz* and boot/initrd* (handling potential leading ./)
1398
1604
  // Skip rescue, kdump, and other special images
@@ -1524,7 +1730,7 @@ rm -rf ${artifacts.join(' ')}`);
1524
1730
  */
1525
1731
  removeDiscoveredMachines() {
1526
1732
  logger.info('Removing all discovered machines from MAAS...');
1527
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
1733
+ Underpost.baremetal.maasCliExec(`discoveries clear all=true`);
1528
1734
  },
1529
1735
 
1530
1736
  /**
@@ -1657,16 +1863,28 @@ shell
1657
1863
  * @method ipxeScriptFactory
1658
1864
  * @description Generates the iPXE script content for stable identity.
1659
1865
  * This iPXE script uses directly boots kernel/initrd via TFTP.
1866
+ * When minimal mode is enabled, generates a simple dhcp+kernel+initrd+boot script.
1660
1867
  * @param {object} params - The parameters for generating the script.
1661
- * @param {string} params.maasIp - The IP address of the MAAS server.
1868
+ * @param {string} params.maasIp - The IP address of the MAAS/file server.
1662
1869
  * @param {string} [params.macAddress] - The MAC address registered in MAAS (for display only).
1663
- * @param {string} params.architecture - The architecture (arm64/amd64).
1870
+ * @param {string} [params.architecture] - The architecture (arm64/amd64).
1664
1871
  * @param {string} params.tftpPrefix - The TFTP prefix path (e.g., 'rpi4mb').
1665
1872
  * @param {string} params.kernelCmd - The kernel command line parameters.
1873
+ * @param {boolean} [params.minimal=false] - Generate a minimal embedded script for ISO builds.
1666
1874
  * @returns {string} The iPXE script content.
1667
1875
  * @memberof UnderpostBaremetal
1668
1876
  */
1669
- ipxeScriptFactory({ maasIp, macAddress, architecture, tftpPrefix, kernelCmd }) {
1877
+ ipxeScriptFactory({ maasIp, macAddress, architecture, tftpPrefix, kernelCmd, minimal = false }) {
1878
+ if (minimal) {
1879
+ return `#!ipxe
1880
+ dhcp
1881
+ set server_ip ${maasIp}
1882
+ set tftp_prefix ${tftpPrefix}
1883
+ kernel tftp://\${server_ip}/\${tftp_prefix}/pxe/vmlinuz-efi ${kernelCmd}
1884
+ initrd tftp://\${server_ip}/\${tftp_prefix}/pxe/initrd.img
1885
+ boot || shell
1886
+ `;
1887
+ }
1670
1888
  const macInfo =
1671
1889
  macAddress && macAddress !== '00:00:00:00:00:00'
1672
1890
  ? `echo Registered MAC: ${macAddress}`
@@ -1704,7 +1922,11 @@ echo ========================================
1704
1922
  echo Loading kernel and initrd via TFTP...
1705
1923
  echo Kernel: tftp://${maasIp}/${kernelPath}
1706
1924
  echo Initrd: tftp://${maasIp}/${initrdPath}
1707
- ${macAddress && macAddress !== '00:00:00:00:00:00' ? `echo Kernel will use MAC: ${macAddress} (via ifname parameter)` : 'echo Kernel will use hardware MAC'}
1925
+ ${
1926
+ macAddress && macAddress !== '00:00:00:00:00:00'
1927
+ ? `echo Kernel will use MAC: ${macAddress} (via ifname parameter)`
1928
+ : 'echo Kernel will use hardware MAC'
1929
+ }
1708
1930
  echo ========================================
1709
1931
 
1710
1932
  # Load kernel via TFTP
@@ -1735,7 +1957,9 @@ echo ========================================
1735
1957
  # Fallback: Try MAAS HTTP bootloader (may have certificate issues)
1736
1958
  set boot-url http://${maasIp}:5248/images/bootloader
1737
1959
  echo Boot URL: \${boot-url}
1738
- chain \${boot-url}/uefi/${architecture}/${grubBootloader === 'grubaa64.efi' ? 'bootaa64.efi' : 'bootx64.efi'} || goto error
1960
+ chain \${boot-url}/uefi/${architecture}/${
1961
+ grubBootloader === 'grubaa64.efi' ? 'bootaa64.efi' : 'bootx64.efi'
1962
+ } || goto error
1739
1963
 
1740
1964
  :error
1741
1965
  echo ========================================
@@ -1771,22 +1995,12 @@ shell
1771
1995
  * @memberof UnderpostBaremetal
1772
1996
  */
1773
1997
  ipxeEfiFactory({ tftpRootPath, ipxeCacheDir, arch, underpostRoot, embeddedScriptPath, forceRebuild = false }) {
1774
- const shouldRebuild =
1775
- forceRebuild || (!fs.existsSync(`${tftpRootPath}/ipxe.efi`) && !fs.existsSync(`${ipxeCacheDir}/ipxe.efi`));
1998
+ const embedArg =
1999
+ embeddedScriptPath && fs.existsSync(embeddedScriptPath) ? ` --embed-script ${embeddedScriptPath}` : '';
2000
+ const rebuildArg = forceRebuild ? ' --rebuild' : '';
1776
2001
 
1777
- if (!shouldRebuild) return;
1778
-
1779
- if (embeddedScriptPath && fs.existsSync(embeddedScriptPath)) {
1780
- logger.info('Rebuilding iPXE with embedded boot script...', {
1781
- embeddedScriptPath,
1782
- forced: forceRebuild,
1783
- });
1784
- shellExec(
1785
- `${underpostRoot}/scripts/ipxe-setup.sh ${tftpRootPath} --target-arch ${arch} --embed-script ${embeddedScriptPath} --rebuild`,
1786
- );
1787
- } else if (shouldRebuild) {
1788
- shellExec(`${underpostRoot}/scripts/ipxe-setup.sh ${tftpRootPath} --target-arch ${arch}`);
1789
- }
2002
+ logger.info('Building iPXE EFI binary...', { embeddedScriptPath, forced: forceRebuild });
2003
+ shellExec(`${underpostRoot}/scripts/ipxe-setup.sh ${tftpRootPath} --target-arch ${arch}${embedArg}${rebuildArg}`);
1790
2004
 
1791
2005
  shellExec(`mkdir -p ${ipxeCacheDir}`);
1792
2006
  shellExec(`cp ${tftpRootPath}/ipxe.efi ${ipxeCacheDir}/ipxe.efi`);
@@ -1840,7 +2054,11 @@ shell
1840
2054
  echo "${menuentryStr}"
1841
2055
  echo " ${Underpost.version}"
1842
2056
  echo "Date: ${new Date().toISOString()}"
1843
- ${cmd.match('/MAAS/metadata/by-id/') ? `echo "System ID: ${cmd.split('/MAAS/metadata/by-id/')[1].split('/')[0]}"` : ''}
2057
+ ${
2058
+ cmd.match('/MAAS/metadata/by-id/')
2059
+ ? `echo "System ID: ${cmd.split('/MAAS/metadata/by-id/')[1].split('/')[0]}"`
2060
+ : ''
2061
+ }
1844
2062
  echo "TFTP server: ${tftpIp}"
1845
2063
  echo "Kernel path: ${kernelPath}"
1846
2064
  echo "Initrd path: ${initrdPath}"
@@ -1856,6 +2074,72 @@ shell
1856
2074
  };
1857
2075
  },
1858
2076
 
2077
+ /**
2078
+ * @method bootstrapHttpServerPortFactory
2079
+ * @description Determines the bootstrap HTTP server port.
2080
+ * @param {object} params - Parameters for determining the port.
2081
+ * @param {number} [params.port] - The port passed via options.
2082
+ * @param {string} params.workflowId - The workflow identifier.
2083
+ * @param {object} params.workflowsConfig - The loaded workflows configuration.
2084
+ * @returns {number} The determined port number.
2085
+ * @memberof UnderpostBaremetal
2086
+ */
2087
+ bootstrapHttpServerPortFactory({ port, workflowId, workflowsConfig }) {
2088
+ return port || workflowsConfig[workflowId]?.bootstrapHttpServerPort || 8888;
2089
+ },
2090
+
2091
+ /**
2092
+ * @method commissioningWriteFilesFactory
2093
+ * @description Generates the write_files configuration for the commissioning script.
2094
+ * @param {object} params
2095
+ * @param {object} params.machine - The machine object.
2096
+ * @param {object} params.authCredentials - MAAS authentication credentials.
2097
+ * @param {string} params.runnerHostIp - The IP address of the runner host.
2098
+ * @memberof UnderpostBaremetal
2099
+ * @returns {Array} The write_files array.
2100
+ */
2101
+ commissioningWriteFilesFactory({ machine, authCredentials, runnerHostIp }) {
2102
+ const { consumer_key, token_key, token_secret } = authCredentials;
2103
+ return [
2104
+ {
2105
+ path: '/usr/local/bin/underpost-enlist.sh',
2106
+ permissions: '0755',
2107
+ owner: 'root:root',
2108
+ content: `#!/bin/bash
2109
+ # set -euo pipefail
2110
+ CONSUMER_KEY="${consumer_key}"
2111
+ TOKEN_KEY="${token_key}"
2112
+ TOKEN_SECRET="${token_secret}"
2113
+ LOG_FILE="/var/log/underpost-enlistment.log"
2114
+ RESPONSE_FILE="/tmp/maas_response.txt"
2115
+ STATUS_FILE="/tmp/maas_status.txt"
2116
+
2117
+ echo "Starting MAAS Commissioning Request..." | tee -a "$LOG_FILE"
2118
+
2119
+ curl -X POST \\
2120
+ --location --verbose \\
2121
+ --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)\\"" \\
2122
+ -F "enable_ssh=1" \\
2123
+ http://${runnerHostIp}:5240/MAAS/api/2.0/machines/${machine.system_id}/op-commission \\
2124
+ --output "$RESPONSE_FILE" --write-out "%{http_code}" > "$STATUS_FILE" 2>> "$LOG_FILE"
2125
+
2126
+ HTTP_STATUS=$(cat "$STATUS_FILE")
2127
+
2128
+ echo "HTTP Status: $HTTP_STATUS" | tee -a "$LOG_FILE"
2129
+ echo "Response Body:" | tee -a "$LOG_FILE"
2130
+ cat "$RESPONSE_FILE" | tee -a "$LOG_FILE"
2131
+
2132
+ if [ "$HTTP_STATUS" -eq 200 ]; then
2133
+ echo "Commissioning requested successfully." | tee -a "$LOG_FILE"
2134
+ else
2135
+ echo "ERROR: MAAS commissioning failed with status $HTTP_STATUS" | tee -a "$LOG_FILE"
2136
+ exit 0
2137
+ fi
2138
+ `,
2139
+ },
2140
+ ];
2141
+ },
2142
+
1859
2143
  /**
1860
2144
  * @method httpBootstrapServerStaticFactory
1861
2145
  * @description Creates static files for the bootstrap HTTP server including cloud-init configuration.
@@ -1863,36 +2147,40 @@ shell
1863
2147
  * @param {string} params.bootstrapHttpServerPath - The path where static files will be created.
1864
2148
  * @param {string} params.hostname - The hostname of the client machine.
1865
2149
  * @param {string} params.cloudConfigSrc - The cloud-init configuration YAML source.
1866
- * @param {object} [params.metadata] - Optional metadata to include in meta-data file.
1867
- * @param {string} [params.vendorData] - Optional vendor-data content (default: empty string).
2150
+ * @param {string} params.kickstartSrc - The kickstart configuration content.
2151
+ * @param {string} params.vendorData - The cloud-init vendor-data content.
2152
+ * @param {string} params.isoUrl - Optional ISO URL to cache and serve.
1868
2153
  * @memberof UnderpostBaremetal
1869
2154
  * @returns {void}
1870
2155
  */
1871
2156
  httpBootstrapServerStaticFactory({
1872
- bootstrapHttpServerPath,
1873
- hostname,
1874
- cloudConfigSrc,
1875
- metadata = {},
2157
+ bootstrapHttpServerPath = '',
2158
+ hostname = '',
2159
+ cloudConfigSrc = '',
2160
+ kickstartSrc = '',
1876
2161
  vendorData = '',
2162
+ isoUrl = '',
1877
2163
  }) {
1878
- // Create directory structure
1879
- shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
2164
+ shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}`);
1880
2165
 
1881
- // Write user-data file
1882
- fs.writeFileSync(
1883
- `${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`,
1884
- `#cloud-config\n${cloudConfigSrc}`,
1885
- 'utf8',
1886
- );
2166
+ Underpost.cloudInit.httpServerStaticFactory({ bootstrapHttpServerPath, hostname, cloudConfigSrc, vendorData });
2167
+ Underpost.kickstart.httpServerStaticFactory({ bootstrapHttpServerPath, hostname, kickstartSrc });
1887
2168
 
1888
- // Write meta-data file
1889
- const metaDataContent = `instance-id: ${metadata.instanceId || hostname}\nlocal-hostname: ${metadata.localHostname || hostname}`;
1890
- fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/meta-data`, metaDataContent, 'utf8');
2169
+ if (isoUrl) {
2170
+ const isoFilename = isoUrl.split('/').pop();
2171
+ const isoCacheDir = `/var/tmp/live-iso`;
2172
+ const isoCachePath = `${isoCacheDir}/${isoFilename}`;
2173
+ const isoDestPath = `${bootstrapHttpServerPath}/${hostname}/${isoFilename}`;
1891
2174
 
1892
- // Write vendor-data file
1893
- fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/vendor-data`, vendorData, 'utf8');
2175
+ if (!fs.existsSync(isoCachePath)) {
2176
+ logger.info(`Downloading ISO to cache: ${isoUrl}`);
2177
+ shellExec(`mkdir -p ${isoCacheDir}`);
2178
+ shellExec(`wget --progress=bar:force -O ${isoCachePath} "${isoUrl}"`);
2179
+ }
1894
2180
 
1895
- logger.info(`Cloud-init files written to ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
2181
+ logger.info(`Copying ISO to bootstrap server: ${isoDestPath}`);
2182
+ shellExec(`cp ${isoCachePath} ${isoDestPath}`);
2183
+ }
1896
2184
  },
1897
2185
 
1898
2186
  /**
@@ -1912,15 +2200,17 @@ shell
1912
2200
  const bootstrapHttpServerPath = options.bootstrapHttpServerPath || './public/localhost';
1913
2201
  const hostname = options.hostname || 'localhost';
1914
2202
 
1915
- shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
2203
+ shellExec(`node bin run kill ${port}`, { silent: true });
1916
2204
 
1917
- // Kill any existing HTTP server
1918
- shellExec(`sudo pkill -f 'python3 -m http.server ${port}'`, { silent: true });
2205
+ const app = express();
1919
2206
 
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
- );
2207
+ app.use(loggerMiddleware(import.meta, 'debug', () => false));
2208
+
2209
+ app.use('/', express.static(bootstrapHttpServerPath));
2210
+
2211
+ app.listen(port, () => {
2212
+ logger.info(`Static file server running on port ${port}`);
2213
+ });
1924
2214
 
1925
2215
  // Configure iptables to allow incoming LAN connections
1926
2216
  shellExec(
@@ -1949,9 +2239,10 @@ shell
1949
2239
  */
1950
2240
  updateKernelFiles({ commissioningImage, resourcesPath, tftpRootPath, kernelFilesPaths }) {
1951
2241
  // Copy EFI bootloaders to TFTP path.
1952
- const efiFiles = commissioningImage.architecture.match('arm64')
1953
- ? ['bootaa64.efi', 'grubaa64.efi']
1954
- : ['bootx64.efi', 'grubx64.efi'];
2242
+ const arch = resourcesPath.split('/').pop();
2243
+ const efiFiles =
2244
+ arch === 'arm64' || arch === 'aarch64' ? ['bootaa64.efi', 'grubaa64.efi'] : ['bootx64.efi', 'grubx64.efi'];
2245
+
1955
2246
  for (const file of efiFiles) {
1956
2247
  shellExec(`sudo cp -a ${resourcesPath}/${file} ${tftpRootPath}/pxe/${file}`);
1957
2248
  }
@@ -1963,7 +2254,7 @@ shell
1963
2254
  // GRUB on ARM64 often crashes with synchronous exception (0x200) if handling large compressed kernels directly.
1964
2255
  if (file === 'vmlinuz-efi') {
1965
2256
  const kernelDest = `${tftpRootPath}/pxe/${file}`;
1966
- const fileType = shellExec(`file ${kernelDest}`, { silent: true }).stdout;
2257
+ const fileType = shellExec(`file ${kernelDest}`).stdout;
1967
2258
 
1968
2259
  // Handle gzip compressed kernels
1969
2260
  if (fileType.includes('gzip compressed data')) {
@@ -2003,6 +2294,12 @@ shell
2003
2294
  * @param {object} options.machine - The machine object containing system_id.
2004
2295
  * @param {boolean} [options.dev=false] - Whether to enable dev mode with dracut debugging parameters.
2005
2296
  * @param {string} [options.osIdLike=''] - OS family identifier (e.g., 'rhel centos fedora' or 'debian ubuntu').
2297
+ * @param {object} options.authCredentials - Authentication credentials for fetching files (if needed).
2298
+ * @param {string} options.authCredentials.consumer_key - Consumer key for authentication.
2299
+ * @param {string} options.authCredentials.consumer_secret - Consumer secret for authentication.
2300
+ * @param {string} options.authCredentials.token_key - Token key for authentication.
2301
+ * @param {string} options.authCredentials.token_secret - Token secret for authentication.
2302
+ * @param {string} options.architecture - The architecture of the machine (e.g., 'amd64', 'arm64').
2006
2303
  * @returns {object} An object containing the constructed command line string.
2007
2304
  * @memberof UnderpostBaremetal
2008
2305
  */
@@ -2021,9 +2318,10 @@ shell
2021
2318
  type: '',
2022
2319
  macAddress: '',
2023
2320
  cloudInit: false,
2024
- machine: { system_id: '' },
2025
2321
  dev: false,
2026
2322
  osIdLike: '',
2323
+ authCredentials: { consumer_key: '', consumer_secret: '', token_key: '', token_secret: '' },
2324
+ architecture,
2027
2325
  },
2028
2326
  ) {
2029
2327
  // Construct kernel command line arguments for NFS boot.
@@ -2042,12 +2340,15 @@ shell
2042
2340
  macAddress,
2043
2341
  cloudInit,
2044
2342
  osIdLike,
2343
+ architecture,
2045
2344
  } = options;
2046
2345
 
2047
- const ipParam = true
2048
- ? `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2049
- `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`
2050
- : 'ip=dhcp';
2346
+ // Determine OS family from osIdLike
2347
+ const { isDebianBased, isRhelBased } = Underpost.baremetal.getFamilyBaseOs(options.osIdLike);
2348
+
2349
+ const ipParam =
2350
+ `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2351
+ `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`;
2051
2352
 
2052
2353
  const nfsOptions = `${
2053
2354
  type === 'chroot-debootstrap' || type === 'chroot-container'
@@ -2074,7 +2375,9 @@ shell
2074
2375
  : []
2075
2376
  }`;
2076
2377
 
2077
- const nfsRootParam = `nfsroot=${ipFileServer}:${process.env.NFS_EXPORT_PATH}/${hostname}${nfsOptions ? `,${nfsOptions}` : ''}`;
2378
+ const nfsRootParam = `nfsroot=${ipFileServer}:${process.env.NFS_EXPORT_PATH}/${hostname}${
2379
+ nfsOptions ? `,${nfsOptions}` : ''
2380
+ }`;
2078
2381
 
2079
2382
  const permissionsParams = [
2080
2383
  `rw`,
@@ -2092,9 +2395,9 @@ shell
2092
2395
  // `layerfs-path=filesystem.squashfs`,
2093
2396
  // `root=/dev/ram0`,
2094
2397
  // `toram`,
2095
- 'nomodeset',
2096
- `editable_rootfs=tmpfs`,
2097
- `ramdisk_size=3550000`,
2398
+ // 'nomodeset',
2399
+ // `editable_rootfs=tmpfs`, // all writes to rootfs go to RAM, keeping underlying storage pristine
2400
+ // `ramdisk_size=3550000`,
2098
2401
  // `root=/dev/sda1`, // rpi4 usb port unit
2099
2402
  'apparmor=0', // Disable AppArmor security
2100
2403
  ...(networkInterfaceName === 'eth0'
@@ -2136,51 +2439,34 @@ shell
2136
2439
 
2137
2440
  let cmd = [];
2138
2441
  if (type === 'iso-ram') {
2139
- const netBootParams = [`netboot=url`];
2140
- if (fileSystemUrl) netBootParams.push(`url=${fileSystemUrl.replace('https', 'http')}`);
2141
- cmd = [ipParam, `boot=casper`, ...netBootParams, ...kernelParams];
2142
- } else if (type === 'chroot-debootstrap' || type === 'chroot-container') {
2143
- 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
2442
  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`]);
2443
+ cmd = Underpost.kickstart.kernelParamsFactory(macAddress, [ipParam, ...kernelParams], options);
2444
+ } else {
2445
+ // ISO-RAM (Debian/Ubuntu): full live ISO downloaded into RAM via casper toram.
2446
+ const netBootParams = [`netboot=url`];
2447
+ if (fileSystemUrl) netBootParams.push(`url=${fileSystemUrl.replace('https', 'http')}`);
2448
+ cmd = [ipParam, `boot=casper`, 'toram', ...netBootParams, ...kernelParams, ...performanceParams];
2161
2449
  }
2162
-
2450
+ } else if (type === 'chroot-debootstrap' || type === 'chroot-container') {
2451
+ let qemuNfsRootParams = [`root=/dev/nfs`, `rootfstype=nfs`];
2163
2452
  cmd = [ipParam, ...qemuNfsRootParams, nfsRootParam, ...kernelParams];
2164
2453
  } else {
2165
- // 'iso-nfs'
2454
+ // 'iso-nfs' — Debian/Ubuntu NFS root boot: kernel/initrd from ISO, root filesystem served via NFS.
2166
2455
  cmd = [ipParam, `netboot=nfs`, nfsRootParam, ...kernelParams, ...performanceParams];
2456
+ }
2167
2457
 
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
- }
2458
+ // Add RHEL/Rocky/Fedora based images specific parameters
2459
+ if (isRhelBased) {
2460
+ cmd = cmd.concat([`rd.neednet=1`, `rd.timeout=180`, `selinux=0`, `enforcing=0`]);
2461
+ if (options.dev) cmd = cmd.concat([`rd.shell`, `rd.debug`]);
2462
+ }
2463
+ // Add Debian/Ubuntu based images specific parameters
2464
+ else if (isDebianBased) {
2465
+ cmd = cmd.concat([`initrd=initrd.img`, `init=/sbin/init`]);
2466
+ if (options.dev) cmd = cmd.concat([`debug`, `ignore_loglevel`]);
2183
2467
  }
2468
+
2469
+ if (cloudInit) cmd = Underpost.cloudInit.kernelParamsFactory(macAddress, cmd, options);
2184
2470
  // cmd.push('---');
2185
2471
  const cmdStr = cmd.join(' ');
2186
2472
  logger.info('Constructed kernel command line');
@@ -2204,12 +2490,7 @@ shell
2204
2490
  async commissionMonitor({ macAddress, ipAddress, hostname, architecture, machine }) {
2205
2491
  {
2206
2492
  // 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
- );
2493
+ const discoveries = Underpost.baremetal.maasCliExec(`discoveries read`);
2213
2494
 
2214
2495
  for (const discovery of discoveries) {
2215
2496
  const discoverHostname = discovery.hostname
@@ -2227,6 +2508,20 @@ shell
2227
2508
  if (discovery.ip === ipAddress) {
2228
2509
  logger.info('Machine discovered!', discovery);
2229
2510
  if (!machine) {
2511
+ // Check if a machine with the discovered MAC already exists to avoid conflicts
2512
+ const [existingMachine] =
2513
+ Underpost.baremetal.maasCliExec(`machines read mac_address=${discovery.mac_address}`) || [];
2514
+
2515
+ if (existingMachine) {
2516
+ logger.warn(
2517
+ `Machine ${existingMachine.hostname} (${existingMachine.system_id}) already exists with MAC ${discovery.mac_address}`,
2518
+ );
2519
+ logger.info(
2520
+ `Deleting existing machine ${existingMachine.system_id} to create new machine ${hostname}...`,
2521
+ );
2522
+ Underpost.baremetal.maasCliExec(`machine delete ${existingMachine.system_id}`);
2523
+ }
2524
+
2230
2525
  logger.info('Creating new machine with discovered hardware MAC...', {
2231
2526
  discoveredMAC: discovery.mac_address,
2232
2527
  ipAddress,
@@ -2238,12 +2533,18 @@ shell
2238
2533
  hostname,
2239
2534
  architecture,
2240
2535
  }).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
- });
2536
+
2537
+ if (machine && machine.system_id) {
2538
+ console.log('New machine system id:', machine.system_id.bgYellow.bold.black);
2539
+ Underpost.baremetal.writeGrubConfigToFile({
2540
+ grubCfgSrc: Underpost.baremetal
2541
+ .getGrubConfigFromFile()
2542
+ .grubCfgSrc.replaceAll('system-id', machine.system_id),
2543
+ });
2544
+ } else {
2545
+ logger.error('Failed to create machine or obtain system_id', machine);
2546
+ throw new Error('Machine creation failed');
2547
+ }
2247
2548
  } else {
2248
2549
  const systemId = machine.system_id;
2249
2550
  console.log('Using pre-registered machine system_id:', systemId.bgYellow.bold.black);
@@ -2256,22 +2557,45 @@ shell
2256
2557
  discoveredMAC: discovery.mac_address,
2257
2558
  });
2258
2559
 
2259
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-broken ${systemId}`, {
2260
- silent: true,
2261
- });
2560
+ // Check current machine status before attempting state transitions
2561
+ const currentMachine = Underpost.baremetal.maasCliExec(`machine read ${systemId}`);
2562
+ const currentStatus = currentMachine ? currentMachine.status_name : 'Unknown';
2563
+ logger.info('Current machine status before interface update:', { systemId, status: currentStatus });
2564
+
2565
+ // Only mark-broken if the machine is in a state that supports it (e.g. Ready, New, Allocated)
2566
+ // Machines already in Broken state don't need to be marked broken again
2567
+ if (currentStatus !== 'Broken') {
2568
+ try {
2569
+ Underpost.baremetal.maasCliExec(`machine mark-broken ${systemId}`);
2570
+ logger.info('Machine marked as broken successfully');
2571
+ } catch (markBrokenError) {
2572
+ logger.warn('Failed to mark machine as broken, attempting interface update anyway...', {
2573
+ error: markBrokenError.message,
2574
+ currentStatus,
2575
+ });
2576
+ }
2577
+ } else {
2578
+ logger.info('Machine is already in Broken state, skipping mark-broken');
2579
+ }
2262
2580
 
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
- },
2581
+ Underpost.baremetal.maasCliExec(
2582
+ `interface update ${systemId} ${machine.boot_interface.id}` + ` mac_address=${discovery.mac_address}`,
2270
2583
  );
2271
2584
 
2272
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-fixed ${systemId}`, {
2273
- silent: true,
2274
- });
2585
+ // Re-check status before mark-fixed only attempt if actually Broken
2586
+ const updatedMachine = Underpost.baremetal.maasCliExec(`machine read ${systemId}`);
2587
+ const updatedStatus = updatedMachine ? updatedMachine.status_name : 'Unknown';
2588
+
2589
+ if (updatedStatus === 'Broken') {
2590
+ try {
2591
+ Underpost.baremetal.maasCliExec(`machine mark-fixed ${systemId}`);
2592
+ logger.info('Machine marked as fixed successfully');
2593
+ } catch (markFixedError) {
2594
+ logger.warn('Failed to mark machine as fixed:', { error: markFixedError.message });
2595
+ }
2596
+ } else {
2597
+ logger.info('Machine is not in Broken state, skipping mark-fixed', { status: updatedStatus });
2598
+ }
2275
2599
 
2276
2600
  logger.info('✓ Machine interface MAC address updated successfully');
2277
2601
 
@@ -2300,6 +2624,73 @@ shell
2300
2624
  }
2301
2625
  },
2302
2626
 
2627
+ /**
2628
+ * @method maasCliExec
2629
+ * @description Executes a MAAS CLI command and returns the parsed JSON output.
2630
+ * This method abstracts the execution of MAAS CLI commands, ensuring that the output is captured and parsed correctly.
2631
+ * @param {string} cmd - The MAAS CLI command to execute (e.g., 'machines read').
2632
+ * @returns {object|null} The parsed JSON output from the MAAS CLI command, or null if there is no output.
2633
+ * @memberof UnderpostBaremetal
2634
+ */
2635
+ maasCliExec(cmd) {
2636
+ const output = shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} ${cmd}`, {
2637
+ stdout: true,
2638
+ silent: true,
2639
+ }).trim();
2640
+ try {
2641
+ return output ? JSON.parse(output) : null;
2642
+ } catch (error) {
2643
+ console.log('output', output);
2644
+ logger.error(error);
2645
+ throw error;
2646
+ }
2647
+ },
2648
+
2649
+ /**
2650
+ * @method maasAuthCredentialsFactory
2651
+ * @description Retrieves MAAS API key credentials from the MAAS CLI.
2652
+ * This method parses the output of `maas apikey` to extract the consumer key,
2653
+ * consumer secret, token key, and token secret.
2654
+ * @returns {object} An object containing the MAAS authentication credentials.
2655
+ * @memberof UnderpostBaremetal
2656
+ * @throws {Error} If the MAAS API key format is invalid.
2657
+ */
2658
+ maasAuthCredentialsFactory() {
2659
+ // Expected formats:
2660
+ // <consumer_key>:<consumer_token>:<secret> (older format)
2661
+ // <consumer_key>:<consumer_secret>:<token_key>:<token_secret> (newer format)
2662
+ // Commands used to generate API keys:
2663
+ // maas apikey --with-names --username ${process.env.MAAS_ADMIN_USERNAME}
2664
+ // maas ${process.env.MAAS_ADMIN_USERNAME} account create-authorisation-token
2665
+ // maas apikey --generate --username ${process.env.MAAS_ADMIN_USERNAME}
2666
+ // Reference: https://github.com/CanonicalLtd/maas-docs/issues/647
2667
+
2668
+ const parts = shellExec(`maas apikey --with-names --username ${process.env.MAAS_ADMIN_USERNAME}`, {
2669
+ stdout: true,
2670
+ })
2671
+ .trim()
2672
+ .split(`\n`)[0] // Take only the first line of output.
2673
+ .split(':'); // Split by colon to get individual parts.
2674
+
2675
+ let consumer_key, consumer_secret, token_key, token_secret;
2676
+
2677
+ // Determine the format of the API key and assign parts accordingly.
2678
+ if (parts.length === 4) {
2679
+ [consumer_key, consumer_secret, token_key, token_secret] = parts;
2680
+ } else if (parts.length === 3) {
2681
+ // Handle older 3-part format, setting consumer_secret as empty.
2682
+ [consumer_key, token_key, token_secret] = parts;
2683
+ consumer_secret = '';
2684
+ token_secret = token_secret.split(' MAAS consumer')[0].trim(); // Clean up token secret.
2685
+ } else {
2686
+ // Throw an error if the format is not recognized.
2687
+ throw new Error('Invalid token format');
2688
+ }
2689
+
2690
+ logger.info('Maas api token generated', { consumer_key, consumer_secret, token_key, token_secret });
2691
+ return { consumer_key, consumer_secret, token_key, token_secret };
2692
+ },
2693
+
2303
2694
  /**
2304
2695
  * @method mountBinfmtMisc
2305
2696
  * @description Mounts the binfmt_misc filesystem to enable QEMU user-static binfmt support.
@@ -2335,7 +2726,7 @@ shell
2335
2726
  const systemId = typeof machine === 'string' ? machine : machine.system_id;
2336
2727
  if (ignore && ignore.find((mId) => mId === systemId)) continue;
2337
2728
  logger.info(`Removing machine: ${systemId}`);
2338
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${systemId}`);
2729
+ Underpost.baremetal.maasCliExec(`machine delete ${systemId}`);
2339
2730
  }
2340
2731
  return [];
2341
2732
  },
@@ -2349,9 +2740,9 @@ shell
2349
2740
  * @returns {void}
2350
2741
  */
2351
2742
  clearDiscoveries({ force }) {
2352
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
2743
+ Underpost.baremetal.maasCliExec(`discoveries clear all=true`);
2353
2744
  if (force === true) {
2354
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries scan force=true`);
2745
+ Underpost.baremetal.maasCliExec(`discoveries scan force=true`);
2355
2746
  }
2356
2747
  },
2357
2748
 
@@ -2892,9 +3283,10 @@ logdir /var/log/chrony
2892
3283
  * @param {string} params.nfsHostPath - The path to the NFS server export.
2893
3284
  * @memberof UnderpostBaremetal
2894
3285
  * @param {string} [params.subnet='192.168.1.0/24'] - The subnet allowed to access the NFS export.
3286
+ * @param {boolean} [params.nfsReset=false] - Flag to completely reset the NFS server (restart service).
2895
3287
  * @returns {void}
2896
3288
  */
2897
- rebuildNfsServer({ nfsHostPath, subnet }) {
3289
+ rebuildNfsServer({ nfsHostPath, subnet, nfsReset }) {
2898
3290
  if (!subnet) subnet = '192.168.1.0/24'; // Default subnet if not provided.
2899
3291
  // Write the NFS exports configuration to /etc/exports.
2900
3292
  fs.writeFileSync(
@@ -2943,9 +3335,11 @@ udp-port = 32766
2943
3335
 
2944
3336
  // Restart the nfs-server service to apply all configuration changes,
2945
3337
  // including port settings from /etc/nfs.conf and export changes.
2946
- logger.info('Restarting nfs-server service...');
2947
- shellExec(`sudo systemctl restart nfs-server`);
2948
- logger.info('NFS server restarted.');
3338
+ if (nfsReset) {
3339
+ logger.info('Restarting nfs-server service...');
3340
+ shellExec(`sudo systemctl restart nfs-server`);
3341
+ logger.info('NFS server restarted.');
3342
+ }
2949
3343
  },
2950
3344
 
2951
3345
  /**
@@ -2967,10 +3361,10 @@ udp-port = 32766
2967
3361
  // Check both /usr/local/bin (compiled) and system paths
2968
3362
  let qemuAarch64Path = null;
2969
3363
 
2970
- if (shellExec('test -x /usr/local/bin/qemu-system-aarch64', { silent: true }).code === 0) {
3364
+ if (shellExec('test -x /usr/local/bin/qemu-system-aarch64').code === 0) {
2971
3365
  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();
3366
+ } else if (shellExec('which qemu-system-aarch64').code === 0) {
3367
+ qemuAarch64Path = shellExec('which qemu-system-aarch64').stdout.trim();
2974
3368
  }
2975
3369
 
2976
3370
  if (!qemuAarch64Path) {
@@ -2983,7 +3377,7 @@ udp-port = 32766
2983
3377
  logger.info(`Found qemu-system-aarch64 at: ${qemuAarch64Path}`);
2984
3378
 
2985
3379
  // Verify that the installed qemu supports the 'virt' machine type (required for arm64)
2986
- const machineHelp = shellExec(`${qemuAarch64Path} -machine help`, { silent: true }).stdout;
3380
+ const machineHelp = shellExec(`${qemuAarch64Path} -machine help`).stdout;
2987
3381
  if (!machineHelp.includes('virt')) {
2988
3382
  throw new Error(
2989
3383
  'The installed qemu-system-aarch64 does not support the "virt" machine type.\n' +
@@ -2996,10 +3390,10 @@ udp-port = 32766
2996
3390
  // Check both /usr/local/bin (compiled) and system paths
2997
3391
  let qemuX86Path = null;
2998
3392
 
2999
- if (shellExec('test -x /usr/local/bin/qemu-system-x86_64', { silent: true }).code === 0) {
3393
+ if (shellExec('test -x /usr/local/bin/qemu-system-x86_64').code === 0) {
3000
3394
  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();
3395
+ } else if (shellExec('which qemu-system-x86_64').code === 0) {
3396
+ qemuX86Path = shellExec('which qemu-system-x86_64').stdout.trim();
3003
3397
  }
3004
3398
 
3005
3399
  if (!qemuX86Path) {
@@ -3012,7 +3406,7 @@ udp-port = 32766
3012
3406
  logger.info(`Found qemu-system-x86_64 at: ${qemuX86Path}`);
3013
3407
 
3014
3408
  // 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;
3409
+ const machineHelp = shellExec(`${qemuX86Path} -machine help`).stdout;
3016
3410
  if (!machineHelp.includes('pc') && !machineHelp.includes('q35')) {
3017
3411
  throw new Error(
3018
3412
  'The installed qemu-system-x86_64 does not support the "pc" or "q35" machine type.\n' +