@underpostnet/underpost 2.96.1 → 2.97.0

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.
@@ -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,21 +50,40 @@ 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 {string} [options.bootcmd=''] - Comma-separated list of boot commands to execute.
86
+ * @param {string} [options.runcmd=''] - Comma-separated list of run commands to execute.
67
87
  * @param {boolean} [options.nfsBuild=false] - Flag to build the NFS root filesystem.
68
88
  * @param {boolean} [options.nfsMount=false] - Flag to mount the NFS root filesystem.
69
89
  * @param {boolean} [options.nfsUnmount=false] - Flag to unmount the NFS root filesystem.
@@ -74,22 +94,41 @@ class UnderpostBaremetal {
74
94
  */
75
95
  async callback(
76
96
  workflowId,
77
- hostname,
78
97
  ipAddress,
98
+ hostname,
99
+ ipFileServer,
100
+ ipConfig,
101
+ netmask,
102
+ dnsServer,
79
103
  options = {
80
104
  dev: false,
81
105
  controlServerInstall: false,
82
106
  controlServerUninstall: false,
107
+ controlServerRestart: false,
83
108
  controlServerDbInstall: false,
84
109
  controlServerDbUninstall: false,
110
+ createMachine: false,
111
+ mac: '',
112
+ ipxe: false,
113
+ ipxeRebuild: false,
85
114
  installPacker: false,
86
115
  packerMaasImageTemplate: false,
87
116
  packerWorkflowId: '',
88
117
  packerMaasImageBuild: false,
89
118
  packerMaasImageUpload: false,
90
119
  packerMaasImageCached: false,
120
+ removeMachines: '',
121
+ clearDiscovered: false,
91
122
  cloudInitUpdate: false,
123
+ cloudInit: false,
92
124
  commission: false,
125
+ bootstrapHttpServerPort: 8888,
126
+ bootstrapHttpServerPath: './public/localhost',
127
+ isoUrl: '',
128
+ ubuntuToolsBuild: false,
129
+ ubuntuToolsTest: false,
130
+ bootcmd: '',
131
+ runcmd: '',
93
132
  nfsBuild: false,
94
133
  nfsMount: false,
95
134
  nfsUnmount: false,
@@ -105,37 +144,123 @@ class UnderpostBaremetal {
105
144
  const underpostRoot = options?.dev === true ? '.' : `${npmRoot}/underpost`;
106
145
 
107
146
  // Set default values if not provided.
108
- workflowId = workflowId ? workflowId : 'rpi4mb';
147
+ workflowId = workflowId ? workflowId : 'rpi4mbarm64-iso-ram';
109
148
  hostname = hostname ? hostname : workflowId;
110
- ipAddress = ipAddress ? ipAddress : '192.168.1.192';
149
+ ipAddress = ipAddress ? ipAddress : '192.168.1.191';
150
+ ipFileServer = ipFileServer ? ipFileServer : getLocalIPv4Address();
151
+ netmask = netmask ? netmask : '255.255.255.0';
152
+ dnsServer = dnsServer ? dnsServer : '8.8.8.8';
153
+
154
+ // IpConfig options:
155
+ // dhcp - DHCP configuration
156
+ // dhpc6 - DHCP IPv6 configuration
157
+ // auto6 - automatic IPv6 configuration
158
+ // on, any - any protocol available in the kernel (default)
159
+ // none, off - no autoconfiguration, static network configuration
160
+ ipConfig = ipConfig ? ipConfig : 'none';
111
161
 
112
162
  // Set default MAC address
113
- let macAddress = '00:00:00:00:00:00';
163
+ let macAddress = UnderpostBaremetal.API.macAddressFactory(options).mac;
164
+ const workflowsConfig = UnderpostBaremetal.API.loadWorkflowsConfig();
114
165
 
166
+ if (!workflowsConfig[workflowId]) {
167
+ throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
168
+ }
169
+
170
+ const tftpPrefix = workflowsConfig[workflowId].tftpPrefix || 'rpi4mb';
115
171
  // Define the debootstrap architecture.
116
172
  let debootstrapArch;
117
173
 
174
+ // Set debootstrap architecture.
175
+ if (workflowsConfig[workflowId].type === 'chroot') {
176
+ const { architecture } = workflowsConfig[workflowId].debootstrap.image;
177
+ debootstrapArch = architecture;
178
+ }
179
+
118
180
  // Define the database provider ID.
119
181
  const dbProviderId = 'postgresql-17';
120
182
 
121
183
  // Define the NFS host path based on the environment variable and hostname.
122
184
  const nfsHostPath = `${process.env.NFS_EXPORT_PATH}/${hostname}`;
123
185
 
124
- // Define the TFTP root path based on the environment variable and hostname.
125
- const tftpRootPath = `${process.env.TFTP_ROOT}/${hostname}`;
186
+ // Define the TFTP root prefix path based
187
+ const tftpRootPath = `${process.env.TFTP_ROOT}/${tftpPrefix}`;
188
+
189
+ // Define the bootstrap HTTP server path.
190
+ const bootstrapHttpServerPath = options.bootstrapHttpServerPath
191
+ ? options.bootstrapHttpServerPath
192
+ : `/tmp/bootstrap-http-server/${workflowId}`;
126
193
 
127
194
  // Capture metadata for the callback execution, useful for logging and auditing.
128
195
  const callbackMetaData = {
129
- args: { hostname, ipAddress, workflowId },
196
+ args: { workflowId, ipAddress, hostname, ipFileServer, ipConfig, netmask, dnsServer },
130
197
  options,
131
198
  runnerHost: { architecture: UnderpostBaremetal.API.getHostArch().alias, ip: getLocalIPv4Address() },
132
199
  nfsHostPath,
133
200
  tftpRootPath,
201
+ bootstrapHttpServerPath,
134
202
  };
135
203
 
136
204
  // Log the initiation of the baremetal callback with relevant metadata.
137
205
  logger.info('Baremetal callback', callbackMetaData);
138
206
 
207
+ // Create a new machine in MAAS if the option is set.
208
+ let machine;
209
+ if (options.createMachine === true) {
210
+ const [searhMachine] = JSON.parse(
211
+ shellExec(`maas maas machines read hostname=${hostname}`, {
212
+ stdout: true,
213
+ silent: true,
214
+ }),
215
+ );
216
+
217
+ if (searhMachine) {
218
+ // Check if existing machine's MAC matches the specified MAC
219
+ const existingMac = searhMachine.boot_interface?.mac_address || searhMachine.mac_address;
220
+
221
+ // If using hardware MAC (macAddress is null), skip MAC validation and use existing machine
222
+ if (macAddress === null) {
223
+ logger.info(`Using hardware MAC mode - keeping existing machine ${hostname} with MAC ${existingMac}`);
224
+ machine = searhMachine;
225
+ } else if (existingMac && existingMac !== macAddress) {
226
+ logger.warn(`⚠ Machine ${hostname} exists with MAC ${existingMac}, but --mac specified ${macAddress}`);
227
+ logger.info(`Deleting existing machine ${searhMachine.system_id} to recreate with correct MAC...`);
228
+
229
+ // Delete the existing machine
230
+ shellExec(`maas maas machine delete ${searhMachine.system_id}`, {
231
+ silent: true,
232
+ });
233
+
234
+ // Create new machine with correct MAC
235
+ machine = UnderpostBaremetal.API.machineFactory({
236
+ hostname,
237
+ ipAddress,
238
+ macAddress,
239
+ maas: workflowsConfig[workflowId].maas,
240
+ }).machine;
241
+
242
+ logger.info(`✓ Machine recreated with MAC ${macAddress}`);
243
+ } else {
244
+ logger.info(`Using existing machine ${hostname} with MAC ${existingMac}`);
245
+ machine = searhMachine;
246
+ }
247
+ } else {
248
+ // No existing machine found, create new one
249
+ // For hardware MAC mode (macAddress is null), we'll create machine after discovery
250
+ if (macAddress === null) {
251
+ logger.info(`Hardware MAC mode - machine will be created after discovery`);
252
+ machine = null;
253
+ } else {
254
+ machine = UnderpostBaremetal.API.machineFactory({
255
+ hostname,
256
+ ipAddress,
257
+ macAddress,
258
+ maas: workflowsConfig[workflowId].maas,
259
+ }).machine;
260
+ }
261
+ }
262
+ }
263
+
139
264
  if (options.installPacker) {
140
265
  await UnderpostBaremetal.API.installPacker(underpostRoot);
141
266
  return;
@@ -183,9 +308,9 @@ class UnderpostBaremetal {
183
308
  workflows[workflowId] = workflowConfig;
184
309
  UnderpostBaremetal.API.writePackerMaasImageBuildWorkflows(workflows);
185
310
 
186
- logger.info('\nTemplate extracted successfully!');
187
- logger.info(`\nAdded configuration for ${workflowId} to engine/baremetal/packer-workflows.json`);
188
- logger.info('\nNext steps:');
311
+ logger.info('Template extracted successfully!');
312
+ logger.info(`Added configuration for ${workflowId} to engine/baremetal/packer-workflows.json`);
313
+ logger.info('Next steps');
189
314
  logger.info(`1. Review and customize the Packer template files in: ${targetDir}`);
190
315
  logger.info(`2. Review the workflow configuration in engine/baremetal/packer-workflows.json`);
191
316
  logger.info(
@@ -324,18 +449,31 @@ rm -rf ${artifacts.join(' ')}`);
324
449
  return;
325
450
  }
326
451
 
327
- if (options.logs === 'cloud') {
452
+ if (options.logs === 'dhcp-lease') {
453
+ shellExec(`cat /var/snap/maas/common/maas/dhcp/dhcpd.leases`);
454
+ shellExec(`cat /var/snap/maas/common/maas/dhcp/dhcpd.pid`);
455
+ return;
456
+ }
457
+
458
+ if (options.logs === 'dhcp-lan') {
459
+ shellExec(`sudo tcpdump -l -n -i any -s0 -vv 'udp and (port 67 or 68)'`);
460
+ return;
461
+ }
462
+
463
+ if (options.logs === 'cloud-init') {
328
464
  shellExec(`tail -f -n 900 ${nfsHostPath}/var/log/cloud-init.log`);
329
465
  return;
330
466
  }
331
467
 
332
- if (options.logs === 'machine') {
468
+ if (options.logs === 'cloud-init-machine') {
333
469
  shellExec(`tail -f -n 900 ${nfsHostPath}/var/log/cloud-init-output.log`);
334
470
  return;
335
471
  }
336
472
 
337
- if (options.logs === 'cloud-config') {
338
- shellExec(`cat ${nfsHostPath}/etc/cloud/cloud.cfg.d/90_maas.cfg`);
473
+ if (options.logs === 'cloud-init-config') {
474
+ shellExec(`cat ${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`);
475
+ shellExec(`cat ${bootstrapHttpServerPath}/${hostname}/cloud-init/meta-data`);
476
+ shellExec(`cat ${bootstrapHttpServerPath}/${hostname}/cloud-init/vendor-data`);
339
477
  return;
340
478
  }
341
479
 
@@ -371,7 +509,6 @@ rm -rf ${artifacts.join(' ')}`);
371
509
  shellExec(`chmod +x ${underpostRoot}/scripts/maas-setup.sh`);
372
510
  shellExec(`chmod +x ${underpostRoot}/scripts/nat-iptables.sh`);
373
511
  shellExec(`${underpostRoot}/scripts/maas-setup.sh`);
374
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`);
375
512
  return;
376
513
  }
377
514
 
@@ -393,6 +530,12 @@ rm -rf ${artifacts.join(' ')}`);
393
530
  return;
394
531
  }
395
532
 
533
+ // Handle control server restart.
534
+ if (options.controlServerRestart === true) {
535
+ shellExec(`sudo snap restart maas`);
536
+ return;
537
+ }
538
+
396
539
  // Handle control server database installation.
397
540
  if (options.controlServerDbInstall === true) {
398
541
  // Deploy the database provider and manage MAAS database.
@@ -410,41 +553,43 @@ rm -rf ${artifacts.join(' ')}`);
410
553
  return;
411
554
  }
412
555
 
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
556
  // Handle NFS mount operation.
425
557
  if (options.nfsMount === true) {
426
- // Mount binfmt_misc filesystem.
427
- UnderpostBaremetal.API.mountBinfmtMisc({ nfsHostPath });
428
- UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, mount: true });
558
+ const { isMounted } = UnderpostBaremetal.API.nfsMountCallback({
559
+ hostname,
560
+ nfsHostPath,
561
+ workflowId,
562
+ mount: true,
563
+ });
564
+ return;
429
565
  }
430
566
 
431
567
  // Handle NFS unmount operation.
432
568
  if (options.nfsUnmount === true) {
433
- UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId, unmount: true });
569
+ const { isMounted } = UnderpostBaremetal.API.nfsMountCallback({
570
+ hostname,
571
+ nfsHostPath,
572
+ workflowId,
573
+ unmount: true,
574
+ });
575
+ return;
434
576
  }
435
577
 
436
578
  // Handle NFS root filesystem build operation.
437
579
  if (options.nfsBuild === true) {
438
- // Check if NFS is already mounted to avoid redundant builds.
439
- const { isMounted } = UnderpostBaremetal.API.nfsMountCallback({ hostname, workflowId });
580
+ const { isMounted } = UnderpostBaremetal.API.nfsMountCallback({
581
+ hostname,
582
+ nfsHostPath,
583
+ workflowId,
584
+ unmount: true,
585
+ });
586
+
587
+ if (isMounted) throw new Error(`NFS path ${nfsHostPath} is currently mounted. Please unmount before building.`);
440
588
 
441
589
  // Clean and create the NFS host path.
442
590
  shellExec(`sudo rm -rf ${nfsHostPath}/*`);
443
591
  shellExec(`mkdir -p ${nfsHostPath}`);
444
592
 
445
- // Mount binfmt_misc filesystem.
446
- UnderpostBaremetal.API.mountBinfmtMisc({ nfsHostPath });
447
-
448
593
  // Perform the first stage of debootstrap.
449
594
  {
450
595
  const { architecture, name } = workflowsConfig[workflowId].debootstrap.image;
@@ -477,6 +622,14 @@ rm -rf ${artifacts.join(' ')}`);
477
622
  shellExec(`podman ps -a`);
478
623
  shellExec(`file ${nfsHostPath}/bin/bash`); // Verify the bash executable in the chroot.
479
624
 
625
+ // Mount necessary filesystems and register binfmt for the second stage.
626
+ UnderpostBaremetal.API.nfsMountCallback({
627
+ hostname,
628
+ nfsHostPath,
629
+ workflowId,
630
+ mount: true,
631
+ });
632
+
480
633
  // Perform the second stage of debootstrap within the chroot environment.
481
634
  UnderpostBaremetal.API.crossArchRunner({
482
635
  nfsHostPath,
@@ -484,34 +637,7 @@ rm -rf ${artifacts.join(' ')}`);
484
637
  callbackMetaData,
485
638
  steps: [`/debootstrap/debootstrap --second-stage`],
486
639
  });
487
-
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;
497
-
498
- UnderpostBaremetal.API.crossArchRunner({
499
- nfsHostPath,
500
- debootstrapArch,
501
- callbackMetaData,
502
- 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(),
512
- ],
513
- });
514
- }
640
+ return;
515
641
  }
516
642
 
517
643
  // Fetch boot resources and machines if commissioning or listing.
@@ -544,14 +670,33 @@ rm -rf ${artifacts.join(' ')}`);
544
670
  console.table(machines);
545
671
  }
546
672
 
673
+ if (options.clearDiscovered) UnderpostBaremetal.API.removeDiscoveredMachines();
674
+
675
+ // Handle remove existing machines from MAAS.
676
+ if (options.removeMachines)
677
+ machines = UnderpostBaremetal.API.removeMachines({
678
+ machines: options.removeMachines === 'all' ? machines : options.removeMachines.split(','),
679
+ ignore: machine ? [machine.system_id] : [],
680
+ });
681
+
547
682
  // Handle commissioning tasks (placeholder for future implementation).
683
+
548
684
  if (options.commission === true) {
549
- const { firmwares, networkInterfaceName, maas, netmask, menuentryStr } = workflowsConfig[workflowId];
685
+ let { firmwares, networkInterfaceName, maas, menuentryStr, type } = workflowsConfig[workflowId];
686
+
687
+ // Use commissioning config (Ubuntu ephemeral) for PXE boot resources
688
+ const commissioningImage = maas.commissioning;
550
689
  const resource = resources.find(
551
- (o) => o.architecture === maas.image.architecture && o.name === maas.image.name,
690
+ (o) => o.architecture === commissioningImage.architecture && o.name === commissioningImage.name,
552
691
  );
553
692
  logger.info('Commissioning resource', resource);
554
693
 
694
+ if (type === 'iso-nfs') {
695
+ // Prepare NFS casper path if using NFS boot.
696
+ shellExec(`sudo rm -rf ${nfsHostPath}`);
697
+ shellExec(`mkdir -p ${nfsHostPath}/casper`);
698
+ }
699
+
555
700
  // Clean and create TFTP root path.
556
701
  shellExec(`sudo rm -rf ${tftpRootPath}`);
557
702
  shellExec(`mkdir -p ${tftpRootPath}/pxe`);
@@ -569,221 +714,1280 @@ rm -rf ${artifacts.join(' ')}`);
569
714
  shellExec(`sudo cp -a ${path}/* ${tftpRootPath}`); // Copy firmware files to TFTP root.
570
715
 
571
716
  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',
717
+ const bootConfSrc = UnderpostBaremetal.API.bootConfFactory({
718
+ workflowId,
719
+ tftpIp: callbackMetaData.runnerHost.ip,
720
+ tftpPrefixStr: hostname,
721
+ macAddress,
722
+ clientIp: ipAddress,
723
+ subnet,
724
+ gateway,
725
+ });
726
+ if (bootConfSrc) fs.writeFileSync(`${tftpRootPath}/boot_${name}.conf`, bootConfSrc, 'utf8');
727
+ }
728
+ }
729
+ }
730
+
731
+ // Configure GRUB for PXE boot.
732
+ {
733
+ // Fetch kernel and initrd paths from MAAS boot resource.
734
+ // Both NFS and disk-based commissioning use MAAS boot resources.
735
+ const { kernelFilesPaths, resourcesPath } = UnderpostBaremetal.API.kernelFactory({
736
+ resource,
737
+ type,
738
+ nfsHostPath,
739
+ isoUrl: options.isoUrl || workflowsConfig[workflowId].isoUrl,
740
+ });
741
+
742
+ const { cmd } = UnderpostBaremetal.API.kernelCmdBootParamsFactory({
743
+ ipClient: ipAddress,
744
+ ipDhcpServer: callbackMetaData.runnerHost.ip,
745
+ ipConfig,
746
+ ipFileServer,
747
+ netmask,
748
+ hostname,
749
+ dnsServer,
750
+ networkInterfaceName,
751
+ fileSystemUrl: kernelFilesPaths.isoUrl,
752
+ bootstrapHttpServerPort:
753
+ options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort || 8888,
754
+ type,
755
+ macAddress,
756
+ cloudInit: options.cloudInit,
757
+ machine,
758
+ });
759
+
760
+ // Check if iPXE mode is enabled AND the iPXE EFI binary exists
761
+ let useIpxe = options.ipxe;
762
+ if (options.ipxe) {
763
+ const arch = commissioningImage.architecture.split('/')[0];
764
+ const ipxeScript = UnderpostBaremetal.API.ipxeScriptFactory({
765
+ maasIp: callbackMetaData.runnerHost.ip,
766
+ macAddress,
767
+ architecture: arch,
768
+ tftpPrefix,
769
+ kernelCmd: cmd,
770
+ });
771
+ fs.writeFileSync(`${tftpRootPath}/stable-id.ipxe`, ipxeScript, 'utf8');
772
+
773
+ // Create embedded boot script that does DHCP and chains to the main script
774
+ const embeddedScript = UnderpostBaremetal.API.ipxeEmbeddedScriptFactory({
775
+ tftpServer: callbackMetaData.runnerHost.ip,
776
+ scriptPath: `/${tftpPrefix}/stable-id.ipxe`,
777
+ macAddress: macAddress,
778
+ });
779
+ fs.writeFileSync(`${tftpRootPath}/boot.ipxe`, embeddedScript, 'utf8');
780
+
781
+ logger.info('✓ iPXE script generated for MAAS commissioning', {
782
+ registeredMAC: macAddress,
783
+ path: `${tftpRootPath}/stable-id.ipxe`,
784
+ embeddedPath: `${tftpRootPath}/boot.ipxe`,
785
+ });
786
+ if (macAddress === null) {
787
+ logger.info('ℹ Hardware MAC mode - device will use actual hardware MAC address');
788
+ logger.info('ℹ MAAS will identify the machine by its hardware MAC after discovery');
789
+ } else {
790
+ logger.info('ℹ Machine registered in MAAS with MAC:', macAddress);
791
+ if (macAddress !== '00:00:00:00:00:00') {
792
+ logger.info('ℹ MAAS will identify the machine by MAC:', macAddress);
793
+ } else {
794
+ logger.info('ℹ Device will boot with its actual hardware MAC address');
795
+ logger.info('ℹ MAAS will identify the machine by its hardware MAC');
796
+ }
797
+ }
798
+
799
+ // Rebuild iPXE with embedded boot script if requested or if binary doesn't exist
800
+ const embeddedScriptPath = `${tftpRootPath}/boot.ipxe`;
801
+ const shouldRebuild = options.ipxeRebuild || !fs.existsSync(`${tftpRootPath}/ipxe.efi`);
802
+
803
+ if (shouldRebuild && fs.existsSync(embeddedScriptPath)) {
804
+ logger.info('Rebuilding iPXE with embedded boot script...', {
805
+ embeddedScriptPath,
806
+ forced: options.ipxeRebuild,
807
+ });
808
+ shellExec(
809
+ `${underpostRoot}/scripts/ipxe-setup.sh ${tftpRootPath} --target-arch ${arch} --embed-script ${embeddedScriptPath} --rebuild`,
584
810
  );
811
+ } else if (shouldRebuild) {
812
+ logger.warn('⚠ Embedded script not found, building without embedded script');
813
+ shellExec(`${underpostRoot}/scripts/ipxe-setup.sh ${tftpRootPath} --target-arch ${arch}`);
814
+ } else {
815
+ logger.info('ℹ Using existing iPXE binary (use --ipxe-rebuild to force rebuild)');
585
816
  }
817
+
818
+ // Only use iPXE chainloading if the binary exists
819
+ if (fs.existsSync(`${tftpRootPath}/ipxe.efi`)) {
820
+ logger.info('✓ iPXE EFI binary found');
821
+ } else {
822
+ useIpxe = false;
823
+ logger.warn(`⚠ iPXE EFI binary not found at ${tftpRootPath}/ipxe.efi - falling back to direct GRUB boot`);
824
+ logger.warn(' The iPXE script was generated but cannot be used without the iPXE EFI binary.');
825
+ logger.warn(' Consider booting without --ipxe flag or providing the iPXE EFI binary.');
826
+ }
827
+ }
828
+
829
+ const { grubCfgSrc } = UnderpostBaremetal.API.grubFactory({
830
+ menuentryStr,
831
+ kernelPath: `/${tftpPrefix}/pxe/vmlinuz-efi`,
832
+ initrdPath: `/${tftpPrefix}/pxe/initrd.img`,
833
+ cmd,
834
+ tftpIp: callbackMetaData.runnerHost.ip,
835
+ ipxe: useIpxe,
836
+ ipxePath: `/${tftpPrefix}/ipxe.efi`,
837
+ });
838
+ UnderpostBaremetal.API.writeGrubConfigToFile({
839
+ grubCfgSrc: machine ? grubCfgSrc.replaceAll('system-id', machine.system_id) : grubCfgSrc,
840
+ });
841
+ if (machine) {
842
+ logger.info('✓ GRUB config written with system_id', { system_id: machine.system_id });
586
843
  }
844
+ UnderpostBaremetal.API.updateKernelFiles({
845
+ commissioningImage,
846
+ resourcesPath,
847
+ tftpRootPath,
848
+ kernelFilesPaths,
849
+ });
850
+ }
851
+
852
+ // Pass architecture from commissioning or deployment config
853
+ const grubArch = maas.commissioning.architecture;
854
+ UnderpostBaremetal.API.efiGrubModulesFactory({ image: { architecture: grubArch } });
855
+
856
+ // Set ownership and permissions for TFTP root.
857
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${process.env.TFTP_ROOT}`);
858
+ shellExec(`sudo sudo chmod 755 ${process.env.TFTP_ROOT}`);
859
+
860
+ UnderpostBaremetal.API.httpBootstrapServerRunnerFactory({
861
+ hostname,
862
+ bootstrapHttpServerPath,
863
+ bootstrapHttpServerPort:
864
+ options.bootstrapHttpServerPort || workflowsConfig[workflowId].bootstrapHttpServerPort,
865
+ });
866
+ }
867
+
868
+ if (options.cloudInit || options.cloudInitUpdate) {
869
+ const { chronyc, networkInterfaceName } = workflowsConfig[workflowId];
870
+ const { timezone, chronyConfPath } = chronyc;
871
+ const authCredentials = UnderpostCloudInit.API.authCredentialsFactory();
872
+ const { cloudConfigSrc } = UnderpostCloudInit.API.configFactory(
873
+ {
874
+ controlServerIp: callbackMetaData.runnerHost.ip,
875
+ hostname,
876
+ commissioningDeviceIp: ipAddress,
877
+ gatewayip: callbackMetaData.runnerHost.ip,
878
+ mac: macAddress,
879
+ timezone,
880
+ chronyConfPath,
881
+ networkInterfaceName,
882
+ ubuntuToolsBuild: options.ubuntuToolsBuild,
883
+ bootcmd: options.bootcmd,
884
+ runcmd: options.runcmd,
885
+ },
886
+ authCredentials,
887
+ );
888
+
889
+ shellExec(`mkdir -p ${bootstrapHttpServerPath}`);
890
+ fs.writeFileSync(
891
+ `${bootstrapHttpServerPath}/${hostname}/cloud-init/user-data`,
892
+ `#cloud-config\n${cloudConfigSrc}`,
893
+ 'utf8',
894
+ );
895
+ fs.writeFileSync(
896
+ `${bootstrapHttpServerPath}/${hostname}/cloud-init/meta-data`,
897
+ `instance-id: ${hostname}\nlocal-hostname: ${hostname}`,
898
+ 'utf8',
899
+ );
900
+ fs.writeFileSync(`${bootstrapHttpServerPath}/${hostname}/cloud-init/vendor-data`, ``, 'utf8');
901
+
902
+ logger.info(`Cloud-init files written to ${bootstrapHttpServerPath}`);
903
+ if (options.cloudInitUpdate) return;
904
+ }
905
+
906
+ if (workflowsConfig[workflowId].type === 'chroot') {
907
+ if (options.ubuntuToolsBuild) {
908
+ UnderpostCloudInit.API.buildTools({
909
+ workflowId,
910
+ nfsHostPath,
911
+ hostname,
912
+ callbackMetaData,
913
+ dev: options.dev,
914
+ });
915
+
916
+ const { chronyc, keyboard } = workflowsConfig[workflowId];
917
+ const { timezone, chronyConfPath } = chronyc;
918
+ const systemProvisioning = 'ubuntu';
919
+
920
+ UnderpostBaremetal.API.crossArchRunner({
921
+ nfsHostPath,
922
+ debootstrapArch,
923
+ callbackMetaData,
924
+ steps: [
925
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].base(),
926
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].user(),
927
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].timezone({
928
+ timezone,
929
+ chronyConfPath,
930
+ }),
931
+ ...UnderpostBaremetal.API.systemProvisioningFactory[systemProvisioning].keyboard(keyboard.layout),
932
+ ],
933
+ });
587
934
  }
588
935
 
589
- // Rebuild NFS server configuration.
936
+ if (options.ubuntuToolsTest)
937
+ UnderpostBaremetal.API.crossArchRunner({
938
+ nfsHostPath,
939
+ debootstrapArch,
940
+ callbackMetaData,
941
+ steps: [
942
+ `chmod +x /underpost/date.sh`,
943
+ `chmod +x /underpost/keyboard.sh`,
944
+ `chmod +x /underpost/dns.sh`,
945
+ `chmod +x /underpost/help.sh`,
946
+ `chmod +x /underpost/host.sh`,
947
+ `chmod +x /underpost/test.sh`,
948
+ `chmod +x /underpost/start.sh`,
949
+ `chmod +x /underpost/reset.sh`,
950
+ `chmod +x /underpost/shutdown.sh`,
951
+ `chmod +x /underpost/device_scan.sh`,
952
+ `chmod +x /underpost/mac.sh`,
953
+ `chmod +x /underpost/enlistment.sh`,
954
+ `sudo chmod 700 ~/.ssh/`, // Set secure permissions for .ssh directory.
955
+ `sudo chmod 600 ~/.ssh/authorized_keys`, // Set secure permissions for authorized_keys.
956
+ `sudo chmod 644 ~/.ssh/known_hosts`, // Set permissions for known_hosts.
957
+ `sudo chmod 600 ~/.ssh/id_rsa`, // Set secure permissions for private key.
958
+ `sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`, // Set secure permissions for host key.
959
+ `chown -R root:root ~/.ssh`, // Ensure root owns the .ssh directory.
960
+ `/underpost/test.sh`,
961
+ ],
962
+ });
963
+ }
964
+
965
+ shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
966
+ // Rebuild NFS server configuration.
967
+ if (workflowsConfig[workflowId].type === 'iso-nfs' || workflowsConfig[workflowId].type === 'chroot')
590
968
  UnderpostBaremetal.API.rebuildNfsServer({
591
969
  nfsHostPath,
592
970
  });
593
971
 
594
- // Configure GRUB for PXE boot.
972
+ // Final commissioning steps.
973
+ if (options.commission === true) {
974
+ const { type } = workflowsConfig[workflowId];
975
+
976
+ if (type === 'chroot') {
977
+ const { isMounted } = UnderpostBaremetal.API.nfsMountCallback({
978
+ hostname,
979
+ nfsHostPath,
980
+ workflowId,
981
+ mount: true,
982
+ });
983
+ if (!isMounted) throw new Error('NFS root filesystem is not mounted');
984
+ }
985
+ const commissionMonitorPayload = {
986
+ macAddress,
987
+ ipAddress,
988
+ hostname,
989
+ maas: workflowsConfig[workflowId].maas,
990
+ machine,
991
+ };
992
+ logger.info('Waiting for commissioning...', {
993
+ ...commissionMonitorPayload,
994
+ machine: machine ? machine.system_id : null,
995
+ });
996
+
997
+ const { discovery } = await UnderpostBaremetal.API.commissionMonitor(commissionMonitorPayload);
998
+
999
+ if (type === 'chroot' && options.cloudInit === true) {
1000
+ openTerminal(`node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init`);
1001
+ openTerminal(
1002
+ `node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init-machine`,
1003
+ );
1004
+ shellExec(
1005
+ `node ${underpostRoot}/bin baremetal ${workflowId} ${ipAddress} ${hostname} --logs cloud-init-config`,
1006
+ );
1007
+ }
1008
+ }
1009
+ },
1010
+
1011
+ /**
1012
+ * @method macAddressFactory
1013
+ * @description Generates or returns a MAC address based on options.
1014
+ * @param {object} options - Options for MAC address generation.
1015
+ * @param {string} options.mac - 'random' for random MAC, 'hardware' to use device's actual MAC, specific MAC string, or empty for default.
1016
+ * @returns {object} Object with mac property - null for 'hardware', generated/specified MAC otherwise.
1017
+ * @memberof UnderpostBaremetal
1018
+ */
1019
+ macAddressFactory(options = { mac: '' }) {
1020
+ const len = 6;
1021
+ const defaultMac = range(1, len)
1022
+ .map(() => '00')
1023
+ .join(':');
1024
+ if (options) {
1025
+ if (!options.mac) options.mac = defaultMac;
1026
+ if (options.mac === 'hardware') {
1027
+ // Return null to indicate hardware MAC should be used (no spoofing)
1028
+ options.mac = null;
1029
+ } else if (options.mac === 'random') {
1030
+ options.mac = range(1, len)
1031
+ .map(() => s4().toLowerCase().substring(0, 2))
1032
+ .join(':');
1033
+ }
1034
+ } else options = { mac: defaultMac };
1035
+ return options;
1036
+ },
1037
+
1038
+ /**
1039
+ * @method downloadUbuntuLiveISO
1040
+ * @description Downloads Ubuntu live ISO and extracts casper boot files for live boot.
1041
+ * @param {object} params - Parameters for the method.
1042
+ * @param {object} params.resource - The MAAS boot resource object.
1043
+ * @param {string} params.architecture - The architecture (arm64 or amd64).
1044
+ * @param {string} params.nfsHostPath - The NFS host path to store the ISO and extracted files.
1045
+ * @returns {object} An object containing paths to the extracted kernel, initrd, and squashfs.
1046
+ * @memberof UnderpostBaremetal
1047
+ */
1048
+ downloadUbuntuLiveISO({ resource, architecture, nfsHostPath, isoUrl }) {
1049
+ const arch = architecture || resource.architecture.split('/')[0];
1050
+ const osName = resource.name.split('/')[1]; // e.g., "focal", "jammy", "noble"
1051
+
1052
+ // Map Ubuntu codenames to versions - different versions available for different architectures
1053
+ // ARM64 ISOs are hosted on cdimage.ubuntu.com, AMD64 on releases.ubuntu.com
1054
+ const versionMap = {
1055
+ arm64: {
1056
+ focal: '20.04.5', // ARM64 focal only up to 20.04.5 on cdimage
1057
+ jammy: '22.04.5',
1058
+ noble: '24.04.3', // ubuntu-24.04.3-live-server-arm64+largemem.iso
1059
+ bionic: '18.04.6',
1060
+ },
1061
+ amd64: {
1062
+ focal: '20.04.6',
1063
+ jammy: '22.04.5',
1064
+ noble: '24.04.1',
1065
+ bionic: '18.04.6',
1066
+ },
1067
+ };
1068
+
1069
+ shellExec(`mkdir -p ${nfsHostPath}/casper`);
1070
+
1071
+ const version = (versionMap[arch] && versionMap[arch][osName]) || '20.04.5';
1072
+ const majorVersion = version.split('.').slice(0, 2).join('.');
1073
+
1074
+ // Determine ISO filename and URL based on architecture
1075
+ // ARM64 ISOs are on cdimage.ubuntu.com, AMD64 on releases.ubuntu.com
1076
+ let isoFilename;
1077
+ if (arch === 'arm64') {
1078
+ isoFilename = `ubuntu-${version}-live-server-arm64${osName === 'noble' ? '+largemem' : ''}.iso`;
1079
+ } else {
1080
+ isoFilename = `ubuntu-${version}-live-server-amd64.iso`;
1081
+ }
1082
+ if (!isoUrl) isoUrl = `https://cdimage.ubuntu.com/releases/${majorVersion}/release/${isoFilename}`;
1083
+ else isoFilename = isoUrl.split('/').pop();
1084
+
1085
+ const isoPath = `/var/tmp/ubuntu-live-iso/${isoFilename}`;
1086
+ const extractDir = `${nfsHostPath}/casper`;
1087
+
1088
+ if (!fs.existsSync(isoPath)) {
1089
+ logger.info(`Downloading Ubuntu ${version} live ISO for ${arch}...`);
1090
+ logger.info(`URL: ${isoUrl}`);
1091
+ logger.info(`This may take a while (typically 1-2 GB)...`);
1092
+ shellExec(`wget --progress=bar:force -O ${isoPath} "${isoUrl}"`, { silent: false });
1093
+ // Verify download by checking file existence and size (not exit code, which can be unreliable)
1094
+ if (!fs.existsSync(isoPath)) {
1095
+ throw new Error(`Failed to download ISO from ${isoUrl} - file not created`);
1096
+ }
1097
+ const stats = fs.statSync(isoPath);
1098
+ if (stats.size < 100 * 1024 * 1024) {
1099
+ shellExec(`rm -f ${isoPath}`);
1100
+ throw new Error(`Downloaded ISO is too small (${stats.size} bytes), download may have failed`);
1101
+ }
1102
+ logger.info(`Downloaded ISO to ${isoPath} (${(stats.size / 1024 / 1024 / 1024).toFixed(2)} GB)`);
1103
+ }
1104
+
1105
+ // Mount ISO and extract casper files
1106
+ const mountPoint = `${nfsHostPath}/mnt-${osName}-${arch}`;
1107
+ shellExec(`mkdir -p ${mountPoint}`);
1108
+
1109
+ // Ensure mount point is not already mounted
1110
+ shellExec(`sudo umount ${mountPoint} 2>/dev/null || true`, { silent: true });
1111
+
1112
+ try {
1113
+ // Mount the ISO
1114
+ shellExec(`sudo mount -o loop,ro ${isoPath} ${mountPoint}`, { silent: false });
1115
+ // Verify mount succeeded by checking if casper directory exists
1116
+ if (!fs.existsSync(`${mountPoint}/casper`)) {
1117
+ throw new Error(`Failed to mount ISO or casper directory not found: ${isoPath}`);
1118
+ }
1119
+ logger.info(`Mounted ISO at ${mountPoint}`);
1120
+
1121
+ // List casper directory to see what's available
1122
+ logger.info(`Checking casper directory contents...`);
1123
+ shellExec(`ls -la ${mountPoint}/casper/ 2>/dev/null || echo "casper directory not found"`, { silent: false });
1124
+
1125
+ // Extract casper files
1126
+ shellExec(`sudo cp -a ${mountPoint}/casper/* ${extractDir}/`);
1127
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${extractDir}`);
1128
+ logger.info(`Extracted casper files from ISO`);
1129
+
1130
+ // Rename kernel and initrd to standard names if needed
1131
+ if (!fs.existsSync(`${extractDir}/vmlinuz`)) {
1132
+ const vmlinuz = shellExec(`ls ${extractDir}/vmlinuz* | head -1`, {
1133
+ silent: true,
1134
+ stdout: true,
1135
+ }).stdout.trim();
1136
+ if (vmlinuz) shellExec(`mv ${vmlinuz} ${extractDir}/vmlinuz`);
1137
+ }
1138
+ if (!fs.existsSync(`${extractDir}/initrd`)) {
1139
+ const initrd = shellExec(`ls ${extractDir}/initrd* | head -1`, { silent: true, stdout: true }).stdout.trim();
1140
+ if (initrd) shellExec(`mv ${initrd} ${extractDir}/initrd`);
1141
+ }
1142
+ } finally {
1143
+ shellExec(`ls -la ${mountPoint}/`);
1144
+
1145
+ // Unmount ISO
1146
+ shellExec(`sudo umount ${mountPoint}`, { silent: true });
1147
+ logger.info(`Unmounted ISO`);
1148
+ // Clean up temporary mount point
1149
+ shellExec(`rmdir ${mountPoint}`, { silent: true });
1150
+ }
1151
+
1152
+ return {
1153
+ 'vmlinuz-efi': `${extractDir}/vmlinuz`,
1154
+ 'initrd.img': `${extractDir}/initrd`,
1155
+ isoUrl,
1156
+ };
1157
+ },
1158
+
1159
+ /**
1160
+ * @method machineFactory
1161
+ * @description Creates a new machine in MAAS with specified options.
1162
+ * @param {object} options - Options for creating the machine.
1163
+ * @param {string} options.macAddress - The MAC address of the machine.
1164
+ * @param {string} options.hostname - The hostname for the machine.
1165
+ * @param {string} options.ipAddress - The IP address for the machine.
1166
+ * @param {string} options.powerType - The power type for the machine (default is 'manual').
1167
+ * @param {object} options.maas - Additional MAAS-specific options.
1168
+ * @returns {object} An object containing the created machine details.
1169
+ * @memberof UnderpostBaremetal
1170
+ */
1171
+ machineFactory(
1172
+ options = {
1173
+ macAddress: '',
1174
+ hostname: '',
1175
+ ipAddress: '',
1176
+ powerType: 'manual',
1177
+ maas: {},
1178
+ },
1179
+ ) {
1180
+ if (!options.powerType) options.powerType = 'manual';
1181
+ const maas = options.maas || {};
1182
+ const payload = {
1183
+ architecture: (maas.commissioning?.architecture || 'arm64/generic').match('arm')
1184
+ ? 'arm64/generic'
1185
+ : 'amd64/generic',
1186
+ mac_address: options.macAddress,
1187
+ mac_addresses: options.macAddress,
1188
+ hostname: options.hostname,
1189
+ power_type: options.powerType,
1190
+ ip: options.ipAddress,
1191
+ };
1192
+ logger.info('Creating MAAS machine', payload);
1193
+ const machine = shellExec(
1194
+ `maas ${process.env.MAAS_ADMIN_USERNAME} machines create ${Object.keys(payload)
1195
+ .map((k) => `${k}="${payload[k]}"`)
1196
+ .join(' ')}`,
595
1197
  {
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
- }),
1198
+ silent: true,
1199
+ stdout: true,
1200
+ },
1201
+ );
1202
+ // console.log(machine);
1203
+ try {
1204
+ return { machine: JSON.parse(machine) };
1205
+ } catch (error) {
1206
+ console.log(error);
1207
+ logger.error(error);
1208
+ throw new Error(`Failed to create MAAS machine. Output:\n${machine}`);
1209
+ }
1210
+ },
1211
+
1212
+ /**
1213
+ * @method kernelFactory
1214
+ * @description Retrieves kernel, initrd, and root filesystem paths from a MAAS boot resource.
1215
+ * @param {object} params - Parameters for the method.
1216
+ * @param {object} params.resource - The MAAS boot resource object.
1217
+ * @param {boolean} params.useLiveIso - Whether to use Ubuntu live ISO instead of MAAS boot resources.
1218
+ * @param {string} params.nfsHostPath - The NFS host path for storing extracted files.
1219
+ * @returns {object} An object containing paths to the kernel, initrd, and root filesystem.
1220
+ * @memberof UnderpostBaremetal
1221
+ */
1222
+ kernelFactory({ resource, type, nfsHostPath, isoUrl }) {
1223
+ // For disk-based commissioning (casper), use Ubuntu live ISO files
1224
+ if (type === 'iso-ram' || type === 'iso-nfs') {
1225
+ logger.info('Using Ubuntu live ISO for casper boot (disk-based commissioning)');
1226
+ const arch = resource.architecture.split('/')[0];
1227
+ const kernelFilesPaths = UnderpostBaremetal.API.downloadUbuntuLiveISO({
1228
+ resource,
1229
+ architecture: arch,
1230
+ nfsHostPath,
1231
+ isoUrl,
1232
+ });
1233
+ const resourcesPath = `/var/snap/maas/common/maas/image-storage/bootloaders/uefi/${arch}`;
1234
+ return { kernelFilesPaths, resourcesPath };
1235
+ }
1236
+
1237
+ const resourceData = JSON.parse(
1238
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} boot-resource read ${resource.id}`, {
1239
+ stdout: true,
1240
+ silent: true,
1241
+ disableLog: true,
1242
+ }),
1243
+ );
1244
+ let kernelFilesPaths = {};
1245
+ const bootFiles = resourceData.sets[Object.keys(resourceData.sets)[0]].files;
1246
+ const arch = resource.architecture.split('/')[0];
1247
+ const resourcesPath = `/var/snap/maas/common/maas/image-storage/bootloaders/uefi/${arch}`;
1248
+ const kernelPath = `/var/snap/maas/common/maas/image-storage`;
1249
+
1250
+ logger.info('Available boot files', Object.keys(bootFiles));
1251
+ logger.info('Boot files info', {
1252
+ id: resourceData.id,
1253
+ type: resourceData.type,
1254
+ name: resourceData.name,
1255
+ architecture: resourceData.architecture,
1256
+ bootFiles,
1257
+ arch,
1258
+ resourcesPath,
1259
+ kernelPath,
1260
+ });
1261
+
1262
+ // Try standard synced image structure (Ubuntu, CentOS from MAAS repos)
1263
+ const _suffix = resource.architecture.match('xgene') ? '.xgene' : '';
1264
+ if (bootFiles['boot-kernel' + _suffix] && bootFiles['boot-initrd' + _suffix] && bootFiles['squashfs']) {
1265
+ kernelFilesPaths = {
1266
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel' + _suffix].filename_on_disk}`,
1267
+ 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd' + _suffix].filename_on_disk}`,
1268
+ squashfs: `${kernelPath}/${bootFiles['squashfs'].filename_on_disk}`,
1269
+ };
1270
+ }
1271
+ // Try uploaded image structure (Packer-built images, custom uploads)
1272
+ else if (bootFiles['boot-kernel'] && bootFiles['boot-initrd'] && bootFiles['root-tgz']) {
1273
+ kernelFilesPaths = {
1274
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel'].filename_on_disk}`,
1275
+ 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd'].filename_on_disk}`,
1276
+ squashfs: `${kernelPath}/${bootFiles['root-tgz'].filename_on_disk}`,
1277
+ };
1278
+ }
1279
+ // Try alternative uploaded structure with root-image-xz
1280
+ else if (bootFiles['boot-kernel'] && bootFiles['boot-initrd'] && bootFiles['root-image-xz']) {
1281
+ kernelFilesPaths = {
1282
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles['boot-kernel'].filename_on_disk}`,
1283
+ 'initrd.img': `${kernelPath}/${bootFiles['boot-initrd'].filename_on_disk}`,
1284
+ squashfs: `${kernelPath}/${bootFiles['root-image-xz'].filename_on_disk}`,
1285
+ };
1286
+ }
1287
+ // Fallback: try to find any kernel, initrd, and root image
1288
+ else {
1289
+ logger.warn('Non-standard boot file structure detected. Available files', Object.keys(bootFiles));
1290
+
1291
+ const rootArchiveKey = Object.keys(bootFiles).find(
1292
+ (k) => k.includes('root') && (k.includes('tgz') || k.includes('tar.gz')),
1293
+ );
1294
+ const explicitKernel = Object.keys(bootFiles).find((k) => k.includes('kernel'));
1295
+ const explicitInitrd = Object.keys(bootFiles).find((k) => k.includes('initrd'));
1296
+
1297
+ if (rootArchiveKey && (!explicitKernel || !explicitInitrd)) {
1298
+ logger.info(`Root archive found (${rootArchiveKey}) and missing kernel/initrd. Attempting to extract.`);
1299
+ const rootArchivePath = `${kernelPath}/${bootFiles[rootArchiveKey].filename_on_disk}`;
1300
+ const tempExtractDir = `/tmp/maas-extract-${resource.id}`;
1301
+ shellExec(`mkdir -p ${tempExtractDir}`);
1302
+
1303
+ // List files in archive to find kernel and initrd
1304
+ const tarList = shellExec(`tar -tf ${rootArchivePath}`, { silent: true }).stdout.split('\n');
1305
+
1306
+ // Look for boot/vmlinuz* and boot/initrd* (handling potential leading ./)
1307
+ // Skip rescue, kdump, and other special images
1308
+ const vmlinuzPaths = tarList.filter(
1309
+ (f) => f.match(/(\.\/)?boot\/vmlinuz-[0-9]/) && !f.includes('rescue') && !f.includes('kdump'),
602
1310
  );
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(' ');
667
-
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}`);
1311
+ const initrdPaths = tarList.filter(
1312
+ (f) =>
1313
+ f.match(/(\.\/)?boot\/(initrd|initramfs)-[0-9]/) &&
1314
+ !f.includes('rescue') &&
1315
+ !f.includes('kdump') &&
1316
+ f.includes('.img'),
1317
+ );
1318
+
1319
+ logger.info(`Found kernel candidates:`, { vmlinuzPaths, initrdPaths });
1320
+
1321
+ // Try to match kernel and initrd by version number
1322
+ let vmlinuzPath = null;
1323
+ let initrdPath = null;
1324
+
1325
+ if (vmlinuzPaths.length > 0 && initrdPaths.length > 0) {
1326
+ // Extract version from kernel filename (e.g., "5.14.0-611.11.1.el9_7.aarch64")
1327
+ for (const kernelPath of vmlinuzPaths.sort().reverse()) {
1328
+ const kernelVersion = kernelPath.match(/vmlinuz-(.+)$/)?.[1];
1329
+ if (kernelVersion) {
1330
+ // Look for matching initrd
1331
+ const matchingInitrd = initrdPaths.find((p) => p.includes(kernelVersion));
1332
+ if (matchingInitrd) {
1333
+ vmlinuzPath = kernelPath;
1334
+ initrdPath = matchingInitrd;
1335
+ logger.info(`Matched kernel and initrd by version: ${kernelVersion}`);
1336
+ break;
1337
+ }
1338
+ }
1339
+ }
671
1340
  }
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}`);
1341
+
1342
+ // Fallback: use newest versions if no match found
1343
+ if (!vmlinuzPath && vmlinuzPaths.length > 0) {
1344
+ vmlinuzPath = vmlinuzPaths.sort().pop();
1345
+ }
1346
+ if (!initrdPath && initrdPaths.length > 0) {
1347
+ initrdPath = initrdPaths.sort().pop();
675
1348
  }
676
1349
 
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
- }
1350
+ logger.info(`Selected kernel: ${vmlinuzPath}, initrd: ${initrdPath}`);
693
1351
 
694
- `,
695
- 'utf8',
696
- );
1352
+ if (vmlinuzPath && initrdPath) {
1353
+ // Extract specific files
1354
+ // Extract all files in boot/ to ensure symlinks resolve
1355
+ shellExec(`tar -xf ${rootArchivePath} -C ${tempExtractDir} --wildcards '*boot/*'`);
1356
+
1357
+ kernelFilesPaths = {
1358
+ 'vmlinuz-efi': `${tempExtractDir}/${vmlinuzPath}`,
1359
+ 'initrd.img': `${tempExtractDir}/${initrdPath}`,
1360
+ squashfs: rootArchivePath,
1361
+ };
1362
+ logger.info('Extracted kernel and initrd from root archive.');
1363
+ } else {
1364
+ logger.error(
1365
+ `Failed to find kernel/initrd in archive. Contents of boot/ directory:`,
1366
+ tarList.filter((f) => f.includes('boot/')),
1367
+ );
1368
+ throw new Error(`Could not find kernel or initrd in ${rootArchiveKey}`);
1369
+ }
1370
+ } else {
1371
+ const kernelFile = Object.keys(bootFiles).find((k) => k.includes('kernel')) || Object.keys(bootFiles)[0];
1372
+ const initrdFile = Object.keys(bootFiles).find((k) => k.includes('initrd')) || Object.keys(bootFiles)[1];
1373
+ const rootFile =
1374
+ Object.keys(bootFiles).find(
1375
+ (k) => k.includes('root') || k.includes('squashfs') || k.includes('tgz') || k.includes('xz'),
1376
+ ) || Object.keys(bootFiles)[2];
1377
+
1378
+ if (kernelFile && initrdFile && rootFile) {
1379
+ kernelFilesPaths = {
1380
+ 'vmlinuz-efi': `${kernelPath}/${bootFiles[kernelFile].filename_on_disk}`,
1381
+ 'initrd.img': `${kernelPath}/${bootFiles[initrdFile].filename_on_disk}`,
1382
+ squashfs: `${kernelPath}/${bootFiles[rootFile].filename_on_disk}`,
1383
+ };
1384
+ logger.info('Using detected files', { kernel: kernelFile, initrd: initrdFile, root: rootFile });
1385
+ } else {
1386
+ throw new Error(`Cannot identify boot files. Available: ${Object.keys(bootFiles).join(', ')}`);
1387
+ }
697
1388
  }
1389
+ }
1390
+ return {
1391
+ resource,
1392
+ bootFiles,
1393
+ arch,
1394
+ resourcesPath,
1395
+ kernelPath,
1396
+ resourceData,
1397
+ kernelFilesPaths,
1398
+ };
1399
+ },
1400
+
1401
+ /**
1402
+ * @method writeGrubConfigToFile
1403
+ * @description Writes the GRUB configuration content to the grub.cfg file in the TFTP root.
1404
+ * @param {object} params - Parameters for the method.
1405
+ * @param {string} params.grubCfgSrc - The GRUB configuration content to write.
1406
+ * @memberof UnderpostBaremetal
1407
+ * @returns {void}
1408
+ */
1409
+ writeGrubConfigToFile({ grubCfgSrc = '' }) {
1410
+ shellExec(`mkdir -p ${process.env.TFTP_ROOT}/grub`, {
1411
+ disableLog: true,
1412
+ });
1413
+ return fs.writeFileSync(`${process.env.TFTP_ROOT}/grub/grub.cfg`, grubCfgSrc, 'utf8');
1414
+ },
1415
+
1416
+ /**
1417
+ * @method getGrubConfigFromFile
1418
+ * @description Reads the GRUB configuration content from the grub.cfg file in the TFTP root.
1419
+ * @memberof UnderpostBaremetal
1420
+ * @returns {string} The GRUB configuration content.
1421
+ */
1422
+ getGrubConfigFromFile() {
1423
+ const grubCfgPath = `${process.env.TFTP_ROOT}/grub/grub.cfg`;
1424
+ const grubCfgSrc = fs.readFileSync(grubCfgPath, 'utf8');
1425
+ return { grubCfgPath, grubCfgSrc };
1426
+ },
1427
+
1428
+ /**
1429
+ * @method removeDiscoveredMachines
1430
+ * @description Removes all machines in the 'discovered' status from MAAS.
1431
+ * @memberof UnderpostBaremetal
1432
+ * @returns {void}
1433
+ */
1434
+ removeDiscoveredMachines() {
1435
+ logger.info('Removing all discovered machines from MAAS...');
1436
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries clear all=true`);
1437
+ },
698
1438
 
1439
+ /**
1440
+ * @method efiGrubModulesFactory
1441
+ * @description Copies the appropriate EFI GRUB modules to the TFTP root based on the image architecture.
1442
+ * @param {object} options - Options for determining which GRUB modules to copy.
1443
+ * @param {object} options.image - Image configuration object.
1444
+ * @param {string} options.image.architecture - The architecture of the image ('amd64' or 'arm64').
1445
+ * @memberof UnderpostBaremetal
1446
+ * @returns {void}
1447
+ */
1448
+ efiGrubModulesFactory(options = { image: { architecture: 'amd64' } }) {
1449
+ if (options.image.architecture.match('arm64')) {
699
1450
  // Copy ARM64 EFI GRUB modules.
700
1451
  const arm64EfiPath = `${process.env.TFTP_ROOT}/grub/arm64-efi`;
701
1452
  if (fs.existsSync(arm64EfiPath)) shellExec(`sudo rm -rf ${arm64EfiPath}`);
702
1453
  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}`);
1454
+ } else {
1455
+ // Copy AMD64 EFI GRUB modules.
1456
+ const amd64EfiPath = `${process.env.TFTP_ROOT}/grub/x86_64-efi`;
1457
+ if (fs.existsSync(amd64EfiPath)) shellExec(`sudo rm -rf ${amd64EfiPath}`);
1458
+ shellExec(`sudo cp -a /usr/lib/grub/x86_64-efi ${amd64EfiPath}`);
707
1459
  }
