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
package/src/cli/run.js CHANGED
@@ -56,6 +56,7 @@ const logger = loggerFactory(import.meta);
56
56
  * @property {boolean} dev - Whether to run in development mode.
57
57
  * @property {string} podName - The name of the pod to run.
58
58
  * @property {string} nodeName - The name of the node to run.
59
+ * @property {string} sshKeyPath - Private key path for node SSH operations, forwarded to volume shipping over SSH.
59
60
  * @property {number} port - Custom port to use.
60
61
  * @property {string} volumeHostPath - The host path for the volume.
61
62
  * @property {string} volumeMountPath - The mount path for the volume.
@@ -127,6 +128,7 @@ const DEFAULT_OPTION = {
127
128
  dev: false,
128
129
  podName: '',
129
130
  nodeName: '',
131
+ sshKeyPath: '',
130
132
  port: 0,
131
133
  volumeHostPath: '',
132
134
  volumeMountPath: '',
@@ -373,6 +375,228 @@ class UnderpostRun {
373
375
  );
374
376
  },
375
377
 
378
+ /**
379
+ * @method node-move
380
+ * @description Abstract runner that relocates any schedulable Kubernetes workload
381
+ * (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob, ReplicationController)
382
+ * onto a target node by patching its pod-template `nodeSelector` and rolling it out.
383
+ * Resource-kind agnostic: it resolves the kind dynamically and applies the right
384
+ * patch path, so it works for `sts`, `deployment`, etc. without bespoke logic.
385
+ *
386
+ * Selection grammar via `path`:
387
+ * - `<kind>/<name>` -> a single resource (e.g. `deployment/dd-core-production-blue`)
388
+ * - `<kind>` -> every resource of that kind in the namespace (e.g. `statefulset`)
389
+ * - `` -> all movable workloads (deployment, statefulset, daemonset) in the namespace
390
+ *
391
+ * Placement:
392
+ * - default: built-in `kubernetes.io/hostname=<node>` (no node mutation required)
393
+ * - `--labels k=v,...`: label the target node with those pairs and use them as the
394
+ * nodeSelector (matches the "label node + nodeSelector" pattern), enabling reusable
395
+ * workload pools instead of pinning to a single hostname.
396
+ *
397
+ * Flags: `--node-name <node>` (target), `--namespace <ns>`, `--dry-run` (preview only),
398
+ * `--remove` (clear the nodeSelector / unpin placement).
399
+ *
400
+ * Caveats: Services/ConfigMaps and bare Pods are not schedulable controllers and are
401
+ * skipped (move the owning controller). StatefulSets bound to node-local PVs may stay
402
+ * Pending after a move until their volume is available on the target node.
403
+ * @param {string} path - Resource selector (`kind/name`, `kind`, or empty).
404
+ * @param {Object} options - The default underpost runner options for customizing workflow
405
+ * @memberof UnderpostRun
406
+ * @returns {Array<{ref:string,kind:string,status:string,node?:string}>} Per-resource outcome.
407
+ */
408
+ 'node-move': (path = '', options = DEFAULT_OPTION) => {
409
+ const node = options.nodeName;
410
+ const ns = options.namespace || 'default';
411
+ const dryRun = options.dryRun === true;
412
+ const remove = options.remove === true;
413
+
414
+ if (!remove && !node) {
415
+ throw new Error('node-move requires --node-name <target-node> (or --remove to clear placement)');
416
+ }
417
+
418
+ const normalizeKind = (k) =>
419
+ ({
420
+ deploy: 'deployment',
421
+ deployments: 'deployment',
422
+ deployment: 'deployment',
423
+ sts: 'statefulset',
424
+ statefulsets: 'statefulset',
425
+ statefulset: 'statefulset',
426
+ ds: 'daemonset',
427
+ daemonsets: 'daemonset',
428
+ daemonset: 'daemonset',
429
+ rs: 'replicaset',
430
+ replicasets: 'replicaset',
431
+ replicaset: 'replicaset',
432
+ rc: 'replicationcontroller',
433
+ replicationcontroller: 'replicationcontroller',
434
+ job: 'job',
435
+ jobs: 'job',
436
+ cj: 'cronjob',
437
+ cronjob: 'cronjob',
438
+ cronjobs: 'cronjob',
439
+ po: 'pod',
440
+ pod: 'pod',
441
+ pods: 'pod',
442
+ svc: 'service',
443
+ service: 'service',
444
+ services: 'service',
445
+ })[k] || k;
446
+
447
+ // Kinds that own a pod template we can patch; rolloutKinds additionally
448
+ // support `kubectl rollout restart` to reschedule existing pods now.
449
+ const templated = [
450
+ 'deployment',
451
+ 'statefulset',
452
+ 'daemonset',
453
+ 'replicaset',
454
+ 'job',
455
+ 'cronjob',
456
+ 'replicationcontroller',
457
+ ];
458
+ const rolloutKinds = ['deployment', 'statefulset', 'daemonset'];
459
+ const templateSelectorPath = (kind) =>
460
+ kind === 'cronjob'
461
+ ? ['spec', 'jobTemplate', 'spec', 'template', 'spec', 'nodeSelector']
462
+ : ['spec', 'template', 'spec', 'nodeSelector'];
463
+
464
+ // Resolve the desired nodeSelector. Custom --labels enables reusable pools;
465
+ // otherwise pin by the always-present hostname label.
466
+ let selector = { 'kubernetes.io/hostname': node };
467
+ if (!remove && options.labels) {
468
+ selector = {};
469
+ for (const pair of `${options.labels}`
470
+ .split(',')
471
+ .map((s) => s.trim())
472
+ .filter(Boolean)) {
473
+ const eq = pair.indexOf('=');
474
+ if (eq < 0) continue;
475
+ selector[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
476
+ }
477
+ }
478
+
479
+ // Verify the target node exists, and apply custom labels to it if provided.
480
+ if (!remove) {
481
+ const found = shellExec(`kubectl get node ${node} -o name`, {
482
+ silent: true,
483
+ stdout: true,
484
+ silentOnError: true,
485
+ }).trim();
486
+ if (!found) throw new Error(`Target node not found: ${node}`);
487
+ if (options.labels) {
488
+ const labelArgs = Object.entries(selector)
489
+ .map(([k, v]) => `${k}=${v}`)
490
+ .join(' ');
491
+ const labelCmd = `kubectl label node ${node} ${labelArgs} --overwrite`;
492
+ if (dryRun) logger.info(`[dry-run] ${labelCmd}`);
493
+ else shellExec(labelCmd);
494
+ }
495
+ }
496
+
497
+ const kubectlNames = (kind) =>
498
+ (
499
+ shellExec(`kubectl get ${kind} -n ${ns} -o name`, {
500
+ silent: true,
501
+ stdout: true,
502
+ silentOnError: true,
503
+ }).trim() || ''
504
+ )
505
+ .split('\n')
506
+ .map((s) => s.trim())
507
+ .filter(Boolean);
508
+
509
+ // Build the list of "kind/name" targets from the selection grammar.
510
+ let targets = [];
511
+ if (!path) {
512
+ for (const kind of ['deployment', 'statefulset', 'daemonset']) targets.push(...kubectlNames(kind));
513
+ } else if (path.includes('/')) {
514
+ targets = [path];
515
+ } else {
516
+ targets = kubectlNames(path);
517
+ }
518
+
519
+ if (targets.length === 0) {
520
+ logger.warn('node-move: no matching resources found', { path, namespace: ns });
521
+ return [];
522
+ }
523
+
524
+ // Merge-patch body that sets (or clears, when --remove) the nodeSelector.
525
+ const buildPatch = (kind) => {
526
+ const keys = templateSelectorPath(kind);
527
+ let obj = remove ? null : selector;
528
+ for (let i = keys.length - 1; i >= 0; i--) obj = { [keys[i]]: obj };
529
+ return JSON.stringify(obj);
530
+ };
531
+
532
+ const results = [];
533
+ for (const ref of targets) {
534
+ const slash = ref.indexOf('/');
535
+ const rawKind = slash >= 0 ? ref.slice(0, slash) : path && !path.includes('/') ? path : '';
536
+ const name = slash >= 0 ? ref.slice(slash + 1) : ref;
537
+ const kind = normalizeKind(`${rawKind}`.split('.')[0].toLowerCase());
538
+
539
+ if (!templated.includes(kind)) {
540
+ logger.warn(`node-move: ${kind}/${name} is not a schedulable controller; skipping`, {
541
+ hint: 'move its owning controller (deployment/statefulset/daemonset) instead',
542
+ });
543
+ results.push({ ref, kind, status: 'skipped' });
544
+ continue;
545
+ }
546
+
547
+ // Idempotency: skip the patch + rollout if the resource is already where
548
+ // we want it. Compares the live pod-template nodeSelector against the
549
+ // desired placement so a repeated run does not trigger an unnecessary
550
+ // rollout restart.
551
+ const basePath = kind === 'cronjob' ? 'spec.jobTemplate.spec.template.spec' : 'spec.template.spec';
552
+ const jsonpath = (expr) =>
553
+ shellExec(`kubectl get ${kind} ${name} -n ${ns} -o jsonpath='${expr}'`, {
554
+ silent: true,
555
+ stdout: true,
556
+ silentOnError: true,
557
+ disableLog: true,
558
+ }).trim();
559
+
560
+ if (remove) {
561
+ const current = jsonpath(`{.${basePath}.nodeSelector}`);
562
+ if (!current || current === 'map[]') {
563
+ logger.info(`node-move: ${kind}/${name} already has no nodeSelector; nothing to clear`);
564
+ results.push({ ref, kind, status: 'already-cleared' });
565
+ continue;
566
+ }
567
+ } else {
568
+ const alreadyOnNode = Object.entries(selector).every(([k, v]) => {
569
+ const esc = k.replace(/\./g, '\\.');
570
+ return jsonpath(`{.${basePath}.nodeSelector.${esc}}`) === v;
571
+ });
572
+ if (alreadyOnNode) {
573
+ logger.info(`node-move: ${kind}/${name} already pinned to ${node}; skipping`, { namespace: ns });
574
+ results.push({ ref, kind, status: 'already-on-node', node });
575
+ continue;
576
+ }
577
+ }
578
+
579
+ const patchCmd = `kubectl patch ${kind} ${name} -n ${ns} --type=merge -p '${buildPatch(kind)}'`;
580
+ const restartCmd = `kubectl rollout restart ${kind} ${name} -n ${ns}`;
581
+ if (dryRun) {
582
+ logger.info(`[dry-run] ${patchCmd}`);
583
+ if (rolloutKinds.includes(kind)) logger.info(`[dry-run] ${restartCmd}`);
584
+ results.push({ ref, kind, status: 'dry-run', node: remove ? undefined : node });
585
+ continue;
586
+ }
587
+
588
+ shellExec(patchCmd);
589
+ if (rolloutKinds.includes(kind)) shellExec(restartCmd);
590
+ logger.info(remove ? `Cleared node placement: ${kind}/${name}` : `Moved ${kind}/${name} -> ${node}`, {
591
+ namespace: ns,
592
+ });
593
+ results.push({ ref, kind, status: remove ? 'cleared' : 'moved', node: remove ? undefined : node });
594
+ }
595
+
596
+ logger.info('node-move complete', { namespace: ns, node: remove ? null : node, count: results.length });
597
+ return results;
598
+ },
599
+
376
600
  /**
377
601
  * @method dev-hosts-expose
378
602
  * @description Deploys a specified service in development mode with `/etc/hosts` modification for local access.
@@ -405,7 +629,9 @@ class UnderpostRun {
405
629
  * @memberof UnderpostRun
406
630
  */
407
631
  'cluster-build': (path, options = DEFAULT_OPTION) => {
408
- const nodeOptions = options.nodeName ? ` --node-name ${options.nodeName}` : '';
632
+ const nodeOptions =
633
+ (options.nodeName ? ` --node-name ${options.nodeName}` : '') +
634
+ (options.sshKeyPath ? ` --ssh-key-path ${options.sshKeyPath}` : '');
409
635
  shellExec(`node bin run clean`);
410
636
  shellExec(`node bin run --dev sync-replica template-deploy${nodeOptions}`);
411
637
  shellExec(`node bin run sync-replica template-deploy${nodeOptions}`);
@@ -701,7 +927,7 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
701
927
  replicas = replicas ? replicas : defaultPath[1];
702
928
  versions = versions ? versions.replaceAll('+', ',') : defaultPath[2];
703
929
  image = image ? image : defaultPath[3];
704
- node = node ? node : defaultPath[4];
930
+ node = node ? node : options.nodeName ? options.nodeName : defaultPath[4];
705
931
  shellExec(`${baseCommand} cluster --ns-use ${options.namespace}`);
706
932
 
707
933
  if (image && !image.startsWith('localhost'))
@@ -742,13 +968,14 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
742
968
  const skipFullBuildFlag = options.skipFullBuild ? ' --skip-full-build' : '';
743
969
  const pullBundleFlag = options.pullBundle ? ' --pull-bundle' : '';
744
970
  const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
971
+ const sshKeyPathFlag = options.sshKeyPath ? ` --ssh-key-path ${options.sshKeyPath}` : '';
745
972
 
746
973
  shellExec(
747
974
  `${baseCommand} deploy${clusterFlag} --build-manifest --sync --info-router --replicas ${replicas} --node ${node}${
748
975
  image ? ` --image ${image}` : ''
749
976
  }${versions ? ` --versions ${versions}` : ''}${
750
977
  options.namespace ? ` --namespace ${options.namespace}` : ''
751
- }${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag} ${deployId} ${env}`,
978
+ }${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag}${sshKeyPathFlag} ${deployId} ${env}`,
752
979
  );
753
980
 
754
981
  if (isDeployRunnerContext(path, options)) {
@@ -757,9 +984,9 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
757
984
  `${baseCommand} db ${deployId} ${clusterFlag}${baseClusterCommand} --repo-backup --primary-pod --git --force-clone --preserveUUID ${options.namespace ? ` --ns ${options.namespace}` : ''}`,
758
985
  );
759
986
  shellExec(
760
- `${baseCommand} deploy${clusterFlag}${cmdString} --replicas ${replicas} --disable-update-proxy ${deployId} ${env} --versions ${versions}${
987
+ `${baseCommand} deploy${clusterFlag}${cmdString} --replicas ${replicas} --node ${node} --disable-update-proxy ${deployId} ${env} --versions ${versions}${
761
988
  options.namespace ? ` --namespace ${options.namespace}` : ''
762
- }${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}`,
989
+ }${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}${sshKeyPathFlag}`,
763
990
  );
764
991
  if (!targetTraffic)
765
992
  targetTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
@@ -1119,9 +1346,16 @@ EOF
1119
1346
  deployId: _deployId,
1120
1347
  env,
1121
1348
  version: targetTraffic,
1122
- nodeName: options.nodeName,
1349
+ nodeName: Underpost.deploy.resolveDeployNode({
1350
+ node: options.nodeName,
1351
+ kind: options.kind,
1352
+ kubeadm: options.kubeadm,
1353
+ k3s: options.k3s,
1354
+ env,
1355
+ }),
1123
1356
  clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
1124
1357
  gitClean: options.gitClean || false,
1358
+ sshKeyPath: options.sshKeyPath || '',
1125
1359
  });
1126
1360
  // Regenerate the parent deploy's gRPC ClusterIP service pointing to the
1127
1361
  // parent's current traffic colour and apply it before the instance pod starts so
@@ -1443,8 +1677,24 @@ gpgcheck=1
1443
1677
  gpgkey=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/repodata/repomd.xml.key
1444
1678
  EOF`);
1445
1679
  shellExec(`sudo dnf -y install cri-o`);
1446
- // crictl is in the kubernetes repo but excluded by default install it explicitly
1447
- shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes`);
1680
+ // Add the Kubernetes repo so cri-tools (crictl CLI) is available.
1681
+ // The repo has exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni, so we
1682
+ // use --disableexcludes=kubernetes to override and install cri-tools.
1683
+ shellExec(`cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
1684
+ [kubernetes]
1685
+ name=Kubernetes
1686
+ baseurl=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/
1687
+ enabled=1
1688
+ gpgcheck=1
1689
+ gpgkey=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/repodata/repomd.xml.key
1690
+ exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
1691
+ EOF`);
1692
+ shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes 2>/dev/null || {
1693
+ CRICTL_VERSION="v1.31.0"
1694
+ ARCH="amd64"
1695
+ log "Kubernetes repo not available, downloading crictl binary directly..."
1696
+ 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
1697
+ }`);
1448
1698
  // Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
