Add --dockerfile option to the build, deploy and push commands

It allows the selection of an alternative Dockerfile in single-
container projects that do not include a docker-compose file.

Change-type: minor
Signed-off-by: Paulo Castro <paulo@balena.io>
This commit is contained in:
Paulo Castro 2019-04-11 12:49:19 +01:00
parent 296f1ae2de
commit db25a65753
12 changed files with 193 additions and 60 deletions

View File

@ -1442,6 +1442,10 @@ The source that should be sent to the balena builder to be built (defaults to th
Force an emulated build to occur on the remote builder Force an emulated build to occur on the remote builder
#### --dockerfile &#60;Dockerfile&#62;
Alternative Dockerfile name/path, relative to the source folder
#### --nocache, -c #### --nocache, -c
Don't use cache when building this project Don't use cache when building this project
@ -1712,20 +1716,20 @@ name of container to stop
## build [source] ## build [source]
Use this command to build an image or a complete multicontainer project Use this command to build an image or a complete multicontainer project with
with the provided docker daemon in your development machine or balena the provided docker daemon in your development machine or balena device.
device. (See also the `balena push` command for the option of building (See also the `balena push` command for the option of building images in the
images in the balenaCloud build servers.) balenaCloud build servers.)
You must provide either an application or a device-type/architecture You must provide either an application or a device-type/architecture pair to use
pair to use the balena Dockerfile pre-processor the balena Dockerfile pre-processor (e.g. Dockerfile.template -> Dockerfile).
(e.g. Dockerfile.template -> Dockerfile).
This command will look into the given source directory (or the current working This command will look into the given source directory (or the current working
directory if one isn't specified) for a compose file. If one is found, this directory if one isn't specified) for a docker-compose.yml file. If it is found,
command will build each service defined in the compose file. If a compose file this command will build each service defined in the compose file. If a compose
isn't found, the command will look for a Dockerfile, and if yet that isn't found, file isn't found, the command will look for a Dockerfile[.template] file (or
it will try to generate one. alternative Dockerfile specified with the `-f` option), and if yet that isn't
found, it will try to generate one.
The --registry-secrets option specifies a JSON or YAML file containing private The --registry-secrets option specifies a JSON or YAML file containing private
Docker registry usernames and passwords to be used when pulling base images. Docker registry usernames and passwords to be used when pulling base images.
@ -1772,6 +1776,10 @@ Specify an alternate project name; default is the directory name
Run an emulated build using Qemu Run an emulated build using Qemu
#### --dockerfile &#60;Dockerfile&#62;
Alternative Dockerfile name/path, relative to the source folder
#### --logs #### --logs
Display full log output Display full log output
@ -1831,17 +1839,18 @@ balena device. (See also the `balena push` command for the option of building
the image in the balenaCloud build servers.) the image in the balenaCloud build servers.)
Unless an image is specified, this command will look into the current directory Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a compose file. If one is found, this (or the one specified by --source) for a docker-compose.yml file. If one is
command will deploy each service defined in the compose file, building it first found, this command will deploy each service defined in the compose file,
if an image for it doesn't exist. If a compose file isn't found, the command building it first if an image for it doesn't exist. If a compose file isn't
will look for a Dockerfile, and if yet that isn't found, it will try to found, the command will look for a Dockerfile[.template] file (or alternative
generate one. Dockerfile specified with the `-f` option), and if yet that isn't found, it
will try to generate one.
To deploy to an app on which you're a collaborator, use To deploy to an app on which you're a collaborator, use
`balena deploy <appOwnerUsername>/<appName>`. `balena deploy <appOwnerUsername>/<appName>`.
When --build is used, all options supported by `balena build` are also When --build is used, all options supported by `balena build` are also supported
supported by this command. by this command.
The --registry-secrets option specifies a JSON or YAML file containing private The --registry-secrets option specifies a JSON or YAML file containing private
Docker registry usernames and passwords to be used when pulling base images. Docker registry usernames and passwords to be used when pulling base images.
@ -1885,6 +1894,10 @@ Specify an alternate project name; default is the directory name
Run an emulated build using Qemu Run an emulated build using Qemu
#### --dockerfile &#60;Dockerfile&#62;
Alternative Dockerfile name/path, relative to the source folder
#### --logs #### --logs
Display full log output Display full log output

