underpost 3.2.80 → 3.2.90
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 +182 -1
- package/CLI-HELP.md +37 -16
- package/README.md +2 -2
- package/bin/deploy.js +18 -16
- package/docker-compose.yml +1 -1
- 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/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +17 -17
- package/scripts/nat-iptables.sh +10 -4
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/cluster.js +740 -55
- package/src/cli/db.js +2 -2
- package/src/cli/deploy.js +1679 -174
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/image.js +15 -6
- package/src/cli/index.js +124 -35
- package/src/cli/ipfs.js +82 -11
- package/src/cli/monitor.js +1 -1
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +2161 -420
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +8 -28
- package/src/client-builder/client-build.js +94 -11
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +47 -32
- package/src/index.js +1 -1
- package/src/server/conf.js +1208 -70
- package/src/server/cri.js +70 -0
- package/src/server/underpost-gateway.js +1073 -0
- package/src/server/underpost-ingress.js +364 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +469 -0
- package/test/underpost-ingress.test.js +253 -0
package/src/cli/ssh.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
|
|
8
8
|
import { pbcopy, shellExec } from '../server/process.js';
|
|
9
9
|
import { loggerFactory } from '../server/logger.js';
|
|
10
|
+
import { waitForPort } from '../server/conf.js';
|
|
10
11
|
import fs from 'fs-extra';
|
|
11
12
|
import Underpost from '../index.js';
|
|
12
13
|
|
|
@@ -606,8 +607,8 @@ EOF
|
|
|
606
607
|
},
|
|
607
608
|
|
|
608
609
|
/**
|
|
609
|
-
* Waits until a TCP SSH port becomes reachable on a host.
|
|
610
|
-
* @
|
|
610
|
+
* Waits until a TCP SSH port becomes reachable on a host. Delegates to
|
|
611
|
+
* {@link ServerConfBuilder.waitForPort}, which owns the probe.
|
|
611
612
|
* @function waitForSshPort
|
|
612
613
|
* @memberof UnderpostSSH
|
|
613
614
|
* @param {object} params
|
|
@@ -617,25 +618,14 @@ EOF
|
|
|
617
618
|
* @param {number} [params.intervalMs=3000] - Poll interval.
|
|
618
619
|
* @returns {Promise<boolean>} True once the port accepts connections, false on timeout.
|
|
619
620
|
*/
|
|
620
|
-
waitForSshPort:
|
|
621
|
-
|
|
622
|
-
while (Date.now() < deadline) {
|
|
623
|
-
const probe = shellExec(
|
|
624
|
-
`timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
|
|
625
|
-
{ silent: true, stdout: true, silentOnError: true, disableLog: true },
|
|
626
|
-
);
|
|
627
|
-
if (`${probe}`.trim() === 'open') return true;
|
|
628
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
629
|
-
}
|
|
630
|
-
logger.warn(`SSH port ${host}:${port} not reachable within timeout`);
|
|
631
|
-
return false;
|
|
632
|
-
},
|
|
621
|
+
waitForSshPort: ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) =>
|
|
622
|
+
waitForPort({ host, port, open: true, timeoutMs, intervalMs }),
|
|
633
623
|
|
|
634
624
|
/**
|
|
635
625
|
* Waits until a host's SSH port stops accepting connections (e.g. while it
|
|
636
626
|
* reboots). Used to detect a reboot edge before waiting for the port to come
|
|
637
627
|
* back up, so callers don't latch onto the pre-reboot (ephemeral) sshd.
|
|
638
|
-
* @
|
|
628
|
+
* Delegates to {@link ServerConfBuilder.waitForPort}.
|
|
639
629
|
* @function waitForSshPortClosed
|
|
640
630
|
* @memberof UnderpostSSH
|
|
641
631
|
* @param {object} params
|
|
@@ -645,18 +635,8 @@ EOF
|
|
|
645
635
|
* @param {number} [params.intervalMs=3000] - Poll interval.
|
|
646
636
|
* @returns {Promise<boolean>} True once the port is closed, false on timeout.
|
|
647
637
|
*/
|
|
648
|
-
waitForSshPortClosed:
|
|
649
|
-
|
|
650
|
-
while (Date.now() < deadline) {
|
|
651
|
-
const probe = shellExec(
|
|
652
|
-
`timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
|
|
653
|
-
{ silent: true, stdout: true, silentOnError: true, disableLog: true },
|
|
654
|
-
);
|
|
655
|
-
if (`${probe}`.trim() === 'closed') return true;
|
|
656
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
657
|
-
}
|
|
658
|
-
return false;
|
|
659
|
-
},
|
|
638
|
+
waitForSshPortClosed: ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) =>
|
|
639
|
+
waitForPort({ host, port, open: false, timeoutMs, intervalMs }),
|
|
660
640
|
|
|
661
641
|
/**
|
|
662
642
|
* Orchestrates a non-interactive, key-only SSH session against a freshly
|
|
@@ -23,12 +23,81 @@ import { shellExec } from '../server/process.js';
|
|
|
23
23
|
import { SitemapStream, streamToPromise } from 'sitemap';
|
|
24
24
|
import { Readable } from 'stream';
|
|
25
25
|
import { buildIcons } from './client-icons.js';
|
|
26
|
+
import { statusPageBuildSegment } from '../server/underpost-gateway.js';
|
|
26
27
|
import Underpost from '../index.js';
|
|
27
28
|
import { buildDocs } from './client-build-docs.js';
|
|
28
29
|
import { ssrFactory } from './ssr.js';
|
|
29
30
|
|
|
30
31
|
// Static Site Generation (SSG)
|
|
31
32
|
|
|
33
|
+
const STATUS_PAGE_VIEW_PATH = /^\/([1-5]\d{2})$/;
|
|
34
|
+
|
|
35
|
+
// Views a route intercepts before the workload sees the request. They are
|
|
36
|
+
// declared by the flag that already marks them as the app's default for that
|
|
37
|
+
// condition, so a new one becomes edge-served by adding its flag here rather
|
|
38
|
+
// than by naming its path in a second place.
|
|
39
|
+
const INTERCEPT_VIEW_FLAGS = ['maintenanceDefault', 'offlineDefault'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves the SSR views that render an HTTP status page into the static
|
|
43
|
+
* artifacts they build to. A view is a status page when its route path is a
|
|
44
|
+
* bare status code (`/404`, `/500`, `/503`), which the build writes to
|
|
45
|
+
* `<path>/index.html` inside the served bundle.
|
|
46
|
+
*
|
|
47
|
+
* Single source of truth for PWA status-page routing: this build writes the
|
|
48
|
+
* artifact, and `deploy --build-manifest` points its HTTPRoute rules at the
|
|
49
|
+
* same resolved URL — neither side hardcodes a status code.
|
|
50
|
+
* @function statusPageRoutesFactory
|
|
51
|
+
* @param {Array<object>} [views] - SSR view entries from `conf.ssr.json`.
|
|
52
|
+
* @param {string} [proxyPath] - The client's proxy sub-path (`/`, `/peer`, ...).
|
|
53
|
+
* @returns {Array<{status: string, routePath: string, indexUrl: string, title: string, client: string}>}
|
|
54
|
+
* One entry per status view, in declaration order.
|
|
55
|
+
* @memberof clientBuild
|
|
56
|
+
*/
|
|
57
|
+
const statusPageRoutesFactory = ({ views = [], proxyPath = '/' } = {}) => {
|
|
58
|
+
const prefix = !proxyPath || proxyPath === '/' ? '' : proxyPath.replace(/\/$/, '');
|
|
59
|
+
return (Array.isArray(views) ? views : [])
|
|
60
|
+
.map((view) => ({ view, status: STATUS_PAGE_VIEW_PATH.exec(view?.path || '')?.[1] }))
|
|
61
|
+
.filter(({ status }) => status !== undefined)
|
|
62
|
+
.map(({ view, status }) => ({
|
|
63
|
+
status,
|
|
64
|
+
routePath: `${prefix}${view.path}`,
|
|
65
|
+
indexUrl: `${prefix}${view.path}/index.html`,
|
|
66
|
+
title: view.title,
|
|
67
|
+
client: view.client,
|
|
68
|
+
}));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the SSR views a gateway route intercepts and serves statically —
|
|
73
|
+
* the maintenance and offline documents. They carry no request-time logic, so
|
|
74
|
+
* the workload never needs to see them; the same build that writes the artifact
|
|
75
|
+
* hands `deploy --build-manifest` the URL its HTTPRoute rule targets.
|
|
76
|
+
* @function staticContextRoutesFactory
|
|
77
|
+
* @param {Array<object>} [views] - SSR view entries from `conf.ssr.json`.
|
|
78
|
+
* @param {string} [proxyPath] - The client's proxy sub-path (`/`, `/peer`, ...).
|
|
79
|
+
* @returns {Array<{context: string, routePath: string, indexUrl: string, title: string, client: string}>}
|
|
80
|
+
* One entry per intercepted view, in declaration order.
|
|
81
|
+
* @memberof clientBuild
|
|
82
|
+
*/
|
|
83
|
+
const staticContextRoutesFactory = ({ views = [], proxyPath = '/' } = {}) => {
|
|
84
|
+
const prefix = !proxyPath || proxyPath === '/' ? '' : proxyPath.replace(/\/$/, '');
|
|
85
|
+
return (Array.isArray(views) ? views : [])
|
|
86
|
+
.filter(
|
|
87
|
+
(view) =>
|
|
88
|
+
view?.path &&
|
|
89
|
+
!STATUS_PAGE_VIEW_PATH.test(view.path) &&
|
|
90
|
+
INTERCEPT_VIEW_FLAGS.some((flag) => view[flag] === true),
|
|
91
|
+
)
|
|
92
|
+
.map((view) => ({
|
|
93
|
+
context: view.path.replace(/^\/+|\/+$/g, ''),
|
|
94
|
+
routePath: `${prefix}${view.path}`,
|
|
95
|
+
indexUrl: `${prefix}${view.path}/index.html`,
|
|
96
|
+
title: view.title,
|
|
97
|
+
client: view.client,
|
|
98
|
+
}));
|
|
99
|
+
};
|
|
100
|
+
|
|
32
101
|
/**
|
|
33
102
|
* Recursively copies files from source to destination, but only files that don't exist in destination.
|
|
34
103
|
* @function copyNonExistingFiles
|
|
@@ -742,14 +811,12 @@ const buildClient = async (
|
|
|
742
811
|
}
|
|
743
812
|
|
|
744
813
|
if (views) {
|
|
745
|
-
if (
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
(p) => p.srcBuildPath.startsWith(`./src/client/ssr`) || p.srcBuildPath.slice(-9) === '.index.js',
|
|
750
|
-
)
|
|
814
|
+
if (!(
|
|
815
|
+
enableLiveRebuild &&
|
|
816
|
+
!options.liveClientBuildPaths.find(
|
|
817
|
+
(p) => p.srcBuildPath.startsWith(`./src/client/ssr`) || p.srcBuildPath.slice(-9) === '.index.js',
|
|
751
818
|
)
|
|
752
|
-
)
|
|
819
|
+
))
|
|
753
820
|
for (const view of views) {
|
|
754
821
|
const buildPath = `${
|
|
755
822
|
rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
|
|
@@ -977,6 +1044,8 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
|
|
|
977
1044
|
// when the network is unreachable.
|
|
978
1045
|
const ssrClientConf = confSSR[getCapVariableName(client)] || {};
|
|
979
1046
|
const ssrViews = Array.isArray(ssrClientConf.views) ? ssrClientConf.views : [];
|
|
1047
|
+
const statusPageRoutes = statusPageRoutesFactory({ views: ssrViews, proxyPath: path });
|
|
1048
|
+
if (statusPageRoutes.length > 0) logger.info('ssr status page routes', statusPageRoutes);
|
|
980
1049
|
const PRE_CACHED_RESOURCES = [];
|
|
981
1050
|
let offlineFallbackUrl = null;
|
|
982
1051
|
let maintenanceFallbackUrl = null;
|
|
@@ -999,9 +1068,16 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
|
|
|
999
1068
|
renderApi: { JSONweb },
|
|
1000
1069
|
});
|
|
1001
1070
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1071
|
+
// A status view is built under `status-pages/<status>/`, not on its own
|
|
1072
|
+
// `/<status>` route: the gateway serves it by intercepting the
|
|
1073
|
+
// runtime's error, and a document on that route would give the runtime
|
|
1074
|
+
// a page of its own to serve or redirect to for the same condition.
|
|
1075
|
+
const statusCode = statusPageRoutes.find((route) => route.routePath === `${proxyPrefix}${view.path}`)?.status;
|
|
1076
|
+
const clientRoot =
|
|
1077
|
+
rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath;
|
|
1078
|
+
const buildPath = statusCode
|
|
1079
|
+
? `${clientRoot}/${dir.dirname(statusPageBuildSegment(statusCode))}/`
|
|
1080
|
+
: `${clientRoot}${view.path === '/' ? view.path : `${view.path}/`}`;
|
|
1005
1081
|
|
|
1006
1082
|
const indexUrl = buildIndexUrl(view.path);
|
|
1007
1083
|
if (view.offlineDefault) {
|
|
@@ -1089,4 +1165,11 @@ ${swTransformedJs}`,
|
|
|
1089
1165
|
}
|
|
1090
1166
|
};
|
|
1091
1167
|
|
|
1092
|
-
export {
|
|
1168
|
+
export {
|
|
1169
|
+
buildClient,
|
|
1170
|
+
copyNonExistingFiles,
|
|
1171
|
+
unzipClientBuild,
|
|
1172
|
+
mergeClientBuildZip,
|
|
1173
|
+
staticContextRoutesFactory,
|
|
1174
|
+
statusPageRoutesFactory,
|
|
1175
|
+
};
|
|
@@ -9,9 +9,8 @@ import vm from 'node:vm';
|
|
|
9
9
|
|
|
10
10
|
import Underpost from '../index.js';
|
|
11
11
|
|
|
12
|
-
import { srcFormatted
|
|
12
|
+
import { srcFormatted } from './client-formatted.js';
|
|
13
13
|
import { loggerFactory } from '../server/logger.js';
|
|
14
|
-
import { getRootDirectory } from '../server/process.js';
|
|
15
14
|
|
|
16
15
|
const logger = loggerFactory(import.meta);
|
|
17
16
|
|
|
@@ -47,78 +46,33 @@ const sanitizeHtml = (res, req, html) => {
|
|
|
47
46
|
};
|
|
48
47
|
|
|
49
48
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
49
|
+
* Creates the Express middleware that terminates an unmatched request and an
|
|
50
|
+
* unhandled error.
|
|
51
|
+
*
|
|
52
|
+
* Both return a bare status and nothing else. Status page delivery belongs to
|
|
53
|
+
* the edge: the gateway intercepts the status and serves the declared document
|
|
54
|
+
* from `underpost-gateway`, preserving this response's code and the client's
|
|
55
|
+
* URI. A runtime that rendered its own page, redirected to one, or fetched one
|
|
56
|
+
* over HTTP would be competing with that — and would be the only one of the
|
|
57
|
+
* three runtimes doing so.
|
|
58
|
+
* @param {string} [path] - The instance's proxy sub-path, used only to alias `/home`.
|
|
59
|
+
* @returns {Promise<{error500: Function, error400: Function}>} The two terminators.
|
|
58
60
|
* @memberof ServerSideRendering
|
|
59
61
|
*/
|
|
60
|
-
const ssrMiddlewareFactory = async ({
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
renderApi: {
|
|
76
|
-
JSONweb,
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
const path404 = `${directory ? directory : `${getRootDirectory()}${rootHostPath}`}/404/index.html`;
|
|
80
|
-
const page404 = fs.existsSync(path404) ? `${path === '/' ? '' : path}/404` : undefined;
|
|
81
|
-
|
|
82
|
-
const defaultHtmlSrc500 = Render({
|
|
83
|
-
title: '500 Server Error',
|
|
84
|
-
ssrPath,
|
|
85
|
-
ssrHeadComponents: '',
|
|
86
|
-
ssrBodyComponents: (await ssrFactory(`./src/client/ssr/body/500.js`))(),
|
|
87
|
-
renderPayload: {
|
|
88
|
-
apiBasePath: process.env.BASE_API,
|
|
89
|
-
version: Underpost.version,
|
|
90
|
-
},
|
|
91
|
-
renderApi: {
|
|
92
|
-
JSONweb,
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
const path500 = `${directory ? directory : `${getRootDirectory()}${rootHostPath}`}/500/index.html`;
|
|
96
|
-
const page500 = fs.existsSync(path500) ? `${path === '/' ? '' : path}/500` : undefined;
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
error500: function (err, req, res, next) {
|
|
100
|
-
logger.error(err, err.stack);
|
|
101
|
-
if (page500) return res.status(500).redirect(page500);
|
|
102
|
-
else {
|
|
103
|
-
res.set('Content-Type', 'text/html');
|
|
104
|
-
return res.status(500).send(sanitizeHtml(res, req, defaultHtmlSrc500));
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
error400: function (req, res, next) {
|
|
108
|
-
// if /<path>/home redirect to /<path>
|
|
109
|
-
const homeRedirectPath = `${path === '/' ? '' : path}/home`;
|
|
110
|
-
if (req.url.startsWith(homeRedirectPath)) {
|
|
111
|
-
const redirectUrl = req.url.replace('/home', '');
|
|
112
|
-
return res.redirect(redirectUrl.startsWith('/') ? redirectUrl : `/${redirectUrl}`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (page404) return res.status(404).redirect(page404);
|
|
116
|
-
else {
|
|
117
|
-
res.set('Content-Type', 'text/html');
|
|
118
|
-
return res.status(404).send(sanitizeHtml(res, req, defaultHtmlSrc404));
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
};
|
|
62
|
+
const ssrMiddlewareFactory = async ({ path = '/' } = {}) => ({
|
|
63
|
+
error500: function (err, req, res, next) {
|
|
64
|
+
logger.error(err, err.stack);
|
|
65
|
+
return res.sendStatus(500);
|
|
66
|
+
},
|
|
67
|
+
error400: function (req, res, next) {
|
|
68
|
+
// `/<path>/home` is an alias of `/<path>`, not a missing route.
|
|
69
|
+
const homeRedirectPath = `${path === '/' ? '' : path}/home`;
|
|
70
|
+
if (req.url.startsWith(homeRedirectPath)) {
|
|
71
|
+
const redirectUrl = req.url.replace('/home', '');
|
|
72
|
+
return res.redirect(redirectUrl.startsWith('/') ? redirectUrl : `/${redirectUrl}`);
|
|
73
|
+
}
|
|
74
|
+
return res.sendStatus(404);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
123
77
|
|
|
124
78
|
export { ssrMiddlewareFactory, ssrFactory, sanitizeHtml };
|