1460
+ },
708
1461
 
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;
1462
+ /**
1463
+ * @method ipxeEmbeddedScriptFactory
1464
+ * @description Generates the embedded iPXE boot script that performs DHCP and chains to the main script.
1465
+ * This script is embedded into the iPXE binary or loaded first by GRUB.
1466
+ * Supports MAC address spoofing for baremetal commissioning workflows.
1467
+ * @param {object} params - The parameters for generating the script.
1468
+ * @param {string} params.tftpServer - The IP address of the TFTP server.
1469
+ * @param {string} params.scriptPath - The path to the main iPXE script on TFTP server.
1470
+ * @param {string} [params.macAddress] - Optional MAC address to spoof for MAAS registration.
1471
+ * @returns {string} The embedded iPXE script content.
1472
+ * @memberof UnderpostBaremetal
1473
+ */
1474
+ ipxeEmbeddedScriptFactory({ tftpServer, scriptPath, macAddress = null }) {
1475
+ const macSpoofingBlock =
1476
+ macAddress && macAddress !== '00:00:00:00:00:00'
1477
+ ? `
1478
+ # MAC Address Information
1479
+ echo ========================================
1480
+ echo Target MAC for MAAS: ${macAddress}
1481
+ echo Hardware MAC: \${net0/mac}
1482
+ echo ========================================
1483
+ echo NOTE: iPXE MAC spoofing does NOT persist to kernel
1484
+ echo The kernel will receive MAC via ifname= parameter
1485
+ echo ========================================
1486
+ `
1487
+ : `
1488
+ # Using hardware MAC address
1489
+ echo ========================================
1490
+ echo Using device hardware MAC address
1491
+ echo Hardware MAC: \${net0/mac}
1492
+ echo MAC spoofing disabled - device uses actual MAC
1493
+ echo ========================================
1494
+ `;
1495
+
1496
+ return `#!ipxe
1497
+ # Embedded iPXE Boot Script
1498
+ # This script performs DHCP configuration and chains to the main boot script
1499
+
1500
+ echo ========================================
1501
+ echo iPXE Embedded Boot Loader
1502
+ echo ========================================
1503
+ echo TFTP Server: ${tftpServer}
1504
+ echo Script Path: ${scriptPath}
1505
+ echo ========================================
1506
+
1507
+ ${macSpoofingBlock}
1508
+
1509
+ # Show network interface info before DHCP
1510
+ echo Network Interface Info (before DHCP):
1511
+ echo Interface: net0
1512
+ echo MAC: \${net0/mac}
1513
+ ifstat
1514
+
1515
+ # Perform DHCP to get network configuration
1516
+ echo ========================================
1517
+ echo Performing DHCP configuration...
1518
+ echo ========================================
1519
+ dhcp net0 || goto dhcp_retry
1520
+
1521
+ echo DHCP configuration successful
1522
+ echo IP Address: \${net0/ip}
1523
+ echo Netmask: \${net0/netmask}
1524
+ echo Gateway: \${net0/gateway}
1525
+ echo DNS: \${net0/dns}
1526
+ echo TFTP Server: \${next-server}
1527
+ echo MAC used by DHCP: \${net0/mac}
1528
+
1529
+ # Chain to the main iPXE script
1530
+ echo ========================================
1531
+ echo Chainloading main boot script...
1532
+ echo Script: tftp://${tftpServer}${scriptPath}
1533
+ echo ========================================
1534
+ chain tftp://${tftpServer}${scriptPath} || goto chain_failed
1535
+
1536
+ :dhcp_retry
1537
+ echo DHCP failed, retrying in 5 seconds...
1538
+ sleep 5
1539
+ dhcp net0 || goto dhcp_retry
1540
+ goto dhcp_success
1541
+
1542
+ :dhcp_success
1543
+ echo DHCP retry successful
1544
+ echo IP Address: \${net0/ip}
1545
+ echo MAC: \${net0/mac}
1546
+ chain tftp://${tftpServer}${scriptPath} || goto chain_failed
1547
+
1548
+ :chain_failed
1549
+ echo ========================================
1550
+ echo ERROR: Failed to chain to main script
1551
+ echo TFTP Server: ${tftpServer}
1552
+ echo Script Path: ${scriptPath}
1553
+ echo ========================================
1554
+ echo Retrying in 10 seconds...
1555
+ sleep 10
1556
+ chain tftp://${tftpServer}${scriptPath} || goto shell_debug
1557
+
1558
+ :shell_debug
1559
+ echo Dropping to iPXE shell for manual debugging
1560
+ echo Try: chain tftp://${tftpServer}${scriptPath}
1561
+ shell
1562
+ `;
1563
+ },
713
1564
 