View File

@ -19,6 +19,8 @@ buildProject = (docker, logger, composeOpts, opts) ->
logger logger
composeOpts.projectPath composeOpts.projectPath
composeOpts.projectName composeOpts.projectName
undefined # image: name of pre-built image
composeOpts.dockerfilePath # ok if undefined
) )
.then (project) -> .then (project) ->
appType = opts.app?.application_type?[0] appType = opts.app?.application_type?[0]
@ -50,20 +52,20 @@ module.exports =
description: 'Build a single image or a multicontainer project locally' description: 'Build a single image or a multicontainer project locally'
primary: true primary: true
help: """ help: """
Use this command to build an image or a complete multicontainer project Use this command to build an image or a complete multicontainer project with
with the provided docker daemon in your development machine or balena the provided docker daemon in your development machine or balena device.
device. (See also the `balena push` command for the option of building (See also the `balena push` command for the option of building images in the
images in the balenaCloud build servers.) balenaCloud build servers.)
You must provide either an application or a device-type/architecture You must provide either an application or a device-type/architecture pair to use
pair to use the balena Dockerfile pre-processor the balena Dockerfile pre-processor (e.g. Dockerfile.template -> Dockerfile).
(e.g. Dockerfile.template -> Dockerfile).
This command will look into the given source directory (or the current working This command will look into the given source directory (or the current working
directory if one isn't specified) for a compose file. If one is found, this directory if one isn't specified) for a docker-compose.yml file. If it is found,
command will build each service defined in the compose file. If a compose file this command will build each service defined in the compose file. If a compose
isn't found, the command will look for a Dockerfile, and if yet that isn't found, file isn't found, the command will look for a Dockerfile[.template] file (or
it will try to generate one. alternative Dockerfile specified with the `-f` option), and if yet that isn't
found, it will try to generate one.
#{registrySecretsHelp} #{registrySecretsHelp}

View File

@ -10,6 +10,7 @@ Opts must be an object with the following keys:
app: the application instance to deploy to app: the application instance to deploy to
image: the image to deploy; optional image: the image to deploy; optional
dockerfilePath: name of an alternative Dockerfile; optional
shouldPerformBuild shouldPerformBuild
shouldUploadLogs shouldUploadLogs
buildEmulated buildEmulated
@ -25,6 +26,7 @@ deployProject = (docker, logger, composeOpts, opts) ->
composeOpts.projectPath composeOpts.projectPath
composeOpts.projectName composeOpts.projectName
opts.image opts.image
composeOpts.dockerfilePath # ok if undefined
) )
.then (project) -> .then (project) ->
if project.descriptors.length > 1 and !opts.app.application_type?[0]?.supports_multicontainer if project.descriptors.length > 1 and !opts.app.application_type?[0]?.supports_multicontainer
@ -133,17 +135,18 @@ module.exports =
the image in the balenaCloud build servers.) the image in the balenaCloud build servers.)
Unless an image is specified, this command will look into the current directory Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a compose file. If one is found, this (or the one specified by --source) for a docker-compose.yml file. If one is
command will deploy each service defined in the compose file, building it first found, this command will deploy each service defined in the compose file,
if an image for it doesn't exist. If a compose file isn't found, the command building it first if an image for it doesn't exist. If a compose file isn't
will look for a Dockerfile, and if yet that isn't found, it will try to found, the command will look for a Dockerfile[.template] file (or alternative
generate one. Dockerfile specified with the `-f` option), and if yet that isn't found, it
will try to generate one.
To deploy to an app on which you're a collaborator, use To deploy to an app on which you're a collaborator, use
`balena deploy <appOwnerUsername>/<appName>`. `balena deploy <appOwnerUsername>/<appName>`.
When --build is used, all options supported by `balena build` are also When --build is used, all options supported by `balena build` are also supported
supported by this command. by this command.
#{registrySecretsHelp} #{registrySecretsHelp}

