@underpostnet/underpost 2.96.1 → 2.97.1

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 (51) hide show
  1. package/.dockerignore +1 -2
  2. package/.env.development +0 -3
  3. package/.env.production +0 -3
  4. package/.env.test +0 -3
  5. package/.prettierignore +1 -2
  6. package/README.md +31 -31
  7. package/baremetal/commission-workflows.json +94 -17
  8. package/bin/deploy.js +1 -1
  9. package/cli.md +75 -41
  10. package/conf.js +1 -0
  11. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  12. package/manifests/deployment/dd-test-development/deployment.yaml +4 -4
  13. package/package.json +3 -2
  14. package/packer/scripts/fuse-tar-root +3 -3
  15. package/scripts/disk-clean.sh +128 -187
  16. package/scripts/gpu-diag.sh +2 -2
  17. package/scripts/ip-info.sh +11 -11
  18. package/scripts/ipxe-setup.sh +197 -0
  19. package/scripts/maas-upload-boot-resource.sh +1 -1
  20. package/scripts/nvim.sh +1 -1
  21. package/scripts/packer-setup.sh +13 -13
  22. package/scripts/ports-ls.sh +31 -0
  23. package/scripts/quick-tftp.sh +19 -0
  24. package/scripts/rocky-setup.sh +2 -2
  25. package/scripts/rpmfusion-ffmpeg-setup.sh +4 -4
  26. package/scripts/ssl.sh +7 -7
  27. package/src/api/document/document.controller.js +15 -0
  28. package/src/api/document/document.model.js +44 -1
  29. package/src/api/document/document.router.js +2 -0
  30. package/src/api/document/document.service.js +398 -26
  31. package/src/cli/baremetal.js +2001 -463
  32. package/src/cli/cloud-init.js +354 -231
  33. package/src/cli/cluster.js +51 -53
  34. package/src/cli/db.js +22 -0
  35. package/src/cli/deploy.js +7 -3
  36. package/src/cli/image.js +1 -0
  37. package/src/cli/index.js +40 -37
  38. package/src/cli/lxd.js +3 -3
  39. package/src/cli/run.js +78 -12
  40. package/src/cli/ssh.js +1 -1
  41. package/src/client/components/core/Css.js +16 -2
  42. package/src/client/components/core/Input.js +3 -1
  43. package/src/client/components/core/Modal.js +125 -159
  44. package/src/client/components/core/Panel.js +436 -31
  45. package/src/client/components/core/PanelForm.js +222 -37
  46. package/src/client/components/core/SearchBox.js +801 -0
  47. package/src/client/components/core/Translate.js +11 -0
  48. package/src/client/services/document/document.service.js +42 -0
  49. package/src/index.js +1 -1
  50. package/src/server/dns.js +12 -6
  51. package/src/server/start.js +14 -6
@@ -15,8 +15,9 @@ import path from 'path';
15
15
  import Downloader from '../server/downloader.js';
16
16
  import UnderpostCloudInit from './cloud-init.js';
17
17
  import UnderpostRepository from './repository.js';
18
- import { s4, timer } from '../client/components/core/CommonJs.js';
18
+ import { newInstance, range, s4, timer } from '../client/components/core/CommonJs.js';
19
19
  import { spawnSync } from 'child_process';
20
+ import Underpost from '../index.js';
20
21
 
21
22
  const logger = loggerFactory(import.meta);
22
23
 
