underpost 3.2.28 → 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/CHANGELOG.md +41 -1
- package/CLI-HELP.md +6 -1
- package/README.md +2 -2
- 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 +1 -1
- package/scripts/test-monitor.sh +3 -5
- package/src/cli/deploy.js +112 -7
- package/src/cli/env.js +11 -2
- package/src/cli/index.js +14 -0
- package/src/cli/repository.js +81 -0
- package/src/cli/run.js +36 -10
- package/src/cli/secrets.js +7 -2
- package/src/cli/ssh.js +44 -0
- package/src/client.build.js +0 -2
- package/src/db/mongo/MongoBootstrap.js +12 -12
- package/src/index.js +1 -1
- package/src/server/runtime-status.js +18 -1
- package/src/server/start.js +12 -2
- package/src/server.js +5 -1
- package/test/deploy-monitor.test.js +26 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 2026-06-
|
|
3
|
+
## 2026-06-25
|
|
4
|
+
|
|
5
|
+
### server
|
|
6
|
+
|
|
7
|
+
- Add only build client in development mode in normal server run start up ([a332ae458](https://github.com/underpostnet/engine/commit/a332ae458f79458c7911a4a158037ada250d3af6))
|
|
8
|
+
|
|
9
|
+
### cli-start
|
|
10
|
+
|
|
11
|
+
- Add start-container-status in start pipeline to insulate readinessProbe monitor server status, prevent external overwritten status ([75f67cf3f](https://github.com/underpostnet/engine/commit/75f67cf3fcbc07716415caac4e67ab91abb05985))
|
|
12
|
+
|
|
13
|
+
### cyberia-cli
|
|
14
|
+
|
|
15
|
+
- Enhance cyberia-saga variety theme separate custom prompt of random subject theme ([1945a2553](https://github.com/underpostnet/engine/commit/1945a2553780d1d3b5fa20e40eedcc7d0b1ecd9c))
|
|
16
|
+
- Enhance cyberia-saga variety theme handle and prompt seed customization ([a335d3e20](https://github.com/underpostnet/engine/commit/a335d3e20025f8e714ff98fdc27d7895970187b5))
|
|
17
|
+
|
|
18
|
+
### db
|
|
19
|
+
|
|
20
|
+
- Improve wait status ready mongo pods in bootstrap methods ([6805e35c2](https://github.com/underpostnet/engine/commit/6805e35c22e43315c14dc9e7457ca3d599877ea8))
|
|
21
|
+
|
|
22
|
+
### cli-env
|
|
23
|
+
|
|
24
|
+
- Add keepKeys array options in clean env method ([5b3be0bca](https://github.com/underpostnet/engine/commit/5b3be0bca14a0ccfa64740aff12d2af17a7c29ce))
|
|
25
|
+
|
|
26
|
+
### repository
|
|
27
|
+
|
|
28
|
+
- Add getRemoteUrl and switchRemote repository methods in repository cli ([0412b9986](https://github.com/underpostnet/engine/commit/0412b998683a8369833eb363f8c9953744ae1a3a))
|
|
29
|
+
|
|
30
|
+
### deploy
|
|
31
|
+
|
|
32
|
+
- Enhance node customization in default deployment and custom instance workloads ([31c6b6d3b](https://github.com/underpostnet/engine/commit/31c6b6d3b397229b0216f82b8aef29b6941e474e))
|
|
33
|
+
- Fix node affinity assignment in volume mount factory ([9ec1ef931](https://github.com/underpostnet/engine/commit/9ec1ef9313ac0dbb5993cd3f158d2d01eae7dec5))
|
|
34
|
+
|
|
35
|
+
### cli-run
|
|
36
|
+
|
|
37
|
+
- Add flag ssh key path to customize key usage for ssh operations in deployments pipelines ([861cd7373](https://github.com/underpostnet/engine/commit/861cd73734d3442783419af4403bbb8e88711f19))
|
|
38
|
+
|
|
39
|
+
### start-cli
|
|
40
|
+
|
|
41
|
+
- Improve underpost container status persistence ([bff2e8f8d](https://github.com/underpostnet/engine/commit/bff2e8f8d65c6a2fe8572fe0bc9c42b9566db918))
|
|
42
|
+
|
|
43
|
+
## New release v:3.2.28 (2026-06-23)
|
|
4
44
|
|
|
5
45
|
### hardhat
|
|
6
46
|
|
package/CLI-HELP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
## Underpost CLI
|
|
2
2
|
|
|
3
|
-
> underpost ci/cd cli v3.2.
|
|
3
|
+
> underpost ci/cd cli v3.2.30
|
|
4
4
|
|
|
5
5
|
**Usage:** `underpost [options] [command]`
|
|
6
6
|
|
|
@@ -222,6 +222,9 @@ Manages commits to a GitHub repository, supporting various commit types and opti
|
|
|
222
222
|
| `--bc <commit-hash>` | Shows branches that contain the specified commit. |
|
|
223
223
|
| `--is-remote-repo <url-repo>` | Checks whether a remote Git repository URL is reachable. Prints true or false. |
|
|
224
224
|
| `--has-changes` | Prints "1" if there are staged or unstaged git changes in the repository, empty string otherwise. |
|
|
225
|
+
| `--remote-url` | Prints the current git remote URL (origin) in plain text. |
|
|
226
|
+
| `--switch-repo <url>` | Switches the git remote (origin) to <url> and force-pulls the target branch, overwriting the current working tree (discards local commits and tracked changes). Accepts a full URL or "owner/repo". |
|
|
227
|
+
| `--target-branch <branch>` | Target branch for --switch-repo (default: master). |
|
|
225
228
|
| `-h, --help` | display help for command |
|
|
226
229
|
|
|
227
230
|
---
|
|
@@ -467,6 +470,7 @@ Manages application deployments, defaulting to deploying development pods.
|
|
|
467
470
|
| `--cert-hosts <hosts>` | Resets TLS/SSL certificate secrets for specified hosts. |
|
|
468
471
|
| `--self-signed` | Use a pre-created self-signed TLS secret (kubernetes.io/tls) instead of cert-manager. The secret must already exist in the namespace with the same name as the host. Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer. |
|
|
469
472
|
| `--node <node>` | Sets optional node for deployment operations. |
|
|
473
|
+
| `--ssh-key-path <path>` | Private key path for node SSH operations. Currently used when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa. |
|
|
470
474
|
| `--build-manifest` | Builds Kubernetes YAML manifests, including deployments, services, proxies, and secrets. |
|
|
471
475
|
| `--replicas <replicas>` | Sets a custom number of replicas for deployments. |
|
|
472
476
|
| `--image <image>` | Sets a custom image for deployments. |
|
|
@@ -830,6 +834,7 @@ Runs specified scripts using various runners.
|
|
|
830
834
|
| `--replicas <replicas>` | Sets a custom number of replicas for deployment. |
|
|
831
835
|
| `--pod-name <pod-name>` | Optional: Specifies the pod name for execution. |
|
|
832
836
|
| `--node-name <node-name>` | Optional: Specifies the node name for execution. |
|
|
837
|
+
| `--ssh-key-path <path>` | Optional: Private key path for node SSH operations, forwarded to volume shipping over SSH. Defaults to engine-private/deploy/id_rsa. |
|
|
833
838
|
| `--port <port>` | Optional: Specifies the port for execution. |
|
|
834
839
|
| `--etc-hosts` | Enables etc-hosts context for the runner execution. |
|
|
835
840
|
| `--volume-host-path <volume-host-path>` | Optional: Specifies the volume host path for test execution. |
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
<div align="center">
|
|
18
18
|
|
|
19
|
-
[](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml) [](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml) [](https://www.npmjs.com/package/underpost) [](https://www.jsdelivr.com/package/npm/underpost) [](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml) [](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml) [](https://www.npmjs.com/package/underpost) [](https://www.jsdelivr.com/package/npm/underpost) [](https://socket.dev/npm/package/underpost/overview/3.2.30) [](https://coveralls.io/github/underpostnet/engine?branch=master) [](https://www.npmjs.org/package/underpost) [](https://www.npmjs.com/package/underpost)
|
|
20
20
|
|
|
21
21
|
</div>
|
|
22
22
|
|
|
@@ -88,7 +88,7 @@ npm run dev
|
|
|
88
88
|
<!-- cli-index-start -->
|
|
89
89
|
## Underpost CLI
|
|
90
90
|
|
|
91
|
-
> underpost ci/cd cli v3.2.
|
|
91
|
+
> underpost ci/cd cli v3.2.30
|
|
92
92
|
|
|
93
93
|
**Usage:** `underpost [options] [command]`
|
|
94
94
|
|
|
@@ -17,7 +17,7 @@ spec:
|
|
|
17
17
|
spec:
|
|
18
18
|
containers:
|
|
19
19
|
- name: dd-default-development-blue
|
|
20
|
-
image: underpost/underpost-engine:v3.2.
|
|
20
|
+
image: underpost/underpost-engine:v3.2.30
|
|
21
21
|
# resources:
|
|
22
22
|
# requests:
|
|
23
23
|
# memory: "124Ki"
|
|
@@ -98,7 +98,7 @@ spec:
|
|
|
98
98
|
spec:
|
|
99
99
|
containers:
|
|
100
100
|
- name: dd-default-development-green
|
|
101
|
-
image: underpost/underpost-engine:v3.2.
|
|
101
|
+
image: underpost/underpost-engine:v3.2.30
|
|
102
102
|
# resources:
|
|
103
103
|
# requests:
|
|
104
104
|
# memory: "124Ki"
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"type": "module",
|
|
3
3
|
"main": "src/index.js",
|
|
4
4
|
"name": "underpost",
|
|
5
|
-
"version": "3.2.
|
|
5
|
+
"version": "3.2.30",
|
|
6
6
|
"description": "Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orchestration. Extensible via downstream CLIs.",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"start": "node --max-old-space-size=8192 src/server",
|
package/scripts/test-monitor.sh
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
#
|
|
3
3
|
# test-monitor.sh — end-to-end deploy + two-phase monitor smoke test.
|
|
4
4
|
#
|
|
5
|
-
# Two deployment shapes are supported
|
|
6
|
-
# src/client/public/nexodev/docs/references/Deploy-Monitor-PRD.md and
|
|
7
|
-
# 'Deploy custom instance to K8S.md'):
|
|
5
|
+
# Two deployment shapes are supported:
|
|
8
6
|
#
|
|
9
7
|
# --mode runtime `underpost start` deploy (e.g. dd-test). Monitored with the
|
|
10
8
|
# HTTP gate: /_internal/ready probes + port-forward status.
|
|
@@ -26,11 +24,11 @@ MODE=runtime # runtime | instance
|
|
|
26
24
|
ENV=development # development | production
|
|
27
25
|
DEPLOY_ID=dd-test # deploy id (instance mode: parent of conf.instances.json)
|
|
28
26
|
INSTANCE_IDS= # instance mode: csv of ids (default: all in conf.instances.json)
|
|
29
|
-
IMAGE=underpost/wp:v3.2.
|
|
27
|
+
IMAGE=underpost/wp:v3.2.30 # runtime mode image (instance mode reads image from conf)
|
|
30
28
|
VERSIONS=green # csv of blue/green versions
|
|
31
29
|
REPLICAS=1 # replicas per deployment
|
|
32
30
|
NAMESPACE=default # k8s namespace
|
|
33
|
-
CLUSTER=
|
|
31
|
+
CLUSTER=kind # kind | kubeadm | k3s | "" (auto/none)
|
|
34
32
|
TIMEOUT_RESPONSE=300000ms # HTTPProxy per-route response timeout
|
|
35
33
|
TEMPLATE_REPO=underpostnet/pwa-microservices-template-private # runtime mode link repo
|
|
36
34
|
ENVOY_NAMESPACE=projectcontour # ingress namespace (instance TLS exposure)
|
package/src/cli/deploy.js
CHANGED
|
@@ -383,6 +383,10 @@ spec:
|
|
|
383
383
|
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
|
|
384
384
|
* @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
|
|
385
385
|
* @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes (migration path).
|
|
386
|
+
* @param {string} [options.node] - Explicit target node for hostPath PV nodeAffinity pinning; resolved through {@link UnderpostDeploy.resolveDeployNode} together with the cluster flags.
|
|
387
|
+
* @param {boolean} [options.kind] - Kind cluster context; affects the cluster-type node default when no explicit node is set.
|
|
388
|
+
* @param {boolean} [options.kubeadm] - Kubeadm cluster context; affects the cluster-type node default when no explicit node is set.
|
|
389
|
+
* @param {boolean} [options.k3s] - K3s cluster context; affects the cluster-type node default when no explicit node is set.
|
|
386
390
|
* @returns {Promise<void>} - Promise that resolves when the manifest is built.
|
|
387
391
|
* @memberof UnderpostDeploy
|
|
388
392
|
*/
|
|
@@ -455,6 +459,15 @@ ${Underpost.deploy
|
|
|
455
459
|
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
|
|
456
460
|
: [];
|
|
457
461
|
if (confVolume.length > 0) {
|
|
462
|
+
// Mirror deployVolume's data-node resolution so the generated manifest
|
|
463
|
+
// pins the PV to the same node that physically receives the volume data.
|
|
464
|
+
const pvDataNode = Underpost.deploy.resolveDeployNode({
|
|
465
|
+
node: options.node,
|
|
466
|
+
kind: options.kind,
|
|
467
|
+
kubeadm: options.kubeadm,
|
|
468
|
+
k3s: options.k3s,
|
|
469
|
+
env,
|
|
470
|
+
});
|
|
458
471
|
let volumeYaml = '';
|
|
459
472
|
for (const deploymentVersion of deploymentVersions) {
|
|
460
473
|
for (const volume of confVolume) {
|
|
@@ -466,6 +479,7 @@ ${Underpost.deploy
|
|
|
466
479
|
pvcId,
|
|
467
480
|
namespace: options.namespace,
|
|
468
481
|
hostPath,
|
|
482
|
+
nodeName: pvDataNode,
|
|
469
483
|
})}\n`;
|
|
470
484
|
}
|
|
471
485
|
}
|
|
@@ -720,7 +734,8 @@ spec:
|
|
|
720
734
|
* @param {string} options.image - Docker image for the deployment.
|
|
721
735
|
* @param {string} options.traffic - Traffic status for the deployment.
|
|
722
736
|
* @param {string} options.replicas - Number of replicas for the deployment.
|
|
723
|
-
* @param {string} options.node -
|
|
737
|
+
* @param {string} options.node - Explicit target node (highest precedence in the node chain). When empty, {@link UnderpostDeploy.resolveDeployNode} falls back to the cluster-type default (`kind-worker` for kind, host for kubeadm/k3s). Used for both volume placement and hostPath PV nodeAffinity.
|
|
738
|
+
* @param {string} [options.sshKeyPath] - Private key path for node SSH operations, forwarded to deployVolume when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.
|
|
724
739
|
* @param {boolean} options.disableUpdateDeployment - Whether to disable deployment updates.
|
|
725
740
|
* @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
|
|
726
741
|
* @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
|
|
@@ -938,9 +953,16 @@ EOF`);
|
|
|
938
953
|
env,
|
|
939
954
|
version,
|
|
940
955
|
namespace,
|
|
941
|
-
nodeName:
|
|
956
|
+
nodeName: Underpost.deploy.resolveDeployNode({
|
|
957
|
+
node: options.node,
|
|
958
|
+
kind: options.kind,
|
|
959
|
+
kubeadm: options.kubeadm,
|
|
960
|
+
k3s: options.k3s,
|
|
961
|
+
env,
|
|
962
|
+
}),
|
|
942
963
|
clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
|
|
943
964
|
gitClean: options.gitClean || false,
|
|
965
|
+
sshKeyPath: options.sshKeyPath || '',
|
|
944
966
|
});
|
|
945
967
|
}
|
|
946
968
|
|
|
@@ -1036,6 +1058,37 @@ EOF`);
|
|
|
1036
1058
|
Underpost.env.set(`${deployId}-${env}-traffic`, targetTraffic);
|
|
1037
1059
|
},
|
|
1038
1060
|
|
|
1061
|
+
/**
|
|
1062
|
+
* Resolves the effective target node for a deployment, applying a single
|
|
1063
|
+
* precedence chain shared by every deploy workflow — the default `deploy`
|
|
1064
|
+
* callback, `run sync`, and custom `run instance` — so node customization
|
|
1065
|
+
* behaves identically everywhere:
|
|
1066
|
+
*
|
|
1067
|
+
* 1. **Explicit node** — `node` (the resolved `--node` value). Upstream
|
|
1068
|
+
* runners derive it from the comma-path field or `--node-name`
|
|
1069
|
+
* (`run sync`: `path.split(',')[4]` > `--node-name` > default) and from
|
|
1070
|
+
* `--node-name` directly (`run instance`).
|
|
1071
|
+
* 2. **Cluster-type default** — when no explicit node is given: `kind-worker`
|
|
1072
|
+
* for a kind cluster (the node that hosts kind hostPath volumes),
|
|
1073
|
+
* otherwise the control-plane / current host (`os.hostname()`) for
|
|
1074
|
+
* kubeadm / k3s. With no explicit cluster flag, `development` is treated
|
|
1075
|
+
* as kind and `production` as the host, preserving legacy behaviour.
|
|
1076
|
+
*
|
|
1077
|
+
* @param {object} params
|
|
1078
|
+
* @param {string} [params.node=''] - Explicit node (`--node`); highest precedence.
|
|
1079
|
+
* @param {boolean} [params.kind=false] - Kind cluster context.
|
|
1080
|
+
* @param {boolean} [params.kubeadm=false] - Kubeadm cluster context.
|
|
1081
|
+
* @param {boolean} [params.k3s=false] - K3s cluster context.
|
|
1082
|
+
* @param {string} [params.env=''] - Deployment environment; tie-breaker when no cluster flag is set.
|
|
1083
|
+
* @returns {string} The effective node name.
|
|
1084
|
+
* @memberof UnderpostDeploy
|
|
1085
|
+
*/
|
|
1086
|
+
resolveDeployNode({ node = '', kind = false, kubeadm = false, k3s = false, env = '' } = {}) {
|
|
1087
|
+
if (node) return node;
|
|
1088
|
+
const isKind = kind || (!kubeadm && !k3s && env !== 'production');
|
|
1089
|
+
return isKind ? 'kind-worker' : os.hostname();
|
|
1090
|
+
},
|
|
1091
|
+
|
|
1039
1092
|
/**
|
|
1040
1093
|
* Deploys a volume for a deployment.
|
|
1041
1094
|
* @param {object} volume - Volume configuration.
|
|
@@ -1047,9 +1100,10 @@ EOF`);
|
|
|
1047
1100
|
* @param {string} options.env - Environment for the deployment.
|
|
1048
1101
|
* @param {string} options.version - Version of the deployment.
|
|
1049
1102
|
* @param {string} options.namespace - Kubernetes namespace for the deployment.
|
|
1050
|
-
* @param {string} options.nodeName -
|
|
1103
|
+
* @param {string} options.nodeName - Effective target node (already resolved via {@link UnderpostDeploy.resolveDeployNode}). The volume data is written/shipped here and the PV is pinned to it; an empty value falls back to the cluster-type default inside this method.
|
|
1051
1104
|
* @param {string} [options.clusterContext='kind'] - Cluster context type ('kind', 'kubeadm', or 'k3s').
|
|
1052
1105
|
* @param {boolean} [options.gitClean=false] - Whether to run git clean on volumeMountPath before copying.
|
|
1106
|
+
* @param {string} [options.sshKeyPath=''] - Private key path used when the target node is remote and the volume is shipped over SSH. Empty falls back to copyDirToNode's default (engine-private/deploy/id_rsa).
|
|
1053
1107
|
* @memberof UnderpostDeploy
|
|
1054
1108
|
*/
|
|
1055
1109
|
deployVolume(
|
|
@@ -1062,6 +1116,7 @@ EOF`);
|
|
|
1062
1116
|
nodeName: '',
|
|
1063
1117
|
clusterContext: 'kind',
|
|
1064
1118
|
gitClean: false,
|
|
1119
|
+
sshKeyPath: '',
|
|
1065
1120
|
},
|
|
1066
1121
|
) {
|
|
1067
1122
|
if (!volume.claimName) {
|
|
@@ -1076,16 +1131,48 @@ EOF`);
|
|
|
1076
1131
|
if (options.gitClean && volume.volumeMountPath) {
|
|
1077
1132
|
Underpost.repo.clean({ paths: [volume.volumeMountPath] });
|
|
1078
1133
|
}
|
|
1134
|
+
// The node that physically receives the volume data. hostPath volumes are
|
|
1135
|
+
// node-local, so the data must land on the node where the pod will run, and
|
|
1136
|
+
// the PV is pinned there (nodeAffinity) so the scheduler co-locates the pod
|
|
1137
|
+
// with its volume — never mounting an empty DirectoryOrCreate on another node.
|
|
1138
|
+
let dataNode;
|
|
1079
1139
|
if (clusterContext === 'kind') {
|
|
1080
1140
|
const kindNode = options.nodeName || 'kind-worker';
|
|
1141
|
+
dataNode = kindNode;
|
|
1081
1142
|
shellExec(`docker exec -i ${kindNode} bash -c "mkdir -p ${rootVolumeHostPath}"`);
|
|
1082
1143
|
shellExec(`tar -C ${volume.volumeMountPath} -c . | docker cp - ${kindNode}:${rootVolumeHostPath}`);
|
|
1083
1144
|
shellExec(
|
|
1084
1145
|
`docker exec -i ${kindNode} bash -c "chown -R 1000:1000 ${rootVolumeHostPath}; chmod -R 755 ${rootVolumeHostPath}"`,
|
|
1085
1146
|
);
|
|
1086
1147
|
} else {
|
|
1087
|
-
|
|
1088
|
-
|
|
1148
|
+
const localHost = os.hostname();
|
|
1149
|
+
dataNode = options.nodeName || localHost;
|
|
1150
|
+
if (dataNode === localHost) {
|
|
1151
|
+
// Target node is the control plane / current host: write directly.
|
|
1152
|
+
if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
|
|
1153
|
+
fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
|
|
1154
|
+
} else {
|
|
1155
|
+
// Target node is remote: fs.copySync would only write the control-plane
|
|
1156
|
+
// filesystem, leaving the real node's hostPath empty. Ship the folder to
|
|
1157
|
+
// the node over SSH so the data exists where the pod is pinned.
|
|
1158
|
+
const nodeHost =
|
|
1159
|
+
shellExec(
|
|
1160
|
+
`kubectl get node ${dataNode} -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'`,
|
|
1161
|
+
{ stdout: true, silent: true, silentOnError: true },
|
|
1162
|
+
).trim() || dataNode;
|
|
1163
|
+
logger.info('Shipping volume to remote node over SSH', {
|
|
1164
|
+
node: dataNode,
|
|
1165
|
+
host: nodeHost,
|
|
1166
|
+
src: volume.volumeMountPath,
|
|
1167
|
+
dest: rootVolumeHostPath,
|
|
1168
|
+
});
|
|
1169
|
+
Underpost.ssh.copyDirToNode({
|
|
1170
|
+
host: nodeHost,
|
|
1171
|
+
localDir: volume.volumeMountPath,
|
|
1172
|
+
remoteDir: rootVolumeHostPath,
|
|
1173
|
+
...(options.sshKeyPath ? { keyPath: options.sshKeyPath } : {}),
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1089
1176
|
}
|
|
1090
1177
|
shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
|
|
1091
1178
|
shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
|
|
@@ -1094,6 +1181,7 @@ ${Underpost.deploy.persistentVolumeFactory({
|
|
|
1094
1181
|
hostPath: rootVolumeHostPath,
|
|
1095
1182
|
pvcId,
|
|
1096
1183
|
namespace,
|
|
1184
|
+
nodeName: dataNode,
|
|
1097
1185
|
})}
|
|
1098
1186
|
EOF
|
|
1099
1187
|
`);
|
|
@@ -1182,11 +1270,28 @@ ${secret ? ` readOnly: true\n` : ''}`;
|
|
|
1182
1270
|
* @param {string} options.hostPath - Host path for the persistent volume.
|
|
1183
1271
|
* @param {string} options.pvcId - Persistent volume claim ID.
|
|
1184
1272
|
* @param {string} [options.namespace='default'] - Kubernetes namespace for the PVC claimRef.
|
|
1273
|
+
* @param {string} [options.nodeName=''] - Node name to which the persistent volume is pinned (optional).
|
|
1185
1274
|
* @returns {string} - YAML configuration for the persistent volume and claim.
|
|
1186
1275
|
* @memberof UnderpostDeploy
|
|
1187
1276
|
*/
|
|
1188
|
-
persistentVolumeFactory({ hostPath, pvcId, namespace = 'default' }) {
|
|
1277
|
+
persistentVolumeFactory({ hostPath, pvcId, namespace = 'default', nodeName = '' }) {
|
|
1189
1278
|
const pvId = pvcId.replace(/^pvc-/, 'pv-');
|
|
1279
|
+
// hostPath volumes are node-local: deployVolume writes the content to the
|
|
1280
|
+
// filesystem of a single node. Without nodeAffinity the scheduler can place
|
|
1281
|
+
// the pod on a different node and mount an empty DirectoryOrCreate hostPath
|
|
1282
|
+
// (missing the materialized assets). Pin the PV to the node that holds the
|
|
1283
|
+
// data so the pod is always co-located with its volume.
|
|
1284
|
+
const nodeAffinity = nodeName
|
|
1285
|
+
? `
|
|
1286
|
+
nodeAffinity:
|
|
1287
|
+
required:
|
|
1288
|
+
nodeSelectorTerms:
|
|
1289
|
+
- matchExpressions:
|
|
1290
|
+
- key: kubernetes.io/hostname
|
|
1291
|
+
operator: In
|
|
1292
|
+
values:
|
|
1293
|
+
- ${nodeName}`
|
|
1294
|
+
: '';
|
|
1190
1295
|
return `apiVersion: v1
|
|
1191
1296
|
kind: PersistentVolume
|
|
1192
1297
|
metadata:
|
|
@@ -1197,7 +1302,7 @@ spec:
|
|
|
1197
1302
|
accessModes:
|
|
1198
1303
|
- ReadWriteOnce
|
|
1199
1304
|
persistentVolumeReclaimPolicy: Retain
|
|
1200
|
-
storageClassName: manual
|
|
1305
|
+
storageClassName: manual${nodeAffinity}
|
|
1201
1306
|
claimRef:
|
|
1202
1307
|
apiVersion: v1
|
|
1203
1308
|
kind: PersistentVolumeClaim
|
package/src/cli/env.js
CHANGED
|
@@ -143,12 +143,21 @@ class UnderpostRootEnv {
|
|
|
143
143
|
/**
|
|
144
144
|
* @method clean
|
|
145
145
|
* @description Cleans the underpost root environment by removing the environment file.
|
|
146
|
+
* @param {object} options - Options for cleaning the environment.
|
|
147
|
+
* @param {Array<string>} [options.keepKeys=[]] - List of keys to keep in the environment file. If provided, only these keys will be retained.
|
|
146
148
|
* @memberof UnderpostEnv
|
|
147
149
|
*/
|
|
148
|
-
clean() {
|
|
150
|
+
clean(options = { keepKeys: [] }) {
|
|
151
|
+
const { keepKeys } = options;
|
|
149
152
|
const exeRootPath = `${getNpmRootPath()}/underpost`;
|
|
150
153
|
const envPath = `${exeRootPath}/.env`;
|
|
151
|
-
fs.
|
|
154
|
+
if (keepKeys && keepKeys.length > 0 && fs.existsSync(envPath)) {
|
|
155
|
+
const env = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
|
|
156
|
+
const filteredEnv = Object.fromEntries(Object.entries(env).filter(([key]) => keepKeys.includes(key)));
|
|
157
|
+
writeEnv(envPath, filteredEnv);
|
|
158
|
+
} else {
|
|
159
|
+
fs.removeSync(envPath);
|
|
160
|
+
}
|
|
152
161
|
},
|
|
153
162
|
/**
|
|
154
163
|
* @method isInsideContainer
|
package/src/cli/index.js
CHANGED
|
@@ -132,6 +132,12 @@ program
|
|
|
132
132
|
'--has-changes',
|
|
133
133
|
'Prints "1" if there are staged or unstaged git changes in the repository, empty string otherwise.',
|
|
134
134
|
)
|
|
135
|
+
.option('--remote-url', 'Prints the current git remote URL (origin) in plain text.')
|
|
136
|
+
.option(
|
|
137
|
+
'--switch-repo <url>',
|
|
138
|
+
'Switches the git remote (origin) to <url> and force-pulls the target branch, overwriting the current working tree (discards local commits and tracked changes). Accepts a full URL or "owner/repo".',
|
|
139
|
+
)
|
|
140
|
+
.option('--target-branch <branch>', 'Target branch for --switch-repo (default: master).')
|
|
135
141
|
.description('Manages commits to a GitHub repository, supporting various commit types and options.')
|
|
136
142
|
.action(Underpost.repo.commit);
|
|
137
143
|
|
|
@@ -330,6 +336,10 @@ program
|
|
|
330
336
|
'Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.',
|
|
331
337
|
)
|
|
332
338
|
.option('--node <node>', 'Sets optional node for deployment operations.')
|
|
339
|
+
.option(
|
|
340
|
+
'--ssh-key-path <path>',
|
|
341
|
+
'Private key path for node SSH operations. Currently used when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.',
|
|
342
|
+
)
|
|
333
343
|
.option(
|
|
334
344
|
'--build-manifest',
|
|
335
345
|
'Builds Kubernetes YAML manifests, including deployments, services, proxies, and secrets.',
|
|
@@ -630,6 +640,10 @@ program
|
|
|
630
640
|
.option('--replicas <replicas>', 'Sets a custom number of replicas for deployment.')
|
|
631
641
|
.option('--pod-name <pod-name>', 'Optional: Specifies the pod name for execution.')
|
|
632
642
|
.option('--node-name <node-name>', 'Optional: Specifies the node name for execution.')
|
|
643
|
+
.option(
|
|
644
|
+
'--ssh-key-path <path>',
|
|
645
|
+
'Optional: Private key path for node SSH operations, forwarded to volume shipping over SSH. Defaults to engine-private/deploy/id_rsa.',
|
|
646
|
+
)
|
|
633
647
|
.option('--port <port>', 'Optional: Specifies the port for execution.')
|
|
634
648
|
.option('--etc-hosts', 'Enables etc-hosts context for the runner execution.')
|
|
635
649
|
.option('--volume-host-path <volume-host-path>', 'Optional: Specifies the volume host path for test execution.')
|
package/src/cli/repository.js
CHANGED
|
@@ -106,6 +106,9 @@ class UnderpostRepository {
|
|
|
106
106
|
* @param {boolean} [options.changelogBuild=false] - If true, scrapes all git history and builds a full CHANGELOG.md. Commits containing 'New release v:' are used as version section titles. Only commits starting with '[<tag>]' are included as entries.
|
|
107
107
|
* @param {string} [options.changelogMinVersion=''] - If set, overrides the default minimum version limit (2.85.0) for --changelog-build.
|
|
108
108
|
* @param {boolean} [options.changelogNoHash=false] - If true, omits commit hashes from the changelog entries.
|
|
109
|
+
* @param {boolean} [options.remoteUrl=false] - If true, prints the current git remote URL (origin) in plain text and returns.
|
|
110
|
+
* @param {string} [options.switchRepo=''] - If set, switches the remote `origin` to this URL and force-pulls the target branch, overwriting the current working tree.
|
|
111
|
+
* @param {string} [options.targetBranch=''] - Target branch for `switchRepo` (defaults to `master`).
|
|
109
112
|
* @memberof UnderpostRepository
|
|
110
113
|
*/
|
|
111
114
|
commit(
|
|
@@ -135,6 +138,9 @@ class UnderpostRepository {
|
|
|
135
138
|
bc: '',
|
|
136
139
|
isRemoteRepo: '',
|
|
137
140
|
hasChanges: false,
|
|
141
|
+
remoteUrl: false,
|
|
142
|
+
switchRepo: '',
|
|
143
|
+
targetBranch: '',
|
|
138
144
|
},
|
|
139
145
|
) {
|
|
140
146
|
if (!repoPath) repoPath = '.';
|
|
@@ -155,6 +161,22 @@ class UnderpostRepository {
|
|
|
155
161
|
return;
|
|
156
162
|
}
|
|
157
163
|
|
|
164
|
+
if (options.remoteUrl) {
|
|
165
|
+
const url = Underpost.repo.getRemoteUrl({ path: repoPath });
|
|
166
|
+
if (options.copy) pbcopy(url);
|
|
167
|
+
else console.log(url);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.switchRepo) {
|
|
172
|
+
Underpost.repo.switchRemote({
|
|
173
|
+
path: repoPath,
|
|
174
|
+
url: options.switchRepo,
|
|
175
|
+
branch: options.targetBranch || 'master',
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
158
180
|
if (options.bc) {
|
|
159
181
|
console.log(
|
|
160
182
|
shellExec(`cd ${repoPath} && git for-each-ref --contains ${options.bc} --format='%(refname:short)'`, {
|
|
@@ -1332,6 +1354,65 @@ Prevent build private config repo.`,
|
|
|
1332
1354
|
return typeof raw === 'string' && /^[0-9a-f]{40}\t/m.test(raw);
|
|
1333
1355
|
},
|
|
1334
1356
|
|
|
1357
|
+
/**
|
|
1358
|
+
* Returns the current URL of a git remote in plain text.
|
|
1359
|
+
* @param {object} [opts]
|
|
1360
|
+
* @param {string} [opts.path='.'] - Path to the git repository.
|
|
1361
|
+
* @param {string} [opts.remote='origin'] - Remote name to query.
|
|
1362
|
+
* @returns {string} The remote URL, or '' when the remote is not configured.
|
|
1363
|
+
* @memberof UnderpostRepository
|
|
1364
|
+
*/
|
|
1365
|
+
getRemoteUrl({ path: repoPath = '.', remote = 'origin' } = {}) {
|
|
1366
|
+
return shellExec(`cd "${repoPath}" && git remote get-url ${remote}`, {
|
|
1367
|
+
stdout: true,
|
|
1368
|
+
silent: true,
|
|
1369
|
+
disableLog: true,
|
|
1370
|
+
silentOnError: true,
|
|
1371
|
+
}).trim();
|
|
1372
|
+
},
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Switches a local repository onto a different remote and force-syncs its
|
|
1376
|
+
* working tree to a target branch, discarding local commits and tracked
|
|
1377
|
+
* changes — effectively "switch repo to <url>#<branch>".
|
|
1378
|
+
*
|
|
1379
|
+
* Sequence (idempotent, re-runnable):
|
|
1380
|
+
* 1. Normalize the URL (`owner/repo` → full GitHub HTTPS) and set/add the
|
|
1381
|
+
* remote, storing the token-free URL so no secret leaks into `.git/config`.
|
|
1382
|
+
* 2. Force-fetch the target branch (auth injected inline for private repos).
|
|
1383
|
+
* 3. Reset the working tree to the fetched tip and check out the target
|
|
1384
|
+
* branch, overwriting any current tracked content.
|
|
1385
|
+
*
|
|
1386
|
+
* Untracked files are intentionally left in place (no `git clean`).
|
|
1387
|
+
*
|
|
1388
|
+
* @param {object} opts
|
|
1389
|
+
* @param {string} opts.url - New remote URL (full URL or "owner/repo" short form).
|
|
1390
|
+
* @param {string} [opts.path='.'] - Path to the git repository.
|
|
1391
|
+
* @param {string} [opts.branch='master'] - Target branch to overwrite the current tree with.
|
|
1392
|
+
* @param {string} [opts.remote='origin'] - Remote name to set and fetch from.
|
|
1393
|
+
* @returns {void}
|
|
1394
|
+
* @memberof UnderpostRepository
|
|
1395
|
+
*/
|
|
1396
|
+
switchRemote({ url, path: repoPath = '.', branch = 'master', remote = 'origin' }) {
|
|
1397
|
+
if (!url) throw new Error('switchRemote requires a target remote url');
|
|
1398
|
+
if (!fs.existsSync(`${repoPath}/.git`)) throw new Error(`switchRemote: not a git repository: ${repoPath}`);
|
|
1399
|
+
// Token-free URL for the stored remote; auth-injected URL only for the fetch.
|
|
1400
|
+
let normalized = url;
|
|
1401
|
+
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) {
|
|
1402
|
+
normalized = `https://github.com/${url}`;
|
|
1403
|
+
}
|
|
1404
|
+
const authUrl = Underpost.repo.resolveAuthUrl(url);
|
|
1405
|
+
const current = Underpost.repo.getRemoteUrl({ path: repoPath, remote });
|
|
1406
|
+
if (!current) shellExec(`cd "${repoPath}" && git remote add ${remote} "${normalized}"`);
|
|
1407
|
+
else shellExec(`cd "${repoPath}" && git remote set-url ${remote} "${normalized}"`);
|
|
1408
|
+
logger.info('switchRemote', { path: repoPath, remote, branch, url: normalized });
|
|
1409
|
+
shellExec(`cd "${repoPath}" && GIT_TERMINAL_PROMPT=0 git fetch --force "${authUrl}" ${branch}`);
|
|
1410
|
+
// reset --hard first clears the worktree so the checkout cannot be blocked
|
|
1411
|
+
// by conflicting local changes; -B points the target branch at the fetched tip.
|
|
1412
|
+
shellExec(`cd "${repoPath}" && git reset --hard FETCH_HEAD`);
|
|
1413
|
+
shellExec(`cd "${repoPath}" && git checkout -B ${branch} FETCH_HEAD`);
|
|
1414
|
+
},
|
|
1415
|
+
|
|
1335
1416
|
/**
|
|
1336
1417
|
* Returns metadata about unpushed commits in a git repository.
|
|
1337
1418
|
* Fetches from origin, then counts commits ahead of the remote branch.
|
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: '',
|
|
@@ -444,7 +446,15 @@ class UnderpostRun {
|
|
|
444
446
|
|
|
445
447
|
// Kinds that own a pod template we can patch; rolloutKinds additionally
|
|
446
448
|
// support `kubectl rollout restart` to reschedule existing pods now.
|
|
447
|
-
const templated = [
|
|
449
|
+
const templated = [
|
|
450
|
+
'deployment',
|
|
451
|
+
'statefulset',
|
|
452
|
+
'daemonset',
|
|
453
|
+
'replicaset',
|
|
454
|
+
'job',
|
|
455
|
+
'cronjob',
|
|
456
|
+
'replicationcontroller',
|
|
457
|
+
];
|
|
448
458
|
const rolloutKinds = ['deployment', 'statefulset', 'daemonset'];
|
|
449
459
|
const templateSelectorPath = (kind) =>
|
|
450
460
|
kind === 'cronjob'
|
|
@@ -456,7 +466,10 @@ class UnderpostRun {
|
|
|
456
466
|
let selector = { 'kubernetes.io/hostname': node };
|
|
457
467
|
if (!remove && options.labels) {
|
|
458
468
|
selector = {};
|
|
459
|
-
for (const pair of `${options.labels}
|
|
469
|
+
for (const pair of `${options.labels}`
|
|
470
|
+
.split(',')
|
|
471
|
+
.map((s) => s.trim())
|
|
472
|
+
.filter(Boolean)) {
|
|
460
473
|
const eq = pair.indexOf('=');
|
|
461
474
|
if (eq < 0) continue;
|
|
462
475
|
selector[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
@@ -483,8 +496,11 @@ class UnderpostRun {
|
|
|
483
496
|
|
|
484
497
|
const kubectlNames = (kind) =>
|
|
485
498
|
(
|
|
486
|
-
shellExec(`kubectl get ${kind} -n ${ns} -o name`, {
|
|
487
|
-
|
|
499
|
+
shellExec(`kubectl get ${kind} -n ${ns} -o name`, {
|
|
500
|
+
silent: true,
|
|
501
|
+
stdout: true,
|
|
502
|
+
silentOnError: true,
|
|
503
|
+
}).trim() || ''
|
|
488
504
|
)
|
|
489
505
|
.split('\n')
|
|
490
506
|
.map((s) => s.trim())
|
|
@@ -613,7 +629,9 @@ class UnderpostRun {
|
|
|
613
629
|
* @memberof UnderpostRun
|
|
614
630
|
*/
|
|
615
631
|
'cluster-build': (path, options = DEFAULT_OPTION) => {
|
|
616
|
-
const nodeOptions =
|
|
632
|
+
const nodeOptions =
|
|
633
|
+
(options.nodeName ? ` --node-name ${options.nodeName}` : '') +
|
|
634
|
+
(options.sshKeyPath ? ` --ssh-key-path ${options.sshKeyPath}` : '');
|
|
617
635
|
shellExec(`node bin run clean`);
|
|
618
636
|
shellExec(`node bin run --dev sync-replica template-deploy${nodeOptions}`);
|
|
619
637
|
shellExec(`node bin run sync-replica template-deploy${nodeOptions}`);
|
|
@@ -909,7 +927,7 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
|
|
|
909
927
|
replicas = replicas ? replicas : defaultPath[1];
|
|
910
928
|
versions = versions ? versions.replaceAll('+', ',') : defaultPath[2];
|
|
911
929
|
image = image ? image : defaultPath[3];
|
|
912
|
-
node = node ? node : defaultPath[4];
|
|
930
|
+
node = node ? node : options.nodeName ? options.nodeName : defaultPath[4];
|
|
913
931
|
shellExec(`${baseCommand} cluster --ns-use ${options.namespace}`);
|
|
914
932
|
|
|
915
933
|
if (image && !image.startsWith('localhost'))
|
|
@@ -950,13 +968,14 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
|
|
|
950
968
|
const skipFullBuildFlag = options.skipFullBuild ? ' --skip-full-build' : '';
|
|
951
969
|
const pullBundleFlag = options.pullBundle ? ' --pull-bundle' : '';
|
|
952
970
|
const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
|
|
971
|
+
const sshKeyPathFlag = options.sshKeyPath ? ` --ssh-key-path ${options.sshKeyPath}` : '';
|
|
953
972
|
|
|
954
973
|
shellExec(
|
|
955
974
|
`${baseCommand} deploy${clusterFlag} --build-manifest --sync --info-router --replicas ${replicas} --node ${node}${
|
|
956
975
|
image ? ` --image ${image}` : ''
|
|
957
976
|
}${versions ? ` --versions ${versions}` : ''}${
|
|
958
977
|
options.namespace ? ` --namespace ${options.namespace}` : ''
|
|
959
|
-
}${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag} ${deployId} ${env}`,
|
|
978
|
+
}${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag}${sshKeyPathFlag} ${deployId} ${env}`,
|
|
960
979
|
);
|
|
961
980
|
|
|
962
981
|
if (isDeployRunnerContext(path, options)) {
|
|
@@ -965,9 +984,9 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
|
|
|
965
984
|
`${baseCommand} db ${deployId} ${clusterFlag}${baseClusterCommand} --repo-backup --primary-pod --git --force-clone --preserveUUID ${options.namespace ? ` --ns ${options.namespace}` : ''}`,
|
|
966
985
|
);
|
|
967
986
|
shellExec(
|
|
968
|
-
`${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}${
|
|
969
988
|
options.namespace ? ` --namespace ${options.namespace}` : ''
|
|
970
|
-
}${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}`,
|
|
989
|
+
}${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}${sshKeyPathFlag}`,
|
|
971
990
|
);
|
|
972
991
|
if (!targetTraffic)
|
|
973
992
|
targetTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
|
|
@@ -1327,9 +1346,16 @@ EOF
|
|
|
1327
1346
|
deployId: _deployId,
|
|
1328
1347
|
env,
|
|
1329
1348
|
version: targetTraffic,
|
|
1330
|
-
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
|
+
}),
|
|
1331
1356
|
clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
|
|
1332
1357
|
gitClean: options.gitClean || false,
|
|
1358
|
+
sshKeyPath: options.sshKeyPath || '',
|
|
1333
1359
|
});
|
|
1334
1360
|
// Regenerate the parent deploy's gRPC ClusterIP service pointing to the
|
|
1335
1361
|
// parent's current traffic colour and apply it before the instance pod starts so
|
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.
|
package/src/client.build.js
CHANGED
|
@@ -290,19 +290,16 @@ class MongoBootstrap {
|
|
|
290
290
|
* @returns {Promise<number>} Number of pods that failed to become ready (0 = all good).
|
|
291
291
|
*/
|
|
292
292
|
static async waitForPods(namespace, replicaCount) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const failed = results.filter((r) => !r.ready).map((r) => `${MONGODB_STATEFULSET_NAME}-${r.index}`);
|
|
302
|
-
if (failed.length > 0) {
|
|
303
|
-
logger.error('MongoDB pods did not become ready', { failed });
|
|
293
|
+
let failedCount = 0;
|
|
294
|
+
for (let i = 0; i < replicaCount; i++) {
|
|
295
|
+
const podName = `${MONGODB_STATEFULSET_NAME}-${i}`;
|
|
296
|
+
const result = shellExec(`kubectl wait --for=condition=Ready pod/${podName} -n ${namespace} --timeout=60s`);
|
|
297
|
+
if (result.code !== 0) {
|
|
298
|
+
logger.error(`Pod ${podName} did not become ready`);
|
|
299
|
+
failedCount++;
|
|
300
|
+
}
|
|
304
301
|
}
|
|
305
|
-
return
|
|
302
|
+
return failedCount;
|
|
306
303
|
}
|
|
307
304
|
|
|
308
305
|
/**
|
|
@@ -620,6 +617,9 @@ class MongoBootstrap {
|
|
|
620
617
|
process.env.DB_PASSWORD ||
|
|
621
618
|
readTrimmedFile('./engine-private/mongodb-password');
|
|
622
619
|
|
|
620
|
+
// Ensure the pod is ready before querying
|
|
621
|
+
shellExec(`kubectl wait --for=condition=Ready pod/${podName} -n ${namespace} --timeout=60s`);
|
|
622
|
+
|
|
623
623
|
const evalExpr = 'rs.status().members.filter(m=>m.stateStr=="PRIMARY").map(m=>m.name)';
|
|
624
624
|
|
|
625
625
|
const cli = disableAuth ? 'mongo' : 'mongosh';
|
package/src/index.js
CHANGED
|
@@ -40,6 +40,7 @@ const RUNTIME_STATUS = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
const CONTAINER_STATUS_KEY = 'container-status';
|
|
43
|
+
const START_CONTAINER_STATUS_KEY = 'start-container-status';
|
|
43
44
|
const INTERNAL_STATUS_PATH = '/_internal/status';
|
|
44
45
|
const INTERNAL_READY_PATH = '/_internal/ready';
|
|
45
46
|
const INTERNAL_HEALTH_PATH = '/_internal/health';
|
|
@@ -118,6 +119,20 @@ const normalizeContainerStatus = (raw) => {
|
|
|
118
119
|
const getRuntimeStatus = () =>
|
|
119
120
|
normalizeContainerStatus(Underpost.env.get(CONTAINER_STATUS_KEY, undefined, { disableLog: true }));
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Reads the start-container-status env key — an insulated marker set by the
|
|
124
|
+
* start pipeline after it completes the running phase. Unlike container-status
|
|
125
|
+
* (which external scripts / backup failures may clobber), this key is written
|
|
126
|
+
* once and survives globalSecretClean. Used exclusively by the readinessProbe
|
|
127
|
+
* endpoint so K8s pod readiness is never derailed by lifecycle noise.
|
|
128
|
+
* @memberof RuntimeStatus
|
|
129
|
+
* @returns {string|undefined}
|
|
130
|
+
*/
|
|
131
|
+
const getStartContainerStatus = () => {
|
|
132
|
+
const raw = Underpost.env.get(START_CONTAINER_STATUS_KEY, undefined, { disableLog: true });
|
|
133
|
+
return raw && typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
|
134
|
+
};
|
|
135
|
+
|
|
121
136
|
/**
|
|
122
137
|
* Minimal, secret-free payload served by the internal status endpoint and used
|
|
123
138
|
* by the monitor for failure classification and observability.
|
|
@@ -188,7 +203,7 @@ const startInternalStatusServer = (port = resolveInternalStatusPort()) => {
|
|
|
188
203
|
case INTERNAL_HEALTH_PATH:
|
|
189
204
|
return sendJson(200, { status: 'ok' });
|
|
190
205
|
case INTERNAL_READY_PATH:
|
|
191
|
-
return
|
|
206
|
+
return getStartContainerStatus()
|
|
192
207
|
? sendJson(200, { status: RUNTIME_STATUS.RUNNING })
|
|
193
208
|
: sendJson(503, { status: getRuntimeStatus() ?? null });
|
|
194
209
|
case INTERNAL_STATUS_PATH:
|
|
@@ -220,6 +235,7 @@ const stopInternalStatusServer = () =>
|
|
|
220
235
|
export {
|
|
221
236
|
RUNTIME_STATUS,
|
|
222
237
|
CONTAINER_STATUS_KEY,
|
|
238
|
+
START_CONTAINER_STATUS_KEY,
|
|
223
239
|
INTERNAL_STATUS_PATH,
|
|
224
240
|
INTERNAL_READY_PATH,
|
|
225
241
|
INTERNAL_HEALTH_PATH,
|
|
@@ -228,6 +244,7 @@ export {
|
|
|
228
244
|
containerStatusValue,
|
|
229
245
|
normalizeContainerStatus,
|
|
230
246
|
getRuntimeStatus,
|
|
247
|
+
getStartContainerStatus,
|
|
231
248
|
runtimeStatusPayload,
|
|
232
249
|
setRuntimeStatus,
|
|
233
250
|
startInternalStatusServer,
|
package/src/server/start.js
CHANGED
|
@@ -8,7 +8,14 @@ import fs from 'fs-extra';
|
|
|
8
8
|
import { awaitDeployMonitor } from './conf.js';
|
|
9
9
|
import { actionInitLog, loggerFactory } from './logger.js';
|
|
10
10
|
import { shellCd, shellExec } from './process.js';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
RUNTIME_STATUS,
|
|
13
|
+
START_CONTAINER_STATUS_KEY,
|
|
14
|
+
setRuntimeStatus,
|
|
15
|
+
startInternalStatusServer,
|
|
16
|
+
deployStatusPort,
|
|
17
|
+
containerStatusValue,
|
|
18
|
+
} from './runtime-status.js';
|
|
12
19
|
import Underpost from '../index.js';
|
|
13
20
|
const logger = loggerFactory(import.meta);
|
|
14
21
|
|
|
@@ -241,7 +248,10 @@ class UnderpostStartUp {
|
|
|
241
248
|
const result = await awaitDeployMonitor(true);
|
|
242
249
|
if (result === true) {
|
|
243
250
|
if (env === 'production' && Underpost.env.isInsideContainer()) Underpost.secret.globalSecretClean();
|
|
244
|
-
|
|
251
|
+
setTimeout(() => {
|
|
252
|
+
setRuntimeStatus(deployId, env, RUNTIME_STATUS.RUNNING);
|
|
253
|
+
Underpost.env.set(START_CONTAINER_STATUS_KEY, containerStatusValue(deployId, env, RUNTIME_STATUS.RUNNING));
|
|
254
|
+
});
|
|
245
255
|
} else {
|
|
246
256
|
setRuntimeStatus(deployId, env, RUNTIME_STATUS.ERROR);
|
|
247
257
|
}
|
package/src/server.js
CHANGED
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
// https://nodejs.org/api
|
|
4
4
|
// https://expressjs.com/en/4x/api.html
|
|
5
5
|
|
|
6
|
+
import dotenv from 'dotenv';
|
|
6
7
|
import { loggerFactory } from './server/logger.js';
|
|
7
8
|
import { buildClient } from './client-builder/client-build.js';
|
|
8
9
|
import { buildRuntime } from './server/runtime.js';
|
|
9
10
|
import { ProcessController } from './server/process.js';
|
|
10
11
|
import { Config } from './server/conf.js';
|
|
12
|
+
|
|
13
|
+
dotenv.config();
|
|
14
|
+
|
|
11
15
|
await Config.build();
|
|
12
16
|
|
|
13
17
|
const logger = loggerFactory(import.meta);
|
|
14
18
|
|
|
15
19
|
await logger.setUpInfo();
|
|
16
20
|
|
|
17
|
-
await buildClient();
|
|
21
|
+
if (process.env.NODE_ENV === 'development') await buildClient();
|
|
18
22
|
|
|
19
23
|
await buildRuntime();
|
|
20
24
|
|
|
@@ -69,12 +69,10 @@ describe('Deploy monitor — two-phase state machine (e2e, real HTTP transport)'
|
|
|
69
69
|
process.env.npm_config_prefix = tmpPrefix;
|
|
70
70
|
|
|
71
71
|
Underpost.env.set('container-status', 'init');
|
|
72
|
+
Underpost.env.set('start-container-status', '');
|
|
72
73
|
const npmRoot = shellExec('npm root -g', { stdout: true, silent: true, disableLog: true }).trim();
|
|
73
74
|
envFile = path.join(npmRoot, 'underpost', '.env');
|
|
74
75
|
|
|
75
|
-
// Real in-pod internal status server: serves container-status from the same
|
|
76
|
-
// env file the runtime writes. Bound in this test process; the monitor's
|
|
77
|
-
// port-forward tunnel (localPort == INTERNAL_PORT) resolves straight to it.
|
|
78
76
|
process.env.UNDERPOST_INTERNAL_PORT = String(INTERNAL_PORT);
|
|
79
77
|
startInternalStatusServer(INTERNAL_PORT);
|
|
80
78
|
|
|
@@ -148,10 +146,11 @@ try {
|
|
|
148
146
|
beforeEach(() => {
|
|
149
147
|
// Deploy in flight: app not yet reporting running.
|
|
150
148
|
Underpost.env.set('container-status', INIT_STATUS);
|
|
149
|
+
// start-container-status is unset (empty) — the readinessProbe
|
|
150
|
+
// sees 503 until the start pipeline completes and stamps this key.
|
|
151
|
+
Underpost.env.set('start-container-status', '');
|
|
151
152
|
});
|
|
152
153
|
|
|
153
|
-
// Spawns the real monitorReadyRunner with the fake cluster on PATH; resolves
|
|
154
|
-
// with its exit code. `overrides` inject deterministic timing / target port.
|
|
155
154
|
const spawnMonitor = (overrides = {}) =>
|
|
156
155
|
new Promise((resolve) => {
|
|
157
156
|
const envVars = {
|
|
@@ -160,9 +159,6 @@ try {
|
|
|
160
159
|
POD_NAME,
|
|
161
160
|
npm_config_prefix: tmpPrefix,
|
|
162
161
|
UNDERPOST_INTERNAL_PORT: String(INTERNAL_PORT),
|
|
163
|
-
// Pin the tunnel's local port so the no-op fake port-forward + the real
|
|
164
|
-
// internal server (bound to INTERNAL_PORT in this process) resolve to the
|
|
165
|
-
// same address the monitor's HTTP GET targets.
|
|
166
162
|
UNDERPOST_PF_LOCAL_PORT: String(INTERNAL_PORT),
|
|
167
163
|
UNDERPOST_MONITOR_DELAY_MS: '100',
|
|
168
164
|
UNDERPOST_MONITOR_MAX_ITERATIONS: '60',
|
|
@@ -183,12 +179,15 @@ try {
|
|
|
183
179
|
|
|
184
180
|
it('success (default exec transport): both phases satisfied → monitor exits 0', async () => {
|
|
185
181
|
Underpost.env.set('container-status', RUNNING_STATUS);
|
|
182
|
+
// start-container-status not needed for monitor (reads container-status);
|
|
183
|
+
// this test exercises the default exec transport path.
|
|
186
184
|
const code = await spawnMonitor({ FAKE_POD_READY: 'True' });
|
|
187
185
|
expect(code).to.equal(0);
|
|
188
186
|
});
|
|
189
187
|
|
|
190
188
|
it('success (opt-in http transport): both phases satisfied → monitor exits 0', async () => {
|
|
191
189
|
Underpost.env.set('container-status', RUNNING_STATUS);
|
|
190
|
+
Underpost.env.set('start-container-status', RUNNING_STATUS);
|
|
192
191
|
const code = await spawnMonitor({ FAKE_POD_READY: 'True', MON_TRANSPORT: 'http' });
|
|
193
192
|
expect(code).to.equal(0);
|
|
194
193
|
});
|
|
@@ -202,13 +201,13 @@ try {
|
|
|
202
201
|
it('readiness mismatch: runtime running but pod not Ready → never succeeds (exits 1)', async () => {
|
|
203
202
|
// Phase 2 satisfied, Phase 1 not: success requires BOTH, so it must time out.
|
|
204
203
|
Underpost.env.set('container-status', RUNNING_STATUS);
|
|
204
|
+
// start-container-status is NOT set — the readinessProbe would fail,
|
|
205
|
+
// but the monitor only checks container-status via exec/http transport.
|
|
205
206
|
const code = await spawnMonitor({ FAKE_POD_READY: 'False', UNDERPOST_MONITOR_MAX_ITERATIONS: '4' });
|
|
206
207
|
expect(code).to.equal(1);
|
|
207
208
|
});
|
|
208
209
|
|
|
209
210
|
it('transport failure (http): endpoint unreachable is never success (exits 1)', async () => {
|
|
210
|
-
// Opt into http and point the monitor at a port with no internal server; the
|
|
211
|
-
// HTTP read always fails, so runtime readiness is never confirmed → timeout.
|
|
212
211
|
Underpost.env.set('container-status', RUNNING_STATUS);
|
|
213
212
|
const code = await spawnMonitor({
|
|
214
213
|
MON_TRANSPORT: 'http',
|
|
@@ -221,6 +220,8 @@ try {
|
|
|
221
220
|
|
|
222
221
|
it('timeout: runtime stuck initializing → monitor exits 1', async () => {
|
|
223
222
|
Underpost.env.set('container-status', INIT_STATUS);
|
|
223
|
+
// start-container-status remains unset — readinessProbe returns 503
|
|
224
|
+
// (correct, since the runtime is still initializing).
|
|
224
225
|
const code = await spawnMonitor({ FAKE_POD_READY: 'True', UNDERPOST_MONITOR_MAX_ITERATIONS: '4' });
|
|
225
226
|
expect(code).to.equal(1);
|
|
226
227
|
});
|
|
@@ -235,6 +236,21 @@ try {
|
|
|
235
236
|
expect(await monitorExit).to.equal(1);
|
|
236
237
|
});
|
|
237
238
|
|
|
239
|
+
it('readinessProbe: start-container-status set → returns 200 (probe passes)', async () => {
|
|
240
|
+
Underpost.env.set('start-container-status', RUNNING_STATUS);
|
|
241
|
+
const res = await fetch(`http://127.0.0.1:${INTERNAL_PORT}/_internal/ready`);
|
|
242
|
+
expect(res.status).to.equal(200);
|
|
243
|
+
const body = await res.json();
|
|
244
|
+
expect(body.status).to.equal('running-deployment');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('readinessProbe: start-container-status unset → returns 503 (probe fails)', async () => {
|
|
248
|
+
// Unset from beforeEach re-stamp; clear it.
|
|
249
|
+
Underpost.env.set('start-container-status', '');
|
|
250
|
+
const res = await fetch(`http://127.0.0.1:${INTERNAL_PORT}/_internal/ready`);
|
|
251
|
+
expect(res.status).to.equal(503);
|
|
252
|
+
});
|
|
253
|
+
|
|
238
254
|
// Custom instances (cyberia-*) gate on K8s Ready and read status via exec;
|
|
239
255
|
// their runtime never stamps `running-deployment` (stays `initializing`).
|
|
240
256
|
it('instance (kubernetes gate + exec): K8s Ready with initializing status → exits 0', async () => {
|