underpost 3.2.22 → 3.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.github/workflows/ghpkg.ci.yml +1 -1
  2. package/.github/workflows/gitlab.ci.yml +1 -1
  3. package/.github/workflows/npmpkg.ci.yml +1 -1
  4. package/.github/workflows/publish.ci.yml +2 -2
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  7. package/CHANGELOG.md +181 -1
  8. package/CLI-HELP.md +61 -2
  9. package/README.md +3 -2
  10. package/baremetal/commission-workflows.json +1 -0
  11. package/bin/build.js +1 -0
  12. package/docker-compose.yml +224 -0
  13. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  15. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  16. package/package.json +27 -15
  17. package/scripts/kubeadm-node-setup.sh +317 -0
  18. package/scripts/rocky-kickstart.sh +877 -185
  19. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  20. package/scripts/test-monitor.sh +3 -5
  21. package/src/cli/baremetal.js +717 -16
  22. package/src/cli/cluster.js +3 -1
  23. package/src/cli/deploy.js +118 -9
  24. package/src/cli/docker-compose.js +591 -0
  25. package/src/cli/env.js +11 -2
  26. package/src/cli/fs.js +35 -11
  27. package/src/cli/index.js +89 -0
  28. package/src/cli/kickstart.js +142 -20
  29. package/src/cli/repository.js +153 -11
  30. package/src/cli/run.js +285 -8
  31. package/src/cli/secrets.js +7 -2
  32. package/src/cli/ssh.js +234 -0
  33. package/src/cli/static.js +2 -2
  34. package/src/{server → client-builder}/client-build-docs.js +15 -5
  35. package/src/{server → client-builder}/client-build-live.js +3 -3
  36. package/src/{server → client-builder}/client-build.js +25 -22
  37. package/src/{server → client-builder}/client-dev-server.js +3 -3
  38. package/src/{server → client-builder}/client-icons.js +2 -2
  39. package/src/{server → client-builder}/ssr.js +5 -5
  40. package/src/client.build.js +1 -3
  41. package/src/client.dev.js +1 -1
  42. package/src/db/mongo/MongoBootstrap.js +12 -12
  43. package/src/index.js +12 -1
  44. package/src/mailer/EmailRender.js +1 -1
  45. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  46. package/src/runtime/express/Express.js +2 -2
  47. package/src/runtime/nginx/Nginx.js +250 -0
  48. package/src/server/catalog.js +7 -14
  49. package/src/server/conf.js +3 -3
  50. package/src/server/runtime-status.js +18 -1
  51. package/src/server/start.js +12 -2
  52. package/src/server.js +6 -2
  53. package/test/deploy-monitor.test.js +26 -10
  54. package/typedoc.json +3 -1
  55. package/src/server/ipfs-client.js +0 -599
  56. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  57. /package/src/{server → client-builder}/client-formatted.js +0 -0
