underpost 3.2.22 → 3.2.28
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 +141 -1
- package/CLI-HELP.md +56 -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/src/cli/baremetal.js +717 -16
- package/src/cli/cluster.js +3 -1
- package/src/cli/deploy.js +6 -2
- package/src/cli/docker-compose.js +591 -0
- package/src/cli/fs.js +35 -11
- package/src/cli/index.js +75 -0
- package/src/cli/kickstart.js +142 -20
- package/src/cli/repository.js +72 -11
- package/src/cli/run.js +253 -2
- package/src/cli/ssh.js +190 -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 -1
- package/src/client.dev.js +1 -1
- 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.js +1 -1
- 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/run.js
CHANGED
|
@@ -373,6 +373,214 @@ class UnderpostRun {
|
|
|
373
373
|
);
|
|
374
374
|
},
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* @method node-move
|
|
378
|
+
* @description Abstract runner that relocates any schedulable Kubernetes workload
|
|
379
|
+
* (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob, ReplicationController)
|
|
380
|
+
* onto a target node by patching its pod-template `nodeSelector` and rolling it out.
|
|
381
|
+
* Resource-kind agnostic: it resolves the kind dynamically and applies the right
|
|
382
|
+
* patch path, so it works for `sts`, `deployment`, etc. without bespoke logic.
|
|
383
|
+
*
|
|
384
|
+
* Selection grammar via `path`:
|
|
385
|
+
* - `<kind>/<name>` -> a single resource (e.g. `deployment/dd-core-production-blue`)
|
|
386
|
+
* - `<kind>` -> every resource of that kind in the namespace (e.g. `statefulset`)
|
|
387
|
+
* - `` -> all movable workloads (deployment, statefulset, daemonset) in the namespace
|
|
388
|
+
*
|
|
389
|
+
* Placement:
|
|
390
|
+
* - default: built-in `kubernetes.io/hostname=<node>` (no node mutation required)
|
|
391
|
+
* - `--labels k=v,...`: label the target node with those pairs and use them as the
|
|
392
|
+
* nodeSelector (matches the "label node + nodeSelector" pattern), enabling reusable
|
|
393
|
+
* workload pools instead of pinning to a single hostname.
|
|
394
|
+
*
|
|
395
|
+
* Flags: `--node-name <node>` (target), `--namespace <ns>`, `--dry-run` (preview only),
|
|
396
|
+
* `--remove` (clear the nodeSelector / unpin placement).
|
|
397
|
+
*
|
|
398
|
+
* Caveats: Services/ConfigMaps and bare Pods are not schedulable controllers and are
|
|
399
|
+
* skipped (move the owning controller). StatefulSets bound to node-local PVs may stay
|
|
400
|
+
* Pending after a move until their volume is available on the target node.
|
|
401
|
+
* @param {string} path - Resource selector (`kind/name`, `kind`, or empty).
|
|
402
|
+
* @param {Object} options - The default underpost runner options for customizing workflow
|
|
403
|
+
* @memberof UnderpostRun
|
|
404
|
+
* @returns {Array<{ref:string,kind:string,status:string,node?:string}>} Per-resource outcome.
|
|
405
|
+
*/
|
|
406
|
+
'node-move': (path = '', options = DEFAULT_OPTION) => {
|
|
407
|
+
const node = options.nodeName;
|
|
408
|
+
const ns = options.namespace || 'default';
|
|
409
|
+
const dryRun = options.dryRun === true;
|
|
410
|
+
const remove = options.remove === true;
|
|
411
|
+
|
|
412
|
+
if (!remove && !node) {
|
|
413
|
+
throw new Error('node-move requires --node-name <target-node> (or --remove to clear placement)');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const normalizeKind = (k) =>
|
|
417
|
+
({
|
|
418
|
+
deploy: 'deployment',
|
|
419
|
+
deployments: 'deployment',
|
|
420
|
+
deployment: 'deployment',
|
|
421
|
+
sts: 'statefulset',
|
|
422
|
+
statefulsets: 'statefulset',
|
|
423
|
+
statefulset: 'statefulset',
|
|
424
|
+
ds: 'daemonset',
|
|
425
|
+
daemonsets: 'daemonset',
|
|
426
|
+
daemonset: 'daemonset',
|
|
427
|
+
rs: 'replicaset',
|
|
428
|
+
replicasets: 'replicaset',
|
|
429
|
+
replicaset: 'replicaset',
|
|
430
|
+
rc: 'replicationcontroller',
|
|
431
|
+
replicationcontroller: 'replicationcontroller',
|
|
432
|
+
job: 'job',
|
|
433
|
+
jobs: 'job',
|
|
434
|
+
cj: 'cronjob',
|
|
435
|
+
cronjob: 'cronjob',
|
|
436
|
+
cronjobs: 'cronjob',
|
|
437
|
+
po: 'pod',
|
|
438
|
+
pod: 'pod',
|
|
439
|
+
pods: 'pod',
|
|
440
|
+
svc: 'service',
|
|
441
|
+
service: 'service',
|
|
442
|
+
services: 'service',
|
|
443
|
+
})[k] || k;
|
|
444
|
+
|
|
445
|
+
// Kinds that own a pod template we can patch; rolloutKinds additionally
|
|
446
|
+
// support `kubectl rollout restart` to reschedule existing pods now.
|
|
447
|
+
const templated = ['deployment', 'statefulset', 'daemonset', 'replicaset', 'job', 'cronjob', 'replicationcontroller'];
|
|
448
|
+
const rolloutKinds = ['deployment', 'statefulset', 'daemonset'];
|
|
449
|
+
const templateSelectorPath = (kind) =>
|
|
450
|
+
kind === 'cronjob'
|
|
451
|
+
? ['spec', 'jobTemplate', 'spec', 'template', 'spec', 'nodeSelector']
|
|
452
|
+
: ['spec', 'template', 'spec', 'nodeSelector'];
|
|
453
|
+
|
|
454
|
+
// Resolve the desired nodeSelector. Custom --labels enables reusable pools;
|
|
455
|
+
// otherwise pin by the always-present hostname label.
|
|
456
|
+
let selector = { 'kubernetes.io/hostname': node };
|
|
457
|
+
if (!remove && options.labels) {
|
|
458
|
+
selector = {};
|
|
459
|
+
for (const pair of `${options.labels}`.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
460
|
+
const eq = pair.indexOf('=');
|
|
461
|
+
if (eq < 0) continue;
|
|
462
|
+
selector[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Verify the target node exists, and apply custom labels to it if provided.
|
|
467
|
+
if (!remove) {
|
|
468
|
+
const found = shellExec(`kubectl get node ${node} -o name`, {
|
|
469
|
+
silent: true,
|
|
470
|
+
stdout: true,
|
|
471
|
+
silentOnError: true,
|
|
472
|
+
}).trim();
|
|
473
|
+
if (!found) throw new Error(`Target node not found: ${node}`);
|
|
474
|
+
if (options.labels) {
|
|
475
|
+
const labelArgs = Object.entries(selector)
|
|
476
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
477
|
+
.join(' ');
|
|
478
|
+
const labelCmd = `kubectl label node ${node} ${labelArgs} --overwrite`;
|
|
479
|
+
if (dryRun) logger.info(`[dry-run] ${labelCmd}`);
|
|
480
|
+
else shellExec(labelCmd);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const kubectlNames = (kind) =>
|
|
485
|
+
(
|
|
486
|
+
shellExec(`kubectl get ${kind} -n ${ns} -o name`, { silent: true, stdout: true, silentOnError: true }).trim() ||
|
|
487
|
+
''
|
|
488
|
+
)
|
|
489
|
+
.split('\n')
|
|
490
|
+
.map((s) => s.trim())
|
|
491
|
+
.filter(Boolean);
|
|
492
|
+
|
|
493
|
+
// Build the list of "kind/name" targets from the selection grammar.
|
|
494
|
+
let targets = [];
|
|
495
|
+
if (!path) {
|
|
496
|
+
for (const kind of ['deployment', 'statefulset', 'daemonset']) targets.push(...kubectlNames(kind));
|
|
497
|
+
} else if (path.includes('/')) {
|
|
498
|
+
targets = [path];
|
|
499
|
+
} else {
|
|
500
|
+
targets = kubectlNames(path);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (targets.length === 0) {
|
|
504
|
+
logger.warn('node-move: no matching resources found', { path, namespace: ns });
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Merge-patch body that sets (or clears, when --remove) the nodeSelector.
|
|
509
|
+
const buildPatch = (kind) => {
|
|
510
|
+
const keys = templateSelectorPath(kind);
|
|
511
|
+
let obj = remove ? null : selector;
|
|
512
|
+
for (let i = keys.length - 1; i >= 0; i--) obj = { [keys[i]]: obj };
|
|
513
|
+
return JSON.stringify(obj);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const results = [];
|
|
517
|
+
for (const ref of targets) {
|
|
518
|
+
const slash = ref.indexOf('/');
|
|
519
|
+
const rawKind = slash >= 0 ? ref.slice(0, slash) : path && !path.includes('/') ? path : '';
|
|
520
|
+
const name = slash >= 0 ? ref.slice(slash + 1) : ref;
|
|
521
|
+
const kind = normalizeKind(`${rawKind}`.split('.')[0].toLowerCase());
|
|
522
|
+
|
|
523
|
+
if (!templated.includes(kind)) {
|
|
524
|
+
logger.warn(`node-move: ${kind}/${name} is not a schedulable controller; skipping`, {
|
|
525
|
+
hint: 'move its owning controller (deployment/statefulset/daemonset) instead',
|
|
526
|
+
});
|
|
527
|
+
results.push({ ref, kind, status: 'skipped' });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Idempotency: skip the patch + rollout if the resource is already where
|
|
532
|
+
// we want it. Compares the live pod-template nodeSelector against the
|
|
533
|
+
// desired placement so a repeated run does not trigger an unnecessary
|
|
534
|
+
// rollout restart.
|
|
535
|
+
const basePath = kind === 'cronjob' ? 'spec.jobTemplate.spec.template.spec' : 'spec.template.spec';
|
|
536
|
+
const jsonpath = (expr) =>
|
|
537
|
+
shellExec(`kubectl get ${kind} ${name} -n ${ns} -o jsonpath='${expr}'`, {
|
|
538
|
+
silent: true,
|
|
539
|
+
stdout: true,
|
|
540
|
+
silentOnError: true,
|
|
541
|
+
disableLog: true,
|
|
542
|
+
}).trim();
|
|
543
|
+
|
|
544
|
+
if (remove) {
|
|
545
|
+
const current = jsonpath(`{.${basePath}.nodeSelector}`);
|
|
546
|
+
if (!current || current === 'map[]') {
|
|
547
|
+
logger.info(`node-move: ${kind}/${name} already has no nodeSelector; nothing to clear`);
|
|
548
|
+
results.push({ ref, kind, status: 'already-cleared' });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
const alreadyOnNode = Object.entries(selector).every(([k, v]) => {
|
|
553
|
+
const esc = k.replace(/\./g, '\\.');
|
|
554
|
+
return jsonpath(`{.${basePath}.nodeSelector.${esc}}`) === v;
|
|
555
|
+
});
|
|
556
|
+
if (alreadyOnNode) {
|
|
557
|
+
logger.info(`node-move: ${kind}/${name} already pinned to ${node}; skipping`, { namespace: ns });
|
|
558
|
+
results.push({ ref, kind, status: 'already-on-node', node });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const patchCmd = `kubectl patch ${kind} ${name} -n ${ns} --type=merge -p '${buildPatch(kind)}'`;
|
|
564
|
+
const restartCmd = `kubectl rollout restart ${kind} ${name} -n ${ns}`;
|
|
565
|
+
if (dryRun) {
|
|
566
|
+
logger.info(`[dry-run] ${patchCmd}`);
|
|
567
|
+
if (rolloutKinds.includes(kind)) logger.info(`[dry-run] ${restartCmd}`);
|
|
568
|
+
results.push({ ref, kind, status: 'dry-run', node: remove ? undefined : node });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
shellExec(patchCmd);
|
|
573
|
+
if (rolloutKinds.includes(kind)) shellExec(restartCmd);
|
|
574
|
+
logger.info(remove ? `Cleared node placement: ${kind}/${name}` : `Moved ${kind}/${name} -> ${node}`, {
|
|
575
|
+
namespace: ns,
|
|
576
|
+
});
|
|
577
|
+
results.push({ ref, kind, status: remove ? 'cleared' : 'moved', node: remove ? undefined : node });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
logger.info('node-move complete', { namespace: ns, node: remove ? null : node, count: results.length });
|
|
581
|
+
return results;
|
|
582
|
+
},
|
|
583
|
+
|
|
376
584
|
/**
|
|
377
585
|
* @method dev-hosts-expose
|
|
378
586
|
* @description Deploys a specified service in development mode with `/etc/hosts` modification for local access.
|
|
@@ -1443,8 +1651,24 @@ gpgcheck=1
|
|
|
1443
1651
|
gpgkey=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/repodata/repomd.xml.key
|
|
1444
1652
|
EOF`);
|
|
1445
1653
|
shellExec(`sudo dnf -y install cri-o`);
|
|
1446
|
-
//
|
|
1447
|
-
|
|
1654
|
+
// Add the Kubernetes repo so cri-tools (crictl CLI) is available.
|
|
1655
|
+
// The repo has exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni, so we
|
|
1656
|
+
// use --disableexcludes=kubernetes to override and install cri-tools.
|
|
1657
|
+
shellExec(`cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
|
|
1658
|
+
[kubernetes]
|
|
1659
|
+
name=Kubernetes
|
|
1660
|
+
baseurl=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/
|
|
1661
|
+
enabled=1
|
|
1662
|
+
gpgcheck=1
|
|
1663
|
+
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/repodata/repomd.xml.key
|
|
1664
|
+
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
|
|
1665
|
+
EOF`);
|
|
1666
|
+
shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes 2>/dev/null || {
|
|
1667
|
+
CRICTL_VERSION="v1.31.0"
|
|
1668
|
+
ARCH="amd64"
|
|
1669
|
+
log "Kubernetes repo not available, downloading crictl binary directly..."
|
|
1670
|
+
curl -sL "https://github.com/kubernetes-sigs/cri-tools/releases/download/\${CRICTL_VERSION}/crictl-\${CRICTL_VERSION}-\${ARCH}.tar.gz" | sudo tar -C /usr/local/bin -xz
|
|
1671
|
+
}`);
|
|
1448
1672
|
// Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
|
|
1449
1673
|
shellExec(`sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf`, {
|
|
1450
1674
|
silentOnError: true,
|
|
@@ -2775,6 +2999,33 @@ EOF`;
|
|
|
2775
2999
|
|
|
2776
3000
|
logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
|
|
2777
3001
|
},
|
|
3002
|
+
/**
|
|
3003
|
+
* @method shared-dir-add-user
|
|
3004
|
+
* @description Add a user to an existing shared directory without changing
|
|
3005
|
+
* file owners. Grants recursive group/ACL access so the user can read and
|
|
3006
|
+
* write throughout the shared workspace while preserving existing ownership.
|
|
3007
|
+
*
|
|
3008
|
+
* @param {string} path - Shared directory (defaults to `/home/dd/engine`).
|
|
3009
|
+
* @param {Object} options - Underpost runner options.
|
|
3010
|
+
* Key fields:
|
|
3011
|
+
* - options.user (default: 'admin')
|
|
3012
|
+
* - options.group (default: 'engine-dev')
|
|
3013
|
+
*
|
|
3014
|
+
* @memberof UnderpostRun
|
|
3015
|
+
*/ 'shared-dir-add-user': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
|
|
3016
|
+
const dir = path || '/home/dd/engine';
|
|
3017
|
+
const user = options.user || 'admin';
|
|
3018
|
+
|
|
3019
|
+
logger.info(`[shared-dir-add-user] dir=${dir} user=${user}`);
|
|
3020
|
+
|
|
3021
|
+
// Give the user direct access without changing owners or group ownership.
|
|
3022
|
+
shellExec(`sudo setfacl -R -m u:${user}:rwx ${dir}`);
|
|
3023
|
+
|
|
3024
|
+
// Make future files/directories inherit the same user ACL.
|
|
3025
|
+
shellExec(`sudo find ${dir} -type d -exec setfacl -d -m u:${user}:rwx {} \\;`);
|
|
3026
|
+
|
|
3027
|
+
logger.info(`[shared-dir-add-user] User '${user}' added to shared directory: ${dir}`);
|
|
3028
|
+
},
|
|
2778
3029
|
};
|
|
2779
3030
|
|
|
2780
3031
|
static API = {
|
package/src/cli/ssh.js
CHANGED
|
@@ -545,6 +545,196 @@ EOF
|
|
|
545
545
|
return shellExec(sshScript, { stdout: true });
|
|
546
546
|
},
|
|
547
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Waits until a TCP SSH port becomes reachable on a host.
|
|
550
|
+
* @async
|
|
551
|
+
* @function waitForSshPort
|
|
552
|
+
* @memberof UnderpostSSH
|
|
553
|
+
* @param {object} params
|
|
554
|
+
* @param {string} params.host - Target host/IP.
|
|
555
|
+
* @param {number} [params.port=22] - SSH port.
|
|
556
|
+
* @param {number} [params.timeoutMs=600000] - Maximum wait window.
|
|
557
|
+
* @param {number} [params.intervalMs=3000] - Poll interval.
|
|
558
|
+
* @returns {Promise<boolean>} True once the port accepts connections, false on timeout.
|
|
559
|
+
*/
|
|
560
|
+
waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => {
|
|
561
|
+
const deadline = Date.now() + timeoutMs;
|
|
562
|
+
while (Date.now() < deadline) {
|
|
563
|
+
const probe = shellExec(
|
|
564
|
+
`timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
|
|
565
|
+
{ silent: true, stdout: true, silentOnError: true, disableLog: true },
|
|
566
|
+
);
|
|
567
|
+
if (`${probe}`.trim() === 'open') return true;
|
|
568
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
569
|
+
}
|
|
570
|
+
logger.warn(`SSH port ${host}:${port} not reachable within timeout`);
|
|
571
|
+
return false;
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Waits until a host's SSH port stops accepting connections (e.g. while it
|
|
576
|
+
* reboots). Used to detect a reboot edge before waiting for the port to come
|
|
577
|
+
* back up, so callers don't latch onto the pre-reboot (ephemeral) sshd.
|
|
578
|
+
* @async
|
|
579
|
+
* @function waitForSshPortClosed
|
|
580
|
+
* @memberof UnderpostSSH
|
|
581
|
+
* @param {object} params
|
|
582
|
+
* @param {string} params.host - Target host/IP.
|
|
583
|
+
* @param {number} [params.port=22] - SSH port.
|
|
584
|
+
* @param {number} [params.timeoutMs=180000] - Maximum wait window.
|
|
585
|
+
* @param {number} [params.intervalMs=3000] - Poll interval.
|
|
586
|
+
* @returns {Promise<boolean>} True once the port is closed, false on timeout.
|
|
587
|
+
*/
|
|
588
|
+
waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => {
|
|
589
|
+
const deadline = Date.now() + timeoutMs;
|
|
590
|
+
while (Date.now() < deadline) {
|
|
591
|
+
const probe = shellExec(
|
|
592
|
+
`timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
|
|
593
|
+
{ silent: true, stdout: true, silentOnError: true, disableLog: true },
|
|
594
|
+
);
|
|
595
|
+
if (`${probe}`.trim() === 'closed') return true;
|
|
596
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
597
|
+
}
|
|
598
|
+
return false;
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Orchestrates a non-interactive, key-only SSH session against a freshly
|
|
603
|
+
* provisioned host: waits for the port, attempts key-based auth, runs a
|
|
604
|
+
* remote command batch, and returns a structured result. Used by the
|
|
605
|
+
* commissioning flow once the ephemeral runtime reports SSH readiness.
|
|
606
|
+
* @async
|
|
607
|
+
* @function sshExecBatch
|
|
608
|
+
* @memberof UnderpostSSH
|
|
609
|
+
* @param {object} params
|
|
610
|
+
* @param {string} params.host - Target host/IP.
|
|
611
|
+
* @param {string} params.command - Remote command batch to execute.
|
|
612
|
+
* @param {number} [params.port=22] - SSH port.
|
|
613
|
+
* @param {string} [params.user='root'] - SSH user (key-only).
|
|
614
|
+
* @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
|
|
615
|
+
* @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout.
|
|
616
|
+
* @param {number} [params.retries=3] - Auth/exec retry attempts.
|
|
617
|
+
* @param {number} [params.retryDelayMs=5000] - Base backoff between retries.
|
|
618
|
+
* @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
|
|
619
|
+
* @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
|
|
620
|
+
*/
|
|
621
|
+
sshExecBatch: async ({
|
|
622
|
+
host,
|
|
623
|
+
command,
|
|
624
|
+
port = 22,
|
|
625
|
+
user = 'root',
|
|
626
|
+
keyPath = './engine-private/deploy/id_rsa',
|
|
627
|
+
connectTimeoutSec = 15,
|
|
628
|
+
retries = 3,
|
|
629
|
+
retryDelayMs = 5000,
|
|
630
|
+
waitForPortMs = 0,
|
|
631
|
+
}) => {
|
|
632
|
+
if (!host) throw new Error('sshExecBatch requires a host');
|
|
633
|
+
if (!command) throw new Error('sshExecBatch requires a command');
|
|
634
|
+
|
|
635
|
+
if (waitForPortMs > 0) {
|
|
636
|
+
const reachable = await Underpost.ssh.waitForSshPort({ host, port, timeoutMs: waitForPortMs });
|
|
637
|
+
if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
|
|
641
|
+
|
|
642
|
+
const sshOpts = [
|
|
643
|
+
`-i ${keyPath}`,
|
|
644
|
+
`-o BatchMode=yes`,
|
|
645
|
+
`-o PreferredAuthentications=publickey`,
|
|
646
|
+
`-o PubkeyAuthentication=yes`,
|
|
647
|
+
`-o PasswordAuthentication=no`,
|
|
648
|
+
`-o StrictHostKeyChecking=no`,
|
|
649
|
+
`-o UserKnownHostsFile=/dev/null`,
|
|
650
|
+
`-o ConnectTimeout=${connectTimeoutSec}`,
|
|
651
|
+
// Tolerate a freshly-booted node whose network briefly flaps (e.g. while
|
|
652
|
+
// NetworkManager applies a static profile): retry the TCP connect and
|
|
653
|
+
// keep the session alive across short stalls.
|
|
654
|
+
`-o ConnectionAttempts=3`,
|
|
655
|
+
`-o ServerAliveInterval=10`,
|
|
656
|
+
`-o ServerAliveCountMax=6`,
|
|
657
|
+
`-p ${port}`,
|
|
658
|
+
].join(' ');
|
|
659
|
+
|
|
660
|
+
let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 };
|
|
661
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
662
|
+
const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, {
|
|
663
|
+
stdout: false,
|
|
664
|
+
silentOnError: true,
|
|
665
|
+
disableLog: true,
|
|
666
|
+
});
|
|
667
|
+
last = {
|
|
668
|
+
ok: result.code === 0,
|
|
669
|
+
code: result.code,
|
|
670
|
+
stdout: result.stdout || '',
|
|
671
|
+
stderr: result.stderr || '',
|
|
672
|
+
attempts: attempt,
|
|
673
|
+
};
|
|
674
|
+
if (last.ok) {
|
|
675
|
+
logger.info(`sshExecBatch succeeded on ${user}@${host}:${port} (attempt ${attempt})`);
|
|
676
|
+
return last;
|
|
677
|
+
}
|
|
678
|
+
logger.warn(`sshExecBatch attempt ${attempt}/${retries} failed on ${user}@${host}:${port}`, {
|
|
679
|
+
code: last.code,
|
|
680
|
+
stderr: last.stderr.slice(-400),
|
|
681
|
+
});
|
|
682
|
+
if (attempt < retries) await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
|
|
683
|
+
}
|
|
684
|
+
return last;
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Transfers a local script to a remote host and runs it over key-only SSH.
|
|
689
|
+
* The script is base64-encoded so no shell-quoting/escaping is needed, then
|
|
690
|
+
* decoded, made executable, and executed with the given arguments. Reuses
|
|
691
|
+
* sshExecBatch for the actual transport, retries, and structured result.
|
|
692
|
+
* @async
|
|
693
|
+
* @function sshRunScript
|
|
694
|
+
* @memberof UnderpostSSH
|
|
695
|
+
* @param {object} params
|
|
696
|
+
* @param {string} params.host - Target host/IP.
|
|
697
|
+
* @param {string} params.scriptPath - Local path to the script to run.
|
|
698
|
+
* @param {string} [params.args=''] - Arguments appended to the remote invocation.
|
|
699
|
+
* @param {object} [params.env={}] - Environment variables exported for the remote run (e.g. secrets). Passed inline to the command, never echoed.
|
|
700
|
+
* @param {string} [params.remotePath='/tmp/underpost-remote-script.sh'] - Remote path to write the script.
|
|
701
|
+
* @param {number} [params.port=22] - SSH port.
|
|
702
|
+
* @param {string} [params.user='root'] - SSH user (key-only).
|
|
703
|
+
* @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
|
|
704
|
+
* @param {number} [params.retries=3] - Retry attempts.
|
|
705
|
+
* @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
|
|
706
|
+
* @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
|
|
707
|
+
*/
|
|
708
|
+
sshRunScript: async ({
|
|
709
|
+
host,
|
|
710
|
+
scriptPath,
|
|
711
|
+
args = '',
|
|
712
|
+
env = {},
|
|
713
|
+
remotePath = '/tmp/underpost-remote-script.sh',
|
|
714
|
+
port = 22,
|
|
715
|
+
user = 'root',
|
|
716
|
+
keyPath = './engine-private/deploy/id_rsa',
|
|
717
|
+
retries = 3,
|
|
718
|
+
waitForPortMs = 0,
|
|
719
|
+
}) => {
|
|
720
|
+
if (!fs.existsSync(scriptPath)) throw new Error(`sshRunScript: script not found: ${scriptPath}`);
|
|
721
|
+
const b64 = Buffer.from(fs.readFileSync(scriptPath, 'utf8'), 'utf8').toString('base64');
|
|
722
|
+
// Inline env assignments (single-quote escaped) so secrets are exported for
|
|
723
|
+
// the remote run without appearing as logged CLI args.
|
|
724
|
+
const sq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
725
|
+
const envPrefix = Object.entries(env)
|
|
726
|
+
.filter(([, v]) => v !== undefined && v !== null && `${v}` !== '')
|
|
727
|
+
.map(([k, v]) => `${k}=${sq(v)}`)
|
|
728
|
+
.join(' ');
|
|
729
|
+
const command = [
|
|
730
|
+
'set -e',
|
|
731
|
+
`echo '${b64}' | base64 -d > ${remotePath}`,
|
|
732
|
+
`chmod +x ${remotePath}`,
|
|
733
|
+
`${envPrefix ? `${envPrefix} ` : ''}bash ${remotePath} ${args}`,
|
|
734
|
+
].join('\n');
|
|
735
|
+
return Underpost.ssh.sshExecBatch({ host, port, user, keyPath, retries, waitForPortMs, command });
|
|
736
|
+
},
|
|
737
|
+
|
|
548
738
|
/**
|
|
549
739
|
* Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API.
|
|
550
740
|
* @async
|
package/src/cli/static.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import express from 'express';
|
|
9
|
-
import { ssrFactory } from '../
|
|
9
|
+
import { ssrFactory } from '../client-builder/ssr.js';
|
|
10
10
|
import { shellExec } from '../server/process.js';
|
|
11
11
|
import Underpost from '../index.js';
|
|
12
|
-
import { JSONweb } from '../
|
|
12
|
+
import { JSONweb } from '../client-builder/client-formatted.js';
|
|
13
13
|
import { loggerFactory, loggerMiddleware } from '../server/logger.js';
|
|
14
14
|
const logger = loggerFactory(import.meta);
|
|
15
15
|
/**
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Module for building project documentation (JSDoc, Swagger, Coverage).
|
|
5
|
-
* @module src/
|
|
5
|
+
* @module src/client-builder/client-build-docs.js
|
|
6
6
|
* @namespace clientBuildDocs
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import fs from 'fs-extra';
|
|
10
|
-
import { shellExec } from '
|
|
11
|
-
import { loggerFactory } from '
|
|
10
|
+
import { shellExec } from '../server/process.js';
|
|
11
|
+
import { loggerFactory } from '../server/logger.js';
|
|
12
12
|
import { JSONweb } from './client-formatted.js';
|
|
13
13
|
import { ssrFactory } from './ssr.js';
|
|
14
14
|
|
|
@@ -396,10 +396,20 @@ const buildCoverage = async ({ docs, docsDestination }) => {
|
|
|
396
396
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
397
397
|
if (pkg.scripts && pkg.scripts.coverage) {
|
|
398
398
|
logger.info('generating coverage report', coveragePath);
|
|
399
|
-
|
|
399
|
+
try {
|
|
400
|
+
await shellExec(`cd ${coveragePath} && npm run coverage`, { silent: true });
|
|
401
|
+
} catch (err) {
|
|
402
|
+
logger.warn('coverage generation failed (non-fatal), skipping:', err.message);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
400
405
|
} else if (pkg.scripts && pkg.scripts.test) {
|
|
401
406
|
logger.info('generating coverage via test', coveragePath);
|
|
402
|
-
|
|
407
|
+
try {
|
|
408
|
+
await shellExec(`cd ${coveragePath} && npm test`, { silent: true, silentOnError: true });
|
|
409
|
+
} catch (err) {
|
|
410
|
+
logger.warn('coverage generation failed (non-fatal), skipping:', err.message);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
403
413
|
}
|
|
404
414
|
}
|
|
405
415
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module for live building client-side code
|
|
3
|
-
* @module src/
|
|
3
|
+
* @module src/client-builder/client-build-live.js
|
|
4
4
|
* @namespace clientLiveBuild
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import fs from 'fs-extra';
|
|
8
|
-
import { Config, loadConf, readConfJson } from '
|
|
9
|
-
import { loggerFactory } from '
|
|
8
|
+
import { Config, loadConf, readConfJson } from '../server/conf.js';
|
|
9
|
+
import { loggerFactory } from '../server/logger.js';
|
|
10
10
|
import { buildClient } from './client-build.js';
|
|
11
11
|
|
|
12
12
|
const logger = loggerFactory(import.meta);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Manages the client-side build process, including full builds and incremental builds.
|
|
3
|
-
* @module
|
|
3
|
+
* @module src/client-builder/client-build.js
|
|
4
4
|
* @namespace clientBuild
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -8,18 +8,18 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'fs-extra';
|
|
10
10
|
import { transformClientJs, JSONweb } from './client-formatted.js';
|
|
11
|
-
import { loggerFactory } from '
|
|
11
|
+
import { loggerFactory } from '../server/logger.js';
|
|
12
12
|
import {
|
|
13
13
|
getCapVariableName,
|
|
14
14
|
newInstance,
|
|
15
15
|
orderArrayFromAttrInt,
|
|
16
16
|
uniqueArray,
|
|
17
17
|
} from '../client/components/core/CommonJs.js';
|
|
18
|
-
import { readConfJson } from '
|
|
18
|
+
import { readConfJson } from '../server/conf.js';
|
|
19
19
|
import { minify } from 'html-minifier-terser';
|
|
20
20
|
import AdmZip from 'adm-zip';
|
|
21
21
|
import * as dir from 'path';
|
|
22
|
-
import { shellExec } from '
|
|
22
|
+
import { shellExec } from '../server/process.js';
|
|
23
23
|
import { SitemapStream, streamToPromise } from 'sitemap';
|
|
24
24
|
import { Readable } from 'stream';
|
|
25
25
|
import { buildIcons } from './client-icons.js';
|
|
@@ -751,8 +751,9 @@ const buildClient = async (
|
|
|
751
751
|
)
|
|
752
752
|
)
|
|
753
753
|
for (const view of views) {
|
|
754
|
-
const buildPath = `${
|
|
755
|
-
|
|
754
|
+
const buildPath = `${
|
|
755
|
+
rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
|
|
756
|
+
}${view.path === '/' ? view.path : `${view.path}/`}`;
|
|
756
757
|
|
|
757
758
|
if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true });
|
|
758
759
|
|
|
@@ -768,8 +769,9 @@ const buildClient = async (
|
|
|
768
769
|
fs.writeFileSync(`${buildPath}${buildId}.js`, jsSrc, 'utf8');
|
|
769
770
|
const title = metadata.title ? metadata.title : title;
|
|
770
771
|
|
|
771
|
-
const canonicalURL = `https://${host}${path}${
|
|
772
|
-
}
|
|
772
|
+
const canonicalURL = `https://${host}${path}${
|
|
773
|
+
view.path === '/' ? (path === '/' ? '' : '/') : path === '/' ? `${view.path.slice(1)}/` : `${view.path}/`
|
|
774
|
+
}`;
|
|
773
775
|
|
|
774
776
|
let ssrHeadComponents = ``;
|
|
775
777
|
let ssrBodyComponents = ``;
|
|
@@ -903,12 +905,12 @@ const buildClient = async (
|
|
|
903
905
|
`${buildPath}index.html`,
|
|
904
906
|
minifyBuild
|
|
905
907
|
? await minify(htmlSrc, {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
908
|
+
minifyCSS: true,
|
|
909
|
+
minifyJS: true,
|
|
910
|
+
collapseBooleanAttributes: true,
|
|
911
|
+
collapseInlineTagWhitespace: true,
|
|
912
|
+
collapseWhitespace: true,
|
|
913
|
+
})
|
|
912
914
|
: htmlSrc,
|
|
913
915
|
'utf8',
|
|
914
916
|
);
|
|
@@ -997,8 +999,9 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
|
|
|
997
999
|
renderApi: { JSONweb },
|
|
998
1000
|
});
|
|
999
1001
|
|
|
1000
|
-
const buildPath = `${
|
|
1001
|
-
|
|
1002
|
+
const buildPath = `${
|
|
1003
|
+
rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
|
|
1004
|
+
}${view.path === '/' ? view.path : `${view.path}/`}`;
|
|
1002
1005
|
|
|
1003
1006
|
const indexUrl = buildIndexUrl(view.path);
|
|
1004
1007
|
if (view.offlineDefault) {
|
|
@@ -1018,12 +1021,12 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
|
|
|
1018
1021
|
buildHtmlPath,
|
|
1019
1022
|
minifyBuild
|
|
1020
1023
|
? await minify(htmlSrc, {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1024
|
+
minifyCSS: true,
|
|
1025
|
+
minifyJS: true,
|
|
1026
|
+
collapseBooleanAttributes: true,
|
|
1027
|
+
collapseInlineTagWhitespace: true,
|
|
1028
|
+
collapseWhitespace: true,
|
|
1029
|
+
})
|
|
1027
1030
|
: htmlSrc,
|
|
1028
1031
|
'utf8',
|
|
1029
1032
|
);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module for creating a client-side development server
|
|
3
|
-
* @module src/
|
|
3
|
+
* @module src/client-builder/client-dev-server.js
|
|
4
4
|
* @namespace clientDevServer
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
7
|
import nodemon from 'nodemon';
|
|
8
8
|
import dotenv from 'dotenv';
|
|
9
|
-
import { shellExec } from '
|
|
10
|
-
import { loggerFactory } from '
|
|
9
|
+
import { shellExec } from '../server/process.js';
|
|
10
|
+
import { loggerFactory } from '../server/logger.js';
|
|
11
11
|
import Underpost from '../index.js';
|
|
12
12
|
|
|
13
13
|
const logger = loggerFactory(import.meta);
|