714
- // Build cloud-init tools.
715
- UnderpostCloudInit.API.buildTools({
716
- workflowId,
717
- nfsHostPath,
718
- hostname,
719
- callbackMetaData,
720
- dev: options.dev,
721
- });
1565
+ /**
1566
+ * @method ipxeScriptFactory
1567
+ * @description Generates the iPXE script content for stable identity.
1568
+ * This iPXE script uses directly boots kernel/initrd via TFTP.
1569
+ * @param {object} params - The parameters for generating the script.
1570
+ * @param {string} params.maasIp - The IP address of the MAAS server.
1571
+ * @param {string} [params.macAddress] - The MAC address registered in MAAS (for display only).
1572
+ * @param {string} params.architecture - The architecture (arm64/amd64).
1573
+ * @param {string} params.tftpPrefix - The TFTP prefix path (e.g., 'rpi4mb').
1574
+ * @param {string} params.kernelCmd - The kernel command line parameters.
1575
+ * @returns {string} The iPXE script content.
1576
+ * @memberof UnderpostBaremetal
1577
+ */
1578
+ ipxeScriptFactory({ maasIp, macAddress, architecture, tftpPrefix, kernelCmd }) {
1579
+ const macInfo =
1580
+ macAddress && macAddress !== '00:00:00:00:00:00'
1581
+ ? `echo Registered MAC: ${macAddress}`
1582
+ : `echo Using hardware MAC address`;
1583
+
1584
+ // Construct the full TFTP paths for kernel and initrd
1585
+ const kernelPath = `${tftpPrefix}/pxe/vmlinuz-efi`;
1586
+ const initrdPath = `${tftpPrefix}/pxe/initrd.img`;
1587
+ const grubBootloader = architecture === 'arm64' ? 'grubaa64.efi' : 'grubx64.efi';
1588
+ const grubPath = `${tftpPrefix}/pxe/${grubBootloader}`;
1589
+
1590
+ return `#!ipxe
1591
+ echo ========================================
1592
+ echo iPXE Network Boot
1593
+ echo ========================================
1594
+ echo MAAS Server: ${maasIp}
1595
+ echo Architecture: ${architecture}
1596
+ ${macInfo}
1597
+ echo ========================================
1598
+
1599
+ # Show current network configuration
1600
+ echo Current Network Configuration:
1601
+ ifstat
1602
+
1603
+ # Display MAC address information
1604
+ echo MAC Address: \${net0/mac}
1605
+ echo IP Address: \${net0/ip}
1606
+ echo Gateway: \${net0/gateway}
1607
+ echo DNS: \${net0/dns}
1608
+ ${macAddress && macAddress !== '00:00:00:00:00:00' ? `echo Target MAC for kernel: ${macAddress}` : ''}
1609
+
1610
+ # Direct kernel/initrd boot via TFTP
1611
+ # Modern simplified approach: Direct kernel/initrd boot via TFTP
1612
+ echo ========================================
1613
+ echo Loading kernel and initrd via TFTP...
1614
+ echo Kernel: tftp://${maasIp}/${kernelPath}
1615
+ echo Initrd: tftp://${maasIp}/${initrdPath}
1616
+ ${macAddress && macAddress !== '00:00:00:00:00:00' ? `echo Kernel will use MAC: ${macAddress} (via ifname parameter)` : 'echo Kernel will use hardware MAC'}
1617
+ echo ========================================
1618
+
1619
+ # Load kernel via TFTP
1620
+ kernel tftp://${maasIp}/${kernelPath} ${kernelCmd || 'console=ttyS0,115200'} || goto grub_fallback
1621
+ echo Kernel loaded successfully
1622
+
1623
+ # Load initrd via TFTP
1624
+ initrd tftp://${maasIp}/${initrdPath} || goto grub_fallback
1625
+ echo Initrd loaded successfully
1626
+
1627
+ # Boot the kernel
1628
+ echo Booting kernel...
1629
+ boot
1630
+
1631
+ :grub_fallback
1632
+ echo ========================================
1633
+ echo Direct kernel boot failed, falling back to GRUB chainload...
1634
+ echo TFTP Path: tftp://${maasIp}/${grubPath}
1635
+ echo ========================================
1636
+
1637
+ # Fallback: Chain to GRUB via TFTP (avoids malformed HTTP bootloader issues)
1638
+ chain tftp://${maasIp}/${grubPath} || goto http_fallback
1639
+
1640
+ :http_fallback
1641
+ echo TFTP GRUB chainload failed, trying HTTP fallback...
1642
+ echo ========================================
1643
+
1644
+ # Fallback: Try MAAS HTTP bootloader (may have certificate issues)
1645
+ set boot-url http://${maasIp}:5248/images/bootloader
1646
+ echo Boot URL: \${boot-url}
1647
+ chain \${boot-url}/uefi/${architecture}/${grubBootloader === 'grubaa64.efi' ? 'bootaa64.efi' : 'bootx64.efi'} || goto error
1648
+
1649
+ :error
1650
+ echo ========================================
1651
+ echo ERROR: All boot methods failed
1652
+ echo ========================================
1653
+ echo MAAS IP: ${maasIp}
1654
+ echo Architecture: ${architecture}
1655
+ echo MAC: \${net0/mac}
1656
+ echo IP: \${net0/ip}
1657
+ echo ========================================
1658
+ echo Retrying GRUB TFTP in 10 seconds...
1659
+ sleep 10
1660
+ chain tftp://${maasIp}/${grubPath} || goto shell_debug
1661
+
1662
+ :shell_debug
1663
+ echo Dropping to iPXE shell for manual intervention
1664
+ shell
1665
+ `;
1666
+ },
722
1667
 
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
- ],
742
- });
1668
+ /**
1669
+ * @method grubFactory
1670
+ * @description Generates the GRUB configuration file content.
1671
+ * @param {object} params - The parameters for generating the configuration.
1672
+ * @param {string} params.menuentryStr - The title of the menu entry.
1673
+ * @param {string} params.kernelPath - The path to the kernel file (relative to TFTP root).
1674
+ * @param {string} params.initrdPath - The path to the initrd file (relative to TFTP root).
1675
+ * @param {string} params.cmd - The kernel command line parameters.
1676
+ * @param {string} params.tftpIp - The IP address of the TFTP server.
1677
+ * @param {boolean} [params.ipxe] - Flag to enable iPXE chainloading.
1678
+ * @param {string} [params.ipxePath] - The path to the iPXE binary.
1679
+ * @returns {object} An object containing 'grubCfgSrc' the GRUB configuration source string.
1680
+ * @memberof UnderpostBaremetal
1681
+ */
1682
+ grubFactory({ menuentryStr, kernelPath, initrdPath, cmd, tftpIp, ipxe, ipxePath }) {
1683
+ if (ipxe) {
1684
+ return {
1685
+ grubCfgSrc: `
1686
+ set default="0"
1687
+ set timeout=10
1688
+ insmod tftp
1689
+ set root=(tftp,${tftpIp})
1690
+
1691
+ menuentry 'iPXE ${menuentryStr}' {
1692
+ echo "Loading iPXE with embedded script..."
1693
+ echo "[INFO] TFTP Server: ${tftpIp}"
1694
+ echo "[INFO] iPXE Binary: ${ipxePath}"
1695
+ echo "[INFO] iPXE will execute embedded script (dhcp + chain)"
1696
+ chainloader ${ipxePath}
1697
+ boot
1698
+ }
1699
+ `,
1700
+ };
1701
+ }
1702
+ return {
1703
+ grubCfgSrc: `
1704
+ set default="0"
1705
+ set timeout=10
1706
+ insmod nfs
1707
+ insmod gzio
1708
+ insmod http
1709
+ insmod tftp
1710
+ set root=(tftp,${tftpIp})
1711
+
1712
+ menuentry '${menuentryStr}' {
1713
+ echo "${menuentryStr}"
1714
+ echo " ${Underpost.version}"
1715
+ echo "Date: ${new Date().toISOString()}"
1716
+ ${cmd.match('/MAAS/metadata/by-id/') ? `echo "System ID: ${cmd.split('/MAAS/metadata/by-id/')[1].split('/')[0]}"` : ''}
1717
+ echo "TFTP server: ${tftpIp}"
1718
+ echo "Kernel path: ${kernelPath}"
1719
+ echo "Initrd path: ${initrdPath}"
1720
+ echo "Starting boot process..."
1721
+ echo "Loading kernel..."
1722
+ linux /${kernelPath} ${cmd}
1723
+ echo "Loading initrd..."
1724
+ initrd /${initrdPath}
1725
+ echo "Booting..."
1726
+ boot
1727
+ }
1728
+ `,
1729
+ };
1730
+ },
743
1731
 