@@ -34,6 +34,11 @@ class UnderpostBaremetal {
34
34
  // statd=32765 (listen), statdOutgoing=32766 (SM_NOTIFY source port) is the standard split.
35
35
  static NFS_V3_PORTS = { mountd: 20048, statd: 32765, statdOutgoing: 32766, lockd: 32803 };
36
36
 
37
+ // Lifecycle events POSTed by the ephemeral runtime to the bootstrap HTTP
38
+ // server, keyed by hostname. Populated by httpBootstrapServerRunnerFactory's
39
+ // POST handler and consumed by waitForBootstrapStage during orchestration.
40
+ static bootstrapStatusEvents = new Map();
41
+
37
42
  static API = {
38
43
  /**
39
44
  * @method callback
@@ -87,6 +92,20 @@ class UnderpostBaremetal {
87
92
  * @param {boolean} [options.nfsUnmount=false] - Flag to unmount the NFS root filesystem.
88
93
  * @param {boolean} [options.nfsSh=false] - Flag to chroot into the NFS environment for shell access.
89
94
  * @param {string} [options.logs=''] - Specifies which logs to display ('dhcp', 'cloud', 'machine', 'cloud-config').
95
+ * @param {string} [options.installDisk=''] - Specifies the disk to install the OS on (e.g., /dev/sda).
96
+ * @param {boolean} [options.autoInstall=true] - Flag to enable automatic installation of the OS on the baremetal machine.
97
+ * @param {boolean} [options.remoteInstall=true] - Flag to enable remote installation of the OS on the baremetal machine.
98
+ * @param {boolean} [options.worker=false] - Flag to designate the machine as a worker node.
99
+ * @param {string} [options.control=''] - Specifies the control node for the baremetal machine.
100
+ * @param {string} [options.sshKeyDir=''] - Specifies the directory containing SSH keys for the baremetal machine.
101
+ * @param {string} [options.deployId=''] - Specifies the deployment ID for SSH key resolution.
102
+ * @param {string} [options.engineRepo=''] - Specifies the custom engine repository URL.
103
+ * @param {string} [options.engineBranch=''] - Specifies the custom engine repository branch.
104
+ * @param {string} [options.enginePrivateRepo=''] - Specifies the custom private engine repository URL.
105
+ * @param {string} [options.enginePrivateBranch=''] - Specifies the custom private engine repository branch.
106
+ * @param {string} [options.user=''] - Specifies the SSH user for the baremetal machine.
107
+ * @param {boolean} [options.resumeInfraSetup=false] - Flag to skip commissioning and OS install, resuming SSH-based infra setup on an already installed node.
108
+ * @param {boolean} [options.resumeJoin=false] - Flag to skip everything except the kubeadm join command.
90
109
  * @memberof UnderpostBaremetal
91
110
  * @returns {void}
92
111
  */
@@ -138,6 +157,20 @@ class UnderpostBaremetal {
138
157
  nfsUnmount: false,
139
158
  nfsSh: false,
140
159
  logs: '',
160
+ installDisk: '',
161
+ autoInstall: true,
162
+ remoteInstall: true,
163
+ worker: false,
164
+ control: '',
165
+ sshKeyDir: '',
166
+ user: '',
167
+ deployId: '',
168
+ engineRepo: '',
169
+ engineBranch: '',
170
+ enginePrivateRepo: '',
171
+ enginePrivateBranch: '',
172
+ resumeInfraSetup: false,
173
+ resumeJoin: false,
141
174
  },
142
175
  ) {
143
176
  let { ipAddress, hostname, ipFileServer, ipConfig, netmask, dnsServer } = options;
@@ -213,6 +246,60 @@ class UnderpostBaremetal {
213
246
  // Log the initiation of the baremetal callback with relevant metadata.
214
247
  logger.info('Baremetal callback', callbackMetaData);
215
248
 
249
+ // --resume-infra-setup: skip commissioning, OS install, and all PXE/TFTP/MAAS
250
+ // bootstrapping; directly resume the SSH-based infra setup on a node that
251
+ // already has the OS installed and is reachable via SSH.
252
+ if (options.resumeInfraSetup) {
253
+ const { privateKeyPath, user: resolvedUser } = Underpost.baremetal.resolveSshKeyPaths({
254
+ options,
255
+ workflowsConfig,
256
+ workflowId,
257
+ });
258
+ logger.info('--resume-infra-setup: skipping commission/bootstrapping; resuming SSH infra setup', {
259
+ hostname,
260
+ ipAddress,
261
+ keyPath: privateKeyPath,
262
+ user: resolvedUser,
263
+ workflowId,
264
+ infraSetup: workflowsConfig[workflowId]?.infraSetup || 'none',
265
+ });
266
+ return await Underpost.baremetal.postInstallDispatcher({
267
+ workflowId,
268
+ workflowsConfig,
269
+ hostname,
270
+ ipAddress,
271
+ options,
272
+ underpostRoot,
273
+ keyPath: privateKeyPath,
274
+ controlUser: resolvedUser,
275
+ });
276
+ }
277
+
278
+ // --resume-join: skip everything except the kubeadm join command.
279
+ // Even lighter than --resume-infra-setup: no engine setup, no npm install,
280
+ // no init-host, no config. Assumes all infra is already installed.
281
+ if (options.resumeJoin) {
282
+ const { privateKeyPath, user: resolvedUser } = Underpost.baremetal.resolveSshKeyPaths({
283
+ options,
284
+ workflowsConfig,
285
+ workflowId,
286
+ });
287
+ logger.info('--resume-join: skipping all bootstrapping; joining cluster directly with minimal SSH command', {
288
+ hostname,
289
+ ipAddress,
290
+ keyPath: privateKeyPath,
291
+ user: resolvedUser,
292
+ });
293
+ return await Underpost.baremetal.infraSetupKubeadm({
294
+ hostname,
295
+ ipAddress,
296
+ options,
297
+ underpostRoot,
298
+ keyPath: privateKeyPath,
299
+ controlUser: resolvedUser,
300
+ });
301
+ }
302
+
216
303
  // Create a new machine in MAAS if the option is set.
217
304
  let machine;
218
305
  if (options.createMachine === true) {
@@ -908,12 +995,60 @@ rm -rf ${artifacts.join(' ')}`);
908
995
  // Rocky/RHEL Kickstart generation
909
996
  let kickstartSrc = '';
910
997
  if (Underpost.baremetal.getFamilyBaseOs(workflowsConfig[workflowId].osIdLike).isRhelBased) {
998
+ const bootstrapPort = Underpost.baremetal.bootstrapHttpServerPortFactory({
999
+ port: options.bootstrapHttpServerPort,
1000
+ workflowId,
1001
+ workflowsConfig,
1002
+ });
1003
+ // Base URL the ephemeral runtime POSTs lifecycle events to:
1004
+ // http://<controller-ip>:<port>/<hostname> -> .../status
1005
+ const bootstrapUrl = `http://${callbackMetaData.runnerHost.ip}:${bootstrapPort}/${hostname}`;
1006
+ const { publicKeyPath } = Underpost.baremetal.resolveSshKeyPaths({ options, workflowsConfig, workflowId });
1007
+ if (!fs.existsSync(publicKeyPath)) {
1008
+ throw new Error(
1009
+ `SSH public key not found at ${publicKeyPath}. Set --ssh-key-dir <dir> (expects <dir>/id_rsa.pub) or the workflow "sshKeyDir".`,
1010
+ );
1011
+ }
1012
+ const { rootPassword, adminUsername, adminPassword, deployUsername, deployPassword } =
1013
+ Underpost.baremetal.resolveInstallCredentials({ options, workflowsConfig, workflowId });
1014
+ const { netIp, netPrefix, netGateway, netDns } = Underpost.baremetal.resolveInstalledNetwork({
1015
+ ipAddress,
1016
+ netmask,
1017
+ dnsServer,
1018
+ });
1019
+ logger.info('Resolved installed-OS login + network', {
1020
+ adminUsername,
1021
+ adminPasswordSet: Boolean(adminPassword),
1022
+ deployUsername: deployUsername || '(none)',
1023
+ rootPasswordSet: Boolean(rootPassword),
1024
+ staticIp: `${netIp}/${netPrefix}`,
1025
+ gateway: netGateway,
1026
+ });
911
1027
  kickstartSrc = Underpost.kickstart.kickstartFactory({
912
1028
  lang: 'en_US.UTF-8',
913
1029
  keyboard: workflowsConfig[workflowId].keyboard?.layout,
914
1030
  timezone: workflowsConfig[workflowId].chronyc?.timezone,
915
- rootPassword: process.env.MAAS_ADMIN_PASS,
916
- authorizedKeys: fs.readFileSync('/home/dd/engine/engine-private/deploy/id_rsa.pub', 'utf8').trim(),
1031
+ chronyConfPath: workflowsConfig[workflowId].chronyc?.chronyConfPath,
1032
+ rootPassword,
1033
+ adminUsername,
1034
+ adminPassword,
1035
+ deployUsername,
1036
+ deployPassword,
1037
+ netIp,
1038
+ netPrefix,
1039
+ netGateway,
1040
+ netDns,
1041
+ authorizedKeys: fs.readFileSync(publicKeyPath, 'utf8').trim(),
1042
+ bootstrapUrl,
1043
+ workflowId,
1044
+ systemId: machine?.system_id || '',
1045
+ targetHostname: hostname,
1046
+ sshPort: 22,
1047
+ installDiskHint:
1048
+ (typeof options.installDisk === 'string' ? options.installDisk : '') ||
1049
+ workflowsConfig[workflowId].installDisk ||
1050
+ '',
1051
+ autoInstall: options.autoInstall !== false,
917
1052
  });
918
1053
  }
919
1054
 
@@ -975,15 +1110,11 @@ rm -rf ${artifacts.join(' ')}`);
975
1110
  shellExec(`mkdir -p ${nfsHostPath}/casper`);
976
1111
  }
977
1112
 
978
- // Clean and create TFTP root path.
1113
+ // Clean and create TFTP root path. The iPXE EFI binary is restored from
1114
+ // cache (or rebuilt) later by the gated build block, so no early copy here.
979
1115
  shellExec(`sudo rm -rf ${tftpRootPath}`);
980
1116
  shellExec(`mkdir -p ${tftpRootPath}/pxe`);
981
1117
 
982
- // Restore iPXE build from cache if available and not forcing rebuild
983
- if (fs.existsSync(`${ipxeCacheDir}/ipxe.efi`) && !options.ipxeRebuild) {
984
- shellExec(`cp ${ipxeCacheDir}/ipxe.efi ${tftpRootPath}/ipxe.efi`);
985
- }
986
-
987
1118
  // Process firmwares for TFTP.
988
1119
  for (const firmware of firmwares) {
989
1120
  const { url, gateway, subnet } = firmware;
@@ -1093,14 +1224,32 @@ rm -rf ${artifacts.join(' ')}`);
1093
1224
  embeddedPath: `${tftpRootPath}/boot.ipxe`,
1094
1225
  });