@@ -49,22 +50,44 @@ class UnderpostBaremetal {
49
50
  * It handles NFS root filesystem building, control server installation/uninstallation,
50
51
  * and system-level provisioning tasks like timezone and keyboard configuration.
51
52
  * @param {string} [workflowId='rpi4mb'] - Identifier for the specific workflow configuration to use.
52
- * @param {string} [hostname=workflowId] - The hostname of the target baremetal machine.
53
53
  * @param {string} [ipAddress=getLocalIPv4Address()] - The IP address of the control server or the local machine.
54
+ * @param {string} [hostname=workflowId] - The hostname of the target baremetal machine.
55
+ * @param {string} [ipFileServer=getLocalIPv4Address()] - The IP address of the file server (NFS/TFTP).
56
+ * @param {string} [ipConfig=''] - IP configuration string for the baremetal machine.
57
+ * @param {string} [netmask=''] - Netmask of network
58
+ * @param {string} [dnsServer=''] - DNS server IP address.
54
59
  * @param {object} [options] - An object containing boolean flags for various operations.
55
60
  * @param {boolean} [options.dev=false] - Development mode flag.
56
61
  * @param {boolean} [options.controlServerInstall=false] - Flag to install the control server (e.g., MAAS).
57
62
  * @param {boolean} [options.controlServerUninstall=false] - Flag to uninstall the control server.
63
+ * @param {boolean} [options.controlServerRestart=false] - Flag to restart the control server.
58
64
  * @param {boolean} [options.controlServerDbInstall=false] - Flag to install the control server's database.
65
+ * @param {boolean} [options.createMachine=false] - Flag to create a machine in MAAS.
59
66
  * @param {boolean} [options.controlServerDbUninstall=false] - Flag to uninstall the control server's database.
67
+ * @param {string} [options.mac=''] - MAC address of the baremetal machine.
68
+ * @param {boolean} [options.ipxe=false] - Flag to use iPXE for booting.
69
+ * @param {boolean} [options.ipxeRebuild=false] - Flag to rebuild the iPXE binary with embedded script.
60
70
  * @param {boolean} [options.installPacker=false] - Flag to install Packer CLI.
61
71
  * @param {string} [options.packerMaasImageTemplate] - Template path from canonical/packer-maas to extract (requires workflow-id).
62
72
  * @param {string} [options.packerWorkflowId] - Workflow ID for Packer MAAS image operations (used with --packer-maas-image-build or --packer-maas-image-upload).
63
73
  * @param {boolean} [options.packerMaasImageBuild=false] - Flag to build a Packer MAAS image for the workflow specified by packerWorkflowId.
64
74
  * @param {boolean} [options.packerMaasImageUpload=false] - Flag to upload a Packer MAAS image artifact without rebuilding for the workflow specified by packerWorkflowId.
75
+ * @param {boolean} [options.packerMaasImageCached=false] - Flag to use cached artifacts when building the Packer MAAS image.
76
+ * @param {string} [options.removeMachines=''] - Comma-separated list of machine system IDs or '*' to remove existing machines from MAAS before commissioning.
77
+ * @param {boolean} [options.clearDiscovered=false] - Flag to clear discovered machines from MAAS before commissioning.
65
78
  * @param {boolean} [options.cloudInitUpdate=false] - Flag to update cloud-init configuration on the baremetal machine.
66
79
  * @param {boolean} [options.commission=false] - Flag to commission the baremetal machine.
80
+ * @param {number} [options.bootstrapHttpServerPort=8888] - Port for the bootstrap HTTP server.
81
+ * @param {string} [options.bootstrapHttpServerPath='./public/localhost'] - Path for the bootstrap HTTP server files.
82
+ * @param {string} [options.isoUrl=''] - Uses a custom ISO URL for baremetal machine commissioning.
83
+ * @param {boolean} [options.ubuntuToolsBuild=false] - Builds ubuntu tools for chroot environment.
84
+ * @param {boolean} [options.ubuntuToolsTest=false] - Tests ubuntu tools in chroot environment.
85
+ * @param {boolean} [options.rockyToolsBuild=false] - Builds rocky linux tools for chroot environment.
86
+ * @param {boolean} [options.rockyToolsTest=false] - Tests rocky linux tools in chroot environment.
87
+ * @param {string} [options.bootcmd=''] - Comma-separated list of boot commands to execute.
88
+ * @param {string} [options.runcmd=''] - Comma-separated list of run commands to execute.
67
89
  * @param {boolean} [options.nfsBuild=false] - Flag to build the NFS root filesystem.
90
+ * @param {boolean} [options.nfsBuildServer=false] - Flag to build the NFS server components.
68
91
  * @param {boolean} [options.nfsMount=false] - Flag to mount the NFS root filesystem.
69
92
  * @param {boolean} [options.nfsUnmount=false] - Flag to unmount the NFS root filesystem.
70
93
  * @param {boolean} [options.nfsSh=false] - Flag to chroot into the NFS environment for shell access.
@@ -74,23 +97,45 @@ class UnderpostBaremetal {
74
97
  */
75
98
  async callback(
76
99
  workflowId,
77
- hostname,
78
100
  ipAddress,
101
+ hostname,
102
+ ipFileServer,
103
+ ipConfig,
104
+ netmask,
105
+ dnsServer,
79
106
  options = {
80
107
  dev: false,
81
108
  controlServerInstall: false,
82
109
  controlServerUninstall: false,
110
+ controlServerRestart: false,
83
111
  controlServerDbInstall: false,
84
112
  controlServerDbUninstall: false,
113
+ createMachine: false,
114
+ mac: '',
115
+ ipxe: false,
116
+ ipxeRebuild: false,
85
117
  installPacker: false,
86
118
  packerMaasImageTemplate: false,
87
119
  packerWorkflowId: '',
88
120
  packerMaasImageBuild: false,
89
121
  packerMaasImageUpload: false,
90
122
  packerMaasImageCached: false,
123
+ removeMachines: '',
124
+ clearDiscovered: false,
91
125
  cloudInitUpdate: false,
126
+ cloudInit: false,
92
127
  commission: false,
128
+ bootstrapHttpServerPort: 8888,
129
+ bootstrapHttpServerPath: './public/localhost',
130
+ isoUrl: '',
131
+ ubuntuToolsBuild: false,
132
+ ubuntuToolsTest: false,
133
+ rockyToolsBuild: false,
134
+ rockyToolsTest: false,
135
+ bootcmd: '',
136
+ runcmd: '',
93
137
  nfsBuild: false,
138
+ nfsBuildServer: false,
94
139
  nfsMount: false,
95
140
  nfsUnmount: false,
96
141
  nfsSh: false,
@@ -105,15 +150,41 @@ class UnderpostBaremetal {
105
150
  const underpostRoot = options?.dev === true ? '.' : `${npmRoot}/underpost`;
106
151
 
107
152
  // Set default values if not provided.
108
- workflowId = workflowId ? workflowId : 'rpi4mb';
153
+ workflowId = workflowId ? workflowId : 'rpi4mbarm64-iso-ram';
109
154
  hostname = hostname ? hostname : workflowId;
110
- ipAddress = ipAddress ? ipAddress : '192.168.1.192';
155
+ ipAddress = ipAddress ? ipAddress : '192.168.1.191';
156
+ ipFileServer = ipFileServer ? ipFileServer : getLocalIPv4Address();
157
+ netmask = netmask ? netmask : '255.255.255.0';
158
+ dnsServer = dnsServer ? dnsServer : '8.8.8.8';
159
+
160
+ // IpConfig options:
161
+ // dhcp - DHCP configuration
162
+ // dhpc6 - DHCP IPv6 configuration
163
+ // auto6 - automatic IPv6 configuration
164
+ // on, any - any protocol available in the kernel (default)
165
+ // none, off - no autoconfiguration, static network configuration
166
+ ipConfig = ipConfig ? ipConfig : 'none';
111
167
 
112
168
  // Set default MAC address
113
- let macAddress = '00:00:00:00:00:00';
169
+ let macAddress = UnderpostBaremetal.API.macAddressFactory(options).mac;
170
+ const workflowsConfig = UnderpostBaremetal.API.loadWorkflowsConfig();
171
+
172
+ if (!workflowsConfig[workflowId]) {
173
+ throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
174
+ }
175
+
176
+ const tftpPrefix = workflowsConfig[workflowId].tftpPrefix || 'rpi4mb';
177
+ // Define the bootstrap architecture.
178
+ let bootstrapArch;
114
179
 
115
- // Define the debootstrap architecture.
116
- let debootstrapArch;
180
+ // Set bootstrap architecture.
181
+ if (workflowsConfig[workflowId].type === 'chroot-debootstrap') {
182
+ const { architecture } = workflowsConfig[workflowId].debootstrap.image;
183
+ bootstrapArch = architecture;
184
+ } else if (workflowsConfig[workflowId].type === 'chroot-container') {
185
+ const { architecture } = workflowsConfig[workflowId].container;
186
+ bootstrapArch = architecture;
187
+ }
117
188
 
118
189
  // Define the database provider ID.
119
190
  const dbProviderId = 'postgresql-17';
@@ -121,21 +192,87 @@ class UnderpostBaremetal {
121
192
  // Define the NFS host path based on the environment variable and hostname.
122
193
  const nfsHostPath = `${process.env.NFS_EXPORT_PATH}/${hostname}`;
123
194
 
124
- // Define the TFTP root path based on the environment variable and hostname.
125
- const tftpRootPath = `${process.env.TFTP_ROOT}/${hostname}`;
195
+ // Define the TFTP root prefix path based
196
+ const tftpRootPath = `${process.env.TFTP_ROOT}/${tftpPrefix}`;
197
+
198
+ // Define the iPXE cache directory to preserve builds across tftproot cleanups
199
+ const ipxeCacheDir = `/tmp/ipxe-cache/${tftpPrefix}`;
200
+
201
+ // Define the bootstrap HTTP server path.
202
+ const bootstrapHttpServerPath = options.bootstrapHttpServerPath
203
+ ? options.bootstrapHttpServerPath
204
+ : `/tmp/bootstrap-http-server/${workflowId}`;
126
205
 
127
206
  // Capture metadata for the callback execution, useful for logging and auditing.
128
207
  const callbackMetaData = {
129
- args: { hostname, ipAddress, workflowId },
208
+ args: { workflowId, ipAddress, hostname, ipFileServer, ipConfig, netmask, dnsServer },
130
209
  options,
131
210
  runnerHost: { architecture: UnderpostBaremetal.API.getHostArch().alias, ip: getLocalIPv4Address() },
132
211
  nfsHostPath,
133
212
  tftpRootPath,
213
+ bootstrapHttpServerPath,
134
214
  };
135
215
 
136
216
  // Log the initiation of the baremetal callback with relevant metadata.
137
217
  logger.info('Baremetal callback', callbackMetaData);
138
218
 
219
+ // Create a new machine in MAAS if the option is set.
220
+ let machine;
221
+ if (options.createMachine === true) {
222
+ const [searhMachine] = JSON.parse(
223
+ shellExec(`maas maas machines read hostname=${hostname}`, {
224
+ stdout: true,
225
+ silent: true,
226
+ }),
227
+ );
228
+
229
+ if (searhMachine) {
230
+ // Check if existing machine's MAC matches the specified MAC
231
+ const existingMac = searhMachine.boot_interface?.mac_address || searhMachine.mac_address;
232
+
233
+ // If using hardware MAC (macAddress is null), skip MAC validation and use existing machine
234
+ if (macAddress === null) {
235
+ logger.info(`Using hardware MAC mode - keeping existing machine ${hostname} with MAC ${existingMac}`);
236
+ machine = searhMachine;
237
+ } else if (existingMac && existingMac !== macAddress) {
238
+ logger.warn(`⚠ Machine ${hostname} exists with MAC ${existingMac}, but --mac specified ${macAddress}`);
239
+ logger.info(`Deleting existing machine ${searhMachine.system_id} to recreate with correct MAC...`);
240
+
241
+ // Delete the existing machine
242
+ shellExec(`maas maas machine delete ${searhMachine.system_id}`, {
243
+ silent: true,
244
+ });
245
+
246
+ // Create new machine with correct MAC
247
+ machine = UnderpostBaremetal.API.machineFactory({
248
+ hostname,
249
+ ipAddress,
250
+ macAddress,
251
+ maas: workflowsConfig[workflowId].maas,
252
+ }).machine;
253
+
254
+ logger.info(`✓ Machine recreated with MAC ${macAddress}`);
255
+ } else {
256
+ logger.info(`Using existing machine ${hostname} with MAC ${existingMac}`);
257
+ machine = searhMachine;
258
+ }
259
+ } else {
260
+ // No existing machine found, create new one
261
+ // For hardware MAC mode (macAddress is null), we'll create machine after discovery
262
+ if (macAddress === null) {
263
+ logger.info(`Hardware MAC mode - machine will be created after discovery`);
264
+ machine = null;
265
+ } else {
266
+ machine = UnderpostBaremetal.API.machineFactory({
267
+ hostname,
268
+ ipAddress,
269
+ macAddress,
270
+ maas: workflowsConfig[workflowId].maas,
271
+ }).machine;
272
+ }
273
+ }
274
+ }
275
+
139
276
  if (options.installPacker) {
140
277
  await UnderpostBaremetal.API.installPacker(underpostRoot);
141
278
  return;
@@ -183,9 +320,9 @@ class UnderpostBaremetal {
183
320
  workflows[workflowId] = workflowConfig;
184
321
  UnderpostBaremetal.API.writePackerMaasImageBuildWorkflows(workflows);
185
322
 
186
- logger.info('\nTemplate extracted successfully!');
187
- logger.info(`\nAdded configuration for ${workflowId} to engine/baremetal/packer-workflows.json`);
188
- logger.info('\nNext steps:');
323
+ logger.info('Template extracted successfully!');
324
+ logger.info(`Added configuration for ${workflowId} to engine/baremetal/packer-workflows.json`);
325
+ logger.info('Next steps');
189
326
  logger.info(`1. Review and customize the Packer template files in: ${targetDir}`);
190
327
  logger.info(`2. Review the workflow configuration in engine/baremetal/packer-workflows.json`);
191
328
  logger.info(
@@ -324,31 +461,39 @@ rm -rf ${artifacts.join(' ')}`);
324
461
  return;
325
462
  }
326
463
 
327
- if (options.logs === 'cloud') {
464
+ if (options.logs === 'dhcp-lease') {
465
+ shellExec(`cat /var/snap/maas/common/maas/dhcp/dhcpd.leases`);
466
+ shellExec(`cat /var/snap/maas/common/maas/dhcp/dhcpd.pid`);
467
+ return;
468
+ }
469
+
470
+ if (options.logs === 'dhcp-lan') {
471
+ shellExec(`sudo tcpdump -l -n -i any -s0 -vv 'udp and (port 67 or 68)'`);
472
+ return;
473
+ }
474
+
475
+ if (options.logs === 'cloud-init') {
328
476
  shellExec(`tail -f -n 900 ${nfsHostPath}/var/log/cloud-init.log`);
329
477
  return;
330
478
  }
331
479
 
332
- if (options.logs === 'machine') {
480
+ if (options.logs === 'cloud-init-machine') {
333
481
  shellExec(`tail -f -n 900 ${nfsHostPath}/var/log/cloud-init-output.log`);
334
482
  return;
335
483
  }
336
484
 
337
- if (options.logs === 'cloud-config') {
338
- shellExec(`cat ${nfsHostPath}/etc/cloud/cloud.cfg.d/90_maas.cfg`);
485
+ if (options.logs === 'cloud-init-config') {
486
+ shellExec(`cat ${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`);
487
+ shellExec(`cat ${bootstrapHttpServerPath}/${hostname}/cloud-init/meta-data`);
488
+ shellExec(`cat ${bootstrapHttpServerPath}/${hostname}/cloud-init/vendor-data`);
339
489
  return;
340
490
  }
341
491
 
342
492
  // Handle NFS shell access option.
343
493
  if (options.nfsSh === true) {
344
- const workflowsConfig = UnderpostBaremetal.API.loadWorkflowsConfig();
345
- if (!workflowsConfig[workflowId]) {
346
- throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
347
- }
348
- const { debootstrap } = workflowsConfig[workflowId];
349
494
  // Copy the chroot command to the clipboard for easy execution.
350
- if (debootstrap.image.architecture !== callbackMetaData.runnerHost.architecture)
351
- switch (debootstrap.image.architecture) {
495
+ if (bootstrapArch && bootstrapArch !== callbackMetaData.runnerHost.architecture)
496
+ switch (bootstrapArch) {
352
497
  case 'arm64':
353
498
  pbcopy(`sudo chroot ${nfsHostPath} /usr/bin/qemu-aarch64-static /bin/bash`);
354
499
  break;
@@ -371,16 +516,15 @@ rm -rf ${artifacts.join(' ')}`);
371
516
  shellExec(`chmod +x ${underpostRoot}/scripts/maas-setup.sh`);
372
517
  shellExec(`chmod +x ${underpostRoot}/scripts/nat-iptables.sh`);
373
518
  shellExec(`${underpostRoot}/scripts/maas-setup.sh`);
374
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`);
375
519
  return;
376
520
  }
377
521
 
378
522
  // Handle control server uninstallation.
379
523
  if (options.controlServerUninstall === true) {
380
524
  // Stop and remove MAAS services, handling potential errors gracefully.
381
- shellExec(`sudo snap stop maas.pebble || true`);
525
+ shellExec(`sudo snap stop maas.pebble`);
382
526
  shellExec(`sudo snap stop maas`);
383
- shellExec(`sudo snap remove maas --purge || true`);
527
+ shellExec(`sudo snap remove maas --purge`);
384
528
 
385
529
  // Remove residual snap data to ensure a clean uninstall.
386
530
  shellExec(`sudo rm -rf /var/snap/maas`);
@@ -393,6 +537,12 @@ rm -rf ${artifacts.join(' ')}`);
393
537
  return;
394
538
  }
395
539
 
540
+ // Handle control server restart.
541
+ if (options.controlServerRestart === true) {
542
+ shellExec(`sudo snap restart maas`);
543
+ return;
544
+ }
545
+
396
546
  // Handle control server database installation.
397
547
  if (options.controlServerDbInstall === true) {
398
548
  // Deploy the database provider and manage MAAS database.
@@ -410,43 +560,43 @@ rm -rf ${artifacts.join(' ')}`);
410
560
  return;
411
561
  }
412
562
 
413
- const workflowsConfig = UnderpostBaremetal.API.loadWorkflowsConfig();
414
- if (!workflowsConfig[workflowId]) {
415
- throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
416
- }
417
-
418
- // Set debootstrap architecture.
419
- {
420
- const { architecture } = workflowsConfig[workflowId].debootstrap.image;
421
- debootstrapArch = architecture;
422
- }
423
-
424
563
  // Handle NFS mount operation.
425
564
  if (options.nfsMount === true) {
426
- // Mount binfmt_misc filesystem.
427
- UnderpostBaremetal.API.mountBinfmtMisc({ nfsHostPath });
428
- UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, mount: true });
565
+ await UnderpostBaremetal.API.nfsMountCallback({
566
+ hostname,
567
+ nfsHostPath,
568
+ workflowId,
569
+ mount: true,
570
+ });
571
+ return;
429
572
  }
430
573
 
431
574
  // Handle NFS unmount operation.
432
575
  if (options.nfsUnmount === true) {
433
- UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, unmount: true });
576
+ await UnderpostBaremetal.API.nfsMountCallback({
577
+ hostname,
578
+ nfsHostPath,
579
+ workflowId,
580
+ unmount: true,
581
+ });
582
+ return;
434
583
  }
435
584
 
436
585
  // Handle NFS root filesystem build operation.
437
586
  if (options.nfsBuild === true) {
438
- // Check if NFS is already mounted to avoid redundant builds.
439
- const { isMounted } = UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId });
587
+ await UnderpostBaremetal.API.nfsMountCallback({
588
+ hostname,
589
+ nfsHostPath,
590
+ workflowId,
591
+ unmount: true,
592
+ });
440
593
 
441
594
  // Clean and create the NFS host path.
442
595
  shellExec(`sudo rm -rf ${nfsHostPath}/*`);
443
596
  shellExec(`mkdir -p ${nfsHostPath}`);
444
597
 
445
- // Mount binfmt_misc filesystem.
446
- UnderpostBaremetal.API.mountBinfmtMisc({ nfsHostPath });
447
-
448
598
  // Perform the first stage of debootstrap.
449
- {
599
+ if (workflowsConfig[workflowId].type === 'chroot-debootstrap') {
450
600
  const { architecture, name } = workflowsConfig[workflowId].debootstrap.image;
451
601
  shellExec(
452
602
  [
@@ -459,6 +609,12 @@ rm -rf ${artifacts.join(' ')}`);
459
609
  `http://ports.ubuntu.com/ubuntu-ports/`,
460
610
  ].join(' '),
461
611
  );
612
+ } else if (workflowsConfig[workflowId].type === 'chroot-container') {
613
+ const { image } = workflowsConfig[workflowId].container;
614
+ shellExec(`sudo podman pull --arch=${bootstrapArch} ${image}`);
615
+ shellExec(`sudo podman create --arch=${bootstrapArch} --name chroot-source ${image}`);
616
+ shellExec(`sudo podman export chroot-source | sudo tar -x -C ${nfsHostPath}`);
617
+ shellExec(`sudo podman rm chroot-source`);
462
618
  }
463
619
 
464
620
  // Create a podman container to extract QEMU static binaries.
@@ -466,10 +622,10 @@ rm -rf ${artifacts.join(' ')}`);
466
622
  shellExec(`podman ps -a`); // List all podman containers for verification.
467
623
 
468
624
  // If cross-architecture, copy the QEMU static binary into the chroot.
469
- if (debootstrapArch !== callbackMetaData.runnerHost.architecture)
625
+ if (bootstrapArch !== callbackMetaData.runnerHost.architecture)
470
626
  UnderpostBaremetal.API.crossArchBinFactory({
471
627
  nfsHostPath,
472
- debootstrapArch,
628
+ bootstrapArch,
473
629
  });
474
630
 
475
631
  // Clean up the temporary podman container.
@@ -477,40 +633,104 @@ rm -rf ${artifacts.join(' ')}`);
477
633
  shellExec(`podman ps -a`);
478
634
  shellExec(`file ${nfsHostPath}/bin/bash`); // Verify the bash executable in the chroot.
479
635
 
480
- // Perform the second stage of debootstrap within the chroot environment.
481
- UnderpostBaremetal.API.crossArchRunner({
636
+ // Mount necessary filesystems and register binfmt for the second stage.
637
+ await UnderpostBaremetal.API.nfsMountCallback({
638
+ hostname,
482
639
  nfsHostPath,
483
- debootstrapArch,
484
- callbackMetaData,
485
- steps: [`/debootstrap/debootstrap --second-stage`],
640
+ workflowId,
641
+ mount: true,
486
642
  });
487
643
 
488
- // Mount NFS if it's not already mounted after the build.
489
- if (!isMounted) {
490
- UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, mount: true });
491
- }
492
-
493
- // Apply system provisioning steps (base, user, timezone, keyboard).
494
- {
495
- const { systemProvisioning, kernelLibVersion, chronyc } = workflowsConfig[workflowId];
496
- const { timezone, chronyConfPath } = chronyc;
644
+ // Perform the second stage of debootstrap within the chroot environment.
645
+ if (workflowsConfig[workflowId].type === 'chroot-debootstrap') {
646
+ UnderpostBaremetal.API.crossArchRunner({
647
+ nfsHostPath,
648
+ bootstrapArch,
649
+ callbackMetaData,
650
+ steps: [`/debootstrap/debootstrap --second-stage`],
651
+ });
652
+ } else if (
653
+ workflowsConfig[workflowId].type === 'chroot-container' &&
654
+ workflowsConfig[workflowId].osIdLike.match('rhel')
655
+ ) {
656
+ // Copy resolv.conf to allow network access inside chroot
657
+ shellExec(`sudo cp /etc/resolv.conf ${nfsHostPath}/etc/resolv.conf`);
658
+
659
+ // Consolidate all package installations into one step to avoid redundancy
660
+ const { packages } = workflowsConfig[workflowId].container;
661
+ const basePackages = [
662
+ 'findutils',
663
+ 'systemd',
664
+ 'sudo',
665
+ 'dracut',
666
+ 'dracut-network',
667
+ 'dracut-config-generic',
668
+ 'nfs-utils',
669
+ 'file',
670
+ 'binutils',
671
+ 'kernel-modules-core',
672
+ 'NetworkManager',
673
+ 'dhclient',
674
+ 'iputils',
675
+ ];
676
+ const allPackages = packages && packages.length > 0 ? [...basePackages, ...packages] : basePackages;
497
677
 
498
678
  UnderpostBaremetal.API.crossArchRunner({
499
679
  nfsHostPath,
500
- debootstrapArch,
680
+ bootstrapArch,
501
681
  callbackMetaData,
502
682
  steps: [
503
- ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].base({
504
- kernelLibVersion,
505
- }),
506
- ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].user(),
507
- ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].timezone({
508
- timezone,
509
- chronyConfPath,
510
- }),
511
- ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].keyboard(),
683
+ `dnf install -y --allowerasing ${allPackages.join(' ')} 2>/dev/null || yum install -y --allowerasing ${allPackages.join(' ')} 2>/dev/null || echo "Package install completed"`,
684
+ `dnf clean all`,
685
+ `echo "=== Installed packages verification ==="`,
686
+ `rpm -qa | grep -E "dracut|kernel|nfs" | sort`,
687
+ `echo "=== Boot directory contents ==="`,
688
+ `ls -la /boot /lib/modules/*/`,
689
+ // Search for bootable kernel in order of preference:
690
+ // 1. Raw ARM64 Image file (preferred for GRUB)
691
+ // 2. vmlinuz or vmlinux (may be PE32+ on Rocky Linux)
692
+ `echo "Searching for bootable kernel..."`,
693
+ `KERNEL_FILE=""`,
694
+ // First try to find raw Image file
695
+ `if [ -f /boot/Image ]; then KERNEL_FILE=/boot/Image; echo "Found raw ARM64 Image: $KERNEL_FILE"; fi`,
696
+ `if [ -z "$KERNEL_FILE" ]; then KERNEL_FILE=$(find /lib/modules -name "Image" -o -name "Image.gz" 2>/dev/null | head -n 1); test -n "$KERNEL_FILE" && echo "Found kernel Image in modules: $KERNEL_FILE"; fi`,
697
+ // Fallback to vmlinuz
698
+ `if [ -z "$KERNEL_FILE" ]; then KERNEL_FILE=$(find /boot -name "vmlinuz-*" 2>/dev/null | head -n 1); test -n "$KERNEL_FILE" && echo "Found vmlinuz: $KERNEL_FILE"; fi`,
699
+ `if [ -z "$KERNEL_FILE" ]; then KERNEL_FILE=$(find /lib/modules -name "vmlinuz" 2>/dev/null | head -n 1); test -n "$KERNEL_FILE" && echo "Found vmlinuz in modules: $KERNEL_FILE"; fi`,
700
+ // Last resort: any vmlinux
701
+ `if [ -z "$KERNEL_FILE" ]; then KERNEL_FILE=$(find /lib/modules -name "vmlinux" 2>/dev/null | head -n 1); test -n "$KERNEL_FILE" && echo "Found vmlinux: $KERNEL_FILE"; fi`,
702
+ `if [ -z "$KERNEL_FILE" ]; then echo "ERROR: No kernel found!"; exit 1; fi`,
703
+ // Copy and check kernel type
704
+ `cp "$KERNEL_FILE" /boot/vmlinuz-efi.tmp`,
705
+ // Decompress if gzipped
706
+ `if file /boot/vmlinuz-efi.tmp | grep -q gzip; then echo "Decompressing gzipped kernel..."; gunzip -c /boot/vmlinuz-efi.tmp > /boot/vmlinuz-efi && rm /boot/vmlinuz-efi.tmp; else mv /boot/vmlinuz-efi.tmp /boot/vmlinuz-efi; fi`,
707
+ `KERNEL_TYPE=$(file /boot/vmlinuz-efi 2>/dev/null)`,
708
+ `echo "Final kernel file type: $KERNEL_TYPE"`,
709
+ // Handle PE32+ if still present - use kernel directly without extraction since iPXE can boot it
710
+ `case "$KERNEL_TYPE" in *PE32+*|*EFI*application*) echo "WARNING: Kernel is PE32+ EFI executable"; echo "GRUB may fail to boot this - recommend using iPXE chainload or installing kernel-core package"; echo "Keeping PE32+ kernel as-is for now..."; ;; *ARM64*|*aarch64*|*Image*|*data*) echo "Kernel appears to be raw ARM64 format - suitable for GRUB"; ;; *) echo "Unknown kernel format - attempting to use anyway"; ;; esac`,
711
+ // Get kernel version for initramfs rebuild
712
+ `KVER=$(basename $(dirname "$KERNEL_FILE"))`,
713
+ `echo "Kernel version: $KVER"`,
714
+ // Rebuild initramfs with NFS and network support
715
+ `echo "Rebuilding initramfs with NFS and network support..."`,
716
+ `echo "Available dracut modules:"`,
717
+ `dracut --list-modules 2>/dev/null | grep -E "network|nfs" || echo "No network modules listed"`,
718
+ // Use network-manager module (it's available in Rocky 9) for better compatibility
719
+ `dracut --force --add "nfs network base" --add-drivers "nfs sunrpc" --kver "$KVER" /boot/initrd.img "$KVER" 2>&1 || echo "Initramfs rebuild failed"`,
720
+ // Fallback: if rebuild fails, use existing initramfs
721
+ `if [ ! -f /boot/initrd.img ]; then echo "Initramfs rebuild failed, using existing..."; INITRD=$(find /boot -name "initramfs-$KVER.img" 2>/dev/null | head -n 1); if [ -z "$INITRD" ]; then INITRD=$(find /boot -name "initramfs*.img" 2>/dev/null | grep -v kdump | head -n 1); fi; if [ -n "$INITRD" ]; then cp "$INITRD" /boot/initrd.img; echo "Copied existing initramfs: $INITRD"; else echo "ERROR: No initramfs found!"; fi; fi`,
722
+ `echo "=== Final boot files ==="`,
723
+ `ls -lh /boot/vmlinuz-efi /boot/initrd.img`,
724
+ `file /boot/vmlinuz-efi`,
725
+ `file /boot/initrd.img`,
726
+ `echo "=== Setting root password ==="`,
727
+ `echo "root:root" | chpasswd`,
512
728
  ],
513
729
  });
730
+ } else {
731
+ throw new Error(
732
+ `Unsupported workflow type for NFS build: ${workflowsConfig[workflowId].type} and like os ID ${workflowsConfig[workflowId].osIdLike}`,
733
+ );
514
734
  }
515
735
  }
516
736
 
@@ -544,18 +764,187 @@ rm -rf ${artifacts.join(' ')}`);
544
764
  console.table(machines);
545
765
  }
546
766
 
547
- // Handle commissioning tasks (placeholder for future implementation).
767
+ if (options.clearDiscovered) UnderpostBaremetal.API.removeDiscoveredMachines();
768
+
769
+ // Handle remove existing machines from MAAS.
770
+ if (options.removeMachines)
771
+ machines = UnderpostBaremetal.API.removeMachines({
772
+ machines: options.removeMachines === 'all' ? machines : options.removeMachines.split(','),
773
+ ignore: machine ? [machine.system_id] : [],
774
+ });
775
+
776
+ if (workflowsConfig[workflowId].type === 'chroot-debootstrap') {
777
+ if (options.ubuntuToolsBuild) {
778
+ UnderpostCloudInit.API.buildTools({
779
+ workflowId,
780
+ nfsHostPath,
781
+ hostname,
782
+ callbackMetaData,
783
+ dev: options.dev,
784
+ });
785
+
786
+ const { chronyc, keyboard } = workflowsConfig[workflowId];
787
+ const { timezone, chronyConfPath } = chronyc;
788
+ const systemProvisioning = 'ubuntu';
789
+
790
+ UnderpostBaremetal.API.crossArchRunner({
791
+ nfsHostPath,
792
+ bootstrapArch,
793
+ callbackMetaData,
794
+ steps: [
795
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].base(),
796
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].user(),
797
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].timezone({
798
+ timezone,
799
+ chronyConfPath,
800
+ }),
801
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].keyboard(keyboard.layout),
802
+ ],
803
+ });
804
+ }
805
+
806
+ if (options.ubuntuToolsTest)
807
+ UnderpostBaremetal.API.crossArchRunner({
808
+ nfsHostPath,
809
+ bootstrapArch,
810
+ callbackMetaData,
811
+ steps: [
812
+ `chmod +x /underpost/date.sh`,
813
+ `chmod +x /underpost/keyboard.sh`,
814
+ `chmod +x /underpost/dns.sh`,
815
+ `chmod +x /underpost/help.sh`,
816
+ `chmod +x /underpost/host.sh`,
817
+ `chmod +x /underpost/test.sh`,
818
+ `chmod +x /underpost/start.sh`,
819
+ `chmod +x /underpost/reset.sh`,
820
+ `chmod +x /underpost/shutdown.sh`,
821
+ `chmod +x /underpost/device_scan.sh`,
822
+ `chmod +x /underpost/mac.sh`,
823
+ `chmod +x /underpost/enlistment.sh`,
824
+ `sudo chmod 700 ~/.ssh/`, // Set secure permissions for .ssh directory.
825
+ `sudo chmod 600 ~/.ssh/authorized_keys`, // Set secure permissions for authorized_keys.
826
+ `sudo chmod 644 ~/.ssh/known_hosts`, // Set permissions for known_hosts.
827
+ `sudo chmod 600 ~/.ssh/id_rsa`, // Set secure permissions for private key.
828
+ `sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`, // Set secure permissions for host key.
829
+ `chown -R root:root ~/.ssh`, // Ensure root owns the .ssh directory.
830
+ `/underpost/test.sh`,
831
+ ],
832
+ });
833
+ }
834
+
835
+ if (
836
+ workflowsConfig[workflowId].type === 'chroot-container' &&
837
+ workflowsConfig[workflowId].osIdLike.match('rhel')
838
+ ) {
839
+ if (options.rockyToolsBuild) {
840
+ const { chronyc, keyboard } = workflowsConfig[workflowId];
841
+ const { timezone } = chronyc;
842
+ const systemProvisioning = 'rocky';
843
+
844
+ UnderpostBaremetal.API.crossArchRunner({
845
+ nfsHostPath,
846
+ bootstrapArch,
847
+ callbackMetaData,
848
+ steps: [
849
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].base(),
850
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].user(),
851
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].timezone({
852
+ timezone,
853
+ chronyConfPath: chronyc.chronyConfPath,
854
+ }),
855
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].keyboard(keyboard.layout),
856
+ ],
857
+ });
858
+ }
859
+
860
+ if (options.rockyToolsTest)
861
+ UnderpostBaremetal.API.crossArchRunner({
862
+ nfsHostPath,
863
+ bootstrapArch,
864
+ callbackMetaData,
865
+ steps: [
866
+ `node --version`,
867
+ `npm --version`,
868
+ `underpost --version`,
869
+ `timedatectl status`,
870
+ `localectl status`,
871
+ `id root`,
872
+ `ls -la /home/root/.ssh/`,
873
+ `cat /home/root/.ssh/authorized_keys`,
874
+ 'underpost test',
875
+ ],
876
+ });
877
+ }
878
+
879
+ if (options.cloudInit || options.cloudInitUpdate) {
880
+ const { chronyc, networkInterfaceName } = workflowsConfig[workflowId];
881
+ const { timezone, chronyConfPath } = chronyc;
882
+ const authCredentials = UnderpostCloudInit.API.authCredentialsFactory();
883
+ const { cloudConfigSrc } = UnderpostCloudInit.API.configFactory(
884
+ {
885
+ controlServerIp: callbackMetaData.runnerHost.ip,
886
+ hostname,
887
+ commissioningDeviceIp: ipAddress,
888
+ gatewayip: callbackMetaData.runnerHost.ip,
889
+ mac: macAddress,
890
+ timezone,
891
+ chronyConfPath,
892
+ networkInterfaceName,
893
+ ubuntuToolsBuild: options.ubuntuToolsBuild,
894
+ bootcmd: options.bootcmd,
895
+ runcmd: options.runcmd,
896
+ },
897
+ authCredentials,
898
+ );
899
+
900
+ UnderpostBaremetal.API.httpBootstrapServerStaticFactory({
901
+ bootstrapHttpServerPath,
902
+ hostname,
903
+ cloudConfigSrc,
904
+ });
905
+ }
906
+
907
+ // Rebuild NFS server configuration.
908
+ if (
909
+ (options.nfsBuildServer === true || options.commission === true) &&
910
+ (workflowsConfig[workflowId].type === 'iso-nfs' ||
911
+ workflowsConfig[workflowId].type === 'chroot-debootstrap' ||
912
+ workflowsConfig[workflowId].type === 'chroot-container')
913
+ ) {
914
+ shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
915
+ UnderpostBaremetal.API.rebuildNfsServer({
916
+ nfsHostPath,
917
+ });
918
+ }
919
+ // Handle commissioning tasks
548
920
  if (options.commission === true) {
549
- const { firmwares, networkInterfaceName, maas, netmask, menuentryStr } = workflowsConfig[workflowId];
921
+ let { firmwares, networkInterfaceName, maas, menuentryStr, type } = workflowsConfig[workflowId];
922
+
923
+ // Use commissioning config (Ubuntu ephemeral) for PXE boot resources
924
+ const commissioningImage = maas?.commissioning || {
925
+ architecture: 'arm64/generic',
926
+ name: 'ubuntu/noble',
927
+ };
550
928
  const resource = resources.find(
551
- (o) => o.architecture === maas.image.architecture && o.name === maas.image.name,
929
+ (o) => o.architecture === commissioningImage.architecture && o.name === commissioningImage.name,
552
930
  );
553
931
  logger.info('Commissioning resource', resource);
554
932
 
933
+ if (type === 'iso-nfs') {
934
+ // Prepare NFS casper path if using NFS boot.
935
+ shellExec(`sudo rm -rf ${nfsHostPath}`);
936
+ shellExec(`mkdir -p ${nfsHostPath}/casper`);
937
+ }
938
+
555
939
  // Clean and create TFTP root path.
556
940
  shellExec(`sudo rm -rf ${tftpRootPath}`);
557
941
  shellExec(`mkdir -p ${tftpRootPath}/pxe`);
558
942
 
943
+ // Restore iPXE build from cache if available and not forcing rebuild
944
+ if (fs.existsSync(`${ipxeCacheDir}/ipxe.efi`) && !options.ipxeRebuild) {
945
+ shellExec(`cp ${ipxeCacheDir}/ipxe.efi ${tftpRootPath}/ipxe.efi`);
946
+ }
947
+
559
948
  // Process firmwares for TFTP.
560
949
  for (const firmware of firmwares) {
561
950
  const { url, gateway, subnet } = firmware;
@@ -569,221 +958,1235 @@ rm -rf ${artifacts.join(' ')}`);
569
958
  shellExec(`sudo cp -a ${path}/* ${tftpRootPath}`); // Copy firmware files to TFTP root.
570
959
 
571
960
  if (gateway && subnet) {
572
- fs.writeFileSync(
573
- `${tftpRootPath}/boot_${name}.conf`,
574
- UnderpostBaremetal.API.bootConfFactory({
575
- workflowId,
576
- tftpIp: callbackMetaData.runnerHost.ip,
577
- tftpPrefixStr: hostname,
578
- macAddress,
579
- clientIp: ipAddress,
580
- subnet,
581
- gateway,
582
- }),
583
- 'utf8',
584
- );
961
+ const bootConfSrc = UnderpostBaremetal.API.bootConfFactory({
962
+ workflowId,
963
+ tftpIp: callbackMetaData.runnerHost.ip,
964
+ tftpPrefixStr: tftpPrefix,
965
+ macAddress,
966
+ clientIp: ipAddress,
967
+ subnet,
968
+ gateway,
969
+ });
970
+ if (bootConfSrc) fs.writeFileSync(`${tftpRootPath}/boot_${name}.conf`, bootConfSrc, 'utf8');
585
971
  }
586
972
  }
587
973
  }
588
974
 
589
- // Rebuild NFS server configuration.
590
- UnderpostBaremetal.API.rebuildNfsServer({
591
- nfsHostPath,
975
+ // Configure GRUB for PXE boot.
976
+ {
977
+ // Fetch kernel and initrd paths from MAAS boot resource.
978
+ // Both NFS and disk-based commissioning use MAAS boot resources.
979
+ let kernelFilesPaths, resourcesPath;
980
+ if (workflowsConfig[workflowId].type === 'chroot-container') {
981
+ const arch = commissioningImage.architecture.split('/')[0];
982
+ resourcesPath = `/var/snap/maas/common/maas/image-storage/bootloaders/uefi/${arch}`;
983
+ kernelFilesPaths = {
984
+ 'vmlinuz-efi': `${nfsHostPath}/boot/vmlinuz-efi`,
985
+ 'initrd.img': `${nfsHostPath}/boot/initrd.img`,
986
+ };
987
+ } else {
988
+ const kf = UnderpostBaremetal.API.kernelFactory({
989
+ resource,
990
+ type,
991
+ nfsHostPath,
992
+ isoUrl: options.isoUrl || workflowsConfig[workflowId].isoUrl,
993
+ workflowId,
994
+ });
995
+ kernelFilesPaths = kf.kernelFilesPaths;
996
+ resourcesPath = kf.resourcesPath;
997
+ }
998
+
999
+ const { cmd } = UnderpostBaremetal.API.kernelCmdBootParamsFactory({
1000
+ ipClient: ipAddress,
1001
+ ipDhcpServer: callbackMetaData.runnerHost.ip,
1002
+ ipConfig,
1003
+ ipFileServer,
1004
+ netmask,
1005
+ hostname,
1006
+ dnsServer,
1007
+ networkInterfaceName,
1008
+ fileSystemUrl: kernelFilesPaths.isoUrl,
1009
+ bootstrapHttpServerPort:
1010
+ options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort || 8888,
1011
+ type,
1012
+ macAddress,
1013
+ cloudInit: options.cloudInit,
1014
+ machine,
1015
+ dev: options.dev,
1016
+ osIdLike: workflowsConfig[workflowId].osIdLike || '',
1017
+ });
1018
+
1019
+ // Check if iPXE mode is enabled AND the iPXE EFI binary exists
1020
+ let useIpxe = options.ipxe;
1021
+ if (options.ipxe) {
1022
+ const arch = commissioningImage.architecture.split('/')[0];
1023
+ const ipxeScript = UnderpostBaremetal.API.ipxeScriptFactory({
1024
+ maasIp: callbackMetaData.runnerHost.ip,
1025
+ macAddress,
1026
+ architecture: arch,
1027
+ tftpPrefix,
1028
+ kernelCmd: cmd,
1029
+ });
1030
+ fs.writeFileSync(`${tftpRootPath}/stable-id.ipxe`, ipxeScript, 'utf8');
1031
+
1032
+ // Create embedded boot script that does DHCP and chains to the main script
1033
+ const embeddedScript = UnderpostBaremetal.API.ipxeEmbeddedScriptFactory({
1034
+ tftpServer: callbackMetaData.runnerHost.ip,
1035
+ scriptPath: `/${tftpPrefix}/stable-id.ipxe`,
1036
+ macAddress: macAddress,
1037
+ });
1038
+ fs.writeFileSync(`${tftpRootPath}/boot.ipxe`, embeddedScript, 'utf8');
1039
+
1040
+ logger.info('✓ iPXE script generated for MAAS commissioning', {
1041
+ registeredMAC: macAddress,
1042
+ path: `${tftpRootPath}/stable-id.ipxe`,
1043
+ embeddedPath: `${tftpRootPath}/boot.ipxe`,
1044
+ });
1045
+
1046
+ UnderpostBaremetal.API.ipxeEfiFactory({
1047
+ tftpRootPath,
1048
+ ipxeCacheDir,
1049
+ arch,
1050
+ underpostRoot,
1051
+ embeddedScriptPath: `${tftpRootPath}/boot.ipxe`,
1052
+ forceRebuild: options.ipxeRebuild,
1053
+ });
1054
+ }
1055
+
1056
+ const { grubCfgSrc } = UnderpostBaremetal.API.grubFactory({
1057
+ menuentryStr,
1058
+ kernelPath: `/${tftpPrefix}/pxe/vmlinuz-efi`,
1059
+ initrdPath: `/${tftpPrefix}/pxe/initrd.img`,
1060
+ cmd,
1061
+ tftpIp: callbackMetaData.runnerHost.ip,
1062
+ ipxe: useIpxe,
1063
+ ipxePath: `/${tftpPrefix}/ipxe.efi`,
1064
+ });
1065
+ UnderpostBaremetal.API.writeGrubConfigToFile({
1066
+ grubCfgSrc: machine ? grubCfgSrc.replaceAll('system-id', machine.system_id) : grubCfgSrc,
1067
+ });
1068
+ if (machine) {
1069
+ logger.info('✓ GRUB config written with system_id', { system_id: machine.system_id });
1070
+ }
1071
+ UnderpostBaremetal.API.updateKernelFiles({
1072
+ commissioningImage,
1073
+ resourcesPath,
1074
+ tftpRootPath,
1075
+ kernelFilesPaths,
1076
+ });
1077
+ }
1078
+
1079
+ // Pass architecture from commissioning or deployment config
1080
+ const grubArch = commissioningImage.architecture;
1081
+ UnderpostBaremetal.API.efiGrubModulesFactory({ image: { architecture: grubArch } });
1082
+
1083
+ // Set ownership and permissions for TFTP root.
1084
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${process.env.TFTP_ROOT}`);
1085
+ shellExec(`sudo sudo chmod 755 ${process.env.TFTP_ROOT}`);
1086
+
1087
+ UnderpostBaremetal.API.httpBootstrapServerRunnerFactory({
1088
+ hostname,
1089
+ bootstrapHttpServerPath,
1090
+ bootstrapHttpServerPort:
1091
+ options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort,
592
1092
  });
593
1093
 
594
- // Configure GRUB for PXE boot.
1094
+ if (type === 'chroot-debootstrap' || type === 'chroot-container')
1095
+ await UnderpostBaremetal.API.nfsMountCallback({
1096
+ hostname,
1097
+ nfsHostPath,
1098
+ workflowId,
1099
+ mount: true,
1100
+ });
1101
+
1102
+ const commissionMonitorPayload = {
1103
+ macAddress,
1104
+ ipAddress,
1105
+ hostname,
1106
+ architecture:
1107
+ workflowsConfig[workflowId].maas?.commissioning?.architecture ||
1108
+ workflowsConfig[workflowId].container?.architecture ||
1109
+ workflowsConfig[workflowId].debootstrap?.image?.architecture ||
1110
+ 'arm64/generic',
1111
+ machine,
1112
+ };
1113
+ logger.info('Waiting for commissioning...', {
1114
+ ...commissionMonitorPayload,
1115
+ machine: machine ? machine.system_id : null,
1116
+ });
1117
+
1118
+ const { discovery } = await UnderpostBaremetal.API.commissionMonitor(commissionMonitorPayload);
1119
+
1120
+ if ((type === 'chroot-debootstrap' || type === 'chroot-container') && options.cloudInit === true) {
1121
+ openTerminal(`node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init`);
1122
+ openTerminal(
1123
+ `node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init-machine`,
1124
+ );
1125
+ shellExec(
1126
+ `node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init-config`,
1127
+ );
1128
+ }
1129
+ }
1130
+ },
1131
+
1132
+ /**
1133
+ * @method macAddressFactory
1134
+ * @description Generates or returns a MAC address based on options.
1135
+ * @param {object} options - Options for MAC address generation.
1136
+ * @param {string} options.mac - 'random' for random MAC, 'hardware' to use device's actual MAC, specific MAC string, or empty for default.
1137
+ * @returns {object} Object with mac property - null for 'hardware', generated/specified MAC otherwise.
1138
+ * @memberof UnderpostBaremetal
1139
+ */
1140
+ macAddressFactory(options = { mac: '' }) {
1141
+ const len = 6;
1142
+ const defaultMac = range(1, len)
1143
+ .map(() => '00')
1144
+ .join(':');
1145
+ if (options) {
1146
+ if (!options.mac) options.mac = defaultMac;
1147
+ if (options.mac === 'hardware') {
1148
+ // Return null to indicate hardware MAC should be used (no spoofing)
1149
+ options.mac = null;
1150
+ } else if (options.mac === 'random') {
1151
+ options.mac = range(1, len)
1152
+ .map(() => s4().toLowerCase().substring(0, 2))
1153
+ .join(':');
1154
+ }
1155
+ } else options = { mac: defaultMac };
1156
+ return options;
1157
+ },
1158
+
1159
+ /**
1160
+ * @method downloadISO
1161
+ * @description Downloads a generic ISO and extracts kernel boot files.
1162
+ * @param {object} params - Parameters for the method.
1163
+ * @param {object} params.resource - The MAAS boot resource object.
1164
+ * @param {string} params.architecture - The architecture (arm64 or amd64).
1165
+ * @param {string} params.nfsHostPath - The NFS host path to store the ISO and extracted files.
1166
+ * @param {string} params.isoUrl - The full URL to the ISO file to download.
1167
+ * @param {string} params.osIdLike - OS family identifier (e.g., 'debian ubuntu' or 'rhel centos fedora').
1168
+ * @returns {object} An object containing paths to the extracted kernel, initrd, and optionally squashfs.
1169
+ * @memberof UnderpostBaremetal
1170
+ */
1171
+ downloadISO({ resource, architecture, nfsHostPath, isoUrl, osIdLike }) {
1172
+ const arch = architecture || resource.architecture.split('/')[0];
1173
+
1174
+ // Validate that isoUrl is provided
1175
+ if (!isoUrl) {
1176
+ throw new Error('isoUrl parameter is required. Please specify the full ISO URL in the workflow configuration.');
1177
+ }
1178
+
1179
+ // Extract ISO filename from URL
1180
+ const isoFilename = isoUrl.split('/').pop();
1181
+
1182
+ // Determine OS family from osIdLike
1183
+ const isDebianBased = osIdLike && osIdLike.match(/debian|ubuntu/i);
1184
+ const isRhelBased = osIdLike && osIdLike.match(/rhel|centos|fedora|alma|rocky/i);
1185
+
1186
+ // Set extraction directory based on OS family
1187
+ const extractDirName = isDebianBased ? 'casper' : 'iso-extract';
1188
+ shellExec(`mkdir -p ${nfsHostPath}/${extractDirName}`);
1189
+
1190
+ const isoPath = `/var/tmp/live-iso/${isoFilename}`;
1191
+ const extractDir = `${nfsHostPath}/${extractDirName}`;
1192
+
1193
+ if (!fs.existsSync(isoPath)) {
1194
+ logger.info(`Downloading ISO for ${arch}...`);
1195
+ logger.info(`URL: ${isoUrl}`);
1196
+ shellExec(`mkdir -p /var/tmp/live-iso`);
1197
+ shellExec(`wget --progress=bar:force -O ${isoPath} "${isoUrl}"`);
1198
+ // Verify download by checking file existence and size (not exit code, which can be unreliable)
1199
+ if (!fs.existsSync(isoPath)) {
1200
+ throw new Error(`Failed to download ISO from ${isoUrl} - file not created`);
1201
+ }
1202
+ const stats = fs.statSync(isoPath);
1203
+ if (stats.size < 100 * 1024 * 1024) {
1204
+ shellExec(`rm -f ${isoPath}`);
1205
+ throw new Error(`Downloaded ISO is too small (${stats.size} bytes), download may have failed`);
1206
+ }
1207
+ logger.info(`Downloaded ISO to ${isoPath} (${(stats.size / 1024 / 1024 / 1024).toFixed(2)} GB)`);
1208
+ }
1209
+
1210
+ // Mount ISO and extract boot files
1211
+ const mountPoint = `${nfsHostPath}/mnt-iso-${arch}`;
1212
+ shellExec(`mkdir -p ${mountPoint}`);
1213
+
1214
+ // Ensure mount point is not already mounted
1215
+ shellExec(`sudo umount ${mountPoint} 2>/dev/null`, { silent: true });
1216
+
1217
+ try {
1218
+ // Mount the ISO
1219
+ shellExec(`sudo mount -o loop,ro ${isoPath} ${mountPoint}`, { silent: false });
1220
+ logger.info(`Mounted ISO at ${mountPoint}`);
1221
+
1222
+ // Distribution-specific extraction logic
1223
+ if (isDebianBased) {
1224
+ // Ubuntu/Debian: Extract from casper directory
1225
+ if (!fs.existsSync(`${mountPoint}/casper`)) {
1226
+ throw new Error(`Failed to mount ISO or casper directory not found: ${isoPath}`);
1227
+ }
1228
+ logger.info(`Checking casper directory contents...`);
1229
+ shellExec(`ls -la ${mountPoint}/casper/ 2>/dev/null || echo "casper directory not found"`);
1230
+ shellExec(`sudo cp -a ${mountPoint}/casper/* ${extractDir}/`);
1231
+ }
1232
+ } finally {
1233
+ shellExec(`ls -la ${mountPoint}/`);
1234
+
1235
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${extractDir}`);
1236
+ // Unmount ISO
1237
+ shellExec(`sudo umount ${mountPoint}`, { silent: true });
1238
+ logger.info(`Unmounted ISO`);
1239
+ // Clean up temporary mount point
1240
+ shellExec(`rmdir ${mountPoint}`, { silent: true });
1241
+ }
1242
+
1243
+ return {
1244
+ 'vmlinuz-efi': `${extractDir}/vmlinuz`,
1245
+ 'initrd.img': `${extractDir}/initrd`,
1246
+ isoUrl,
1247
+ };
1248
+ },
1249
+
1250
+ /**
1251
+ * @method machineFactory
1252
+ * @description Creates a new machine in MAAS with specified options.
1253
+ * @param {object} options - Options for creating the machine.
1254
+ * @param {string} options.macAddress - The MAC address of the machine.
1255
+ * @param {string} options.hostname - The hostname for the machine.
1256
+ * @param {string} options.ipAddress - The IP address for the machine.
1257
+ * @param {string} options.powerType - The power type for the machine (default is 'manual').
1258
+ * @param {object} options.maas - Additional MAAS-specific options.
1259
+ * @returns {object} An object containing the created machine details.
1260
+ * @memberof UnderpostBaremetal
1261
+ */
1262
+ machineFactory(
1263
+ options = {
1264
+ macAddress: '',
1265
+ hostname: '',
1266
+ ipAddress: '',
1267
+ powerType: 'manual',
1268
+ architecture: 'arm64/generic',
1269
+ },
1270
+ ) {
1271
+ if (!options.powerType) options.powerType = 'manual';
1272
+ const payload = {
1273
+ architecture: (options.architecture || 'arm64/generic').match('arm') ? 'arm64/generic' : 'amd64/generic',
1274
+ mac_address: options.macAddress,
1275
+ mac_addresses: options.macAddress,
1276
+ hostname: options.hostname,
1277
+ power_type: options.powerType,
1278
+ ip: options.ipAddress,
1279
+ };
1280
+ logger.info('Creating MAAS machine', payload);
1281
+ const machine = shellExec(
1282
+ `maas ${process.env.MAAS_ADMIN_USERNAME} machines create ${Object.keys(payload)
1283
+ .map((k) => `${k}="${payload[k]}"`)
1284
+ .join(' ')}`,
595
1285
  {
596
- const resourceData = JSON.parse(
597
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resource read ${resource.id}`, {
598
- stdout: true,
599
- silent: true,
600
- disableLog: true,
601
- }),
1286
+ silent: true,
1287
+ stdout: true,
1288
+ },
1289
+ );
1290
+ // console.log(machine);
1291
+ try {
1292
+ return { machine: JSON.parse(machine) };
1293
+ } catch (error) {
1294
+ console.log(error);
1295
+ logger.error(error);
1296
+ throw new Error(`Failed to create MAAS machine. Output:\n${machine}`);
1297
+ }
1298
+ },
1299
+
1300
+ /**
1301
+ * @method kernelFactory
1302
+ * @description Retrieves kernel, initrd, and root filesystem paths from a MAAS boot resource.
1303
+ * @param {object} params - Parameters for the method.
1304
+ * @param {object} params.resource - The MAAS boot resource object.
1305
+ * @param {string} params.type - The type of boot (e.g., 'iso-ram', 'iso-nfs', etc.).
1306
+ * @param {string} params.nfsHostPath - The NFS host path (used for ISO types).
1307
+ * @param {string} params.isoUrl - The ISO URL (used for ISO types).
1308
+ * @param {string} params.workflowId - The workflow identifier.
1309
+ * @returns {object} An object containing paths to the kernel, initrd, and root filesystem.
1310
+ * @memberof UnderpostBaremetal
1311
+ */
1312
+ kernelFactory({ resource, type, nfsHostPath, isoUrl, workflowId }) {
1313
+ // For disk-based commissioning (casper/iso), use live ISO files
1314
+ if (type === 'iso-ram' || type === 'iso-nfs') {
1315
+ logger.info('Using live ISO for boot (disk-based commissioning)');
1316
+ const arch = resource.architecture.split('/')[0];
1317
+ const workflowsConfig = UnderpostBaremetal.API.loadWorkflowsConfig();
1318
+ const kernelFilesPaths = UnderpostBaremetal.API.downloadISO({
1319
+ resource,
1320
+ architecture: arch,
1321
+ nfsHostPath,
1322
+ isoUrl,
1323
+ osIdLike: workflowsConfig[workflowId].osIdLike || '',
1324
+ });
1325
+ const resourcesPath = `/var/snap/maas/common/maas/image-storage/bootloaders/uefi/${arch}`;
1326
+ return { kernelFilesPaths, resourcesPath };
1327
+ }
1328
+
1329
+ const resourceData = JSON.parse(
1330
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resource read ${resource.id}`, {
1331
+ stdout: true,
1332
+ silent: true,
1333
+ disableLog: true,
1334
+ }),
1335
+ );
1336
+ let kernelFilesPaths = {};
1337
+ const bootFiles = resourceData.sets[Object.keys(resourceData.sets)[0]].files;
1338
+ const arch = resource.architecture.split('/')[0];
1339
+ const resourcesPath = `/var/snap/maas/common/maas/image-storage/bootloaders/uefi/${arch}`;
1340
+ const kernelPath = `/var/snap/maas/common/maas/image-storage`;
1341
+
1342
+ logger.info('Available boot files', Object.keys(bootFiles));
1343
+ logger.info('Boot files info', {
1344
+ id: resourceData.id,
1345
+ type: resourceData.type,
1346
+ name: resourceData.name,
1347
+ architecture: resourceData.architecture,
1348
+ bootFiles,
1349
+ arch,
1350
+ resourcesPath,
1351
+ kernelPath,
1352
+ });
1353
+
1354
+ // Try standard synced image structure (Ubuntu, CentOS from MAAS repos)
1355
+ const _suffix = resource.architecture.match('xgene') ? '.xgene' : '';
1356
+ if (bootFiles['boot-kernel' + _suffix] && bootFiles['boot-initrd' + _suffix] && bootFiles['squashfs']) {
1357
+ kernelFilesPaths = {
1358
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel' + _suffix].filename_on_disk}`,
1359
+ 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd' + _suffix].filename_on_disk}`,
1360
+ squashfs: `${kernelPath}/${bootFiles['squashfs'].filename_on_disk}`,
1361
+ };
1362
+ }
1363
+ // Try uploaded image structure (Packer-built images, custom uploads)
1364
+ else if (bootFiles['boot-kernel'] && bootFiles['boot-initrd'] && bootFiles['root-tgz']) {
1365
+ kernelFilesPaths = {
1366
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel'].filename_on_disk}`,
1367
+ 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd'].filename_on_disk}`,
1368
+ squashfs: `${kernelPath}/${bootFiles['root-tgz'].filename_on_disk}`,
1369
+ };
1370
+ }
1371
+ // Try alternative uploaded structure with root-image-xz
1372
+ else if (bootFiles['boot-kernel'] && bootFiles['boot-initrd'] && bootFiles['root-image-xz']) {
1373
+ kernelFilesPaths = {
1374
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel'].filename_on_disk}`,
1375
+ 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd'].filename_on_disk}`,
1376
+ squashfs: `${kernelPath}/${bootFiles['root-image-xz'].filename_on_disk}`,
1377
+ };
1378
+ }
1379
+ // Fallback: try to find any kernel, initrd, and root image
1380
+ else {
1381
+ logger.warn('Non-standard boot file structure detected. Available files', Object.keys(bootFiles));
1382
+
1383
+ const rootArchiveKey = Object.keys(bootFiles).find(
1384
+ (k) => k.includes('root') && (k.includes('tgz') || k.includes('tar.gz')),
1385
+ );
1386
+ const explicitKernel = Object.keys(bootFiles).find((k) => k.includes('kernel'));
1387
+ const explicitInitrd = Object.keys(bootFiles).find((k) => k.includes('initrd'));
1388
+
1389
+ if (rootArchiveKey && (!explicitKernel || !explicitInitrd)) {
1390
+ logger.info(`Root archive found (${rootArchiveKey}) and missing kernel/initrd. Attempting to extract.`);
1391
+ const rootArchivePath = `${kernelPath}/${bootFiles[rootArchiveKey].filename_on_disk}`;
1392
+ const tempExtractDir = `/tmp/maas-extract-${resource.id}`;
1393
+ shellExec(`mkdir -p ${tempExtractDir}`);
1394
+
1395
+ // List files in archive to find kernel and initrd
1396
+ const tarList = shellExec(`tar -tf ${rootArchivePath}`, { silent: true }).stdout.split('\n');
1397
+
1398
+ // Look for boot/vmlinuz* and boot/initrd* (handling potential leading ./)
1399
+ // Skip rescue, kdump, and other special images
1400
+ const vmlinuzPaths = tarList.filter(
1401
+ (f) => f.match(/(\.\/)?boot\/vmlinuz-[0-9]/) && !f.includes('rescue') && !f.includes('kdump'),
602
1402
  );
603
- const bootFiles = resourceData.sets[Object.keys(resourceData.sets)[0]].files;
604
- const suffix = resource.architecture.match('xgene') ? '.xgene' : '';
605
- const resourcesPath = `/var/snap/maas/common/maas/image-storage/bootloaders/uefi/arm64`;
606
- const kernelPath = `/var/snap/maas/common/maas/image-storage`;
607
- const kernelFilesPaths = {
608
- 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel' + suffix].filename_on_disk}`,
609
- 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd' + suffix].filename_on_disk}`,
610
- squashfs: `${kernelPath}/${bootFiles['squashfs'].filename_on_disk}`,
611
- };
612
- // Construct kernel command line arguments for NFS boot.
613
- const cmd = [
614
- `console=serial0,115200`,
615
- // `console=ttyAMA0,115200`,
616
- `console=tty1`,
617
- // `initrd=-1`,
618
- // `net.ifnames=0`,
619
- // `dwc_otg.lpm_enable=0`,
620
- // `elevator=deadline`,
621
- `root=/dev/nfs`,
622
- `nfsroot=${callbackMetaData.runnerHost.ip}:${process.env.NFS_EXPORT_PATH}/rpi4mb,${[
623
- 'tcp',
624
- 'vers=3',
625
- 'nfsvers=3',
626
- 'nolock',
627
- // 'protocol=tcp',
628
- // 'hard=true',
629
- 'port=2049',
630
- // 'sec=none',
631
- 'rw',
632
- 'hard',
633
- 'intr',
634
- 'rsize=32768',
635
- 'wsize=32768',
636
- 'acregmin=0',
637
- 'acregmax=0',
638
- 'acdirmin=0',
639
- 'acdirmax=0',
640
- 'noac',
641
- // 'nodev',
642
- // 'nosuid',
643
- ]}`,
644
- `ip=${ipAddress}:${callbackMetaData.runnerHost.ip}:${callbackMetaData.runnerHost.ip}:${netmask}:${hostname}:${networkInterfaceName}:static`,
645
- `rootfstype=nfs`,
646
- `rw`,
647
- `rootwait`,
648
- `fixrtc`,
649
- 'initrd=initrd.img',
650
- // 'boot=casper',
651
- // 'ro',
652
- 'netboot=nfs',
653
- `init=/sbin/init`,
654
- // `cloud-config-url=/dev/null`,
655
- // 'ip=dhcp',
656
- // 'ip=dfcp',
657
- // 'autoinstall',
658
- // 'rd.break',
659
-
660
- // Disable services that not apply over nfs
661
- `systemd.mask=systemd-network-generator.service`,
662
- `systemd.mask=systemd-networkd.service`,
663
- `systemd.mask=systemd-fsck-root.service`,
664
- `systemd.mask=systemd-udev-trigger.service`,
665
- ];
666
- const nfsConnectStr = cmd.join(' ');
1403
+ const initrdPaths = tarList.filter(
1404
+ (f) =>
1405
+ f.match(/(\.\/)?boot\/(initrd|initramfs)-[0-9]/) &&
1406
+ !f.includes('rescue') &&
1407
+ !f.includes('kdump') &&
1408
+ f.includes('.img'),
1409
+ );
1410
+
1411
+ logger.info(`Found kernel candidates:`, { vmlinuzPaths, initrdPaths });
1412
+
1413
+ // Try to match kernel and initrd by version number
1414
+ let vmlinuzPath = null;
1415
+ let initrdPath = null;
1416
+
1417
+ if (vmlinuzPaths.length > 0 && initrdPaths.length > 0) {
1418
+ // Extract version from kernel filename (e.g., "5.14.0-611.11.1.el9_7.aarch64")
1419
+ for (const kernelPath of vmlinuzPaths.sort().reverse()) {
1420
+ const kernelVersion = kernelPath.match(/vmlinuz-(.+)$/)?.[1];
1421
+ if (kernelVersion) {
1422
+ // Look for matching initrd
1423
+ const matchingInitrd = initrdPaths.find((p) => p.includes(kernelVersion));
1424
+ if (matchingInitrd) {
1425
+ vmlinuzPath = kernelPath;
1426
+ initrdPath = matchingInitrd;
1427
+ logger.info(`Matched kernel and initrd by version: ${kernelVersion}`);
1428
+ break;
1429
+ }
1430
+ }
1431
+ }
1432
+ }
667
1433
 
668
- // Copy EFI bootloaders to TFTP path.
669
- for (const file of ['bootaa64.efi', 'grubaa64.efi']) {
670
- shellExec(`sudo cp -a ${resourcesPath}/${file} ${tftpRootPath}/pxe/${file}`);
1434
+ // Fallback: use newest versions if no match found
1435
+ if (!vmlinuzPath && vmlinuzPaths.length > 0) {
1436
+ vmlinuzPath = vmlinuzPaths.sort().pop();
671
1437
  }
672
- // Copy kernel and initrd images to TFTP path.
673
- for (const file of Object.keys(kernelFilesPaths)) {
674
- shellExec(`sudo cp -a ${kernelFilesPaths[file]} ${tftpRootPath}/pxe/${file}`);
1438
+ if (!initrdPath && initrdPaths.length > 0) {
1439
+ initrdPath = initrdPaths.sort().pop();
675
1440
  }
676
1441
 
677
- // Write GRUB configuration file.
678
- fs.writeFileSync(
679
- `${process.env.TFTP_ROOT}/grub/grub.cfg`,
680
- `
681
- insmod gzio
682
- insmod http
683
- insmod nfs
684
- set timeout=5
685
- set default=0
686
-
687
- menuentry '${menuentryStr}' {
688
- set root=(tftp,${callbackMetaData.runnerHost.ip})
689
- linux /${hostname}/pxe/vmlinuz-efi ${nfsConnectStr}
690
- initrd /${hostname}/pxe/initrd.img
691
- boot
692
- }
1442
+ logger.info(`Selected kernel: ${vmlinuzPath}, initrd: ${initrdPath}`);
693
1443
 
694
- `,
695
- 'utf8',
696
- );
1444
+ if (vmlinuzPath && initrdPath) {
1445
+ // Extract specific files
1446
+ // Extract all files in boot/ to ensure symlinks resolve
1447
+ shellExec(`tar -xf ${rootArchivePath} -C ${tempExtractDir} --wildcards '*boot/*'`);
1448
+
1449
+ kernelFilesPaths = {
1450
+ 'vmlinuz-efi': `${tempExtractDir}/${vmlinuzPath}`,
1451
+ 'initrd.img': `${tempExtractDir}/${initrdPath}`,
1452
+ squashfs: rootArchivePath,
1453
+ };
1454
+ logger.info('Extracted kernel and initrd from root archive.');
1455
+ } else {
1456
+ logger.error(
1457
+ `Failed to find kernel/initrd in archive. Contents of boot/ directory:`,
1458
+ tarList.filter((f) => f.includes('boot/')),
1459
+ );
1460
+ throw new Error(`Could not find kernel or initrd in ${rootArchiveKey}`);
1461
+ }
1462
+ } else {
1463
+ const kernelFile = Object.keys(bootFiles).find((k) => k.includes('kernel')) || Object.keys(bootFiles)[0];
1464
+ const initrdFile = Object.keys(bootFiles).find((k) => k.includes('initrd')) || Object.keys(bootFiles)[1];
1465
+ const rootFile =
1466
+ Object.keys(bootFiles).find(
1467
+ (k) => k.includes('root') || k.includes('squashfs') || k.includes('tgz') || k.includes('xz'),
1468
+ ) || Object.keys(bootFiles)[2];
1469
+
1470
+ if (kernelFile && initrdFile && rootFile) {
1471
+ kernelFilesPaths = {
1472
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles[kernelFile].filename_on_disk}`,
1473
+ 'initrd.img': `${kernelPath}/${bootFiles[initrdFile].filename_on_disk}`,
1474
+ squashfs: `${kernelPath}/${bootFiles[rootFile].filename_on_disk}`,
1475
+ };
1476
+ logger.info('Using detected files', { kernel: kernelFile, initrd: initrdFile, root: rootFile });
1477
+ } else {
1478
+ throw new Error(`Cannot identify boot files. Available: ${Object.keys(bootFiles).join(', ')}`);
1479
+ }
697
1480
  }
1481
+ }
1482
+ return {
1483
+ resource,
1484
+ bootFiles,
1485
+ arch,
1486
+ resourcesPath,
1487
+ kernelPath,
1488
+ resourceData,
1489
+ kernelFilesPaths,
1490
+ };
1491
+ },
1492
+
1493
+ /**
1494
+ * @method writeGrubConfigToFile
1495
+ * @description Writes the GRUB configuration content to the grub.cfg file in the TFTP root.
1496
+ * @param {object} params - Parameters for the method.
1497
+ * @param {string} params.grubCfgSrc - The GRUB configuration content to write.
1498
+ * @memberof UnderpostBaremetal
1499
+ * @returns {void}
1500
+ */
1501
+ writeGrubConfigToFile({ grubCfgSrc = '' }) {
1502
+ shellExec(`mkdir -p ${process.env.TFTP_ROOT}/grub`, {
1503
+ disableLog: true,
1504
+ });
1505
+ return fs.writeFileSync(`${process.env.TFTP_ROOT}/grub/grub.cfg`, grubCfgSrc, 'utf8');
1506
+ },
698
1507
 
1508
+ /**
1509
+ * @method getGrubConfigFromFile
1510
+ * @description Reads the GRUB configuration content from the grub.cfg file in the TFTP root.
1511
+ * @memberof UnderpostBaremetal
1512
+ * @returns {string} The GRUB configuration content.
1513
+ */
1514
+ getGrubConfigFromFile() {
1515
+ const grubCfgPath = `${process.env.TFTP_ROOT}/grub/grub.cfg`;
1516
+ const grubCfgSrc = fs.readFileSync(grubCfgPath, 'utf8');
1517
+ return { grubCfgPath, grubCfgSrc };
1518
+ },
1519
+
1520
+ /**
1521
+ * @method removeDiscoveredMachines
1522
+ * @description Removes all machines in the 'discovered' status from MAAS.
1523
+ * @memberof UnderpostBaremetal
1524
+ * @returns {void}
1525
+ */
1526
+ removeDiscoveredMachines() {
1527
+ logger.info('Removing all discovered machines from MAAS...');
1528
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
1529
+ },
1530
+
1531
+ /**
1532
+ * @method efiGrubModulesFactory
1533
+ * @description Copies the appropriate EFI GRUB modules to the TFTP root based on the image architecture.
1534
+ * @param {object} options - Options for determining which GRUB modules to copy.
1535
+ * @param {object} options.image - Image configuration object.
1536
+ * @param {string} options.image.architecture - The architecture of the image ('amd64' or 'arm64').
1537
+ * @memberof UnderpostBaremetal
1538
+ * @returns {void}
1539
+ */
1540
+ efiGrubModulesFactory(options = { image: { architecture: 'amd64' } }) {
1541
+ if (options.image.architecture.match('arm64')) {
699
1542
  // Copy ARM64 EFI GRUB modules.
700
1543
  const arm64EfiPath = `${process.env.TFTP_ROOT}/grub/arm64-efi`;
701
1544
  if (fs.existsSync(arm64EfiPath)) shellExec(`sudo rm -rf ${arm64EfiPath}`);
702
1545
  shellExec(`sudo cp -a /usr/lib/grub/arm64-efi ${arm64EfiPath}`);
703
-
704
- // Set ownership and permissions for TFTP root.
705
- shellExec(`sudo chown -R root:root ${process.env.TFTP_ROOT}`);
706
- shellExec(`sudo sudo chmod 755 ${process.env.TFTP_ROOT}`);
1546
+ } else {
1547
+ // Copy AMD64 EFI GRUB modules.
1548
+ const amd64EfiPath = `${process.env.TFTP_ROOT}/grub/x86_64-efi`;
1549
+ if (fs.existsSync(amd64EfiPath)) shellExec(`sudo rm -rf ${amd64EfiPath}`);
1550
+ shellExec(`sudo cp -a /usr/lib/grub/x86_64-efi ${amd64EfiPath}`);
707
1551
  }
1552
+ },
708
1553
 
709
- // Final commissioning steps.
710
- if (options.commission === true || options.cloudInitUpdate === true) {
711
- const { debootstrap, networkInterfaceName, chronyc, maas } = workflowsConfig[workflowId];
712
- const { timezone, chronyConfPath } = chronyc;
1554
+ /**
1555
+ * @method ipxeEmbeddedScriptFactory
1556
+ * @description Generates the embedded iPXE boot script that performs DHCP and chains to the main script.
1557
+ * This script is embedded into the iPXE binary or loaded first by GRUB.
1558
+ * Supports MAC address spoofing for baremetal commissioning workflows.
1559
+ * @param {object} params - The parameters for generating the script.
1560
+ * @param {string} params.tftpServer - The IP address of the TFTP server.
1561
+ * @param {string} params.scriptPath - The path to the main iPXE script on TFTP server.
1562
+ * @param {string} [params.macAddress] - Optional MAC address to spoof for MAAS registration.
1563
+ * @returns {string} The embedded iPXE script content.
1564
+ * @memberof UnderpostBaremetal
1565
+ */
1566
+ ipxeEmbeddedScriptFactory({ tftpServer, scriptPath, macAddress = null }) {
1567
+ const macSpoofingBlock =
1568
+ macAddress && macAddress !== '00:00:00:00:00:00'
1569
+ ? `
1570
+ # MAC Address Information
1571
+ echo ========================================
1572
+ echo Target MAC for MAAS: ${macAddress}
1573
+ echo Hardware MAC: \${net0/mac}
1574
+ echo ========================================
1575
+ echo NOTE: iPXE MAC spoofing does NOT persist to kernel
1576
+ echo The kernel will receive MAC via ifname= parameter
1577
+ echo ========================================
1578
+ `
1579
+ : `
1580
+ # Using hardware MAC address
1581
+ echo ========================================
1582
+ echo Using device hardware MAC address
1583
+ echo Hardware MAC: \${net0/mac}
1584
+ echo MAC spoofing disabled - device uses actual MAC
1585
+ echo ========================================
1586
+ `;
1587
+
1588
+ return `#!ipxe
1589
+ # Embedded iPXE Boot Script
1590
+ # This script performs DHCP configuration and chains to the main boot script
1591
+
1592
+ echo ========================================
1593
+ echo iPXE Embedded Boot Loader
1594
+ echo ========================================
1595
+ echo TFTP Server: ${tftpServer}
1596
+ echo Script Path: ${scriptPath}
1597
+ echo ========================================
1598
+
1599
+ ${macSpoofingBlock}
1600
+
1601
+ # Show network interface info before DHCP
1602
+ echo Network Interface Info (before DHCP):
1603
+ echo Interface: net0
1604
+ echo MAC: \${net0/mac}
1605
+ ifstat
1606
+
1607
+ # Perform DHCP to get network configuration
1608
+ echo ========================================
1609
+ echo Performing DHCP configuration...
1610
+ echo ========================================
1611
+ dhcp net0 || goto dhcp_retry
1612
+
1613
+ echo DHCP configuration successful
1614
+ echo IP Address: \${net0/ip}
1615
+ echo Netmask: \${net0/netmask}
1616
+ echo Gateway: \${net0/gateway}
1617
+ echo DNS: \${net0/dns}
1618
+ echo TFTP Server: \${next-server}
1619
+ echo MAC used by DHCP: \${net0/mac}
1620
+
1621
+ # Chain to the main iPXE script
1622
+ echo ========================================
1623
+ echo Chainloading main boot script...
1624
+ echo Script: tftp://${tftpServer}${scriptPath}
1625
+ echo ========================================
1626
+ chain tftp://${tftpServer}${scriptPath} || goto chain_failed
1627
+
1628
+ :dhcp_retry
1629
+ echo DHCP failed, retrying in 5 seconds...
1630
+ sleep 5
1631
+ dhcp net0 || goto dhcp_retry
1632
+ goto dhcp_success
1633
+
1634
+ :dhcp_success
1635
+ echo DHCP retry successful
1636
+ echo IP Address: \${net0/ip}
1637
+ echo MAC: \${net0/mac}
1638
+ chain tftp://${tftpServer}${scriptPath} || goto chain_failed
1639
+
1640
+ :chain_failed
1641
+ echo ========================================
1642
+ echo ERROR: Failed to chain to main script
1643
+ echo TFTP Server: ${tftpServer}
1644
+ echo Script Path: ${scriptPath}
1645
+ echo ========================================
1646
+ echo Retrying in 10 seconds...
1647
+ sleep 10
1648
+ chain tftp://${tftpServer}${scriptPath} || goto shell_debug
1649
+
1650
+ :shell_debug
1651
+ echo Dropping to iPXE shell for manual debugging
1652
+ echo Try: chain tftp://${tftpServer}${scriptPath}
1653
+ shell
1654
+ `;
1655
+ },
713
1656
 
714
- // Build cloud-init tools.
715
- UnderpostCloudInit.API.buildTools({
716
- workflowId,
717
- nfsHostPath,
718
- hostname,
719
- callbackMetaData,
720
- dev: options.dev,
721
- });
1657
+ /**
1658
+ * @method ipxeScriptFactory
1659
+ * @description Generates the iPXE script content for stable identity.
1660
+ * This iPXE script uses directly boots kernel/initrd via TFTP.
1661
+ * @param {object} params - The parameters for generating the script.
1662
+ * @param {string} params.maasIp - The IP address of the MAAS server.
1663
+ * @param {string} [params.macAddress] - The MAC address registered in MAAS (for display only).
1664
+ * @param {string} params.architecture - The architecture (arm64/amd64).
1665
+ * @param {string} params.tftpPrefix - The TFTP prefix path (e.g., 'rpi4mb').
1666
+ * @param {string} params.kernelCmd - The kernel command line parameters.
1667
+ * @returns {string} The iPXE script content.
1668
+ * @memberof UnderpostBaremetal
1669
+ */
1670
+ ipxeScriptFactory({ maasIp, macAddress, architecture, tftpPrefix, kernelCmd }) {
1671
+ const macInfo =
1672
+ macAddress && macAddress !== '00:00:00:00:00:00'
1673
+ ? `echo Registered MAC: ${macAddress}`
1674
+ : `echo Using hardware MAC address`;
1675
+
1676
+ // Construct the full TFTP paths for kernel and initrd
1677
+ const kernelPath = `${tftpPrefix}/pxe/vmlinuz-efi`;
1678
+ const initrdPath = `${tftpPrefix}/pxe/initrd.img`;
1679
+ const grubBootloader = architecture === 'arm64' ? 'grubaa64.efi' : 'grubx64.efi';
1680
+ const grubPath = `${tftpPrefix}/pxe/${grubBootloader}`;
1681
+
1682
+ return `#!ipxe
1683
+ echo ========================================
1684
+ echo iPXE Network Boot
1685
+ echo ========================================
1686
+ echo MAAS Server: ${maasIp}
1687
+ echo Architecture: ${architecture}
1688
+ ${macInfo}
1689
+ echo ========================================
1690
+
1691
+ # Show current network configuration
1692
+ echo Current Network Configuration:
1693
+ ifstat
1694
+
1695
+ # Display MAC address information
1696
+ echo MAC Address: \${net0/mac}
1697
+ echo IP Address: \${net0/ip}
1698
+ echo Gateway: \${net0/gateway}
1699
+ echo DNS: \${net0/dns}
1700
+ ${macAddress && macAddress !== '00:00:00:00:00:00' ? `echo Target MAC for kernel: ${macAddress}` : ''}
1701
+
1702
+ # Direct kernel/initrd boot via TFTP
1703
+ # Modern simplified approach: Direct kernel/initrd boot via TFTP
1704
+ echo ========================================
1705
+ echo Loading kernel and initrd via TFTP...
1706
+ echo Kernel: tftp://${maasIp}/${kernelPath}
1707
+ echo Initrd: tftp://${maasIp}/${initrdPath}
1708
+ ${macAddress && macAddress !== '00:00:00:00:00:00' ? `echo Kernel will use MAC: ${macAddress} (via ifname parameter)` : 'echo Kernel will use hardware MAC'}
1709
+ echo ========================================
1710
+
1711
+ # Load kernel via TFTP
1712
+ kernel tftp://${maasIp}/${kernelPath} ${kernelCmd || 'console=ttyS0,115200'} || goto grub_fallback
1713
+ echo Kernel loaded successfully
1714
+
1715
+ # Load initrd via TFTP
1716
+ initrd tftp://${maasIp}/${initrdPath} || goto grub_fallback
1717
+ echo Initrd loaded successfully
1718
+
1719
+ # Boot the kernel
1720
+ echo Booting kernel...
1721
+ boot
1722
+
1723
+ :grub_fallback
1724
+ echo ========================================
1725
+ echo Direct kernel boot failed, falling back to GRUB chainload...
1726
+ echo TFTP Path: tftp://${maasIp}/${grubPath}
1727
+ echo ========================================
1728
+
1729
+ # Fallback: Chain to GRUB via TFTP (avoids malformed HTTP bootloader issues)
1730
+ chain tftp://${maasIp}/${grubPath} || goto http_fallback
1731
+
1732
+ :http_fallback
1733
+ echo TFTP GRUB chainload failed, trying HTTP fallback...
1734
+ echo ========================================
1735
+
1736
+ # Fallback: Try MAAS HTTP bootloader (may have certificate issues)
1737
+ set boot-url http://${maasIp}:5248/images/bootloader
1738
+ echo Boot URL: \${boot-url}
1739
+ chain \${boot-url}/uefi/${architecture}/${grubBootloader === 'grubaa64.efi' ? 'bootaa64.efi' : 'bootx64.efi'} || goto error
1740
+
1741
+ :error
1742
+ echo ========================================
1743
+ echo ERROR: All boot methods failed
1744
+ echo ========================================
1745
+ echo MAAS IP: ${maasIp}
1746
+ echo Architecture: ${architecture}
1747
+ echo MAC: \${net0/mac}
1748
+ echo IP: \${net0/ip}
1749
+ echo ========================================
1750
+ echo Retrying GRUB TFTP in 10 seconds...
1751
+ sleep 10
1752
+ chain tftp://${maasIp}/${grubPath} || goto shell_debug
1753
+
1754
+ :shell_debug
1755
+ echo Dropping to iPXE shell for manual intervention
1756
+ shell
1757
+ `;
1758
+ },
722
1759
 
723
- // Run cloud-init reset and configure cloud-init.
724
- UnderpostBaremetal.API.crossArchRunner({
725
- nfsHostPath,
726
- debootstrapArch: debootstrap.image.architecture,
727
- callbackMetaData,
728
- steps: [
729
- options.cloudInitUpdate === true ? '' : `/underpost/reset.sh`,
730
- `chown root:root /usr/bin/sudo && chmod 4755 /usr/bin/sudo`,
731
- UnderpostCloudInit.API.configFactory({
732
- controlServerIp: callbackMetaData.runnerHost.ip,
733
- hostname,
734
- commissioningDeviceIp: ipAddress,
735
- gatewayip: callbackMetaData.runnerHost.ip,
736
- mac: macAddress, // Initial MAC, will be updated.
737
- timezone,
738
- chronyConfPath,
739
- networkInterfaceName,
740
- }),
741
- ],
1760
+ /**
1761
+ * @method ipxeEfiFactory
1762
+ * @description Manages iPXE EFI binary build with cache support.
1763
+ * Checks cache, builds only if needed, saves to cache after build.
1764
+ * @param {object} params - The parameters for iPXE build.
1765
+ * @param {string} params.tftpRootPath - TFTP root directory path.
1766
+ * @param {string} params.ipxeCacheDir - iPXE cache directory path.
1767
+ * @param {string} params.arch - Target architecture (arm64/amd64).
1768
+ * @param {string} params.underpostRoot - Underpost root directory.
1769
+ * @param {string} [params.embeddedScriptPath] - Path to embedded boot script.
1770
+ * @param {boolean} [params.forceRebuild=false] - Force rebuild regardless of cache.
1771
+ * @returns {void}
1772
+ * @memberof UnderpostBaremetal
1773
+ */
1774
+ ipxeEfiFactory({ tftpRootPath, ipxeCacheDir, arch, underpostRoot, embeddedScriptPath, forceRebuild = false }) {
1775
+ const shouldRebuild =
1776
+ forceRebuild || (!fs.existsSync(`${tftpRootPath}/ipxe.efi`) && !fs.existsSync(`${ipxeCacheDir}/ipxe.efi`));
1777
+
1778
+ if (!shouldRebuild) return;
1779
+
1780
+ if (embeddedScriptPath && fs.existsSync(embeddedScriptPath)) {
1781
+ logger.info('Rebuilding iPXE with embedded boot script...', {
1782
+ embeddedScriptPath,
1783
+ forced: forceRebuild,
742
1784
  });
1785
+ shellExec(
1786
+ `${underpostRoot}/scripts/ipxe-setup.sh ${tftpRootPath} --target-arch ${arch} --embed-script ${embeddedScriptPath} --rebuild`,
1787
+ );
1788
+ } else if (shouldRebuild) {
1789
+ shellExec(`${underpostRoot}/scripts/ipxe-setup.sh ${tftpRootPath} --target-arch ${arch}`);
1790
+ }
743
1791
 
744
- if (options.cloudInitUpdate === true) return;
1792
+ shellExec(`mkdir -p ${ipxeCacheDir}`);
1793
+ shellExec(`cp ${tftpRootPath}/ipxe.efi ${ipxeCacheDir}/ipxe.efi`);
1794
+ },
745
1795
 
746
- // Apply NAT iptables rules.
747
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
1796
+ /**
1797
+ * @method grubFactory
1798
+ * @description Generates the GRUB configuration file content.
1799
+ * @param {object} params - The parameters for generating the configuration.
1800
+ * @param {string} params.menuentryStr - The title of the menu entry.
1801
+ * @param {string} params.kernelPath - The path to the kernel file (relative to TFTP root).
1802
+ * @param {string} params.initrdPath - The path to the initrd file (relative to TFTP root).
1803
+ * @param {string} params.cmd - The kernel command line parameters.
1804
+ * @param {string} params.tftpIp - The IP address of the TFTP server.
1805
+ * @param {boolean} [params.ipxe] - Flag to enable iPXE chainloading.
1806
+ * @param {string} [params.ipxePath] - The path to the iPXE binary.
1807
+ * @returns {object} An object containing 'grubCfgSrc' the GRUB configuration source string.
1808
+ * @memberof UnderpostBaremetal
1809
+ */
1810
+ grubFactory({ menuentryStr, kernelPath, initrdPath, cmd, tftpIp, ipxe, ipxePath }) {
1811
+ if (ipxe) {
1812
+ return {
1813
+ grubCfgSrc: `
1814
+ set default="0"
1815
+ set timeout=10
1816
+ insmod tftp
1817
+ set root=(tftp,${tftpIp})
1818
+
1819
+ menuentry 'iPXE ${menuentryStr}' {
1820
+ echo "Loading iPXE with embedded script..."
1821
+ echo "[INFO] TFTP Server: ${tftpIp}"
1822
+ echo "[INFO] iPXE Binary: ${ipxePath}"
1823
+ echo "[INFO] iPXE will execute embedded script (dhcp + chain)"
1824
+ chainloader ${ipxePath}
1825
+ boot
1826
+ }
1827
+ `,
1828
+ };
1829
+ }
1830
+ return {
1831
+ grubCfgSrc: `
1832
+ set default="0"
1833
+ set timeout=10
1834
+ insmod nfs
1835
+ insmod gzio
1836
+ insmod http
1837
+ insmod tftp
1838
+ set root=(tftp,${tftpIp})
1839
+
1840
+ menuentry '${menuentryStr}' {
1841
+ echo "${menuentryStr}"
1842
+ echo " ${Underpost.version}"
1843
+ echo "Date: ${new Date().toISOString()}"
1844
+ ${cmd.match('/MAAS/metadata/by-id/') ? `echo "System ID: ${cmd.split('/MAAS/metadata/by-id/')[1].split('/')[0]}"` : ''}
1845
+ echo "TFTP server: ${tftpIp}"
1846
+ echo "Kernel path: ${kernelPath}"
1847
+ echo "Initrd path: ${initrdPath}"
1848
+ echo "Starting boot process..."
1849
+ echo "Loading kernel..."
1850
+ linux /${kernelPath} ${cmd}
1851
+ echo "Loading initrd..."
1852
+ initrd /${initrdPath}
1853
+ echo "Booting..."
1854
+ boot
1855
+ }
1856
+ `,
1857
+ };
1858
+ },
748
1859
 
749
- // Wait for MAC address assignment.
750
- logger.info('Waiting for MAC assignment...');
751
- fs.removeSync(`${nfsHostPath}/underpost/mac`); // Clear previous MAC.
752
- await UnderpostBaremetal.API.macMonitor({ nfsHostPath }); // Monitor for MAC file.
753
- macAddress = fs.readFileSync(`${nfsHostPath}/underpost/mac`, 'utf8').trim(); // Read assigned MAC.
1860
+ /**
1861
+ * @method httpBootstrapServerStaticFactory
1862
+ * @description Creates static files for the bootstrap HTTP server including cloud-init configuration.
1863
+ * @param {object} params - Parameters for creating static files.
1864
+ * @param {string} params.bootstrapHttpServerPath - The path where static files will be created.
1865
+ * @param {string} params.hostname - The hostname of the client machine.
1866
+ * @param {string} params.cloudConfigSrc - The cloud-init configuration YAML source.
1867
+ * @param {object} [params.metadata] - Optional metadata to include in meta-data file.
1868
+ * @param {string} [params.vendorData] - Optional vendor-data content (default: empty string).
1869
+ * @memberof UnderpostBaremetal
1870
+ * @returns {void}
1871
+ */
1872
+ httpBootstrapServerStaticFactory({
1873
+ bootstrapHttpServerPath,
1874
+ hostname,
1875
+ cloudConfigSrc,
1876
+ metadata = {},
1877
+ vendorData = '',
1878
+ }) {
1879
+ // Create directory structure
1880
+ shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
1881
+
1882
+ // Write user-data file
1883
+ fs.writeFileSync(
1884
+ `${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`,
1885
+ `#cloud-config\n${cloudConfigSrc}`,
1886
+ 'utf8',
1887
+ );
754
1888
 
755
- // Re-run cloud-init config factory with the newly assigned MAC address.
756
- UnderpostBaremetal.API.crossArchRunner({
757
- nfsHostPath,
758
- debootstrapArch: debootstrap.image.architecture,
759
- callbackMetaData,
760
- steps: [
761
- UnderpostCloudInit.API.configFactory({
762
- controlServerIp: callbackMetaData.runnerHost.ip,
763
- hostname,
764
- commissioningDeviceIp: ipAddress,
765
- gatewayip: callbackMetaData.runnerHost.ip,
766
- mac: macAddress, // Updated MAC address.
767
- timezone,
768
- chronyConfPath,
769
- networkInterfaceName,
770
- }),
771
- ],
772
- });
1889
+ // Write meta-data file
1890
+ const metaDataContent = `instance-id: ${metadata.instanceId || hostname}\nlocal-hostname: ${metadata.localHostname || hostname}`;
1891
+ fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/meta-data`, metaDataContent, 'utf8');
773
1892
 
774
- // Remove existing machines from MAAS.
775
- machines = UnderpostBaremetal.API.removeMachines({ machines });
1893
+ // Write vendor-data file
1894
+ fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/vendor-data`, vendorData, 'utf8');
776
1895
 
777
- // Monitor commissioning process.
778
- UnderpostBaremetal.API.commissionMonitor({
779
- macAddress,
780
- nfsHostPath,
781
- underpostRoot,
782
- hostname,
783
- maas,
784
- networkInterfaceName,
785
- });
1896
+ logger.info(`Cloud-init files written to ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
1897
+ },
1898
+
1899
+ /**
1900
+ * @method httpBootstrapServerRunnerFactory
1901
+ * @description Starts a simple HTTP server to serve boot files for network booting.
1902
+ * @param {object} options - Options for the HTTP server.
1903
+ * @param {string} hostname - The hostname of the client machine.
1904
+ * @param {string} options.bootstrapHttpServerPath - The path to serve files from (default: './public/localhost').
1905
+ * @param {number} options.bootstrapHttpServerPort - The port on which to start the HTTP server (default: 8888).
1906
+ * @memberof UnderpostBaremetal
1907
+ * @returns {void}
1908
+ */
1909
+ httpBootstrapServerRunnerFactory(
1910
+ options = { hostname: 'localhost', bootstrapHttpServerPath: './public/localhost', bootstrapHttpServerPort: 8888 },
1911
+ ) {
1912
+ const port = options.bootstrapHttpServerPort || 8888;
1913
+ const bootstrapHttpServerPath = options.bootstrapHttpServerPath || './public/localhost';
1914
+ const hostname = options.hostname || 'localhost';
1915
+
1916
+ shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
1917
+
1918
+ // Kill any existing HTTP server
1919
+ shellExec(`sudo pkill -f 'python3 -m http.server ${port}'`, { silent: true });
1920
+
1921
+ shellExec(
1922
+ `cd ${bootstrapHttpServerPath} && nohup python3 -m http.server ${port} --bind 0.0.0.0 > /tmp/http-boot-server.log 2>&1 &`,
1923
+ { silent: true, async: true },
1924
+ );
1925
+
1926
+ // Configure iptables to allow incoming LAN connections
1927
+ shellExec(
1928
+ `sudo iptables -I INPUT 1 -p tcp -s 192.168.1.0/24 --dport ${port} -m conntrack --ctstate NEW -j ACCEPT`,
1929
+ );
1930
+ // Option for any host:
1931
+ // sudo iptables -I INPUT 1 -p tcp --dport ${port} -m conntrack --ctstate NEW -j ACCEPT
1932
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${bootstrapHttpServerPath}`);
1933
+ shellExec(`sudo sudo chmod 755 ${bootstrapHttpServerPath}`);
1934
+
1935
+ logger.info(`Started Bootstrap Http Server on port ${port}`);
1936
+ },
1937
+
1938
+ /**
1939
+ * @method updateKernelFiles
1940
+ * @description Copies EFI bootloaders, kernel, and initrd images to the TFTP root path.
1941
+ * It also handles decompression of the kernel if necessary for ARM64 compatibility,
1942
+ * and extracts raw kernel images from PE32+ EFI wrappers (common in Rocky Linux ARM64).
1943
+ * @param {object} params - The parameters for the function.
1944
+ * @param {object} params.commissioningImage - The commissioning image configuration.
1945
+ * @param {string} params.resourcesPath - The path where resources are located.
1946
+ * @param {string} params.tftpRootPath - The TFTP root path.
1947
+ * @param {object} params.kernelFilesPaths - Paths to kernel files.
1948
+ * @memberof UnderpostBaremetal
1949
+ * @returns {void}
1950
+ */
1951
+ updateKernelFiles({ commissioningImage, resourcesPath, tftpRootPath, kernelFilesPaths }) {
1952
+ // Copy EFI bootloaders to TFTP path.
1953
+ const efiFiles = commissioningImage.architecture.match('arm64')
1954
+ ? ['bootaa64.efi', 'grubaa64.efi']
1955
+ : ['bootx64.efi', 'grubx64.efi'];
1956
+ for (const file of efiFiles) {
1957
+ shellExec(`sudo cp -a ${resourcesPath}/${file} ${tftpRootPath}/pxe/${file}`);
1958
+ }
1959
+ // Copy kernel and initrd images to TFTP path.
1960
+ for (const file of Object.keys(kernelFilesPaths)) {
1961
+ if (file == 'isoUrl') continue; // Skip URL entries
1962
+ shellExec(`sudo cp -a ${kernelFilesPaths[file]} ${tftpRootPath}/pxe/${file}`);
1963
+ // If the file is a kernel (vmlinuz-efi) and is gzipped, unzip it for GRUB compatibility on ARM64.
1964
+ // GRUB on ARM64 often crashes with synchronous exception (0x200) if handling large compressed kernels directly.
1965
+ if (file === 'vmlinuz-efi') {
1966
+ const kernelDest = `${tftpRootPath}/pxe/${file}`;
1967
+ const fileType = shellExec(`file ${kernelDest}`, { silent: true }).stdout;
1968
+
1969
+ // Handle gzip compressed kernels
1970
+ if (fileType.includes('gzip compressed data')) {
1971
+ logger.info(`Decompressing kernel ${file} for ARM64 UEFI compatibility...`);
1972
+ shellExec(`sudo mv ${kernelDest} ${kernelDest}.gz`);
1973
+ shellExec(`sudo gunzip ${kernelDest}.gz`);
1974
+ }
1975
+
1976
+ // Handle PE32+ EFI wrapped kernels (common in Rocky Linux ARM64)
1977
+ // Rocky Linux ARM64 kernels are distributed as PE32+ EFI executables, which
1978
+ // are bootable directly via UEFI firmware. However, GRUB's 'linux' command
1979
+ // expects a raw ARM64 Linux kernel Image format, not a PE32+ wrapper.
1980
+ if (fileType.includes('PE32+') || fileType.includes('EFI application')) {
1981
+ logger.warn('Detected PE32+ EFI wrapped kernel. Need to extract raw kernel image for GRUB.');
1982
+ }
1983
+ }
1984
+ }
1985
+ },
1986
+
1987
+ /**
1988
+ * @method kernelCmdBootParamsFactory
1989
+ * @description Constructs kernel command line parameters for NFS booting.
1990
+ * @param {object} options - Options for constructing the command line.
1991
+ * @param {string} options.ipClient - The IP address of the client.
1992
+ * @param {string} options.ipDhcpServer - The IP address of the DHCP server.
1993
+ * @param {string} options.ipFileServer - The IP address of the file server.
1994
+ * @param {string} options.ipConfig - The IP configuration method (e.g., 'dhcp').
1995
+ * @param {string} options.netmask - The network mask.
1996
+ * @param {string} options.hostname - The hostname of the client.
1997
+ * @param {string} options.dnsServer - The DNS server address.
1998
+ * @param {string} options.networkInterfaceName - The name of the network interface.
1999
+ * @param {string} options.fileSystemUrl - The URL of the root filesystem.
2000
+ * @param {number} options.bootstrapHttpServerPort - The port of the bootstrap HTTP server.
2001
+ * @param {string} options.type - The type of boot ('iso-ram', 'chroot-debootstrap', 'chroot-container', 'iso-nfs', etc.).
2002
+ * @param {string} options.macAddress - The MAC address of the client.
2003
+ * @param {boolean} options.cloudInit - Whether to include cloud-init parameters.
2004
+ * @param {object} options.machine - The machine object containing system_id.
2005
+ * @param {boolean} [options.dev=false] - Whether to enable dev mode with dracut debugging parameters.
2006
+ * @param {string} [options.osIdLike=''] - OS family identifier (e.g., 'rhel centos fedora' or 'debian ubuntu').
2007
+ * @returns {object} An object containing the constructed command line string.
2008
+ * @memberof UnderpostBaremetal
2009
+ */
2010
+ kernelCmdBootParamsFactory(
2011
+ options = {
2012
+ ipClient: '',
2013
+ ipDhcpServer: '',
2014
+ ipFileServer: '',
2015
+ ipConfig: '',
2016
+ netmask: '',
2017
+ hostname: '',
2018
+ dnsServer: '',
2019
+ networkInterfaceName: '',
2020
+ fileSystemUrl: '',
2021
+ bootstrapHttpServerPort: 8888,
2022
+ type: '',
2023
+ macAddress: '',
2024
+ cloudInit: false,
2025
+ machine: { system_id: '' },
2026
+ dev: false,
2027
+ osIdLike: '',
2028
+ },
2029
+ ) {
2030
+ // Construct kernel command line arguments for NFS boot.
2031
+ const {
2032
+ ipClient,
2033
+ ipDhcpServer,
2034
+ ipFileServer,
2035
+ ipConfig,
2036
+ netmask,
2037
+ hostname,
2038
+ dnsServer,
2039
+ networkInterfaceName,
2040
+ fileSystemUrl,
2041
+ bootstrapHttpServerPort,
2042
+ type,
2043
+ macAddress,
2044
+ cloudInit,
2045
+ osIdLike,
2046
+ } = options;
2047
+
2048
+ const ipParam = true
2049
+ ? `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2050
+ `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`
2051
+ : 'ip=dhcp';
2052
+
2053
+ const nfsOptions = `${
2054
+ type === 'chroot-debootstrap' || type === 'chroot-container'
2055
+ ? [
2056
+ 'tcp',
2057
+ 'nfsvers=3',
2058
+ 'nolock',
2059
+ // 'protocol=tcp',
2060
+ // 'hard=true',
2061
+ 'port=2049',
2062
+ // 'sec=none',
2063
+ 'hard',
2064
+ 'intr',
2065
+ 'rsize=32768',
2066
+ 'wsize=32768',
2067
+ 'acregmin=0',
2068
+ 'acregmax=0',
2069
+ 'acdirmin=0',
2070
+ 'acdirmax=0',
2071
+ 'noac',
2072
+ // 'nodev',
2073
+ // 'nosuid',
2074
+ ]
2075
+ : []
2076
+ }`;
2077
+
2078
+ const nfsRootParam = `nfsroot=${ipFileServer}:${process.env.NFS_EXPORT_PATH}/${hostname}${nfsOptions ? `,${nfsOptions}` : ''}`;
2079
+
2080
+ const permissionsParams = [
2081
+ `rw`,
2082
+ // `ro`
2083
+ ];
2084
+
2085
+ const kernelParams = [
2086
+ ...permissionsParams,
2087
+ `ignore_uuid`,
2088
+ `rootwait`,
2089
+ `ipv6.disable=1`,
2090
+ `fixrtc`,
2091
+ // `console=serial0,115200`,
2092
+ // `console=tty1`,
2093
+ // `layerfs-path=filesystem.squashfs`,
2094
+ // `root=/dev/ram0`,
2095
+ // `toram`,
2096
+ 'nomodeset',
2097
+ `editable_rootfs=tmpfs`,
2098
+ `ramdisk_size=3550000`,
2099
+ // `root=/dev/sda1`, // rpi4 usb port unit
2100
+ 'apparmor=0', // Disable AppArmor security
2101
+ ...(networkInterfaceName === 'eth0'
2102
+ ? [
2103
+ 'net.ifnames=0', // Disable predictable network interface names
2104
+ 'biosdevname=0', // Disable BIOS device naming
2105
+ ]
2106
+ : []),
2107
+ ];
2108
+
2109
+ const performanceParams = [
2110
+ // --- Boot Automation & Stability ---
2111
+ 'auto=true', // Enable automated installation/configuration
2112
+ 'noeject', // Do not attempt to eject boot media on reboot
2113
+ `casper-getty`, // Enable console login for live sessions
2114
+ 'nowatchdog', // Disable watchdog timers to prevent unexpected reboots
2115
+ 'noprompt', // Don't wait for "Press Enter" during boot/reboot
2116
+
2117
+ // --- CPU & System Performance ---
2118
+ 'mitigations=off', // Disable CPU security mitigations for maximum speed
2119
+ 'clocksource=tsc', // Use fastest available hardware clock
2120
+ 'tsc=reliable', // Trust CPU clock without extra verification
2121
+ 'hpet=disable', // Disable legacy slow timer
2122
+ 'nohz=on', // Reduce overhead by disabling timer ticks on idle CPUs
2123
+
2124
+ // --- Memory & Hardware Optimization ---
2125
+ 'cma=40M', // Reserve contiguous RAM for RPi hardware/video
2126
+ 'zswap.enabled=1', // Use compressed RAM cache (vital for NFS/SD)
2127
+ 'zswap.compressor=zstd', // Best balance of speed and compression
2128
+ 'zswap.max_pool_percent=30', // Use max 30% of RAM as compressed storage
2129
+ 'zswap.zpool=zsmalloc', // Efficient memory management for zswap
2130
+ 'fsck.mode=skip', // Skip disk checks to accelerate boot
2131
+ 'max_loop=255', // Ensure enough loop devices for squashfs/snaps
2132
+
2133
+ // --- Immutable Filesystem ---
2134
+ 'overlayroot=tmpfs', // Run entire OS in RAM to protect storage
2135
+ 'overlayroot_cfgdisk=disabled', // Ignore external overlay configurations
2136
+ ];
2137
+
2138
+ let cmd = [];
2139
+ if (type === 'iso-ram') {
2140
+ const netBootParams = [`netboot=url`];
2141
+ if (fileSystemUrl) netBootParams.push(`url=${fileSystemUrl.replace('https', 'http')}`);
2142
+ cmd = [ipParam, `boot=casper`, ...netBootParams, ...kernelParams];
2143
+ } else if (type === 'chroot-debootstrap' || type === 'chroot-container') {
2144
+ let qemuNfsRootParams = [`root=/dev/nfs`, `rootfstype=nfs`];
2145
+
2146
+ // Determine OS family from osIdLike configuration
2147
+ const isRhelBased = osIdLike && osIdLike.match(/rhel|centos|fedora|alma|rocky/i);
2148
+ const isDebianBased = osIdLike && osIdLike.match(/debian|ubuntu/i);
2149
+
2150
+ // Add RHEL/Rocky/Fedora based images specific parameters
2151
+ if (isRhelBased) {
2152
+ qemuNfsRootParams = qemuNfsRootParams.concat([`rd.neednet=1`, `rd.timeout=180`, `selinux=0`, `enforcing=0`]);
2153
+ }
2154
+ // Add Debian/Ubuntu based images specific parameters
2155
+ else if (isDebianBased) {
2156
+ qemuNfsRootParams = qemuNfsRootParams.concat([`initrd=initrd.img`, `init=/sbin/init`]);
2157
+ }
2158
+
2159
+ // Add debugging parameters in dev mode for dracut troubleshooting
2160
+ if (options.dev) {
2161
+ // qemuNfsRootParams = qemuNfsRootParams.concat([`rd.shell`, `rd.debug`]);
2162
+ }
2163
+
2164
+ cmd = [ipParam, ...qemuNfsRootParams, nfsRootParam, ...kernelParams];
2165
+ } else {
2166
+ // 'iso-nfs'
2167
+ cmd = [ipParam, `netboot=nfs`, nfsRootParam, ...kernelParams, ...performanceParams];
2168
+
2169
+ cmd.push(`ifname=${networkInterfaceName}:${macAddress}`);
2170
+
2171
+ if (cloudInit) {
2172
+ const cloudInitPreseedUrl = `http://${ipDhcpServer}:5248/MAAS/metadata/by-id/${options.machine?.system_id ? options.machine.system_id : 'system-id'}/?op=get_preseed`;
2173
+ cmd = cmd.concat([
2174
+ `cloud-init=enabled`,
2175
+ 'autoinstall',
2176
+ `cloud-config-url=${cloudInitPreseedUrl}`,
2177
+ `ds=nocloud-net;s=${cloudInitPreseedUrl}`,
2178
+ `log_host=${ipDhcpServer}`,
2179
+ `log_port=5247`,
2180
+ // `BOOTIF=${macAddress}`,
2181
+ // `cc:{'datasource_list': ['MAAS']}end_cc`,
2182
+ ]);
2183
+ }
786
2184
  }
2185
+ // cmd.push('---');
2186
+ const cmdStr = cmd.join(' ');
2187
+ logger.info('Constructed kernel command line');
2188
+ console.log(newInstance(cmdStr).bgRed.bold.black);
2189
+ return { cmd: cmdStr };
787
2190
  },
788
2191
 
789
2192
  /**
@@ -792,25 +2195,15 @@ menuentry '${menuentryStr}' {
792
2195
  * once a matching MAC address is found. It also opens terminal windows for live logs.
793
2196
  * @param {object} params - The parameters for the function.
794
2197
  * @param {string} params.macAddress - The MAC address to monitor for.
795
- * @param {string} params.nfsHostPath - The NFS host path for storing system-id and auth tokens.
796
- * @param {string} params.underpostRoot - The root directory of the Underpost project.
797
- * @param {string} params.hostname - The desired hostname for the new machine.
798
- * @param {object} params.maas - MAAS configuration details.
799
- * @param {string} params.networkInterfaceName - The name of the network interface.
800
- * @returns {Promise<void>} A promise that resolves when commissioning is initiated or after a delay.
2198
+ * @param {string} params.ipAddress - The IP address of the machine (used if MAC is all zeros).
2199
+ * @param {string} [params.hostname] - The hostname for the machine (optional).
2200
+ * @param {string} [params.architecture] - The architecture of the machine (optional).
2201
+ * @param {object} [params.machine] - Existing machine payload to use (optional).
2202
+ * @returns {Promise<void>} A promise object with machine and discovery details.
801
2203
  * @memberof UnderpostBaremetal
802
2204
  */
803
- async commissionMonitor({ macAddress, nfsHostPath, underpostRoot, hostname, maas, networkInterfaceName }) {
2205
+ async commissionMonitor({ macAddress, ipAddress, hostname, architecture, machine }) {
804
2206
  {
805
- logger.info('Waiting for commissioning...', {
806
- macAddress,
807
- nfsHostPath,
808
- underpostRoot,
809
- hostname,
810
- maas,
811
- networkInterfaceName,
812
- });
813
-
814
2207
  // Query observed discoveries from MAAS.
815
2208
  const discoveries = JSON.parse(
816
2209
  shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries read`, {
@@ -819,130 +2212,92 @@ menuentry '${menuentryStr}' {
819
2212
  }),
820
2213
  );
821
2214
 
822
- // {
823
- // "discovery_id": "",
824
- // "ip": "192.168.1.189",
825
- // "mac_address": "00:00:00:00:00:00",
826
- // "last_seen": "2025-05-05T14:17:37.354",
827
- // "hostname": null,
828
- // "fabric_name": "",
829
- // "vid": null,
830
- // "mac_organization": "",
831
- // "observer": {
832
- // "system_id": "",
833
- // "hostname": "",
834
- // "interface_id": 1,
835
- // "interface_name": ""
836
- // },
837
- // "resource_uri": "/MAAS/api/2.0/discovery/MTkyLjE2OC4xLjE4OSwwMDowMDowMDowMDowMDowMA==/"
838
- // },
839
-
840
- // Log discovered IPs for visibility.
841
- console.log(discoveries.map((d) => d.ip).join(' | '));
842
-
843
- // Iterate through discoveries to find a matching MAC address.
844
2215
  for (const discovery of discoveries) {
845
- const machine = {
846
- architecture: maas.image.architecture.match('amd') ? 'amd64/generic' : 'arm64/generic',
847
- mac_address: discovery.mac_address,
848
- hostname: discovery.hostname
849
- ? discovery.hostname
850
- : discovery.mac_organization
851
- ? discovery.mac_organization
852
- : discovery.domain
853
- ? discovery.domain
854
- : `generic-host-${s4()}${s4()}`,
855
- power_type: 'manual',
856
- mac_addresses: discovery.mac_address,
857
- ip: discovery.ip,
858
- };
859
- machine.hostname = machine.hostname.replaceAll(' ', '').replaceAll('.', ''); // Sanitize hostname.
860
-
861
- if (machine.mac_addresses === macAddress)
862
- try {
863
- machine.hostname = hostname;
864
- machine.mac_address = macAddress;
865
- // Create a new machine in MAAS.
866
- let newMachine = shellExec(
867
- `maas ${process.env.MAAS_ADMIN_USERNAME} machines create ${Object.keys(machine)
868
- .map((k) => `${k}="${machine[k]}"`)
869
- .join(' ')}`,
870
- {
2216
+ const dicoverHostname = discovery.hostname
2217
+ ? discovery.hostname
2218
+ : discovery.mac_organization
2219
+ ? discovery.mac_organization
2220
+ : discovery.domain
2221
+ ? discovery.domain
2222
+ : `generic-host-${s4()}${s4()}`;
2223
+
2224
+ console.log(dicoverHostname.bgBlue.bold.white);
2225
+ console.log('ip target:'.green + ipAddress, 'ip discovered:'.green + discovery.ip);
2226
+ console.log('mac target:'.green + macAddress, 'mac discovered:'.green + discovery.mac_address);
2227
+
2228
+ if (discovery.ip === ipAddress) {
2229
+ logger.info('Machine discovered!', discovery);
2230
+ if (!machine) {
2231
+ logger.info('Creating new machine with discovered hardware MAC...', {
2232
+ discoveredMAC: discovery.mac_address,
2233
+ ipAddress,
2234
+ hostname,
2235
+ });
2236
+ machine = UnderpostBaremetal.API.machineFactory({
2237
+ ipAddress,
2238
+ macAddress: discovery.mac_address,
2239
+ hostname,
2240
+ architecture,
2241
+ }).machine;
2242
+ console.log('New machine system id:', machine.system_id.bgYellow.bold.black);
2243
+ UnderpostBaremetal.API.writeGrubConfigToFile({
2244
+ grubCfgSrc: UnderpostBaremetal.API.getGrubConfigFromFile().grubCfgSrc.replaceAll(
2245
+ 'system-id',
2246
+ machine.system_id,
2247
+ ),
2248
+ });
2249
+ } else {
2250
+ const systemId = machine.system_id;
2251
+ console.log('Using pre-registered machine system_id:', systemId.bgYellow.bold.black);
2252
+
2253
+ // Update the boot interface MAC if hardware MAC differs from pre-registered MAC
2254
+ // This handles both hardware mode (macAddress is null) and MAC mismatch scenarios
2255
+ if (macAddress === null || macAddress !== discovery.mac_address) {
2256
+ logger.info('Updating machine interface with discovered hardware MAC...', {
2257
+ preRegisteredMAC: macAddress || 'none (hardware mode)',
2258
+ discoveredMAC: discovery.mac_address,
2259
+ });
2260
+
2261
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-broken ${systemId}`, {
871
2262
  silent: true,
872
- stdout: true,
873
- },
874
- );
875
- newMachine = { discovery, machine: JSON.parse(newMachine) };
876
- console.log(newMachine);
877
-
878
- const discoverInterfaceName = 'eth0'; // Default interface name for discovery.
2263
+ });
879
2264
 
880
- // Read interface data.
881
- const interfaceData = JSON.parse(
882
2265
  shellExec(
883
- `maas ${process.env.MAAS_ADMIN_USERNAME} interface read ${newMachine.machine.boot_interface.system_id} ${discoverInterfaceName}`,
2266
+ // name=${networkInterfaceName}
2267
+ `maas ${process.env.MAAS_ADMIN_USERNAME} interface update ${systemId} ${machine.boot_interface.id}` +
2268
+ ` mac_address=${discovery.mac_address}`,
884
2269
  {
885
2270
  silent: true,
886
- stdout: true,
887
2271
  },
888
- ),
889
- );
890
-
891
- logger.info('Interface', interfaceData);
892
-
893
- // Mark machine as broken, update interface name, then mark as fixed.
894
- shellExec(
895
- `maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-broken ${newMachine.machine.boot_interface.system_id}`,
896
- );
897
-
898
- shellExec(
899
- `maas ${process.env.MAAS_ADMIN_USERNAME} interface update ${newMachine.machine.boot_interface.system_id} ${interfaceData.id} name=${networkInterfaceName}`,
900
- );
901
-
902
- shellExec(
903
- `maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-fixed ${newMachine.machine.boot_interface.system_id}`,
904
- );
905
-
906
- // commissioning_scripts=90-verify-user.sh
907
- // shellExec(
908
- // `maas ${process.env.MAAS_ADMIN_USERNAME} machine commission --debug --insecure ${newMachine.machine.boot_interface.system_id} enable_ssh=1 skip_bmc_config=1 skip_networking=1 skip_storage=1`,
909
- // {
910
- // silent: true,
911
- // },
912
- // );
913
-
914
- // Save system-id for enlistment.
915
- logger.info('system-id', newMachine.machine.boot_interface.system_id);
916
- fs.writeFileSync(
917
- `${nfsHostPath}/underpost/system-id`,
918
- newMachine.machine.boot_interface.system_id,
919
- 'utf8',
920
- );
921
-
922
- // Get and save MAAS authentication credentials.
923
- const { consumer_key, token_key, token_secret } = UnderpostCloudInit.API.authCredentialsFactory();
924
-
925
- fs.writeFileSync(`${nfsHostPath}/underpost/consumer-key`, consumer_key, 'utf8');
926
- fs.writeFileSync(`${nfsHostPath}/underpost/token-key`, token_key, 'utf8');
927
- fs.writeFileSync(`${nfsHostPath}/underpost/token-secret`, token_secret, 'utf8');
928
-
929
- // Open new terminals for live cloud-init logs.
930
- openTerminal(`node ${underpostRoot}/bin baremetal --logs cloud`);
931
- openTerminal(`node ${underpostRoot}/bin baremetal --logs machine`);
932
- } catch (error) {
933
- logger.error(error, error.stack);
934
- } finally {
935
- process.exit(0);
2272
+ );
2273
+
2274
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-fixed ${systemId}`, {
2275
+ silent: true,
2276
+ });
2277
+
2278
+ logger.info('✓ Machine interface MAC address updated successfully');
2279
+
2280
+ // commissioning_scripts=90-verify-user.sh
2281
+ }
2282
+ logger.info('Machine resource uri', machine.resource_uri);
2283
+ for (const iface of machine.interface_set)
2284
+ logger.info('Interface info', {
2285
+ name: iface.name,
2286
+ mac_address: iface.mac_address,
2287
+ resource_uri: iface.resource_uri,
2288
+ });
936
2289
  }
2290
+
2291
+ return { discovery, machine };
2292
+ }
937
2293
  }
938
2294
  await timer(1000);
939
- UnderpostBaremetal.API.commissionMonitor({
2295
+ return await UnderpostBaremetal.API.commissionMonitor({
940
2296
  macAddress,
941
- nfsHostPath,
942
- underpostRoot,
2297
+ ipAddress,
943
2298
  hostname,
944
- maas,
945
- networkInterfaceName,
2299
+ architecture,
2300
+ machine,
946
2301
  });
947
2302
  }
948
2303
  },
@@ -952,11 +2307,10 @@ menuentry '${menuentryStr}' {
952
2307
  * @description Mounts the binfmt_misc filesystem to enable QEMU user-static binfmt support.
953
2308
  * This is necessary for cross-architecture execution within a chroot environment.
954
2309
  * @param {object} params - The parameters for the function.
955
- * @param {string} params.nfsHostPath - The path to the NFS root filesystem on the host.
956
2310
  * @memberof UnderpostBaremetal
957
2311
  * @returns {void}
958
2312
  */
959
- mountBinfmtMisc({ nfsHostPath }) {
2313
+ mountBinfmtMisc() {
960
2314
  // Install necessary packages for debootstrap and QEMU.
961
2315
  shellExec(`sudo dnf install -y iptables-legacy`);
962
2316
  shellExec(`sudo dnf install -y debootstrap`);
@@ -966,9 +2320,6 @@ menuentry '${menuentryStr}' {
966
2320
  // Mount binfmt_misc filesystem.
967
2321
  shellExec(`sudo modprobe binfmt_misc`);
968
2322
  shellExec(`sudo mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc`);
969
- // Set ownership and permissions for the NFS host path.
970
- shellExec(`sudo chown -R root:root ${nfsHostPath}`);
971
- shellExec(`sudo chmod 755 ${nfsHostPath}`);
972
2323
  },
973
2324
 
974
2325
  /**
@@ -976,12 +2327,17 @@ menuentry '${menuentryStr}' {
976
2327
  * @description Deletes all specified machines from MAAS.
977
2328
  * @param {object} params - The parameters for the function.
978
2329
  * @param {Array<object>} params.machines - An array of machine objects, each with a `system_id`.
2330
+ * @param {Array<string>} [params.ignore] - An optional array of system IDs to ignore during deletion.
979
2331
  * @memberof UnderpostBaremetal
980
2332
  * @returns {Array<object>} An empty array after machines are removed.
981
2333
  */
982
- removeMachines({ machines }) {
2334
+ removeMachines({ machines, ignore }) {
983
2335
  for (const machine of machines) {
984
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${machine.system_id}`);
2336
+ // Handle both string system_ids and machine objects
2337
+ const systemId = typeof machine === 'string' ? machine : machine.system_id;
2338
+ if (ignore && ignore.find((mId) => mId === systemId)) continue;
2339
+ logger.info(`Removing machine: ${systemId}`);
2340
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${systemId}`);
985
2341
  }
986
2342
  return [];
987
2343
  },
@@ -1030,8 +2386,8 @@ menuentry '${menuentryStr}' {
1030
2386
  * @param {'arm64'|'amd64'} params.debootstrapArch - The target architecture of the debootstrap environment.
1031
2387
  * @returns {void}
1032
2388
  */
1033
- crossArchBinFactory({ nfsHostPath, debootstrapArch }) {
1034
- switch (debootstrapArch) {
2389
+ crossArchBinFactory({ nfsHostPath, bootstrapArch }) {
2390
+ switch (bootstrapArch) {
1035
2391
  case 'arm64':
1036
2392
  // Copy QEMU static binary for ARM64.
1037
2393
  shellExec(`sudo podman cp extract:/usr/bin/qemu-aarch64-static ${nfsHostPath}/usr/bin/`);
@@ -1042,7 +2398,7 @@ menuentry '${menuentryStr}' {
1042
2398
  break;
1043
2399
  default:
1044
2400
  // Log a warning or throw an error for unsupported architectures.
1045
- logger.warn(`Unsupported debootstrap architecture: ${debootstrapArch}`);
2401
+ logger.warn(`Unsupported bootstrap architecture: ${bootstrapArch}`);
1046
2402
  break;
1047
2403
  }
1048
2404
  // Install GRUB EFI modules for both architectures to ensure compatibility.
@@ -1062,14 +2418,14 @@ menuentry '${menuentryStr}' {
1062
2418
  * @param {string[]} params.steps - An array of shell commands to execute.
1063
2419
  * @returns {void}
1064
2420
  */
1065
- crossArchRunner({ nfsHostPath, debootstrapArch, callbackMetaData, steps }) {
2421
+ crossArchRunner({ nfsHostPath, bootstrapArch, callbackMetaData, steps }) {
1066
2422
  // Render the steps with logging for better visibility during execution.
1067
2423
  steps = UnderpostBaremetal.API.stepsRender(steps, false);
1068
2424
 
1069
2425
  let qemuCrossArchBash = '';
1070
2426
  // Determine if QEMU is needed for cross-architecture execution.
1071
- if (debootstrapArch !== callbackMetaData.runnerHost.architecture)
1072
- switch (debootstrapArch) {
2427
+ if (bootstrapArch !== callbackMetaData.runnerHost.architecture)
2428
+ switch (bootstrapArch) {
1073
2429
  case 'arm64':
1074
2430
  qemuCrossArchBash = '/usr/bin/qemu-aarch64-static ';
1075
2431
  break;
@@ -1124,45 +2480,80 @@ EOF`);
1124
2480
  * It checks the mount status and performs mount/unmount operations as requested.