744
- if (options.cloudInitUpdate === true) return;
1732
+ /**
1733
+ * @method httpBootstrapServerRunnerFactory
1734
+ * @description Starts a simple HTTP server to serve boot files for network booting.
1735
+ * @param {object} options - Options for the HTTP server.
1736
+ * @param {string} hostname - The hostname of the client machine.
1737
+ * @param {string} options.bootstrapHttpServerPath - The path to serve files from (default: './public/localhost').
1738
+ * @param {number} options.bootstrapHttpServerPort - The port on which to start the HTTP server (default: 8888).
1739
+ * @memberof UnderpostBaremetal
1740
+ * @returns {void}
1741
+ */
1742
+ httpBootstrapServerRunnerFactory(
1743
+ options = { hostname: 'localhost', bootstrapHttpServerPath: './public/localhost', bootstrapHttpServerPort: 8888 },
1744
+ ) {
1745
+ const port = options.bootstrapHttpServerPort || 8888;
1746
+ const bootstrapHttpServerPath = options.bootstrapHttpServerPath || './public/localhost';
1747
+ const hostname = options.hostname || 'localhost';
745
1748
 
746
- // Apply NAT iptables rules.
747
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
1749
+ shellExec(`mkdir -p ${bootstrapHttpServerPath}/${hostname}/cloud-init`);
748
1750
 
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.
1751
+ // Kill any existing HTTP server
1752
+ shellExec(`sudo pkill -f 'python3 -m http.server ${port}' || true`, { silent: true });
754
1753
 
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
- });
1754
+ shellExec(
1755
+ `cd ${bootstrapHttpServerPath} && nohup python3 -m http.server ${port} --bind 0.0.0.0 > /tmp/http-boot-server.log 2>&1 &`,
1756
+ { silent: true, async: true },
1757
+ );
773
1758
 
