underpost 3.2.22 → 3.2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ghpkg.ci.yml +1 -1
- package/.github/workflows/gitlab.ci.yml +1 -1
- package/.github/workflows/npmpkg.ci.yml +1 -1
- package/.github/workflows/publish.ci.yml +2 -2
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/CHANGELOG.md +181 -1
- package/CLI-HELP.md +61 -2
- package/README.md +3 -2
- package/baremetal/commission-workflows.json +1 -0
- package/bin/build.js +1 -0
- package/docker-compose.yml +224 -0
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +27 -15
- package/scripts/kubeadm-node-setup.sh +317 -0
- package/scripts/rocky-kickstart.sh +877 -185
- package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
- package/scripts/test-monitor.sh +3 -5
- package/src/cli/baremetal.js +717 -16
- package/src/cli/cluster.js +3 -1
- package/src/cli/deploy.js +118 -9
- package/src/cli/docker-compose.js +591 -0
- package/src/cli/env.js +11 -2
- package/src/cli/fs.js +35 -11
- package/src/cli/index.js +89 -0
- package/src/cli/kickstart.js +142 -20
- package/src/cli/repository.js +153 -11
- package/src/cli/run.js +285 -8
- package/src/cli/secrets.js +7 -2
- package/src/cli/ssh.js +234 -0
- package/src/cli/static.js +2 -2
- package/src/{server → client-builder}/client-build-docs.js +15 -5
- package/src/{server → client-builder}/client-build-live.js +3 -3
- package/src/{server → client-builder}/client-build.js +25 -22
- package/src/{server → client-builder}/client-dev-server.js +3 -3
- package/src/{server → client-builder}/client-icons.js +2 -2
- package/src/{server → client-builder}/ssr.js +5 -5
- package/src/client.build.js +1 -3
- package/src/client.dev.js +1 -1
- package/src/db/mongo/MongoBootstrap.js +12 -12
- package/src/index.js +12 -1
- package/src/mailer/EmailRender.js +1 -1
- package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
- package/src/runtime/express/Express.js +2 -2
- package/src/runtime/nginx/Nginx.js +250 -0
- package/src/server/catalog.js +7 -14
- package/src/server/conf.js +3 -3
- package/src/server/runtime-status.js +18 -1
- package/src/server/start.js +12 -2
- package/src/server.js +6 -2
- package/test/deploy-monitor.test.js +26 -10
- package/typedoc.json +3 -1
- package/src/server/ipfs-client.js +0 -599
- /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
- /package/src/{server → client-builder}/client-formatted.js +0 -0
package/src/cli/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 =
|
|
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:
|
|
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
|
-
//
|
|
1447
|
-
|
|
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 = {
|
package/src/cli/secrets.js
CHANGED
|
@@ -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 '../
|
|
9
|
+
import { ssrFactory } from '../client-builder/ssr.js';
|
|
10
10
|
import { shellExec } from '../server/process.js';
|
|
11
11
|
import Underpost from '../index.js';
|
|
12
|
-
import { JSONweb } from '../
|
|
12
|
+
import { JSONweb } from '../client-builder/client-formatted.js';
|
|
13
13
|
import { loggerFactory, loggerMiddleware } from '../server/logger.js';
|
|
14
14
|
const logger = loggerFactory(import.meta);
|
|
15
15
|
/**
|