View File

@ -104,6 +104,7 @@ export const push: CommandDefinition<
{ {
source: string; source: string;
emulated: boolean; emulated: boolean;
dockerfile: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
nocache: boolean; nocache: boolean;
'registry-secrets': string; 'registry-secrets': string;
live: boolean; live: boolean;
@ -158,6 +159,12 @@ export const push: CommandDefinition<
description: 'Force an emulated build to occur on the remote builder', description: 'Force an emulated build to occur on the remote builder',
boolean: true, boolean: true,
}, },
{
signature: 'dockerfile',
parameter: 'Dockerfile',
description:
'Alternative Dockerfile name/path, relative to the source folder',
},
{ {
signature: 'nocache', signature: 'nocache',
alias: 'c', alias: 'c',
@ -194,7 +201,9 @@ export const push: CommandDefinition<
const { exitIfNotLoggedIn, exitWithExpectedError } = await import( const { exitIfNotLoggedIn, exitWithExpectedError } = await import(
'../utils/patterns' '../utils/patterns'
); );
const { parseRegistrySecrets } = await import('../utils/compose_ts'); const { validateSpecifiedDockerfile, parseRegistrySecrets } = await import(
'../utils/compose_ts'
);
const { BuildError } = await import('../utils/device/errors'); const { BuildError } = await import('../utils/device/errors');
const appOrDevice: string | null = params.applicationOrDevice; const appOrDevice: string | null = params.applicationOrDevice;
@ -207,6 +216,11 @@ export const push: CommandDefinition<
console.log(`[debug] Using ${source} as build source`); console.log(`[debug] Using ${source} as build source`);
} }
const dockerfilePath = validateSpecifiedDockerfile(
source,
options.dockerfile,
);
const registrySecrets = options['registry-secrets'] const registrySecrets = options['registry-secrets']
? await parseRegistrySecrets(options['registry-secrets']) ? await parseRegistrySecrets(options['registry-secrets'])
: {}; : {};
@ -229,6 +243,7 @@ export const push: CommandDefinition<
getAppOwner(sdk, app), getAppOwner(sdk, app),
async (token, baseUrl, owner) => { async (token, baseUrl, owner) => {
const opts = { const opts = {
dockerfilePath,
emulated: options.emulated, emulated: options.emulated,
nocache: options.nocache, nocache: options.nocache,
registrySecrets, registrySecrets,
@ -254,6 +269,7 @@ export const push: CommandDefinition<
deviceDeploy.deployToDevice({ deviceDeploy.deployToDevice({
source, source,
deviceHost: device, deviceHost: device,
dockerfilePath,
registrySecrets, registrySecrets,
nocache: options.nocache || false, nocache: options.nocache || false,
live: options.live || false, live: options.live || false,

View File

@ -36,16 +36,21 @@ exports.appendOptions = (opts) ->
boolean: true boolean: true
alias: 'e' alias: 'e'
}, },
{
signature: 'dockerfile'
parameter: 'Dockerfile'
description: 'Alternative Dockerfile name/path, relative to the source folder'
},
{ {
signature: 'logs' signature: 'logs'
description: 'Display full log output' description: 'Display full log output'
boolean: true boolean: true
}, },
{ {
signature: 'registry-secrets', signature: 'registry-secrets'
alias: 'R', alias: 'R'
parameter: 'secrets.yml|.json', parameter: 'secrets.yml|.json'
description: 'Path to a YAML or JSON file with passwords for a private Docker registry', description: 'Path to a YAML or JSON file with passwords for a private Docker registry'
}, },
] ]
@ -55,6 +60,7 @@ exports.generateOpts = (options) ->
projectName: options.projectName projectName: options.projectName
projectPath: projectPath projectPath: projectPath
inlineLogs: !!options.logs inlineLogs: !!options.logs
dockerfilePath: options.dockerfile
compositionFileNames = [ compositionFileNames = [
'docker-compose.yml' 'docker-compose.yml'
@ -97,11 +103,14 @@ createProject = (composePath, composeStr, projectName = null) ->
# of it in one go. if image is given, it'll create a default project for # of it in one go. if image is given, it'll create a default project for
# that without looking for a project. falls back to creating a default # that without looking for a project. falls back to creating a default
# project if none is found at the given projectPath. # project if none is found at the given projectPath.
exports.loadProject = (logger, projectPath, projectName, image) -> exports.loadProject = (logger, projectPath, projectName, image, dockerfilePath) ->
{ validateSpecifiedDockerfile } = require('./compose_ts')
compose = require('resin-compose-parse') compose = require('resin-compose-parse')
logger.logDebug('Loading project...') logger.logDebug('Loading project...')
Promise.try -> Promise.try ->
dockerfilePath = validateSpecifiedDockerfile(projectPath, dockerfilePath)
if image? if image?
logger.logInfo("Creating default composition with image: #{image}") logger.logInfo("Creating default composition with image: #{image}")
return compose.defaultComposition(image) return compose.defaultComposition(image)
@ -110,11 +119,14 @@ exports.loadProject = (logger, projectPath, projectName, image) ->
resolveProject(projectPath) resolveProject(projectPath)
.tap -> .tap ->
logger.logInfo('Compose file detected') if dockerfilePath
logger.logWarn("Ignoring alternative dockerfile \"#{dockerfilePath}\"\ because a docker-compose file exists")
else
logger.logInfo('Compose file detected')
.catch (e) -> .catch (e) ->
logger.logDebug("Failed to resolve project: #{e}") logger.logDebug("Failed to resolve project: #{e}")
logger.logInfo("Creating default composition with source: #{projectPath}") logger.logInfo("Creating default composition with source: #{projectPath}")
return compose.defaultComposition() return compose.defaultComposition(undefined, dockerfilePath)
.then (composeStr) -> .then (composeStr) ->
logger.logDebug('Creating project...') logger.logDebug('Creating project...')
createProject(projectPath, composeStr, projectName) createProject(projectPath, composeStr, projectName)
@ -126,7 +138,7 @@ exports.tarDirectory = tarDirectory = (dir, preFinalizeCallback = null) ->
fs = require('mz/fs') fs = require('mz/fs')
streamToPromise = require('stream-to-promise') streamToPromise = require('stream-to-promise')
{ FileIgnorer } = require('./ignore') { FileIgnorer } = require('./ignore')
{ toPosixPath } = require('./helpers') { toPosixPath } = require('resin-multibuild').PathUtils
getFiles = -> getFiles = ->
streamToPromise(klaw(dir)) streamToPromise(klaw(dir))
@ -175,7 +187,7 @@ exports.buildProject = (
builder = require('resin-multibuild') builder = require('resin-multibuild')
transpose = require('docker-qemu-transpose') transpose = require('docker-qemu-transpose')
qemu = require('./qemu') qemu = require('./qemu')
{ toPosixPath } = require('./helpers') { toPosixPath } = builder.PathUtils
logger.logInfo("Building for #{arch}/#{deviceType}") logger.logInfo("Building for #{arch}/#{deviceType}")

View File

@ -46,6 +46,7 @@ export function loadProject(
projectPath: string, projectPath: string,
projectName: string, projectName: string,
image?: string, image?: string,
dockerfilePath?: string,
): Bluebird<ComposeProject>; ): Bluebird<ComposeProject>;
export function tarDirectory( export function tarDirectory(

View File

@ -155,3 +155,70 @@ async function performResolution(
}).then(resolve, reject); }).then(resolve, reject);
}); });
} }
/**
* Enforce that, for example, if 'myProject/MyDockerfile.template' is specified
* as an alternativate Dockerfile name, then 'myProject/MyDockerfile' must not
* exist.
* @param projectPath The project source folder (-s command-line option)
* @param dockerfilePath The alternative Dockerfile specified by the user
*/
export function validateSpecifiedDockerfile(
projectPath: string,
dockerfilePath: string = '',
): string {
if (!dockerfilePath) {
return dockerfilePath;
}
const { exitWithExpectedError } = require('../utils/patterns');
const { isAbsolute, join, normalize, parse, posix } = require('path');
const { existsSync } = require('fs');
const { stripIndent } = require('common-tags');
const { contains, toNativePath, toPosixPath } = MultiBuild.PathUtils;
// reminder: native windows paths may start with a drive specificaton,
// e.g. 'C:\absolute' or 'C:relative'.
if (isAbsolute(dockerfilePath) || posix.isAbsolute(dockerfilePath)) {
exitWithExpectedError(stripIndent`
Error: absolute Dockerfile path detected:
"${dockerfilePath}"
The Dockerfile path should be relative to the source folder.
`);
}
const nativeProjectPath = normalize(projectPath);
const nativeDockerfilePath = join(projectPath, toNativePath(dockerfilePath));
if (!contains(nativeProjectPath, nativeDockerfilePath)) {
// Note that testing the existence of nativeDockerfilePath in the
// filesystem (after joining its path to the source folder) is not
// sufficient, because the user could have added '../' to the path.
exitWithExpectedError(stripIndent`
Error: the specified Dockerfile must be in a subfolder of the source folder:
Specified dockerfile: "${nativeDockerfilePath}"
Source folder: "${nativeProjectPath}"
`);
}
if (!existsSync(nativeDockerfilePath)) {
exitWithExpectedError(stripIndent`
Error: Dockerfile not found: "${nativeDockerfilePath}"
`);
}
const { dir, ext, name } = parse(nativeDockerfilePath);
if (ext) {
const nativePathMinusExt = join(dir, name);
if (existsSync(nativePathMinusExt)) {
exitWithExpectedError(stripIndent`
Error: "${name}" exists on the same folder as "${dockerfilePath}".
When an alternative Dockerfile name is specified, a file with the same
base name (minus the file extension) must not exist in the same folder.
This is because the base name file will be auto generated and added to
the tar stream that is sent to the docker daemon, resulting in duplicate
Dockerfiles and undefined behavior.
`);
}
}
return posix.normalize(toPosixPath(dockerfilePath));
}