774
- // Remove existing machines from MAAS.
775
- machines = UnderpostBaremetal.API.removeMachines({ machines });
1759
+ // Configure iptables to allow incoming LAN connections
1760
+ shellExec(
1761
+ `sudo iptables -I INPUT 1 -p tcp -s 192.168.1.0/24 --dport ${port} -m conntrack --ctstate NEW -j ACCEPT`,
1762
+ );
1763
+ // Option for any host:
1764
+ // sudo iptables -I INPUT 1 -p tcp --dport ${port} -m conntrack --ctstate NEW -j ACCEPT
1765
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${bootstrapHttpServerPath}`);
1766
+ shellExec(`sudo sudo chmod 755 ${bootstrapHttpServerPath}`);
776
1767
 
777
- // Monitor commissioning process.
778
- UnderpostBaremetal.API.commissionMonitor({
779
- macAddress,
780
- nfsHostPath,
781
- underpostRoot,
782
- hostname,
783
- maas,
784
- networkInterfaceName,
785
- });
1768
+ logger.info(`Started Bootstrap Http Server on port ${port}`);
1769
+ },
1770
+
1771
+ /**
1772
+ * @method updateKernelFiles
1773
+ * @description Copies EFI bootloaders, kernel, and initrd images to the TFTP root path.
1774
+ * It also handles decompression of the kernel if necessary for ARM64 compatibility.
1775
+ * @param {object} params - The parameters for the function.
1776
+ * @param {object} params.commissioningImage - The commissioning image configuration.
1777
+ * @param {string} params.resourcesPath - The path where resources are located.
1778
+ * @param {string} params.tftpRootPath - The TFTP root path.
1779
+ * @param {object} params.kernelFilesPaths - Paths to kernel files.
1780
+ * @memberof UnderpostBaremetal
1781
+ * @returns {void}
1782
+ */
1783
+ updateKernelFiles({ commissioningImage, resourcesPath, tftpRootPath, kernelFilesPaths }) {
1784
+ // Copy EFI bootloaders to TFTP path.
1785
+ const efiFiles = commissioningImage.architecture.match('arm64')
1786
+ ? ['bootaa64.efi', 'grubaa64.efi']
1787
+ : ['bootx64.efi', 'grubx64.efi'];
1788
+ for (const file of efiFiles) {
1789
+ shellExec(`sudo cp -a ${resourcesPath}/${file} ${tftpRootPath}/pxe/${file}`);
1790
+ }
1791
+ // Copy kernel and initrd images to TFTP path.
1792
+ for (const file of Object.keys(kernelFilesPaths)) {
1793
+ if (file == 'isoUrl') continue; // Skip URL entries
1794
+ shellExec(`sudo cp -a ${kernelFilesPaths[file]} ${tftpRootPath}/pxe/${file}`);
1795
+ // If the file is a kernel (vmlinuz-efi) and is gzipped, unzip it for GRUB compatibility on ARM64.
1796
+ // GRUB on ARM64 often crashes with synchronous exception (0x200) if handling large compressed kernels directly.
1797
+ if (file === 'vmlinuz-efi') {
1798
+ const kernelDest = `${tftpRootPath}/pxe/${file}`;
1799
+ const fileType = shellExec(`file ${kernelDest}`, { silent: true }).stdout;
1800
+ if (fileType.includes('gzip compressed data')) {
1801
+ logger.info(`Decompressing kernel ${file} for ARM64 UEFI compatibility...`);
1802
+ shellExec(`sudo mv ${kernelDest} ${kernelDest}.gz`);
1803
+ shellExec(`sudo gunzip ${kernelDest}.gz`);
1804
+ }
1805
+ }
1806
+ }
1807
+ },
1808
+
1809
+ /**
1810
+ * @method kernelCmdBootParamsFactory
1811
+ * @description Constructs kernel command line parameters for NFS booting.
1812
+ * @param {object} options - Options for constructing the command line.
1813
+ * @param {string} options.ipClient - The IP address of the client.
1814
+ * @param {string} options.ipDhcpServer - The IP address of the DHCP server.
1815
+ * @param {string} options.ipFileServer - The IP address of the file server.
1816
+ * @param {string} options.ipConfig - The IP configuration method (e.g., 'dhcp').
1817
+ * @param {string} options.netmask - The network mask.
1818
+ * @param {string} options.hostname - The hostname of the client.
1819
+ * @param {string} options.dnsServer - The DNS server address.
1820
+ * @param {string} options.networkInterfaceName - The name of the network interface.
1821
+ * @param {string} options.fileSystemUrl - The URL of the root filesystem.
1822
+ * @param {number} options.bootstrapHttpServerPort - The port of the bootstrap HTTP server.
1823
+ * @param {string} options.type - The type of boot ('iso-ram', 'chroot', 'iso-nfs', etc.).
1824
+ * @param {string} options.macAddress - The MAC address of the client.
1825
+ * @param {boolean} options.cloudInit - Whether to include cloud-init parameters.
1826
+ * @param {object} options.machine - The machine object containing system_id.
1827
+ * @returns {object} An object containing the constructed command line string.
1828
+ * @memberof UnderpostBaremetal
1829
+ */
1830
+ kernelCmdBootParamsFactory(
1831
+ options = {
1832
+ ipClient: '',
1833
+ ipDhcpServer: '',
1834
+ ipFileServer: '',
1835
+ ipConfig: '',
1836
+ netmask: '',
1837
+ hostname: '',
1838
+ dnsServer: '',
1839
+ networkInterfaceName: '',
1840
+ fileSystemUrl: '',
1841
+ bootstrapHttpServerPort: 8888,
1842
+ type: '',
1843
+ macAddress: '',
1844
+ cloudInit: false,
1845
+ machine: { system_id: '' },
1846
+ },
1847
+ ) {
1848
+ // Construct kernel command line arguments for NFS boot.
1849
+ const {
1850
+ ipClient,
1851
+ ipDhcpServer,
1852
+ ipFileServer,
1853
+ ipConfig,
1854
+ netmask,
1855
+ hostname,
1856
+ dnsServer,
1857
+ networkInterfaceName,
1858
+ fileSystemUrl,
1859
+ bootstrapHttpServerPort,
1860
+ type,
1861
+ macAddress,
1862
+ cloudInit,
1863
+ } = options;
1864
+
1865
+ const ipParam = true
1866
+ ? `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
1867
+ `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`
1868
+ : 'ip=dhcp';
1869
+
1870
+ const nfsOptions = `${
1871
+ type === 'chroot'
1872
+ ? [
1873
+ 'tcp',
1874
+ 'nfsvers=3',
1875
+ 'nolock',
1876
+ 'vers=3',
1877
+ // 'protocol=tcp',
1878
+ // 'hard=true',
1879
+ 'port=2049',
1880
+ // 'sec=none',
1881
+ 'hard',
1882
+ 'intr',
1883
+ 'rsize=32768',
1884
+ 'wsize=32768',
1885
+ 'acregmin=0',
1886
+ 'acregmax=0',
1887
+ 'acdirmin=0',
1888
+ 'acdirmax=0',
1889
+ 'noac',
1890
+ // 'nodev',
1891
+ // 'nosuid',
1892
+ ]
1893
+ : []
1894
+ }`;
1895
+
1896
+ const nfsRootParam = `nfsroot=${ipFileServer}:${process.env.NFS_EXPORT_PATH}/${hostname}${nfsOptions ? `,${nfsOptions}` : ''}`;
1897
+
1898
+ const permissionsParams = [
1899
+ `rw`,
1900
+ // `ro`
1901
+ ];
1902
+
1903
+ const kernelParams = [
1904
+ ...permissionsParams,
1905
+ `ignore_uuid`,
1906
+ `rootwait`,
1907
+ `ipv6.disable=1`,
1908
+ `fixrtc`,
1909
+ // `console=serial0,115200`,
1910
+ // `console=tty1`,
1911
+ // `layerfs-path=filesystem.squashfs`,
1912
+ // `root=/dev/ram0`,
1913
+ // `toram`,
1914
+ 'nomodeset',
1915
+ `editable_rootfs=tmpfs`,
1916
+ `ramdisk_size=3550000`,
1917
+ // `root=/dev/sda1`, // rpi4 usb port unit
1918
+ 'apparmor=0', // Disable AppArmor security
1919
+ ...(networkInterfaceName === 'eth0'
1920
+ ? [
1921
+ 'net.ifnames=0', // Disable predictable network interface names
1922
+ 'biosdevname=0', // Disable BIOS device naming
1923
+ ]
1924
+ : []),
1925
+ ];
1926
+
1927
+ const performanceParams = [
1928
+ // --- Boot Automation & Stability ---
1929
+ 'auto=true', // Enable automated installation/configuration
1930
+ 'noeject', // Do not attempt to eject boot media on reboot
1931
+ `casper-getty`, // Enable console login for live sessions
1932
+ 'nowatchdog', // Disable watchdog timers to prevent unexpected reboots
1933
+ 'noprompt', // Don't wait for "Press Enter" during boot/reboot
1934
+
1935
+ // --- CPU & System Performance ---
1936
+ 'mitigations=off', // Disable CPU security mitigations for maximum speed
1937
+ 'clocksource=tsc', // Use fastest available hardware clock
1938
+ 'tsc=reliable', // Trust CPU clock without extra verification
1939
+ 'hpet=disable', // Disable legacy slow timer
1940
+ 'nohz=on', // Reduce overhead by disabling timer ticks on idle CPUs
1941
+
1942
+ // --- Memory & Hardware Optimization ---
1943
+ 'cma=40M', // Reserve contiguous RAM for RPi hardware/video
1944
+ 'zswap.enabled=1', // Use compressed RAM cache (vital for NFS/SD)
1945
+ 'zswap.compressor=zstd', // Best balance of speed and compression
1946
+ 'zswap.max_pool_percent=30', // Use max 30% of RAM as compressed storage
1947
+ 'zswap.zpool=zsmalloc', // Efficient memory management for zswap
1948
+ 'fsck.mode=skip', // Skip disk checks to accelerate boot
1949
+ 'max_loop=255', // Ensure enough loop devices for squashfs/snaps
1950
+
1951
+ // --- Immutable Filesystem ---
1952
+ 'overlayroot=tmpfs', // Run entire OS in RAM to protect storage
1953
+ 'overlayroot_cfgdisk=disabled', // Ignore external overlay configurations
1954
+ ];
1955
+
1956
+ const baseNfsParams = [`netboot=nfs`];
1957
+
1958
+ let cmd = [];
1959
+ if (type === 'iso-ram') {
1960
+ const netBootParams = [`netboot=url`];
1961
+ if (fileSystemUrl) netBootParams.push(`url=${fileSystemUrl.replace('https', 'http')}`);
1962
+ cmd = [ipParam, `boot=casper`, ...netBootParams, ...kernelParams];
1963
+ } else if (type === 'chroot') {
1964
+ const qemuNfsRootParams = [`root=/dev/nfs`, `rootfstype=nfs`, `initrd=initrd.img`, `init=/sbin/init`];
1965
+ cmd = [ipParam, ...baseNfsParams, ...qemuNfsRootParams, nfsRootParam, ...kernelParams];
1966
+ } else {
1967
+ // 'iso-nfs'
1968
+ cmd = [ipParam, ...baseNfsParams, nfsRootParam, ...kernelParams, ...performanceParams];
1969
+
1970
+ cmd.push(`ifname=${networkInterfaceName}:${macAddress}`);
1971
+
1972
+ if (cloudInit) {
1973
+ const cloudInitPreseedUrl = `http://${ipDhcpServer}:5248/MAAS/metadata/by-id/${options.machine?.system_id ? options.machine.system_id : 'system-id'}/?op=get_preseed`;
1974
+ cmd = cmd.concat([
1975
+ `cloud-init=enabled`,
1976
+ 'autoinstall',
1977
+ `cloud-config-url=${cloudInitPreseedUrl}`,
1978
+ `ds=nocloud-net;s=${cloudInitPreseedUrl}`,
1979
+ `log_host=${ipDhcpServer}`,
1980
+ `log_port=5247`,
1981
+ // `BOOTIF=${macAddress}`,
1982
+ // `cc:{'datasource_list': ['MAAS']}end_cc`,
1983
+ ]);
1984
+ }
786
1985
  }
