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.
- package/.github/workflows/ghpkg.ci.yml +1 -1
- package/.github/workflows/gitlab.ci.yml +1 -1
- package/.github/workflows/npmpkg.ci.yml +1 -1
- package/.github/workflows/publish.ci.yml +2 -2
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/CHANGELOG.md +181 -1
- package/CLI-HELP.md +61 -2
- package/README.md +3 -2
- package/baremetal/commission-workflows.json +1 -0
- package/bin/build.js +1 -0
- package/docker-compose.yml +224 -0
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +27 -15
- package/scripts/kubeadm-node-setup.sh +317 -0
- package/scripts/rocky-kickstart.sh +877 -185
- package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
- package/scripts/test-monitor.sh +3 -5
- package/src/cli/baremetal.js +717 -16
- package/src/cli/cluster.js +3 -1
- package/src/cli/deploy.js +118 -9
- package/src/cli/docker-compose.js +591 -0
- package/src/cli/env.js +11 -2
- package/src/cli/fs.js +35 -11
- package/src/cli/index.js +89 -0
- package/src/cli/kickstart.js +142 -20
- package/src/cli/repository.js +153 -11
- package/src/cli/run.js +285 -8
- package/src/cli/secrets.js +7 -2
- package/src/cli/ssh.js +234 -0
- package/src/cli/static.js +2 -2
- package/src/{server → client-builder}/client-build-docs.js +15 -5
- package/src/{server → client-builder}/client-build-live.js +3 -3
- package/src/{server → client-builder}/client-build.js +25 -22
- package/src/{server → client-builder}/client-dev-server.js +3 -3
- package/src/{server → client-builder}/client-icons.js +2 -2
- package/src/{server → client-builder}/ssr.js +5 -5
- package/src/client.build.js +1 -3
- package/src/client.dev.js +1 -1
- package/src/db/mongo/MongoBootstrap.js +12 -12
- package/src/index.js +12 -1
- package/src/mailer/EmailRender.js +1 -1
- package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
- package/src/runtime/express/Express.js +2 -2
- package/src/runtime/nginx/Nginx.js +250 -0
- package/src/server/catalog.js +7 -14
- package/src/server/conf.js +3 -3
- package/src/server/runtime-status.js +18 -1
- package/src/server/start.js +12 -2
- package/src/server.js +6 -2
- package/test/deploy-monitor.test.js +26 -10
- package/typedoc.json +3 -1
- package/src/server/ipfs-client.js +0 -599
- /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
- /package/src/{server → client-builder}/client-formatted.js +0 -0
package/src/cli/baremetal.js
CHANGED
|
@@ -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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|