underpost 3.2.14 → 3.2.22
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 +94 -1
- package/CLI-HELP.md +99 -30
- package/README.md +2 -2
- package/bin/build.js +12 -1
- package/bin/build.template.js +4 -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/link-local-underpost-cli.sh +6 -0
- package/scripts/test-monitor.sh +250 -0
- package/src/cli/deploy.js +200 -54
- package/src/cli/env.js +1 -4
- package/src/cli/index.js +47 -0
- package/src/cli/monitor.js +269 -72
- package/src/cli/release.js +21 -6
- package/src/cli/repository.js +21 -4
- package/src/cli/run.js +44 -4
- package/src/client/components/core/PanelForm.js +44 -44
- package/src/db/mongo/MongooseDB.js +2 -1
- package/src/index.js +1 -1
- package/src/server/conf.js +91 -18
- package/src/server/ipfs-client.js +5 -3
- package/src/server/runtime-status.js +235 -0
- package/src/server/start.js +26 -9
- package/test/deploy-monitor.test.js +132 -69
package/src/cli/env.js
CHANGED
|
@@ -95,10 +95,7 @@ class UnderpostRootEnv {
|
|
|
95
95
|
get(key, value, options = { plain: false, disableLog: false, copy: false }) {
|
|
96
96
|
const exeRootPath = `${getNpmRootPath()}/underpost`;
|
|
97
97
|
const envPath = `${exeRootPath}/.env`;
|
|
98
|
-
if (!fs.existsSync(envPath) || !fs.statSync(envPath).isFile())
|
|
99
|
-
logger.warn(`Empty environment variables`);
|
|
100
|
-
return undefined;
|
|
101
|
-
}
|
|
98
|
+
if (!fs.existsSync(envPath) || !fs.statSync(envPath).isFile()) return undefined;
|
|
102
99
|
const env = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
|
|
103
100
|
if (!options.disableLog)
|
|
104
101
|
options?.plain === true ? console.log(env[key]) : logger.info(`${key}(${typeof env[key]})`, env[key]);
|
package/src/cli/index.js
CHANGED
|
@@ -70,6 +70,10 @@ program
|
|
|
70
70
|
'--pull-bundle',
|
|
71
71
|
'Downloads the pre-built client bundle from Cloudinary via pull-bundle before starting. Use together with --skip-full-build to skip the local build entirely.',
|
|
72
72
|
)
|
|
73
|
+
.option(
|
|
74
|
+
'--private-test-repo',
|
|
75
|
+
'During --build, clone the private test source repo (engine-test-<id>) instead of the production engine-<id> repo.',
|
|
76
|
+
)
|
|
73
77
|
.action(Underpost.start.callback)
|
|
74
78
|
.description('Initiates application servers, build pipelines, or other defined services based on the deployment ID.');
|
|
75
79
|
|
|
@@ -124,6 +128,10 @@ program
|
|
|
124
128
|
'--is-remote-repo <url-repo>',
|
|
125
129
|
'Checks whether a remote Git repository URL is reachable. Prints true or false.',
|
|
126
130
|
)
|
|
131
|
+
.option(
|
|
132
|
+
'--has-changes',
|
|
133
|
+
'Prints "1" if there are staged or unstaged git changes in the repository, empty string otherwise.',
|
|
134
|
+
)
|
|
127
135
|
.description('Manages commits to a GitHub repository, supporting various commit types and options.')
|
|
128
136
|
.action(Underpost.repo.commit);
|
|
129
137
|
|
|
@@ -315,6 +323,12 @@ program
|
|
|
315
323
|
.option('--expose', 'Exposes services matching the provided deployment ID list.')
|
|
316
324
|
.option('--cert', 'Resets TLS/SSL certificate secrets for deployments.')
|
|
317
325
|
.option('--cert-hosts <hosts>', 'Resets TLS/SSL certificate secrets for specified hosts.')
|
|
326
|
+
.option(
|
|
327
|
+
'--self-signed',
|
|
328
|
+
'Use a pre-created self-signed TLS secret (kubernetes.io/tls) instead of cert-manager. ' +
|
|
329
|
+
'The secret must already exist in the namespace with the same name as the host. ' +
|
|
330
|
+
'Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.',
|
|
331
|
+
)
|
|
318
332
|
.option('--node <node>', 'Sets optional node for deployment operations.')
|
|
319
333
|
.option(
|
|
320
334
|
'--build-manifest',
|
|
@@ -332,6 +346,8 @@ program
|
|
|
332
346
|
.option('--retry-count <count>', 'Sets HTTPProxy per-route retry count (e.g., 3).')
|
|
333
347
|
.option('--retry-per-try-timeout <duration>', 'Sets HTTPProxy retry per-try timeout (e.g., "150ms").')
|
|
334
348
|
.option('--disable-update-deployment', 'Disables updates to deployments.')
|
|
349
|
+
.option('--disable-runtime-probes', 'Omits the internal-status HTTP probes from generated deployment manifests.')
|
|
350
|
+
.option('--tcp-probes', 'Generates legacy TCP socket probes instead of HTTP internal-status probes (migration).')
|
|
335
351
|
.option('--disable-update-proxy', 'Disables updates to proxies.')
|
|
336
352
|
.option('--disable-deployment-proxy', 'Disables proxies of deployments.')
|
|
337
353
|
.option('--disable-update-volume', 'Disables updates to volume mounts during deployment.')
|
|
@@ -351,6 +367,14 @@ program
|
|
|
351
367
|
'--expose-port <port>',
|
|
352
368
|
'Sets the local:remote port to expose when --expose is active (overrides auto-detected service port).',
|
|
353
369
|
)
|
|
370
|
+
.option(
|
|
371
|
+
'--expose-local-port <port>',
|
|
372
|
+
'Sets a different local port for --expose (e.g. 80) while keeping the remote service port. Useful for /etc/hosts local access without specifying a port in the browser.',
|
|
373
|
+
)
|
|
374
|
+
.option(
|
|
375
|
+
'--local-proxy',
|
|
376
|
+
'Forward all service TCP ports locally and start the Node.js path-routing proxy. Enables full path-based routing (e.g. /wp alongside /) without needing --expose-local-port. Requires --expose.',
|
|
377
|
+
)
|
|
354
378
|
.option('--cmd <cmd>', 'Custom initialization command for deployment (comma-separated commands).')
|
|
355
379
|
.option(
|
|
356
380
|
'--skip-full-build',
|
|
@@ -364,6 +388,12 @@ program
|
|
|
364
388
|
'--image-pull-policy <policy>',
|
|
365
389
|
'Override container imagePullPolicy in the generated deployment manifest (Always, IfNotPresent, Never). Defaults to Never for localhost/ images and IfNotPresent otherwise.',
|
|
366
390
|
)
|
|
391
|
+
.option(
|
|
392
|
+
'--tls',
|
|
393
|
+
'Enables TLS for the local proxy started by --expose --local-proxy. ' +
|
|
394
|
+
'The proxy will serve HTTPS on port 443 using self-signed certificates resolved from the local SSL store. ' +
|
|
395
|
+
'Use together with --expose and --local-proxy.',
|
|
396
|
+
)
|
|
367
397
|
.description('Manages application deployments, defaulting to deploying development pods.')
|
|
368
398
|
.action(Underpost.deploy.callback);
|
|
369
399
|
|
|
@@ -701,6 +731,10 @@ program
|
|
|
701
731
|
'Explicitly download the pre-built client bundle from Cloudinary inside the container (supported by: sync, template-deploy). Use together with --skip-full-build.',
|
|
702
732
|
)
|
|
703
733
|
.option('--remove', 'Remove/teardown resources')
|
|
734
|
+
.option(
|
|
735
|
+
'--test',
|
|
736
|
+
'Enables test/generic-purpose mode for the runner (e.g. use self-signed TLS instead of cert-manager).',
|
|
737
|
+
)
|
|
704
738
|
.description('Runs specified scripts using various runners.')
|
|
705
739
|
.action(Underpost.run.callback);
|
|
706
740
|
|
|
@@ -889,6 +923,19 @@ program
|
|
|
889
923
|
'--dry-run',
|
|
890
924
|
'For --build: previews version-bump changes (per-file substitution counts) without writing files or running downstream commands.',
|
|
891
925
|
)
|
|
926
|
+
.option(
|
|
927
|
+
'--mongo-host <host>',
|
|
928
|
+
'For --build: override DB_HOST in the template .env.example for the smoke test (e.g., "192.168.1.82:27017").',
|
|
929
|
+
)
|
|
930
|
+
.option('--mongo-user <user>', 'For --build: override DB_USER in the template .env.example for the smoke test.')
|
|
931
|
+
.option(
|
|
932
|
+
'--mongo-password <password>',
|
|
933
|
+
'For --build: override DB_PASSWORD in the template .env.example for the smoke test.',
|
|
934
|
+
)
|
|
935
|
+
.option(
|
|
936
|
+
'--valkey-host <host>',
|
|
937
|
+
'For --build: override VALKEY_HOST in the template .env.example for the smoke test (e.g., "192.168.1.82").',
|
|
938
|
+
)
|
|
892
939
|
.description('Release orchestrator for building new versions and deploying releases of the Underpost CLI.')
|
|
893
940
|
.action(async (version, options) => {
|
|
894
941
|
if (options.build) return Underpost.release.build(version, options);
|
package/src/cli/monitor.js
CHANGED
|
@@ -10,11 +10,19 @@ import {
|
|
|
10
10
|
loadConfServerJson,
|
|
11
11
|
loadCronDeployEnv,
|
|
12
12
|
etcHostFactory,
|
|
13
|
+
deployRangePortFactory,
|
|
13
14
|
} from '../server/conf.js';
|
|
14
15
|
import { loggerFactory } from '../server/logger.js';
|
|
15
16
|
import { timer } from '../client/components/core/CommonJs.js';
|
|
17
|
+
import {
|
|
18
|
+
RUNTIME_STATUS,
|
|
19
|
+
INTERNAL_STATUS_PATH,
|
|
20
|
+
normalizeContainerStatus,
|
|
21
|
+
deployStatusPort,
|
|
22
|
+
} from '../server/runtime-status.js';
|
|
16
23
|
import axios from 'axios';
|
|
17
24
|
import fs from 'fs-extra';
|
|
25
|
+
import net from 'node:net';
|
|
18
26
|
import { shellExec } from '../server/process.js';
|
|
19
27
|
import Underpost from '../index.js';
|
|
20
28
|
|
|
@@ -328,62 +336,230 @@ class UnderpostMonitor {
|
|
|
328
336
|
};
|
|
329
337
|
},
|
|
330
338
|
/**
|
|
331
|
-
*
|
|
339
|
+
* Resolves a free ephemeral TCP port on the loopback interface, used as the
|
|
340
|
+
* local end of the `kubectl port-forward` tunnel so it never collides with
|
|
341
|
+
* host-local services.
|
|
342
|
+
* @returns {Promise<number>}
|
|
343
|
+
* @memberof UnderpostMonitor
|
|
344
|
+
*/
|
|
345
|
+
findFreePort() {
|
|
346
|
+
return new Promise((resolve) => {
|
|
347
|
+
const srv = net.createServer();
|
|
348
|
+
srv.once('error', () => resolve(20000 + Math.floor(Math.random() * 20000)));
|
|
349
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
350
|
+
const { port } = srv.address();
|
|
351
|
+
srv.close(() => resolve(port));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
/**
|
|
356
|
+
* Resolves the deployment's internal status port (Phase-2 transport target).
|
|
357
|
+
*
|
|
358
|
+
* Canonical value is `fromPort - 1` from the deployment router — the exact
|
|
359
|
+
* port `buildManifest` injects into the pod (UNDERPOST_INTERNAL_PORT) and
|
|
360
|
+
* uses for the probes — so the tunnel target always matches the in-pod bind.
|
|
361
|
+
* `UNDERPOST_INTERNAL_PORT` overrides; ambient resolution is the last resort.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} deployId
|
|
364
|
+
* @param {string} env
|
|
365
|
+
* @returns {Promise<number>}
|
|
366
|
+
* @memberof UnderpostMonitor
|
|
367
|
+
*/
|
|
368
|
+
async deployInternalPort(deployId, env) {
|
|
369
|
+
const override = parseInt(process.env.UNDERPOST_INTERNAL_PORT);
|
|
370
|
+
if (!Number.isNaN(override)) return override;
|
|
371
|
+
try {
|
|
372
|
+
const router = await Underpost.deploy.routerFactory(deployId, env);
|
|
373
|
+
const { fromPort } = deployRangePortFactory(router);
|
|
374
|
+
if (Number.isFinite(fromPort) && fromPort > 0) return fromPort - 1;
|
|
375
|
+
} catch (_) {
|
|
376
|
+
/* fall through to ambient resolution */
|
|
377
|
+
}
|
|
378
|
+
return deployStatusPort(deployId, env) ?? 3000;
|
|
379
|
+
},
|
|
380
|
+
/**
|
|
381
|
+
* Reads Phase-2 runtime status from a single pod using the selected transport.
|
|
382
|
+
*
|
|
383
|
+
* - `exec` (default): `kubectl exec … underpost config get container-status`
|
|
384
|
+
* reads the env-file value. Synchronous, no background process — required
|
|
385
|
+
* for custom instances (cyberia-server/client) and the safe choice for
|
|
386
|
+
* CI/SSH. See `Deploy custom instance to K8S.md`.
|
|
387
|
+
* - `http`: port-forward to the in-pod `/_internal/status` endpoint served
|
|
388
|
+
* by the `underpost start` launcher (dd-* runtime deploys). Opt-in.
|
|
332
389
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
390
|
+
* Transport failures are reported as `{ ok: false }` and must never be read
|
|
391
|
+
* as success — they are retried, not promoted.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} podName
|
|
394
|
+
* @param {string} namespace
|
|
395
|
+
* @param {number} internalPort
|
|
396
|
+
* @param {('http'|'exec')} [transport='exec']
|
|
397
|
+
* @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
|
|
398
|
+
* @memberof UnderpostMonitor
|
|
399
|
+
*/
|
|
400
|
+
async readRuntimeStatus(podName, namespace, internalPort, transport = 'exec') {
|
|
401
|
+
return transport === 'exec'
|
|
402
|
+
? Underpost.monitor.readRuntimeStatusViaExec(podName, namespace)
|
|
403
|
+
: Underpost.monitor.readRuntimeStatusViaHttp(podName, namespace, internalPort);
|
|
404
|
+
},
|
|
405
|
+
/**
|
|
406
|
+
* Phase-2 read over `kubectl exec` (env-file transport). Works for any pod
|
|
407
|
+
* whose image bakes the underpost CLI — notably custom instances that stamp
|
|
408
|
+
* `container-status` from `lifecycle.postStart`/`preStop` hooks.
|
|
409
|
+
* @param {string} podName
|
|
410
|
+
* @param {string} namespace
|
|
411
|
+
* @returns {{ok: boolean, status?: (string|null), transportError?: string}}
|
|
412
|
+
* @memberof UnderpostMonitor
|
|
413
|
+
*/
|
|
414
|
+
readRuntimeStatusViaExec(podName, namespace) {
|
|
415
|
+
try {
|
|
416
|
+
const raw = shellExec(
|
|
417
|
+
`sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
|
|
418
|
+
{ silent: true, disableLog: true, stdout: true, silentOnError: true },
|
|
419
|
+
);
|
|
420
|
+
const status = normalizeContainerStatus(raw ? raw.toString().trim() : '');
|
|
421
|
+
return status === undefined ? { ok: false, transportError: 'empty_status' } : { ok: true, status };
|
|
422
|
+
} catch (error) {
|
|
423
|
+
return { ok: false, transportError: error?.code || error?.message || 'exec_failed' };
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
/**
|
|
427
|
+
* Phase-2 read over `kubectl port-forward` + HTTP `/_internal/status`.
|
|
339
428
|
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
|
|
429
|
+
* The local side of the tunnel MUST be an ephemeral free port: pinning it to
|
|
430
|
+
* internalPort collides with any host-local service on that number (e.g. a
|
|
431
|
+
* dev runtime on the same machine as the cluster), making port-forward fail
|
|
432
|
+
* to bind and every read return a false transport error.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} podName
|
|
435
|
+
* @param {string} namespace
|
|
436
|
+
* @param {number} internalPort
|
|
437
|
+
* @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
|
|
438
|
+
* @memberof UnderpostMonitor
|
|
439
|
+
*/
|
|
440
|
+
async readRuntimeStatusViaHttp(podName, namespace, internalPort) {
|
|
441
|
+
const override = parseInt(process.env.UNDERPOST_PF_LOCAL_PORT);
|
|
442
|
+
const localPort = Number.isNaN(override) ? await Underpost.monitor.findFreePort() : override;
|
|
443
|
+
const url = `http://127.0.0.1:${localPort}${INTERNAL_STATUS_PATH}`;
|
|
444
|
+
let portForward;
|
|
445
|
+
try {
|
|
446
|
+
// `exec` makes the tracked child the sudo/kubectl process (so kill
|
|
447
|
+
// reaches it); stdio is redirected to /dev/null so the tunnel never
|
|
448
|
+
// inherits — and therefore never holds open — a CI/SSH session's pipes,
|
|
449
|
+
// which would hang the job after a successful deploy.
|
|
450
|
+
portForward = shellExec(
|
|
451
|
+
`exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace} </dev/null >/dev/null 2>&1`,
|
|
452
|
+
{ async: true, silent: true, disableLog: true, silentOnError: true },
|
|
453
|
+
);
|
|
454
|
+
} catch (_) {
|
|
455
|
+
portForward = undefined;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
let lastError;
|
|
459
|
+
const attempts = parseInt(process.env.UNDERPOST_PF_ATTEMPTS) || 20;
|
|
460
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
461
|
+
try {
|
|
462
|
+
const res = await axios.get(url, { timeout: 2500 });
|
|
463
|
+
const raw = res?.data?.status ?? null;
|
|
464
|
+
return { ok: true, status: normalizeContainerStatus(raw) ?? raw, payload: res.data };
|
|
465
|
+
} catch (error) {
|
|
466
|
+
lastError = error;
|
|
467
|
+
await timer(350);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { ok: false, transportError: lastError?.code || lastError?.message || 'transport_failed' };
|
|
471
|
+
} finally {
|
|
472
|
+
if (portForward && typeof portForward.kill === 'function') {
|
|
473
|
+
try {
|
|
474
|
+
portForward.kill('SIGTERM');
|
|
475
|
+
} catch (_) {
|
|
476
|
+
/* tunnel already gone */
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
/**
|
|
482
|
+
* Monitors a deployment to terminal readiness using a deterministic
|
|
483
|
+
* two-phase state machine.
|
|
484
|
+
*
|
|
485
|
+
* Phase 1 (Kubernetes): pod `Ready` condition via `checkDeploymentReadyStatus`.
|
|
486
|
+
* Phase 2 (Runtime): `container-status`, read via the selected transport.
|
|
487
|
+
*
|
|
488
|
+
* Two deployment shapes are supported via `options`:
|
|
489
|
+
* - `runtime` gate (default, dd-* deploys): the `underpost start` launcher
|
|
490
|
+
* stamps `running-deployment`. Success requires K8S Ready AND every pod
|
|
491
|
+
* reporting `running-deployment`.
|
|
492
|
+
* - `kubernetes` gate (custom instances, e.g. cyberia): the runtime is a
|
|
493
|
+
* bare binary; K8S `readinessProbe` (TCP) IS the running signal and
|
|
494
|
+
* `container-status` is stamped to `initializing`/`stopping` by lifecycle
|
|
495
|
+
* hooks. Success requires K8S Ready; the status read is used only for
|
|
496
|
+
* fast `error` detection and display.
|
|
497
|
+
*
|
|
498
|
+
* Phase-2 transport defaults to `exec` (`kubectl exec`, no background
|
|
499
|
+
* process). The `http` transport (`kubectl port-forward` → `/_internal/status`)
|
|
500
|
+
* is opt-in via `options.statusTransport='http'` or
|
|
501
|
+
* `UNDERPOST_STATUS_TRANSPORT=http`; it must not be used in CI/SSH sessions
|
|
502
|
+
* where a stray tunnel can hang the job.
|
|
503
|
+
*
|
|
504
|
+
* Contract (both shapes):
|
|
505
|
+
* - Runtime readiness is never declared before Kubernetes readiness.
|
|
506
|
+
* - An explicit runtime `error` (or a fatal pod status) transitions
|
|
507
|
+
* immediately to `failed` (throw → CD exit 1).
|
|
508
|
+
* - Transport failures never count as success and never advance state.
|
|
509
|
+
* - `timeout` is a distinct terminal state from `failed`.
|
|
510
|
+
* - Every transition emits a structured, secret-free event.
|
|
351
511
|
*
|
|
352
512
|
* @param {string} deployId - Deployment ID for which the ready status is being monitored.
|
|
353
513
|
* @param {string} env - Environment for which the ready status is being monitored.
|
|
354
514
|
* @param {string} targetTraffic - Target traffic status for the deployment.
|
|
355
515
|
* @param {Array<string>} ignorePods - List of pod names to ignore.
|
|
356
516
|
* @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
|
|
517
|
+
* @param {object} [options] - Monitoring shape.
|
|
518
|
+
* @param {('runtime'|'kubernetes')} [options.readyGate='runtime'] - Running-signal owner.
|
|
519
|
+
* @param {('http'|'exec')} [options.statusTransport='http'] - Phase-2 read transport.
|
|
357
520
|
* @returns {object} - Object containing the ready status of the deployment.
|
|
358
521
|
* @memberof UnderpostMonitor
|
|
359
522
|
*/
|
|
360
|
-
async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
|
|
361
|
-
const delayMs = 1000;
|
|
362
|
-
const maxIterations = 3000;
|
|
523
|
+
async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default', options = {}) {
|
|
524
|
+
const delayMs = parseInt(process.env.UNDERPOST_MONITOR_DELAY_MS) || 1000;
|
|
525
|
+
const maxIterations = parseInt(process.env.UNDERPOST_MONITOR_MAX_ITERATIONS) || 3000;
|
|
363
526
|
const deploymentId = `${deployId}-${env}-${targetTraffic}`;
|
|
364
|
-
const expectedContainerStatus = `${deployId}-${env}-running-deployment`;
|
|
365
527
|
const tag = `[${deploymentId}]`;
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
528
|
+
const expectedStatus = RUNTIME_STATUS.RUNNING;
|
|
529
|
+
const readyGate = options.readyGate === 'kubernetes' ? 'kubernetes' : 'runtime';
|
|
530
|
+
// Default to `exec`: a single synchronous `kubectl exec` read leaves no
|
|
531
|
+
// background process behind. The `http` transport spawns `kubectl
|
|
532
|
+
// port-forward` children that, if orphaned, inherit a CI/SSH session's
|
|
533
|
+
// stdio and hang the job after a successful deploy — opt in explicitly.
|
|
534
|
+
const statusTransport =
|
|
535
|
+
(options.statusTransport || process.env.UNDERPOST_STATUS_TRANSPORT) === 'http' ? 'http' : 'exec';
|
|
536
|
+
const internalPort =
|
|
537
|
+
statusTransport === 'http' ? await Underpost.monitor.deployInternalPort(deployId, env) : null;
|
|
538
|
+
const podErrorStates = ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'];
|
|
539
|
+
|
|
540
|
+
const emit = (state, status) =>
|
|
541
|
+
logger.info('deploy-monitor', {
|
|
542
|
+
deployId: deploymentId,
|
|
543
|
+
phase: state.startsWith('runtime') ? 'runtime' : 'kubernetes',
|
|
544
|
+
state,
|
|
545
|
+
status: status ?? null,
|
|
546
|
+
timestamp: new Date().toISOString(),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
logger.info('Deployment init', {
|
|
550
|
+
deployId,
|
|
551
|
+
env,
|
|
552
|
+
targetTraffic,
|
|
553
|
+
namespace,
|
|
554
|
+
internalPort,
|
|
555
|
+
readyGate,
|
|
556
|
+
statusTransport,
|
|
557
|
+
});
|
|
558
|
+
emit('pending');
|
|
369
559
|
|
|
370
|
-
const
|
|
560
|
+
const runtimeStatusCache = new Map();
|
|
371
561
|
const advancedPods = new Set();
|
|
372
562
|
|
|
373
|
-
const readContainerStatus = (podName) => {
|
|
374
|
-
try {
|
|
375
|
-
const raw = shellExec(
|
|
376
|
-
`sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
|
|
377
|
-
{ silent: true, disableLog: true, stdout: true, silentOnError: true },
|
|
378
|
-
);
|
|
379
|
-
const val = raw ? raw.toString().trim() : '';
|
|
380
|
-
return val && val !== 'undefined' ? val : containerStatusDefault;
|
|
381
|
-
} catch (_) {
|
|
382
|
-
// exec failed (e.g. pod not yet running) — preserve last known value
|
|
383
|
-
return podStatusCache.get(podName) || containerStatusDefault;
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
|
|
387
563
|
for (let i = 0; i < maxIterations; i++) {
|
|
388
564
|
const result = await Underpost.monitor.checkDeploymentReadyStatus(
|
|
389
565
|
deployId,
|
|
@@ -392,39 +568,62 @@ class UnderpostMonitor {
|
|
|
392
568
|
ignorePods,
|
|
393
569
|
namespace,
|
|
394
570
|
);
|
|
395
|
-
|
|
396
571
|
const allPods = [...result.readyPods, ...result.notReadyPods];
|
|
397
572
|
|
|
573
|
+
if (allPods.length === 0) {
|
|
574
|
+
emit('pending');
|
|
575
|
+
await timer(delayMs);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
emit('pod_scheduled');
|
|
579
|
+
|
|
580
|
+
// Phase 1 fatal: a Kubernetes-level pod failure is terminal (failed,
|
|
581
|
+
// not timeout) — fail the CD runner immediately instead of waiting out
|
|
582
|
+
// the full window.
|
|
398
583
|
for (const pod of allPods) {
|
|
399
|
-
if (!pod?.NAME) continue;
|
|
400
584
|
const podStatus = (pod.STATUS || '').toLowerCase().trim();
|
|
401
|
-
if (
|
|
402
|
-
['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'].find((s) =>
|
|
403
|
-
podStatus.match(s),
|
|
404
|
-
)
|
|
405
|
-
)
|
|
585
|
+
if (podErrorStates.find((s) => podStatus.includes(s)))
|
|
406
586
|
throw new Error(`Pod ${pod.NAME} has error pod status: ${pod.STATUS}`);
|
|
407
|
-
const status = readContainerStatus(pod.NAME);
|
|
408
|
-
if (status === 'error') throw new Error(`Pod ${pod.NAME} has error container-status`);
|
|
409
|
-
if (advancedPods.has(pod.NAME) && status === containerStatusDefault)
|
|
410
|
-
throw new Error(`Pod ${pod.NAME} container-status regressed to default — pod likely restarted`);
|
|
411
|
-
if (status !== containerStatusDefault) advancedPods.add(pod.NAME);
|
|
412
|
-
podStatusCache.set(pod.NAME, status);
|
|
413
587
|
}
|
|
414
588
|
|
|
415
|
-
const allPodsK8sReady =
|
|
589
|
+
const allPodsK8sReady = result.notReadyPods.length === 0;
|
|
590
|
+
if (allPodsK8sReady) emit('pod_ready');
|
|
416
591
|
|
|
417
|
-
|
|
418
|
-
|
|
592
|
+
// Phase 2: runtime status via the selected transport. Transport failures
|
|
593
|
+
// neither advance state nor count as success; explicit `error` is terminal.
|
|
594
|
+
let allRuntimeRead = true;
|
|
595
|
+
for (const pod of allPods) {
|
|
596
|
+
if (!pod?.NAME) continue;
|
|
597
|
+
const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort, statusTransport);
|
|
598
|
+
if (!read.ok) {
|
|
599
|
+
allRuntimeRead = false;
|
|
600
|
+
emit('runtime_booting', `transport:${read.transportError}`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const status = read.status;
|
|
604
|
+
if (status === RUNTIME_STATUS.ERROR) throw new Error(`Pod ${pod.NAME} reported runtime status=error`);
|
|
605
|
+
// Regression (advanced → empty/build) means a pod restarted. Under the
|
|
606
|
+
// kubernetes gate the runtime never advances past `initializing`, so
|
|
607
|
+
// only treat a drop to empty/build as a regression there.
|
|
608
|
+
if (advancedPods.has(pod.NAME) && (!status || status === RUNTIME_STATUS.BUILD))
|
|
609
|
+
throw new Error(`Pod ${pod.NAME} runtime status regressed (${status ?? 'empty'}) — pod likely restarted`);
|
|
610
|
+
if (status && status !== RUNTIME_STATUS.BUILD) advancedPods.add(pod.NAME);
|
|
611
|
+
runtimeStatusCache.set(pod.NAME, status);
|
|
612
|
+
emit('runtime_booting', status);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Under the kubernetes gate the readinessProbe is the running signal, so
|
|
616
|
+
// K8S Ready alone confirms Phase 2; the status read above is kept only
|
|
617
|
+
// for `error` fast-fail and display.
|
|
618
|
+
const allRuntimeReady =
|
|
619
|
+
readyGate === 'kubernetes'
|
|
620
|
+
? true
|
|
621
|
+
: allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
|
|
419
622
|
|
|
420
|
-
// Print snapshot for every pod — annotate when container-status hasn't caught
|
|
421
|
-
// up to the K8S Ready condition yet.
|
|
422
623
|
for (const pod of allPods) {
|
|
423
|
-
const status =
|
|
624
|
+
const status = runtimeStatusCache.get(pod.NAME) || 'waiting for status';
|
|
424
625
|
const podStatus = pod.STATUS || 'Unknown';
|
|
425
|
-
const
|
|
426
|
-
const statusDisplay = statusMatchesExpected ? status : `${status} (pending)`;
|
|
427
|
-
|
|
626
|
+
const statusDisplay = status === expectedStatus ? status : `${status} (pending)`;
|
|
428
627
|
console.log(
|
|
429
628
|
'Target pod:',
|
|
430
629
|
pod.NAME[pod.NAME.includes('green') ? 'bgGreen' : 'bgBlue'].bold.black,
|
|
@@ -435,22 +634,20 @@ class UnderpostMonitor {
|
|
|
435
634
|
);
|
|
436
635
|
}
|
|
437
636
|
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
logger.info(`${tag} |
|
|
637
|
+
// Terminal success requires both phases. runtime_ready cannot precede
|
|
638
|
+
// Kubernetes readiness.
|
|
639
|
+
if (allPodsK8sReady && allRuntimeReady) {
|
|
640
|
+
const readySignal = readyGate === 'kubernetes' ? 'K8S readinessProbe' : `runtime ${expectedStatus}`;
|
|
641
|
+
emit('runtime_ready', readyGate === 'kubernetes' ? 'k8s-ready' : expectedStatus);
|
|
642
|
+
logger.info(`${tag} | Deployment ready (K8S Ready + ${readySignal})`);
|
|
444
643
|
return result;
|
|
445
644
|
}
|
|
446
645
|
|
|
447
646
|
await timer(delayMs);
|
|
448
|
-
|
|
449
|
-
if ((i + 1) % 10 === 0) {
|
|
450
|
-
logger.info(`${tag} | In progress... iteration ${i + 1}`);
|
|
451
|
-
}
|
|
647
|
+
if ((i + 1) % 10 === 0) logger.info(`${tag} | In progress... iteration ${i + 1}`);
|
|
452
648
|
}
|
|
453
649
|
|
|
650
|
+
emit('timeout');
|
|
454
651
|
logger.error(`${tag} | Deployment timeout after ${maxIterations} iterations`);
|
|
455
652
|
throw new Error(
|
|
456
653
|
`monitorReadyRunner timeout: ${deploymentId} did not become Ready within ${maxIterations}*${delayMs}ms`,
|
package/src/cli/release.js
CHANGED
|
@@ -264,18 +264,29 @@ const ISOLATED_ENV = 'env -i HOME="$HOME" PATH="$PATH" USER="$USER" LOGNAME="$LO
|
|
|
264
264
|
*
|
|
265
265
|
* @returns {boolean} true when the template started cleanly, false otherwise.
|
|
266
266
|
*/
|
|
267
|
-
async function buildAndTestTemplate() {
|
|
267
|
+
async function buildAndTestTemplate(opts = {}) {
|
|
268
268
|
killDevServers();
|
|
269
269
|
Underpost.repo.clean({ paths: ['/home/dd/engine', '/home/dd/engine/engine-private '] });
|
|
270
270
|
shellExec(`node bin pull . ${process.env.GITHUB_USERNAME}/engine`);
|
|
271
|
+
fs.removeSync(TEMPLATE_PATH);
|
|
271
272
|
shellExec(`npm run build:template`);
|
|
272
273
|
shellExec(`node bin run shared-dir ${TEMPLATE_PATH}`);
|
|
273
274
|
|
|
275
|
+
const upsertEnvVar = (content, key, value) => {
|
|
276
|
+
const re = new RegExp(`^(${key}=).*`, 'm');
|
|
277
|
+
if (re.test(content)) return content.replace(re, `$1${value}`);
|
|
278
|
+
return `${content.trimEnd()}\n${key}=${value}\n`;
|
|
279
|
+
};
|
|
280
|
+
|
|
274
281
|
const dhcpHostIp = Dns.getLocalIPv4Address();
|
|
275
282
|
logger.info(`DHCP host IP for template test: ${dhcpHostIp}`);
|
|
276
283
|
let envContent = fs.readFileSync(`${TEMPLATE_PATH}/.env.example`, 'utf8');
|
|
277
284
|
if (dhcpHostIp) envContent = envContent.replace(/127\.0\.0\.1/g, dhcpHostIp);
|
|
278
|
-
envContent = envContent
|
|
285
|
+
envContent = upsertEnvVar(envContent, 'ENABLE_FILE_LOGS', 'true');
|
|
286
|
+
if (opts.mongoHost) envContent = upsertEnvVar(envContent, 'DB_HOST', opts.mongoHost);
|
|
287
|
+
if (opts.mongoUser) envContent = upsertEnvVar(envContent, 'DB_USER', opts.mongoUser);
|
|
288
|
+
if (opts.mongoPassword) envContent = upsertEnvVar(envContent, 'DB_PASSWORD', opts.mongoPassword);
|
|
289
|
+
if (opts.valkeyHost) envContent = upsertEnvVar(envContent, 'VALKEY_HOST', opts.valkeyHost);
|
|
279
290
|
// fs.writeFileSync(`${TEMPLATE_PATH}/.env`, envContent, 'utf8');
|
|
280
291
|
fs.writeFileSync(`${TEMPLATE_PATH}/.env.example`, envContent, 'utf8');
|
|
281
292
|
shellExec(`cd ${TEMPLATE_PATH} && npm install`);
|
|
@@ -337,7 +348,12 @@ class UnderpostRelease {
|
|
|
337
348
|
*
|
|
338
349
|
* @method build
|
|
339
350
|
* @param {string} [newVersion] - The new version string to set. Defaults to current version if not provided.
|
|
340
|
-
* @param {{dryRun?: boolean}} [options] - Commander options.
|
|
351
|
+
* @param {{dryRun?: boolean, mongoHost?: string, mongoUser?: string, mongoPassword?: string, valkeyHost?: string}} [options] - Commander options.
|
|
352
|
+
* `--dry-run` previews changes without writing files.
|
|
353
|
+
* `--mongo-host` overrides `DB_HOST` in the template `.env.example` smoke test.
|
|
354
|
+
* `--mongo-user` overrides `DB_USER` in the template `.env.example` smoke test.
|
|
355
|
+
* `--mongo-password` overrides `DB_PASSWORD` in the template `.env.example` smoke test.
|
|
356
|
+
* `--valkey-host` overrides `VALKEY_HOST` in the template `.env.example` smoke test.
|
|
341
357
|
* @memberof UnderpostRelease
|
|
342
358
|
*/
|
|
343
359
|
async build(newVersion, options = {}) {
|
|
@@ -352,7 +368,7 @@ class UnderpostRelease {
|
|
|
352
368
|
logger.info(`Release build — bumping ${version} → ${newVersion}${dryRun ? ' (dry-run)' : ''}`);
|
|
353
369
|
|
|
354
370
|
if (!dryRun) {
|
|
355
|
-
const templateOk = await buildAndTestTemplate();
|
|
371
|
+
const templateOk = await buildAndTestTemplate(options);
|
|
356
372
|
if (!templateOk) return;
|
|
357
373
|
}
|
|
358
374
|
|
|
@@ -390,8 +406,7 @@ class UnderpostRelease {
|
|
|
390
406
|
shellExec(`node bin/deploy cli-docs ${version} ${newVersion}`);
|
|
391
407
|
shellExec(`node bin/deploy update-dependencies`);
|
|
392
408
|
shellExec(`node bin/build dd`);
|
|
393
|
-
shellExec(`node bin
|
|
394
|
-
shellExec(`node bin deploy --build-manifest --sync --info-router --replicas 1 dd development`);
|
|
409
|
+
shellExec(`node bin run build-cluster-deployment-manifests`);
|
|
395
410
|
shellExec(`node bin new --default-conf --conf-workflow-id template`);
|
|
396
411
|
shellExec(`sudo rm -rf ./engine-private/conf/dd-default`);
|
|
397
412
|
shellExec(`node bin new --deploy-id dd-default`);
|
package/src/cli/repository.js
CHANGED
|
@@ -8,6 +8,7 @@ import dotenv from 'dotenv';
|
|
|
8
8
|
import { commitData } from '../client/components/core/CommonJs.js';
|
|
9
9
|
import { pbcopy, shellCd, shellExec } from '../server/process.js';
|
|
10
10
|
import { actionInitLog, loggerFactory } from '../server/logger.js';
|
|
11
|
+
import path from 'path';
|
|
11
12
|
import fs from 'fs-extra';
|
|
12
13
|
import {
|
|
13
14
|
getNpmRootPath,
|
|
@@ -133,10 +134,21 @@ class UnderpostRepository {
|
|
|
133
134
|
p: undefined,
|
|
134
135
|
bc: '',
|
|
135
136
|
isRemoteRepo: '',
|
|
137
|
+
hasChanges: false,
|
|
136
138
|
},
|
|
137
139
|
) {
|
|
138
140
|
if (!repoPath) repoPath = '.';
|
|
139
141
|
|
|
142
|
+
if (options.hasChanges) {
|
|
143
|
+
const status = shellExec(`cd ${repoPath} && git status --porcelain`, {
|
|
144
|
+
stdout: true,
|
|
145
|
+
silent: true,
|
|
146
|
+
disableLog: true,
|
|
147
|
+
}).trim();
|
|
148
|
+
process.stdout.write(status ? '1' : '');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
if (options.isRemoteRepo) {
|
|
141
153
|
const accessible = Underpost.repo.isRemoteRepo(options.isRemoteRepo);
|
|
142
154
|
console.log(accessible);
|
|
@@ -608,7 +620,8 @@ class UnderpostRepository {
|
|
|
608
620
|
const npmRoot = getNpmRootPath();
|
|
609
621
|
const underpostRoot = options?.dev === true ? '.' : `${npmRoot}/underpost`;
|
|
610
622
|
const destFolder = `./${projectName}`;
|
|
611
|
-
|
|
623
|
+
const deployId = projectName.startsWith('dd-') ? projectName : `dd-${projectName}`;
|
|
624
|
+
logger.info('build app', { destFolder, deployId });
|
|
612
625
|
if (fs.existsSync(destFolder)) fs.removeSync(destFolder);
|
|
613
626
|
fs.mkdirSync(destFolder, { recursive: true });
|
|
614
627
|
if (!options.dev) {
|
|
@@ -621,8 +634,9 @@ class UnderpostRepository {
|
|
|
621
634
|
UnderpostRepository.API.initLocalRepo({ path: destFolder });
|
|
622
635
|
shellExec(`cd ${destFolder} && git add . && git commit -m "Base template implementation"`);
|
|
623
636
|
}
|
|
624
|
-
shellExec(`cd ${destFolder} &&
|
|
625
|
-
shellExec(`cd ${destFolder} &&
|
|
637
|
+
shellExec(`cd ${destFolder} && node bin new --deploy-id ${deployId} --default-conf`);
|
|
638
|
+
shellExec(`cd ${destFolder} && node bin client ${deployId}`);
|
|
639
|
+
shellExec(`cd ${destFolder} && DEPLOY_ID=${deployId} npm run dev`);
|
|
626
640
|
}
|
|
627
641
|
return resolve(true);
|
|
628
642
|
} catch (error) {
|
|
@@ -1380,8 +1394,9 @@ Prevent build private config repo.`,
|
|
|
1380
1394
|
const gitEmail = process.env.GITHUB_EMAIL || `development@underpost.net`;
|
|
1381
1395
|
|
|
1382
1396
|
if (!fs.existsSync(`${repoPath}/.git`)) {
|
|
1383
|
-
shellExec(`
|
|
1397
|
+
shellExec(`mkdir -p "${repoPath}" && git init "${repoPath}"`);
|
|
1384
1398
|
}
|
|
1399
|
+
|
|
1385
1400
|
shellExec(`cd "${repoPath}" && git config user.name '${gitUsername}'`);
|
|
1386
1401
|
shellExec(`cd "${repoPath}" && git config user.email '${gitEmail}'`);
|
|
1387
1402
|
shellExec(`cd "${repoPath}" && git config core.filemode false`);
|
|
@@ -1724,6 +1739,8 @@ Prevent build private config repo.`,
|
|
|
1724
1739
|
if (!fs.existsSync(repoPath)) {
|
|
1725
1740
|
shellExec(`cd .. && underpost clone ${gitUri}`, { silent: true });
|
|
1726
1741
|
} else {
|
|
1742
|
+
const repoAbsPath = path.resolve(repoPath);
|
|
1743
|
+
shellExec(`git config --global --add safe.directory '${repoAbsPath}'`);
|
|
1727
1744
|
shellExec(`cd ${repoPath} && git checkout . && git clean -f -d && underpost pull . ${gitUri}`, {
|
|
1728
1745
|
silent: true,
|
|
1729
1746
|
});
|