1449
1699
  shellExec(`sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf`, {
1450
1700
  silentOnError: true,
@@ -2775,6 +3025,33 @@ EOF`;
2775
3025
 
2776
3026
  logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
2777
3027
  },
3028
+ /**
3029
+ * @method shared-dir-add-user
3030
+ * @description Add a user to an existing shared directory without changing
3031
+ * file owners. Grants recursive group/ACL access so the user can read and
3032
+ * write throughout the shared workspace while preserving existing ownership.
3033
+ *
3034
+ * @param {string} path - Shared directory (defaults to `/home/dd/engine`).
3035
+ * @param {Object} options - Underpost runner options.
3036
+ * Key fields:
3037
+ * - options.user (default: 'admin')
3038
+ * - options.group (default: 'engine-dev')
3039
+ *
3040
+ * @memberof UnderpostRun
3041
+ */ 'shared-dir-add-user': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
3042
+ const dir = path || '/home/dd/engine';
3043
+ const user = options.user || 'admin';
3044
+
3045
+ logger.info(`[shared-dir-add-user] dir=${dir} user=${user}`);
3046
+
3047
+ // Give the user direct access without changing owners or group ownership.
3048
+ shellExec(`sudo setfacl -R -m u:${user}:rwx ${dir}`);
3049
+
3050
+ // Make future files/directories inherit the same user ACL.
3051
+ shellExec(`sudo find ${dir} -type d -exec setfacl -d -m u:${user}:rwx {} \\;`);
3052
+
3053
+ logger.info(`[shared-dir-add-user] User '${user}' added to shared directory: ${dir}`);
3054
+ },
2778
3055
  };
2779
3056
 
2780
3057
  static API = {
@@ -104,12 +104,17 @@ class UnderpostSecret {
104
104
  /**
105
105
  * Removes all filesystem traces of secrets after deployment startup.
106
106
  * Centralizes the defense-in-depth cleanup performed
107
+ * @param {object} options - Options for cleaning the environment.
108
+ * @param {Array<string>} [options.keepKeys=[]] - List of keys to keep in the environment file. If provided, only these keys will be retained.
107
109
  * @memberof UnderpostSecret
108
110
  */
109
- globalSecretClean() {
111
+ globalSecretClean(options = { keepKeys: [] }) {
112
+ const { keepKeys } = options;
110
113
  loadConf('clean');
111
114
  Underpost.repo.cleanupPrivateEngineRepo();
112
- Underpost.env.clean();
115
+ Underpost.env.clean({
116
+ keepKeys: keepKeys.length > 0 ? keepKeys : ['container-status', 'start-container-status'],
117
+ });
113
118
  },
114
119
  };
115
120
  }
package/src/cli/ssh.js CHANGED
@@ -496,6 +496,50 @@ EOF`);
496
496
  if (options.status) shellExec('service sshd status');
497
497
  },
