underpost 3.2.21 → 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.
Files changed (55) 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 +178 -1
  8. package/CLI-HELP.md +58 -2
  9. package/README.md +3 -2
  10. package/baremetal/commission-workflows.json +1 -0
  11. package/bin/build.js +13 -1
  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 +242 -78
  21. package/src/cli/baremetal.js +717 -16
  22. package/src/cli/cluster.js +3 -1
  23. package/src/cli/deploy.js +11 -10
  24. package/src/cli/docker-compose.js +591 -0
  25. package/src/cli/fs.js +35 -11
  26. package/src/cli/index.js +83 -0
  27. package/src/cli/kickstart.js +142 -20
  28. package/src/cli/monitor.js +114 -29
  29. package/src/cli/repository.js +75 -11
  30. package/src/cli/run.js +275 -6
  31. package/src/cli/ssh.js +190 -0
  32. package/src/cli/static.js +2 -2
  33. package/src/client/components/core/PanelForm.js +44 -44
  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 -1
  41. package/src/client.dev.js +1 -1
  42. package/src/index.js +12 -1
  43. package/src/mailer/EmailRender.js +1 -1
  44. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  45. package/src/runtime/express/Express.js +2 -2
  46. package/src/runtime/nginx/Nginx.js +250 -0
  47. package/src/server/catalog.js +7 -14
  48. package/src/server/conf.js +61 -12
  49. package/src/server/start.js +17 -5
  50. package/src/server.js +1 -1
  51. package/test/deploy-monitor.test.js +33 -5
  52. package/typedoc.json +3 -1
  53. package/src/server/ipfs-client.js +0 -597
  54. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  55. /package/src/{server → client-builder}/client-formatted.js +0 -0
@@ -8,6 +8,7 @@ import dotenv from 'dotenv';
8
8
  import { commitData } from '../client/components/core/CommonJs.js';
9
9
  import { pbcopy, shellCd, shellExec } from '../server/process.js';
10
10
  import { actionInitLog, loggerFactory } from '../server/logger.js';
11
+ import path from 'path';
11
12
  import fs from 'fs-extra';