1095
1226
 
1096
- Underpost.baremetal.ipxeEfiFactory({
1097
- tftpRootPath,
1098
- ipxeCacheDir,
1099
- arch,
1100
- underpostRoot,
1101
- embeddedScriptPath: `${tftpRootPath}/boot.ipxe`,
1102
- forceRebuild: options.ipxeRebuild,
1103
- });
1227
+ // iPXE EFI binary build policy:
1228
+ // --ipxe-rebuild -> always rebuild.
1229
+ // --ipxe (cache exists) -> reuse the cached binary, no build.
1230
+ // --ipxe (no cache) -> warn and build, then cache for reuse.
1231
+ const cachedIpxePath = `${ipxeCacheDir}/ipxe.efi`;
1232
+ const tftpIpxePath = `${tftpRootPath}/ipxe.efi`;
1233
+ if (options.ipxeRebuild || !fs.existsSync(cachedIpxePath)) {
1234
+ if (!options.ipxeRebuild) {
1235
+ logger.warn(
1236
+ '⚠ No cached iPXE EFI binary found — building now. Later runs reuse the cache; use --ipxe-rebuild to force a rebuild.',
1237
+ );
1238
+ }
1239
+ Underpost.baremetal.ipxeEfiFactory({
1240
+ tftpRootPath,
1241
+ ipxeCacheDir,
1242
+ arch,
1243
+ underpostRoot,
1244
+ embeddedScriptPath: `${tftpRootPath}/boot.ipxe`,
1245
+ forceRebuild: true,
1246
+ });
1247
+ } else {
1248
+ shellExec(`cp ${cachedIpxePath} ${tftpIpxePath}`);
1249
+ logger.info('✓ Using cached iPXE EFI binary (pass --ipxe-rebuild to force a rebuild)', {
1250
+ path: cachedIpxePath,
1251
+ });
1252
+ }
1104
1253
  }
1105
1254
 
