diff --git a/.resinci.yml b/.resinci.yml index fcf68704..65b61524 100644 --- a/.resinci.yml +++ b/.resinci.yml @@ -7,9 +7,14 @@ npm: node_versions: - "12" - "14" - - name: linux - os: alpine - architecture: x86_64 - node_versions: - - "12" - - "14" + ## + ## Temporarily skip Alpine tests until the following issues are resolved: + ## * https://github.com/concourse/concourse/issues/7905 + ## * https://github.com/product-os/balena-concourse/issues/631 + ## + # - name: linux + # os: alpine + # architecture: x86_64 + # node_versions: + # - "12" + # - "14" diff --git a/lib/utils/docker.ts b/lib/utils/docker.ts index ac67c8ef..b9467583 100644 --- a/lib/utils/docker.ts +++ b/lib/utils/docker.ts @@ -174,14 +174,8 @@ export async function isBalenaEngine(docker: dockerode): Promise { ); } -export interface ExtendedDockerOptions extends dockerode.DockerOptions { - docker?: string; // socket path, e.g. /var/run/docker.sock - dockerHost?: string; // host name or IP address - dockerPort?: number; // TCP port number, e.g. 2375 -} - export async function getDocker( - options: ExtendedDockerOptions, + options: DockerConnectionCliFlags, ): Promise { const connectOpts = await generateConnectOpts(options); const client = await createClient(connectOpts); @@ -196,14 +190,18 @@ export async function createClient( return new Docker(opts); } -async function generateConnectOpts(opts: ExtendedDockerOptions) { - let connectOpts: dockerode.DockerOptions = {}; - - // Start with docker-modem defaults which take several env vars into account, - // including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK - // https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70 - const Modem = require('docker-modem'); - const defaultOpts = new Modem(); +/** + * Initialize Docker connection options with the default values from the + * 'docker-modem' package, which takes several env vars into account, + * including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK + * https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70 + * + * @param opts Command line options like --dockerHost and --dockerPort + */ +export function getDefaultDockerModemOpts( + opts: DockerConnectionCliFlags, +): dockerode.DockerOptions { + const connectOpts: dockerode.DockerOptions = {}; const optsOfInterest: Array = [ 'ca', 'cert', @@ -215,9 +213,33 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) { 'username', 'timeout', ]; - for (const opt of optsOfInterest) { - connectOpts[opt] = defaultOpts[opt]; + const Modem = require('docker-modem'); + const originalDockerHost = process.env.DOCKER_HOST; + try { + if (opts.dockerHost) { + process.env.DOCKER_HOST ||= opts.dockerPort + ? `${opts.dockerHost}:${opts.dockerPort}` + : opts.dockerHost; + } + const defaultOpts = new Modem(); + for (const opt of optsOfInterest) { + connectOpts[opt] = defaultOpts[opt]; + } + } finally { + // Did you know? Any value assigned to `process.env.XXX` becomes a string. + // For example, `process.env.DOCKER_HOST = undefined` results in + // value 'undefined' (a 9-character string) being assigned. + if (originalDockerHost) { + process.env.DOCKER_HOST = originalDockerHost; + } else { + delete process.env.DOCKER_HOST; + } } + return connectOpts; +} + +export async function generateConnectOpts(opts: DockerConnectionCliFlags) { + let connectOpts = getDefaultDockerModemOpts(opts); // Now override the default options with any explicit command line options if (opts.docker != null && opts.dockerHost == null) { @@ -241,9 +263,9 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) { // These should be file paths (strings) const tlsOpts = [opts.ca, opts.cert, opts.key]; - // If any are set... + // If any tlsOpts are set... if (tlsOpts.some((opt) => opt)) { - // but not all () + // but not all if (!tlsOpts.every((opt) => opt)) { throw new ExpectedError( 'You must provide a CA, certificate and key in order to use TLS', @@ -258,7 +280,11 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) { const [ca, cert, key] = await Promise.all( tlsOpts.map((opt: string) => fs.readFile(opt, 'utf8')), ); - connectOpts = { ...connectOpts, ca, cert, key }; + // Also ensure that the protocol is 'https' like 'docker-modem' does: + // https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L101-L103 + // TODO: delete redundant logic from this function now that similar logic + // exists in the 'docker-modem' package. + connectOpts = { ...connectOpts, ca, cert, key, protocol: 'https' }; } return connectOpts; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index b9fb0f3a..e7bc6e31 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2557,9 +2557,9 @@ } }, "@types/dockerode": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.0.tgz", - "integrity": "sha512-3Mc0b2gnypJB8Gwmr+8UVPkwjpf4kg1gVxw8lAI4Y/EzpK50LixU1wBSPN9D+xqiw2Ubb02JO8oM0xpwzvi2mg==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.8.tgz", + "integrity": "sha512-/Hip29GzPBWfbSS87lyQDVoB7Ja+kr8oOFWXsySxNFa7jlyj3Yws8LaZRmn1xZl7uJH3Xxsg0oI09GHpT1pIBw==", "dev": true, "requires": { "@types/docker-modem": "*", @@ -2989,9 +2989,9 @@ } }, "@types/ssh2": { - "version": "0.5.49", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.49.tgz", - "integrity": "sha512-ffxhQhJqgTzrw8NxHTgkaDtAmAj2qxCyoves7ztpRgqvzbHcZTpTcm+ATWuuCbPQzxnnF4F3SGGTLGEWTZpwqA==", + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", "dev": true, "requires": { "@types/node": "*", diff --git a/package.json b/package.json index aa71311e..0449e9b4 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "@types/chai-as-promised": "^7.1.4", "@types/cli-truncate": "^2.0.0", "@types/common-tags": "^1.8.1", - "@types/dockerode": "^3.3.0", + "@types/dockerode": "^3.3.8", "@types/ejs": "^3.1.0", "@types/express": "^4.17.13", "@types/fs-extra": "^9.0.13", diff --git a/tests/utils/docker.spec.ts b/tests/utils/docker.spec.ts new file mode 100644 index 00000000..997abdd4 --- /dev/null +++ b/tests/utils/docker.spec.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2022 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; + +import { + DockerConnectionCliFlags, + generateConnectOpts, + getDefaultDockerModemOpts, +} from '../../build/utils/docker'; + +const defaultSocketPath = + process.platform === 'win32' + ? '//./pipe/docker_engine' + : '/var/run/docker.sock'; + +describe('getDefaultDockerModemOpts() function', function () { + it('should use a Unix socket when --dockerHost is not used', () => { + const cliFlags: DockerConnectionCliFlags = { + dockerPort: 2376, + }; + const defaultOps = getDefaultDockerModemOpts(cliFlags); + expect(defaultOps).to.deep.include({ + host: undefined, + port: undefined, + protocol: 'http', + socketPath: defaultSocketPath, + }); + }); + + it('should use the HTTP protocol when --dockerPort is 2375', () => { + const cliFlags: DockerConnectionCliFlags = { + dockerHost: 'foo', + dockerPort: 2375, + }; + const defaultOps = getDefaultDockerModemOpts(cliFlags); + expect(defaultOps).to.deep.include({ + host: 'foo', + port: '2375', + protocol: 'http', + socketPath: undefined, + }); + }); + + it('should use the HTTPS protocol when --dockerPort is 2376', () => { + const cliFlags: DockerConnectionCliFlags = { + dockerHost: 'foo', + dockerPort: 2376, + }; + const defaultOps = getDefaultDockerModemOpts(cliFlags); + expect(defaultOps).to.deep.include({ + host: 'foo', + port: '2376', + protocol: 'https', + socketPath: undefined, + }); + }); +}); + +describe('generateConnectOpts() function', function () { + it('should use a Unix socket when --docker is used', async () => { + const cliFlags: DockerConnectionCliFlags = { + docker: 'foo', + }; + const connectOpts = await generateConnectOpts(cliFlags); + expect(connectOpts).to.deep.include({ + protocol: 'http', + socketPath: 'foo', + }); + expect(connectOpts).to.not.have.any.keys('host', 'port'); + }); + + it('should use the HTTP protocol when --dockerPort is 2375', async () => { + const cliFlags: DockerConnectionCliFlags = { + dockerHost: 'foo', + dockerPort: 2375, + }; + const connectOpts = await generateConnectOpts(cliFlags); + expect(connectOpts).to.deep.include({ + host: 'foo', + port: 2375, + protocol: 'http', + }); + expect(connectOpts).to.not.have.any.keys('socketPath'); + }); + + it('should use the HTTPS protocol when --dockerPort is 2376', async () => { + const cliFlags: DockerConnectionCliFlags = { + dockerHost: 'foo', + dockerPort: 2376, + }; + const connectOpts = await generateConnectOpts(cliFlags); + expect(connectOpts).to.deep.include({ + host: 'foo', + port: 2376, + protocol: 'https', + }); + expect(connectOpts).to.not.have.any.keys('socketPath'); + }); + + it('should use the HTTPS protocol when ca/cert/key are used', async () => { + const path = await import('path'); + const aFile = path.join( + __dirname, + '../test-data/projects/no-docker-compose/dockerignore1/a.txt', + ); + const cliFlags: DockerConnectionCliFlags = { + ca: aFile, + cert: aFile, + key: aFile, + }; + const connectOpts = await generateConnectOpts(cliFlags); + expect(connectOpts).to.deep.include({ + ca: 'a', + cert: 'a', + key: 'a', + host: undefined, + port: undefined, + protocol: 'https', + socketPath: defaultSocketPath, + }); + }); +});