1986
+ // cmd.push('---');
1987
+ const cmdStr = cmd.join(' ');
1988
+ logger.info('Constructed kernel command line');
1989
+ console.log(newInstance(cmdStr).bgRed.bold.black);
1990
+ return { cmd: cmdStr };
787
1991
  },
788
1992
 
789
1993
  /**
@@ -792,25 +1996,15 @@ menuentry '${menuentryStr}' {
792
1996
  * once a matching MAC address is found. It also opens terminal windows for live logs.
793
1997
  * @param {object} params - The parameters for the function.
794
1998
  * @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.
1999
+ * @param {string} params.ipAddress - The IP address of the machine (used if MAC is all zeros).
2000
+ * @param {string} [params.hostname] - The hostname for the machine (optional).
2001
+ * @param {object} [params.maas] - Additional MAAS-specific options (optional).
2002
+ * @param {object} [params.machine] - Existing machine payload to use (optional).
800
2003
  * @returns {Promise<void>} A promise that resolves when commissioning is initiated or after a delay.
801
2004
  * @memberof UnderpostBaremetal
802
2005
  */
803
- async commissionMonitor({ macAddress, nfsHostPath, underpostRoot, hostname, maas, networkInterfaceName }) {
2006
+ async commissionMonitor({ macAddress, ipAddress, hostname, maas, machine }) {
804
2007
  {
805
- logger.info('Waiting for commissioning...', {
806
- macAddress,
807
- nfsHostPath,
808
- underpostRoot,
809
- hostname,
810
- maas,
811
- networkInterfaceName,
812
- });
813
-
814
2008
  // Query observed discoveries from MAAS.
815
2009
  const discoveries = JSON.parse(
816
2010
  shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} discoveries read`, {
@@ -819,131 +2013,95 @@ menuentry '${menuentryStr}' {
819
2013
  }),
820
2014
  );
821
2015
 
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
2016
  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
- {
2017
+ const dicoverHostname = discovery.hostname
2018
+ ? discovery.hostname
2019
+ : discovery.mac_organization
2020
+ ? discovery.mac_organization
2021
+ : discovery.domain
2022
+ ? discovery.domain
2023
+ : `generic-host-${s4()}${s4()}`;
2024
+
2025
+ console.log(dicoverHostname.bgBlue.bold.white);
2026
+ console.log('ip target:'.green + ipAddress, 'ip discovered:'.green + discovery.ip);
2027
+ console.log('mac target:'.green + macAddress, 'mac discovered:'.green + discovery.mac_address);
2028
+
2029
+ if (discovery.ip === ipAddress) {
2030
+ logger.info('Machine discovered!', discovery);
2031
+ if (!machine) {
2032
+ logger.info('Creating new machine with discovered hardware MAC...', {
2033
+ discoveredMAC: discovery.mac_address,
2034
+ ipAddress,
2035
+ hostname,
2036
+ });
2037
+ machine = UnderpostBaremetal.API.machineFactory({
2038
+ ipAddress,
2039
+ macAddress: discovery.mac_address,
2040
+ hostname,
2041
+ maas,
2042
+ }).machine;
2043
+ console.log('New machine system id:', machine.system_id.bgYellow.bold.black);
2044
+ UnderpostBaremetal.API.writeGrubConfigToFile({
2045
+ grubCfgSrc: UnderpostBaremetal.API.getGrubConfigFromFile().grubCfgSrc.replaceAll(
2046
+ 'system-id',
2047
+ machine.system_id,
2048
+ ),
2049
+ });
2050
+ } else {
2051
+ const systemId = machine.system_id;
2052
+ console.log('Using pre-registered machine system_id:', systemId.bgYellow.bold.black);
2053
+
2054
+ // Update the boot interface MAC if hardware MAC differs from pre-registered MAC
2055
+ // This handles both hardware mode (macAddress is null) and MAC mismatch scenarios
2056
+ if (macAddress === null || macAddress !== discovery.mac_address) {
2057
+ logger.info('Updating machine interface with discovered hardware MAC...', {
2058
+ preRegisteredMAC: macAddress || 'none (hardware mode)',
2059
+ discoveredMAC: discovery.mac_address,
2060
+ });
2061
+
2062
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-broken ${systemId}`, {
871
2063
  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.
2064
+ });
879
2065
 
880
- // Read interface data.
881
- const interfaceData = JSON.parse(
882
2066
  shellExec(
883
- `maas ${process.env.MAAS_ADMIN_USERNAME} interface read ${newMachine.machine.boot_interface.system_id} ${discoverInterfaceName}`,
2067
+ // name=${networkInterfaceName}
2068
+ `maas ${process.env.MAAS_ADMIN_USERNAME} interface update ${systemId} ${machine.boot_interface.id}` +
2069
+ ` mac_address=${discovery.mac_address}`,
884
2070
  {
885
2071
  silent: true,
886
- stdout: true,
887
2072
  },
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
- );
2073
+ );
921
2074
 
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);
2075
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine mark-fixed ${systemId}`, {
2076
+ silent: true,
2077
+ });
2078
+
2079
+ logger.info('✓ Machine interface MAC address updated successfully');
2080
+
2081
+ // commissioning_scripts=90-verify-user.sh
2082
+ machine = JSON.parse(
2083
+ shellExec(
2084
+ `maas ${process.env.MAAS_ADMIN_USERNAME} machine commission --debug --insecure ${systemId} enable_ssh=1 skip_bmc_config=1 skip_networking=1 skip_storage=1`,
2085
+ {
2086
+ silent: true,
2087
+ },
2088
+ ),
2089
+ );
2090
+ }
2091
+ logger.info('Machine resource uri', machine.resource_uri);
2092
+ for (const iface of machine.interface_set)
2093
+ logger.info('Interface info', {
2094
+ name: iface.name,
2095
+ mac_address: iface.mac_address,
2096
+ resource_uri: iface.resource_uri,
2097
+ });
936
2098
  }
2099
+
2100
+ return { discovery, machine };
2101
+ }
937
2102
  }
938
2103
  await timer(1000);
939
- UnderpostBaremetal.API.commissionMonitor({
940
- macAddress,
941
- nfsHostPath,
942
- underpostRoot,
943
- hostname,
944
- maas,
945
- networkInterfaceName,
946
- });
2104
+ return await UnderpostBaremetal.API.commissionMonitor({ macAddress, ipAddress, hostname, maas, machine });
947
2105
  }
948
2106
  },
949
2107
 
@@ -952,11 +2110,10 @@ menuentry '${menuentryStr}' {
952
2110
  * @description Mounts the binfmt_misc filesystem to enable QEMU user-static binfmt support.
953
2111
  * This is necessary for cross-architecture execution within a chroot environment.
954
2112
  * @param {object} params - The parameters for the function.
955
- * @param {string} params.nfsHostPath - The path to the NFS root filesystem on the host.
956
2113
  * @memberof UnderpostBaremetal
957
2114
  * @returns {void}
958
2115
  */
959
- mountBinfmtMisc({ nfsHostPath }) {
2116
+ mountBinfmtMisc() {
960
2117
  // Install necessary packages for debootstrap and QEMU.
961
2118
  shellExec(`sudo dnf install -y iptables-legacy`);
962
2119
  shellExec(`sudo dnf install -y debootstrap`);
@@ -966,9 +2123,6 @@ menuentry '${menuentryStr}' {
966
2123
  // Mount binfmt_misc filesystem.
967
2124
  shellExec(`sudo modprobe binfmt_misc`);
968
2125
  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
2126
  },
973
2127
 
974
2128
  /**
@@ -976,12 +2130,17 @@ menuentry '${menuentryStr}' {
976
2130
  * @description Deletes all specified machines from MAAS.
977
2131
  * @param {object} params - The parameters for the function.
978
2132
  * @param {Array<object>} params.machines - An array of machine objects, each with a `system_id`.
2133
+ * @param {Array<string>} [params.ignore] - An optional array of system IDs to ignore during deletion.
979
2134
  * @memberof UnderpostBaremetal
980
2135
  * @returns {Array<object>} An empty array after machines are removed.
981
2136
  */
982
- removeMachines({ machines }) {
2137
+ removeMachines({ machines, ignore }) {
983
2138
  for (const machine of machines) {
984
- shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${machine.system_id}`);
2139
+ // Handle both string system_ids and machine objects
2140
+ const systemId = typeof machine === 'string' ? machine : machine.system_id;
2141
+ if (ignore && ignore.find((mId) => mId === systemId)) continue;
2142
+ logger.info(`Removing machine: ${systemId}`);
2143
+ shellExec(`maas ${process.env.MAAS_ADMIN_USERNAME} machine delete ${systemId}`);
985
2144
  }
