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.
- 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 +178 -1
- package/CLI-HELP.md +58 -2
- package/README.md +3 -2
- package/baremetal/commission-workflows.json +1 -0
- package/bin/build.js +13 -1
- package/docker-compose.yml +224 -0
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +27 -15
- package/scripts/kubeadm-node-setup.sh +317 -0
- package/scripts/rocky-kickstart.sh +877 -185
- package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
- package/scripts/test-monitor.sh +242 -78
- package/src/cli/baremetal.js +717 -16
- package/src/cli/cluster.js +3 -1
- package/src/cli/deploy.js +11 -10
- package/src/cli/docker-compose.js +591 -0
- package/src/cli/fs.js +35 -11
- package/src/cli/index.js +83 -0
- package/src/cli/kickstart.js +142 -20
- package/src/cli/monitor.js +114 -29
- package/src/cli/repository.js +75 -11
- package/src/cli/run.js +275 -6
- package/src/cli/ssh.js +190 -0
- package/src/cli/static.js +2 -2
- package/src/client/components/core/PanelForm.js +44 -44
- 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 +61 -12
- package/src/server/start.js +17 -5
- package/src/server.js +1 -1
- package/test/deploy-monitor.test.js +33 -5
- package/typedoc.json +3 -1
- package/src/server/ipfs-client.js +0 -597
- /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
- /package/src/{server → client-builder}/client-formatted.js +0 -0
package/src/cli/repository.js
CHANGED
|
@@ -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 '../
|
|
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
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
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
|
|
1505
|
+
runtimeSiteRoot({ runtime, host, routePath = '/', directory } = {}) {
|
|
1506
|
+
const runtimeBase = {
|
|
1494
1507
|
wp: `/opt/lampp/htdocs/wp/${host}`,
|
|
1495
1508
|
};
|
|
1496
|
-
|
|
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
|
|
1546
|
-
|
|
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' : ''} --
|
|
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
|
-
|
|
1011
|
-
|
|
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
|
-
//
|
|
1429
|
-
|
|
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
|