1106
1255
  const { grubCfgSrc } = Underpost.baremetal.grubFactory({
@@ -1157,9 +1306,357 @@ rm -rf ${artifacts.join(' ')}`);
1157
1306
  const { discovery, machine: discoveredMachine } =
1158
1307
  await Underpost.baremetal.commissionMonitor(commissionMonitorPayload);
1159
1308
  if (discoveredMachine) machine = discoveredMachine;
1309
+
1310
+ // Rocky/RHEL disk-install orchestration:
1311
+ // Once the ephemeral Kickstart/Anaconda runtime reports key-only SSH
1312
+ // readiness over the bootstrap HTTP POST sink, drive the unattended
1313
+ // install of Rocky onto the detected target disk via a key-only,
1314
+ // non-interactive remote command. The runtime also has an AUTO_INSTALL
1315
+ // fallback, so a missed handshake never blocks installation forever.
1316
+ if (
1317
+ Underpost.baremetal.getFamilyBaseOs(workflowsConfig[workflowId].osIdLike).isRhelBased &&
1318
+ options.remoteInstall !== false
1319
+ ) {
1320
+ const { privateKeyPath, user: resolvedUser } = Underpost.baremetal.resolveSshKeyPaths({
1321
+ options,
1322
+ workflowsConfig,
1323
+ workflowId,
1324
+ });
1325
+
1326
+ await Underpost.baremetal.remoteInstallOrchestrator({
1327
+ hostname,
1328
+ ipAddress,
1329
+ sshPort: 22,
1330
+ keyPath: privateKeyPath,
1331
+ });
1332
+
1333
+ // After the OS is installed and the node reboots into the deployed
1334
+ // disk, run the post-install dispatcher: it reads the workflow's
1335
+ // infraSetup field and provisions the requested infrastructure (e.g.
1336
+ // a kubeadm control-plane or worker) over key-only SSH. The resolved
1337
+ // user is used when authenticating to an EXISTING control-plane (e.g.
1338
+ // admin@dd-core); fresh nodes are always reached as root.
1339
+ await Underpost.baremetal.postInstallDispatcher({
1340
+ workflowId,
1341
+ workflowsConfig,
1342
+ hostname,
1343
+ ipAddress,
1344
+ options,
1345
+ underpostRoot,
1346
+ keyPath: privateKeyPath,
1347
+ controlUser: resolvedUser,
1348
+ });
1349
+ }
1350
+ }
1351
+ },
1352
+
1353
+ /**
1354
+ * @method postInstallDispatcher
1355
+ * @description Generic first-boot post-install dispatcher. Reads the workflow's
1356
+ * `infraSetup` field and runs the matching infrastructure setup on the freshly
1357
+ * deployed node. Easy to extend: add a new case per supported infraSetup type.
1358
+ * Fails with a clear error if the workflow requests an unsupported type.
1359
+ * @param {object} params
1360
+ * @param {string} params.workflowId - Active workflow id.
1361
+ * @param {object} params.workflowsConfig - Loaded workflows config.
1362
+ * @param {string} params.hostname - Node hostname.
1363
+ * @param {string} params.ipAddress - Node IP (installed OS, key-only SSH).
1364
+ * @param {object} params.options - CLI options (carries --worker / --control).
1365
+ * @param {string} params.underpostRoot - Engine root for resolving scripts.
1366
+ * @param {string} [params.keyPath] - Private key path for key-only SSH.
1367
+ * @param {string} [params.controlUser='root'] - SSH user for an existing control-plane.
1368
+ * @returns {Promise<void>}
1369
+ * @memberof UnderpostBaremetal
1370
+ */
1371
+ async postInstallDispatcher({
1372
+ workflowId,
1373
+ workflowsConfig,
1374
+ hostname,
1375
+ ipAddress,
1376
+ options,
1377
+ underpostRoot,
1378
+ keyPath,
1379
+ controlUser = 'root',
1380
+ }) {
1381
+ const infraSetup = workflowsConfig[workflowId]?.infraSetup;
1382
+ if (!infraSetup) {
1383
+ logger.info('No infraSetup configured for workflow; skipping post-install setup', { workflowId });
1384
+ return;
1385
+ }
1386
+
1387
+ logger.info('Post-install dispatcher selecting infra setup', { workflowId, infraSetup });
1388
+ switch (infraSetup) {
1389
+ case 'underpost-kubeadm-contour':
1390
+ await Underpost.baremetal.infraSetupKubeadm({
1391
+ hostname,
1392
+ ipAddress,
1393
+ options,
1394
+ underpostRoot,
1395
+ keyPath,
1396
+ controlUser,
1397
+ });
1398
+ break;
1399
+ default:
1400
+ throw new Error(
1401
+ `Unsupported infraSetup "${infraSetup}" for workflow "${workflowId}". Supported: underpost-kubeadm-contour`,
1402
+ );
1403
+ }
1404
+ },
1405
+
1406
+ /**
1407
+ * @method infraSetupKubeadm
1408
+ * @description Provisions a kubeadm node on the freshly deployed machine over
1409
+ * key-only SSH. Control mode (default) initializes a new control-plane. Worker
1410
+ * mode (`--worker`) joins an existing cluster: the join token is retrieved
1411
+ * dynamically over SSH from the control-plane node (`--control <ip>`) — never
1412
+ * pasted manually — and passed to the node setup script.
1413
+ * @param {object} params
1414
+ * @param {string} params.hostname - Node hostname (logging).
1415
+ * @param {string} params.ipAddress - Target node IP (installed OS).
1416
+ * @param {object} params.options - CLI options; options.worker, options.control.
1417
+ * @param {string} params.underpostRoot - Engine root for resolving the script.
1418
+ * @param {string} [params.keyPath] - Private key path for key-only SSH.
1419
+ * @param {string} [params.controlUser='root'] - SSH login user for an existing control-plane (e.g. admin).
1420
+ * @returns {Promise<void>}
1421
+ * @memberof UnderpostBaremetal
1422
+ */
1423
+ async infraSetupKubeadm({
1424
+ hostname,
1425
+ ipAddress,
1426
+ options,
1427
+ underpostRoot,
1428
+ keyPath = 'engine-private/deploy/id_rsa',
1429
+ controlUser = 'root',
1430
+ }) {
1431
+ const role = options.worker ? 'worker' : 'control';
1432
+ const scriptPath = `${underpostRoot}/scripts/kubeadm-node-setup.sh`;
1433
+ const installedOsTimeoutMs = 15 * 60 * 1000;
1434
+
1435
+ logger.info(`Kubeadm post-install setup (${role}) on ${hostname} (${ipAddress})`);
1436
+
1437
+ if (!options.resumeInfraSetup && !options.resumeJoin) {
1438
+ // The node is rebooting from the ephemeral installer into the deployed OS.
1439
+ // First wait for the (ephemeral) SSH port to CLOSE so we don't latch onto
1440
+ // the pre-reboot sshd, then wait for the installed OS sshd to come UP.
1441
+ logger.info('Waiting for node to reboot into the deployed OS (port close → reopen)...', { ipAddress });
1442
+ await Underpost.ssh.waitForSshPortClosed({ host: ipAddress, port: 22, timeoutMs: 4 * 60 * 1000 });
1443
+ const reachable = await Underpost.ssh.waitForSshPort({
1444
+ host: ipAddress,
1445
+ port: 22,
1446
+ timeoutMs: installedOsTimeoutMs,
1447
+ });
1448
+ if (!reachable) {
1449
+ logger.error('Installed OS SSH not reachable after reboot; cannot run kubeadm setup', { ipAddress });
1450
+ return;
1451
+ }
1452
+ // The first time the port opens the node is still early in boot and
1453
+ // NetworkManager may re-apply the static profile, resetting in-flight TCP
1454
+ // connections. Let the network settle before driving the long node setup.
1455
+ logger.info('Deployed OS SSH is up; letting the network settle before node setup...', { ipAddress });
1456
+ await timer(30000);
1457
+ } else {
1458
+ logger.info('--resume-infra-setup: SSH already up, skipping port close/reopen wait', { ipAddress });
1459
+ }
1460
+
1461
+ let scriptArgs;
1462
+ if (role === 'worker') {
1463
+ const controlIp = options.control;
1464
+ if (!controlIp || typeof controlIp !== 'string') {
1465
+ throw new Error('Worker mode requires the control-plane IP via --control <ip>');
1466
+ }
1467
+
1468
+ // Retrieve the join command dynamically from the control-plane node over
1469
+ // SSH (reuses the same key-only SSH execution path). No manual token paste.
1470
+ // The control-plane may be an existing deploy reached as a non-root user
1471
+ // (e.g. admin@dd-core), selected via --user/--deploy-id.
1472
+ logger.info('Retrieving kubeadm join command from control-plane over SSH', {
1473
+ controlIp,
1474
+ controlUser,
1475
+ keyPath,
1476
+ });
1477
+ const joinResult = await Underpost.ssh.sshExecBatch({
1478
+ host: controlIp,
1479
+ port: 22,
1480
+ user: controlUser,
1481
+ keyPath,
1482
+ retries: 4,
1483
+ waitForPortMs: 5 * 60 * 1000,
1484
+ command: 'sudo kubeadm token create --print-join-command',
1485
+ });
1486
+
1487
+ let joinCommand = (joinResult.stdout || '')
1488
+ .split('\n')
1489
+ .map((l) => l.trim())
1490
+ .find((l) => l.startsWith('kubeadm join'));
1491
+ if (!joinResult.ok || !joinCommand) {
1492
+ throw new Error(
1493
+ `Failed to retrieve kubeadm join command from control-plane ${controlUser}@${controlIp} using key "${keyPath}" (code ${joinResult.code}). ` +
1494
+ `Ensure that key authorizes ${controlUser}@${controlIp} — pass --deploy-id <id> --user <user> (key from engine-private/conf/<id>/users/<user>) ` +
1495
+ `or --ssh-key-dir <dir> to use the key/user the control-plane accepts. stderr: ${joinResult.stderr.slice(-300)}`,
1496
+ );
1497
+ }
1498
+ // kubeadm prints the API endpoint as the control-plane's hostname (often
1499
+ // localhost.localdomain), which the worker cannot resolve/route. Capture
1500
+ // that original endpoint host (so the worker can map it -> control IP in
1501
+ // /etc/hosts; the cluster-info / kubeadm-config ConfigMaps reference it),
1502
+ // then rewrite the join positional endpoint to the real control-plane IP.
1503
+ const endpointMatch = joinCommand.match(/kubeadm join\s+([^\s:]+):\d+/);
1504
+ const endpointHost = endpointMatch ? endpointMatch[1] : '';
1505
+ joinCommand = joinCommand.replace(/kubeadm join\s+\S+:(\d+)/, `kubeadm join ${controlIp}:$1`);
1506
+ logger.info('✓ Retrieved kubeadm join command from control-plane', { controlIp, endpointHost, joinCommand });
1507
+ scriptArgs = `--worker --join-command="${joinCommand}" --control-ip=${controlIp}`;
1508
+ if (endpointHost && endpointHost !== controlIp) {
1509
+ scriptArgs += ` --control-endpoint-host=${endpointHost}`;
1510
+ }
1511
+ // --resume-join: skip all node prep and go straight to the kubeadm join.
1512
+ if (options.resumeJoin) scriptArgs += ' --join-only';
1513
+ } else {
1514
+ scriptArgs = '--control';
1515
+ }
1516
+
1517
+ // Secrets needed on the node to clone the private engine-private repo
1518
+ // (engine-private/conf/.../.env.production for `node bin run secret`).
1519
+ const githubUsername = Underpost.baremetal.readEngineConfig('GITHUB_USERNAME') || 'underpostnet';
1520
+ const githubEnv = {
1521
+ GITHUB_TOKEN: Underpost.baremetal.readEngineConfig('GITHUB_TOKEN'),
1522
+ GITHUB_USERNAME: githubUsername,
1523
+ };
1524
+ if (!githubEnv.GITHUB_TOKEN) {
1525
+ logger.warn('GITHUB_TOKEN not resolved on controller; engine-private clone on the node will be skipped');
1526
+ }
1527
+
1528
+ // Resolve the engine + engine-private repos the node clones and normalizes
1529
+ // to /home/dd/engine and /home/dd/engine/engine-private. CLI overrides win;
1530
+ // otherwise default to the base engine and the deployId's private repo
1531
+ // (engine-<id>-private), mirroring repository.privateEngineRepoFactory.
1532
+ const deployIdSuffix = options.deployId ? options.deployId.split('-')[1] : '';
1533
+ const engineRepo = options.engineRepo || `https://github.com/${githubUsername}/engine.git`;
1534
+ const enginePrivateRepo =
1535
+ options.enginePrivateRepo ||
1536
+ (deployIdSuffix
1537
+ ? `https://github.com/${githubUsername}/engine-${deployIdSuffix}-private.git`
1538
+ : `https://github.com/${githubUsername}/engine-private.git`);
1539
+ const repoArgs = [
1540
+ `--engine-repo=${engineRepo}`,
1541
+ `--engine-private-repo=${enginePrivateRepo}`,
1542
+ ...(options.engineBranch ? [`--engine-branch=${options.engineBranch}`] : []),
1543
+ ...(options.enginePrivateBranch ? [`--engine-private-branch=${options.enginePrivateBranch}`] : []),
1544
+ ].join(' ');
1545
+ scriptArgs = `${scriptArgs} ${repoArgs}`.trim();
1546
+ logger.info('Node engine repos', { engineRepo, enginePrivateRepo });
1547
+
1548
+ logger.info(`Running kubeadm-node-setup.sh (${role}) on ${ipAddress}... (this can take several minutes)`);
1549
+ // The node setup is a long, mostly-idempotent install. Keep retries low so
1550
+ // a late failure doesn't re-run the whole thing many times; the script
1551
+ // skips already-completed steps (Node, engine clone) on a re-run.
1552
+ const result = await Underpost.ssh.sshRunScript({
1553
+ host: ipAddress,
1554
+ scriptPath,
1555
+ args: scriptArgs,
1556
+ env: githubEnv,
1557
+ remotePath: '/tmp/kubeadm-node-setup.sh',
1558
+ keyPath,
1559
+ retries: 2,
1560
+ waitForPortMs: 5 * 60 * 1000,
1561
+ });
1562
+
1563
+ if (result.ok) {
1564
+ logger.info(`✓ Kubeadm ${role} node setup completed on ${hostname}`, {
1565
+ stdout: result.stdout.split('\n').slice(-12).join('\n'),
1566
+ });
1567
+ } else {
1568
+ logger.error(`✗ Kubeadm ${role} node setup failed on ${hostname}`, {
1569
+ code: result.code,
1570
+ stderr: result.stderr.slice(-600),
1571
+ });
1160
1572
  }
1161
1573
  },
1162
1574
 
1575
+ /**
1576
+ * @method remoteInstallOrchestrator
1577
+ * @description Waits for the ephemeral runtime's 'ssh-ready' lifecycle event,
1578
+ * then triggers the unattended Rocky install over a key-only SSH batch
1579
+ * command. Returns structured success/failure information. Non-fatal: logs
1580
+ * and returns on failure so the runtime's AUTO_INSTALL fallback can proceed.
1581
+ * @param {object} params
1582
+ * @param {string} params.hostname - Hostname key used by the bootstrap POST sink.
1583
+ * @param {string} params.ipAddress - Fallback IP if the runtime did not report one.
1584
+ * @param {number} [params.sshPort=22] - SSH port of the ephemeral runtime.
1585
+ * @param {string} [params.keyPath] - Private key path for key-only auth.
1586
+ * @returns {Promise<{ok: boolean, host: string, result?: object, reason?: string}>}
1587
+ * @memberof UnderpostBaremetal
1588
+ */
1589
+ async remoteInstallOrchestrator({ hostname, ipAddress, sshPort = 22, keyPath = 'engine-private/deploy/id_rsa' }) {
1590
+ logger.info('Awaiting ephemeral runtime ssh-ready handshake...', { hostname, ipAddress });
1591
+ const readyEvent = await Underpost.baremetal.waitForBootstrapStage({ hostname, stage: 'ssh-ready' });
1592
+
1593
+ const host = readyEvent?.ip && readyEvent.ip !== 'UNKNOWN' ? readyEvent.ip : ipAddress;
1594
+ if (!readyEvent) {
1595
+ logger.warn('No ssh-ready event received; relying on runtime AUTO_INSTALL fallback', { host });
1596
+ return { ok: false, host, reason: 'no ssh-ready event' };
1597
+ }
1598
+ logger.info('SSH-ready event received', { host, metadata: readyEvent });
1599
+
1600
+ // Key-only, non-interactive trigger of the on-host installer. We only DROP
1601
+ // the trigger file here; the persistent %pre lifecycle loop (a child of
1602
+ // Anaconda, not of this sshd session) launches the installer fully
1603
+ // detached. Running the installer directly as an sshd child causes it to
1604
+ // be torn down when the SSH channel closes, before it can even start.
1605
+ const result = await Underpost.ssh.sshExecBatch({
1606
+ host,
1607
+ port: sshPort,
1608
+ user: 'root',
1609
+ keyPath,
1610
+ waitForPortMs: 5 * 60 * 1000,
1611
+ retries: 4,
1612
+ command: ['set -e', 'touch /tmp/.underpost-install-trigger', 'echo "install trigger dropped"'].join('\n'),
1613
+ });
1614
+
1615
+ if (result.ok) {
1616
+ logger.info('✓ Remote install trigger dropped', { host, stdout: result.stdout.trim() });
1617
+ } else {
1618
+ logger.error('Remote install trigger failed; runtime AUTO_INSTALL fallback will cover it', {
1619
+ host,
1620
+ code: result.code,
1621
+ stderr: result.stderr.slice(-400),
1622
+ });
1623
+ }
1624
+
1625
+ // Follow the install lifecycle so the controller reflects real progress.
1626
+ const started = await Underpost.baremetal.waitForBootstrapStage({
1627
+ hostname,
1628
+ stage: 'install-start',
1629
+ timeoutMs: 3 * 60 * 1000,
1630
+ });
1631
+ if (!started) {
1632
+ logger.warn('Installer did not report install-start within 3m; check /tmp/underpost-install.log on the node', {
1633
+ host,
1634
+ });
1635
+ return { ok: false, host, reason: 'no install-start', result };
1636
+ }
1637
+ logger.info('Installer launched on node', { host });
1638
+
1639
+ // Wait for a terminal stage. The node reboots on success, so 'completed'
1640
+ // is the last event we will receive.
1641
+ const terminal = await Underpost.baremetal.waitForBootstrapStage({
1642
+ hostname,
1643
+ stage: 'completed',
1644
+ timeoutMs: 40 * 60 * 1000,
1645
+ });
1646
+ if (terminal) {
1647
+ logger.info('✓ Rocky install completed; node rebooting into deployed OS', { host, event: terminal });
1648
+ return { ok: true, host, result, terminal };
1649
+ }
1650
+
1651
+ const failed = (UnderpostBaremetal.bootstrapStatusEvents.get(hostname) || []).find((e) => e.stage === 'failed');
1652
+ if (failed) {
1653
+ logger.error('✗ Rocky install reported failure', { host, event: failed });
1654
+ return { ok: false, host, reason: 'install failed', result, failed };
1655
+ }
1656
+ logger.warn('Install did not reach a terminal stage within timeout', { host });
1657
+ return { ok: false, host, reason: 'no terminal stage', result };
1658
+ },
1659
+
1163
1660
  /**
1164
1661
  * @method installPacker
1165
1662
  * @description Installs Packer CLI.
@@ -2094,6 +2591,136 @@ shell
2094
2591
  return port || workflowsConfig[workflowId]?.bootstrapHttpServerPort || 8888;
2095
2592
  },
2096
2593
 
2594
+ /**
2595
+ * @method resolveSshKeyPaths
2596
+ * @description Resolves the SSH key pair and login user for commissioning/
2597
+ * orchestration. The key pair always lives at `<dir>/id_rsa` + `<dir>/id_rsa.pub`.
2598
+ *
2599
+ * Key-dir precedence:
2600
+ * 1. `--ssh-key-dir <dir>` (explicit path)
2601
+ * 2. `--deploy-id <id>` (+ `--user <user>`) → `engine-private/conf/<id>/users/<user>`
2602
+ * (the same user↔deployId↔key convention `src/cli/ssh.js` writes; no key path needed)
2603
+ * 3. workflow `sshKeyDir`
2604
+ * 4. workflow `deployId` (+ `user`)
2605
+ * 5. default `engine-private/deploy`
2606
+ *
2607
+ * User precedence: `--user` > workflow `user` > `root`. A leading `~` in the
2608
+ * resolved dir is expanded to the user's home.
2609
+ * @param {object} params
2610
+ * @param {object} [params.options={}] - CLI options (sshKeyDir, deployId, user).
2611
+ * @param {object} [params.workflowsConfig={}] - Loaded workflows config.
2612
+ * @param {string} [params.workflowId=''] - Active workflow id.
2613
+ * @returns {{ keyDir: string, privateKeyPath: string, publicKeyPath: string, user: string, deployId: string }}
2614
+ * @memberof UnderpostBaremetal
2615
+ */
2616
+ resolveSshKeyPaths({ options = {}, workflowsConfig = {}, workflowId = '' } = {}) {
2617
+ const wf = workflowsConfig?.[workflowId] || {};
2618
+ const user = options.user || wf.user || 'root';
2619
+ const deployId = options.deployId || wf.deployId || '';
2620
+
2621
+ let dir;
2622
+ if (options.sshKeyDir) dir = options.sshKeyDir;
2623
+ else if (options.deployId) dir = `engine-private/conf/${options.deployId}/users/${user}`;
2624
+ else if (wf.sshKeyDir) dir = wf.sshKeyDir;
2625
+ else if (deployId) dir = `engine-private/conf/${deployId}/users/${user}`;
2626
+ else dir = 'engine-private/deploy';
2627
+
2628
+ if (dir.startsWith('~')) dir = path.join(process.env.HOME || '', dir.slice(1));
2629
+ return { keyDir: dir, privateKeyPath: `${dir}/id_rsa`, publicKeyPath: `${dir}/id_rsa.pub`, user, deployId };
2630
+ },
2631
+
2632
+ /**
2633
+ * @method resolveInstallCredentials
2634
+ * @description Resolves the login accounts baked into the deployed OS. Always
2635
+ * creates TWO admin accounts so a console login is guaranteed:
2636
+ * 1. the MAAS admin (MAAS_ADMIN_USERNAME / MAAS_ADMIN_PASS) — password login,
2637
+ * 2. the deploy user (`--user`, e.g. admin) — its conf.node.json
2638
+ * password (or MAAS_ADMIN_PASS), plus the SSH key.
2639
+ * root always gets MAAS_ADMIN_PASS. The deploy user/password come from
2640
+ * `engine-private/conf/<deployId>/conf.node.json` (`users[user]`).
2641
+ * @param {object} params
2642
+ * @param {object} [params.options={}] - CLI options.
2643
+ * @param {object} [params.workflowsConfig={}] - Loaded workflows config.
2644
+ * @param {string} [params.workflowId=''] - Active workflow id.
2645
+ * @returns {{ rootPassword: string, adminUsername: string, adminPassword: string, deployUsername: string, deployPassword: string, confUser: object }}
2646
+ * @memberof UnderpostBaremetal
2647
+ */
2648
+ resolveInstallCredentials({ options = {}, workflowsConfig = {}, workflowId = '' } = {}) {
2649
+ const { user, deployId } = Underpost.baremetal.resolveSshKeyPaths({ options, workflowsConfig, workflowId });
2650
+
2651
+ let confUser = {};
2652
+ if (deployId) {
2653
+ const confPath = `engine-private/conf/${deployId}/conf.node.json`;
2654
+ if (fs.existsSync(confPath)) {
2655
+ try {
2656
+ confUser = JSON.parse(fs.readFileSync(confPath, 'utf8'))?.users?.[user] || {};
2657
+ } catch (error) {
2658
+ logger.warn(`Failed to parse ${confPath}: ${error.message}`);
2659
+ }
2660
+ } else {
2661
+ logger.warn(`conf.node.json not found at ${confPath}; using MAAS_ADMIN_* defaults`);
2662
+ }
2663
+ }
2664
+
2665
+ const maasPass = process.env.MAAS_ADMIN_PASS || '';
2666
+ const rootPassword = maasPass;
2667
+ // User 1: the MAAS admin — guaranteed password login on the console.
2668
+ const adminUsername = process.env.MAAS_ADMIN_USERNAME || 'maas';
2669
+ const adminPassword = maasPass;
2670
+ // User 2: the deploy user (only when distinct from root/maas admin).
2671
+ const deployUsername = deployId && user && user !== 'root' && user !== adminUsername ? user : '';
2672
+ const deployPassword = deployUsername ? confUser.password || maasPass : '';
2673
+
2674
+ if (!rootPassword) {
2675
+ logger.warn(
2676
+ 'MAAS_ADMIN_PASS is empty — console password login will not work. Set MAAS_ADMIN_PASS in .env so root/admin have a usable password.',
2677
+ );
2678
+ }
2679
+ return { rootPassword, adminUsername, adminPassword, deployUsername, deployPassword, confUser };
2680
+ },
2681
+
2682
+ /**
2683
+ * @method readEngineConfig
2684
+ * @description Reads an engine config/secret value (e.g. GITHUB_TOKEN) on the
2685
+ * controller. Prefers the loaded process env, falling back to
2686
+ * `node bin config get --plain <key>`.
2687
+ * @param {string} key - Config/env key name.
2688
+ * @returns {string} The resolved value, or '' if unset.
2689
+ * @memberof UnderpostBaremetal
2690
+ */
2691
+ readEngineConfig(key) {
2692
+ if (process.env[key]) return process.env[key];
2693
+ const out = shellExec(`node bin config get --plain ${key}`, {
2694
+ stdout: true,
2695
+ silent: true,
2696
+ silentOnError: true,
2697
+ disableLog: true,
2698
+ });
2699
+ const value = `${out || ''}`.trim();
2700
+ return value && value !== 'undefined' ? value : '';
2701
+ },
2702
+
2703
+ /**
2704
+ * @method resolveInstalledNetwork
2705
+ * @description Resolves the static network config written into the deployed OS
2706
+ * so the controller can reach it at a known IP after reboot. Uses the same IP
2707
+ * the commission flow assigned, a /prefix from the netmask, the subnet's `.1`
2708
+ * as gateway, and the configured DNS.
2709
+ * @param {object} params
2710
+ * @param {string} params.ipAddress - Static IP for the deployed OS.
2711
+ * @param {string} params.netmask - Dotted netmask (e.g. 255.255.255.0).
2712
+ * @param {string} params.dnsServer - DNS server.
2713
+ * @returns {{ netIp: string, netPrefix: number, netGateway: string, netDns: string }}
2714
+ * @memberof UnderpostBaremetal
2715
+ */
2716
+ resolveInstalledNetwork({ ipAddress, netmask, dnsServer }) {
2717
+ const prefix = `${netmask || ''}`
2718
+ .split('.')
2719
+ .reduce((acc, oct) => acc + ((parseInt(oct, 10).toString(2).match(/1/g) || []).length || 0), 0);
2720
+ const netGateway = `${ipAddress || ''}`.replace(/\.\d+$/, '.1');
2721
+ return { netIp: ipAddress || '', netPrefix: prefix || 24, netGateway, netDns: dnsServer || '8.8.8.8' };
2722
+ },
2723
+
2097
2724
  /**
2098
2725
  * @method commissioningWriteFilesFactory
2099
2726
  * @description Generates the write_files configuration for the commissioning script.
@@ -2212,6 +2839,38 @@ fi
2212
2839
 
2213
2840
  app.use(loggerMiddleware(import.meta, 'debug', () => false));
2214
2841
 
2842
+ // Lifecycle event sink. The ephemeral Kickstart/Anaconda runtime POSTs
2843
+ // stage transitions (ssh-ready, installing, completed, …) here so the
2844
+ // controller can observe progress without polling the machine itself.
2845
+ // Events are kept in-memory (consumed by the orchestration step) and
2846
+ // mirrored to disk for auditing.
2847
+ app.use(express.json({ limit: '256kb' }));
2848
+ app.post('/:hostname/status', (req, res) => {
2849
+ const reqHostname = req.params.hostname;
2850
+ const event = {
2851
+ ...(req.body && typeof req.body === 'object' ? req.body : {}),
2852
+ receivedAt: new Date().toISOString(),
2853
+ remoteIp: (req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').toString(),
2854
+ };
2855
+ const stage = event.stage || 'unknown';
2856
+
2857
+ const events = UnderpostBaremetal.bootstrapStatusEvents.get(reqHostname) || [];
2858
+ events.push(event);
2859
+ UnderpostBaremetal.bootstrapStatusEvents.set(reqHostname, events);
2860
+
2861
+ try {
2862
+ const statusDir = `${bootstrapHttpServerPath}/${reqHostname}/status`;
2863
+ fs.ensureDirSync(statusDir);
2864
+ fs.writeFileSync(`${statusDir}/${stage}.json`, JSON.stringify(event, null, 2), 'utf8');
2865
+ fs.appendFileSync(`${statusDir}/events.log`, `${JSON.stringify(event)}\n`, 'utf8');
2866
+ } catch (error) {
2867
+ logger.warn('Failed to persist bootstrap status event', { error: error.message });
2868
+ }
2869
+
2870
+ logger.info(`Bootstrap status event [${reqHostname}] stage=${stage}`, event);
2871
+ res.json({ ok: true, stage });
2872
+ });
2873
+
2215
2874
  app.use('/', express.static(bootstrapHttpServerPath));
2216
2875
 
2217
2876
  app.listen(port, () => {
@@ -2481,6 +3140,48 @@ fi
2481
3140
  return { cmd: cmdStr };
2482
3141
  },
2483
3142
 
3143
+ /**
3144
+ * @method waitForBootstrapStage
3145
+ * @description Polls the in-memory bootstrap status events (populated by the
3146
+ * bootstrap HTTP server POST sink) until the target stage is reported by the
3147
+ * ephemeral runtime, or the timeout elapses.
3148
+ * @param {object} params
3149
+ * @param {string} params.hostname - Hostname key the runtime reports under.
3150
+ * @param {string} params.stage - Stage to wait for (e.g. 'ssh-ready').
3151
+ * @param {number} [params.timeoutMs=900000] - Maximum wait time in ms.
3152
+ * @param {number} [params.intervalMs=2000] - Poll interval in ms.
3153
+ * @returns {Promise<object|null>} The matching event, or null on timeout.
3154
+ * @memberof UnderpostBaremetal
3155
+ */
3156
+ async waitForBootstrapStage({ hostname, stage, timeoutMs = 15 * 60 * 1000, intervalMs = 2000 }) {
3157
+ const deadline = Date.now() + timeoutMs;
3158
+ let lastSeenCount = -1;
3159
+ let lastHeartbeat = 0;
3160
+ const heartbeatMs = 15000;
3161
+ while (Date.now() < deadline) {
3162
+ const events = UnderpostBaremetal.bootstrapStatusEvents.get(hostname) || [];
3163
+ const match = events.find((e) => e.stage === stage);
3164
+ if (match) return match;
3165
+
3166
+ const now = Date.now();
3167
+ if (events.length !== lastSeenCount || now - lastHeartbeat >= heartbeatMs) {
3168
+ lastSeenCount = events.length;
3169
+ lastHeartbeat = now;
3170
+ const remainingSec = Math.max(0, Math.round((deadline - now) / 1000));
3171
+ logger.info(`Waiting for stage '${stage}' from ${hostname}`, {
3172
+ stagesSeen: events.map((e) => e.stage),
3173
+ eventCount: events.length,
3174
+ remainingSec,
3175
+ });
3176
+ }
3177
+ await timer(intervalMs);
3178
+ }
3179
+ logger.warn(`Timed out waiting for bootstrap stage '${stage}' from ${hostname}`, {
3180
+ stagesSeen: (UnderpostBaremetal.bootstrapStatusEvents.get(hostname) || []).map((e) => e.stage),
3181
+ });
3182
+ return null;
3183
+ },
3184
+
2484
3185
  /**
2485
3186
  * @method commissionMonitor
2486
3187
  * @description Monitors the MAAS discoveries and initiates machine creation and commissioning