986
2145
  return [];
987
2146
  },
@@ -1124,45 +2283,66 @@ EOF`);
1124
2283
  * It checks the mount status and performs mount/unmount operations as requested.
1125
2284
  * @param {object} params - The parameters for the function.
1126
2285
  * @param {string} params.hostname - The hostname of the target machine.
2286
+ * @param {string} params.nfsHostPath - The NFS host path for the target machine.
1127
2287
  * @param {string} params.workflowId - The identifier for the workflow configuration.
1128
2288
  * @param {boolean} [params.mount] - If true, attempts to mount the NFS paths.
1129
2289
  * @param {boolean} [params.unmount] - If true, attempts to unmount the NFS paths.
1130
2290
  * @memberof UnderpostBaremetal
1131
2291
  * @returns {{isMounted: boolean}} An object indicating whether any NFS path is currently mounted.
1132
2292
  */
1133
- nfsMountCallback({ hostname, workflowId, mount, unmount }) {
2293
+ nfsMountCallback({ hostname, nfsHostPath, workflowId, mount, unmount }) {
2294
+ // Mount binfmt_misc filesystem.
2295
+ if (mount) UnderpostBaremetal.API.mountBinfmtMisc();
1134
2296
  let isMounted = false;
2297
+ const mountCmds = [];
2298
+ const currentMounts = [];
1135
2299
  const workflowsConfig = UnderpostBaremetal.API.loadWorkflowsConfig();
1136
2300
  if (!workflowsConfig[workflowId]) {
1137
2301
  throw new Error(`Workflow configuration not found for ID: ${workflowId}`);
1138
2302
  }
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
- );
2303
+ if (workflowsConfig[workflowId].type === 'chroot') {
2304
+ const mounts = {
2305
+ bind: ['/proc', '/sys', '/run'],
2306
+ rbind: ['/dev'],
2307
+ };
2308
+
2309
+ for (const mountCmd of Object.keys(mounts)) {
2310
+ for (const mountPath of mounts[mountCmd]) {
2311
+ const hostMountPath = `${process.env.NFS_EXPORT_PATH}/${hostname}${mountPath}`;
2312
+ // Check if the path is already mounted using `mountpoint` command.
2313
+ const isPathMounted = !shellExec(`mountpoint ${hostMountPath}`, { silent: true, stdout: true }).match(
2314
+ 'not a mountpoint',
2315
+ );
1147
2316
 
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}`);
2317
+ if (isPathMounted) {
2318
+ currentMounts.push(mountPath);
2319
+ if (!isMounted) isMounted = true; // Set overall mounted status.
2320
+ logger.warn('Nfs path already mounted', mountPath);
2321
+ if (unmount === true) {
2322
+ // Unmount if requested.
2323
+ mountCmds.push(`sudo umount ${hostMountPath}`);
2324
+ }
1159
2325
  } else {
1160
- logger.warn('Nfs path not mounted', mountPath);
2326
+ if (mount === true) {
2327
+ // Mount if requested and not already mounted.
2328
+ mountCmds.push(`sudo mount --${mountCmd} ${mountPath} ${hostMountPath}`);
2329
+ } else {
2330
+ logger.warn('Nfs path not mounted', mountPath);
2331
+ }
1161
2332
  }