12
13
  import {
13
14
  getNpmRootPath,
@@ -21,7 +22,7 @@ import {
21
22
  buildReplicaId,
22
23
  writeEnv,
23
24
  } from '../server/conf.js';
24
- import { buildClient, unzipClientBuild, mergeClientBuildZip } from '../server/client-build.js';
25
+ import { buildClient, unzipClientBuild, mergeClientBuildZip } from '../client-builder/client-build.js';
25
26
  import { DefaultConf } from '../../conf.js';
26
27
  import Underpost from '../index.js';
27
28
 
@@ -1482,18 +1483,63 @@ Prevent build private config repo.`,
1482
1483
  },
1483
1484
 
1484
1485
  /**
1485
- * Runtime-type in-pod site-root directory resolver.
1486
- * Maps each known runtime to the filesystem path where its repository lives inside the container.
1487
- * @param {string} runtime - The runtime identifier (e.g. 'wp').
1488
- * @param {string} host - The virtual-host name.
1489
- * @returns {string|null} Absolute path inside the pod, or null if the runtime has no known mapping.
1486
+ * Resolves the in-pod site-root directory where a conf route's repository lives.
1487
+ *
1488
+ * General-purpose resolution, independent of any single runtime:
1489
+ * 1. An explicit `directory` (the conf-declared document root) always wins.
1490
+ * 2. Otherwise the runtime's base directory is used (e.g. `wp` `/opt/lampp/htdocs/wp/<host>`).
1491
+ * 3. A subdirectory route (e.g. `/wp`) appends `<subDir>` to the resolved root.
1492
+ *
1493
+ * This mirrors {@link WpService.createApp}'s `vhostDir`/`wpDir` layout so backups target the
1494
+ * exact directory provisioning created, including subdirectory installs and custom directories.
1495
+ *
1496
+ * @param {object} opts
1497
+ * @param {string} opts.runtime - The runtime identifier (e.g. 'wp').
1498
+ * @param {string} opts.host - The virtual-host name.
1499
+ * @param {string} [opts.routePath='/'] - The conf route path the repository is mounted under.
1500
+ * @param {string} [opts.directory] - Explicit document root from conf; overrides the runtime base.
1501
+ * @returns {string|null} Absolute path inside the pod, or null when neither a directory nor a
1502
+ * known runtime base resolves.
1490
1503
  * @memberof UnderpostRepository
1491
1504
  */
1492
- runtimeSiteRoot(runtime, host) {
1493
- const runtimePaths = {
1505
+ runtimeSiteRoot({ runtime, host, routePath = '/', directory } = {}) {
1506
+ const runtimeBase = {
1494
1507
  wp: `/opt/lampp/htdocs/wp/${host}`,
1495
1508
  };
1496
- return runtimePaths[runtime] || null;
1509
+ const vhostDir = directory || runtimeBase[runtime];
1510
+ if (!vhostDir) return null;
1511
+ const subDir = routePath && routePath !== '/' ? routePath.replace(/^\/+/, '').replace(/\/+$/, '') : '';
1512
+ return subDir ? `${vhostDir}/${subDir}` : vhostDir;
1513
+ },
1514
+
1515
+ /**
1516
+ * Probes a running pod for the first candidate directory that is a git repository
1517
+ * (i.e. contains a `.git` entry). Used to locate a site root before backing it up so a
1518
+ * missing/unprovisioned directory is detected up-front instead of producing an opaque
1519
+ * shell `exit 1` from a failed `cd`.
1520
+ *
1521
+ * @param {object} opts
1522
+ * @param {string} opts.podName - Target pod name.
1523
+ * @param {string} opts.namespace - Kubernetes namespace.
1524
+ * @param {string[]} opts.candidates - Absolute paths to probe, most-specific first.
1525
+ * @returns {string|null} The first candidate that is a git repo, or null if none match.
1526
+ * @memberof UnderpostRepository
1527
+ */
1528
+ podRepoDir({ podName, namespace, candidates }) {
1529
+ const probe = candidates.map((dir) => `if [ -d '${dir}/.git' ]; then echo '${dir}'; exit 0; fi`).join('; ');
1530
+ let out = '';
1531
+ try {
1532
+ out = Underpost.kubectl.exec({ podName, namespace, command: `${probe}; echo ''` }) || '';
1533
+ } catch (err) {
1534
+ logger.warn(`podRepoDir: probe failed in pod ${podName}`, err.message);
1535
+ return null;
1536
+ }
1537
+ return (
1538
+ out
1539
+ .split('\n')
1540
+ .map((line) => line.trim())
1541
+ .find(Boolean) || null
1542
+ );
1497
1543
  },
1498
1544
 
1499
1545
  /**
@@ -1542,12 +1588,28 @@ Prevent build private config repo.`,
1542
1588
  const entry = confServer[host][routePath];
1543
1589
  if (!entry.repository) continue;
1544
1590
 
1545
- const siteRoot = Underpost.repo.runtimeSiteRoot(entry.runtime, host);
1546
- if (!siteRoot) {
1591
+ const siteRootArgs = { runtime: entry.runtime, host, directory: entry.directory };
1592
+ const repoRoot = Underpost.repo.runtimeSiteRoot({ ...siteRootArgs, routePath });
1593
+ if (!repoRoot) {
1547
1594
  logger.warn(`backupPodRepositories: no site-root mapping for runtime '${entry.runtime}' (${host})`);
1548
1595
  continue;
1549
1596
  }
1550
1597
 
1598
+ // Probe the pod for the actual git repo so an unprovisioned/missing site root is
1599
+ // detected and skipped cleanly instead of failing the in-pod `cd` with exit 1.
1600
+ // Fall back to the vhost dir (root route) to cover conf/path drift.
1601
+ const vhostRoot = Underpost.repo.runtimeSiteRoot({ ...siteRootArgs, routePath: '/' });
1602
+ const candidates = [...new Set([repoRoot, vhostRoot].filter(Boolean))];
1603
+ const siteRoot = Underpost.repo.podRepoDir({ podName, namespace, candidates });
1604
+ if (!siteRoot) {
1605
+ logger.warn(
1606
+ `backupPodRepositories: no git repository found in pod ${podName} for ${host} (checked ${candidates.join(
1607
+ ', ',
1608
+ )}) — site may not be provisioned yet; skipping`,
1609
+ );
1610
+ continue;
1611
+ }
1612
+
1551
1613
  const repoName = entry.repository.split('/').pop().split('.')[0];
1552
1614
 
1553
1615
  // Build the backup script — secrets are injected as env vars in the exec,
@@ -1738,6 +1800,8 @@ Prevent build private config repo.`,
1738
1800
  if (!fs.existsSync(repoPath)) {
1739
1801
  shellExec(`cd .. && underpost clone ${gitUri}`, { silent: true });
1740
1802
  } else {
1803
+ const repoAbsPath = path.resolve(repoPath);
1804
+ shellExec(`git config --global --add safe.directory '${repoAbsPath}'`);
1741
1805
  shellExec(`cd ${repoPath} && git checkout . && git clean -f -d && underpost pull . ${gitUri}`, {
1742
1806
  silent: true,
1743
1807
  });
package/src/cli/run.js CHANGED
@@ -120,6 +120,7 @@ const logger = loggerFactory(import.meta);
120
120
  * @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
121
121
  * @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
122
122
  * @property {boolean} remove - Whether to remove/teardown resources instead of creating them (e.g. delete-expose for k3s proxy devices in dev-cluster).
123
+ * @property {boolean} test - Whether to enable test/generic-purpose mode (e.g. use self-signed TLS instead of cert-manager).
123
124
  * @memberof UnderpostRun
124
125
  */
125
126
  const DEFAULT_OPTION = {
@@ -188,6 +189,7 @@ const DEFAULT_OPTION = {
188
189
  skipFullBuild: false,
189
190
  pullBundle: false,
190
191
  remove: false,
192
+ test: false,
191
193
  };
192
194
 
193
195
  /**
@@ -223,7 +225,7 @@ class UnderpostRun {
223
225
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''}`);
224
226
 
225
227
  shellExec(
226
- `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb4 --service-host ${mongoHosts.join(
228
+ `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb --service-host ${mongoHosts.join(
227
229
  ',',
228
230
  )} --pull-image`,
229
231
  );
@@ -371,6 +373,214 @@ class UnderpostRun {
371
373
  );
372
374
  },
373
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
+
374
584
  /**
375
585
  * @method dev-hosts-expose
376
586
  * @description Deploys a specified service in development mode with `/etc/hosts` modification for local access.
@@ -1007,8 +1217,19 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
1007
1217
  // pathRewritePolicy,
1008
1218
  });
1009
1219
  if (options.tls) {
1010
- shellExec(`sudo kubectl delete Certificate ${_host} -n ${options.namespace} --ignore-not-found`);
1011
- proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host: _host });
1220
+ if (options.test) {
1221
+ const sslDir = `./engine-private/ssl/${_host}`;
1222
+ const nameSafe = _host.replace(/[^a-zA-Z0-9_.-]/g, '_');
1223
+ fs.mkdirpSync(sslDir);
1224
+ shellExec(`bash ./scripts/ssl.sh "${sslDir}" "${_host}"`);
1225
+ shellExec(`kubectl delete secret ${_host} -n ${options.namespace} --ignore-not-found`);
1226
+ shellExec(
1227
+ `kubectl create secret tls ${_host} --cert="${sslDir}/${nameSafe}.pem" --key="${sslDir}/${nameSafe}-key.pem" -n ${options.namespace}`,
1228
+ );
1229
+ } else {
1230
+ shellExec(`sudo kubectl delete Certificate ${_host} -n ${options.namespace} --ignore-not-found`);
1231
+ proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host: _host });
1232
+ }
1012
1233
  }
1013
1234
  // console.log(proxyYaml);
1014
1235
  shellExec(`kubectl delete HTTPProxy ${_host} --namespace ${options.namespace} --ignore-not-found`);
@@ -1077,6 +1298,7 @@ EOF
1077
1298
  // Examples images:
1078
1299
  // `underpost/underpost-engine:${Underpost.version}`
1079
1300
  // `localhost/rockylinux9-underpost:${Underpost.version}`
1301
+ if (options.imageName) _image = options.imageName;
1080
1302
  if (!_image) _image = `underpost/underpost-engine:${Underpost.version}`;
1081
1303
 
1082
1304
  if (_image && !_image.startsWith('localhost'))
@@ -1172,12 +1394,16 @@ EOF
1172
1394
  `,
1173
1395
  { disableLog: true },
1174
1396
  );
1397
+ // Custom instances run a bare binary (no `underpost start` / internal
1398
+ // HTTP endpoint): Kubernetes readiness is the running signal and
1399
+ // container-status is read via exec. See `Deploy custom instance to K8S.md`.
1175
1400
  const { ready, readyPods } = await Underpost.monitor.monitorReadyRunner(
1176
1401
  _deployId,
1177
1402
  env,
1178
1403
  targetTraffic,
1179
1404
  ignorePods,
1180
1405
  options.namespace,
1406
+ { readyGate: 'kubernetes', statusTransport: 'exec' },
1181
1407
  );
1182
1408
 
1183
1409
  if (!ready) {
@@ -1187,7 +1413,7 @@ EOF
1187
1413
  shellExec(
1188
1414
  `${baseCommand} run${baseClusterCommand} --namespace ${options.namespace}` +
1189
1415
  `${options.nodeName ? ` --node-name ${options.nodeName}` : ''}` +
1190
- `${options.tls ? ` --tls` : ''}` +
1416
+ `${options.tls ? ` --tls ${options.test ? '--test' : ''}` : ''}` +
1191
1417
  ` instance-promote '${path}'`,
1192
1418
  );
1193
1419
  }
@@ -1425,8 +1651,24 @@ gpgcheck=1
1425
1651
  gpgkey=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/repodata/repomd.xml.key
1426
1652
  EOF`);
1427
1653
  shellExec(`sudo dnf -y install cri-o`);
1428
- // crictl is in the kubernetes repo but excluded by default install it explicitly
1429
- shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes`);
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
+ }`);
1430
1672
  // Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
1431
1673
  shellExec(`sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf`, {
1432
1674
  silentOnError: true,
@@ -2757,6 +2999,33 @@ EOF`;
2757
2999
 
2758
3000
  logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
2759
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
+ },
2760
3029
  };
2761
3030
 
2762
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