1125
2481
  * @param {object} params - The parameters for the function.
1126
2482
  * @param {string} params.hostname - The hostname of the target machine.
2483
+ * @param {string} params.nfsHostPath - The NFS host path for the target machine.
1127
2484
  * @param {string} params.workflowId - The identifier for the workflow configuration.
1128
2485
  * @param {boolean} [params.mount] - If true, attempts to mount the NFS paths.
1129
2486
  * @param {boolean} [params.unmount] - If true, attempts to unmount the NFS paths.
2487
+ * @param {number} [currentRecall=0] - The current recall attempt count for retries.
2488
+ * @param {number} [maxRecalls=5] - The maximum number of recall attempts allowed.
1130
2489
  * @memberof UnderpostBaremetal
1131
- * @returns {{isMounted: boolean}} An object indicating whether any NFS path is currently mounted.
2490
+ * @returns {Promise<void>} A promise that resolves when the mount/unmount operations are complete.
1132
2491
  */
1133
- nfsMountCallback({ hostname, workflowId, mount, unmount }) {
1134
- let isMounted = false;
2492
+ async nfsMountCallback({ hostname, nfsHostPath, workflowId, mount, unmount }, currentRecall = 0, maxRecalls = 5) {
2493
+ // Mount binfmt_misc filesystem.
2494
+ if (mount) UnderpostBaremetal.API.mountBinfmtMisc();
2495
+ const unMountCmds = [];
2496
+ const mountCmds = [];
1135
2497
  const workflowsConfig = UnderpostBaremetal.API.loadWorkflowsConfig();
2498
+ let recall = false;
1136
2499
  if (!workflowsConfig[workflowId]) {
1137
2500
  throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
1138
2501
  }
1139
- // Iterate through defined NFS mounts in the workflow configuration.
1140
- for (const mountCmd of Object.keys(workflowsConfig[workflowId].nfs.mounts)) {
1141
- for (const mountPath of workflowsConfig[workflowId].nfs.mounts[mountCmd]) {
1142
- const hostMountPath = `${process.env.NFS_EXPORT_PATH}/${hostname}${mountPath}`;
1143
- // Check if the path is already mounted using `mountpoint` command.
1144
- const isPathMounted = !shellExec(`mountpoint ${hostMountPath}`, { silent: true, stdout: true }).match(
1145
- 'not a mountpoint',
1146
- );
2502
+ if (
2503
+ workflowsConfig[workflowId].type === 'chroot-debootstrap' ||
2504
+ workflowsConfig[workflowId].type === 'chroot-container'
2505
+ ) {
2506
+ const mounts = {
2507
+ bind: ['/proc', '/sys', '/run'],
2508
+ rbind: ['/dev'],
2509
+ };
2510
+
2511
+ for (const mountCmd of Object.keys(mounts)) {
2512
+ for (const mountPath of mounts[mountCmd]) {
2513
+ const hostMountPath = `${process.env.NFS_EXPORT_PATH}/${hostname}${mountPath}`;
2514
+ // Check if the path is already mounted using `mountpoint` command.
2515
+ const isPathMounted = !shellExec(`mountpoint ${hostMountPath}`, { silent: true, stdout: true }).match(
2516
+ 'not a mountpoint',
2517
+ );
1147
2518
 
1148
- if (isPathMounted) {
1149
- if (!isMounted) isMounted = true; // Set overall mounted status.
1150
- logger.warn('Nfs path already mounted', mountPath);
1151
- if (unmount === true) {
1152
- // Unmount if requested.
1153
- shellExec(`sudo umount ${hostMountPath}`);
1154
- }
1155
- } else {
1156
- if (mount === true) {
1157
- // Mount if requested and not already mounted.
1158
- shellExec(`sudo mount --${mountCmd} ${mountPath} ${hostMountPath}`);
2519
+ if (isPathMounted) {
2520
+ logger.warn('Nfs path already mounted', mountPath);
2521
+ if (unmount === true) {
2522
+ // Unmount if requested.
2523
+ unMountCmds.push(`sudo umount -Rfl ${hostMountPath}`);
2524
+ if (!recall) recall = true;
2525
+ }
1159
2526
  } else {
1160
- logger.warn('Nfs path not mounted', mountPath);
2527
+ if (mount === true) {
2528
+ // Mount if requested and not already mounted.
2529
+ mountCmds.push(`sudo mount --${mountCmd} ${mountPath} ${hostMountPath}`);
2530
+ } else {
2531
+ logger.warn('Nfs path not mounted', mountPath);
2532
+ }
1161
2533
  }
1162
2534
  }
1163
2535
  }
2536
+ for (const unMountCmd of unMountCmds) shellExec(unMountCmd);
2537
+ if (recall) {
2538
+ if (currentRecall >= maxRecalls) {
2539
+ throw new Error(
2540
+ `Maximum recall attempts (${maxRecalls}) reached for nfsMountCallback. Hostname: ${hostname}`,
2541
+ );
2542
+ }
2543
+ logger.info(`nfsMountCallback recall attempt ${currentRecall + 1}/${maxRecalls} for hostname: ${hostname}`);
2544
+ await timer(1000);
2545
+ return await UnderpostBaremetal.API.nfsMountCallback(
2546
+ { hostname, nfsHostPath, workflowId, mount, unmount },
2547
+ currentRecall + 1,
2548
+ maxRecalls,
2549
+ );
2550
+ }
2551
+ if (mountCmds.length > 0) {
2552
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${nfsHostPath}`);
2553
+ shellExec(`sudo chmod -R 755 ${nfsHostPath}`);
2554
+ for (const mountCmd of mountCmds) shellExec(mountCmd);
2555
+ }
1164
2556
  }
1165
- return { isMounted };
1166
2557
  },
1167
2558
 
1168
2559
  /**
@@ -1203,35 +2594,29 @@ EOF`);
1203
2594
  * This includes updating package lists, installing essential build tools,
1204
2595
  * kernel modules, cloud-init, SSH server, and other core utilities.
1205
2596
  * @param {object} params - The parameters for the function.
1206
- * @param {string} params.kernelLibVersion - The specific kernel library version to install.
1207
2597
  * @memberof UnderpostBaremetal.systemProvisioningFactory.ubuntu
1208
2598
  * @returns {string[]} An array of shell commands.
1209
2599
  */
1210
- base: ({ kernelLibVersion }) => [
1211
- // Configure APT sources for Ubuntu ports.
2600
+ base: () => [
2601
+ // Configure APT sources for Ubuntu ports
1212
2602
  `cat <<SOURCES | tee /etc/apt/sources.list
1213
2603
  deb http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
1214
2604
  deb http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
1215
2605
  deb http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
1216
2606
  SOURCES`,
1217
2607
 
1218
- // Update package lists and perform a full system upgrade.
2608
+ // Update package lists and perform a full system upgrade
1219
2609
  `apt update -qq`,
1220
2610
  `apt -y full-upgrade`,
1221
- // Install essential development and system utilities.
1222
- `apt install -y build-essential xinput x11-xkb-utils usbutils uuid-runtime`,
1223
- 'apt install -y linux-image-generic',
1224
- // Install specific kernel modules.
1225
- `apt install -y linux-modules-${kernelLibVersion} linux-modules-extra-${kernelLibVersion}`,
1226
-
1227
- `depmod -a ${kernelLibVersion}`, // Update kernel module dependencies.
1228
- // Install cloud-init, systemd, SSH, sudo, locales, udev, and networking tools.
1229
- `apt install -y cloud-init systemd-sysv openssh-server sudo locales udev util-linux systemd-sysv iproute2 netplan.io ca-certificates curl wget chrony`,
1230
- `ln -sf /lib/systemd/systemd /sbin/init`, // Ensure systemd is the init system.
1231
-
1232
- `apt-get update`,
1233
- `DEBIAN_FRONTEND=noninteractive apt-get install -y apt-utils`, // Install apt-utils non-interactively.
1234
- `DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata kmod keyboard-configuration console-setup iputils-ping`, // Install timezone data, kernel modules, and network tools.
2611
+
2612
+ // Install all essential packages in one consolidated step
2613
+ `DEBIAN_FRONTEND=noninteractive apt install -y build-essential xinput x11-xkb-utils usbutils uuid-runtime linux-image-generic systemd-sysv openssh-server sudo locales udev util-linux iproute2 netplan.io ca-certificates curl wget chrony apt-utils tzdata kmod keyboard-configuration console-setup iputils-ping`,
2614
+
2615
+ // Ensure systemd is the init system
2616
+ `ln -sf /lib/systemd/systemd /sbin/init`,
2617
+
2618
+ // Clean up
2619
+ `apt-get clean`,
1235
2620
  ],
1236
2621
  /**
1237
2622
  * @method user
@@ -1336,15 +2721,167 @@ logdir /var/log/chrony
1336
2721
  * @method keyboard
1337
2722
  * @description Generates shell commands for configuring the keyboard layout.
1338
2723
  * This ensures correct input behavior on the provisioned system.
2724
+ * @param {string} [keyCode='en'] - The keyboard layout code (e.g., 'en', 'es').
1339
2725
  * @memberof UnderpostBaremetal.systemProvisioningFactory.ubuntu
1340
2726
  * @returns {string[]} An array of shell commands.
1341
2727
  */
1342
- keyboard: () => [
1343
- `sudo locale-gen en_US.UTF-8`, // Generate the specified locale.
1344
- `sudo update-locale LANG=en_US.UTF-8`, // Update system locale.
1345
- `sudo sed -i 's/XKBLAYOUT="us"/XKBLAYOUT="es"/' /etc/default/keyboard`, // Change keyboard layout to Spanish.
1346
- `sudo dpkg-reconfigure --frontend noninteractive keyboard-configuration`, // Reconfigure keyboard non-interactively.
1347
- `sudo systemctl restart keyboard-setup.service`, // Restart keyboard setup service.
2728
+ keyboard: (keyCode = 'en') => [
2729
+ `sudo locale-gen en_US.UTF-8`,
2730
+ `sudo update-locale LANG=en_US.UTF-8`,
2731
+ `sudo sed -i 's/XKBLAYOUT="us"/XKBLAYOUT="${keyCode}"/' /etc/default/keyboard`,
2732
+ `sudo dpkg-reconfigure --frontend noninteractive keyboard-configuration`,
2733
+ `sudo systemctl restart keyboard-setup.service`,
2734
+ ],
2735
+ },
2736
+ /**
2737
+ * @property {object} rocky
2738
+ * @description Provisioning steps for Rocky Linux-based systems.
2739
+ * @memberof UnderpostBaremetal.systemProvisioningFactory
2740
+ * @namespace UnderpostBaremetal.systemProvisioningFactory.rocky
2741
+ */
2742
+ rocky: {
2743
+ /**
2744
+ * @method base
2745
+ * @description Generates shell commands for basic Rocky Linux system provisioning.
2746
+ * This includes installing Node.js, npm, and underpost CLI tools.
2747
+ * @param {object} params - The parameters for the function.
2748
+ * @memberof UnderpostBaremetal.systemProvisioningFactory.rocky
2749
+ * @returns {string[]} An array of shell commands.
2750
+ */
2751
+ base: () => [
2752
+ // Update system and install EPEL repository
2753
+ `dnf -y update`,
2754
+ `dnf -y install epel-release`,
2755
+
2756
+ // Install essential system tools (avoiding duplicates from container packages)
2757
+ `dnf -y install --allowerasing bzip2 openssh-server nano vim-enhanced less openssl-devel git gnupg2 libnsl perl`,
2758
+ `dnf clean all`,
2759
+
2760
+ // Install Node.js
2761
+ `curl -fsSL https://rpm.nodesource.com/setup_24.x | bash -`,
2762
+ `dnf install -y nodejs`,
2763
+ `dnf clean all`,
2764
+
2765
+ // Verify Node.js and npm versions
2766
+ `node --version`,
2767
+ `npm --version`,
2768
+
2769
+ // Install underpost ci/cd cli
2770
+ `npm install -g underpost`,
2771
+ `underpost --version`,
2772
+ ],
2773
+ /**
2774
+ * @method user
2775
+ * @description Generates shell commands for creating a root user and configuring SSH access on Rocky Linux.
2776
+ * This is a critical security step for initial access to the provisioned system.
2777
+ * @memberof UnderpostBaremetal.systemProvisioningFactory.rocky
2778
+ * @returns {string[]} An array of shell commands.
2779
+ */
2780
+ user: () => [
2781
+ `useradd -m -s /bin/bash -G wheel root`, // Create a root user with bash shell and wheel group (sudo on RHEL)
2782
+ `echo 'root:root' | chpasswd`, // Set a default password for the root user
2783
+ `mkdir -p /home/root/.ssh`, // Create .ssh directory for authorized keys
2784
+ // Add the public SSH key to authorized_keys for passwordless login
2785
+ `echo '${fs.readFileSync(
2786
+ `/home/dd/engine/engine-private/deploy/id_rsa.pub`,
2787
+ 'utf8',
2788
+ )}' > /home/root/.ssh/authorized_keys`,
2789
+ `chown -R root:root /home/root/.ssh`, // Set ownership for security
2790
+ `chmod 700 /home/root/.ssh`, // Set permissions for the .ssh directory
2791
+ `chmod 600 /home/root/.ssh/authorized_keys`, // Set permissions for authorized_keys
2792
+ ],
2793
+ /**
2794
+ * @method timezone
2795
+ * @description Generates shell commands for configuring the system timezone on Rocky Linux.
2796
+ * @param {object} params - The parameters for the function.
2797
+ * @param {string} params.timezone - The timezone string (e.g., 'America/Santiago').
2798
+ * @param {string} params.chronyConfPath - The path to the Chrony configuration file (optional).
2799
+ * @memberof UnderpostBaremetal.systemProvisioningFactory.rocky
2800
+ * @returns {string[]} An array of shell commands.
2801
+ */
2802
+ timezone: ({ timezone, chronyConfPath = '/etc/chrony.conf' }) => [
2803
+ // Set system timezone using both methods (for chroot and running system)
2804
+ `ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`,
2805
+ `echo '${timezone}' > /etc/timezone`,
2806
+ `timedatectl set-timezone ${timezone} 2>/dev/null`,
2807
+
2808
+ // Configure chrony with local NTP server and common NTP pools
2809
+ `echo '# Local NTP server' > ${chronyConfPath}`,
2810
+ `echo 'server 192.168.1.1 iburst prefer' >> ${chronyConfPath}`,
2811
+ `echo '' >> ${chronyConfPath}`,
2812
+ `echo '# Fallback public NTP servers' >> ${chronyConfPath}`,
2813
+ `echo 'server 0.pool.ntp.org iburst' >> ${chronyConfPath}`,
2814
+ `echo 'server 1.pool.ntp.org iburst' >> ${chronyConfPath}`,
2815
+ `echo 'server 2.pool.ntp.org iburst' >> ${chronyConfPath}`,
2816
+ `echo 'server 3.pool.ntp.org iburst' >> ${chronyConfPath}`,
2817
+ `echo '' >> ${chronyConfPath}`,
2818
+ `echo '# Configuration' >> ${chronyConfPath}`,
2819
+ `echo 'driftfile /var/lib/chrony/drift' >> ${chronyConfPath}`,
2820
+ `echo 'makestep 1.0 3' >> ${chronyConfPath}`,
2821
+ `echo 'rtcsync' >> ${chronyConfPath}`,
2822
+ `echo 'logdir /var/log/chrony' >> ${chronyConfPath}`,
2823
+
2824
+ // Enable chronyd to start on boot
2825
+ `systemctl enable chronyd 2>/dev/null`,
2826
+
2827
+ // Create systemd link for boot (works in chroot)
2828
+ `mkdir -p /etc/systemd/system/multi-user.target.wants`,
2829
+ `ln -sf /usr/lib/systemd/system/chronyd.service /etc/systemd/system/multi-user.target.wants/chronyd.service 2>/dev/null`,
2830
+
2831
+ // Start chronyd if systemd is running
2832
+ `systemctl start chronyd 2>/dev/null`,
2833
+
2834
+ // Restart chronyd to apply configuration
2835
+ `systemctl restart chronyd 2>/dev/null`,
2836
+
2837
+ // Force immediate time synchronization (only if chronyd is running)
2838
+ `chronyc makestep 2>/dev/null`,
2839
+
2840
+ // Verify timezone configuration
2841
+ `ls -l /etc/localtime`,
2842
+ `cat /etc/timezone || echo 'No /etc/timezone file'`,
2843
+ `timedatectl status 2>/dev/null || echo 'Timezone set to ${timezone} (timedatectl not available in chroot)'`,
2844
+ `chronyc tracking 2>/dev/null || echo 'Chrony configured but not running (will start on boot)'`,
2845
+ ],
2846
+ /**
2847
+ * @method keyboard
2848
+ * @description Generates shell commands for configuring the keyboard layout on Rocky Linux.
2849
+ * This uses localectl to set the keyboard layout for both console and X11.
2850
+ * @param {string} [keyCode='us'] - The keyboard layout code (e.g., 'us', 'es').
2851
+ * @memberof UnderpostBaremetal.systemProvisioningFactory.rocky
2852
+ * @returns {string[]} An array of shell commands.
2853
+ */
2854
+ keyboard: (keyCode = 'us') => [
2855
+ // Configure vconsole.conf for console keyboard layout (persistent)
2856
+ `echo 'KEYMAP=${keyCode}' > /etc/vconsole.conf`,
2857
+ `echo 'FONT=latarcyrheb-sun16' >> /etc/vconsole.conf`,
2858
+
2859
+ // Configure locale.conf for system locale
2860
+ `echo 'LANG=en_US.UTF-8' > /etc/locale.conf`,
2861
+ `echo 'LC_ALL=en_US.UTF-8' >> /etc/locale.conf`,
2862
+
2863
+ // Set keyboard layout using localectl (works if systemd is running)
2864
+ `localectl set-locale LANG=en_US.UTF-8 2>/dev/null`,
2865
+ `localectl set-keymap ${keyCode} 2>/dev/null`,
2866
+ `localectl set-x11-keymap ${keyCode} 2>/dev/null`,
2867
+
2868
+ // Configure X11 keyboard layout file directly
2869
+ `mkdir -p /etc/X11/xorg.conf.d`,
2870
+ `echo 'Section "InputClass"' > /etc/X11/xorg.conf.d/00-keyboard.conf`,
2871
+ `echo ' Identifier "system-keyboard"' >> /etc/X11/xorg.conf.d/00-keyboard.conf`,
2872
+ `echo ' MatchIsKeyboard "on"' >> /etc/X11/xorg.conf.d/00-keyboard.conf`,
2873
+ `echo ' Option "XkbLayout" "${keyCode}"' >> /etc/X11/xorg.conf.d/00-keyboard.conf`,
2874
+ `echo 'EndSection' >> /etc/X11/xorg.conf.d/00-keyboard.conf`,
2875
+
2876
+ // Load the keymap immediately (if not in chroot)
2877
+ `loadkeys ${keyCode} 2>/dev/null || echo 'Keymap ${keyCode} configured (loadkeys not available in chroot)'`,
2878
+
2879
+ // Verify configuration
2880
+ `echo 'Keyboard configuration files:'`,
2881
+ `cat /etc/vconsole.conf`,
2882
+ `cat /etc/locale.conf`,
2883
+ `cat /etc/X11/xorg.conf.d/00-keyboard.conf 2>/dev/null || echo 'X11 config created'`,
2884
+ `localectl status 2>/dev/null || echo 'Keyboard layout set to ${keyCode} (localectl not available in chroot)'`,
1348
2885
  ],
1349
2886
  },
1350
2887
  },
@@ -1354,7 +2891,7 @@ logdir /var/log/chrony
1354
2891
  * @description Configures and restarts the NFS server to export the specified path.
1355
2892
  * This is crucial for allowing baremetal machines to boot via NFS.
1356
2893
  * @param {object} params - The parameters for the function.
1357
- * @param {string} params.nfsHostPath - The path to be exported by the NFS server.
2894
+ * @param {string} params.nfsHostPath - The path to the NFS server export.
1358
2895
  * @memberof UnderpostBaremetal
1359
2896
  * @param {string} [params.subnet='192.168.1.0/24'] - The subnet allowed to access the NFS export.
1360
2897
  * @returns {void}
@@ -1403,7 +2940,7 @@ udp-port = 32766
1403
2940
  shellExec(`sudo exportfs -rav`);
1404
2941
 
1405
2942
  // Display the currently active NFS exports for verification.
1406
- logger.info('Displaying active NFS exports:');
2943
+ logger.info('Displaying active NFS exports');
1407
2944
  shellExec(`sudo exportfs -s`);
1408
2945
 
1409
2946
  // Restart the nfs-server service to apply all configuration changes,
@@ -1505,9 +3042,14 @@ udp-port = 32766
1505
3042
  * @throws {Error} If an invalid workflow ID is provided.
1506
3043
  */
1507
3044
  bootConfFactory({ workflowId, tftpIp, tftpPrefixStr, macAddress, clientIp, subnet, gateway }) {
1508
- switch (workflowId) {
1509
- case 'rpi4mb':
1510
- return `[all]
3045
+ if (workflowId.startsWith('rpi4mb')) {
3046
+ // Instructions: Flash sd with Raspberry Pi OS lite and update:
3047
+ // EEPROM (Electrically Erasable Programmable Read-Only Memory) like microcontrollers
3048
+ // sudo rpi-eeprom-config --apply /boot/firmware/boot.conf
3049
+ // sudo reboot
3050
+ // vcgencmd bootloader_config
3051
+ // shutdown -h now
3052
+ return `[all]
1511
3053
  BOOT_UART=0
1512
3054
  WAKE_ON_GPIO=1
1513
3055
  POWER_OFF_ON_HALT=0
@@ -1540,7 +3082,7 @@ TFTP_PREFIX_STR=${tftpPrefixStr}/
1540
3082
  # Manually override Ethernet MAC address
1541
3083
  # ─────────────────────────────────────────────────────────────
1542
3084
 
1543
- MAC_ADDRESS=${macAddress}
3085
+ #MAC_ADDRESS=${macAddress}
1544
3086
 
1545
3087
  # OTP MAC address override
1546
3088
  #MAC_ADDRESS_OTP=0,1
@@ -1548,18 +3090,14 @@ MAC_ADDRESS=${macAddress}
1548
3090
  # ─────────────────────────────────────────────────────────────
1549
3091
  # Static IP configuration (bypasses DHCP completely)
1550
3092
  # ─────────────────────────────────────────────────────────────
1551
- CLIENT_IP=${clientIp}
3093
+ #CLIENT_IP=${clientIp}
1552
3094
  SUBNET=${subnet}
1553
3095
  GATEWAY=${gateway}`;
1554
-
1555
- default:
1556
- throw new Error('Boot conf factory invalid workflow ID:' + workflowId);
1557
- }
3096
+ } else logger.warn(`No boot configuration factory defined for workflow ID: ${workflowId}`);
1558
3097
  },
1559
3098
 
1560
3099
  /**
1561
3100
  * @method loadWorkflowsConfig
1562
- * @namespace UnderpostBaremetal.API
1563
3101
  * @description Loads the commission workflows configuration from commission-workflows.json.
1564
3102
  * Each workflow defines specific parameters like system provisioning type,
1565
3103
  * kernel version, Chrony settings, debootstrap image details, and NFS mounts. *