1162
2333
  }
1163
2334
  }
2335
+
2336
+ if (!isMounted) {
2337
+ // if all path unmounted, set ownership and permissions for the NFS host path.
2338
+ shellExec(`sudo chown -R $(whoami):$(whoami) ${nfsHostPath}`);
2339
+ shellExec(`sudo chmod -R 755 ${nfsHostPath}`);
2340
+ }
2341
+ for (const mountCmd of mountCmds) shellExec(mountCmd);
2342
+ if (mount) isMounted = true;
2343
+ logger.info('Current mounts', currentMounts);
1164
2344
  }
1165
- return { isMounted };
2345
+ return { isMounted, currentMounts };
1166
2346
  },
1167
2347
 
1168
2348
  /**
@@ -1203,11 +2383,10 @@ EOF`);
1203
2383
  * This includes updating package lists, installing essential build tools,
1204
2384
  * kernel modules, cloud-init, SSH server, and other core utilities.
1205
2385
  * @param {object} params - The parameters for the function.
1206
- * @param {string} params.kernelLibVersion - The specific kernel library version to install.
1207
2386
  * @memberof UnderpostBaremetal.systemProvisioningFactory.ubuntu
1208
2387
  * @returns {string[]} An array of shell commands.
1209
2388
  */
1210
- base: ({ kernelLibVersion }) => [
2389
+ base: () => [
1211
2390
  // Configure APT sources for Ubuntu ports.
1212
2391
  `cat <<SOURCES | tee /etc/apt/sources.list
1213
2392
  deb http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
@@ -1221,12 +2400,9 @@ SOURCES`,
1221
2400
  // Install essential development and system utilities.
1222
2401
  `apt install -y build-essential xinput x11-xkb-utils usbutils uuid-runtime`,
1223
2402
  'apt install -y linux-image-generic',
1224
- // Install specific kernel modules.
1225
- `apt install -y linux-modules-${kernelLibVersion} linux-modules-extra-${kernelLibVersion}`,
1226
2403
 
1227
- `depmod -a ${kernelLibVersion}`, // Update kernel module dependencies.
1228
2404
  // 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`,
2405
+ `apt install -y systemd-sysv openssh-server sudo locales udev util-linux systemd-sysv iproute2 netplan.io ca-certificates curl wget chrony`,
1230
2406
  `ln -sf /lib/systemd/systemd /sbin/init`, // Ensure systemd is the init system.
1231
2407
 
1232
2408
  `apt-get update`,
@@ -1336,15 +2512,16 @@ logdir /var/log/chrony
1336
2512
  * @method keyboard
1337
2513
  * @description Generates shell commands for configuring the keyboard layout.
1338
2514
  * This ensures correct input behavior on the provisioned system.
2515
+ * @param {string} [keyCode='en'] - The keyboard layout code (e.g., 'en', 'es').
1339
2516
  * @memberof UnderpostBaremetal.systemProvisioningFactory.ubuntu
1340
2517
  * @returns {string[]} An array of shell commands.
1341
2518
  */
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.
2519
+ keyboard: (keyCode = 'en') => [
2520
+ `sudo locale-gen en_US.UTF-8`,
2521
+ `sudo update-locale LANG=en_US.UTF-8`,
2522
+ `sudo sed -i 's/XKBLAYOUT="us"/XKBLAYOUT="${keyCode}"/' /etc/default/keyboard`,
2523
+ `sudo dpkg-reconfigure --frontend noninteractive keyboard-configuration`,
2524
+ `sudo systemctl restart keyboard-setup.service`,
1348
2525
  ],
1349
2526
  },
1350
2527
  },
@@ -1354,7 +2531,7 @@ logdir /var/log/chrony
1354
2531
  * @description Configures and restarts the NFS server to export the specified path.
1355
2532
  * This is crucial for allowing baremetal machines to boot via NFS.
1356
2533
  * @param {object} params - The parameters for the function.
1357
- * @param {string} params.nfsHostPath - The path to be exported by the NFS server.
2534
+ * @param {string} params.nfsHostPath - The path to the NFS server export.
1358
2535
  * @memberof UnderpostBaremetal
1359
2536
  * @param {string} [params.subnet='192.168.1.0/24'] - The subnet allowed to access the NFS export.
1360
2537
  * @returns {void}
@@ -1403,7 +2580,7 @@ udp-port = 32766
1403
2580
  shellExec(`sudo exportfs -rav`);
1404
2581
 
1405
2582
  // Display the currently active NFS exports for verification.
1406
- logger.info('Displaying active NFS exports:');
2583
+ logger.info('Displaying active NFS exports');
1407
2584
  shellExec(`sudo exportfs -s`);
1408
2585
 
1409
2586
  // Restart the nfs-server service to apply all configuration changes,
@@ -1505,9 +2682,14 @@ udp-port = 32766
1505
2682
  * @throws {Error} If an invalid workflow ID is provided.
1506
2683
  */
1507
2684
  bootConfFactory({ workflowId, tftpIp, tftpPrefixStr, macAddress, clientIp, subnet, gateway }) {
1508
- switch (workflowId) {
1509
- case 'rpi4mb':
1510
- return `[all]
2685
+ if (workflowId.startsWith('rpi4mb')) {
2686
+ // Instructions: Flash sd with Raspberry Pi OS lite and update:
2687
+ // EEPROM (Electrically Erasable Programmable Read-Only Memory) like microcontrollers
2688
+ // sudo rpi-eeprom-config --apply /boot/firmware/boot.conf
2689
+ // sudo reboot
2690
+ // vcgencmd bootloader_config
2691
+ // shutdown -h now
2692
+ return `[all]
1511
2693
  BOOT_UART=0
1512
2694
  WAKE_ON_GPIO=1
1513
2695
  POWER_OFF_ON_HALT=0
@@ -1540,7 +2722,7 @@ TFTP_PREFIX_STR=${tftpPrefixStr}/
1540
2722
  # Manually override Ethernet MAC address
1541
2723
  # ─────────────────────────────────────────────────────────────
1542
2724
 
1543
- MAC_ADDRESS=${macAddress}
2725
+ #MAC_ADDRESS=${macAddress}
1544
2726
 
1545
2727
  # OTP MAC address override
1546
2728
  #MAC_ADDRESS_OTP=0,1
@@ -1548,18 +2730,14 @@ MAC_ADDRESS=${macAddress}
1548
2730
  # ─────────────────────────────────────────────────────────────
1549
2731
  # Static IP configuration (bypasses DHCP completely)
1550
2732
  # ─────────────────────────────────────────────────────────────
1551
- CLIENT_IP=${clientIp}
2733
+ #CLIENT_IP=${clientIp}
1552
2734
  SUBNET=${subnet}
1553
2735
  GATEWAY=${gateway}`;
1554
-
1555
- default:
1556
- throw new Error('Boot conf factory invalid workflow ID:' + workflowId);
1557
- }
2736
+ } else logger.warn(`No boot configuration factory defined for workflow ID: ${workflowId}`);
1558
2737
  },
1559
2738
 
1560
2739
  /**
1561
2740
  * @method loadWorkflowsConfig
1562
- * @namespace UnderpostBaremetal.API
1563
2741
  * @description Loads the commission workflows configuration from commission-workflows.json.
1564
2742
  * Each workflow defines specific parameters like system provisioning type,
1565
2743
  * kernel version, Chrony settings, debootstrap image details, and NFS mounts. *