View File

@ -43,6 +43,7 @@ export interface DeviceDeployOptions {
source: string; source: string;
deviceHost: string; deviceHost: string;
devicePort?: number; devicePort?: number;
dockerfilePath?: string;
registrySecrets: RegistrySecrets; registrySecrets: RegistrySecrets;
nocache: boolean; nocache: boolean;
live: boolean; live: boolean;
@ -99,7 +100,13 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
globalLogger.logInfo(`Starting build on device ${opts.deviceHost}`); globalLogger.logInfo(`Starting build on device ${opts.deviceHost}`);
const project = await loadProject(globalLogger, opts.source, 'local'); const project = await loadProject(
globalLogger,
opts.source, // project path
'local', // project name
undefined, // name of a pre-built image
opts.dockerfilePath, // alternative Dockerfile; OK to be undefined
);
// Attempt to attach to the device's docker daemon // Attempt to attach to the device's docker daemon
const docker = connectToDocker( const docker = connectToDocker(

View File

@ -19,20 +19,14 @@ import Bluebird = require('bluebird');
import chalk from 'chalk'; import chalk from 'chalk';
import _ = require('lodash'); import _ = require('lodash');
import os = require('os'); import os = require('os');
import path = require('path');
import visuals = require('resin-cli-visuals'); import visuals = require('resin-cli-visuals');
import rindle = require('rindle'); import rindle = require('rindle');
import { InitializeEmitter, OperationState } from 'balena-device-init'; import { InitializeEmitter, OperationState } from 'balena-device-init';
const waitStreamAsync = Bluebird.promisify(rindle.wait); const waitStreamAsync = Bluebird.promisify(rindle.wait);
const balena = BalenaSdk.fromSharedOptions(); const balena = BalenaSdk.fromSharedOptions();
export function toPosixPath(p: string): string {
return p.replace(new RegExp('\\' + path.sep, 'g'), '/');
}
export function getGroupDefaults(group: { export function getGroupDefaults(group: {
options: Array<{ name: string; default?: string }>; options: Array<{ name: string; default?: string }>;
}): { [name: string]: string | undefined } { }): { [name: string]: string | undefined } {

View File

@ -1,11 +1,28 @@
/**
* @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 _ from 'lodash'; import * as _ from 'lodash';
import { fs } from 'mz'; import { fs } from 'mz';
import * as path from 'path'; import * as path from 'path';
import * as MultiBuild from 'resin-multibuild';
import dockerIgnore = require('@zeit/dockerignore'); import dockerIgnore = require('@zeit/dockerignore');
import ignore from 'ignore'; import ignore from 'ignore';
import { toPosixPath } from './helpers'; const { toPosixPath } = MultiBuild.PathUtils;
export enum IgnoreFileType { export enum IgnoreFileType {
DockerIgnore, DockerIgnore,

View File

@ -32,6 +32,7 @@ const CURSOR_METADATA_REGEX = /([a-z]+)([0-9]+)?/;
const TRIM_REGEX = /\n+$/; const TRIM_REGEX = /\n+$/;
export interface BuildOpts { export interface BuildOpts {
dockerfilePath: string;
emulated: boolean; emulated: boolean;
nocache: boolean; nocache: boolean;
registrySecrets: RegistrySecrets; registrySecrets: RegistrySecrets;
@ -78,6 +79,7 @@ async function getBuilderEndpoint(
const args = querystring.stringify({ const args = querystring.stringify({
owner, owner,
app, app,
dockerfilePath: opts.dockerfilePath,
emulated: opts.emulated, emulated: opts.emulated,
nocache: opts.nocache, nocache: opts.nocache,
}); });

View File

@ -72,11 +72,15 @@
"@types/fs-extra": "5.0.4", "@types/fs-extra": "5.0.4",
"@types/is-root": "1.0.0", "@types/is-root": "1.0.0",
"@types/lodash": "4.14.112", "@types/lodash": "4.14.112",
"@types/mixpanel": "2.14.0",
"@types/mkdirp": "0.5.2", "@types/mkdirp": "0.5.2",
"@types/node": "6.14.2", "@types/node": "6.14.2",
"@types/prettyjson": "0.0.28", "@types/prettyjson": "0.0.28",
"@types/stream-to-promise": "2.2.0",
"@types/raven": "2.5.1", "@types/raven": "2.5.1",
"@types/request": "2.48.1",
"@types/tar-stream": "1.6.0", "@types/tar-stream": "1.6.0",
"@types/through2": "2.0.33",
"catch-uncommitted": "^1.0.0", "catch-uncommitted": "^1.0.0",
"ent": "^2.2.0", "ent": "^2.2.0",
"filehound": "^1.16.2", "filehound": "^1.16.2",
@ -98,11 +102,6 @@
}, },
"dependencies": { "dependencies": {
"@resin.io/valid-email": "^0.1.0", "@resin.io/valid-email": "^0.1.0",
"@types/dockerode": "2.5.5",
"@types/mixpanel": "2.14.0",
"@types/request": "2.48.1",
"@types/stream-to-promise": "2.2.0",
"@types/through2": "^2.0.33",
"@zeit/dockerignore": "0.0.3", "@zeit/dockerignore": "0.0.3",
"JSONStream": "^1.0.3", "JSONStream": "^1.0.3",
"ansi-escapes": "^2.0.0", "ansi-escapes": "^2.0.0",
@ -162,7 +161,7 @@
"request": "^2.81.0", "request": "^2.81.0",
"resin-cli-form": "^2.0.1", "resin-cli-form": "^2.0.1",
"resin-cli-visuals": "^1.4.0", "resin-cli-visuals": "^1.4.0",
"resin-compose-parse": "^2.0.4", "resin-compose-parse": "^2.1.0",
"resin-doodles": "0.0.1", "resin-doodles": "0.0.1",
"resin-image-fs": "^5.0.2", "resin-image-fs": "^5.0.2",
"resin-multibuild": "^3.1.0", "resin-multibuild": "^3.1.0",