From 23f2242e22a09c559bcb8b62ce4e8f5c8ad6e1db Mon Sep 17 00:00:00 2001 From: Pagan Gazzard Date: Fri, 24 Apr 2020 14:41:15 +0100 Subject: [PATCH] Convert lib/utils/docker-coffee.coffee to javascript Change-type: patch --- lib/utils/docker-coffee.coffee | 219 ------------------------- lib/utils/docker-coffee.d.ts | 43 ----- lib/utils/docker-js.js | 281 +++++++++++++++++++++++++++++++++ lib/utils/docker.ts | 2 +- 4 files changed, 282 insertions(+), 263 deletions(-) delete mode 100644 lib/utils/docker-coffee.coffee delete mode 100644 lib/utils/docker-coffee.d.ts create mode 100644 lib/utils/docker-js.js diff --git a/lib/utils/docker-coffee.coffee b/lib/utils/docker-coffee.coffee deleted file mode 100644 index cce74782..00000000 --- a/lib/utils/docker-coffee.coffee +++ /dev/null @@ -1,219 +0,0 @@ -###* -# @license -# Copyright 2017-2019 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. -### - -# Functions to help actions which rely on using docker - -Promise = require('bluebird') -_ = require('lodash') - -# Use this function to seed an action's list of capitano options -# with the docker options. Using this interface means that -# all functions using docker will expose the same interface -# -# NOTE: Care MUST be taken when using the function, so as to -# not redefine/override options already provided. -exports.appendConnectionOptions = appendConnectionOptions = (opts) -> - opts.concat [ - { - signature: 'docker' - parameter: 'docker' - description: 'Path to a local docker socket (e.g. /var/run/docker.sock)' - alias: 'P' - }, - { - signature: 'dockerHost' - parameter: 'dockerHost' - description: 'Docker daemon hostname or IP address (dev machine or balena device) ' - alias: 'h' - }, - { - signature: 'dockerPort' - parameter: 'dockerPort' - description: 'Docker daemon TCP port number (hint: 2375 for balena devices)' - alias: 'p' - }, - { - signature: 'ca' - parameter: 'ca' - description: 'Docker host TLS certificate authority file' - }, - { - signature: 'cert' - parameter: 'cert' - description: 'Docker host TLS certificate file' - }, - { - signature: 'key' - parameter: 'key' - description: 'Docker host TLS key file' - }, - ] - -# Use this function to seed an action's list of capitano options -# with the docker options. Using this interface means that -# all functions using docker will expose the same interface -# -# NOTE: Care MUST be taken when using the function, so as to -# not redefine/override options already provided. -exports.appendOptions = (opts) -> - appendConnectionOptions(opts).concat [ - { - signature: 'tag' - parameter: 'tag' - description: 'The alias to the generated image' - alias: 't' - }, - { - signature: 'buildArg' - parameter: 'arg' - description: 'Set a build-time variable (eg. "-B \'ARG=value\'"). Can be specified multiple times.' - alias: 'B' - }, - { - signature: 'cache-from' - parameter: 'image-list' - description: ' - Comma-separated list (no spaces) of image names for build cache resolution. - Implements the same feature as the "docker build --cache-from" option.' - }, - { - signature: 'nocache' - description: "Don't use docker layer caching when building" - boolean: true - }, - { - signature: 'squash' - description: 'Squash newly built layers into a single new layer' - boolean: true - } - ] - -generateConnectOpts = (opts) -> - fs = require('mz/fs') - _ = require('lodash') - - Promise.try -> - connectOpts = {} - # Firsly need to decide between a local docker socket - # and a host available over a host:port combo - if opts.docker? and not opts.dockerHost? - # good, local docker socket - connectOpts.socketPath = opts.docker - else if opts.dockerHost? and not opts.docker? - # Good a host is provided, and local socket isn't - connectOpts.host = opts.dockerHost - connectOpts.port = opts.dockerPort || 2376 - else if opts.docker? and opts.dockerHost? - # Both provided, no obvious way to continue - throw new Error("Both a local docker socket and docker host have been provided. Don't know how to continue.") - else - # Use docker-modem defaults which take the DOCKER_HOST env var into account - # https://github.com/apocas/docker-modem/blob/v2.0.2/lib/modem.js#L16-L65 - Modem = require('docker-modem') - defaultOpts = new Modem() - connectOpts[opt] = defaultOpts[opt] for opt in ['host', 'port', 'socketPath'] - - # Now need to check if the user wants to connect over TLS - # to the host - - # If any are set... - if (opts.ca? or opts.cert? or opts.key?) - # but not all - if not (opts.ca? and opts.cert? and opts.key?) - throw new Error('You must provide a CA, certificate and key in order to use TLS') - - certBodies = { - ca: fs.readFile(opts.ca, 'utf-8') - cert: fs.readFile(opts.cert, 'utf-8') - key: fs.readFile(opts.key, 'utf-8') - } - return Promise.props(certBodies) - .then (toMerge) -> - _.merge(connectOpts, toMerge) - - return connectOpts - -parseBuildArgs = (args) -> - if not Array.isArray(args) - args = [ args ] - buildArgs = {} - args.forEach (arg) -> - # note: [^] matches any character, including line breaks - pair = /^([^\s]+?)=([^]*)$/.exec(arg) - if pair? - buildArgs[pair[1]] = pair[2] ? '' - else - throw new Error("Could not parse build argument: '#{arg}'") - return buildArgs - -exports.generateBuildOpts = (options) -> - opts = {} - if options.tag? - opts.t = options.tag - if options.nocache? - opts.nocache = true - if options['cache-from']?.trim() - opts.cachefrom = options['cache-from'].split(',').filter((i) -> !!i.trim()) - if options.squash? - opts.squash = true - if options.buildArg? - opts.buildargs = parseBuildArgs(options.buildArg) - if not _.isEmpty(options['registry-secrets']) - opts.registryconfig = options['registry-secrets'] - return opts - -exports.getDocker = (options) -> - generateConnectOpts(options) - .then(createClient) - .tap(ensureDockerSeemsAccessible) - -getDockerToolbelt = _.once -> - Docker = require('docker-toolbelt') - Promise.promisifyAll Docker.prototype, { - filter: (name) -> name == 'run' - multiArgs: true - } - Promise.promisifyAll(Docker.prototype) - Promise.promisifyAll(new Docker({}).getImage().constructor.prototype) - Promise.promisifyAll(new Docker({}).getContainer().constructor.prototype) - return Docker - -# docker-toolbelt v3 is not backwards compatible as it removes all *Async -# methods that are in wide use in the CLI. The workaround for now is to -# manually promisify the client and replace all `new Docker()` calls with -# this shared function that returns a promisified client. -# -# **New code must not use the *Async methods.** -# -exports.createClient = createClient = (opts) -> - Docker = getDockerToolbelt() - docker = new Docker(opts) - modem = docker.modem - # Workaround for a docker-modem 2.0.x bug where it sets a default - # socketPath on Windows even if the input options specify a host/port. - if modem.socketPath and modem.host - if opts.socketPath - modem.host = undefined - modem.port = undefined - else if opts.host - modem.socketPath = undefined - return docker - -ensureDockerSeemsAccessible = (docker) -> - { exitWithExpectedError } = require('./patterns') - docker.ping().catch -> - exitWithExpectedError('Docker seems to be unavailable. Is it installed and running?') diff --git a/lib/utils/docker-coffee.d.ts b/lib/utils/docker-coffee.d.ts deleted file mode 100644 index a3491f03..00000000 --- a/lib/utils/docker-coffee.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @license - * Copyright 2019 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 * as Bluebird from 'bluebird'; -import DockerToolbelt = require('docker-toolbelt'); - -export interface BuildDockerOptions { - ca?: string; // path to ca (Certificate Authority) file (TLS) - cert?: string; // path to cert (Certificate) file (TLS) - key?: string; // path to key file (TLS) - docker?: string; // dockerode DockerOptions.socketPath - dockerHost?: string; // dockerode DockerOptions.host - dockerPort?: number; // dockerode DockerOptions.port - host?: string; - port?: number; - timeout?: number; -} - -export interface DockerToolbeltOpts { - host: string; - port: number; - timeout?: number; -} - -export function getDocker( - options: BuildDockerOptions, -): Bluebird; - -export function createClient(opts: DockerToolbeltOpts): DockerToolbelt; diff --git a/lib/utils/docker-js.js b/lib/utils/docker-js.js new file mode 100644 index 00000000..d8437ca2 --- /dev/null +++ b/lib/utils/docker-js.js @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2017-2020 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. + */ + +// Functions to help actions which rely on using docker + +import * as Promise from 'bluebird'; +import * as _ from 'lodash'; + +// Use this function to seed an action's list of capitano options +// with the docker options. Using this interface means that +// all functions using docker will expose the same interface +// +// NOTE: Care MUST be taken when using the function, so as to +// not redefine/override options already provided. +export const appendConnectionOptions = opts => + opts.concat([ + { + signature: 'docker', + parameter: 'docker', + description: 'Path to a local docker socket (e.g. /var/run/docker.sock)', + alias: 'P', + }, + { + signature: 'dockerHost', + parameter: 'dockerHost', + description: + 'Docker daemon hostname or IP address (dev machine or balena device) ', + alias: 'h', + }, + { + signature: 'dockerPort', + parameter: 'dockerPort', + description: + 'Docker daemon TCP port number (hint: 2375 for balena devices)', + alias: 'p', + }, + { + signature: 'ca', + parameter: 'ca', + description: 'Docker host TLS certificate authority file', + }, + { + signature: 'cert', + parameter: 'cert', + description: 'Docker host TLS certificate file', + }, + { + signature: 'key', + parameter: 'key', + description: 'Docker host TLS key file', + }, + ]); + +// Use this function to seed an action's list of capitano options +// with the docker options. Using this interface means that +// all functions using docker will expose the same interface +// +// NOTE: Care MUST be taken when using the function, so as to +// not redefine/override options already provided. +export function appendOptions(opts) { + return appendConnectionOptions(opts).concat([ + { + signature: 'tag', + parameter: 'tag', + description: 'The alias to the generated image', + alias: 't', + }, + { + signature: 'buildArg', + parameter: 'arg', + description: + 'Set a build-time variable (eg. "-B \'ARG=value\'"). Can be specified multiple times.', + alias: 'B', + }, + { + signature: 'cache-from', + parameter: 'image-list', + description: `\ +Comma-separated list (no spaces) of image names for build cache resolution. \ +Implements the same feature as the "docker build --cache-from" option.`, + }, + { + signature: 'nocache', + description: "Don't use docker layer caching when building", + boolean: true, + }, + { + signature: 'squash', + description: 'Squash newly built layers into a single new layer', + boolean: true, + }, + ]); +} + +const generateConnectOpts = function(opts) { + const fs = require('mz/fs'); + + return Promise.try(function() { + const connectOpts = {}; + // Firsly need to decide between a local docker socket + // and a host available over a host:port combo + if (opts.docker != null && opts.dockerHost == null) { + // good, local docker socket + connectOpts.socketPath = opts.docker; + } else if (opts.dockerHost != null && opts.docker == null) { + // Good a host is provided, and local socket isn't + connectOpts.host = opts.dockerHost; + connectOpts.port = opts.dockerPort || 2376; + } else if (opts.docker != null && opts.dockerHost != null) { + // Both provided, no obvious way to continue + throw new Error( + "Both a local docker socket and docker host have been provided. Don't know how to continue.", + ); + } else { + // Use docker-modem defaults which take the DOCKER_HOST env var into account + // https://github.com/apocas/docker-modem/blob/v2.0.2/lib/modem.js#L16-L65 + const Modem = require('docker-modem'); + const defaultOpts = new Modem(); + for (let opt of ['host', 'port', 'socketPath']) { + connectOpts[opt] = defaultOpts[opt]; + } + } + + // Now need to check if the user wants to connect over TLS + // to the host + + // If any are set... + if (opts.ca != null || opts.cert != null || opts.key != null) { + // but not all + if (!(opts.ca != null && opts.cert != null && opts.key != null)) { + throw new Error( + 'You must provide a CA, certificate and key in order to use TLS', + ); + } + + const certBodies = { + ca: fs.readFile(opts.ca, 'utf-8'), + cert: fs.readFile(opts.cert, 'utf-8'), + key: fs.readFile(opts.key, 'utf-8'), + }; + return Promise.props(certBodies).then(toMerge => + _.merge(connectOpts, toMerge), + ); + } + + return connectOpts; + }); +}; + +const parseBuildArgs = function(args) { + if (!Array.isArray(args)) { + args = [args]; + } + const buildArgs = {}; + args.forEach(function(arg) { + // note: [^] matches any character, including line breaks + const pair = /^([^\s]+?)=([^]*)$/.exec(arg); + if (pair != null) { + buildArgs[pair[1]] = pair[2] ?? ''; + } else { + throw new Error(`Could not parse build argument: '${arg}'`); + } + }); + return buildArgs; +}; + +export function generateBuildOpts(options) { + const opts = {}; + if (options.tag != null) { + opts.t = options.tag; + } + if (options.nocache != null) { + opts.nocache = true; + } + if (options['cache-from']?.trim()) { + opts.cachefrom = options['cache-from'].split(',').filter(i => !!i.trim()); + } + if (options.squash != null) { + opts.squash = true; + } + if (options.buildArg != null) { + opts.buildargs = parseBuildArgs(options.buildArg); + } + if (!_.isEmpty(options['registry-secrets'])) { + opts.registryconfig = options['registry-secrets']; + } + return opts; +} +/** + * @param {{ + * ca?: string; // path to ca (Certificate Authority) file (TLS) + * cert?: string; // path to cert (Certificate) file (TLS) + * key?: string; // path to key file (TLS) + * docker?: string; // dockerode DockerOptions.socketPath + * dockerHost?: string; // dockerode DockerOptions.host + * dockerPort?: number; // dockerode DockerOptions.port + * host?: string; + * port?: number; + * timeout?: number; + * }} options + * @returns {Promise} + */ +export function getDocker(options) { + return generateConnectOpts(options) + .then(createClient) + .tap(ensureDockerSeemsAccessible); +} + +const getDockerToolbelt = _.once(function() { + const Docker = require('docker-toolbelt'); + Promise.promisifyAll(Docker.prototype, { + filter(name) { + return name === 'run'; + }, + multiArgs: true, + }); + Promise.promisifyAll(Docker.prototype); + // @ts-ignore `getImage()` should have a param but this whole thing is a hack that should be removed + Promise.promisifyAll(new Docker({}).getImage().constructor.prototype); + // @ts-ignore `getContainer()` should have a param but this whole thing is a hack that should be removed + Promise.promisifyAll(new Docker({}).getContainer().constructor.prototype); + return Docker; +}); + +// docker-toolbelt v3 is not backwards compatible as it removes all *Async +// methods that are in wide use in the CLI. The workaround for now is to +// manually promisify the client and replace all `new Docker()` calls with +// this shared function that returns a promisified client. +// +// **New code must not use the *Async methods.** +// +/** + * @param {{ + * host: string; + * port: number; + * timeout?: number; + * socketPath?: string + * }} opts + * @returns {import('docker-toolbelt')} + */ +export const createClient = function(opts) { + const Docker = getDockerToolbelt(); + const docker = new Docker(opts); + const { modem } = docker; + // Workaround for a docker-modem 2.0.x bug where it sets a default + // socketPath on Windows even if the input options specify a host/port. + if (modem.socketPath && modem.host) { + if (opts.socketPath) { + modem.host = undefined; + modem.port = undefined; + } else if (opts.host) { + modem.socketPath = undefined; + } + } + return docker; +}; + +var ensureDockerSeemsAccessible = function(docker) { + const { exitWithExpectedError } = require('./patterns'); + return docker + .ping() + .catch(() => + exitWithExpectedError( + 'Docker seems to be unavailable. Is it installed and running?', + ), + ); +}; diff --git a/lib/utils/docker.ts b/lib/utils/docker.ts index 1aef1563..10f7a583 100644 --- a/lib/utils/docker.ts +++ b/lib/utils/docker.ts @@ -18,7 +18,7 @@ import Dockerode = require('dockerode'); import * as dockerode from 'dockerode'; -export * from './docker-coffee'; +export * from './docker-js'; interface BalenaEngineVersion extends dockerode.DockerVersion { Engine?: string;