498
498
 
499
+ /**
500
+ * Synchronously copies a local directory to a remote host over key-only SSH,
501
+ * streaming it as a tar archive (no intermediate file) and fixing ownership.
502
+ * Mirrors the kind-node `tar | docker cp` provisioning pattern but targets a
503
+ * real node via SSH, so node-local hostPath volumes can be materialized on the
504
+ * node where the pod will actually run.
505
+ *
506
+ * Idempotent and re-runnable: `mkdir -p` + `tar -x` overwrite in place. Throws
507
+ * on any SSH/tar failure so an empty-volume deploy is never produced silently.
508
+ *
509
+ * @function copyDirToNode
510
+ * @memberof UnderpostSSH
511
+ * @param {object} params
512
+ * @param {string} params.host - Target host/IP (key-only SSH reachable).
513
+ * @param {string} params.localDir - Local source directory.
514
+ * @param {string} params.remoteDir - Destination directory on the node.
515
+ * @param {number} [params.port=22] - SSH port.
516
+ * @param {string} [params.user='root'] - SSH user (key-only).
517
+ * @param {string} [params.keyPath='./engine-private/deploy/id_rsa'] - Private key path.
518
+ * @param {string} [params.owner='1000:1000'] - chown target on the node (empty to skip).
519
+ * @param {string} [params.mode='755'] - chmod mode on the node (empty to skip).
520
+ * @returns {void}
521
+ */
522
+ copyDirToNode: ({
523
+ host,
524
+ localDir,
525
+ remoteDir,
526
+ port = 22,
527
+ user = 'root',
528
+ keyPath = './engine-private/deploy/id_rsa',
529
+ owner = '1000:1000',
530
+ mode = '755',
531
+ }) => {
532
+ if (!host) throw new Error('copyDirToNode requires a host');
533
+ if (!localDir || !fs.existsSync(localDir)) throw new Error(`copyDirToNode: local dir not found: ${localDir}`);
534
+ if (!remoteDir) throw new Error('copyDirToNode requires a remoteDir');
535
+ shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
536
+ const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
537
+ shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`);
538
+ shellExec(`tar -C ${localDir} -c . | ssh ${sshOpts} ${user}@${host} 'tar -C ${remoteDir} -x'`);
539
+ const fixups = `${owner ? `chown -R ${owner} ${remoteDir}; ` : ''}${mode ? `chmod -R ${mode} ${remoteDir}` : ''}`.trim();
540
+ if (fixups) shellExec(`ssh ${sshOpts} ${user}@${host} '${fixups}'`);
541
+ },
542
+
499
543
  /**
500
544
  * Generic SSH remote command runner that SSH execution logic.
501
545
  * Executes arbitrary shell commands on a remote server via SSH with proper credential handling.
@@ -545,6 +589,196 @@ EOF
545
589
  return shellExec(sshScript, { stdout: true });
546
590
  },
547
591
 
592
+ /**
593
+ * Waits until a TCP SSH port becomes reachable on a host.
594
+ * @async
595
+ * @function waitForSshPort
596
+ * @memberof UnderpostSSH
597
+ * @param {object} params
598
+ * @param {string} params.host - Target host/IP.
599
+ * @param {number} [params.port=22] - SSH port.
600
+ * @param {number} [params.timeoutMs=600000] - Maximum wait window.
601
+ * @param {number} [params.intervalMs=3000] - Poll interval.
602
+ * @returns {Promise<boolean>} True once the port accepts connections, false on timeout.
603
+ */
604
+ waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => {
605
+ const deadline = Date.now() + timeoutMs;
606
+ while (Date.now() < deadline) {
607
+ const probe = shellExec(
608
+ `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
609
+ { silent: true, stdout: true, silentOnError: true, disableLog: true },
610
+ );
611
+ if (`${probe}`.trim() === 'open') return true;
612
+ await new Promise((r) => setTimeout(r, intervalMs));
613
+ }
614
+ logger.warn(`SSH port ${host}:${port} not reachable within timeout`);
615
+ return false;
616
+ },
617
+
618
+ /**
619
+ * Waits until a host's SSH port stops accepting connections (e.g. while it
620
+ * reboots). Used to detect a reboot edge before waiting for the port to come
621
+ * back up, so callers don't latch onto the pre-reboot (ephemeral) sshd.
622
+ * @async
623
+ * @function waitForSshPortClosed
624
+ * @memberof UnderpostSSH
625
+ * @param {object} params
626
+ * @param {string} params.host - Target host/IP.
627
+ * @param {number} [params.port=22] - SSH port.
628
+ * @param {number} [params.timeoutMs=180000] - Maximum wait window.
629
+ * @param {number} [params.intervalMs=3000] - Poll interval.
630
+ * @returns {Promise<boolean>} True once the port is closed, false on timeout.
631
+ */
632
+ waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => {
633
+ const deadline = Date.now() + timeoutMs;
634
+ while (Date.now() < deadline) {
635
+ const probe = shellExec(
636
+ `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
637
+ { silent: true, stdout: true, silentOnError: true, disableLog: true },
638
+ );
639
+ if (`${probe}`.trim() === 'closed') return true;
640
+ await new Promise((r) => setTimeout(r, intervalMs));
641
+ }
642
+ return false;
643
+ },
644
+
645
+ /**
646
+ * Orchestrates a non-interactive, key-only SSH session against a freshly
647
+ * provisioned host: waits for the port, attempts key-based auth, runs a
648
+ * remote command batch, and returns a structured result. Used by the
649
+ * commissioning flow once the ephemeral runtime reports SSH readiness.
650
+ * @async
651
+ * @function sshExecBatch
652
+ * @memberof UnderpostSSH
653
+ * @param {object} params
654
+ * @param {string} params.host - Target host/IP.
655
+ * @param {string} params.command - Remote command batch to execute.
656
+ * @param {number} [params.port=22] - SSH port.
657
+ * @param {string} [params.user='root'] - SSH user (key-only).
658
+ * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
659
+ * @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout.
660
+ * @param {number} [params.retries=3] - Auth/exec retry attempts.
661
+ * @param {number} [params.retryDelayMs=5000] - Base backoff between retries.
662
+ * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
663
+ * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
664
+ */
665
+ sshExecBatch: async ({
666
+ host,
667
+ command,
668
+ port = 22,
669
+ user = 'root',
670
+ keyPath = './engine-private/deploy/id_rsa',
671
+ connectTimeoutSec = 15,
672
+ retries = 3,
673
+ retryDelayMs = 5000,
674
+ waitForPortMs = 0,
675
+ }) => {
676
+ if (!host) throw new Error('sshExecBatch requires a host');
677
+ if (!command) throw new Error('sshExecBatch requires a command');
678
+
679
+ if (waitForPortMs > 0) {
680
+ const reachable = await Underpost.ssh.waitForSshPort({ host, port, timeoutMs: waitForPortMs });
681
+ if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 };
682
+ }
683
+
684
+ shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
685
+
686
+ const sshOpts = [
687
+ `-i ${keyPath}`,
688
+ `-o BatchMode=yes`,
689
+ `-o PreferredAuthentications=publickey`,
690
+ `-o PubkeyAuthentication=yes`,
691
+ `-o PasswordAuthentication=no`,
692
+ `-o StrictHostKeyChecking=no`,
693
+ `-o UserKnownHostsFile=/dev/null`,
694
+ `-o ConnectTimeout=${connectTimeoutSec}`,
695
+ // Tolerate a freshly-booted node whose network briefly flaps (e.g. while
696
+ // NetworkManager applies a static profile): retry the TCP connect and
697
+ // keep the session alive across short stalls.
698
+ `-o ConnectionAttempts=3`,
699
+ `-o ServerAliveInterval=10`,
700
+ `-o ServerAliveCountMax=6`,
701
+ `-p ${port}`,
702
+ ].join(' ');
703
+
704
+ let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 };
705
+ for (let attempt = 1; attempt <= retries; attempt++) {
706
+ const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, {
707
+ stdout: false,
708
+ silentOnError: true,
709
+ disableLog: true,
710
+ });
711
+ last = {
712
+ ok: result.code === 0,
713
+ code: result.code,
714
+ stdout: result.stdout || '',
715
+ stderr: result.stderr || '',
716
+ attempts: attempt,
717
+ };
718
+ if (last.ok) {
719
+ logger.info(`sshExecBatch succeeded on ${user}@${host}:${port} (attempt ${attempt})`);
720
+ return last;
721
+ }
722
+ logger.warn(`sshExecBatch attempt ${attempt}/${retries} failed on ${user}@${host}:${port}`, {
723
+ code: last.code,
724
+ stderr: last.stderr.slice(-400),
725
+ });
726
+ if (attempt < retries) await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
727
+ }
728
+ return last;
729
+ },
730
+
731
+ /**
732
+ * Transfers a local script to a remote host and runs it over key-only SSH.
733
+ * The script is base64-encoded so no shell-quoting/escaping is needed, then
734
+ * decoded, made executable, and executed with the given arguments. Reuses
735
+ * sshExecBatch for the actual transport, retries, and structured result.
736
+ * @async
737
+ * @function sshRunScript
738
+ * @memberof UnderpostSSH
739
+ * @param {object} params
740
+ * @param {string} params.host - Target host/IP.
741
+ * @param {string} params.scriptPath - Local path to the script to run.
742
+ * @param {string} [params.args=''] - Arguments appended to the remote invocation.
743
+ * @param {object} [params.env={}] - Environment variables exported for the remote run (e.g. secrets). Passed inline to the command, never echoed.
744
+ * @param {string} [params.remotePath='/tmp/underpost-remote-script.sh'] - Remote path to write the script.
745
+ * @param {number} [params.port=22] - SSH port.
746
+ * @param {string} [params.user='root'] - SSH user (key-only).
747
+ * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
748
+ * @param {number} [params.retries=3] - Retry attempts.
749
+ * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
750
+ * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
751
+ */
752
+ sshRunScript: async ({
753
+ host,
754
+ scriptPath,
755
+ args = '',
756
+ env = {},
757
+ remotePath = '/tmp/underpost-remote-script.sh',
758
+ port = 22,
759
+ user = 'root',
760
+ keyPath = './engine-private/deploy/id_rsa',
761
+ retries = 3,
762
+ waitForPortMs = 0,
763
+ }) => {
764
+ if (!fs.existsSync(scriptPath)) throw new Error(`sshRunScript: script not found: ${scriptPath}`);
765
+ const b64 = Buffer.from(fs.readFileSync(scriptPath, 'utf8'), 'utf8').toString('base64');
766
+ // Inline env assignments (single-quote escaped) so secrets are exported for
767
+ // the remote run without appearing as logged CLI args.
768
+ const sq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
769
+ const envPrefix = Object.entries(env)
770
+ .filter(([, v]) => v !== undefined && v !== null && `${v}` !== '')
771
+ .map(([k, v]) => `${k}=${sq(v)}`)
772
+ .join(' ');
773
+ const command = [
774
+ 'set -e',
775
+ `echo '${b64}' | base64 -d > ${remotePath}`,
776
+ `chmod +x ${remotePath}`,
777
+ `${envPrefix ? `${envPrefix} ` : ''}bash ${remotePath} ${args}`,
778
+ ].join('\n');
779
+ return Underpost.ssh.sshExecBatch({ host, port, user, keyPath, retries, waitForPortMs, command });
780
+ },
781
+
548
782
  /**
549
783
  * Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API.
550
784
  * @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 '../server/ssr.js';
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 '../server/client-formatted.js';
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
  /**