UNPKG

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

511 lines (453 loc) 25 kB
'use strict'; import { expect } from 'chai'; import fs from 'fs-extra'; import { UNDERPOST_GATEWAY, hostServerConfFactory, kubernetesUpstreamFactory, writeHostServerConf, underpostGatewayManifestsFactory, nginxConfFactory, staticLocationFactory, staticPathSegmentFactory, statusPageAssetPathFactory, statusPageBuildSegment, } from '../src/server/underpost-gateway.js'; import { staticContextRoutesFactory, statusPageRoutesFactory } from '../src/client-builder/client-build.js'; // A `conf.ssr.json` client entry: two intercepted contexts, one status page, // and one ordinary view that must stay with the workload. const VIEWS = [ { path: '/offline', title: 'No Network Connection', client: 'NoNetworkConnection', offlineDefault: true }, { path: '/maintenance', title: 'Server Maintenance', client: 'Maintenance', maintenanceDefault: true }, { path: '/test', title: 'Test', client: 'Test' }, { path: '/404', title: '404 Instance Not Found', client: 'Cyberia404' }, ]; const manifests = (overrides = {}) => underpostGatewayManifestsFactory({ namespace: 'default', hostPath: `/home/dd/engine/volume/${UNDERPOST_GATEWAY.volumeName}`, nodeName: 'node-a', ...overrides, }) .split('\n---\n') .filter((doc) => doc.trim()); const kind = (docs, name) => docs.find((doc) => doc.includes(`\nkind: ${name}\n`)); // Undo the block-scalar indent the ConfigMap wraps the config in, which is the // step that would corrupt it. The trailing newline is the document separator's, // stripped on the way in and restored here. const CONFIG_MAP_KEY = ' nginx.conf: |\n'; const configMapNginxConf = (docs) => { const configMap = kind(docs, 'ConfigMap'); return `${configMap .slice(configMap.indexOf(CONFIG_MAP_KEY) + CONFIG_MAP_KEY.length) .split('\n') .map((line) => (line.length > 0 ? line.slice(4) : line)) .join('\n')}\n`; }; describe('underpost gateway edge tier', () => { describe('view selection', () => { it('routes status pages and intercepted contexts to the edge, and nothing else', () => { expect(statusPageRoutesFactory({ views: VIEWS, proxyPath: '/' }).map((route) => route.status)).to.deep.equal([ '404', ]); expect(staticContextRoutesFactory({ views: VIEWS, proxyPath: '/' }).map((route) => route.context)).to.deep.equal([ 'offline', 'maintenance', ]); }); it('scopes both kinds to the instance sub-path', () => { expect(statusPageRoutesFactory({ views: VIEWS, proxyPath: '/FOREST' })[0].routePath).to.equal('/FOREST/404'); expect(staticContextRoutesFactory({ views: VIEWS, proxyPath: '/FOREST' })[0].routePath).to.equal( '/FOREST/offline', ); }); }); describe('document layout', () => { it('folds the root sub-path into a directory of its own', () => { expect(staticPathSegmentFactory('/')).to.equal('root'); expect(staticPathSegmentFactory('/FOREST')).to.equal('FOREST'); expect(staticPathSegmentFactory('/a/b')).to.equal('a-b'); }); it('keeps each document in a directory, so a prefix rewrite covers what sits beside it', () => { const location = statusPageAssetPathFactory({ host: 'www.cyberiaonline.com', path: '/', status: 404 }); expect(location.assetPath).to.equal('www.cyberiaonline.com/root/status-pages/404/index.html'); expect(location.dir).to.equal('/www.cyberiaonline.com/root/status-pages/404'); expect(location.url).to.equal('/www.cyberiaonline.com/root/status-pages/404/index.html'); expect(location.assetPath).to.equal(`${location.dir}/index.html`.slice(1)); }); it('separates instances of the same host', () => { const forest = statusPageAssetPathFactory({ host: 'client.cyberiaonline.com', path: '/FOREST', status: 404 }); const root = statusPageAssetPathFactory({ host: 'client.cyberiaonline.com', path: '/', status: 404 }); expect(forest.assetPath).to.equal('client.cyberiaonline.com/FOREST/status-pages/404/index.html'); expect(forest.assetPath).to.not.equal(root.assetPath); }); it('gives contexts the same shape as status pages', () => { expect(staticLocationFactory({ host: 'underpost.net', path: '/', context: 'offline' }).assetPath).to.equal( 'underpost.net/root/offline/index.html', ); }); }); describe('nginx config', () => { const conf = nginxConfFactory(); it('resolves a prefix rewrite onto a directory through its index', () => { expect(conf).to.include('try_files $uri $uri/index.html =404;'); expect(conf).to.include(`root ${UNDERPOST_GATEWAY.root};`); }); // Every upstream is dialled through a variable so a redeployed Service is // re-resolved; that requires a resolver, and nginx cannot resolve its own. it('carries a literal resolver address and includes the per-host blocks', () => { expect(conf).to.match(/resolver \d+\.\d+\.\d+\.\d+ valid=/); expect(conf).to.include(`include ${UNDERPOST_GATEWAY.root}/${UNDERPOST_GATEWAY.confDir}/*.conf;`); expect(nginxConfFactory({ resolver: '10.0.0.10' })).to.include('resolver 10.0.0.10 valid='); }); it('defines the websocket upgrade map the host blocks reference', () => { expect(conf).to.include('map $http_upgrade $connection_upgrade'); }); // For every host whose errors are intercepted, this workload is the hop the // site path already passes through — and the last one holding the body in // the clear. Compressing anywhere further out is compressing ciphertext. describe('response compression', () => { it('compresses what it serves and what it proxies', () => { expect(conf).to.include('gzip on;'); expect(conf).to.include('gzip_vary on;'); // Nginx's default is `off`, which skips the proxied responses that carry // most of the bytes this workload forwards. expect(conf).to.include('gzip_proxied any;'); expect(conf).to.match(/gzip_types [^;]*application\/javascript[^;]*;/); expect(conf).to.match(/gzip_types [^;]*text\/css[^;]*;/); }); // Documents are served from a volume, so an operator who places a `.gz` // beside one gets it sent with no compression pass at all. it('prefers a pre-compressed sibling from the static root', () => { expect(conf).to.include('gzip_static on;'); }); it('renders brotli and its modules only when the image is declared to carry them', () => { expect(conf).to.not.include('brotli'); expect(conf).to.not.include('load_module'); const declared = nginxConfFactory({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } }); expect(declared).to.include('load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;'); expect(declared).to.include('brotli on;'); expect(declared).to.include('brotli_static on;'); // Main context: nginx rejects `load_module` anywhere else. expect(declared.indexOf('load_module')).to.be.lessThan(declared.indexOf('events {')); }); // The config is mounted with `subPath`, which Kubernetes never refreshes, // so only a changed pod template rolls the workload onto a new policy. it('rolls the deployment when the policy changes', () => { const hash = (docs) => kind(docs, 'Deployment').match(/nginx-conf-hash: '([0-9a-f]+)'/)[1]; expect(hash(manifests({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } }))).to.not.equal( hash(manifests()), ); }); }); // A 200 would let the PWA service worker store the shared page as the // host's own and keep serving it after the real document lands. it('serves the shared fallback as a 404 that is never stored', () => { expect(conf).to.include(`error_page 404 /${UNDERPOST_GATEWAY.defaultHostDir}/status-pages/404/index.html;`); expect(conf).to.match(/location = \/default\/status-pages\/404\/index\.html \{\s*\n\s*internal;/); expect(conf).to.include("add_header Cache-Control 'no-store' always;"); }); }); // Interception is the whole reason Nginx sits in the request path: the status // code and the client's URI survive, and the document is read from disk so its // size is unbounded — none of which an inline Envoy body can do. describe('host server blocks', () => { const conf = () => hostServerConfFactory({ host: 'server.fixture.test', routes: [ { path: '/', upstream: 'root-service:8083', statuses: { 404: 'status-pages/404' } }, { path: '/FOREST', upstream: 'forest-service:8083', statuses: { 404: 'status-pages/404', 503: 'maintenance' }, stripPrefix: true, }, ], }); it('intercepts each sub-path onto its own document', () => { expect(conf()).to.include('proxy_intercept_errors on;'); expect(conf()).to.include('error_page 404 @status_FOREST_404;'); expect(conf()).to.include('try_files /server.fixture.test/FOREST/status-pages/404/index.html =404;'); expect(conf()).to.include('try_files /server.fixture.test/root/status-pages/404/index.html =404;'); }); it('qualifies short Service names for the runtime DNS resolver', () => { expect(conf()).to.include('set $upstream_root root-service.default.svc.cluster.local:8083;'); expect(conf()).to.include('set $upstream_FOREST forest-service.default.svc.cluster.local:8083;'); expect(kubernetesUpstreamFactory('svc:80', 'games')).to.equal('svc.games.svc.cluster.local:80'); expect(kubernetesUpstreamFactory('svc.other.svc.cluster.local:80', 'games')).to.equal( 'svc.other.svc.cluster.local:80', ); expect(kubernetesUpstreamFactory('10.0.0.8:80', 'games')).to.equal('10.0.0.8:80'); }); // The rewritten document path arrives at this same server block. Without a // location for it, `location /` proxies it to the application, which has no // such route — and an application that redirects its own 404s then bounces // between the route and the rewrite until the browser gives up. it("serves the host's own documents from disk instead of proxying them", () => { expect(conf()).to.include('location /server.fixture.test/ {'); const documentLocation = conf().slice(conf().indexOf('location /server.fixture.test/ {')); expect(documentLocation.slice(0, documentLocation.indexOf('}'))).to.include( 'try_files $uri $uri/index.html =404;', ); }); // Longer prefix than the proxied root, so nginx prefers it. it('places the document location ahead of the proxy', () => { expect(conf().indexOf('location /server.fixture.test/ {')).to.be.lessThan(conf().indexOf('location / {')); }); // A dead workload is what a maintenance page is for. it('answers upstream failure from the maintenance context', () => { expect(conf()).to.include('error_page 503 @status_FOREST_503;'); expect(conf()).to.include('try_files /server.fixture.test/FOREST/maintenance/index.html =503;'); }); // `error_page 404 @x` keeps the upstream's status; `error_page 404 = @x` // would replace it with the status of the page itself. it('never rewrites the status it intercepted', () => { expect(conf()).to.not.match(/error_page \d+ = /); expect(conf()).to.not.include('return 30'); expect(conf()).to.not.include('location.replace'); }); it('strips a variant prefix only where the instance asks for it', () => { expect(conf()).to.include('rewrite ^/FOREST/?(.*)$ /$1 break;'); expect(conf()).to.not.include('rewrite ^//?'); }); // nginx variable names admit only word characters. it('emits identifiers nginx accepts for a multi-segment path', () => { const nested = hostServerConfFactory({ host: 'h.test', routes: [{ path: '/a/b', upstream: 'svc:80', statuses: { 404: 'status-pages/404' } }], }); expect(nested).to.include('set $upstream_a_b svc.default.svc.cluster.local:80;'); expect(nested).to.not.match(/\$upstream_\S*-/); }); // The map lives in the base config; the header that uses it lives here. it('forwards websocket upgrades across the proxied hop', () => { expect(conf()).to.include('proxy_set_header Upgrade $http_upgrade;'); expect(conf()).to.include('proxy_set_header Connection $connection_upgrade;'); }); it('proxies without interception when nothing is declared', () => { const bare = hostServerConfFactory({ host: 'h.test', routes: [{ path: '/', upstream: 'svc:80' }] }); expect(bare).to.include('proxy_intercept_errors off;'); expect(bare).to.not.include('error_page'); }); it('renders nothing for a host that proxies nothing', () => { expect(hostServerConfFactory({ host: 'h.test', routes: [] })).to.equal(''); expect(hostServerConfFactory({ host: 'h.test', routes: [{ path: '/' }] })).to.equal(''); }); }); describe('workload manifests', () => { it('renders the whole workload as one document set', () => { expect(manifests().map((doc) => /\nkind: (\w+)\n/.exec(doc)[1])).to.deep.equal([ 'ConfigMap', 'PersistentVolume', 'PersistentVolumeClaim', 'Deployment', 'Service', ]); }); it('carries the rendered nginx.conf verbatim', () => { expect(configMapNginxConf(manifests())).to.equal(nginxConfFactory()); }); // The config is mounted with `subPath`, which Kubernetes never refreshes in // place: without a pod-template change an edited nginx.conf reaches the // ConfigMap and nothing else. it('rolls the pod when the config changes, and only then', () => { const hash = (docs) => /underpost\.net\/nginx-conf-hash: '(\w+)'/.exec(kind(docs, 'Deployment'))[1]; expect(hash(manifests())).to.have.length(16); expect(hash(manifests())).to.equal(hash(manifests({ storage: '2Gi' }))); }); it('pins the volume to the node holding the documents', () => { const pv = kind(manifests(), 'PersistentVolume'); expect(pv).to.include(`path: /home/dd/engine/volume/${UNDERPOST_GATEWAY.volumeName}`); expect(pv).to.include('- node-a'); }); it('omits node affinity when no node is resolved', () => { expect(kind(manifests({ nodeName: '' }), 'PersistentVolume')).to.not.include('nodeAffinity'); }); }); // A backtick anywhere inside these templates closes the literal early and the // rest is evaluated as JavaScript, so the factory silently returns a number. describe('config template integrity', () => { it('renders a string, not an expression', () => { expect(nginxConfFactory()).to.be.a('string').with.length.greaterThan(0); expect( hostServerConfFactory({ host: 'h.test', routes: [{ path: '/', upstream: 's:80', statuses: { 404: 'status-pages/404' } }], }), ) .to.be.a('string') .with.length.greaterThan(0); }); }); // The client's URI must survive a status page being served. Interception is the // only delivery path that keeps it: a route for `/404` would make the page a // destination, and every hop to a destination is a URI the client did not ask // for. Contexts are different — `/offline` is an address a client requests and // the service worker precaches by URL. describe('status delivery preserves the URI', () => { const statusView = (path) => /^\/([1-5]\d{2})$/.test(path); it('separates status pages from routable contexts', () => { const views = [ { path: '/404', client: 'S404' }, { path: '/offline', client: 'Off', offlineDefault: true }, { path: '/maintenance', client: 'Mnt', maintenanceDefault: true }, ]; expect(statusPageRoutesFactory({ views, proxyPath: '/' }).map((r) => r.routePath)).to.deep.equal(['/404']); expect(staticContextRoutesFactory({ views, proxyPath: '/' }).map((r) => r.routePath)).to.deep.equal([ '/offline', '/maintenance', ]); // Only the contexts are emitted as routes; the status page is not. expect(views.filter((v) => statusView(v.path)).length).to.equal(1); }); // Every reachable path in the block is either the proxy or an internal // interception target — never an outward-facing status page URL. it('exposes no status page as a request target', () => { const conf = hostServerConfFactory({ host: 'h.test', routes: [{ path: '/', upstream: 's:80', statuses: { 404: 'status-pages/404' } }], }); expect(conf).to.not.match(/location\s+\/404\b/); expect(conf).to.include('error_page 404 @status_root_404;'); // Named locations are unreachable from outside by construction. expect(conf).to.include('location @status_root_404 {'); }); }); // The runtime redirects its own 404 only when it has a page on that route to // redirect to. Building the status page off `/<status>` removes the route, so a // runtime that predates the agnostic change still answers a bare 404 — which is // what the gateway intercepts, keeping the client's URI. describe('status pages are not runtime routes', () => { it('builds under status-pages, never on the status route', () => { expect(statusPageBuildSegment(404)).to.equal('status-pages/404/index.html'); expect(statusPageBuildSegment(404)).to.not.equal('404/index.html'); expect(statusPageBuildSegment('503')).to.equal('status-pages/503/index.html'); }); // The document the gateway serves and the one the build writes are named by // the same convention, so the sync cannot look where nothing was written. it('shares the status-pages name with the gateway layout', () => { const served = statusPageAssetPathFactory({ host: 'h.test', path: '/', status: 404 }).assetPath; expect(served).to.include('status-pages/404/index.html'); expect(statusPageBuildSegment(404)).to.equal('status-pages/404/index.html'); }); }); // A build must work with no cluster running: generating manifests cannot depend // on a running gateway to validate against, and cannot mutate the host either. // Installing and reloading is the apply path's job. describe('build/apply separation', () => { const source = fs.readFileSync(new URL('../src/server/underpost-gateway.js', import.meta.url), 'utf8'); const bodyOf = (name) => { const start = source.indexOf(`const ${name} = `); const next = source.slice(start + 1).search(/\nconst \w+ = |\nexport \{/); return source.slice(start, start + 1 + next); }; it('renders and writes without touching the cluster', () => { for (const name of [ 'hostServerConfFactory', 'nginxConfFactory', 'writeHostServerConf', 'statusPageLocationsFactory', ]) { expect(bodyOf(name)).to.not.include('kubectl'); expect(bodyOf(name)).to.not.include('sudo '); } }); it('keeps the cluster work in the install path', () => { expect(bodyOf('installGatewayConf')).to.include('nginx -t'); expect(bodyOf('installGatewayConf')).to.include('nginx -s reload'); expect(bodyOf('installGatewayConf')).to.include('throw new Error'); }); it('writes a block to the directory it is given, and removes it when empty', () => { const dir = fs.mkdtempSync('/tmp/underpost-gateway-test-'); try { expect(writeHostServerConf({ confDir: dir, host: 'h.test', conf: 'server {}\n' })).to.equal(true); expect(fs.readFileSync(`${dir}/h.test.conf`, 'utf8')).to.equal('server {}\n'); // Idempotent: an unchanged block is not rewritten. expect(writeHostServerConf({ confDir: dir, host: 'h.test', conf: 'server {}\n' })).to.equal(false); expect(writeHostServerConf({ confDir: dir, host: 'h.test', conf: '' })).to.equal(true); expect(fs.existsSync(`${dir}/h.test.conf`)).to.equal(false); } finally { fs.removeSync(dir); } }); }); // The manifests are piped to `kubectl apply -f -` through a heredoc. Every // value is already substituted by the template literal, so anything the shell // would expand is content — and `nginx.conf` is nothing but content the shell // recognises. An unquoted delimiter turns `try_files $uri $uri/index.html` // into `try_files /index.html`, which matches nothing, and every host's // status page is answered by the shared default instead. describe('shell safety', () => { const applySites = () => ['src/cli/cluster.js', 'src/cli/deploy.js', 'src/cli/run.js'].flatMap((file) => fs .readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') .split('\n') .map((line, index) => ({ file, line: index + 1, text: line })) .filter((entry) => entry.text.includes('kubectl apply') && entry.text.includes('<<')), ); it('applies every generated manifest through a quoted heredoc', () => { const unquoted = applySites().filter((entry) => !entry.text.includes("<<'EOF'")); expect(unquoted.map((entry) => `${entry.file}:${entry.line}`)).to.deep.equal([]); }); it('renders nginx variables the shell would otherwise eat', () => { expect(nginxConfFactory()).to.include('try_files $uri $uri/index.html =404;'); expect(nginxConfFactory()).to.match(/\$remote_addr.+\$request.+\$status/); }); }); describe('traffic switch publication order', () => { const deploySource = fs.readFileSync(new URL('../src/cli/deploy.js', import.meta.url), 'utf8'); const switchTraffic = deploySource.slice( deploySource.indexOf(' switchTraffic('), deploySource.indexOf(' resolveDeployNode(', deploySource.indexOf(' switchTraffic(')), ); it('loads the rebuilt gateway host blocks before applying HTTPRoutes', () => { const install = switchTraffic.indexOf('installGatewayConf({'); const apply = switchTraffic.indexOf("for (const file of options.gatewayApi ? ['gateway.yaml', 'httproute.yaml']"); expect(install).to.be.greaterThan(-1); expect(apply).to.be.greaterThan(install); }); it('refreshes the shared ingress host table after publishing routes', () => { const apply = switchTraffic.indexOf('shellExec(`sudo kubectl apply -f ${buildPath}/${file}'); const refresh = switchTraffic.indexOf('Underpost.cluster.refreshUnderpostIngress({ namespace, options });'); expect(apply).to.be.greaterThan(-1); expect(refresh).to.be.greaterThan(apply); }); it('keeps the stable selector on the live colour until route migration completes', () => { const ready = switchTraffic.indexOf('!Underpost.deploy.awaitServiceEndpoints({'); const bootstrap = switchTraffic.indexOf('Underpost.deploy.applyTrafficService({', ready); const apply = switchTraffic.indexOf("for (const file of options.gatewayApi ? ['gateway.yaml', 'httproute.yaml']"); const removeOldRoute = switchTraffic.indexOf('Underpost.deploy.removeInactiveHostRoutes({', apply); const targetSelector = switchTraffic.indexOf('if (targetTraffic !== bootstrapTraffic)', removeOldRoute); expect(ready).to.be.greaterThan(-1); expect(bootstrap).to.be.greaterThan(ready); expect(apply).to.be.greaterThan(bootstrap); expect(removeOldRoute).to.be.greaterThan(apply); expect(targetSelector).to.be.greaterThan(removeOldRoute); }); }); describe('merged Gateway listener isolation', () => { const deploySource = fs.readFileSync(new URL('../src/cli/deploy.js', import.meta.url), 'utf8'); const gatewayFactory = deploySource.slice( deploySource.indexOf(' gatewayYamlFactory('), deploySource.indexOf(' gatewayNameFactory(', deploySource.indexOf(' gatewayYamlFactory(')), ); const policyFactory = deploySource.slice( deploySource.indexOf(' clientTrafficPolicyYamlFactory('), deploySource.indexOf(' httpRouteRuleFactory(', deploySource.indexOf(' clientTrafficPolicyYamlFactory(')), ); it('gives every merged HTTP and HTTPS listener an explicit hostname', () => { expect(gatewayFactory).to.include('hostname: ${JSON.stringify(host)}'); expect(gatewayFactory).to.include("gatewayListenerNameFactory({ protocol: 'http', host })"); expect(gatewayFactory).to.include("gatewayListenerNameFactory({ protocol: 'https', host })"); expect(gatewayFactory).not.to.include(' - name: http\n'); expect(gatewayFactory).not.to.include(' - name: https\n'); }); it('targets HTTP/3 at every distinct HTTPS listener from one policy', () => { expect(policyFactory).to.include('const targets = [...new Set([...sectionNames, sectionName].filter(Boolean))]'); expect(policyFactory).to.include('sectionName: ${target}'); expect(policyFactory).to.include(".join('\\n')"); }); }); });