From 4bb46d8a06ff7829b6908bd03c1cf4240767fcc2 Mon Sep 17 00:00:00 2001 From: Rainer Killinger Date: Mon, 9 May 2022 13:51:14 +0200 Subject: [PATCH] feat: support docker swarm deployments --- package-lock.json | 56 ++++++++++++++++++++++-- package.json | 3 ++ src/cli.ts | 3 ++ src/main.ts | 53 ++++++++++++++++++++--- test/main.spec.ts | 106 +++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 204 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 500b0ebf..982a7c8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -649,6 +649,12 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "@types/proxyquire": { + "version": "1.3.28", + "resolved": "https://registry.npmjs.org/@types/proxyquire/-/proxyquire-1.3.28.tgz", + "integrity": "sha512-SQaNzWQ2YZSr7FqAyPPiA3FYpux2Lqh3HWMZQk47x3xbMCqgC/w0dY3dw9rGqlweDDkrySQBcaScXWeR+Yb11Q==", + "dev": true + }, "@types/semver": { "version": "7.3.9", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.9.tgz", @@ -1101,9 +1107,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001338", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001338.tgz", - "integrity": "sha512-1gLHWyfVoRDsHieO+CaeYe7jSo/MT7D7lhaXUiwwbuR5BwQxORs0f1tAwUSQr3YbxRXJvxHM/PA5FfPQRnsPeQ==", + "version": "1.0.30001339", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz", + "integrity": "sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==", "dev": true }, "chai": { @@ -2061,6 +2067,16 @@ "flat-cache": "^3.0.4" } }, + "fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", + "dev": true, + "requires": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2571,6 +2587,12 @@ "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true }, + "is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true + }, "is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -3016,6 +3038,12 @@ } } }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "dev": true + }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3229,6 +3257,12 @@ "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", "dev": true }, + "module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=", + "dev": true + }, "moment": { "version": "2.29.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", @@ -3268,6 +3302,11 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node-port-scanner": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/node-port-scanner/-/node-port-scanner-3.0.1.tgz", + "integrity": "sha512-TuFGEWfye+1atB74v0Vm6myEjpq0L5Jo3UaOG9xgtYHxnFZN0fF9CnwCxp7ENWDerGbI1UXAgdRMIPz8TM73Hg==" + }, "node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -3624,6 +3663,17 @@ "fromentries": "^1.2.0" } }, + "proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "requires": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", diff --git a/package.json b/package.json index 15fb34fc..c1d1296e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dockerode": "3.3.1", "is-cidr": "4.0.2", "mustache": "4.2.0", + "node-port-scanner": "3.0.1", "semver": "7.3.7" }, "devDependencies": { @@ -22,6 +23,7 @@ "@types/chai": "4.3.1", "@types/chai-spies": "1.0.3", "@types/mustache": "4.1.2", + "@types/proxyquire": "1.3.28", "@typescript-eslint/eslint-plugin": "5.22.0", "@typescript-eslint/parser": "5.22.0", "chai": "4.3.6", @@ -36,6 +38,7 @@ "nyc": "15.1.0", "prepend-file-cli": "1.0.6", "prettier": "2.6.2", + "proxyquire": "2.1.3", "rimraf": "3.0.2", "ts-node": "10.7.0", "typedoc": "0.22.15", diff --git a/src/cli.ts b/src/cli.ts index 32abd93a..09a9b562 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -30,6 +30,7 @@ process.on('unhandledRejection', async error => { let containerHashCache = ''; let configHashCache = ''; +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); /** * Reads the container information from the docker socket and updates the nginx config if necessary @@ -51,6 +52,8 @@ async function updateNginxConfig() { // if containers changed -> write config file, reload nginx if (containerHash !== containerHashCache || configHash !== configHashCache) { Logger.log('Generating new NGINX configuration'); + Logger.log('Waiting for Docker network to settle...'); + await delay(10_000); // render nginx config file const nginxConfig = render( diff --git a/src/main.ts b/src/main.ts index 888cd84c..f90cabc9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,6 +32,9 @@ import { /* eslint-disable unicorn/prefer-module */ /* eslint-disable unicorn/no-await-expression-member */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const nodePortScanner = require('node-port-scanner'); + /** * Checks if a ContainerInfo matches a name and version regex * @@ -47,8 +50,11 @@ export function containerMatchesRegex( return ( typeof container.Labels['stapps.version'] === 'string' && container.Labels['stapps.version'].match(versionRegex) !== null && - typeof container.Labels['com.docker.compose.service'] === 'string' && - container.Labels['com.docker.compose.service'] === name + ((typeof container.Labels['com.docker.compose.service'] === 'string' && + container.Labels['com.docker.compose.service'] === name) || + (typeof container.Labels['com.docker.stack.namespace'] === 'string' && + container.Labels['com.docker.swarm.service.name'] === + `${container.Labels['com.docker.stack.namespace']}_${name}`)) ); } @@ -62,14 +68,43 @@ export function containerMatchesRegex( */ export async function getGatewayOfStAppsBackend(container: Dockerode.ContainerInfo): Promise { if (container.Ports.length === 0) { - await Logger.error(`Container ${container.Id} does not advertise any port. + await Logger.error(`Container ${container.Names[0]} does not advertise any port. Please expose a port if the container should be accessible by NGINX.`); return ''; } - // ip:port - return `${container.Ports[0].IP}:${container.Ports[0].PublicPort}`; + // Basic Docker network + if (typeof container.Ports[0].IP !== 'undefined' && typeof container.Ports[0].PublicPort !== 'undefined') { + // ip:port + return `${container.Ports[0].IP}:${container.Ports[0].PublicPort}`; + } + + // Docker Swarm network + if ( + typeof container.NetworkSettings?.Networks?.ingress?.IPAddress !== 'undefined' && + typeof container.Ports[0].PrivatePort !== 'undefined' + ) { + const port = container.Ports[0].PrivatePort; + + // Get a routable network connection + for (const network in container.NetworkSettings.Networks) { + const scan = await nodePortScanner(container.NetworkSettings.Networks[network].IPAddress, [port]); + + if ((scan.ports.open as Array).includes(port)) { + Logger.info( + `${container.Names[0]} reachable via ${container.NetworkSettings.Networks[network].IPAddress}:${port}`, + ); + return `${container.NetworkSettings.Networks[network].IPAddress}:${port}`; + } + } + } + + await Logger.error( + `Couldn't infer ${container.Names[0]} network reachability. It's possible your current Docker network setup isn't supported yet.`, + ); + + return ''; } /** @@ -91,6 +126,10 @@ export async function generateUpstreamMap( let foundMatchingContainer = false; + // const backendContainer = containers.filter(container => container.Image.includes('backend')); + // // eslint-disable-next-line no-console + //console.log(JSON.stringify(backendContainer, undefined, 2)); + // active versions result += ( await Promise.all( @@ -137,7 +176,7 @@ export async function generateUpstreamMap( } } } - await Logger.error('No backend for version', activeVersionRegex, 'found'); + Logger.warn('No backend for version', activeVersionRegex, 'found'); return ` \"~${activeVersionRegex}\" unavailable;\n`; }), @@ -149,7 +188,7 @@ export async function generateUpstreamMap( .map(outdatedVersionRegex => { return ` \"~${outdatedVersionRegex}\" outdated;`; }) - .join(''); + .join('\n'); // eslint-disable-next-line prettier/prettier result += '\n\}'; diff --git a/test/main.spec.ts b/test/main.spec.ts index 382d12ae..fd1e7668 100644 --- a/test/main.spec.ts +++ b/test/main.spec.ts @@ -35,6 +35,9 @@ import { } from '../src/main'; import {resolve} from 'path'; import {mkdirSync, writeFileSync, unlinkSync, rmdirSync} from 'fs'; +import proxyquire from 'proxyquire'; + +proxyquire.callThru().preserveCache(); process.on('unhandledRejection', async error => { await Logger.error(error); @@ -139,6 +142,56 @@ export class MainSpec { Status: 'Up 3 minutes', }; + static swarmBackendContainerWithExposedPorts: ContainerInfo = { + Command: 'node ./bin/www', + Created: 1524669882, + HostConfig: { + NetworkMode: 'deployment_default', + }, + Id: 'e3d3f4d18aceac2780bdb95523845d066ed25c04fc65168a5ddbd37a85671bb7', + Image: 'registry.gitlab.com/openstapps/backend/b-tu-typescript-refactor-for-new-tslint-config', + ImageID: 'sha256:ef9f0c8c4b6f99dd208948c7aae1d042590aa18e05ebeae4f586e4b4beebeac9', + Labels: { + 'com.docker.compose.config-hash': '91c6e0cebad15951824162c93392b6880b69599692f07798ae8de659c1616a03', + 'com.docker.compose.container-number': '1', + 'com.docker.compose.oneoff': 'False', + 'com.docker.compose.project': 'deployment', + 'com.docker.compose.service': 'backend', + 'com.docker.compose.version': '1.21.0', + 'stapps.version': '1.0.0', + }, + Mounts: [], + Names: ['/deployment_backend_1'], + NetworkSettings: { + Networks: { + ingress: { + Aliases: null, + EndpointID: 'da17549a086ff2c9f622e80de833e6f334afda52c8f07080428640c1716dcd14', + Gateway: '172.18.0.1', + GlobalIPv6Address: '', + GlobalIPv6PrefixLen: 0, + IPAMConfig: null, + IPAddress: '172.18.0.3', + IPPrefixLen: 16, + IPv6Gateway: '', + Links: null, + MacAddress: '03:41:ac:11:00:23', + NetworkID: '947ea5247cc7429e1fdebd5404fa4d15f7c05e6765f2b93ddb3bdb6aaffd1193', + }, + }, + }, + Ports: [ + { + IP: 'delete me', + PrivatePort: 3000, + PublicPort: 3000, + Type: 'tcp', + }, + ], + State: 'running', + Status: 'Up 3 minutes', + }; + static sandbox = chai.spy.sandbox(); before() { @@ -199,15 +252,54 @@ export class MainSpec { } @test - async 'upstream map calls logger error when no matching container is found'() { - const spy = MainSpec.sandbox.on(console, 'warn', () => { + async 'get gateway of backend container within docker swarm'() { + const backendContainer = MainSpec.swarmBackendContainerWithExposedPorts as any; + delete backendContainer.Ports[0].IP; + + const main = proxyquire('../src/main', { + 'node-port-scanner': (_host: unknown, _ports: unknown) => { + return new Promise((resolve, _reject) => { + resolve({ + ports: { + open: [3000], + }, + }); + }); + }, + }); + expect(await main.getGatewayOfStAppsBackend(backendContainer)).to.be.equal('172.18.0.3:3000'); + } + + @test + async 'fail to get gateway of backend container if unreachable'() { + const backendContainer = MainSpec.swarmBackendContainerWithExposedPorts as any; + delete backendContainer.Ports[0].IP; + delete backendContainer.Ports[0].PublicPort; + delete backendContainer.Ports[0].PrivatePort; + + const spy = MainSpec.sandbox.on(console, 'error', () => { + // noop }); - expect(await generateUpstreamMap( - ['0\\.8\\.\\d+'], - ['1\\.1\\.\\d+'], - [MainSpec.backendContainerWithExposedPorts], - )).to.be.equal(`map $http_x_stapps_version $proxyurl { + expect(await getGatewayOfStAppsBackend(MainSpec.swarmBackendContainerWithExposedPorts)).to.be.equal(''); + expect(spy.__spy.calls[0][0]).to.contain( + "It's possible your current Docker network setup isn't supported yet.", + ); + } + + @test + async 'upstream map calls logger error when no matching container is found'() { + const spy = MainSpec.sandbox.on(console, 'warn', () => { + // noop + }); + + expect( + await generateUpstreamMap( + ['0\\.8\\.\\d+'], + ['1\\.1\\.\\d+'], + [MainSpec.backendContainerWithExposedPorts], + ), + ).to.be.equal(`map $http_x_stapps_version $proxyurl { default unsupported; "~0\\.8\\.\\d+" unavailable; "~1\\.1\\.\\d+" outdated;