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
306 lines (271 loc) • 15.3 kB
JavaScript
'use strict';
import { expect } from 'chai';
import fs from 'fs-extra';
// Named import: js-yaml's ESM build exports no default.
import { loadAll } from 'js-yaml';
import {
UNDERPOST_INGRESS,
gatewayBackendFactory,
underpostIngressConfFactory,
underpostIngressHostMapFactory,
underpostIngressManifestsFactory,
} from '../src/server/underpost-ingress.js';
const CONTOUR = UNDERPOST_INGRESS.backends.contour;
const GATEWAY = gatewayBackendFactory('envoy-eg-d8c59e83');
const BOTH = { contour: CONTOUR, gateway: GATEWAY };
describe('shared ingress front', () => {
describe('underpostIngressHostMapFactory', () => {
it('routes each hostname to the stack that describes it', () => {
const { entries } = underpostIngressHostMapFactory({
contourHosts: ['legacy.test'],
gatewayHosts: ['app.test'],
});
expect(entries).to.deep.equal([
{ host: 'app.test', backend: 'gateway' },
{ host: 'legacy.test', backend: 'contour' },
]);
});
// Both objects existing for one hostname is what switching stacks leaves
// behind. Only one can win, so the choice is fixed rather than left to
// whichever happened to be listed first.
it('reports a hostname described by both, and resolves it to the preferred stack', () => {
const { entries, conflicts } = underpostIngressHostMapFactory({
contourHosts: ['shared.test'],
gatewayHosts: ['shared.test'],
});
expect(conflicts).to.deep.equal(['shared.test']);
expect(entries).to.deep.equal([{ host: 'shared.test', backend: 'gateway' }]);
});
it('honours an explicit preference for the conflicted hostname', () => {
const { entries } = underpostIngressHostMapFactory({
contourHosts: ['shared.test'],
gatewayHosts: ['shared.test'],
preferred: 'contour',
});
expect(entries).to.deep.equal([{ host: 'shared.test', backend: 'contour' }]);
});
it('deduplicates and drops empty hostnames', () => {
const { entries, conflicts } = underpostIngressHostMapFactory({
contourHosts: ['a.test', 'a.test', '', null],
gatewayHosts: [],
});
expect(entries).to.deep.equal([{ host: 'a.test', backend: 'contour' }]);
expect(conflicts).to.deep.equal([]);
});
it('is empty with nothing routed', () => {
expect(underpostIngressHostMapFactory()).to.deep.equal({ entries: [], conflicts: [] });
});
});
describe('underpostIngressConfFactory', () => {
const conf = (input) => underpostIngressConfFactory({ backends: BOTH, ...input });
it('maps a hostname to its stack in both the HTTP and the TLS table', () => {
const out = conf({ entries: [{ host: 'legacy.test', backend: 'contour' }] });
expect(out).to.include(`legacy.test ${CONTOUR.http};`);
expect(out).to.include(`legacy.test ${CONTOUR.tls};`);
});
// The upstream must reach nginx as a variable. A literal upstream in a
// `stream` block is resolved when the config is parsed, so nginx refuses to
// start until the Service has DNS and then caches that address forever.
it('passes every upstream through a variable so the resolver applies', () => {
const out = conf({ entries: [] });
expect(out).to.include('proxy_pass http://$underpost_ingress_http_upstream;');
expect(out).to.include('proxy_pass $underpost_ingress_tls_upstream;');
expect(out).to.include('proxy_pass $underpost_ingress_quic_upstream;');
expect(out).to.not.match(/proxy_pass\s+[a-z0-9.-]+\.svc\.cluster\.local/);
});
it('falls back to the Gateway API stack for an unknown hostname', () => {
const out = conf({ entries: [] });
expect(out).to.include(`default ${GATEWAY.http};`);
expect(out).to.include(`default ${GATEWAY.tls};`);
});
it('falls back to the only stack installed', () => {
const out = underpostIngressConfFactory({ backends: { contour: CONTOUR }, defaultBackend: 'gateway' });
expect(out).to.include(`default ${CONTOUR.http};`);
});
// A host left over from the other stack would otherwise render a map entry
// pointing at an upstream that does not exist in this config.
it('drops a hostname whose stack is not installed', () => {
const out = underpostIngressConfFactory({
backends: { gateway: GATEWAY },
entries: [
{ host: 'orphan.test', backend: 'contour' },
{ host: 'app.test', backend: 'gateway' },
],
});
expect(out).to.not.include('orphan.test');
expect(out).to.include('app.test');
expect(out).to.not.include('projectcontour');
});
it('serves QUIC only when the Gateway API data plane is present', () => {
expect(conf({ entries: [] })).to.include(`listen ${UNDERPOST_INGRESS.httpsPort} udp;`);
expect(underpostIngressConfFactory({ backends: { contour: CONTOUR } })).to.not.include('udp;');
});
it('refuses to render with no backend at all', () => {
expect(() => underpostIngressConfFactory({ backends: {} })).to.throw(/No data plane backend/);
});
it('carries the cluster DNS resolver into both the http and stream contexts', () => {
const out = conf({ entries: [], resolver: '10.43.0.10' });
expect(out.match(/resolver 10\.43\.0\.10 valid=10s ipv6=off;/g)).to.have.lengthOf(2);
});
describe('response compression', () => {
const halves = (input) => {
const out = conf({ entries: [], ...input });
const split = out.indexOf('\nstream {');
return { http: out.slice(0, split), stream: out.slice(split) };
};
it('compresses the cleartext hop it proxies at L7', () => {
const { http } = halves({ compression: { brotliModuleDir: '' } });
expect(http).to.include('gzip on;');
expect(http).to.include('gzip_proxied any;');
expect(http).to.include('gzip_vary on;');
});
// `:443` is forwarded by SNI without being decrypted, so there is no body
// in this context to encode — and no compression directive is even valid
// in `stream`.
it('leaves the L4 contexts alone', () => {
const { stream } = halves({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } });
expect(stream).to.not.include('gzip');
expect(stream).to.not.include('brotli');
});
// This workload proxies every byte and serves no documents, so there is
// never a pre-compressed sibling on disk to look for.
it('does not look for pre-compressed files it can never have', () => {
const { http } = halves({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } });
expect(http).to.not.include('_static');
});
// `brotli on;` in an image without the module is an unknown directive, and
// this workload holds the node's 80/443 — a start-up failure here is the
// whole edge, so brotli is rendered only where it is declared to exist.
it('renders brotli and its modules only when the image is declared to carry them', () => {
const stock = conf({ entries: [], compression: { brotliModuleDir: '' } });
expect(stock).to.not.include('brotli');
expect(stock).to.not.include('load_module');
const declared = conf({ entries: [], compression: { brotliModuleDir: '/usr/lib/nginx/modules' } });
expect(declared).to.include('load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;');
expect(declared).to.include('brotli on;');
// Main context: nginx rejects `load_module` anywhere else.
expect(declared.indexOf('load_module')).to.be.lessThan(declared.indexOf('events {'));
});
it('renders no compression directive when compression is switched off', () => {
const out = conf({ entries: [], compression: { enabled: false, brotliModuleDir: '/usr/lib/nginx/modules' } });
expect(out).to.not.include('gzip');
expect(out).to.not.include('brotli');
expect(out).to.not.include('load_module');
});
});
});
describe('underpostIngressManifestsFactory', () => {
// The regression that left the edge Pending: under `hostNetwork: true`
// Kubernetes sets `hostPort` to each declared `containerPort`, and the
// scheduler then refuses to place the pod until those host ports are free —
// which they only become once this pod replaces the data planes holding them.
it('takes the host network while declaring no port field at all', () => {
const out = underpostIngressManifestsFactory({ conf: 'worker_processes auto;' });
expect(out).to.include('hostNetwork: true');
expect(out).to.include('dnsPolicy: ClusterFirstWithHostNet');
const fields = out.split('\n').filter((line) => !line.trim().startsWith('#'));
expect(fields.filter((line) => /^\s*-?\s*(ports|containerPort|hostPort):/.test(line))).to.deep.equal([]);
});
it('restarts from the cached Nginx image when an edge node cannot reach Docker Hub', () => {
expect(underpostIngressManifestsFactory({ conf: 'x' })).to.include('imagePullPolicy: IfNotPresent');
});
// Host-map changes are reloaded in place. Keeping them out of the pod
// template avoids a Recreate gap on every HTTPRoute/HTTPProxy migration.
it('keeps the pod template stable and runs from a writable config copy', () => {
const deployment = (conf) =>
loadAll(underpostIngressManifestsFactory({ conf })).find((document) => document?.kind === 'Deployment');
expect(deployment('a').spec.template).to.deep.equal(deployment('b').spec.template);
const out = underpostIngressManifestsFactory({ conf: 'a' });
expect(out).to.include('cp /etc/underpost-ingress/nginx.conf /tmp/nginx.conf');
expect(out).to.not.include('subPath:');
});
// Verified against the real nginx:alpine image: dropping any of these is a
// start-up failure, and only NET_BIND_SERVICE is visible to `nginx -t` —
// CHOWN fails on the temp paths, SETUID/SETGID when workers spawn.
it('grants every capability the nginx master needs to start', () => {
const out = underpostIngressManifestsFactory({ conf: 'x' });
for (const capability of ['NET_BIND_SERVICE', 'CHOWN', 'SETUID', 'SETGID'])
expect(out).to.include(`- ${capability}`);
expect(out).to.include('- ALL');
});
// The node's ports are a single resource: a rolling update would surge a
// second pod that cannot bind and stall the rollout indefinitely.
it('replaces rather than rolls, because the ports cannot be held twice', () => {
expect(underpostIngressManifestsFactory({ conf: 'x' })).to.include('type: Recreate');
});
it('pins to a node only when one is given', () => {
expect(underpostIngressManifestsFactory({ conf: 'x', nodeName: 'node-1' })).to.include(
'kubernetes.io/hostname: node-1',
);
expect(underpostIngressManifestsFactory({ conf: 'x' })).to.not.include('nodeSelector');
});
// The manifest is assembled by string templating, and the config is embedded
// as an indented block scalar — so "it looks right" is not evidence. Parsing
// it back is what proves the pod spec is the one intended and that the
// embedded config survived the indentation intact.
it('parses as YAML, with the config round-tripping unchanged', () => {
const conf = underpostIngressConfFactory({
entries: [{ host: 'legacy.test', backend: 'contour' }],
backends: BOTH,
});
const documents = loadAll(underpostIngressManifestsFactory({ conf, nodeName: 'node-1' })).filter(Boolean);
expect(documents.map((document) => document.kind)).to.deep.equal(['ConfigMap', 'Deployment']);
const deployment = documents.find((document) => document.kind === 'Deployment');
const container = deployment.spec.template.spec.containers[0];
expect(deployment.spec.template.spec.hostNetwork).to.equal(true);
expect(container.ports).to.equal(undefined);
expect(container.securityContext.capabilities.add).to.have.members([
'NET_BIND_SERVICE',
'CHOWN',
'SETUID',
'SETGID',
]);
const configMap = documents.find((document) => document.kind === 'ConfigMap');
expect(configMap.data['nginx.conf'].trimEnd()).to.equal(conf.trimEnd());
});
it('validates and reloads a live config before persisting it', () => {
const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8');
const install = clusterSource.slice(
clusterSource.indexOf(' installUnderpostIngress('),
clusterSource.indexOf(' pruneEndpointlessService(', clusterSource.indexOf(' installUnderpostIngress(')),
);
const testCandidate = install.indexOf("execIngress('nginx -t -c /tmp/nginx.candidate.conf'");
const reload = install.indexOf('nginx -s reload -c /tmp/nginx.conf', testCandidate);
const persist = install.indexOf("shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'", reload);
expect(testCandidate).to.be.greaterThan(-1);
expect(reload).to.be.greaterThan(testCandidate);
expect(persist).to.be.greaterThan(reload);
});
it('never inherits an application node flag during a route-table refresh', () => {
const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8');
const install = clusterSource.slice(
clusterSource.indexOf(' installUnderpostIngress('),
clusterSource.indexOf(' pruneEndpointlessService(', clusterSource.indexOf(' installUnderpostIngress(')),
);
expect(install).to.include("const requestedNode = options.ingressNode || '';");
expect(install).to.not.include('const requestedNode = options.nodeName || options.node;');
expect(install).to.include('requestedNode ||\n liveNode ||');
});
it('waits for an explicit ingress-node replacement before reporting refresh success', () => {
const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8');
const install = clusterSource.slice(
clusterSource.indexOf(' installUnderpostIngress('),
clusterSource.indexOf(' pruneEndpointlessService(', clusterSource.indexOf(' installUnderpostIngress(')),
);
const changesNode = install.indexOf('const changesNode =');
const preflight = install.indexOf('pod/${preflightPod}', changesNode);
const apply = install.indexOf('underpostIngressManifestsFactory({');
const rollout = install.indexOf('if (!canHotReload || changesNode)', apply);
const available = install.indexOf('--for=condition=Available', rollout);
const nginxTest = install.indexOf("execIngress('nginx -t -c /tmp/nginx.conf'", available);
const success = install.indexOf("logger.info('Underpost ingress applied'", rollout);
expect(changesNode).to.be.greaterThan(-1);
expect(preflight).to.be.greaterThan(changesNode);
expect(apply).to.be.greaterThan(preflight);
expect(rollout).to.be.greaterThan(apply);
expect(available).to.be.greaterThan(rollout);
expect(nginxTest).to.be.greaterThan(available);
expect(success).to.be.greaterThan(nginxTest);
});
});
});