underpost
Version:
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 orc
1,084 lines (1,041 loc) • 45 kB
JavaScript
/**
* The centralized gateway infrastructure service.
*
* One Nginx deployment is the cluster's single edge utility layer: it serves
* every host's status pages, maintenance pages and shared edge contexts, and it
* reverse-proxies the application workloads whose errors it is asked to
* intercept. Application runtimes stay agnostic — they return a standard status
* code or become unreachable, and nothing about status page delivery lives in
* them.
*
* Interception is Nginx's `proxy_intercept_errors`, not an Envoy response
* substitution, because only it satisfies all three constraints at once: the
* document is served from disk so its size is unbounded, the upstream's status
* code is preserved, and the client's URI never changes. Envoy's own mechanisms
* substitute an inline body capped at 4096 bytes, and Envoy cannot re-dispatch a
* request to another cluster once the upstream has answered.
*
* Layout under the Nginx root:
* <root>/<host>/<path>/status-pages/<status>/index.html
* <root>/<host>/<path>/<context>/...
* <root>/conf.d/<host>.conf generated server blocks
* where `<path>` is the proxy sub-path with `/` written as `root`, so
* `www.cyberiaonline.com` + `/` + 404 becomes
* `www.cyberiaonline.com/root/status-pages/404/index.html`.
*
* @module src/server/underpost-gateway.js
* @namespace UnderpostGateway
*/
import crypto from 'node:crypto';
import fs from 'fs-extra';
import nodePath from 'node:path';
import { timer } from '../client/components/core/CommonJs.js';
import { instanceStatusPageEntriesFactory, loadConfServerJson, loadReplicas } from './conf.js';
import { compressionConfFactory, compressionModulesConfFactory, nginxImageFactory } from './underpost-compression.js';
import Underpost from '../index.js';
import { loggerFactory } from './logger.js';
import { shellExec } from './process.js';
const logger = loggerFactory(import.meta);
/**
* @constant UNDERPOST_GATEWAY
* @description Identity of the shared gateway workload. One deployment serves
* every host of every deploy, so these names are cluster-wide constants rather
* than per-deploy.
* @memberof UnderpostGateway
*/
const UNDERPOST_GATEWAY = {
name: 'underpost-gateway',
serviceName: 'underpost-gateway-service',
configMapName: 'underpost-gateway-nginx',
claimName: 'pvc-underpost-gateway',
volumeName: 'pv-underpost-gateway',
image: nginxImageFactory(),
root: '/var/www/static',
port: 80,
healthPath: '/healthz',
defaultHostDir: 'default',
confDir: 'conf.d',
// kube-dns's conventional ClusterIP; overridden from the live Service.
resolver: '10.96.0.10',
};
/**
* @method staticPathSegmentFactory
* @description Folds a proxy sub-path into one directory name. `/` has no
* directory of its own, so it is written as `root`; anything else keeps its
* segments joined by `-` to stay a single level under the host.
* @param {string} [path] - Proxy sub-path (`/`, `/peer`, `/app`).
* @returns {string} Directory name.
* @memberof UnderpostGateway
*/
const staticPathSegmentFactory = (path = '/') => {
const segment = `${path || '/'}`.replace(/^\/+|\/+$/g, '').replace(/\//g, '-');
return segment || 'root';
};
/**
* @method kubernetesUpstreamFactory
* @description Qualifies a short Kubernetes Service name for Nginx's runtime DNS resolver.
*
* Nginx uses the configured `resolver` whenever `proxy_pass` contains a
* variable. That resolver does not apply the pod's `/etc/resolv.conf` search
* suffixes, so `service-name:80` fails even though the same short name works in
* wget/curl. A fully-qualified Service DNS name is unambiguous and continues to
* resolve after the Service is recreated.
*
* Already-qualified hosts and IP literals are kept as supplied.
* @param {string} upstream - `host:port`.
* @param {string} [namespace] - Kubernetes namespace containing the Service.
* @returns {string} Runtime-resolvable upstream.
* @memberof UnderpostGateway
*/
const kubernetesUpstreamFactory = (upstream, namespace = 'default') => {
const value = `${upstream || ''}`.trim();
const separator = value.lastIndexOf(':');
if (separator < 1) return value;
const host = value.slice(0, separator);
const port = value.slice(separator + 1);
if (host.includes('.') || host === 'localhost' || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return value;
return `${host}.${namespace}.svc.cluster.local:${port}`;
};
/**
* @method nginxTokenFactory
* @description A sub-path as an identifier nginx accepts. Variable names admit
* only word characters, so the `-` that {@link UnderpostGateway.staticPathSegmentFactory}
* joins multi-segment paths with cannot appear in one.
* @param {string} [path] - Proxy sub-path.
* @returns {string} Identifier-safe token.
* @memberof UnderpostGateway
*/
const nginxTokenFactory = (path = '/') => staticPathSegmentFactory(path).replace(/[^a-zA-Z0-9]/g, '_');
/**
* @method staticLocationFactory
* @description The three forms every placed document is addressed by: where it
* sits under the root, the directory a prefix rewrite targets, and the exact URL
* a full-path rewrite targets.
*
* `dir` is what routes normally use. A `ReplacePrefixMatch` onto the directory
* lets one rule cover the document *and* everything beside it — `/maintenance`
* resolves through `try_files $uri/index.html`, while `/maintenance/logo.png`
* resolves through `$uri` — which is why the layout keeps each context in its
* own directory rather than as a bare file.
* @param {string} host - Hostname the document belongs to.
* @param {string} [path] - Proxy sub-path the document belongs to.
* @param {string} context - Directory under the sub-path (`status-pages/404`, `maintenance`).
* @param {string} [file] - Document within the context.
* @returns {{ assetPath: string, dir: string, url: string }} Root-relative path, prefix target, full-path target.
* @memberof UnderpostGateway
*/
const staticLocationFactory = ({ host, path = '/', context, file = 'index.html' }) => {
const dir = `${host}/${staticPathSegmentFactory(path)}/${context}`;
const assetPath = `${dir}/${file}`;
return { assetPath, dir: `/${dir}`, url: `/${assetPath}` };
};
/**
* @method statusPageAssetPathFactory
* @description Location of one host's status page.
* @param {string} host - Hostname the page belongs to.
* @param {string} [path] - Proxy sub-path the page belongs to.
* @param {string|number} status - HTTP status code.
* @returns {{ assetPath: string, dir: string, url: string }} See {@link UnderpostGateway.staticLocationFactory}.
* @memberof UnderpostGateway
*/
const statusPageAssetPathFactory = ({ host, path = '/', status }) =>
staticLocationFactory({ host, path, context: `status-pages/${status}` });
/**
* @method statusPageBuildSegment
* @description Where a status page is built inside the client bundle, relative
* to that client's served root.
*
* Deliberately not `<status>/index.html`. A status page is an edge artifact, and
* a document sitting on the runtime's own `/<status>` route makes it an
* application route too: the runtime then has a page to serve — or to redirect to
* — for its own errors, which is exactly the URI change the edge exists to
* prevent. Kept under the same `status-pages` name the gateway layout uses, so
* both sides read one convention.
* @param {string|number} status - HTTP status code.
* @returns {string} Bundle-relative path of the document.
* @memberof UnderpostGateway
*/
const statusPageBuildSegment = (status) => `status-pages/${status}/index.html`;
/**
* @method defaultStatusPagePath
* @description Root-relative location of the shared fallback document.
* @param {string|number} [status] - HTTP status code.
* @returns {string} Root-relative path.
* @memberof UnderpostGateway
*/
const defaultStatusPagePath = (status = 404) => `${UNDERPOST_GATEWAY.defaultHostDir}/status-pages/${status}/index.html`;
/**
* @method nginxConfFactory
* @description Renders the server config.
*
* The gateway always rewrites into the layout before forwarding, so `try_files`
* only has to resolve a path that is already root-relative: the document itself
* for an asset request, then `index.html` beneath it for a directory — which is
* what a `ReplacePrefixMatch` onto a context directory produces.
*
* A miss ends on the shared default page, so a host with nothing on disk still
* answers with a deliberate document — but through `error_page`, which keeps the
* 404 status rather than presenting the fallback as the host's own page with a
* 200. That status is what stops the document being stored: the PWA service
* worker caches navigations for hours and would otherwise keep serving the
* fallback long after the host's real page landed in the tree.
*
* Compression is applied here rather than further out because this is the last
* hop that holds a response body in the clear — and, for every host whose
* errors are intercepted, the hop every site path already passes through. See
* {@link module:src/server/underpost-compression.js}.
* @param {string} [resolver] - Cluster DNS ClusterIP.
* @param {object} [compression] - Overrides for the compression policy.
* @returns {string} nginx.conf contents.
* @memberof UnderpostGateway
*/
const nginxConfFactory = ({ resolver = UNDERPOST_GATEWAY.resolver, compression = {} } = {}) => {
const policy = { ...compression, staticRoot: true };
const modules = compressionModulesConfFactory(policy);
const compress = compressionConfFactory(policy);
return `worker_processes auto;
error_log /dev/stderr warn;
pid /tmp/nginx.pid;
${modules ? `${modules}\n` : ''}
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type text/html;
sendfile on;
tcp_nopush on;
server_tokens off;
${compress ? `\n${compress}\n` : ''}
log_format concise '$remote_addr "$request" $status $body_bytes_sent "$host"';
access_log /dev/stdout concise;
# Websocket upgrades must be forwarded verbatim; a proxied hop that drops the
# Connection header leaves the client holding a half-open socket.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Cluster DNS as a literal address — nginx cannot resolve its own resolver.
# Needed because every upstream is passed through a variable: without it nginx
# resolves a Service name once at start-up and keeps the address for the life
# of the process, and a redeployed workload gets a new ClusterIP.
resolver ${resolver} valid=10s ipv6=off;
# Per-host server blocks, written into the volume by the deploy that owns the
# host. They live beside the documents rather than in this ConfigMap because
# the workload is shared: one deploy must not rewrite another's routing.
include ${UNDERPOST_GATEWAY.root}/${UNDERPOST_GATEWAY.confDir}/*.conf;
server {
listen ${UNDERPOST_GATEWAY.port} default_server;
server_name _;
root ${UNDERPOST_GATEWAY.root};
error_page 404 /${defaultStatusPagePath(404)};
# Probes are the only traffic that would otherwise dominate the log.
location = ${UNDERPOST_GATEWAY.healthPath} {
access_log off;
add_header Content-Type text/plain;
return 200 'ok';
}
location = /${defaultStatusPagePath(404)} {
internal;
add_header Cache-Control 'no-store' always;
}
location / {
add_header Cache-Control 'public, max-age=60';
try_files $uri $uri/index.html =404;
}
}
}
`;
};
/**
* @method statusPageLocationsFactory
* @description The internal locations an intercepted status resolves to, and the
* `error_page` lines that reach them.
*
* Each is `internal`, so a client cannot request the document at its storage
* path — it is only ever reached by interception, which is what keeps the
* client's URI unchanged. The `=` form is deliberately absent: `error_page 404
* /x` preserves the upstream's status, while `error_page 404 = /x` would rewrite
* it to the status of the page itself.
* @param {string} host - Hostname the documents belong to.
* @param {string} [path] - Proxy sub-path the documents belong to.
* @param {Object<string,string>} statuses - Status code → context directory under the sub-path.
* @returns {{errorPages: string, locations: string}} Rendered `error_page` directives and their locations.
* @memberof UnderpostGateway
*/
const statusPageLocationsFactory = ({ host, path = '/', statuses }) => {
const entries = Object.entries(statuses);
const named = (status) => `@status_${nginxTokenFactory(path)}_${status}`;
return {
errorPages: entries.map(([status]) => ` error_page ${status} ${named(status)};`).join('\n'),
locations: entries
.map(
([status, context]) => ` location ${named(status)} {
add_header Cache-Control 'no-store' always;
try_files /${staticLocationFactory({ host, path, context }).assetPath} =${status};
}`,
)
.join('\n'),
};
};
/**
* @method hostServerConfFactory
* @description Renders one host's server block: every proxied sub-path, and the
* documents its errors are intercepted with.
*
* `proxy_intercept_errors` is the whole mechanism. The upstream answers 404 or
* dies, Nginx swaps in the document from disk, and the client sees its own URI
* with the upstream's status code — no redirect, no size ceiling, and nothing
* for the application runtime to implement. A sub-path that declares no status
* page is proxied untouched, so an API keeps returning its own error bodies.
* @param {string} host - Hostname this block serves.
* @param {Array<object>} routes - `{ path, upstream, statuses, stripPrefix }` per proxied
* sub-path; `statuses` maps a status code to the context directory holding its
* document, and `stripPrefix` drops the sub-path before dialling the upstream.
* @returns {string} nginx server block, or an empty string when the host proxies nothing.
* @memberof UnderpostGateway
*/
const hostServerConfFactory = ({ host, routes = [], namespace = 'default' }) => {
const proxied = routes.filter((route) => route.upstream);
if (proxied.length === 0) return '';
// Longest sub-path first: nginx prefix locations are longest-match, but the
// emitted order keeps the block readable next to the HTTPRoute it mirrors.
const sorted = [...proxied].sort((a, b) => (b.path || '/').length - (a.path || '/').length);
const blocks = sorted.map((route) => {
const { errorPages, locations } = statusPageLocationsFactory({
host,
path: route.path,
statuses: route.statuses || {},
});
const intercept = errorPages ? ` proxy_intercept_errors on;\n${errorPages}` : ' proxy_intercept_errors off;';
const path = route.path || '/';
// The upstream is dialled through a variable, so nginx forwards the request
// URI verbatim and a prefix strip has to be an explicit rewrite. `break`
// keeps it inside this location instead of re-running location matching.
const rewrite = route.stripPrefix && path !== '/' ? ` rewrite ^${path}/?(.*)$ /$1 break;\n` : '';
return {
path,
upstream: kubernetesUpstreamFactory(route.upstream, namespace),
intercept,
locations,
rewrite,
};
});
return `
server {
listen ${UNDERPOST_GATEWAY.port};
server_name ${host};
root ${UNDERPOST_GATEWAY.root};
# This host's own documents, served from disk before anything is proxied.
# Every path in the layout begins with the hostname, which is exactly what the
# gateway rewrites a status or context route onto — and a longer prefix than
# the proxied root below, so nginx prefers it. Without this location the
# rewritten document path falls into the proxy and is sent to the application,
# which has no such route: the app answers 404, and an app that redirects its
# own 404s turns that into a loop between the route and the rewrite.
location /${host}/ {
add_header Cache-Control 'public, max-age=60';
try_files $uri $uri/index.html =404;
}
${blocks
.map(
({ path, upstream, intercept, rewrite }) => ` location ${path} {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Websockets: the upgrade must survive this hop or a client that negotiated
# one at the edge is left holding a half-open connection.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
set $upstream_${nginxTokenFactory(path)} ${upstream};
${intercept}
${rewrite} proxy_pass http://$upstream_${nginxTokenFactory(path)};
}`,
)
.join('\n\n')}
${blocks
.map(({ locations }) => locations)
.filter(Boolean)
.join('\n')}
}
`;
};
/**
* @method underpostGatewayManifestsFactory
* @description Renders the whole workload: the Nginx config, the hostPath volume
* holding the documents, the deployment and the Service routes target.
* @param {string} [namespace] - Kubernetes namespace.
* @param {string} hostPath - Node directory backing the static root.
* @param {string} [nodeName] - Node the hostPath volume is pinned to.
* @param {string} [storage] - Volume size.
* @param {object} [compression] - Overrides for the compression policy.
* @returns {string} Multi-document YAML.
* @memberof UnderpostGateway
*/
const underpostGatewayManifestsFactory = ({
namespace = 'default',
hostPath,
nodeName = '',
storage = '1Gi',
resolver,
compression = {},
} = {}) => {
const nginxConf = nginxConfFactory({ resolver, compression });
// The config is mounted with `subPath`, which Kubernetes never refreshes in
// place, and the pod template is otherwise identical across rebuilds — so
// without this annotation an edited nginx.conf reaches the ConfigMap and
// nothing else, and the running Nginx keeps serving under the previous
// layout for the life of the pod.
const configHash = crypto.createHash('sha256').update(nginxConf).digest('hex').slice(0, 16);
return `
---
apiVersion: v1
kind: ConfigMap
metadata:
name: ${UNDERPOST_GATEWAY.configMapName}
namespace: ${namespace}
data:
nginx.conf: |
${nginxConf
.replace(/\n$/, '')
.split('\n')
.map((line) => (line.length > 0 ? ` ${line}` : ''))
.join('\n')}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ${UNDERPOST_GATEWAY.volumeName}
spec:
capacity:
storage: ${storage}
accessModes:
- ReadOnlyMany
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manual${
nodeName
? `
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${nodeName}`
: ''
}
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
name: ${UNDERPOST_GATEWAY.claimName}
namespace: ${namespace}
hostPath:
path: ${hostPath}
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ${UNDERPOST_GATEWAY.claimName}
namespace: ${namespace}
spec:
accessModes:
- ReadWriteOnce
storageClassName: manual
volumeName: ${UNDERPOST_GATEWAY.volumeName}
resources:
requests:
storage: ${storage}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${UNDERPOST_GATEWAY.name}
namespace: ${namespace}
labels:
app: ${UNDERPOST_GATEWAY.name}
spec:
replicas: 1
selector:
matchLabels:
app: ${UNDERPOST_GATEWAY.name}
template:
metadata:
labels:
app: ${UNDERPOST_GATEWAY.name}
annotations:
underpost.net/nginx-conf-hash: '${configHash}'
spec:
containers:
- name: nginx
image: ${UNDERPOST_GATEWAY.image}
ports:
- containerPort: ${UNDERPOST_GATEWAY.port}
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 200m
memory: 128Mi
readinessProbe:
httpGet:
path: ${UNDERPOST_GATEWAY.healthPath}
port: ${UNDERPOST_GATEWAY.port}
initialDelaySeconds: 2
periodSeconds: 10
livenessProbe:
httpGet:
path: ${UNDERPOST_GATEWAY.healthPath}
port: ${UNDERPOST_GATEWAY.port}
initialDelaySeconds: 10
periodSeconds: 20
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
- name: static-root
mountPath: ${UNDERPOST_GATEWAY.root}
readOnly: true
volumes:
- name: nginx-conf
configMap:
name: ${UNDERPOST_GATEWAY.configMapName}
- name: static-root
persistentVolumeClaim:
claimName: ${UNDERPOST_GATEWAY.claimName}
---
apiVersion: v1
kind: Service
metadata:
name: ${UNDERPOST_GATEWAY.serviceName}
namespace: ${namespace}
labels:
app: ${UNDERPOST_GATEWAY.name}
spec:
type: ClusterIP
selector:
app: ${UNDERPOST_GATEWAY.name}
ports:
- name: http
protocol: TCP
port: ${UNDERPOST_GATEWAY.port}
targetPort: ${UNDERPOST_GATEWAY.port}
`;
};
/**
* @method writeStaticAsset
* @description Places one document in the node directory backing the static
* root. The deploy runs on that node, so the file is copied directly rather than
* shipped through the API server — which is also what keeps a page of any size
* out of the cluster's object store.
* @param {string} hostRoot - Node directory backing the static root.
* @param {string} assetPath - Root-relative destination.
* @param {string} sourcePath - File to copy.
* @returns {boolean} True when the document was placed.
* @memberof UnderpostGateway
*/
const writeStaticAsset = ({ hostRoot, assetPath, sourcePath }) => {
if (!sourcePath || !fs.existsSync(sourcePath) || fs.statSync(sourcePath).size === 0) return false;
const target = nodePath.join(hostRoot, assetPath);
// sudo: the node directory is root-owned, and the deploy may run unprivileged.
shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
shellExec(`sudo cp -f ${sourcePath} ${target}`, { silent: true });
return true;
};
/**
* @method syncStaticAssetFromPod
* @description Pulls one document out of the running workload and places it in
* the static tree.
*
* The pod is the authority for these artifacts, not the host: several clients
* are built from sources that only exist inside the container (cloned from the
* private repo at start-up), so the host's `public/` tree is both incomplete and
* as old as the last host-side build. Copying from the pod is what makes the
* placed document match what the workload would actually have served.
* @param {string} podName - Workload pod holding the built artifact.
* @param {string} [namespace] - Pod namespace.
* @param {string} [container] - Container within the pod.
* @param {string} sourcePath - Absolute path of the artifact inside the container.
* @param {string} hostRoot - Node directory backing the static root.
* @param {string} assetPath - Root-relative destination.
* @returns {boolean} True when the document was placed.
* @memberof UnderpostGateway
*/
const syncStaticAssetFromPod = ({ podName, namespace = 'default', container, sourcePath, hostRoot, assetPath }) => {
const target = nodePath.join(hostRoot, assetPath);
const containerFlag = container ? ` -c ${container}` : '';
// Keyed by destination, not by basename: every document in the layout is an
// `index.html`, so a shared staging name lets one asset's copy be mistaken
// for another's.
const staged = nodePath.join(
'/tmp',
`underpost-gateway-${crypto.createHash('sha256').update(assetPath).digest('hex').slice(0, 12)}-${process.pid}`,
);
// Staged through /tmp because `kubectl cp` runs unprivileged while the node
// directory is root-owned; the move is the only step that needs sudo.
fs.removeSync(staged);
shellExec(`kubectl cp ${namespace}/${podName}:${sourcePath} ${staged}${containerFlag} 2>/dev/null || true`, {
silent: true,
silentOnError: true,
});
if (!fs.existsSync(staged) || fs.statSync(staged).size === 0) {
fs.removeSync(staged);
return false;
}
shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
shellExec(`sudo cp -f ${staged} ${target}`, { silent: true });
fs.removeSync(staged);
logger.info('Static asset synced from workload', { podName, sourcePath, assetPath });
return true;
};
/**
* @method writeHostServerConf
* @description Writes one host's server block into a directory, or removes it
* when there is nothing to serve.
*
* A build artifact and nothing more: it touches no cluster, so generating
* manifests works with no cluster running at all. Installing the block into the
* live gateway and reloading it is {@link UnderpostGateway.installGatewayConf}'s
* job, on the apply path where a cluster is a precondition.
* @param {string} confDir - Directory the block is written to.
* @param {string} host - Hostname the block serves.
* @param {string} conf - Rendered block from {@link UnderpostGateway.hostServerConfFactory}; empty removes it.
* @returns {boolean} True when the file changed.
* @memberof UnderpostGateway
*/
const writeHostServerConf = ({ confDir, host, conf }) => {
const target = nodePath.join(confDir, `${host}.conf`);
const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
if (current === (conf || '')) return false;
if (!conf) {
fs.removeSync(target);
return true;
}
fs.mkdirpSync(confDir);
fs.writeFileSync(target, conf, 'utf8');
return true;
};
/**
* @method hostInstanceRegistryPathFactory
* @description Path of the descriptor set last published for a host.
*
* Kept beside the host's server block because it describes the same object, and
* given a non-`.conf` suffix so {@link UnderpostGateway.installGatewayConf}
* never installs it into Nginx.
* @param {string} confDir - Directory holding the built blocks.
* @param {string} host - Hostname.
* @returns {string} File path.
* @memberof UnderpostGateway
*/
const hostInstanceRegistryPathFactory = ({ confDir, host }) => nodePath.join(confDir, `${host}.instances.json`);
/**
* @method readHostInstanceRegistry
* @description The instance descriptors last published for a host.
*
* The conf declares what *should* run; this records what the host was last
* rendered with, which is the only place a variant's descriptor survives being
* removed from the conf while its workload is still up. Unreadable or malformed
* content is treated as absent: a broken registry must not block a deploy, it
* just means nothing extra is preserved.
* @param {string} confDir - Directory holding the built blocks.
* @param {string} host - Hostname.
* @returns {Array<object>} Descriptors, or an empty list.
* @memberof UnderpostGateway
*/
const readHostInstanceRegistry = ({ confDir, host }) => {
const target = hostInstanceRegistryPathFactory({ confDir, host });
if (!fs.existsSync(target)) return [];
try {
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
return Array.isArray(parsed) ? parsed.filter((entry) => entry?.id) : [];
} catch (error) {
logger.warn('Ignoring unreadable host instance registry', { target, message: error.message });
return [];
}
};
/**
* @method writeHostInstanceRegistry
* @description Records the descriptors a host was just rendered with.
* @param {string} confDir - Directory holding the built blocks.
* @param {string} host - Hostname.
* @param {Array<object>} instances - Descriptors used for this render.
* @returns {boolean} True when the file changed.
* @memberof UnderpostGateway
*/
const writeHostInstanceRegistry = ({ confDir, host, instances = [] }) => {
const target = hostInstanceRegistryPathFactory({ confDir, host });
const next = `${JSON.stringify(instances, null, 2)}\n`;
const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
if (current === next) return false;
fs.mkdirpSync(confDir);
fs.writeFileSync(target, next, 'utf8');
return true;
};
/**
* @method installGatewayConf
* @description Installs the built server blocks into the shared gateway and
* reloads Nginx.
*
* The blocks live in the volume rather than the ConfigMap because the workload is
* shared by every deploy: a ConfigMap would make one deploy's apply rewrite
* another's routing, and a `subPath` mount would never refresh anyway. Reloading
* signals the running master, so the config lands without dropping a connection.
*
* A block that does not parse would take the whole edge down on reload, so the
* config is validated first and the previous content is put back if it fails —
* leaving the running Nginx exactly as it was.
* @param {string} hostRoot - Node directory backing the gateway root.
* @param {string} confSourceDir - Directory holding the built blocks.
* @param {string} [namespace] - Namespace holding the workload.
* @returns {boolean} True when Nginx was reloaded with the new config.
* @throws {Error} When Nginx rejects the candidate config or cannot reload. A
* rejected candidate is restored before the error is raised.
* @memberof UnderpostGateway
*/
const installGatewayConf = ({ hostRoot, confSourceDir, namespace = 'default' }) => {
if (!fs.existsSync(confSourceDir)) return false;
const blocks = fs.readdirSync(confSourceDir).filter((name) => name.endsWith('.conf'));
if (blocks.length === 0) return false;
const confDir = nodePath.join(hostRoot, UNDERPOST_GATEWAY.confDir);
// sudo: the node directory is root-owned, and the deploy may run unprivileged.
shellExec(`sudo mkdir -p ${confDir}`, { silent: true });
const previous = Object.fromEntries(
blocks.map((name) => {
const target = nodePath.join(confDir, name);
return [name, fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null];
}),
);
const restorePrevious = () => {
for (const [name, content] of Object.entries(previous)) {
const target = nodePath.join(confDir, name);
if (content === null) shellExec(`sudo rm -f ${target}`, { silent: true });
else {
const staged = nodePath.join('/tmp', `underpost-gateway-restore-${name}-${process.pid}`);
fs.writeFileSync(staged, content, 'utf8');
shellExec(`sudo cp -f ${staged} ${target}`, { silent: true });
fs.removeSync(staged);
}
}
};
for (const name of blocks)
shellExec(`sudo cp -f ${nodePath.join(confSourceDir, name)} ${nodePath.join(confDir, name)}`, { silent: true });
const test = shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- nginx -t 2>&1`, {
stdout: true,
silent: true,
silentOnError: true,
});
if (!`${test}`.includes('successful')) {
restorePrevious();
logger.error('Gateway config rejected; the previous config was restored and Nginx left running', {
blocks,
test: `${test}`.trim().split('\n').slice(-3).join(' '),
});
throw new Error(
`Gateway config rejected for ${blocks.join(', ')}: ${`${test}`.trim().split('\n').slice(-3).join(' ')}`,
);
}
try {
shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- nginx -s reload`, {
silent: true,
});
} catch (error) {
restorePrevious();
// The running master normally retains its old config when a reload signal
// fails. Re-signal after restoring so even a partial reload converges back
// to the last validated state.
shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- nginx -s reload`, {
silent: true,
silentOnError: true,
});
throw error;
}
logger.info('Gateway config installed and reloaded', { blocks, confDir });
return true;
};
/**
* @method seedDefaultStatusPage
* @description Writes the shared fallback document `nginx.conf` serves through
* `error_page`, so a host with nothing on disk gets a deliberate page instead of
* Nginx's stock error. Never overwrites an existing document — an operator may
* have replaced it.
* @param {string} hostRoot - Node directory backing the static root.
* @returns {boolean} True when the document was written.
* @memberof UnderpostGateway
*/
const seedDefaultStatusPage = (hostRoot) => {
// The base config includes `<confDir>/*.conf`; the directory has to exist
// before any deploy contributes a block to it.
shellExec(`sudo mkdir -p ${nodePath.join(hostRoot, UNDERPOST_GATEWAY.confDir)}`, { silent: true });
const target = nodePath.join(hostRoot, defaultStatusPagePath(404));
if (fs.existsSync(target)) return false;
const document = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>404 Not Found</title>
</head>
<body>
<h1>404</h1>
<p>The requested resource was not found.</p>
</body>
</html>
`;
shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
shellExec(`sudo tee ${target} > /dev/null <<'EOF'\n${document}EOF\n`, { silent: true });
return true;
};
/**
* @method gatewayStaticAssetExists
* @description Whether a document is present and non-empty under the gateway
* root. An empty file is treated as absent: it is what a half-finished copy
* leaves behind, and serving it would answer a status page with a blank body.
* @param {string} hostRoot - Node directory backing the gateway root.
* @param {string} assetPath - Root-relative path of the document.
* @returns {boolean} True when the document can be served.
* @memberof UnderpostGateway
*/
const gatewayStaticAssetExists = ({ hostRoot, assetPath }) => {
const target = `${hostRoot}/${assetPath}`;
return fs.existsSync(target) && fs.statSync(target).size > 0;
};
/**
* @method pwaFallbackChecksFactory
* @description The fallback probes a deploy's own hosts are verified with, read
* from `conf.server.json` and `conf.ssr.json`.
*
* One probe per host/sub-path that declares a maintenance view, because that is
* the document an unreachable workload has to answer with — the condition the
* edge must satisfy before any application is deployed behind it.
*
* `loadReplicas` expansion and the `singleReplica` skip mirror
* `buildProxyRouter`/`buildManifest` exactly: a plain `replicas` path (no
* `singleReplica`) is built and routed by this same deploy id, so its fallback
* is checked too; a `singleReplica` canonical path is never built or routed
* under this deploy id at all — it is each replica's own, separate deploy id —
* so probing it here would poll a document that structurally cannot exist.
* @param {string} deployId - Deploy id whose conf declares the views.
* @returns {Array<{host: string, path: string, assetPath: string, kind: string}>} One probe per declaring sub-path.
* @memberof UnderpostGateway
*/
const pwaFallbackChecksFactory = (deployId) => {
const confServer = loadReplicas(deployId, loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`));
const confSSRPath = `./engine-private/conf/${deployId}/conf.ssr.json`;
const confSSR = fs.existsSync(confSSRPath) ? JSON.parse(fs.readFileSync(confSSRPath, 'utf8')) : {};
const checks = [];
for (const host of Object.keys(confServer))
for (const path of Object.keys(confServer[host])) {
if (confServer[host][path].singleReplica) continue;
const maintenance = Underpost.deploy
.edgeRouteEntriesFactory({ confServer, confSSR, host, path })
.find((entry) => entry.context === 'maintenance');
if (maintenance)
checks.push({
host,
path,
assetPath: maintenance.assetPath,
kind: maintenance.kind,
});
}
return checks;
};
/**
* @method instanceFallbackChecksFactory
* @description The fallback probes a set of instances are verified with, one per
* host and sub-path.
*
* Deduplicated on `host + path`: a variant that declares several status pages is
* still one route to probe, and probing it once per declared status would report
* the same reachability several times.
* @param {Array<object>} [instances] - Expanded instance entries.
* @returns {Array<{host: string, path: string, assetPath: string, kind: string}>} One probe per instance sub-path.
* @memberof UnderpostGateway
*/
const instanceFallbackChecksFactory = (instances = []) => {
const checks = new Map();
for (const entry of instanceStatusPageEntriesFactory({ instances }))
if (!checks.has(`${entry.host}${entry.path}`))
checks.set(`${entry.host}${entry.path}`, {
host: entry.host,
path: entry.path,
assetPath: entry.assetPath,
kind: `status:${entry.status}`,
});
return [...checks.values()];
};
/**
* @method assertStaticAssets
* @description Fails the deploy when a configured document reached neither the
* placement pass nor the gateway root.
*
* Deliberately fatal. A missing document is not visible at deploy time — the
* routes are accepted, the workload is healthy, and the gap only surfaces later
* as a shared default page in place of the host's own. Refusing to continue is
* what turns that into an immediate, attributable failure.
* @param {Array<object>} records - Placement records carrying `source` and `assetPath`.
* @param {string} hostRoot - Node directory backing the gateway root.
* @param {string} label - Workflow name used in the thrown message.
* @returns {Array<object>} The records, unchanged, when every document is present.
* @throws {Error} When any configured document is absent.
* @memberof UnderpostGateway
*/
const assertStaticAssets = ({ records, hostRoot, label }) => {
const missing = records.filter(
(entry) => !entry.source && !gatewayStaticAssetExists({ hostRoot, assetPath: entry.assetPath }),
);
if (missing.length > 0)
throw new Error(
`[${label}] Static gateway bootstrap is missing configured assets: ` +
missing.map((entry) => entry.assetPath).join(', '),
);
return records;
};
/**
* @method placeInstanceStaticAssets
* @description Places every instance's declared status page in the gateway root
* and asserts the result.
*
* The documents come from the project each instance runs, so this is the only
* pass that can supply them; anything still missing afterwards would leave a
* route pointing at a document that cannot exist.
* @param {Array<object>} instances - Expanded instance entries.
* @param {object} options - Deploy/run options (gateway root, namespace).
* @param {string} label - Workflow name used in the thrown message.
* @returns {Array<object>} One record per document, with where it came from.
* @memberof UnderpostGateway
*/
const placeInstanceStaticAssets = ({ instances, options, label }) => {
const hostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
const records = instanceStatusPageEntriesFactory({ instances }).map((entry) => ({
...entry,
source: writeStaticAsset({ hostRoot, assetPath: entry.assetPath, sourcePath: entry.sourcePath }) ? 'project' : null,
}));
return assertStaticAssets({ records, hostRoot, label });
};
/**
* @method gatewayFallbackProbeRunner
* @description Proves the edge answers each configured fallback with the exact
* document on disk, before any application is deployed behind it.
*
* The assertion is on the response, not on the manifests: an accepted route and a
* Programmed Gateway say nothing about which body a client receives, and every
* failure this pipeline has had was invisible in object status. The expected body
* is hashed from the file the config points at, so a probe cannot pass against a
* shared default page.
*
* With no workload deployed yet the upstream is unreachable, so the wanted status
* is an upstream failure carrying the configured document — which is the whole
* contract being verified. Polling absorbs the reconciliation window in which the
* data plane still serves the previous generation.
*
* `gatewayStatusRunner` is injected rather than imported so this module never
* depends on the runner collection that calls it.
* @param {Array<object>} checks - Probes from {@link UnderpostGateway.pwaFallbackChecksFactory} or {@link UnderpostGateway.instanceFallbackChecksFactory}.
* @param {object} options - Deploy/run options (namespace, dev, gatewayApi).
* @param {string} label - Workflow name used in log lines and thrown messages.
* @param {Function} gatewayStatusRunner - `(hosts, options) => Promise<{programmed: boolean, servesHttps: boolean}>`.
* @returns {Promise<Array<object>>} One result per probe.
* @throws {Error} When the gateway is not operational, or any probe fails.
* @memberof UnderpostGateway
*/
const gatewayFallbackProbeRunner = async ({ checks, options, label, gatewayStatusRunner }) => {
if (!options.gatewayApi || checks.length === 0) return [];
const namespace = options.namespace || 'default';
shellExec(`kubectl rollout status deployment/${UNDERPOST_GATEWAY.name} -n ${namespace} --timeout=5m`);
const hosts = [...new Set(checks.map((check) => check.host))];
const gatewayStatus = await gatewayStatusRunner(hosts.join(','), { ...options, namespace });
if (!gatewayStatus.programmed || (options.dev && !gatewayStatus.servesHttps))
throw new Error(`[${label}] Gateway is not operational before application deployment`);
const hostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
const failures = [];
const results = [];
for (const check of checks) {
const expectedPath = `${hostRoot}/${check.assetPath}`;
const expectedHash = gatewayStaticAssetExists({ hostRoot, assetPath: check.assetPath })
? crypto.createHash('sha256').update(fs.readFileSync(expectedPath)).digest('hex')
: '';
let body = '';
let status = '';
let actualHash = '';
let passed = false;
let attempts = 0;
// Gateway and HTTPRoute status can still show the previous generation for a
// short reconciliation window. Poll the actual response until the intended
// fallback is observable instead of racing the controller once.
for (attempts = 1; attempts <= 30; attempts++) {
if (options.dev) {
const url = `https://${check.host}${check.path || '/'}`;
const curl = `curl -sSk --noproxy '*' --resolve ${check.host}:443:127.0.0.1`;
body = shellExec(`${curl} ${url}`, { stdout: true, silent: true, silentOnError: true });
status = shellExec(`${curl} -o /dev/null -w '%{http_code}' ${url}`, {
stdout: true,
silent: true,
silentOnError: true,
}).trim();
} else {
const request = `http://127.0.0.1${check.path || '/'}`;
body = shellExec(
`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
`"wget -q -O - -T 10 --header 'Host: ${check.host}' ${request} 2>/dev/null || true"`,
{ stdout: true, silent: true, silentOnError: true },
);
const headers = shellExec(
`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
`"wget -S -O /dev/null -T 10 --header 'Host: ${check.host}' ${request} 2>&1 || true"`,
{ stdout: true, silent: true, silentOnError: true },
);
status = [...headers.matchAll(/HTTP\/[0-9.]+\s+([0-9]{3})/g)].pop()?.[1] || '';
}
actualHash = crypto
.createHash('sha256')
.update(body || '')
.digest('hex');
passed = /^50[234]$/.test(status) && !!expectedHash && actualHash === expectedHash;
if (passed) break;
if (attempts < 30) await timer(2000);
}
const result = {
...check,
status,
bodyMatchesConfiguredAsset: actualHash === expectedHash,
attempts,
passed,
};
results.push(result);
if (!passed) failures.push(result);
}
logger.info(`[${label}] Pre-runtime fallback probes`, { results });
if (failures.length > 0) throw new Error(`[${label}] ${failures.length}/${checks.length} fallback probes failed`);
return results;
};
export {
UNDERPOST_GATEWAY,
assertStaticAssets,
gatewayFallbackProbeRunner,
gatewayStaticAssetExists,
hostInstanceRegistryPathFactory,
hostServerConfFactory,
installGatewayConf,
instanceFallbackChecksFactory,
placeInstanceStaticAssets,
pwaFallbackChecksFactory,
readHostInstanceRegistry,
writeHostInstanceRegistry,
kubernetesUpstreamFactory,
underpostGatewayManifestsFactory,
nginxConfFactory,
seedDefaultStatusPage,
staticLocationFactory,
staticPathSegmentFactory,
statusPageAssetPathFactory,
statusPageBuildSegment,
statusPageLocationsFactory,
syncStaticAssetFromPod,
writeHostServerConf,
writeStaticAsset,
};