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
3,064 lines • 144 kB
JavaScript
/**
* Deploy module for managing the deployment of applications and services.
* @module src/cli/deploy.js
* @namespace UnderpostDeploy
*/
import {
buildKindPorts,
buildPortProxyRouter,
buildProxyRouter,
clusterTypeFactory,
Config,
deployHostsFactory,
deployRangePortFactory,
gatewayApiEnabledFactory,
getDataDeploy,
instanceStatusPageEntriesFactory,
loadConfInstances,
loadConfServerJson,
loadReplicas,
nextTrafficFactory,
pathPortAssignmentFactory,
schedulableNodeFactory,
trafficFromRoutingInfoFactory,
} from '../server/conf.js';
import { cronDeployIdResolve } from '../server/cron.js';
import { loggerFactory } from '../server/logger.js';
import { HOST_VOLUME_ROOT } from '../server/environment.js';
import { shellExec } from '../server/process.js';
import { runSELinuxCommands, selinuxRestoreconCommandFactory } from '../server/selinux.js';
import { INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH } from '../server/runtime-status.js';
import { staticContextRoutesFactory, statusPageRoutesFactory } from '../client-builder/client-build.js';
import {
UNDERPOST_GATEWAY,
hostServerConfFactory,
installGatewayConf,
underpostGatewayManifestsFactory,
staticLocationFactory,
statusPageAssetPathFactory,
statusPageBuildSegment,
syncStaticAssetFromPod,
writeHostServerConf,
writeStaticAsset,
} from '../server/underpost-gateway.js';
import { getCapVariableName } from '../client/components/core/CommonJs.js';
import fs from 'fs-extra';
import nodePath from 'node:path';
import dotenv from 'dotenv';
import os from 'node:os';
import crypto from 'node:crypto';
import Underpost from '../index.js';
/**
* Clamps an identifier to the Kubernetes DNS-1123 label limit (63 chars),
* used for pod-local `volumes[].name` / `volumeMounts[].name`. Names within the
* limit are returned verbatim so existing short names are stable; longer ones
* are truncated and suffixed with an 8-char content hash to stay unique and
* deterministic (e.g. the per-variant instance volume names, which append the
* full `<deployId>-<env>-<traffic>` and can exceed 63).
* @param {string} name - Candidate name.
* @returns {string} A name no longer than 63 characters.
*/
const k8sVolumeName = (name) => {
if (typeof name !== 'string' || name.length <= 63) return name;
const hash = crypto.createHash('sha1').update(name).digest('hex').slice(0, 8);
return `${name.slice(0, 54)}-${hash}`;
};
const GATEWAY_API_GROUP = 'gateway.networking.k8s.io';
const GATEWAY_API_GROUP_VERSION = `${GATEWAY_API_GROUP}/v1`;
// QUIC/HTTP3 listener config and direct-response status pages are the two route
// behaviours core Gateway API leaves to the implementation. Both are expressed
// through the Envoy Gateway extension group, so retargeting another Gateway API
// implementation is a change to these two constants and nothing else.
const GATEWAY_EXTENSION_GROUP = 'gateway.envoyproxy.io';
const GATEWAY_EXTENSION_GROUP_VERSION = `${GATEWAY_EXTENSION_GROUP}/v1alpha1`;
const GATEWAY_CONTROLLER_NAME = `${GATEWAY_EXTENSION_GROUP}/gatewayclass-controller`;
// The class `cluster --gateway-api` provisions and the class every generated
// Gateway references: one name, resolved through gatewayApiConfigFactory, so an
// override reaches the installer and the manifests together.
const GATEWAY_CLASS_DEFAULT = 'eg';
// Where `bin client <deployId> <env>` writes each host's bundle, including the
// SSR status views declared in conf.ssr.json (`<host><path>/<status>/index.html`).
// Engine root inside the workload container; the built PWA artifacts live under
// its `public/` tree, which is where the static edge documents are sourced from.
// A workload that is gone answers with none of these itself; Envoy or the
// gateway hop produces them, and a maintenance page is what they mean.
const UPSTREAM_FAILURE_STATUSES = [502, 503, 504];
const CONTAINER_ENGINE_ROOT = '/home/dd/engine';
/**
* Maps a host/path's edge-served views onto the statuses the gateway intercepts
* for it, and the context directory each status is answered from.
*
* The mapping is the config's, not a policy of its own: a declared status page
* (`/404`) answers that status, and the maintenance view answers the codes that
* mean the workload is not there — a dead pod is exactly what a maintenance page
* is for. A host that declares neither is never intercepted and keeps routing
* straight to its workload.
* @param {Array<object>} edgeRoutes - Entries from {@link UnderpostDeploy.edgeRouteEntriesFactory}.
* @returns {Object<string,string>} Status code → context directory under the sub-path.
*/
const interceptStatusesFactory = (edgeRoutes = []) => {
const statuses = {};
for (const route of edgeRoutes) {
if (route.status) statuses[route.status] = `status-pages/${route.status}`;
else if (route.context === 'maintenance')
for (const code of UPSTREAM_FAILURE_STATUSES) statuses[code] = route.context;
}
return statuses;
};
/**
* The API sub-path of a host/path, when it declares one. Kept out of the
* intercepted route so an API answers with its own status and body.
* @param {object} confServer - Parsed `conf.server.json`.
* @param {string} host - Hostname.
* @param {string} path - Proxy sub-path.
* @returns {string} API path prefix, or an empty string when the path serves no API.
*/
const apiPathFactory = ({ confServer, host, path }) => {
const apis = confServer?.[host]?.[path]?.apis;
if (!Array.isArray(apis) || apis.length === 0) return '';
return `${path === '/' ? '' : path}/${process.env.BASE_API || 'api'}`;
};
const GATEWAY_DURATION_UNITS = [
['h', 3600000],
['m', 60000],
['s', 1000],
['ms', 1],
];
/**
* Converts an HTTPProxy duration (`300000ms`, `10s`, `infinity`) into a Gateway
* API Duration. The Gateway API grammar allows at most 5 digits per component,
* so a value that overflows in one unit is re-expressed in a coarser one
* (`300000ms` → `5m`); `infinity` maps to `0s`, which disables the timeout.
* @param {string|number} value - Source duration.
* @returns {string|null} Gateway API Duration, or null when unset/unparsable.
*/
const gatewayDurationFactory = (value) => {
if (value === undefined || value === null || value === '') return null;
const raw = `${value}`.trim();
if (raw === 'infinity' || raw === '0') return '0s';
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw);
if (!match) return null;
const factor = Object.fromEntries(GATEWAY_DURATION_UNITS)[match[2] || 'ms'];
const ms = Math.round(parseFloat(match[1]) * factor);
for (const [suffix, unit] of GATEWAY_DURATION_UNITS)
if (ms % unit === 0 && ms / unit <= 99999) return `${ms / unit}${suffix}`;
return `${Math.ceil(ms / 1000)}s`;
};
const logger = loggerFactory(import.meta);
// hostPath volume trees are written under the operator's home directory, which
// carries a label no unprivileged container can read. Cluster bring-up registers
// the persistent mapping for HOST_VOLUME_ROOT; this applies it to what a deploy
// just wrote. A no-op where SELinux or its userspace is absent.
const restoreContainerContext = (path) =>
runSELinuxCommands([selinuxRestoreconCommandFactory(path)], { execute: shellExec });
/**
* @class UnderpostDeploy
* @description Manages the deployment of applications and services.
* This class provides a set of static methods to handle the deployment process,
* including resource allocation, configuration management, and Kubernetes deployment.
* @memberof UnderpostDeploy
*/
class UnderpostDeploy {
static API = {
/**
* Creates a router configuration for a list of deployments.
* @param {string} deployList - List of deployment IDs to include in the router.
* @param {string} env - Environment for which the router is being created.
* @returns {object} - Router configuration for the specified deployments.
* @memberof UnderpostDeploy
*/
async routerFactory(deployList, env) {
const initEnvPath = `./engine-private/conf/${deployList.split(',')[0]}/.env.${env}`;
const initEnvObj = dotenv.parse(fs.readFileSync(initEnvPath, 'utf8'));
process.env.PORT = initEnvObj.PORT;
process.env.NODE_ENV = env;
await Config.build('proxy', deployList);
return buildPortProxyRouter({ port: env === 'development' ? 80 : 443, proxyRouter: buildProxyRouter() });
},
/**
* Stable Service used by every routing layer for one blue/green workload.
* Its name never carries the colour; promotion changes only its selector.
* @param {string} deployId - Deployment identifier.
* @param {string} env - Deployment environment.
* @returns {string} Kubernetes Service name.
* @memberof UnderpostDeploy
*/
trafficServiceNameFactory({ deployId, env }) {
return k8sVolumeName(`${deployId}-${env}-traffic-service`);
},
/**
* Renders the stable traffic Service. Both Envoy/Contour and the fallback
* gateway use this object, so one selector update moves every port/path.
* @param {string} deployId - Deployment identifier.
* @param {string} env - Deployment environment.
* @param {string} traffic - Selected colour.
* @param {string} [namespace] - Kubernetes namespace.
* @param {number} fromPort - First workload port.
* @param {number} toPort - Last workload port.
* @returns {string} Service YAML.
* @memberof UnderpostDeploy
*/
trafficServiceYamlFactory({ deployId, env, traffic, namespace = 'default', fromPort, toPort }) {
if (!['blue', 'green'].includes(traffic)) throw new Error(`Invalid traffic colour: ${traffic}`);
return `---
apiVersion: v1
kind: Service
metadata:
name: ${Underpost.deploy.trafficServiceNameFactory({ deployId, env })}
namespace: ${namespace}
labels:
underpost.net/traffic-service: "true"
underpost.net/deploy-id: ${deployId}-${env}
spec:
type: ClusterIP
selector:
app: ${deployId}-${env}-${traffic}
ports:
${buildKindPorts(fromPort, toPort)}`;
},
/**
* Applies a stable traffic Service from either a built manifest or explicit
* port bounds, replacing only its selector colour.
* @returns {string} Applied Service name.
* @memberof UnderpostDeploy
*/
applyTrafficService({ deployId, env, traffic, namespace = 'default', manifestPath, fromPort, toPort }) {
if (!['blue', 'green'].includes(traffic)) throw new Error(`Invalid traffic colour: ${traffic}`);
let manifest =
manifestPath && fs.existsSync(manifestPath)
? fs.readFileSync(manifestPath, 'utf8')
: Underpost.deploy.trafficServiceYamlFactory({ deployId, env, traffic, namespace, fromPort, toPort });
const escaped = `${deployId}-${env}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
manifest = manifest.replace(new RegExp(`app: ${escaped}-(?:blue|green)`), `app: ${deployId}-${env}-${traffic}`);
shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
${manifest}
EOF
`);
return Underpost.deploy.trafficServiceNameFactory({ deployId, env });
},
/**
* Removes the route kind owned by the inactive ingress stack. The active
* route is already published before this runs, so migration never creates a
* hostname with no route.
* @param {Array<string>} hosts - Hostnames/resource names to converge.
* @param {boolean} gatewayApi - Whether HTTPRoute is the destination stack.
* @param {string} namespace - Kubernetes namespace.
*/
removeInactiveHostRoutes({ hosts = [], gatewayApi, namespace = 'default' }) {
const kind = gatewayApi ? 'HTTPProxy' : 'HTTPRoute';
for (const host of [...new Set(hosts.filter(Boolean))])
shellExec(`kubectl delete ${kind} ${host} -n ${namespace} --ignore-not-found`, { silent: true });
},
/**
* Creates a YAML service configuration for a deployment.
* @param {string} deployId - Deployment ID for which the service is being created.
* @param {string} path - Path for which the service is being created.
* @param {string} env - Environment for which the service is being created.
* @param {number} port - Port number for the service.
* @param {Array<string>} deploymentVersions - List of deployment versions.
* @param {string} serviceId - Custom service name (optional).
* @param {Array} pathRewritePolicy - Path rewrite policy (optional).
* @param {object} timeoutPolicy - Timeout policy (optional).
* @param {object} retryPolicy - Retry policy (optional).
* @returns {string} - YAML service configuration for the specified deployment.
* @memberof UnderpostDeploy
*/
deploymentYamlServiceFactory({
deployId,
path,
env,
port,
deploymentVersions,
serviceId,
pathRewritePolicy,
timeoutPolicy,
retryPolicy,
}) {
return `
- conditions:
- prefix: ${path}
${
pathRewritePolicy
? `pathRewritePolicy:
replacePrefix:
${pathRewritePolicy.map(
(rd) => `- prefix: ${rd.prefix}
replacement: ${rd.replacement}
`,
).join(`
`)}`
: ''
}${
timeoutPolicy
? `\n timeoutPolicy:\n${timeoutPolicy.response ? ` response: ${timeoutPolicy.response}\n` : ''}${
timeoutPolicy.idle ? ` idle: ${timeoutPolicy.idle}\n` : ''
}`
: ''
}${
retryPolicy
? `\n retryPolicy:\n${retryPolicy.count !== undefined ? ` count: ${retryPolicy.count}\n` : ''}${
retryPolicy.perTryTimeout ? ` perTryTimeout: ${retryPolicy.perTryTimeout}\n` : ''
}`
: ''
}
enableWebsockets: true
services:
${(serviceId ? [null] : deploymentVersions)
.map(
(version, i) =>
` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}
port: ${port}
weight: ${i === 0 ? 100 : 0}
`,
)
.join('')}`;
},
/**
* Builds Kubernetes probes that gate on the in-pod internal status endpoint.
*
* HTTP mode (default) aligns Kubernetes pod readiness with actual Underpost
* runtime readiness:
* - readinessProbe → GET /_internal/ready (200 only when running-deployment)
* - livenessProbe → GET /_internal/health (deadlock / hung-process detection)
* - startupProbe → GET /_internal/ready (long window for hot-built/slow boots)
*
* Migration: pass `useHttp: false` to emit the legacy TCP socket probes
* (port-bound only) for deployments not yet serving the internal endpoint.
*
* @param {object} opts
* @param {number} opts.port - In-pod internal status port (deployment base PORT).
* @param {boolean} [opts.useHttp=true] - Emit HTTP probes; false → legacy TCP.
* @param {boolean} [opts.liveness=true] - Include a livenessProbe.
* @param {boolean} [opts.startup=true] - Include a startupProbe.
* @returns {{readinessProbe: object, livenessProbe?: object, startupProbe?: object}}
* @memberof UnderpostDeploy
*/
runtimeProbesFactory({ port, useHttp = true, liveness = true, startup = true } = {}) {
if (!port) return {};
if (!useHttp) {
const tcp = { tcpSocket: { port }, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 6 };
const probes = { readinessProbe: tcp };
if (liveness) probes.livenessProbe = { ...tcp, initialDelaySeconds: 30 };
return probes;
}
const probes = {
readinessProbe: {
httpGet: { path: INTERNAL_READY_PATH, port },
initialDelaySeconds: 5,
periodSeconds: 5,
timeoutSeconds: 3,
failureThreshold: 3,
},
};
if (liveness)
probes.livenessProbe = {
httpGet: { path: INTERNAL_HEALTH_PATH, port },
initialDelaySeconds: 30,
periodSeconds: 15,
timeoutSeconds: 3,
failureThreshold: 3,
};
if (startup)
// A startupProbe suspends readiness/liveness until it first succeeds, so
// its window bounds in-container hot builds and slow boots. 180 × 10s =
// 30 min before the pod is considered failed to start.
probes.startupProbe = {
httpGet: { path: INTERNAL_READY_PATH, port },
initialDelaySeconds: 10,
periodSeconds: 10,
timeoutSeconds: 3,
failureThreshold: 180,
};
return probes;
},
/**
* Resolves a required readiness probe for a custom workload. A configured
* probe wins; otherwise a TCP probe on the instance port is the safe floor.
* @param {object} [probe] - Configured readiness probe.
* @param {number} port - Instance container port.
* @returns {object} A Kubernetes readiness probe.
*/
requiredReadinessProbeFactory({ probe, port }) {
if (probe) return probe;
if (!port) throw new Error('A readiness probe or container port is required');
return Underpost.deploy.runtimeProbesFactory({ port, useHttp: false, liveness: false, startup: false })
.readinessProbe;
},
/**
* Creates a YAML deployment configuration for a deployment.
* @param {string} deployId - Deployment ID for which the deployment is being created.
* @param {string} env - Environment for which the deployment is being created.
* @param {string} suffix - Suffix for the deployment.
* @param {object} resources - Resource configuration for the deployment.
* @param {number} replicas - Number of replicas for the deployment.
* @param {string} image - Docker image for the deployment.
* @param {string} namespace - Kubernetes namespace for the deployment.
* @param {Array<object>} volumes - Volume configurations for the deployment.
* @param {Array<string>} cmd - Command to run in the deployment container.
* @param {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment.
* @param {boolean} pullBundle - Whether to pull the pre-built client bundle from Cloudinary before starting. Use together with skipFullBuild to skip the local build entirely.
* @param {string} [imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`). When omitted, defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
* @param {object} lifecycle - Kubernetes lifecycle hooks configuration for the deployment container.
* @param {object} readinessProbe - Kubernetes readiness probe configuration for the deployment container.
* @param {object} livenessProbe - Kubernetes liveness probe configuration for the deployment container.
* @param {object} startupProbe - Kubernetes startup probe configuration for the deployment container.
* @param {number} containerPort - Container port to expose for the deployment.
* @param {string} [nodeName] - Kubernetes node hostname that the workload must run on.
* @returns {string} - YAML deployment configuration for the specified deployment.
* @memberof UnderpostDeploy
*/
deploymentYamlPartsFactory({
deployId,
env,
suffix,
resources,
replicas,
image,
namespace,
volumes,
cmd,
skipFullBuild,
pullBundle,
imagePullPolicy,
// K8S lifecycle + probe wiring. Pass-through structures shaped like the
// upstream Kubernetes API, spliced verbatim into the container spec.
// lifecycle: { postStart: { exec: { command: [...] } }, preStop: { exec: { command: [...] } } }
// readinessProbe: { tcpSocket: { port: 8081 }, ... }
// livenessProbe: { tcpSocket: { port: 8081 }, ... }
// containerPort: integer; rendered as ports[0].containerPort. Optional.
lifecycle,
readinessProbe,
livenessProbe,
startupProbe,
containerPort,
nodeName,
// Explicit, secret-free internal status port injected as an env var so the
// in-pod endpoint binds exactly what the probes and the monitor target,
// independent of the ambient `PORT` baked into the image/secret.
internalStatusPort,
}) {
if (!readinessProbe) throw new Error(`Refusing to build ${deployId}-${env}-${suffix} without a readiness probe`);
if (!cmd)
cmd =
pullBundle || skipFullBuild
? [
// When pullBundle (or skipFullBuild) is set the container pulls the pre-built client
// bundle from Cloudinary (push-bundle must have been run on the dev machine beforehand).
`underpost secret underpost --create-from-env`,
`underpost start --build --run --pull-bundle --skip-full-build ${deployId} ${env}`,
]
: [
// `npm install -g npm@11.2.0`,
// `npm install -g underpost`,
`underpost secret underpost --create-from-env`,
`underpost start --build --run ${deployId} ${env}`,
];
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
if (!volumes) volumes = [];
const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
: [];
volumes = volumes.concat(confVolume);
// const containerImage = image ? image : `localhost/rockylinux9-underpost:v${packageJson.version}`;
const containerImage = image ? image : `underpost/underpost-engine:v${packageJson.version}`;
return `apiVersion: apps/v1
kind: Deployment
metadata:
name: ${deployId}-${env}-${suffix}
namespace: ${namespace ? namespace : 'default'}
labels:
app: ${deployId}-${env}-${suffix}
deploy-id: ${deployId}-${env}
spec:
replicas: ${replicas}
selector:
matchLabels:
app: ${deployId}-${env}-${suffix}
template:
metadata:
labels:
app: ${deployId}-${env}-${suffix}
deploy-id: ${deployId}-${env}
spec:
${
nodeName
? ` nodeSelector:
kubernetes.io/hostname: ${nodeName}
`
: ''
} containers:
- name: ${deployId}-${env}-${suffix}
image: ${containerImage}
imagePullPolicy: ${imagePullPolicy ? imagePullPolicy : containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
envFrom:
- secretRef:
name: underpost-config
${
internalStatusPort
? ` env:
- name: UNDERPOST_INTERNAL_PORT
value: "${internalStatusPort}"
`
: ''
}${
containerPort
? ` ports:
- containerPort: ${containerPort}
`
: ''
}${
resources
? ` resources:
requests:
memory: "${resources.requests.memory}"
cpu: "${resources.requests.cpu}"
limits:
memory: "${resources.limits.memory}"
cpu: "${resources.limits.cpu}"`
: ''
}
command:
- /bin/sh
- -c
- >
${cmd.join(' &&\n ')}
${
readinessProbe
? ` readinessProbe:
${JSON.stringify(readinessProbe, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}${
livenessProbe
? ` livenessProbe:
${JSON.stringify(livenessProbe, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}${
startupProbe
? ` startupProbe:
${JSON.stringify(startupProbe, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}${
lifecycle
? ` lifecycle:
${JSON.stringify(lifecycle, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}
${
volumes.length > 0
? Underpost.deploy
.volumeFactory(volumes.map((v) => ((v.version = `${deployId}-${env}-${suffix}`), v)))
.render.split(`\n`)
.map((l) => ' ' + l)
.join(`\n`)
: ''
}
---
apiVersion: v1
kind: Service
metadata:
name: ${deployId}-${env}-${suffix}-service
namespace: ${namespace}
spec:
selector:
app: ${deployId}-${env}-${suffix}
ports:
{{ports}} type: LoadBalancer`;
},
/**
* Builds a manifest for a list of deployments.
* @param {string} deployList - List of deployment IDs to include in the manifest.
* @param {string} env - Environment for which the manifest is being built.
* @param {object} options - Options for the manifest build process.
* @param {string} options.replicas - Number of replicas for each deployment.
* @param {string} options.image - Docker image for the deployment.
* @param {string} options.namespace - Kubernetes namespace for the deployment (defaults to "default").
* @param {string} [options.versions] - Comma-separated list of versions to deploy.
* @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
* @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
* @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
* @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
* @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
* @param {boolean} [options.disableDeploymentProxy] - Whether to disable deployment proxy route generation.
* @param {string} [options.gatewayClass] - GatewayClass name baked into the generated `gateway.yaml`.
* @param {boolean} [options.disableHttp3] - Omit QUIC/HTTP3 listener config and the Alt-Svc advertisement from the Gateway API manifests.
* @param {number|string} [options.quicPort] - UDP port advertised for QUIC/HTTP3.
* @param {string} [options.traffic] - Comma-separated active traffic colour(s) used to select which versions receive traffic (e.g. "blue", "green").
* @param {boolean} [options.cert] - Whether to include cert-manager Certificate resources in secret.yaml (production only).
* @param {boolean} [options.selfSigned] - Whether to include TLS block in HTTPProxy using a pre-created self-signed secret. Enables HTTPS for development without cert-manager.
* @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory.
* @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; forwarded to deploymentYamlPartsFactory. Use together with skipFullBuild.
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
* @param {boolean} [options.disableRuntimeProbes] - Deprecated compatibility flag; readiness remains mandatory.
* @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes (migration path).
* @param {string} [options.node] - Explicit target node for hostPath PV nodeAffinity pinning; resolved through {@link UnderpostDeploy.resolveDeployNode} together with the cluster flags.
* @param {boolean} [options.kind] - Kind cluster context; affects the cluster-type node default when no explicit node is set.
* @param {boolean} [options.kubeadm] - Kubeadm cluster context; affects the cluster-type node default when no explicit node is set.
* @param {boolean} [options.k3s] - K3s cluster context; affects the cluster-type node default when no explicit node is set.
* @returns {Promise<void>} - Promise that resolves when the manifest is built.
* @memberof UnderpostDeploy
*/
async buildManifest(deployList, env, options) {
const replicas = options.replicas;
const image = options.image;
if (!options.namespace) options.namespace = 'default';
for (const _deployId of deployList.split(',')) {
const deployId = _deployId.trim();
if (!deployId) continue;
const confServer = loadReplicas(
deployId,
loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`),
);
const router = await Underpost.deploy.routerFactory(deployId, env);
const pathPortAssignmentData = await pathPortAssignmentFactory(deployId, router, confServer);
const { fromPort, toPort } = deployRangePortFactory(router);
const deploymentVersions = options.versions.split(',');
fs.mkdirSync(`./engine-private/conf/${deployId}/build/${env}`, { recursive: true });
if (env === 'development') fs.mkdirSync(`./manifests/deployment/${deployId}-${env}`, { recursive: true });
logger.info('port range', { deployId, fromPort, toPort });
// The internal status endpoint binds `fromPort - 1`: app instances bind
// the router range starting at `fromPort`, so this slot is always free
// inside the pod. It is injected into the pod env (UNDERPOST_INTERNAL_PORT)
// and used for both the probes and the monitor's port-forward target so
// all three agree regardless of the image's ambient PORT.
// Readiness is a hard promotion invariant. The legacy disable flag is
// intentionally ignored; workloads that cannot serve the internal HTTP
// endpoint can migrate with the explicit TCP probe mode.
const internalPort = fromPort - 1;
const probes = Underpost.deploy.runtimeProbesFactory({ port: internalPort, useHttp: !options.tcpProbes });
let deploymentYamlParts = '';
for (const deploymentVersion of deploymentVersions) {
deploymentYamlParts += `---
${Underpost.deploy
.deploymentYamlPartsFactory({
deployId,
env,
suffix: deploymentVersion,
replicas,
image,
namespace: options.namespace,
cmd: options.cmd ? options.cmd.split(',').map((c) => c.trim()) : undefined,
skipFullBuild: options.skipFullBuild,
pullBundle: options.pullBundle,
imagePullPolicy: options.imagePullPolicy,
// Workload placement belongs in the manifest submitted for the rollout.
// Patching it after promotion creates a second ReplicaSet and can leave the
// old live pod pending termination behind a replacement that is not Ready.
nodeName: options.node
? Underpost.deploy.resolveDeployNode({
node: options.node,
kind: options.kind,
kubeadm: options.kubeadm,
k3s: options.k3s,
env,
})
: '',
internalStatusPort: internalPort,
readinessProbe: probes.readinessProbe,
livenessProbe: probes.livenessProbe,
startupProbe: probes.startupProbe,
})
.replace('{{ports}}', buildKindPorts(fromPort, toPort))}
`;
}
fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/deployment.yaml`, deploymentYamlParts, 'utf8');
const builtTraffic = `${options.traffic || deploymentVersions[0] || 'blue'}`.split(',')[0].trim();
fs.writeFileSync(
`./engine-private/conf/${deployId}/build/${env}/traffic-service.yaml`,
Underpost.deploy.trafficServiceYamlFactory({
deployId,
env,
traffic: builtTraffic,
namespace: options.namespace,
fromPort,
toPort,
}),
'utf8',
);
Underpost.deploy.buildGrpcServiceManifest({
deployId,
env,
confServer,
namespace: options.namespace,
traffic: options.traffic && typeof options.traffic === 'string' ? options.traffic.split(',') : ['blue'],
});
const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
: [];
if (confVolume.length > 0) {
// Mirror deployVolume's data-node resolution so the generated manifest
// pins the PV to the same node that physically receives the volume data.
const pvDataNode = Underpost.deploy.resolveDeployNode({
node: options.node,
kind: options.kind,
kubeadm: options.kubeadm,
k3s: options.k3s,
env,
});
let volumeYaml = '';
for (const deploymentVersion of deploymentVersions) {
for (const volume of confVolume) {
if (!volume.claimName) continue;
const pvcId = `${volume.claimName}-${deployId}-${env}-${deploymentVersion}`;
const pvId = pvcId.replace(/^pvc-/, 'pv-');
const hostPath = `${HOST_VOLUME_ROOT}/${pvId}`;
volumeYaml += `---\n${Underpost.deploy.persistentVolumeFactory({
pvcId,
namespace: options.namespace,
hostPath,
nodeName: pvDataNode,
})}\n`;
}
}
fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/pv-pvc.yaml`, volumeYaml, 'utf8');
}
let proxyYaml = '';
let secretYaml = '';
let gatewayYaml = '';
let httpRouteYaml = '';
const customServices = fs.existsSync(`./engine-private/conf/${deployId}/conf.services.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.services.json`))
: [];
// PWA status pages are SSR views whose route is a bare status code; the
// client build writes each to `<path>/index.html` inside the served
// bundle, so the gateway rewrites to that artifact instead of carrying
// a copy of the document (which is what custom instances need).
const confSSRPath = `./engine-private/conf/${deployId}/conf.ssr.json`;
const confSSR = fs.existsSync(confSSRPath) ? JSON.parse(fs.readFileSync(confSSRPath, 'utf8')) : {};
const { altSvc, http3, gatewayClassName } = Underpost.deploy.gatewayApiConfigFactory(options);
// Node directory backing the static utility's volume; documents are
// placed here rather than in the cluster's object store, which is what
// lifts the size ceiling entirely.
const staticHostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
// Contexts routed to the gateway. Status pages are absent by design: they
// are reached by interception only, never as a destination.
const edgeRouteRecords = [];
// Per-host proxy routes contributed to the shared gateway's own config.
const gatewayRoutesByHost = {};
// Every host attaches to one deploy-scoped Gateway. Each hostname gets
// distinct HTTP/HTTPS listeners: mergeGateways combines every Gateway
// in the class, and (port, protocol, hostname) must remain unique across
// that complete set.
const gatewayName = Underpost.deploy.gatewayNameFactory({ deployId, env });
const trafficServiceName = Underpost.deploy.trafficServiceNameFactory({ deployId, env });
const gatewayHosts = [];
for (const host of Object.keys(confServer)) {
if (env === 'production' && options.cert === true)
secretYaml += Underpost.deploy.buildCertManagerCertificate({ host, namespace: options.namespace });
const pathPortAssignment = pathPortAssignmentData[host];
// logger.info('', { host, pathPortAssignment });
let _proxyYaml = Underpost.deploy.baseProxyYamlFactory({ host, env, options });
// The live colour is a cluster fact, and a build must not need one: with
// no `--traffic` the routes carry every colour this build emits, the
// first at full weight. `switchTraffic` passes the promoted colour
// explicitly, so a real promotion still pins exactly one.
const deploymentVersions = `${options.traffic || options.versions || 'blue,green'}`.split(',');
let proxyRoutes = '';
const globalTimeoutPolicy =
(options.timeoutResponse && options.timeoutResponse !== '') ||
(options.timeoutIdle && options.timeoutIdle !== '')
? {
response: options.timeoutResponse,
idle: options.timeoutIdle,
}
: undefined;
const globalRetryPolicy =
options.retryCount ||
options.retryCount === 0 ||
(options.retryPerTryTimeout && options.retryPerTryTimeout !== '')
? {
count: options.retryCount,
perTryTimeout: options.retryPerTryTimeout,
}
: undefined;
let routeRules = '';
if (!options.disableDeploymentProxy)
for (const conditionObj of pathPortAssignment) {
const { path, port } = conditionObj;
proxyRoutes += Underpost.deploy.deploymentYamlServiceFactory({
path,
port,
serviceId: trafficServiceName,
timeoutPolicy: globalTimeoutPolicy,
retryPolicy: globalRetryPolicy,
});
// Intercepted contexts get a route of their own — `/offline` and
// `/maintenance` are addresses a client asks for, and the service
// worker precaches them by URL.
//
// A status page gets none. It is reached only by interception, so
// the client's URI is always the one it requested: a route for
// `/404` would make the status page a destination, and any hop to
// it — a rewrite, or a runtime that redirects its own 404s — is a
// URI the client did not ask for. `/invalid-path` must answer 404
// with the configured document while staying `/invalid-path`, which
// only `proxy_intercept_errors` in the gateway can do.
const edgeRoutes = Underpost.deploy.edgeRouteEntriesFactory({ confServer, confSSR, host, path });
const interceptStatuses = Object.keys(interceptStatusesFactory(edgeRoutes));
for (const edgeRoute of edgeRoutes.filter((route) => !route.status)) {
routeRules += Underpost.deploy.httpRouteRuleFactory({
path: edgeRoute.routePath,
// Onto the directory, not the document: one rule then covers
// the page and any asset beside it, which is exactly what the
// static utility's `try_files $uri $uri/index.html` resolves.
replacePrefixMatch: edgeRoute.dir,
serviceId: UNDERPOST_GATEWAY.serviceName,
port: UNDERPOST_GATEWAY.port,
timeoutPolicy: globalTimeoutPolicy,
retryPolicy: globalRetryPolicy,
altSvc: http3 ? altSvc : undefined,
});
edgeRouteRecords.push({
host,
path: edgeRoute.routePath,
kind: edgeRoute.kind,
servedBy: UNDERPOST_GATEWAY.serviceName,
rewrite: edgeRoute.dir,
assetPath: edgeRoute.assetPath,
});
}
// The site path goes through the shared gateway, which proxies it
// to this workload and swaps in a status document when the
// workload errors or is gone. The API path is routed straight to
// the workload instead: its errors are its own contract, and a
// client parsing JSON must not receive an HTML page.
const intercepted = interceptStatuses.length > 0;
if (intercepted && apiPathFactory({ confServer, host, path }))
routeRules += Underpost.deploy.httpRouteRuleFactory({
path: apiPathFactory({ confServer, host, path }),
port,
serviceId: trafficServiceName,
timeoutPolicy: globalTimeoutPolicy,
retryPolicy: globalRetryPolicy,
altSvc: http3 ? altSvc : undefined,
});
routeRules += Underpost.deploy.httpRouteRuleFactory({
path,
...(intercepted
? { serviceId: UNDERPOST_GATEWAY.serviceName, port: UNDERPOST_GATEWAY.port }
: { serviceId: trafficServiceName, port }),
timeoutPolicy: globalTimeoutPolicy,
retryPolicy: globalRetryPolicy,
altSvc: http3 ? altSvc : undefined,
});
gatewayRoutesByHost[host] = (gatewayRoutesByHost[host] || []).concat(
intercepted
? {
path,
upstream: `${trafficServiceName}:${port}`,
statuses: interceptStatusesFactory(
Underpost.deploy.edgeRouteEntriesFactory({ confServer, confSSR, host, path }),
),
}
: [],
);
}
for (const customService of customServices) {
const {
path: _path,
port,
serviceId,
host: _host,
pathRewritePolicy,
timeoutPolicy: _timeoutPolicy,
retryPolicy: _retryPolicy,
} = customService;
if (host === _host) {
proxyRoutes += Underpost.deploy.deploymentYamlServiceFactory({
path: _path,
port,
serviceId,
deploymentVersions,
pathRewritePolicy,
timeoutPolicy: _timeoutPolicy ? _timeoutPolicy : globalTimeoutPolicy,
retryPolicy: _retryPolicy ? _retryPolicy : globalRetryPolicy,
});
routeRules += Underpost.deploy.httpRouteRuleFactory({
path: _path,
port,
serviceId,
deploymentVersions,
pathRewritePolicy,
timeoutPolicy: _timeoutPolicy ? _timeoutPolicy : globalTimeoutPolicy,
retryPolicy: _retryPolicy ? _retryPolicy : globalRetryPolicy,
altSvc: http3 ? altSvc : undefined,
});
}
}
if (proxyRoutes) proxyYaml += _proxyYaml + proxyRoutes;
if (routeRules) {
gatewayHosts.push(host);
httpRouteYaml += Underpost.deploy.httpRouteYamlFactory({
host,
options,
rules: routeRules,
parentName: gatewayName,
});
}
}
if (gatewayHosts.length > 0) {
// Instance hostnames belong on this Gateway's certificate list even
// though their routes are applied later by `instance-promote`: one
// Gateway terminates every hostname the deploy serves, and a hostname
// with no certificate listener here has no TLS filter chain to reach.
// The Gateway remains deploy-scoped, while its listeners are scoped by
// hostname so they can coexist with the other merged Gateways.
const allGatewayHosts = [
...new Set([...gatewayHosts, ...deployHostsFactory(deployId).filter((host) => !confServer[host])]),
].sort();
gatewayYaml += Underpost.deploy.gatewayYamlFactory({
name: gatewayName,
hosts: allGatewayHosts,
env,
options,
});
gatewayYaml += Underpost.deploy.clientTrafficPolicyYamlFactory({
name: gatewayName,
sectionNames: allGatewayHosts.map((host) =>
Underpost.deploy.gatewayListenerNameFactory({ protocol: 'https', host }),
),
env,
options,
});
}
// The shared gateway proxies the intercepted paths, so its own config is
// part of this build — written as an artifact beside the manifests and
// nothing more. Installing it into the live workload and reloading it is
// the apply path's job: a build must work with no cluster running.
for (const [gatewayHost, routes] of Object.entries(gatewayRoutesByHost))
writeHostServerConf({
confDir: Underpost.deploy.gatewayConfDirFactory({ deployId, env }),
host: gatewayHost,
conf: hostServerConfFactory({
host: gatewayHost,
routes,
namespace: options.namespace || 'default',
}),
});
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/proxy.yaml`;
fs.writeFileSync(yamlPath, proxyYaml, 'utf8');
const buildPath = `./engine-private/conf/${deployId}/build/${env}`;
for (const [name, content] of Object.entries({
'gateway.yaml': gatewayYaml,
'httproute.yaml': httpRouteYaml,
}))
Underpost.deploy.writeManifest({ filePath: `${buildPath}/${name}`, content });
logger.info('Gateway API manifests written', {
deployId,
env,
gatewayClass: gatewayClassName,
http3,
altSvc: http3 ? altSvc : null,
edgeRoutes: edgeRouteRecords,
});
if (env === 'production') {
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
fs.writeFileSync(yamlPath, secretYaml, 'utf8');
} else {
const deploymentsFiles = [
'Dockerfile',
'proxy.yaml',
'gateway.yaml',
'httproute.yaml',
'deployment.yaml',
'traffic-service.yaml',
'pv-pvc.yaml',
'grpc-service.yaml',
];
for (const file of deploymentsFiles) {
const source = `./engine-private/conf/${deployId}/build/${env}/${file}`;
const target = `./manifests/deployment/${deployId}-${env}/${file}`;
// Mirror absence as well as presence: a file this build no longer
// produces must not survive here from an earlier one.
if (fs.existsSync(source)) fs.copyFileSync(source, target);
else fs.removeSync(target);
}
}
}
},
/**
* Builds and writes a gRPC ClusterIP service YAML for a deployment.
* Scans conf.server.json for gRPC ports and emits grpc-service.yaml under
* `engine-private/conf/<deployId>/build/<env>/`. The selector always uses the
* explicit `app: <deployId>-<env>-<traffic>` label to target only the active
* colour (blue or green).
* @param {string} deployId - Deployment ID.
* @param {string} env - Environment ('development' or 'production').
* @param {object} confServer - Parsed conf.server.json content.
* @param {string} [namespace='default'] - Kubernetes namespace.
* @param {string[]} [traffic=['blue']] - Active traffic colour(s) ('blue', 'green', or both).
* @param {string|null} [host=null] - Specific host to scan for gRPC ports. If null, all hosts are scanned.
* @returns {string|null} - Path to the written YAML file, or null if no gRPC ports found.
* @memberof UnderpostDeploy
*/
buildGrpcServiceManifest({ deployId, env, confServer, namespace = 'default', traffic = ['blue'], host = null }) {
const grpcPorts = new Set();
const hostsToScan = host ? [host] : Object.keys(confServer);
for (const h of hostsToScan) {
if (!confServer[h]) continue;
for (const path of Object.keys(confServer[h])) {
const grpc = confServer[h][path].grpc;
if (grpc && grpc.port) grpcPorts.add(parseInt(grpc.port));
}
}
if (grpcPorts.size === 0) return null;
const grpcPortsList = [...grpcPorts]
.map(
(port) => ` - name: grpc-${port}
protocol: TCP
port: ${port}
targetPort: ${port}`,
)
.join('\n');
let grpcServiceYaml = '';
for (const color of traffic) {
const grpcServiceName = `${deployId}-grpc-service-${env}-${color}`;
const selectorYaml = `app: ${deployId}-${env}-${color}`;
grpcServiceYaml += `---
apiVersion: v1
kind: Service
metadata:
name: ${grpcServiceName}
namespace: ${namespace}
labels:
app: ${grpcServiceName}
spec:
type: ClusterIP
selector:
${selectorYaml}
ports:
${grpcPortsList}
`;
logger.info(
`gRPC ClusterIP service YAML written: ${grpcServiceName} (selector: ${selectorYaml}, ports: ${[...grpcPorts].join(', ')})`,
);
}
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
fs.writeFileSync(yamlPath, grpcServiceYaml, 'utf8');
return yamlPath;
},
/**
* Builds a Certificate resource for a host using cert-manager.
* @param {string} host - Hostname for which the certificate is being built.
* @param {string} namespace - Kubernetes namespace for the certificate.
* @returns {string} - Certificate resource YAML for the specified host.
* @memberof UnderpostDeploy
*/
buildCertManagerCertificate({ host, namespace }) {
return `
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: ${host}
namespace: ${namespace}
spec:
commonName: ${host}
dnsNames:
- ${host}
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
secretName: ${host}`;
},
/**
* Retrieves the current traffic status for a deployment.
* @param {string} deployId - Deployment ID for which the traffic status is being retrieved.
* @param {object} options - Options for the traffic retrieval.
* @param {string} options.hostTest - Hostname to test for traffic status.
* @param {string} options.namespace - Kubernetes namespace for the deployment.
* @param {boolean} [options.gatewayApi] - Force the Gateway API stack; on by default unless `disableGatewayApi` is set.
* @param {boolean} [options.disableGatewayApi] - Read the colour from the Contour HTTPProxy instead of the Gateway API HTTPRoute.
* @param {string} [options.underpostGatewayRoot] - Node directory backing the gateway volume, where an intercepted host's colour lives.
* @returns {string|null} - Current traffic status ('blue' or 'green') or null if not found.
* @memberof UnderpostDeploy
*/
getCurrentTraffic(deployId, options = { hostTest: '', namespace: '', env: '' }) {
if (!options.namespace) options.namespace = 'default';
// The stable Service selector is the blue/green authority. Routes and the
// fallback gateway deliberately contain no colour after migration, so
// reading them first would make a healthy deployment appear unrouted.
for (const env of options.env ? [options.env] : ['production', 'development']) {
const service = Underpost.deploy.trafficServiceNameFactory({ deployId, env });
const selector = shellExec(
`kubectl get service ${service} -n ${options.namespace} -o jsonpath='{.spec.selector.app}'`,
{
stdout: true,
silent: true,
silentOnError: true,
},
);
const traffic = trafficFromRoutingInfoFactory({ info: `${selector}`, deployId, env });
if (traffic) return traffic;
}
const hostTest = options?.hostTest
? options.hostTest
: Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`))[0];
return trafficFromRoutingInfoFactory({
info: Underpost.deploy.readHostRoutingInfo({ host: hostTest, options }),
deployId,
env: options.env,
});
},
/**
* All the routing text that can carry a host's traffic colour.
*
* Two sources, because one is not enough on its own: the route object names
* the colour for a host routed straight at its workload, and the Nginx server
* block names it for a host whose errors the gateway intercepts — those are
* routed at `underpost-gateway-service`, so their colour appears nowhere in
* the route object. Reading both means the colour resolves the same way
* whichever stack is live and whether or not the host is intercepted.
*
* Split out of {@link UnderpostDeploy.getCurrentTraffic} so a report covering
* many deployments and environments can read each host once instead of once
* per row.
*
* The stack the flags select is read first, and the other one is read when
* that finds nothing. Which kind describes a host is a property of the
* cluster, not of the invocation: a command run without `--disable-gateway-api`
* against a Contour-routed host would otherwise report it as having no colour
* at all, and silently act on that — stopping the wrong half of a blue/green
* pair, or reporting a live host as unrouted. When neither kind exists the
* host genuinely has no route and the caller proceeds with the Nginx block
* alone.
* @param {string} host - Hostname whose routing is read.
* @param {object} [options] - Options carrying namespace, gateway stack and gateway root.
* @returns {string} Route object YAML and Nginx block, concatenated; empty when neither exists.
* @memberof UnderpostDeploy
*/
readHostRoutingInfo({ host, options = {} }) {
const namespace = options.namespace || 'default';
// A missing route object is the canonical "no traffic colour set yet"
// state for blue/green rollouts. silentOnError swallows kubectl's NotFound
// exit so this returns empty text rather than throwing. The `kind:` check
// is what distinguishes a real object from anything kubectl printed while
// failing — an empty answer has to mean "absent", or the fallback below
// would never run.
const readRouteObject = (kind) => {
const out = shellExec(`sudo kubectl get ${kind}/${host} -n ${namespace} -o yaml`, {
silent: true,
stdout: true,
silentOnError: true,
});
return `${out || ''}`.includes(`kind: ${kind}`) ? `${out}` : '';
};
const preferred = gatewayApiEnabledFactory(options) ? 'HTTPRoute' : 'HTTPProxy';
const fallback = preferred === 'HTTPRoute' ? 'HTTPProxy' : 'HTTPRoute';
let routeInfo = readRouteObject(preferred);
if (!routeInfo) {
routeInfo = readRouteObject(fallback);
if (routeInfo)
logger.warn('Host is routed by the other stack than the flags select; reading it instead', {
host,
selected: preferred,
found: fallback,
namespace,
});
}
const gatewayConfPath = nodePath.join(
Underpost.deploy.underpostGatewayRootFactory(options),
UNDERPOST_GATEWAY.confDir,
`${host}.conf`,
);
const gatewayInfo = fs.existsSync(gatewayConfPath) ? fs.readFileSync(gatewayConfPath, 'utf8') : '';
return `${routeInfo || ''}\n${gatewayInfo}`;
},
/**
* Creates a base YAML configuration for an HTTPProxy resource.
* @param {string} host - Hostname for which the HTTPProxy is being created.
* @param {string} env - Environment for which the HTTPProxy is being created.
* @param {object} options - Options for the HTTPProxy creation.
* @param {string} options.namespace - Kubernetes namespace for the HTTPProxy.
* @returns {string} - Base YAML configuration for the HTTPProxy resource.
* @memberof UnderpostDeploy
*/
baseProxyYamlFactory({ host, env, options }) {
const includeTls = env !== 'development' || options.selfSigned === true;
return `
---
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: ${host}
namespace: ${options.namespace}
spec:
virtualhost:
fqdn: ${host}${
includeTls
? `
tls:
secretName: ${host}`
: ''
}
routes:`;
},
/**
* Resolves the Gateway API transport settings shared by every generated
* Gateway and HTTPRoute: the gateway class, whether QUIC/HTTP3 is enabled,
* the UDP port QUIC is served on, and the `Alt-Svc` value that advertises
* it. Single source of truth — no other factory reads these options.
* @param {object} [options] - Deploy/run options.
* @param {string} [options.gatewayClass] - GatewayClass name (env: UNDERPOST_GATEWAY_CLASS, default `contour`).
* @param {boolean} [options.disableHttp3] - Disables QUIC/HTTP3 listener config and `Alt-Svc` advertisement.
* @param {number|string} [options.quicPort] - UDP port QUIC is served on (env: UNDERPOST_QUIC_PORT, default 443).
* @param {number|string} [options.altSvcMaxAge] - `Alt-Svc` max-age in seconds (default 86400).
* @returns {{ gatewayClassName: string, http3: boolean, quicPort: number, altSvc: string }} Resolved config.
* @memberof UnderpostDeploy
*/
gatewayApiConfigFactory(options = {}) {
const quicPort = parseInt(options.quicPort || process.env.UNDERPOST_QUIC_PORT || 443, 10);
return {
gatewayClassName: options.gatewayClass || process.env.UNDERPOST_GATEWAY_CLASS || GATEWAY_CLASS_DEFAULT,
http3: options.disableHttp3 !== true,
quicPort,
altSvc: `h3=":${quicPort}"; ma=${parseInt(options.altSvcMaxAge || 86400, 10)}`,
};
},
/**
* Creates the cluster-scoped Gateway API provisioning objects: the
* GatewayClass every generated Gateway attaches to, and the EnvoyProxy that
* decides how the data plane is reachable from outside the cluster.
*
* Exposure differs by environment because the access path does:
* - development — the node *is* the operator's machine, so Envoy binds the
* listener ports on the host network. With the `/etc/hosts` entries the
* `cluster` runner writes, `https://<host>` resolves to 127.0.0.1 and
* reaches the gateway directly, and QUIC gets UDP/443 for free.
* - production — NodePort, mirroring the ports the Contour envoy service
* already publishes (`manifests/envoy-service-nodeport.yaml`).
*
* `sharedIngress` overrides the development binding: with Contour also
* installed the node's 80/443 belong to the shared edge, and a data plane on
* the host network would be competing for the port it is meant to sit behind.
* The listener ports are unchanged — only where they are published moves.
* @param {string} env - `development` | `production`.
* @param {object} [options] - Deploy/run options (gateway class override, shared edge).
* @returns {string} GatewayClass + EnvoyProxy YAML.
* @memberof UnderpostDeploy
*/
gatewayClassYamlFactory({ env, options = {} }) {
const { gatewayClassName } = Underpost.deploy.gatewayApiConfigFactory(options);
const namespace = 'envoy-gateway-system';
const hostBound = env === 'development' && options.sharedIngress !== true;
// `hostNetwork` is not a field of EnvoyProxy's KubernetesPodSpec — the
// deployment `patch` (StrategicMerge) is the supported way to set plain
// PodSpec fields. `dnsPolicy` must move with it: on the host network the
// pod would otherwise inherit the node's resolv.conf and lose the cluster
// DNS it needs to reach the xDS control plane.
//
// `useListenerPortAsContainerPort: true` stops Envoy Gateway remapping
// privileged ports into the ephemeral range, which is what puts 80/443 on
// the host — and by its own contract requires CAP_NET_BIND_SERVICE. The
// development profile also runs Envoy as root: ambient capabilities are
// not expressible in a Kubernetes securityContext, so a non-root process
// cannot reliably hold that capability. Production keeps the hardened
// upstream defaults and is reached through NodePort instead.
const developmentProvider = `
useListenerPortAsContainerPort: true
envoyDeployment:
patch:
type: StrategicMerge
value:
spec:
template:
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
container:
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
envoyService:
type: ClusterIP`;
const productionProvider = `
useListenerPortAsContainerPort: false
envoyService:
type: NodePort`;
// Behind the shared edge the data plane is reached by ClusterIP, so it
// needs neither the host network nor a node port — it is an ordinary
// upstream, and publishing it anywhere else would only re-create the
// contention the edge exists to remove.
const sharedProvider = `
useListenerPortAsContainerPort: false
envoyService:
type: ClusterIP`;
return `
---
apiVersion: ${GATEWAY_EXTENSION_GROUP_VERSION}
kind: EnvoyProxy
metadata:
name: ${gatewayClassName}-proxy-config
namespace: ${namespace}
spec:
# A deploy's hosts share one Gateway, but a cluster can hold more than one
# deploy — and each Gateway would otherwise be provisioned its own data plane,
# every one contending for the same node ports, so at most one could bind and
# the rest would crash-loop. Merging collapses them onto a single Envoy fleet.
# Every generated listener is hostname-scoped because the merge key is
# (port, protocol, hostname). This lets many deploy-scoped Gateways coexist
# in this fleet without an older hostname-less listener shadowing the rest.
mergeGateways: true
provider:
type: Kubernetes
kubernetes:${options.sharedIngress === true ? sharedProvider : hostBound ? developmentProvider : productionProvider}
---
apiVersion: ${GATEWAY_API_GROUP_VERSION}
kind: GatewayClass
metadata:
name: ${gatewayClassName}
spec:
controllerName: ${GATEWAY_CONTROLLER_NAME}
parametersRef:
group: ${GATEWAY_EXTENSION_GROUP}
kind: EnvoyProxy
name: ${gatewayClassName}-proxy-config
namespace: ${namespace}
`;
},
/**
* Provisions the self-signed TLS secret a host is served with when
* cert-manager is not in play (development, and `instance-promote --tls
* --test`). `scripts/ssl.sh` generates the pair through mkcert — which also
* installs its root CA into the system and NSS trust stores, so the browser
* trusts the certificate without a warning — and falls back to OpenSSL.
*
* The secret is deleted before being recreated so a re-run always ends with
* the key pair on disk, and is named after the host because that is the
* `secretName` both the HTTPProxy virtualhost and the Gateway listener
* reference.
* @param {string} host - Hostname to issue the certificate for.
* @param {string} [namespace] - Kubernetes namespace.
* @param {string} [underpostRoot] - Repo root holding `scripts/ssl.sh`.
* @returns {{ sslDir: string, certPath: string, keyPath: string }} Generated artifact paths.
* @memberof UnderpostDeploy
*/
selfSignedTlsSecretFactory({ host, namespace = 'default', underpostRoot = '.' }) {
const sslDir = `./engine-private/ssl/${host}`;
const nameSafe = host.replace(/[^a-zA-Z0-9_.-]/g, '_');
const certPath = `${sslDir}/${nameSafe}.pem`;
const keyPath = `${sslDir}/${nameSafe}-key.pem`;
fs.mkdirpSync(sslDir);
shellExec(`bash ${underpostRoot}/scripts/ssl.sh "${sslDir}" "${host}"`);
shellExec(`kubectl delete secret ${host} -n ${namespace} --ignore-not-found`);
shellExec(`kubectl create secret tls ${host} --cert="${certPath}" --key="${keyPath}" -n ${namespace}`);
logger.info('Self-signed TLS secret created', { host, namespace, certPath, keyPath });
return { sslDir, certPath, keyPath };
},
/**
* Creates the one Gateway a deploy's HTTPRoutes attach to.
*
* Envoy Gateway's `mergeGateways` mode merges every Gateway of the class
* into one data plane. Its required uniqueness key is (port, protocol,
* hostname), so every host needs its own listener pair. Hostname-less
* listeners from separate deploy Gateways conflict: the older listener is
* served and newer hosts receive its certificate and 404 route table.
*
* The HTTPS listener is emitted under the same TLS rules as the HTTPProxy
* virtualhost (production, or development with `--self-signed`); QUIC is
* only wired when that listener exists, since HTTP/3 has no cleartext
* transport.
* @param {string} name - Gateway name, from {@link UnderpostDeploy.gatewayNameFactory}.
* @param {Array<string>} hosts - Every hostname the deploy terminates; each becomes a certificate ref.
* @param {string} env - `development` | `production`.
* @param {object} [options] - Deploy/run options (namespace, gateway/QUIC settings, selfSigned).
* @returns {string} Gateway YAML.
* @memberof UnderpostDeploy
*/
gatewayYamlFactory({ hosts = [], name, env, options = {} }) {
const namespace = options.namespace || 'default';
const { gatewayClassName } = Underpost.deploy.gatewayApiConfigFactory(options);
const includeTls = env !== 'development' || options.selfSigned === true;
const uniqueHosts = [...new Set(hosts.filter(Boolean))].sort();
const allowedRoutes = ` allowedRoutes:
namespaces:
from: Same`;
const listeners = uniqueHosts
.flatMap((host) => {
const http = ` - name: ${Underpost.deploy.gatewayListenerNameFactory({ protocol: 'http', host })}
hostname: ${JSON.stringify(host)}
protocol: HTTP
port: 80
${allowedRoutes}`;
if (!includeTls) return [http];
return [
http,
` - name: ${Underpost.deploy.gatewayListenerNameFactory({ protocol: 'https', host })}
hostname: ${JSON.stringify(host)}
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
name: ${host}
${allowedRoutes}`,
];
})
.join('\n');
return `
---
apiVersion: ${GATEWAY_API_GROUP_VERSION}
kind: Gateway
metadata:
name: ${name}
namespace: ${namespace}
spec:
gatewayClassName: ${gatewayClassName}
listeners:
${listeners}
`;
},
/**
* Produces a stable DNS-label listener name for a hostname and protocol.
* The content hash prevents two hosts that normalize to the same label from
* colliding, while truncation keeps the Gateway API SectionName at 63 chars.
* @param {object} input - Listener identity.
* @param {string} input.protocol - `http` or `https`.
* @param {string} input.host - Listener hostname.
* @returns {string} Stable Kubernetes DNS label.
* @memberof UnderpostDeploy
*/
gatewayListenerNameFactory({ protocol, host }) {
const prefix = `${protocol || 'http'}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const normalized =
`${host || 'host'}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'host';
const hash = crypto
.createHash('sha1')
.update(`${host || ''}`)
.digest('hex')
.slice(0, 8);
const hostLength = 63 - prefix.length - hash.length - 2;
return `${prefix}-${normalized.slice(0, hostLength).replace(/-+$/g, '')}-${hash}`;
},
/**
* Name of the Gateway a deploy's hosts share. The Gateway is consolidated
* per deploy, while its listener pairs remain hostname-scoped so merged
* Gateways have distinct traffic selectors.
* @param {string} deployId - Deploy id.
* @param {string} env - `development` | `production`.
* @returns {string} Gateway name.
* @memberof UnderpostDeploy
*/
gatewayNameFactory({ deployId, env }) {
return `${deployId}-${env}`;
},
/**
* Writes a generated manifest, or removes it when there is nothing to
* write. An empty file is not an empty set for `kubectl apply` — it fails
* with "no objects passed to apply" — so a deploy that declares no objects
* of a kind must leave no file behind, including one a previous build wrote.
* @param {string} filePath - Destination path.
* @param {string} content - Rendered YAML; blank removes the file.
* @returns {boolean} True when a file was written.
* @memberof UnderpostDeploy
*/
writeManifest({ filePath, content }) {
if (content && content.trim()) {
fs.writeFileSync(filePath, content, 'utf8');
return true;
}
fs.removeSync(filePath);
return false;
},
/**
* Creates the QUIC/HTTP3 ClientTrafficPolicy for the merged data plane.
*
* Emitted once per Gateway with one targetRef for each hostname-scoped HTTPS
* listener. A single policy object avoids same-scope policy competition,
* while section-specific targets enable QUIC on every distinct listener.
* @param {string} name - Gateway the policy attaches to, from {@link UnderpostDeploy.gatewayNameFactory}.
* @param {string} [sectionName] - Backward-compatible single listener target.
* @param {Array<string>} [sectionNames] - HTTPS listeners the policy targets.
* @param {string} env - `development` | `production`.
* @param {object} [options] - Deploy/run options (namespace, QUIC settings).
* @returns {string} ClientTrafficPolicy YAML, or an empty string when HTTP/3 has no TLS transport.
* @memberof UnderpostDeploy
*/
clientTrafficPolicyYamlFactory({ name, sectionName, sectionNames = [], env, options = {} }) {
const namespace = options.namespace || 'default';
const { http3 } = Underpost.deploy.gatewayApiConfigFactory(options);
const includeTls = env !== 'development' || options.selfSigned === true;
if (!includeTls || !http3) return '';
const targets = [...new Set([...sectionNames, sectionName].filter(Boolean))];
if (targets.length === 0) return '';
// Scoped to the HTTPS section, not the whole Gateway. QUIC only concerns
// the TLS listener, and an unscoped policy is applied to every listener of
// the merged set — including the plain-HTTP ones, which the implementation
// rejects outright ("applied to multiple http (non https) listeners on the
// same port"), leaving HTTP/3 silently off.
//
return `
---
apiVersion: ${GATEWAY_EXTENSION_GROUP_VERSION}
kind: ClientTrafficPolicy
metadata:
name: ${name}-http3
namespace: ${namespace}
spec:
targetRefs:
${targets
.map(
(target) => ` - group: ${GATEWAY_API_GROUP}
kind: Gateway
name: ${name}
sectionName: ${target}`,
)
.join('\n')}
http3: {}
`;
},
/**
* Renders one HTTPRoute rule. Websockets need no opt-in here (unlike the
* HTTPProxy `enableWebsockets` flag) — Gateway API forwards the upgrade by
* default. A rule carrying `extensionRef` short-circuits at the gateway and
* therefore emits no backendRefs.
*
* The HTTPProxy `timeoutPolicy.idle` has no Gateway API rule-level
* equivalent (idle timeouts are listener/backend scoped) and is dropped.
* @param {string} path - Match value.
* @param {string} [matchType] - `PathPrefix` (default) | `Exact`.
* @param {number} [port] - Backend service port.
* @param {string} [deployId] - Deployment id used to derive the service name.
* @param {string} [env] - Environment used to derive the service name.
* @param {Array<string>} [deploymentVersions] - Traffic colours; the first carries all weight.
* @param {string} [serviceId] - Explicit backend service name (overrides the derived one).
* @param {Array<object>} [pathRewritePolicy] - HTTPProxy-shaped prefix rewrite, mapped to ReplacePrefixMatch.
* @param {string} [replaceFullPath] - Rewrites the request to a fixed path (a single static document).
* @param {string} [replacePrefixMatch] - Rewrites the matched prefix onto a static directory, so the
* document and everything beside it resolve through one rule.
* @param {object} [extensionRef] - `{ group, kind, name }` of a direct-response filter.
* @param {object} [timeoutPolicy] - `{ response }` mapped to Gateway API timeouts.
* @param {object} [retryPolicy] - `{ count, perTryTimeout }` mapped to retry.attempts / timeouts.backendRequest.
* @param {string} [altSvc] - `Alt-Svc` value advertising the QUIC endpoint.
* @returns {string} Rule YAML (indented for `spec.rules`).
* @memberof UnderpostDeploy
*/
httpRouteRuleFactory({
path,
matchType = 'PathPrefix',
port,
deployId,
env,
deploymentVersions = ['blue'],
serviceId,
pathRewritePolicy,
replaceFullPath,
replacePrefixMatch,
extensionRef,
timeoutPolicy,
retryPolicy,
altSvc,
}) {
const lines = [
` - matches:`,
` - path:`,
` type: ${matchType}`,
` value: ${path}`,
];
const filters = [];
const prefixRewrite =
replacePrefixMatch ?? (pathRewritePolicy?.length ? pathRewritePolicy[0].replacement : undefined);
if (replaceFullPath || prefixRewrite) {
filters.push(` - type: URLRewrite`, ` urlRewrite:`, ` path:`);
if (replaceFullPath)
filters.push(` type: ReplaceFullPath`, ` replaceFullPath: ${replaceFullPath}`);
else
filters.push(` type: ReplacePrefixMatch`, ` replacePrefixMatch: ${prefixRewrite}`);
}
if (extensionRef)
filters.push(
` - type: ExtensionRef`,
` extensionRef:`,
` group: ${extensionRef.group}`,
` kind: ${extensionRef.kind}`,
` name: ${extensionRef.name}`,
);
if (altSvc)
filters.push(
` - type: ResponseHeaderModifier`,
` responseHeaderModifier:`,
` set:`,
` - name: Alt-Svc`,
` value: '${altSvc}'`,
);
if (filters.length > 0) lines.push(` filters:`, ...filters);
const timeouts = [];
const request = gatewayDurationFactory(timeoutPolicy?.response);
const backendRequest = gatewayDurationFactory(retryPolicy?.perTryTimeout ?? timeoutPolicy?.response);
if (request) timeouts.push(` request: ${request}`);
if (backendRequest) timeouts.push(` backendRequest: ${backendRequest}`);
if (timeouts.length > 0) lines.push(` timeouts:`, ...timeouts);
const attempts = parseInt(retryPolicy?.count, 10);
if (!isNaN(attempts)) lines.push(` retry:`, ` attempts: ${attempts}`);
// A backend is declared even for a rule the direct-response filter
// short-circuits: the filter answers before the backend is ever dialled,
// but a rule that resolves to nothing at all risks the whole route being
// rejected — which takes every other path on that hostname down with it,
// and shows up only as a bare 404 from the gateway.
if (port !== undefined && (serviceId || deployId)) {
lines.push(` backendRefs:`);
for (const [i, version] of (serviceId ? [null] : deploymentVersions).entries())
lines.push(
` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}`,
` port: ${port}`,
` weight: ${i === 0 ? 100 : 0}`,
);
}
return `${lines.join('\n')}\n`;
},
/**
* Wraps rendered rules in an HTTPRoute attached to the host Gateway. The
* object is named after the host, exactly like the HTTPProxy it mirrors, so
* an apply of a per-instance fragment replaces the host route set the same
* way — the complete multi-instance set is assembled by `instance-promote`.
* @param {string} host - Hostname (Gateway name and route hostname).
* @param {object} options - Deploy/run options (namespace).
* @param {string} rules - Rendered rules from {@link UnderpostDeploy.httpRouteRuleFactory}.
* @param {string} [name] - Route name override.
* @param {string} [parentName] - Gateway the route attaches to; defaults to the host's own Gateway.
* @returns {string} HTTPRoute YAML, or an empty string when there are no rules.
* @memberof UnderpostDeploy
*/
httpRouteYamlFactory({ host, options = {}, rules, name, parentName }) {
if (!rules || !rules.trim()) return '';
const namespace = options.namespace || 'default';
return `
---
apiVersion: ${GATEWAY_API_GROUP_VERSION}
kind: HTTPRoute
metadata:
name: ${name || host}
namespace: ${namespace}
spec:
parentRefs:
- group: ${GATEWAY_API_GROUP}
kind: Gateway
name: ${parentName || host}
namespace: ${namespace}
hostnames:
- ${host}
rules:
${rules}`;
},
/**
* Node directory backing the static utility's volume, following the same
* `HOST_VOLUME_ROOT/<pv>` convention as every other hostPath volume.
* @param {object} [options] - Deploy/run options.
* @returns {string} Absolute host path.
* @memberof UnderpostDeploy
*/
underpostGatewayRootFactory(options = {}) {
return options.underpostGatewayRoot || `${HOST_VOLUME_ROOT}/${UNDERPOST_GATEWAY.volumeName}`;
},
/**
* Reports whether a Service currently has at least one ready endpoint.
*
* The single reachability predicate for a colour: a Service with no ready
* endpoint cannot serve, so it is neither safe to route to nor something
* live traffic can be sitting on.
* @param {string} service - Service name.
* @param {string} [namespace] - Namespace.
* @returns {boolean} True when an endpoint is ready right now.
* @memberof UnderpostDeploy
*/
serviceHasReadyEndpoints({ service, namespace = 'default' }) {
const ready = shellExec(
`kubectl get endpointslice -n ${namespace} -l kubernetes.io/service-name=${service} ` +
`-o jsonpath='{.items[*].endpoints[*].conditions.ready}' 2>/dev/null`,
{ stdout: true, silent: true, silentOnError: true },
);
return `${ready}`.includes('true');
},
/**
* Reports whether the Deployment controller has observed the current
* generation and every desired replica is updated, Ready, and Available.
* @param {string} deployment - Deployment name.
* @param {string} [namespace] - Kubernetes namespace.
* @returns {boolean} True only when the full target colour is ready.
*/
deploymentHasReadyReplicas({ deployment, namespace = 'default' }) {
const state = `${
shellExec(
`kubectl get deployment ${deployment} -n ${namespace} ` +
`-o jsonpath='{.metadata.generation} {.status.observedGeneration} {.spec.replicas} ` +
`{.status.updatedReplicas} {.status.readyReplicas} {.status.availableReplicas}'`,
{ stdout: true, silent: true, silentOnError: true },
) || ''
}`
.trim()
.split(/\s+/)
.map(Number);
if (state.length !== 6 || state.some((value) => !Number.isFinite(value))) return false;
const [generation, observed, desired, updated, ready, available] = state;
return observed >= generation && desired > 0 && updated === desired && ready === desired && available === desired;
},
/**
* Waits for all replicas of a target colour, not merely its first endpoint.
* @param {string} deployment - Deployment name.
* @param {string} [namespace] - Kubernetes namespace.
* @param {number} [timeoutMs] - Maximum wait.
* @returns {boolean} True when the whole Deployment is ready.
*/
awaitDeploymentReady({ deployment, namespace = 'default', timeoutMs = 15 * 60 * 1000 }) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (Underpost.deploy.deploymentHasReadyReplicas({ deployment, namespace })) return true;
shellExec('sleep 2', { silent: true });
}
logger.warn('Deployment never made every desired replica Ready', { deployment, namespace });
return false;
},
/**
* Blocks until a Service has at least one ready endpoint.
*
* Envoy Gateway translates a route's backends at translation time, so the
* moment a route is applied decides whether it works: with no ready
* endpoint the rule is rewritten to a 500 direct response and stays that
* way. Returns false on timeout rather than throwing — a Service that never
* comes up is the deploy's problem to report, not this helper's.
* @param {string} service - Service name.
* @param {string} [namespace] - Namespace.
* @param {number} [timeoutMs] - How long to wait.
* @returns {boolean} True once an endpoint is ready.
* @memberof UnderpostDeploy
*/
awaitServiceEndpoints({ service, namespace = 'default', timeoutMs = 15 * 60 * 1000 }) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (Underpost.deploy.serviceHasReadyEndpoints({ service, namespace })) return true;
shellExec('sleep 2', { silent: true });
}
logger.warn('Service never reported a ready endpoint; routes may be programmed as 500', {
service,
namespace,
});
return false;
},
/**
* Places every edge-served document in the static utility's tree.
*
* Two sources, in that order. The workload is preferred because it is the
* only place all of them exist at once: several clients are built from
* sources cloned into the container at start-up, so this checkout's `public/`
* tree is both incomplete and only as fresh as its last local build. The
* checkout is the fallback, and the reason this runs twice in a cluster
* bring-up — once before the workload exists, to seed the tree so the routes
* are correct the moment they are programmed, and once after it is Ready, to
* replace what the container built better.
*
* A host whose page is in neither place keeps whatever the tree already had,
* ending on the shared default page — which answers 404 rather than
* presenting itself as that host's page.
* @param {string} deployId - Deploy id whose conf declares the views.
* @param {string} env - `development` | `production`.
* @param {object} [options] - Deploy options (namespace, versions, static root).
* @returns {Array<object>} One record per document, with where it came from.
* @memberof UnderpostDeploy
*/
syncStaticAssets(deployId, env, options = {}) {
const namespace = options.namespace || 'default';
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
const confSSRPath = `./engine-private/conf/${deployId}/conf.ssr.json`;
if (!fs.existsSync(confServerPath) || !fs.existsSync(confSSRPath)) {
logger.warn('No conf.server.json / conf.ssr.json; nothing to sync', { deployId, confServerPath });
return [];
}
// loadReplicas expands a plain `replicas` path (no singleReplica) into its
// own literal path key with the canonical path's client/view config
// cloned onto it, matching what buildManifest/buildProxyRouter resolve
// against — so a replica path this workload actually built (e.g. `/r1`)
// gets its edge documents synced too, not just the canonical path.
const confServer = loadReplicas(deployId, JSON.parse(fs.readFileSync(confServerPath, 'utf8')));
const confSSR = JSON.parse(fs.readFileSync(confSSRPath, 'utf8'));
const hostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
const version = (options.versions && `${options.versions}`.split(',')[0]) || 'blue';
const podName = Underpost.kubectl
.get(`${deployId}-${env}-${version}`, 'pods', namespace)
.find((pod) => pod.NAME?.startsWith(`${deployId}-${env}-${version}-`) && pod.STATUS === 'Running')?.NAME;
const synced = [];
for (const host of Object.keys(confServer))
for (const path of Object.keys(confServer[host])) {
// A singleReplica canonical path is never built under this deploy id —
// client-build.js skips it (see `if (singleReplica) continue`) and
// buildProxyRouter/buildManifest route none of its own edge documents
// for it either. Its replicas are each their own deploy id, built and
// synced independently; requiring this path's assets here would fail
// on a document that structurally cannot exist for this deploy.
if (confServer[host][path].singleReplica) continue;
for (const entry of Underpost.deploy.edgeRouteEntriesFactory({ confServer, confSSR, host, path })) {
const fromPod =
!!podName &&
syncStaticAssetFromPod({
podName,
namespace,
sourcePath: entry.containerPath,
hostRoot,
assetPath: entry.assetPath,
});
const fromHost =
!fromPod && writeStaticAsset({ hostRoot, assetPath: entry.assetPath, sourcePath: entry.hostPath });
synced.push({
host,
kind: entry.kind,
assetPath: entry.assetPath,
source: fromPod ? 'workload' : fromHost ? 'checkout' : null,
});
}
}
// Instance status pages are the same kind of document under the same
// layout, so they are placed by the same pass — but they come from neither
// of the sources above. Each is built and versioned by the project its
// instance runs, so `customStatusPages[].hostPath` resolves against that
// project's checkout on this host, and one document is placed per variant
// so `/FOREST/404` and `/404` each land where their own rule rewrites to.
if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`))
for (const entry of instanceStatusPageEntriesFactory({ instances: loadConfInstances(deployId) }))
synced.push({
host: entry.host,
kind: `status:${entry.status}`,
assetPath: entry.assetPath,
source: writeStaticAsset({ hostRoot, assetPath: entry.assetPath, sourcePath: entry.sourcePath })
? 'project'
: null,
});
// The documents were just written under the operator's home tree, whose
// policy label (user_home_t) the unprivileged gateway container cannot
// read — it would answer 403 for every one of them. The persistent
// mapping is registered at cluster bring-up; restore it here so files
// this pass created carry it too.
restoreContainerContext(hostRoot);
logger.info('Static edge documents placed', {
deployId,
podName: podName || '(no running workload; placed from this checkout)',
fromWorkload: synced.filter((entry) => entry.source === 'workload').length,
fromCheckout: synced.filter((entry) => entry.source === 'checkout').length,
fromProject: synced.filter((entry) => entry.source === 'project').length,
missing: synced.filter((entry) => !entry.source).map((entry) => entry.assetPath),
});
return synced;
},
/**
* The SSR views one host/path serves from the static edge tier, with every
* address each of them needs: the route to match, the directory the gateway
* rewrites onto, where the document sits under the static root, and the two
* places the build may have left it — inside the workload, and in this
* checkout's own `public/` tree.
*
* Single source of truth for the two consumers that must agree exactly —
* `--build-manifest`, which emits the rules, and `--sync-static`, which
* places the documents those rules point at.
* @param {object} confServer - Parsed `conf.server.json`.
* @param {object} confSSR - Parsed `conf.ssr.json`.
* @param {string} host - Hostname.
* @param {string} path - Proxy sub-path.
* @returns {Array<object>} One entry per edge-served view.
* @memberof UnderpostDeploy
*/
edgeRouteEntriesFactory({ confServer, confSSR, host, path }) {
const client = confServer?.[host]?.[path]?.client;
const views = client ? confSSR?.[getCapVariableName(client)]?.views : undefined;
if (!views) return [];
// The client build writes each view to `public/<host><path>/<view>/index.html`,
// under the container root for the workload's copy and under this repo for
// the host's.
// A context is built on its own route (`/offline/index.html`) because a
// client requests it by URL; a status page is built under `status-pages/`
// instead, off any route the runtime could answer with. Both sides read the
// segment from one factory so the sync never looks where the build did not
// write.
const publicPath = (segment) => `public/${host}${path === '/' ? '' : path}/${segment}`;
const addresses = (segment) => ({
containerPath: `${CONTAINER_ENGINE_ROOT}/${publicPath(segment)}`,
hostPath: `./${publicPath(segment)}`,
});
return [
...statusPageRoutesFactory({ views, proxyPath: path }).map((route) => ({
...route,
...statusPageAssetPathFactory({ host, path, status: route.status }),
kind: `status:${route.status}`,
...addresses(statusPageBuildSegment(route.status)),
})),
...staticContextRoutesFactory({ views, proxyPath: path }).map((route) => ({
...route,
...staticLocationFactory({ host, path, context: route.context }),
kind: `context:${route.context}`,
...addresses(`${route.context}/index.html`),
})),
];
},
/**
* Renders the static utility workload, resolving its placement from the
* deploy options the way every other hostPath volume is resolved — the
* documents are written to a node directory, so the pod has to land on the
* node that holds them.
* @param {object} [options] - Deploy/run options (namespace, node, cluster flags).
* @returns {string} Multi-document YAML.
* @memberof UnderpostDeploy
*/
underpostGatewayYamlFactory(options = {}) {
return underpostGatewayManifestsFactory({
namespace: options.namespace || 'default',
hostPath: Underpost.deploy.underpostGatewayRootFactory(options),
nodeName: Underpost.deploy.resolveDeployNode(options),
resolver: Underpost.deploy.clusterDnsFactory(),
});
},
/**
* Where a build writes the shared gateway's server blocks. A build artifact
* like every other manifest, installed into the live workload by the apply
* path rather than by the build that produced it.
* @param {string} deployId - Deploy id.
* @param {string} env - `development` | `production`.
* @returns {string} Directory holding the built blocks.
* @memberof UnderpostDeploy
*/
gatewayConfDirFactory({ deployId, env }) {
return `./engine-private/conf/${deployId}/build/${env}/gateway-conf.d`;
},
/**
* The cluster DNS address Nginx resolves upstream Service names through.
* Read from the live Service because it follows the cluster's own Service
* CIDR, and baked into the config because nginx cannot resolve the name of
* its own resolver.
* @returns {string} kube-dns ClusterIP, or the conventional default.
* @memberof UnderpostDeploy
*/
clusterDnsFactory() {
const clusterIp = shellExec(
`kubectl get svc kube-dns -n kube-system -o jsonpath='{.spec.clusterIP}' 2>/dev/null`,
{ stdout: true, silent: true, silentOnError: true },
);
return /^\d+\.\d+\.\d+\.\d+$/.test(`${clusterIp}`.trim()) ? `${clusterIp}`.trim() : UNDERPOST_GATEWAY.resolver;
},
/**
* Renders the HTTPRoute rules that serve an instance's status pages at the
* gateway. Each declared page gets a canonical route under the instance's
* own sub-path (`/404`, `/FOREST/404`), so a status document is reachable
* and cacheable per instance without ever reaching the workload.
*
* With `catchAll`, the same filter is additionally bound to `/`. That rule
* is only ever requested by the host assembly when no instance claims the
* root path — two rules with identical matches would otherwise make gateway
* precedence ambiguous.
* @param {string} deployId - Instance-scoped deploy id.
* @param {string} basePath - The instance's URL sub-path.
* @param {Array<object>} statusPages - `customStatusPages` entries.
* @param {string} [altSvc] - `Alt-Svc` value advertising the QUIC endpoint.
* @param {boolean} [catchAll] - Also bind the first page (404 when present) to `/`.
* @param {string} [host] - Hostname the documents were placed under; falls back to `deployId`.
* @param {Array<string>} [servedStatuses] - Statuses whose document reached the static tree. Undefined means "all declared".
* @returns {string} Rule YAML.
* @memberof UnderpostDeploy
*/
statusPageRouteRulesFactory({
deployId,
basePath = '/',
statusPages = [],
altSvc,
catchAll = false,
host,
servedStatuses,
}) {
// Only statuses whose document was actually placed in the static tree get
// a rule; a rewrite to a missing file would answer with the shared default
// page instead of the host's own.
const pages = statusPages.filter(
(page) =>
page?.status && page?.hostPath && (servedStatuses === undefined || servedStatuses.includes(`${page.status}`)),
);
if (pages.length === 0) return '';
const prefix = !basePath || basePath === '/' ? '' : basePath.replace(/\/$/, '');
// Served by the static utility rather than carried in the gateway config:
// a rendered page is far past the direct-response ceiling, and exceeding
// it fails the whole route.
const location = (status) => statusPageAssetPathFactory({ host: host || deployId, path: basePath, status });
const staticRule = (path, rewrite) =>
Underpost.deploy.httpRouteRuleFactory({
path,
...rewrite,
serviceId: UNDERPOST_GATEWAY.serviceName,
port: UNDERPOST_GATEWAY.port,
altSvc,
});
let rules = '';
// Canonical routes rewrite onto the directory so assets beside the
// document resolve too; the catch-all cannot, because a prefix rewrite of
// `/` would carry the rest of the request path into the target.
for (const page of pages)
rules += staticRule(`${prefix}/${page.status}`, { replacePrefixMatch: location(page.status).dir });
if (catchAll) {
const fallback = pages.find((page) => `${page.status}` === '404') || pages[0];
rules += staticRule('/', { replaceFullPath: location(fallback.status).url });
}
return rules;
},
/**
* Callback function for handling deployment options.
* @param {string} deployList - List of deployment IDs to process.
* @param {string} env - Environment for which the deployment is being processed.
* @param {object} options - Options for the deployment process.
* @param {boolean} options.remove - Whether to remove the deployment.
* @param {boolean} options.infoRouter - Whether to display router information.
* @param {boolean} options.sync - Whether to synchronize deployment configurations.
* @param {boolean} options.buildManifest - Whether to build the deployment manifest.
* @param {boolean} options.infoUtil - Whether to display utility information.
* @param {boolean} options.cert - Whether to create cert-manager Certificate resources for the deployment.
* @param {string} options.certHosts - Comma-separated list of hosts for which to create cert-manager certificates.
* @param {boolean} options.selfSigned - Use a pre-created self-signed TLS secret instead of cert-manager. The secret must already exist in the namespace with the same name as the host. Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.
* @param {string} options.versions - Comma-separated list of versions to deploy.
* @param {string} options.image - Docker image for the deployment.
* @param {string} options.traffic - Traffic status for the deployment.
* @param {string} options.replicas - Number of replicas for the deployment.
* @param {string} options.node - Explicit target node (highest precedence in the node chain). When empty, {@link UnderpostDeploy.resolveDeployNode} falls back to the cluster-type default (`kind-worker` for kind, host for kubeadm/k3s). Used for both volume placement and hostPath PV nodeAffinity.
* @param {string} [options.sshKeyPath] - Private key path for node SSH operations, forwarded to deployVolume when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.
* @param {boolean} options.disableUpdateDeployment - Whether to disable deployment updates.
* @param {boolean} [options.gatewayApi] - Apply the Gateway API stack (Gateway + HTTPRoute) instead of the Contour HTTPProxy. Both manifest sets are always generated by `--build-manifest`.
* @param {string} [options.gatewayClass] - GatewayClass name baked into generated Gateway manifests.
* @param {boolean} [options.disableHttp3] - Omit QUIC/HTTP3 listener config and the Alt-Svc advertisement.
* @param {number|string} [options.quicPort] - UDP port advertised for QUIC/HTTP3.
* @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
* @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
* @param {boolean} options.disableUpdateVolume - Whether to disable volume updates.
* @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
* @param {string} [options.namespace] - Kubernetes namespace for the deployment (defaults to "default").
* @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
* @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
* @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
* @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
* @param {string} [options.cmd] - Custom initialization command (comma-separated) for deploymentYamlPartsFactory.
* @param {boolean} [options.k3s] - Whether to use k3s cluster context.
* @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
* @param {boolean} [options.kind] - Whether to use kind cluster context.
* @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
* @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
* @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; passed through to buildManifest/deploymentYamlPartsFactory. Use together with skipFullBuild.
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); passed through to buildManifest/deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
* @param {boolean} [options.disableRuntimeProbes] - Deprecated compatibility flag; readiness remains mandatory.
* @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes.
* @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
* @memberof UnderpostDeploy
*/
async callback(
deployList = '',
env = 'development',
options = {
remove: false,
infoRouter: false,
sync: false,
buildManifest: false,
infoUtil: false,
cert: false,
certHosts: '',
versions: '',
image: '',
traffic: '',
replicas: '',
node: '',
disableUpdateDeployment: false,
disableUpdateProxy: false,
disableDeploymentProxy: false,
disableUpdateVolume: false,
disableUpdateUnderpostConfig: false,
namespace: '',
timeoutResponse: '',
timeoutIdle: '',
retryCount: '',
retryPerTryTimeout: '',
selfSigned: false,
cmd: '',
k3s: false,
kubeadm: false,
kind: false,
gitClean: false,
imagePullPolicy: '',
},
) {
options = { ...options, gatewayApi: gatewayApiEnabledFactory(options) };
const namespace = options.namespace ? options.namespace : 'default';
if (!deployList && options.certHosts) {
for (const host of options.certHosts.split(',')) {
shellExec(`sudo kubectl apply -f - -n ${namespace} <<'EOF'
${Underpost.deploy.buildCertManagerCertificate({ host, namespace })}
EOF`);
}
return;
} else if (!deployList || deployList === 'dd')
deployList = fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8');
const deployIds = deployList
.split(',')
.map((id) => id.trim())
.filter(Boolean);
const explicitVersions = options.versions && typeof options.versions === 'string' ? options.versions : '';
const explicitTraffic =
options.traffic && typeof options.traffic === 'string' ? options.traffic.split(',')[0] : '';
const liveTrafficByDeployId = Object.fromEntries(
deployIds.map((deployId) => [
deployId,
Underpost.deploy.getCurrentTraffic(deployId, {
namespace,
env,
gatewayApi: options.gatewayApi,
}),
]),
);
const versionsByDeployId = Object.fromEntries(
deployIds.map((deployId) => [
deployId,
explicitVersions || explicitTraffic || nextTrafficFactory(liveTrafficByDeployId[deployId]),
]),
);
if (!options.replicas) options.replicas = 1;
if (options.sync)
await getDataDeploy({
buildSingleReplica: true,
});
if (options.buildManifest === true)
for (const deployId of deployIds)
await Underpost.deploy.buildManifest(deployId, env, {
...options,
versions: versionsByDeployId[deployId],
traffic: explicitTraffic || liveTrafficByDeployId[deployId] || versionsByDeployId[deployId].split(',')[0],
});
if (options.syncStatic === true) {
for (const deployId of deployList
.split(',')
.map((id) => id.trim())
.filter(Boolean))
Underpost.deploy.syncStaticAssets(deployId, env, options);
return;
}
if (options.infoRouter === true || options.buildManifest === true) {
logger.info('router', await Underpost.deploy.routerFactory(deployList, env));
return;
}
if (!options.disableUpdateUnderpostConfig) Underpost.deploy.configMap(env);
for (const _deployId of deployList.split(',')) {
const deployId = _deployId.trim();
if (!deployId) continue;
const deploymentVersions = versionsByDeployId[deployId].split(',').map((version) => version.trim());
const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`);
const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
: [];
if (!options.disableUpdateDeployment)
for (const version of deploymentVersions) {
shellExec(
`sudo kubectl delete svc ${deployId}-${env}-${version}-service -n ${namespace} --ignore-not-found`,
);
shellExec(
`sudo kubectl delete deployment ${deployId}-${env}-${version} -n ${namespace} --ignore-not-found`,
);
if (!options.disableUpdateVolume)
for (const volume of confVolume)
Underpost.deploy.deployVolume(volume, {
deployId,
env,
version,
namespace,
nodeName: Underpost.deploy.resolveDeployNode({
node: options.node,
kind: options.kind,
kubeadm: options.kubeadm,
k3s: options.k3s,
env,
}),
clusterContext: clusterTypeFactory(options),
gitClean: options.gitClean || false,
sshKeyPath: options.sshKeyPath || '',
});
}
for (const host of Object.keys(confServer)) {
if (!options.disableUpdateProxy) {
// The host's route object is left in place and replaced by the `apply`
// below. Deleting it first unpublished the hostname for the whole
// reconciliation window, so every promote dropped live requests before
// the new colour was ever the question.
//
// A deploy that previously ran the per-host model left a Gateway
// named after each host. Those are superseded by the consolidated
// one, and leaving them behind duplicates that hostname's listeners
// in the same merged set. The oldest resource would retain traffic.
//
// `undefined-http3` is the same problem under a different name: the
// consolidated policy was briefly emitted with an unresolved host in
// its metadata. Merged listeners are configured by the oldest policy
// that targets them, so that object outranks the correctly named one
// for as long as it exists.
if (options.gatewayApi)
for (const name of [host, 'undefined']) {
shellExec(`sudo kubectl delete Gateway ${name} -n ${namespace} --ignore-not-found`, { silent: true });
shellExec(`sudo kubectl delete ClientTrafficPolicy ${name}-http3 -n ${namespace} --ignore-not-found`, {
silent: true,
});
}
if (Underpost.deploy.isCertManagerContext({ host, env, options }))
shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
}
}
const manifestsPath =
env === 'production'
? `engine-private/conf/${deployId}/build/production`
: `manifests/deployment/${deployId}-${env}`;
if (!options.remove) {
if (!options.disableUpdateDeployment) {
shellExec(`sudo kubectl apply -f ./${manifestsPath}/deployment.yaml -n ${namespace}`);
const grpcServicePath = `./${manifestsPath}/grpc-service.yaml`;
if (fs.existsSync(grpcServicePath)) shellExec(`sudo kubectl apply -f ${grpcServicePath} -n ${namespace}`);
}
// Ingress is served by exactly one of the two routing stacks: the
// Contour HTTPProxy set, or the Gateway API set (Gateway + HTTPRoute).
// Applying both would publish duplicate routes for the same hostnames.
if (!options.disableUpdateProxy) {
const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, {
namespace,
env,
gatewayApi: options.gatewayApi,
});
const currentReady =
!!currentTraffic &&
Underpost.deploy.serviceHasReadyEndpoints({
service: `${deployId}-${env}-${currentTraffic}-service`,
namespace,
});
// With no explicit traffic request, deploying the opposite colour is
// preparation only: preserve the colour already serving. A first
// deployment has no live selector and starts on the first requested
// version. Explicit --traffic is the only normal apply-time switch.
const desiredTraffic = explicitTraffic || (currentReady ? currentTraffic : deploymentVersions[0]);
const desiredDeployment = `${deployId}-${env}-${desiredTraffic}`;
if (
!options.disableUpdateDeployment &&
(!Underpost.deploy.awaitDeploymentReady({ deployment: desiredDeployment, namespace }) ||
!Underpost.deploy.awaitServiceEndpoints({ service: `${desiredDeployment}-service`, namespace }))
)
throw new Error(`Refusing to route ${deployId}-${env} to unready colour ${desiredTraffic}`);
// Migration is two-phase. First make every route and fallback block
// use a stable Service that still selects the current colour. Only
// after both stacks are converged is its selector moved to the ready
// target, so stack migration cannot create a root/API split.
const bootstrapTraffic = currentReady ? currentTraffic : desiredTraffic;
const trafficServicePath = `./${manifestsPath}/traffic-service.yaml`;
Underpost.deploy.applyTrafficService({
deployId,
env,
traffic: bootstrapTraffic,
namespace,
manifestPath: trafficServicePath,
});
if (options.gatewayApi) {
// Nginx must know how to proxy and intercept this host before Envoy
// can send the first request to it. Installing first removes the
// reconciliation window where the HTTPRoute is Accepted but the
// shared gateway still serves its default server block.
installGatewayConf({
hostRoot: Underpost.deploy.underpostGatewayRootFactory(options),
confSourceDir: Underpost.deploy.gatewayConfDirFactory({ deployId, env }),
namespace,
});
for (const file of ['gateway.yaml', 'httproute.yaml']) {
const gatewayApiPath = `./${manifestsPath}/${file}`;
if (fs.existsSync(gatewayApiPath) && fs.readFileSync(gatewayApiPath, 'utf8').trim())
shellExec(`sudo kubectl apply -f ${gatewayApiPath} -n ${namespace}`);
}
} else shellExec(`sudo kubectl apply -f ./${manifestsPath}/proxy.yaml -n ${namespace}`);
// The hostnames just published have to reach the data plane that now
// describes them. A shared edge built before this apply still sends
// them to the other stack, which answers 404 for a healthy workload.
// No-op when no shared edge is installed.
const sharedIngressUpdated = Underpost.cluster.refreshUnderpostIngress({ namespace, options });
if (sharedIngressUpdated) {
Underpost.deploy.removeInactiveHostRoutes({
hosts: Object.keys(confServer),
gatewayApi: options.gatewayApi,
namespace,
});
Underpost.cluster.refreshUnderpostIngress({ namespace, options });
}
if (desiredTraffic !== bootstrapTraffic)
Underpost.deploy.applyTrafficService({
deployId,
env,
traffic: desiredTraffic,
namespace,
manifestPath: trafficServicePath,
});
if (
!options.disableUpdateDeployment &&
!Underpost.deploy.awaitServiceEndpoints({
service: Underpost.deploy.trafficServiceNameFactory({ deployId, env }),
namespace,
})
) {
if (desiredTraffic !== bootstrapTraffic)
Underpost.deploy.applyTrafficService({
deployId,
env,
traffic: bootstrapTraffic,
namespace,
manifestPath: trafficServicePath,
});
throw new Error(`Traffic Service for ${deployId}-${env} never became ready on ${desiredTraffic}`);
}
}
if (Underpost.deploy.isCertManagerContext({ host: Object.keys(confServer)[0], env, options })) {
const secretPath = `./${manifestsPath}/secret.yaml`;
if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) {
shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`);
} else logger.info('Skipping secret.yaml apply (no objects yet; applied by the --cert step)');
}
}
}
},
/**
* Creates a Kubernetes Secret for a deployment (replaces configMap for secret data).
* Secrets are mounted as tmpfs (never written to node disk) and support RBAC restrictions.
* @param {string} env - Environment for which the secret is being created.
* @param {string} [namespace='default'] - Kubernetes namespace for the secret.
* @memberof UnderpostDeploy
*/
configMap(env, namespace = 'default') {
const cronDeployId = cronDeployIdResolve() || 'dd-cron';
const envFilePath = `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env}`;
// `--from-env-file` turns every KEY=VALUE into a secret key that the Deployment injects via
// `envFrom`. Strip shell/runtime-critical keys (notably PATH) first — an injected PATH
// overrides the image's own and breaks coreutils/sudo resolution inside the pod.
const sanitizedEnvPath = `${envFilePath}.secret`;
fs.writeFileSync(sanitizedEnvPath, Underpost.secret.sanitizeSecretEnvFile(fs.readFileSync(envFilePath, 'utf8')));
shellExec(`kubectl delete secret underpost-config -n ${namespace} --ignore-not-found`);
shellExec(
`kubectl create secret generic underpost-config --from-env-file=${sanitizedEnvPath} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
);
fs.removeSync(sanitizedEnvPath);
},
/**
* Switches the traffic for a deployment.
*
* Routing only: the workload is the caller's to deploy and make Ready, and
* this must never rebuild it. The colour being switched to is, by definition,
* the one about to receive every request, so tearing it down here would make
* the flip the outage it exists to avoid.
* @param {string} deployId - Deployment ID for which the traffic is being switched.
* @param {string} env - Environment for which the traffic is being switched.
* @param {string} targetTraffic - Target traffic status for the deployment.
* @param {number} replicas - Number of replicas for the deployment.
* @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
* @param {object} options - Options for the traffic switch.
* @param {string} options.timeoutResponse - Timeout response setting for the deployment.
* @param {string} options.timeoutIdle - Timeout idle setting for the deployment.
* @param {string} options.retryCount - Retry count setting for the deployment.
* @param {string} options.retryPerTryTimeout - Retry per-try timeout setting for the deployment.
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override; forwarded to the manifest rebuild triggered here.
* @memberof UnderpostDeploy
*/
switchTraffic(
deployId,
env,
targetTraffic,
replicas = 1,
namespace = 'default',
options = {
timeoutResponse: '',
timeoutIdle: '',
retryCount: '',
retryPerTryTimeout: '',
imagePullPolicy: '',
},
) {
options = { ...options, gatewayApi: gatewayApiEnabledFactory(options) };
const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
const gatewayApiFlags = Underpost.deploy.gatewayApiFlagsFactory(options);
// Readiness is a promotion precondition, not advisory. All callers use
// this same gate, including monitor/failover paths, so no code path can
// publish an endpointless target and turn a healthy opposite colour into
// a 500/maintenance response.
if (
!Underpost.deploy.awaitDeploymentReady({
deployment: `${deployId}-${env}-${targetTraffic}`,
namespace,
}) ||
!Underpost.deploy.awaitServiceEndpoints({ service: `${deployId}-${env}-${targetTraffic}-service`, namespace })
)
throw new Error(`Refusing to switch ${deployId}-${env} to unready colour ${targetTraffic}`);
const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, {
namespace,
env,
gatewayApi: options.gatewayApi,
});
const currentReady =
!!currentTraffic &&
Underpost.deploy.serviceHasReadyEndpoints({
service: `${deployId}-${env}-${currentTraffic}-service`,
namespace,
});
const bootstrapTraffic = currentReady ? currentTraffic : targetTraffic;
// Regenerates the manifests against the target colour only: `--build-manifest`
// returns before any cluster mutation, so the workload is untouched and the
// applies below are the whole switch.
shellExec(
`node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags}${imagePullPolicyFlag}${gatewayApiFlags} ${deployId} ${env}`,
);
const buildPath = `./engine-private/conf/${deployId}/build/${env}`;
const trafficServicePath = `${buildPath}/traffic-service.yaml`;
// On the first stable-Service migration, keep serving the current colour
// while the Nginx block and HTTPRoute/HTTPProxy are replaced. Once every
// layer references this Service, one selector update below is the switch.
Underpost.deploy.applyTrafficService({
deployId,
env,
traffic: bootstrapTraffic,
namespace,
manifestPath: trafficServicePath,
});
// A traffic switch rebuilds the underpost-gateway host blocks together
// with the HTTPRoutes. Install and validate those blocks before publishing
// a route that sends an intercepted site path to the shared gateway. The
// regular deploy apply path already does this; omitting it here left Nginx
// on its default server (or a previous environment/colour), so every such
// request became the shared 404 page even though Envoy reported the route
// Accepted and relayed it successfully.
if (options.gatewayApi)
installGatewayConf({
hostRoot: Underpost.deploy.underpostGatewayRootFactory(options),
confSourceDir: Underpost.deploy.gatewayConfDirFactory({ deployId, env }),
namespace,
});
for (const file of options.gatewayApi ? ['gateway.yaml', 'httproute.yaml'] : ['proxy.yaml'])
if (fs.existsSync(`${buildPath}/${file}`) && fs.readFileSync(`${buildPath}/${file}`, 'utf8').trim())
shellExec(`sudo kubectl apply -f ${buildPath}/${file} -n ${namespace}`);
// The shared front derives its Host/SNI table from the live route objects.
// Refresh after applying them so a hostname migrating between HTTPProxy and
// HTTPRoute reaches the stack that now owns it. This is also a no-op when
// underpost-ingress is not installed.
const sharedIngressUpdated = Underpost.cluster.refreshUnderpostIngress({ namespace, options });
if (sharedIngressUpdated) {
const switchHosts = Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`));
Underpost.deploy.removeInactiveHostRoutes({
hosts: switchHosts,
gatewayApi: options.gatewayApi,
namespace,
});
Underpost.cluster.refreshUnderpostIngress({ namespace, options });
}
if (targetTraffic !== bootstrapTraffic)
Underpost.deploy.applyTrafficService({
deployId,
env,
traffic: targetTraffic,
namespace,
manifestPath: trafficServicePath,
});
const trafficService = Underpost.deploy.trafficServiceNameFactory({ deployId, env });
if (!Underpost.deploy.awaitServiceEndpoints({ service: trafficService, namespace })) {
if (targetTraffic !== bootstrapTraffic)
Underpost.deploy.applyTrafficService({
deployId,
env,
traffic: bootstrapTraffic,
namespace,
manifestPath: trafficServicePath,
});
throw new Error(`Traffic Service ${trafficService} never became ready on ${targetTraffic}`);
}
const grpcServicePath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
if (fs.existsSync(grpcServicePath)) shellExec(`kubectl apply -f ${grpcServicePath} -n ${namespace}`);
Underpost.env.set(`${deployId}-${env}-traffic`, targetTraffic);
},
/**
* Resolves the effective target node for a deployment, applying a single
* precedence chain shared by every deploy workflow — the default `deploy`
* callback, `run sync`, and custom `run instance` — so node customization
* behaves identically everywhere:
*
* 1. **Explicit node** — `node` (the resolved `--node` value). Upstream
* runners derive it from the comma-path field or `--node-name`
* (`run sync`: `path.split(',')[4]` > `--node-name` > default) and from
* `--node-name` directly (`run instance`).
* 2. **`UNDERPOST_DEPLOY_NODE` env** — for kubeadm / k3s, the configured
* target node name. This makes hostPath PV `nodeAffinity` deterministic
* regardless of where the manifest is *built*: building inside a
* container or CI runner would otherwise leak that box's `os.hostname()`
* (e.g. a random container id) into `nodeSelector`, pinning the PV to a
* node that does not exist in the cluster.
* 3. **Cluster-type default** — when nothing above is set: `kind-worker`
* for a kind cluster (the node that hosts kind hostPath volumes),
* otherwise the control-plane / current host (`os.hostname()`) for
* kubeadm / k3s. With no explicit cluster flag, `development` is treated
* as kind and `production` as the host, preserving legacy behaviour.
*
* @param {object} params
* @param {string} [params.node=''] - Explicit node (`--node`); highest precedence.
* @param {boolean} [params.kind=false] - Kind cluster context.
* @param {boolean} [params.kubeadm=false] - Kubeadm cluster context.
* @param {boolean} [params.k3s=false] - K3s cluster context.
* @param {string} [params.env=''] - Deployment environment; tie-breaker when no cluster flag is set.
* @returns {string} The effective node name.
* @memberof UnderpostDeploy
*/
resolveDeployNode({ node = '', kind = false, kubeadm = false, k3s = false, env = '' } = {}) {
if (node) return node;
const isKind = kind || (!kubeadm && !k3s && env !== 'production');
if (isKind) return 'kind-worker';
return process.env.UNDERPOST_DEPLOY_NODE || os.hostname();
},
/**
* Checks a node name against the cluster and substitutes a real one when it
* does not exist.
*
* {@link UnderpostDeploy.resolveDeployNode} guesses from the environment when
* no cluster flag is given, so `--dev` against a kubeadm cluster yields
* `kind-worker`. For anything pinned by `nodeSelector` that guess does not
* degrade — it simply never schedules.
* @param {string} [node] - The chosen node name.
* @returns {{node: string, corrected: boolean}} The name to use, and whether it had to change.
* @memberof UnderpostDeploy
*/
resolveSchedulableNode({ node = '' } = {}) {
return schedulableNodeFactory({ nodes: Underpost.kubectl.get('', 'nodes'), node });
},
/**
* Deploys a volume for a deployment.
* @param {object} volume - Volume configuration.
* @param {string} volume.claimName - Name of the persistent volume claim.
* @param {string} volume.volumeMountPath - Mount path of the volume in the container.
* @param {string} volume.volumeName - Name of the volume.
* @param {object} options - Options for the volume deployment.
* @param {string} options.deployId - Deployment ID.
* @param {string} options.env - Environment for the deployment.
* @param {string} options.version - Version of the deployment.
* @param {string} options.namespace - Kubernetes namespace for the deployment.
* @param {string} options.nodeName - Effective target node (already resolved via {@link UnderpostDeploy.resolveDeployNode}). The volume data is written/shipped here and the PV is pinned to it; an empty value falls back to the cluster-type default inside this method.
* @param {string} [options.clusterContext='kind'] - Cluster context type ('kind', 'kubeadm', or 'k3s').
* @param {boolean} [options.gitClean=false] - Whether to run git clean on volumeMountPath before copying.
* @param {string} [options.sshKeyPath=''] - Private key path used when the target node is remote and the volume is shipped over SSH. Empty falls back to copyDirToNode's default (engine-private/deploy/id_rsa).
* @memberof UnderpostDeploy
*/
deployVolume(
volume = { claimName: '', volumeMountPath: '', volumeName: '' },
options = {
deployId: '',
env: '',
version: '',
namespace: '',
nodeName: '',
clusterContext: 'kind',
gitClean: false,
sshKeyPath: '',
},
) {
if (!volume.claimName) {
logger.warn('Volume claimName is required to deploy volume', volume);
return;
}
const { deployId, env, version, namespace } = options;
const clusterContext = options.clusterContext || 'kind';
const pvcId = `${volume.claimName}-${deployId}-${env}-${version}`;
const pvId = `${volume.claimName.replace('pvc-', 'pv-')}-${deployId}-${env}-${version}`;
const rootVolumeHostPath = `${HOST_VOLUME_ROOT}/${pvId}`;
if (options.gitClean && volume.volumeMountPath) {
Underpost.repo.clean({ paths: [volume.volumeMountPath] });
}
// The node that physically receives the volume data. hostPath volumes are
// node-local, so the data must land on the node where the pod will run, and
// the PV is pinned there (nodeAffinity) so the scheduler co-locates the pod
// with its volume — never mounting an empty DirectoryOrCreate on another node.
let dataNode;
if (clusterContext === 'kind') {
const kindNode = options.nodeName || 'kind-worker';
dataNode = kindNode;
shellExec(`docker exec -i ${kindNode} bash -c "mkdir -p ${rootVolumeHostPath}"`);
shellExec(`tar -C ${volume.volumeMountPath} -c . | docker cp - ${kindNode}:${rootVolumeHostPath}`);
shellExec(
`docker exec -i ${kindNode} bash -c "chown -R 1000:1000 ${rootVolumeHostPath}; chmod -R 755 ${rootVolumeHostPath}"`,
);
} else {
const localHost = os.hostname();
dataNode = options.nodeName || localHost;
if (dataNode === localHost) {
// Target node is the control plane / current host: write directly.
if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
restoreContainerContext(rootVolumeHostPath);
} else {
// Target node is remote: fs.copySync would only write the control-plane
// filesystem, leaving the real node's hostPath empty. Ship the folder to
// the node over SSH so the data exists where the pod is pinned.
const nodeHost =
shellExec(
`kubectl get node ${dataNode} -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'`,
{ stdout: true, silent: true, silentOnError: true },
).trim() || dataNode;
logger.info('Shipping volume to remote node over SSH', {
node: dataNode,
host: nodeHost,
src: volume.volumeMountPath,
dest: rootVolumeHostPath,
});
Underpost.ssh.copyDirToNode({
host: nodeHost,
localDir: volume.volumeMountPath,
remoteDir: rootVolumeHostPath,
...(options.sshKeyPath ? { keyPath: options.sshKeyPath } : {}),
});
}
}
shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
${Underpost.deploy.persistentVolumeFactory({
hostPath: rootVolumeHostPath,
pvcId,
namespace,
nodeName: dataNode,
})}
EOF
`);
},
/**
* Creates volume mounts and volumes for a deployment.
* @param {Array<volume>} volumes - List of volume configurations.
* @param {string} volume.volumeName - Name of the volume.
* @param {string} volume.volumeMountPath - Mount path of the volume in the container.
* @param {string} volume.volumeHostPath - Host path of the volume.
* @param {string} volume.volumeType - Type of the volume (e.g. 'Directory').
* @param {string|null} volume.claimName - Name of the persistent volume claim (if applicable).
* @param {string|null} volume.configMap - Name of the config map (if applicable).
* @param {string|null} volume.secret - Name of the Kubernetes Secret (if applicable). Mounts as readOnly.
* @param {boolean} [volume.emptyDir=false] - If true, uses an emptyDir volume (writable tmpfs).
* @returns {object} - Object containing the rendered volume mounts and volumes.
* @memberof UnderpostDeploy
*/
volumeFactory(
volumes = [
{
volumeName: 'volume-name',
volumeMountPath: '/path/in/container',
volumeHostPath: '/path/on/host',
volumeType: 'Directory',
claimName: null,
configMap: null,
version: null,
},
],
) {
let _volumeMounts = `
volumeMounts:`;
let _volumes = `
volumes:`;
volumes.map((volumeData) => {
let {
volumeName,
volumeMountPath,
volumeHostPath,
volumeType,
claimName,
configMap,
secret,
emptyDir,
version,
} = volumeData;
if (version) {
volumeName = `${volumeName}-${version}`;
claimName = claimName ? `${claimName}-${version}` : null;
}
// The pod-local volume name is a DNS-1123 label (max 63 chars); the PVC
// `claimName` it references is a subdomain (max 253) and stays verbatim.
// Per-variant instance names append <deployId>-<env>-<traffic> and can
// exceed 63, so clamp only the pod-local name (mount name must match it).
const podVolumeName = k8sVolumeName(volumeName);
_volumeMounts += `
- name: ${podVolumeName}
mountPath: ${volumeMountPath}
${secret ? ` readOnly: true\n` : ''}`;
_volumes += `
- name: ${podVolumeName}
${
emptyDir
? ` emptyDir: {}`
: secret
? ` secret:
secretName: ${secret}`
: configMap
? ` configMap:
name: ${configMap}`
: claimName
? ` persistentVolumeClaim:
claimName: ${claimName}`
: ` hostPath:
path: ${volumeHostPath}
type: ${volumeType}
`
}
`;
});
return { render: _volumeMounts + _volumes };
},
/**
* Creates a persistent volume and persistent volume claim for a deployment.
* @param {object} options - Options for the persistent volume and claim creation.
* @param {string} options.hostPath - Host path for the persistent volume.
* @param {string} options.pvcId - Persistent volume claim ID.
* @param {string} [options.namespace='default'] - Kubernetes namespace for the PVC claimRef.
* @param {string} [options.nodeName=''] - Node name to which the persistent volume is pinned (optional).
* @returns {string} - YAML configuration for the persistent volume and claim.
* @memberof UnderpostDeploy
*/
persistentVolumeFactory({ hostPath, pvcId, namespace = 'default', nodeName = '' }) {
const pvId = pvcId.replace(/^pvc-/, 'pv-');
// hostPath volumes are node-local: deployVolume writes the content to the
// filesystem of a single node. Without nodeAffinity the scheduler can place
// the pod on a different node and mount an empty DirectoryOrCreate hostPath
// (missing the materialized assets). Pin the PV to the node that holds the
// data so the pod is always co-located with its volume.
const nodeAffinity = nodeName
? `
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${nodeName}`
: '';
return `apiVersion: v1
kind: PersistentVolume
metadata:
name: ${pvId}
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manual${nodeAffinity}
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
name: ${pvcId}
namespace: ${namespace}
hostPath:
path: ${hostPath}
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ${pvcId}
spec:
accessModes:
- ReadWriteOnce
storageClassName: manual
volumeName: ${pvId}
resources:
requests:
storage: 5Gi`;
},
/**
* Checks if a TLS context is valid — i.e. whether the host is served over
* HTTPS at all, by either issuer. Drives the TLS block in the generated
* HTTPProxy virtualhost and Gateway listener.
* @param {object} options - Options for the check.
* @param {string} options.host - Host for which the TLS context is being checked.
* @param {string} options.env - Environment for which the TLS context is being checked.
* @param {object} options.options - Options for the TLS context check.
* @returns {boolean} - True if the TLS context is valid, false otherwise.
* @memberof UnderpostDeploy
*/
isValidTLSContext: ({ host, env, options }) =>
(env === 'production' &&
options.cert === true &&
(!options.certHosts || options.certHosts.split(',').includes(host))) ||
options.selfSigned === true,
/**
* Checks whether cert-manager is the issuer for a host, as opposed to a
* pre-created self-signed secret. Only this predicate may gate operations on
* cert-manager's own objects: its CRDs are absent wherever it is not
* installed (development, notably), and `kubectl --ignore-not-found`
* tolerates a missing object but not a missing resource type.
* @param {object} options - Options for the check.
* @param {string} options.host - Host being checked.
* @param {string} options.env - Environment being checked.
* @param {object} options.options - Deploy options.
* @returns {boolean} - True when cert-manager issues this host's certificate.
* @memberof UnderpostDeploy
*/
isCertManagerContext: ({ host, env, options }) =>
options.selfSigned !== true && Underpost.deploy.isValidTLSContext({ host, env, options }),
/**
* Predefined resource templates for Kubernetes deployments.
* @memberof UnderpostDeploy
*/
resourcesTemplate: {
dev_small: {
id: 'dev_small',
useCase: 'microservice_development',
resources: {
requests: {
memory: '128Mi',
cpu: '250m',
},
limits: {
memory: '512Mi',
cpu: '1',
},
},
},
prod_moderate: {
id: 'prod_moderate',
useCase: 'production_moderate',
resources: {
requests: {
memory: '256Mi',
cpu: '500m',
},
limits: {
memory: '512Mi',
cpu: '1',
},
},
},
memory_heavy: {
id: 'memory_heavy',
useCase: 'memory_intensive_app',
resources: {
requests: {
memory: '512Mi',
cpu: '500m',
},
limits: {
memory: '1Gi',
cpu: '1',
},
},
},
cpu_bound: {
id: 'cpu_bound',
useCase: 'cpu_intensive_job',
resources: {
requests: {
memory: '256Mi',
cpu: '1000m',
},
limits: {
memory: '512Mi',
cpu: '2000m',
},
},
},
},
/**
* Creates a resource object for Kubernetes deployments.
* @param {object} resources - Resource specifications.
* @param {string} resources.requestsMemory - Memory request for the container.
* @param {string} resources.requestsCpu - CPU request for the container.
* @param {string} resources.limitsMemory - Memory limit for the container.
* @param {string} resources.limitsCpu - CPU limit for the container.
* @returns {object|undefined} - Resource object for Kubernetes deployments or undefined if any resource is missing.
* @memberof UnderpostDeploy
*/
resourcesFactory: (
resources = {
resourceTemplateId: '',
requestsMemory: '',
requestsCpu: '',
limitsMemory: '',
limitsCpu: '',
},
) => {
if (resources) {
if (resources.resourceTemplateId)
return Underpost.deploy.resourcesTemplate[resources.resourceTemplateId].resources;
if (resources.requestsMemory && resources.requestsCpu && resources.limitsMemory && resources.limitsCpu)
return {
requests: {
memory: resources.requestsMemory,
cpu: resources.requestsCpu,
},
limits: {
memory: resources.limitsMemory,
cpu: resources.limitsCpu,
},
};
}
return undefined;
},
/**
* Extracts a non-standard `imagePullPolicy` key from an env-resolved
* instance lifecycle block (the convention used in `conf.instances.json`,
* where `imagePullPolicy` sits alongside `postStart`/`preStop` for
* per-instance ergonomics) and returns a clean lifecycle hash that is
* safe to splice into the K8S container spec.
*
* Returns `{ lifecycle, imagePullPolicy }`:
* - `lifecycle` — the input minus `imagePullPolicy`, or `undefined` when
* the resulting block is empty.
* - `imagePullPolicy` — the extracted value, or `undefined` if absent.
*
* @param {object|undefined} lifecycle - Env-resolved lifecycle block
* (already passed through {@link ServerConfBuilder.resolveEnvScoped}). May be `undefined`.
* @returns {{ lifecycle: (object|undefined), imagePullPolicy: (string|undefined) }}
* @memberof UnderpostDeploy
*/
extractInstanceImagePullPolicy(lifecycle) {
if (!lifecycle || typeof lifecycle !== 'object' || !('imagePullPolicy' in lifecycle)) {
return { lifecycle, imagePullPolicy: undefined };
}
const { imagePullPolicy, ...rest } = lifecycle;
return {
lifecycle: Object.keys(rest).length > 0 ? rest : undefined,
imagePullPolicy,
};
},
/**
* Generates timeout flags string for deployment commands.
* @param {object} options - Options containing timeout settings.
* @param {string|number} [options.timeoutResponse] - Timeout response value.
* @param {string|number} [options.timeoutIdle] - Timeout idle value.
* @param {string|number} [options.retryCount] - Retry count value.
* @param {string|number} [options.retryPerTryTimeout] - Retry per try timeout value.
* @returns {string} The timeout flags string.
* @memberof UnderpostDeploy
*/
timeoutFlagsFactory: (options = {}) => {
return (
`${options.timeoutResponse ? ` --timeout-response ${options.timeoutResponse}` : ''}` +
`${options.timeoutIdle ? ` --timeout-idle ${options.timeoutIdle}` : ''}` +
`${options.retryCount || options.retryCount === 0 ? ` --retry-count ${options.retryCount}` : ''}` +
`${options.retryPerTryTimeout ? ` --retry-per-try-timeout ${options.retryPerTryTimeout}` : ''}`
);
},
/**
* Generates the Gateway API / QUIC flag string for spawned deploy commands,
* so a routing choice made once at the top of a workflow reaches every
* child process instead of silently reverting to the HTTPProxy default.
* @param {object} options - Options containing the gateway settings.
* @param {boolean} [options.gatewayApi] - Apply the Gateway API stack.
* @param {boolean} [options.disableGatewayApi] - Apply the legacy Contour HTTPProxy stack.
* @param {string} [options.gatewayClass] - GatewayClass name.
* @param {boolean} [options.disableHttp3] - Disable QUIC/HTTP3.
* @param {string|number} [options.quicPort] - Advertised QUIC port.
* @returns {string} The gateway flags string.
* @memberof UnderpostDeploy
*/
gatewayApiFlagsFactory: (options = {}) => {
return (
`${options.gatewayApi ? ' --gateway-api' : ''}` +
// The legacy selection has to travel too. Gateway API is the default, so
// a child that never receives this flag reverts to it and reads or writes
// a different routing kind than the workflow that spawned it.
`${options.disableGatewayApi ? ' --disable-gateway-api' : ''}` +
`${options.gatewayClass ? ` --gateway-class ${options.gatewayClass}` : ''}` +
`${options.disableHttp3 ? ' --disable-http3' : ''}` +
`${options.quicPort ? ` --quic-port ${options.quicPort}` : ''}`
);
},
};
}
export default UnderpostDeploy;