push/build/deploy: add --nogitignore option and update dockerignore filter library

Connects-to: #1032
Connects-to: #1148
Change-type: minor
This commit is contained in:
Paulo Castro 2020-05-01 12:18:11 +01:00
parent 0dde84ec0b
commit 4577d72ead
48 changed files with 1040 additions and 70 deletions

View File

@ -1805,6 +1805,7 @@ containers. The synchronization is only in one direction, from this machine to
the device, and changes made on the device itself may be overwritten. the device, and changes made on the device itself may be overwritten.
This feature requires a device running supervisor version v9.7.0 or greater. This feature requires a device running supervisor version v9.7.0 or greater.
REGISTRY SECRETS
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.
Sample registry-secrets YAML file: Sample registry-secrets YAML file:
@ -1826,6 +1827,41 @@ If the --registry-secrets option is not specified, and a secrets.yml or
secrets.json file exists in the balena directory (usually $HOME/.balena), secrets.json file exists in the balena directory (usually $HOME/.balena),
this file will be used instead. this file will be used instead.
DOCKERIGNORE AND GITIGNORE FILES
By default, both '.dockerignore' and '.gitignore' files are taken into account
in order to prevent files from being sent to the balenaCloud builder or Docker
or balenaEngine (balenaOS device).
However, this behavior has been DEPRECATED and will change in an upcoming major
version release. The --nogitignore (-G) option should be used to enable the new
behavior already now. This option will cause the CLI to:
* Disregard all '.gitignore' files at the source directory and subdirectories,
and consider only the '.dockerignore' file (if any) at the source directory.
* Consequently, allow files to be sent to balenaCloud / Docker / balenaEngine
even if they are listed in '.gitignore' files (a longstanding feature request).
* Use a new '.dockerignore' parser and filter library that improves compatibility
with "docker build" and fixes several issues (mainly on Windows).
* Prevent a warning message from being printed.
When --nogitignore (-G) is provided, a few "hardcoded" dockerignore patterns are
also used and "merged" (in memory) with the patterns found in the '.dockerignore'
file (if any), in the following order:
**/.git
< user's patterns from the '.dockerignore' file, if any >
!**/.balena
!**/.resin
!**/Dockerfile
!**/Dockerfile.*
!**/docker-compose.yml
If necessary, the effect of the '**/.git' pattern may be modified by adding
"counter patterns" to the '.dockerignore' file, for example '!service1/.git'.
For documentation on pattern format, see:
- https://docs.docker.com/engine/reference/builder/#dockerignore-file
- https://www.npmjs.com/package/@balena/dockerignore
Examples: Examples:
$ balena push myApp $ balena push myApp
@ -1845,7 +1881,7 @@ Examples:
#### --source, -s &#60;source&#62; #### --source, -s &#60;source&#62;
The source that should be sent to the balena builder to be built (defaults to the current directory) Source directory to be sent to balenaCloud or balenaOS device (default: current working dir)
#### --emulated, -e #### --emulated, -e
@ -1909,6 +1945,12 @@ left hand side of the = character will be treated as the variable name.
On Windows only, convert line endings from CRLF (Windows format) to LF (Unix format). On Windows only, convert line endings from CRLF (Windows format) to LF (Unix format).
Source files are not modified. Source files are not modified.
#### --nogitignore, -G
Disregard all .gitignore files, and consider only the .dockerignore file (if any)
at the source directory. This will be the default behavior in an upcoming major
version release. For more information, see 'balena help push'.
# Settings # Settings
## settings ## settings
@ -1971,6 +2013,7 @@ found, it will look for a Dockerfile[.template] file (or alternative Dockerfile
specified with the `--dockerfile` option), and if no dockerfile is found, it specified with the `--dockerfile` option), and if no dockerfile is found, it
will try to generate one. will try to generate one.
REGISTRY SECRETS
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.
Sample registry-secrets YAML file: Sample registry-secrets YAML file:
@ -1992,6 +2035,41 @@ If the --registry-secrets option is not specified, and a secrets.yml or
secrets.json file exists in the balena directory (usually $HOME/.balena), secrets.json file exists in the balena directory (usually $HOME/.balena),
this file will be used instead. this file will be used instead.
DOCKERIGNORE AND GITIGNORE FILES
By default, both '.dockerignore' and '.gitignore' files are taken into account
in order to prevent files from being sent to the balenaCloud builder or Docker
or balenaEngine (balenaOS device).
However, this behavior has been DEPRECATED and will change in an upcoming major
version release. The --nogitignore (-G) option should be used to enable the new
behavior already now. This option will cause the CLI to:
* Disregard all '.gitignore' files at the source directory and subdirectories,
and consider only the '.dockerignore' file (if any) at the source directory.
* Consequently, allow files to be sent to balenaCloud / Docker / balenaEngine
even if they are listed in '.gitignore' files (a longstanding feature request).
* Use a new '.dockerignore' parser and filter library that improves compatibility
with "docker build" and fixes several issues (mainly on Windows).
* Prevent a warning message from being printed.
When --nogitignore (-G) is provided, a few "hardcoded" dockerignore patterns are
also used and "merged" (in memory) with the patterns found in the '.dockerignore'
file (if any), in the following order:
**/.git
< user's patterns from the '.dockerignore' file, if any >
!**/.balena
!**/.resin
!**/Dockerfile
!**/Dockerfile.*
!**/docker-compose.yml
If necessary, the effect of the '**/.git' pattern may be modified by adding
"counter patterns" to the '.dockerignore' file, for example '!service1/.git'.
For documentation on pattern format, see:
- https://docs.docker.com/engine/reference/builder/#dockerignore-file
- https://www.npmjs.com/package/@balena/dockerignore
Examples: Examples:
$ balena build $ balena build
@ -2032,6 +2110,12 @@ Alternative Dockerfile name/path, relative to the source folder
Display full log output Display full log output
#### --nogitignore, -G
Disregard all .gitignore files, and consider only the .dockerignore file (if any)
at the source directory. This will be the default behavior in an upcoming major
version release. For more information, see 'balena help undefined'.
#### --noparent-check #### --noparent-check
Disable project validation check of 'docker-compose.yml' file in parent folder Disable project validation check of 'docker-compose.yml' file in parent folder
@ -2112,6 +2196,7 @@ To deploy to an app on which you're a collaborator, use
When --build is used, all options supported by `balena build` are also supported When --build is used, all options supported by `balena build` are also supported
by this command. by this command.
REGISTRY SECRETS
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.
Sample registry-secrets YAML file: Sample registry-secrets YAML file:
@ -2133,6 +2218,41 @@ If the --registry-secrets option is not specified, and a secrets.yml or
secrets.json file exists in the balena directory (usually $HOME/.balena), secrets.json file exists in the balena directory (usually $HOME/.balena),
this file will be used instead. this file will be used instead.
DOCKERIGNORE AND GITIGNORE FILES
By default, both '.dockerignore' and '.gitignore' files are taken into account
in order to prevent files from being sent to the balenaCloud builder or Docker
or balenaEngine (balenaOS device).
However, this behavior has been DEPRECATED and will change in an upcoming major
version release. The --nogitignore (-G) option should be used to enable the new
behavior already now. This option will cause the CLI to:
* Disregard all '.gitignore' files at the source directory and subdirectories,
and consider only the '.dockerignore' file (if any) at the source directory.
* Consequently, allow files to be sent to balenaCloud / Docker / balenaEngine
even if they are listed in '.gitignore' files (a longstanding feature request).
* Use a new '.dockerignore' parser and filter library that improves compatibility
with "docker build" and fixes several issues (mainly on Windows).
* Prevent a warning message from being printed.
When --nogitignore (-G) is provided, a few "hardcoded" dockerignore patterns are
also used and "merged" (in memory) with the patterns found in the '.dockerignore'
file (if any), in the following order:
**/.git
< user's patterns from the '.dockerignore' file, if any >
!**/.balena
!**/.resin
!**/Dockerfile
!**/Dockerfile.*
!**/docker-compose.yml
If necessary, the effect of the '**/.git' pattern may be modified by adding
"counter patterns" to the '.dockerignore' file, for example '!service1/.git'.
For documentation on pattern format, see:
- https://docs.docker.com/engine/reference/builder/#dockerignore-file
- https://www.npmjs.com/package/@balena/dockerignore
Examples: Examples:
$ balena deploy myApp $ balena deploy myApp
@ -2169,6 +2289,12 @@ Alternative Dockerfile name/path, relative to the source folder
Display full log output Display full log output
#### --nogitignore, -G
Disregard all .gitignore files, and consider only the .dockerignore file (if any)
at the source directory. This will be the default behavior in an upcoming major
version release. For more information, see 'balena help undefined'.
#### --noparent-check #### --noparent-check
Disable project validation check of 'docker-compose.yml' file in parent folder Disable project validation check of 'docker-compose.yml' file in parent folder

View File

@ -21,7 +21,7 @@ import * as Promise from 'bluebird';
import * as dockerUtils from '../utils/docker'; import * as dockerUtils from '../utils/docker';
import * as compose from '../utils/compose'; import * as compose from '../utils/compose';
import { registrySecretsHelp } from '../utils/messages'; import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import { getBalenaSdk } from '../utils/lazy'; import { getBalenaSdk } from '../utils/lazy';
/* /*
@ -62,6 +62,7 @@ const buildProject = function(docker, logger, composeOpts, opts) {
composeOpts.inlineLogs, composeOpts.inlineLogs,
opts.convertEol, opts.convertEol,
composeOpts.dockerfilePath, composeOpts.dockerfilePath,
composeOpts.nogitignore,
); );
}) })
.then(function() { .then(function() {
@ -95,6 +96,8 @@ will try to generate one.
${registrySecretsHelp} ${registrySecretsHelp}
${dockerignoreHelp}
Examples: Examples:
$ balena build $ balena build

View File

@ -21,7 +21,7 @@ import * as Promise from 'bluebird';
import * as dockerUtils from '../utils/docker'; import * as dockerUtils from '../utils/docker';
import * as compose from '../utils/compose'; import * as compose from '../utils/compose';
import { registrySecretsHelp } from '../utils/messages'; import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import { ExpectedError } from '../errors'; import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk } from '../utils/lazy'; import { getBalenaSdk, getChalk } from '../utils/lazy';
@ -95,6 +95,7 @@ const deployProject = function(docker, logger, composeOpts, opts) {
composeOpts.inlineLogs, composeOpts.inlineLogs,
opts.convertEol, opts.convertEol,
composeOpts.dockerfilePath, composeOpts.dockerfilePath,
composeOpts.nogitignore,
) )
.then(builtImages => _.keyBy(builtImages, 'serviceName')); .then(builtImages => _.keyBy(builtImages, 'serviceName'));
}) })
@ -200,6 +201,8 @@ by this command.
${registrySecretsHelp} ${registrySecretsHelp}
${dockerignoreHelp}
Examples: Examples:
$ balena deploy myApp $ balena deploy myApp

View File

@ -20,7 +20,7 @@ import { stripIndent } from 'common-tags';
import { ExpectedError } from '../errors'; import { ExpectedError } from '../errors';
import { getBalenaSdk } from '../utils/lazy'; import { getBalenaSdk } from '../utils/lazy';
import { registrySecretsHelp } from '../utils/messages'; import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import { import {
validateApplicationName, validateApplicationName,
validateDotLocalUrl, validateDotLocalUrl,
@ -109,6 +109,7 @@ export const push: CommandDefinition<
nocache?: boolean; nocache?: boolean;
'noparent-check'?: boolean; 'noparent-check'?: boolean;
'registry-secrets'?: string; 'registry-secrets'?: string;
nogitignore?: boolean;
nolive?: boolean; nolive?: boolean;
detached?: boolean; detached?: boolean;
service?: string | string[]; service?: string | string[];
@ -148,6 +149,8 @@ export const push: CommandDefinition<
${registrySecretsHelp.split('\n').join('\n\t\t')} ${registrySecretsHelp.split('\n').join('\n\t\t')}
${dockerignoreHelp.split('\n').join('\n\t\t')}
Examples: Examples:
$ balena push myApp $ balena push myApp
@ -168,7 +171,7 @@ export const push: CommandDefinition<
signature: 'source', signature: 'source',
alias: 's', alias: 's',
description: description:
'The source that should be sent to the balena builder to be built (defaults to the current directory)', 'Source directory to be sent to balenaCloud or balenaOS device (default: current working dir)',
parameter: 'source', parameter: 'source',
}, },
{ {
@ -259,6 +262,16 @@ export const push: CommandDefinition<
Source files are not modified.`, Source files are not modified.`,
boolean: true, boolean: true,
}, },
{
signature: 'nogitignore',
alias: 'G',
description: stripIndent`
Disregard all .gitignore files, and consider only the .dockerignore file (if any)
at the source directory. This will be the default behavior in an upcoming major
version release. For more information, see 'balena help push'.
`,
boolean: true,
},
], ],
async action(params, options) { async action(params, options) {
const sdk = getBalenaSdk(); const sdk = getBalenaSdk();
@ -336,6 +349,7 @@ export const push: CommandDefinition<
source, source,
auth: token, auth: token,
baseUrl, baseUrl,
nogitignore: !!options.nogitignore,
sdk, sdk,
opts, opts,
}; };
@ -359,6 +373,7 @@ export const push: CommandDefinition<
dockerfilePath, dockerfilePath,
registrySecrets, registrySecrets,
nocache: options.nocache || false, nocache: options.nocache || false,
nogitignore: options.nogitignore || false,
noParentCheck: options['noparent-check'] || false, noParentCheck: options['noparent-check'] || false,
nolive: options.nolive || false, nolive: options.nolive || false,
detached: options.detached || false, detached: options.detached || false,

View File

@ -61,6 +61,9 @@ export async function routeCliFramework(argv: string[], options: AppOptions) {
} }
} }
const Logger = await import('./utils/logger');
Logger.command = cmdSlice[0];
const [isOclif, isTopic] = isOclifCommand(cmdSlice); const [isOclif, isTopic] = isOclifCommand(cmdSlice);
if (isOclif) { if (isOclif) {
@ -68,6 +71,7 @@ export async function routeCliFramework(argv: string[], options: AppOptions) {
if (isTopic) { if (isTopic) {
// convert space-separated commands to oclif's topic:command syntax // convert space-separated commands to oclif's topic:command syntax
oclifArgs = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)]; oclifArgs = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)];
Logger.command = `${cmdSlice[0]} ${cmdSlice[1]}`;
} }
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.log( console.log(

View File

@ -41,6 +41,7 @@ export interface ComposeProject {
} }
interface TarDirectoryOptions { interface TarDirectoryOptions {
preFinalizeCallback?: (pack: Pack) => void;
convertEol?: boolean; convertEol?: boolean;
preFinalizeCallback?: (pack: Pack) => void | Promise<void>;
nogitignore: boolean;
} }

View File

@ -16,8 +16,11 @@
*/ */
import * as Promise from 'bluebird'; import * as Promise from 'bluebird';
import { stripIndent } from 'common-tags';
import * as path from 'path'; import * as path from 'path';
import { getBalenaSdk, getChalk } from './lazy'; import { getBalenaSdk, getChalk } from './lazy';
import { IgnoreFileType } from './ignore';
export const appendProjectOptions = opts => export const appendProjectOptions = opts =>
opts.concat([ opts.concat([
@ -31,6 +34,7 @@ export const appendProjectOptions = opts =>
]); ]);
export function appendOptions(opts) { export function appendOptions(opts) {
const Logger = require('./logger');
return appendProjectOptions(opts).concat([ return appendProjectOptions(opts).concat([
{ {
signature: 'emulated', signature: 'emulated',
@ -49,6 +53,16 @@ export function appendOptions(opts) {
description: 'Display full log output', description: 'Display full log output',
boolean: true, boolean: true,
}, },
{
signature: 'nogitignore',
description: stripIndent`
Disregard all .gitignore files, and consider only the .dockerignore file (if any)
at the source directory. This will be the default behavior in an upcoming major
version release. For more information, see 'balena help ${Logger.command}'.
`,
boolean: true,
alias: 'G',
},
{ {
signature: 'noparent-check', signature: 'noparent-check',
description: description:
@ -83,6 +97,7 @@ export function generateOpts(options) {
projectPath, projectPath,
inlineLogs: !!options.logs, inlineLogs: !!options.logs,
dockerfilePath: options.dockerfile, dockerfilePath: options.dockerfile,
nogitignore: !!options.nogitignore,
noParentCheck: options['noparent-check'], noParentCheck: options['noparent-check'],
})); }));
} }
@ -132,15 +147,33 @@ export function createProject(composePath, composeStr, projectName = null) {
} }
/** /**
* @param {string} dir * Create a tar stream out of the local filesystem at the given directory,
* @param {import('./compose-types').TarDirectoryOptions} [param] * while optionally applying file filters such as '.dockerignore' and
* optionally converting text file line endings (CRLF to LF).
* @param {string} dir Source directory
* @param {import('./compose-types').TarDirectoryOptions} param
* @returns {Promise<import('stream').Readable>} * @returns {Promise<import('stream').Readable>}
*/ */
export const tarDirectory = function(dir, param) { export function tarDirectory(dir, param) {
if (param == null) { let { nogitignore = false } = param;
param = {}; if (nogitignore) {
return Promise.resolve(require('./compose_ts').tarDirectory(dir, param));
} else {
return originalTarDirectory(dir, param);
} }
let { preFinalizeCallback = null, convertEol = false } = param; }
/**
* @param {string} dir Source directory
* @param {import('./compose-types').TarDirectoryOptions} param
* @returns {Promise<import('stream').Readable>}
*/
function originalTarDirectory(dir, param) {
let {
preFinalizeCallback = null,
convertEol = false,
nogitignore = false,
} = param;
if (convertEol == null) { if (convertEol == null) {
convertEol = false; convertEol = false;
} }
@ -149,6 +182,7 @@ export const tarDirectory = function(dir, param) {
const klaw = require('klaw'); const klaw = require('klaw');
const fs = require('mz/fs'); const fs = require('mz/fs');
const streamToPromise = require('stream-to-promise'); const streamToPromise = require('stream-to-promise');
const { printGitignoreWarn } = require('./compose_ts');
const { FileIgnorer } = require('./ignore'); const { FileIgnorer } = require('./ignore');
const { toPosixPath } = require('resin-multibuild').PathUtils; const { toPosixPath } = require('resin-multibuild').PathUtils;
let readFile; let readFile;
@ -167,13 +201,24 @@ export const tarDirectory = function(dir, param) {
const ignore = new FileIgnorer(dir); const ignore = new FileIgnorer(dir);
const pack = tar.pack(); const pack = tar.pack();
const ignoreFiles = {};
return getFiles() return getFiles()
.each(function(file) { .each(function(file) {
const type = ignore.getIgnoreFileType(path.relative(dir, file)); const type = ignore.getIgnoreFileType(path.relative(dir, file));
if (type != null) { if (type != null) {
ignoreFiles[type] = ignoreFiles[type] || [];
ignoreFiles[type].push(path.resolve(dir, file));
return ignore.addIgnoreFile(file, type); return ignore.addIgnoreFile(file, type);
} }
}) })
.tap(() => {
if (!nogitignore) {
printGitignoreWarn(
(ignoreFiles[IgnoreFileType.DockerIgnore] || [])[0] || '',
ignoreFiles[IgnoreFileType.GitIgnore] || [],
);
}
})
.filter(ignore.filter) .filter(ignore.filter)
.map(function(file) { .map(function(file) {
const relPath = path.relative(path.resolve(dir), file); const relPath = path.relative(path.resolve(dir), file);
@ -193,7 +238,7 @@ export const tarDirectory = function(dir, param) {
pack.finalize(); pack.finalize();
return pack; return pack;
}); });
}; }
const truncateString = function(str, len) { const truncateString = function(str, len) {
if (str.length < len) { if (str.length < len) {
@ -221,6 +266,7 @@ export function buildProject(
inlineLogs, inlineLogs,
convertEol, convertEol,
dockerfilePath, dockerfilePath,
nogitignore,
) { ) {
const _ = require('lodash'); const _ = require('lodash');
const humanize = require('humanize'); const humanize = require('humanize');
@ -274,7 +320,7 @@ export function buildProject(
.then(( .then((
needsQemu, // Tar up the directory, ready for the build stream needsQemu, // Tar up the directory, ready for the build stream
) => ) =>
tarDirectory(projectPath, { convertEol }) tarDirectory(projectPath, { convertEol, nogitignore })
.then(tarStream => .then(tarStream =>
makeBuildTasks( makeBuildTasks(
composition, composition,

View File

@ -156,6 +156,93 @@ async function loadBuildMetatada(
return [buildMetadata, metadataPath]; return [buildMetadata, metadataPath];
} }
/**
* Create a tar stream out of the local filesystem at the given directory,
* while optionally applying file filters such as '.dockerignore' and
* optionally converting text file line endings (CRLF to LF).
* @param dir Source directory
* @param param Options
* @returns {Promise<import('stream').Readable>}
*/
export async function tarDirectory(
dir: string,
{
preFinalizeCallback,
convertEol = false,
nogitignore = false,
}: import('./compose-types').TarDirectoryOptions,
): Promise<import('stream').Readable> {
(await import('assert')).strict.strictEqual(nogitignore, true);
const { filterFilesWithDockerignore } = await import('./ignore');
const { toPosixPath } = (await import('resin-multibuild')).PathUtils;
let readFile: (file: string) => Promise<Buffer>;
if (process.platform === 'win32') {
const { readFileWithEolConversion } = require('./eol-conversion');
readFile = file => readFileWithEolConversion(file, convertEol);
} else {
readFile = fs.readFile;
}
const pack = tar.pack();
const fileStatsList = await filterFilesWithDockerignore(dir);
for (const fileStats of fileStatsList) {
pack.entry(
{
name: toPosixPath(fileStats.relPath),
size: fileStats.stats.size,
mode: fileStats.stats.mode,
},
await readFile(fileStats.filePath),
);
}
if (preFinalizeCallback) {
await preFinalizeCallback(pack);
}
pack.finalize();
return pack;
}
/**
* Print a deprecation warning if any '.gitignore' or '.dockerignore' file is
* found and the --nogitignore (-G) option has not been provided.
* @param dockerignoreFile Absolute path to a .dockerignore file
* @param gitignoreFiles Array of absolute paths to .gitginore files
*/
export function printGitignoreWarn(
dockerignoreFile: string,
gitignoreFiles: string[],
) {
const ignoreFiles = [dockerignoreFile, ...gitignoreFiles].filter(e => e);
if (ignoreFiles.length === 0) {
return;
}
const hr =
'-------------------------------------------------------------------------------';
const msg = [' ', hr, 'Using file ignore patterns from:'];
msg.push(...ignoreFiles);
if (gitignoreFiles.length) {
msg.push(stripIndent`
balena CLI currently uses gitgnore and dockerignore files, but an upcoming major
version release will disregard gitignore files and use a dockerignore file only.
Use the --nogitignore (-G) option to enable the new behavior already now and
suppress this warning. For more information, see 'balena help ${Logger.command}'.
`);
msg.push(hr);
Logger.getLogger().logWarn(msg.join('\n'));
} else if (dockerignoreFile && process.platform === 'win32') {
msg.push(stripIndent`
Use the --nogitignore (-G) option to suppress this warning and enable the use
of a better dockerignore parser and filter library that fixes several issues
on Windows and improves compatibility with "docker build", but which may also
cause a different set of files to be filtered out (because of the bug fixes).
The --nogitignore option will be the default behavior in an upcoming balena CLI
major version release. For more information, see 'balena help ${Logger.command}'.
`);
msg.push(hr);
Logger.getLogger().logWarn(msg.join('\n'));
}
}
/** /**
* Check whether the "build secrets" feature is being used and, if so, * Check whether the "build secrets" feature is being used and, if so,
* verify that the target docker daemon is balenaEngine. If the * verify that the target docker daemon is balenaEngine. If the

View File

@ -56,6 +56,7 @@ export interface DeviceDeployOptions {
dockerfilePath?: string; dockerfilePath?: string;
registrySecrets: RegistrySecrets; registrySecrets: RegistrySecrets;
nocache: boolean; nocache: boolean;
nogitignore: boolean;
noParentCheck: boolean; noParentCheck: boolean;
nolive: boolean; nolive: boolean;
detached: boolean; detached: boolean;
@ -186,6 +187,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
globalLogger.logDebug('Tarring all non-ignored files...'); globalLogger.logDebug('Tarring all non-ignored files...');
const tarStream = await tarDirectory(opts.source, { const tarStream = await tarDirectory(opts.source, {
convertEol: opts.convertEol, convertEol: opts.convertEol,
nogitignore: opts.nogitignore,
}); });
// Try to detect the device information // Try to detect the device information
@ -400,7 +402,10 @@ export async function rebuildSingleTask(
} }
}; };
const tarStream = await tarDirectory(source); const tarStream = await tarDirectory(source, {
convertEol: opts.convertEol,
nogitignore: opts.nogitignore,
});
const task = _.find( const task = _.find(
await makeBuildTasks( await makeBuildTasks(

View File

@ -22,6 +22,8 @@ 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 { ExpectedError } from '../errors';
const { toPosixPath } = MultiBuild.PathUtils; const { toPosixPath } = MultiBuild.PathUtils;
export enum IgnoreFileType { export enum IgnoreFileType {
@ -182,3 +184,92 @@ export class FileIgnorer {
return !/^\.\.\//.test(path.posix.relative(path1, path2)); return !/^\.\.\//.test(path.posix.relative(path1, path2));
} }
} }
interface FileStats {
filePath: string;
relPath: string;
stats: fs.Stats;
}
/**
* Create a list of files (FileStats[]) for the filesystem subtree rooted at
* projectDir, listing each file with both a full path and a relative path,
* but excluding entries for directories themselves.
* @param projectDir Source directory (root of subtree to be listed)
* @param dir Used for recursive calls only (omit on first function call)
*/
async function listFiles(
projectDir: string,
dir: string = projectDir,
): Promise<FileStats[]> {
const files: FileStats[] = [];
const dirEntries = await fs.readdir(dir);
await Promise.all(
dirEntries.map(async entry => {
const filePath = path.join(dir, entry);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
files.push(...(await listFiles(projectDir, filePath)));
} else if (stats.isFile()) {
files.push({
filePath,
relPath: path.relative(projectDir, filePath),
stats,
});
}
}),
);
return files;
}
/**
* Return the contents of a .dockerignore file at projectDir, as a string.
* Return an empty string if a .dockerignore file does not exist.
* @param projectDir Source directory
* @returns Contents of the .dockerignore file, as a UTF-8 string
*/
async function readDockerIgnoreFile(projectDir: string): Promise<string> {
const dockerIgnorePath = path.join(projectDir, '.dockerignore');
let dockerIgnoreStr = '';
try {
dockerIgnoreStr = await fs.readFile(dockerIgnorePath, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') {
throw new ExpectedError(
`Error reading file "${dockerIgnorePath}": ${err.message}`,
);
}
}
return dockerIgnoreStr;
}
/**
* Create a list of files (FileStats[]) for the filesystem subtree rooted at
* projectDir, filtered against a .dockerignore file (if any) also at projectDir,
* plus a few hardcoded dockerignore patterns.
* @param projectDir Source directory to
*/
export async function filterFilesWithDockerignore(
projectDir: string,
): Promise<FileStats[]> {
// path.resolve() also converts forward slashes to backslashes on Windows
projectDir = path.resolve(projectDir);
const dockerIgnoreStr = await readDockerIgnoreFile(projectDir);
const $dockerIgnore = (await import('@balena/dockerignore')).default;
const ig = $dockerIgnore({ ignorecase: false });
ig.add(['**/.git']);
if (dockerIgnoreStr) {
ig.add(dockerIgnoreStr);
}
ig.add([
'!**/.balena',
'!**/.resin',
'!**/Dockerfile',
'!**/Dockerfile.*',
'!**/docker-compose.yml',
]);
const files = await listFiles(projectDir);
return files.filter((file: FileStats) => !ig.ignores(file.relPath));
}

View File

@ -39,6 +39,8 @@ enum Level {
*/ */
class Logger { class Logger {
public static readonly Level = Level; public static readonly Level = Level;
// `Logger.command` is currently set in `preparser.ts`
public static command: string; // CLI cmd, e.g. 'push', 'env add', ...
public streams: { public streams: {
build: NodeJS.ReadWriteStream; build: NodeJS.ReadWriteStream;

View File

@ -1,3 +1,20 @@
/**
* @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.
*/
const DEBUG_MODE = !!process.env.DEBUG; const DEBUG_MODE = !!process.env.DEBUG;
export const reachingOut = `\ export const reachingOut = `\
@ -28,6 +45,7 @@ export const balenaAsciiArt = `\
`; `;
export const registrySecretsHelp = `\ export const registrySecretsHelp = `\
REGISTRY SECRETS
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.
Sample registry-secrets YAML file: Sample registry-secrets YAML file:
@ -48,3 +66,39 @@ check: https://github.com/balena-io-playground/sample-gcr-registry-secrets
If the --registry-secrets option is not specified, and a secrets.yml or If the --registry-secrets option is not specified, and a secrets.yml or
secrets.json file exists in the balena directory (usually $HOME/.balena), secrets.json file exists in the balena directory (usually $HOME/.balena),
this file will be used instead.`; this file will be used instead.`;
export const dockerignoreHelp = `\
DOCKERIGNORE AND GITIGNORE FILES
By default, both '.dockerignore' and '.gitignore' files are taken into account
in order to prevent files from being sent to the balenaCloud builder or Docker
or balenaEngine (balenaOS device).
However, this behavior has been DEPRECATED and will change in an upcoming major
version release. The --nogitignore (-G) option should be used to enable the new
behavior already now. This option will cause the CLI to:
* Disregard all '.gitignore' files at the source directory and subdirectories,
and consider only the '.dockerignore' file (if any) at the source directory.
* Consequently, allow files to be sent to balenaCloud / Docker / balenaEngine
even if they are listed in '.gitignore' files (a longstanding feature request).
* Use a new '.dockerignore' parser and filter library that improves compatibility
with "docker build" and fixes several issues (mainly on Windows).
* Prevent a warning message from being printed.
When --nogitignore (-G) is provided, a few "hardcoded" dockerignore patterns are
also used and "merged" (in memory) with the patterns found in the '.dockerignore'
file (if any), in the following order:
**/.git
< user's patterns from the '.dockerignore' file, if any >
!**/.balena
!**/.resin
!**/Dockerfile
!**/Dockerfile.*
!**/docker-compose.yml
If necessary, the effect of the '**/.git' pattern may be modified by adding
"counter patterns" to the '.dockerignore' file, for example '!service1/.git'.
For documentation on pattern format, see:
- https://docs.docker.com/engine/reference/builder/#dockerignore-file
- https://www.npmjs.com/package/@balena/dockerignore`;

View File

@ -51,6 +51,7 @@ export interface RemoteBuild {
source: string; source: string;
auth: string; auth: string;
baseUrl: string; baseUrl: string;
nogitignore: boolean;
opts: BuildOpts; opts: BuildOpts;
sdk: BalenaSDK; sdk: BalenaSDK;
@ -302,6 +303,7 @@ async function getTarStream(build: RemoteBuild): Promise<Stream.Readable> {
return await tarDirectory(path.resolve(build.source), { return await tarDirectory(path.resolve(build.source), {
preFinalizeCallback: preFinalizeCb, preFinalizeCallback: preFinalizeCb,
convertEol: build.opts.convertEol, convertEol: build.opts.convertEol,
nogitignore: build.nogitignore,
}); });
} finally { } finally {
tarSpinner.stop(); tarSpinner.stop();

5
npm-shrinkwrap.json generated
View File

@ -104,6 +104,11 @@
} }
} }
}, },
"@balena/dockerignore": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="
},
"@balena/lint": { "@balena/lint": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/@balena/lint/-/lint-4.1.1.tgz", "resolved": "https://registry.npmjs.org/@balena/lint/-/lint-4.1.1.tgz",

View File

@ -164,6 +164,7 @@
"typescript": "^3.8.3" "typescript": "^3.8.3"
}, },
"dependencies": { "dependencies": {
"@balena/dockerignore": "^1.0.2",
"@oclif/command": "^1.5.19", "@oclif/command": "^1.5.19",
"@resin.io/valid-email": "^0.1.0", "@resin.io/valid-email": "^0.1.0",
"@sentry/node": "^5.13.2", "@sentry/node": "^5.13.2",

View File

@ -25,14 +25,13 @@ import { fs } from 'mz';
import * as path from 'path'; import * as path from 'path';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../balena-api-mock';
import { expectStreamNoCRLF, testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock';
import { cleanOutput, runCommand } from '../helpers';
import { import {
ExpectedTarStreamFiles, ExpectedTarStreamFiles,
ExpectedTarStreamFilesByService, ExpectedTarStreamFilesByService,
expectStreamNoCRLF, } from '../projects';
testDockerBuildStream,
} from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock';
import { cleanOutput, runCommand } from '../helpers';
const repoPath = path.normalize(path.join(__dirname, '..', '..')); const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
@ -53,6 +52,15 @@ const commonQueryParams = [
['labels', ''], ['labels', ''],
]; ];
const commonComposeQueryParams = [
['t', '${tag}'],
[
'buildargs',
'{"MY_VAR_1":"This is a variable","MY_VAR_2":"Also a variable"}',
],
['labels', ''],
];
describe('balena build', function() { describe('balena build', function() {
let api: BalenaAPIMock; let api: BalenaAPIMock;
let docker: DockerMock; let docker: DockerMock;
@ -104,7 +112,7 @@ describe('balena build', function() {
} }
docker.expectGetInfo({}); docker.expectGetInfo({});
await testDockerBuildStream({ await testDockerBuildStream({
commandLine: `build ${projectPath} --deviceType nuc --arch amd64`, commandLine: `build ${projectPath} --deviceType nuc --arch amd64 -G`,
dockerMock: docker, dockerMock: docker,
expectedFilesByService: { main: expectedFiles }, expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams }, expectedQueryParamsByService: { main: commonQueryParams },
@ -178,7 +186,7 @@ describe('balena build', function() {
mock.reRequire('../../build/utils/qemu'); mock.reRequire('../../build/utils/qemu');
docker.expectGetInfo({ OperatingSystem: 'balenaOS 2.44.0+rev1' }); docker.expectGetInfo({ OperatingSystem: 'balenaOS 2.44.0+rev1' });
await testDockerBuildStream({ await testDockerBuildStream({
commandLine: `build ${projectPath} --emulated --deviceType ${deviceType} --arch ${arch}`, commandLine: `build ${projectPath} --emulated --deviceType ${deviceType} --arch ${arch} --nogitignore`,
dockerMock: docker, dockerMock: docker,
expectedFilesByService: { main: expectedFiles }, expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams }, expectedQueryParamsByService: { main: commonQueryParams },
@ -273,8 +281,15 @@ describe('balena build', function() {
'utf8', 'utf8',
); );
const expectedQueryParamsByService = { const expectedQueryParamsByService = {
service1: commonQueryParams, service1: [
service2: [...commonQueryParams, ['dockerfile', 'Dockerfile-alt']], ['t', '${tag}'],
[
'buildargs',
'{"MY_VAR_1":"This is a variable","MY_VAR_2":"Also a variable","SERVICE1_VAR":"This is a service specific variable"}',
],
['labels', ''],
],
service2: [...commonComposeQueryParams, ['dockerfile', 'Dockerfile-alt']],
}; };
const expectedResponseLines: string[] = [ const expectedResponseLines: string[] = [
...commonResponseLines[responseFilename], ...commonResponseLines[responseFilename],
@ -292,7 +307,7 @@ describe('balena build', function() {
} }
docker.expectGetInfo({}); docker.expectGetInfo({});
await testDockerBuildStream({ await testDockerBuildStream({
commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol`, commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol -G`,
dockerMock: docker, dockerMock: docker,
expectedFilesByService, expectedFilesByService,
expectedQueryParamsByService, expectedQueryParamsByService,

View File

@ -23,9 +23,10 @@ import { fs } from 'mz';
import * as path from 'path'; import * as path from 'path';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../balena-api-mock';
import { ExpectedTarStreamFiles, testDockerBuildStream } from '../docker-build'; import { testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock'; import { DockerMock, dockerResponsePath } from '../docker-mock';
import { cleanOutput, runCommand } from '../helpers'; import { cleanOutput, runCommand } from '../helpers';
import { ExpectedTarStreamFiles } from '../projects';
const repoPath = path.normalize(path.join(__dirname, '..', '..')); const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
@ -119,7 +120,7 @@ describe('balena deploy', function() {
} }
await testDockerBuildStream({ await testDockerBuildStream({
commandLine: `deploy testApp --build --source ${projectPath}`, commandLine: `deploy testApp --build --source ${projectPath} -G`,
dockerMock: docker, dockerMock: docker,
expectedFilesByService: { main: expectedFiles }, expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams }, expectedQueryParamsByService: { main: commonQueryParams },

View File

@ -24,12 +24,13 @@ import * as path from 'path';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../balena-api-mock';
import { BuilderMock, builderResponsePath } from '../builder-mock'; import { BuilderMock, builderResponsePath } from '../builder-mock';
import { import { expectStreamNoCRLF, testPushBuildStream } from '../docker-build';
ExpectedTarStreamFiles,
expectStreamNoCRLF,
testPushBuildStream,
} from '../docker-build';
import { cleanOutput, runCommand } from '../helpers'; import { cleanOutput, runCommand } from '../helpers';
import {
addRegSecretsEntries,
ExpectedTarStreamFiles,
setupDockerignoreTestData,
} from '../projects';
const repoPath = path.normalize(path.join(__dirname, '..', '..')); const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
@ -76,6 +77,8 @@ const commonQueryParams = [
['headless', 'false'], ['headless', 'false'],
]; ];
const itSkipWindows = process.platform === 'win32' ? it.skip : it;
describe('balena push', function() { describe('balena push', function() {
let api: BalenaAPIMock; let api: BalenaAPIMock;
let builder: BuilderMock; let builder: BuilderMock;
@ -95,6 +98,14 @@ describe('balena push', function() {
builder.done(); builder.done();
}); });
this.beforeAll(async () => {
await setupDockerignoreTestData();
});
this.afterAll(async () => {
await setupDockerignoreTestData({ cleanup: true });
});
it('should create the expected tar stream (single container)', async () => { it('should create the expected tar stream (single container)', async () => {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: ExpectedTarStreamFiles = { const expectedFiles: ExpectedTarStreamFiles = {
@ -103,6 +114,7 @@ describe('balena push', function() {
Dockerfile: { fileSize: 88, type: 'file' }, Dockerfile: { fileSize: 88, type: 'file' },
'Dockerfile-alt': { fileSize: 30, type: 'file' }, 'Dockerfile-alt': { fileSize: 30, type: 'file' },
}; };
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json'; const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile( const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename), path.join(builderResponsePath, responseFilename),
@ -122,7 +134,7 @@ describe('balena push', function() {
await testPushBuildStream({ await testPushBuildStream({
builderMock: builder, builderMock: builder,
commandLine: `push testApp --source ${projectPath}`, commandLine: `push testApp --source ${projectPath} -R ${regSecretsPath} -G`,
expectedFiles, expectedFiles,
expectedQueryParams: commonQueryParams, expectedQueryParams: commonQueryParams,
expectedResponseLines, expectedResponseLines,
@ -140,6 +152,7 @@ describe('balena push', function() {
Dockerfile: { fileSize: 88, type: 'file' }, Dockerfile: { fileSize: 88, type: 'file' },
'Dockerfile-alt': { fileSize: 30, type: 'file' }, 'Dockerfile-alt': { fileSize: 30, type: 'file' },
}; };
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json'; const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile( const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename), path.join(builderResponsePath, responseFilename),
@ -151,7 +164,7 @@ describe('balena push', function() {
await testPushBuildStream({ await testPushBuildStream({
builderMock: builder, builderMock: builder,
commandLine: `push testApp --source ${projectPath} --dockerfile Dockerfile-alt`, commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} --dockerfile Dockerfile-alt --nogitignore`,
expectedFiles, expectedFiles,
expectedQueryParams, expectedQueryParams,
expectedResponseLines: commonResponseLines[responseFilename], expectedResponseLines: commonResponseLines[responseFilename],
@ -173,6 +186,7 @@ describe('balena push', function() {
Dockerfile: { fileSize: 88, type: 'file' }, Dockerfile: { fileSize: 88, type: 'file' },
'Dockerfile-alt': { fileSize: 30, type: 'file' }, 'Dockerfile-alt': { fileSize: 30, type: 'file' },
}; };
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json'; const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile( const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename), path.join(builderResponsePath, responseFilename),
@ -191,7 +205,182 @@ describe('balena push', function() {
await testPushBuildStream({ await testPushBuildStream({
builderMock: builder, builderMock: builder,
commandLine: `push testApp --source ${projectPath} --convert-eol`, commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -l`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
});
});
// Skip Windows because the old tarDirectory() implementation (still used when
// '--nogitignore' is not provided) uses the old `zeit/dockerignore` npm package
// that is broken on Windows (reason why we created `@balena/dockerignore`).
itSkipWindows(
'should create the expected tar stream (single container, with gitignore)',
async () => {
const projectPath = path.join(
projectsPath,
'no-docker-compose',
'dockerignore1',
);
const expectedFiles: ExpectedTarStreamFiles = {
'.balena/balena.yml': { fileSize: 12, type: 'file' },
'.dockerignore': { fileSize: 438, type: 'file' },
'.gitignore': { fileSize: 20, type: 'file' },
'.git/bar.txt': { fileSize: 4, type: 'file' },
'.git/foo.txt': { fileSize: 4, type: 'file' },
'c.txt': { fileSize: 1, type: 'file' },
Dockerfile: { fileSize: 13, type: 'file' },
'src/.balena/balena.yml': { fileSize: 16, type: 'file' },
'src/.gitignore': { fileSize: 10, type: 'file' },
'vendor/.git/vendor-git-contents': { fileSize: 20, type: 'file' },
};
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename),
'utf8',
);
const expectedResponseLines = [
'[Warn] Using file ignore patterns from:',
`[Warn] ${path.join(projectPath, '.dockerignore')}`,
`[Warn] ${path.join(projectPath, '.gitignore')}`,
`[Warn] ${path.join(projectPath, 'src', '.gitignore')}`,
'[Warn] balena CLI currently uses gitgnore and dockerignore files, but an upcoming major',
'[Warn] version release will disregard gitignore files and use a dockerignore file only.',
'[Warn] Use the --nogitignore (-G) option to enable the new behavior already now and',
"[Warn] suppress this warning. For more information, see 'balena help push'.",
...commonResponseLines[responseFilename],
];
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -l`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
});
},
);
it('should create the expected tar stream (single container, --nogitignore)', async () => {
const projectPath = path.join(
projectsPath,
'no-docker-compose',
'dockerignore1',
);
const expectedFiles: ExpectedTarStreamFiles = {
'.balena/balena.yml': { fileSize: 12, type: 'file' },
'.dockerignore': { fileSize: 438, type: 'file' },
'.gitignore': { fileSize: 20, type: 'file' },
'.git/foo.txt': { fileSize: 4, type: 'file' },
'a.txt': { fileSize: 1, type: 'file' },
'c.txt': { fileSize: 1, type: 'file' },
Dockerfile: { fileSize: 13, type: 'file' },
'src/.balena/balena.yml': { fileSize: 16, type: 'file' },
'src/.gitignore': { fileSize: 10, type: 'file' },
'src/src-a.txt': { fileSize: 5, type: 'file' },
'src/src-c.txt': { fileSize: 5, type: 'file' },
'vendor/.git/vendor-git-contents': { fileSize: 20, type: 'file' },
};
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename),
'utf8',
);
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -l -G`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines: commonResponseLines[responseFilename],
projectPath,
responseBody,
responseCode: 200,
});
});
it('should create the expected tar stream (single container, symbolic links, --nogitignore)', async () => {
const projectPath = path.join(
projectsPath,
'no-docker-compose',
'dockerignore2',
);
const expectedFiles: ExpectedTarStreamFiles = {
'.dockerignore': { fileSize: 34, type: 'file' },
'b.txt': { fileSize: 1, type: 'file' },
Dockerfile: { fileSize: 13, type: 'file' },
'src/src-b.txt': { fileSize: 5, type: 'file' },
'symlink-a.txt': { fileSize: 5, type: 'file' },
};
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename),
'utf8',
);
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -l -G`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines: commonResponseLines[responseFilename],
projectPath,
responseBody,
responseCode: 200,
});
});
it('should create the expected tar stream (single container, dockerignore warn)', async () => {
const projectPath = path.join(
projectsPath,
'no-docker-compose',
'dockerignore2',
);
const expectedFiles: ExpectedTarStreamFiles = {
'.dockerignore': { fileSize: 34, type: 'file' },
'b.txt': { fileSize: 1, type: 'file' },
Dockerfile: { fileSize: 13, type: 'file' },
'src/src-b.txt': { fileSize: 5, type: 'file' },
'symlink-a.txt': { fileSize: 5, type: 'file' },
};
if (isWindows) {
// this test uses the old tarDirectory implementation, which uses
// the zeit/dockerignore library that has bugs on Windows
expectedFiles['src/src-a.txt'] = { fileSize: 5, type: 'file' };
}
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename),
'utf8',
);
const expectedResponseLines = isWindows
? [
'[Warn] Using file ignore patterns from:',
`[Warn] ${path.join(projectPath, '.dockerignore')}`,
'[Warn] Use the --nogitignore (-G) option to suppress this warning and enable the use',
'[Warn] of a better dockerignore parser and filter library that fixes several issues',
'[Warn] on Windows and improves compatibility with "docker build", but which may also',
'[Warn] cause a different set of files to be filtered out (because of the bug fixes).',
'[Warn] The --nogitignore option will be the default behavior in an upcoming balena CLI',
"[Warn] major version release. For more information, see 'balena help push'.",
...commonResponseLines[responseFilename],
]
: commonResponseLines[responseFilename];
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -l`,
expectedFiles, expectedFiles,
expectedQueryParams: commonQueryParams, expectedQueryParams: commonQueryParams,
expectedResponseLines, expectedResponseLines,
@ -204,6 +393,8 @@ describe('balena push', function() {
it('should create the expected tar stream (docker-compose)', async () => { it('should create the expected tar stream (docker-compose)', async () => {
const projectPath = path.join(projectsPath, 'docker-compose', 'basic'); const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
const expectedFiles: ExpectedTarStreamFiles = { const expectedFiles: ExpectedTarStreamFiles = {
'.balena/balena.yml': { fileSize: 197, type: 'file' },
'.dockerignore': { fileSize: 22, type: 'file' },
'docker-compose.yml': { fileSize: 245, type: 'file' }, 'docker-compose.yml': { fileSize: 245, type: 'file' },
'service1/Dockerfile.template': { fileSize: 144, type: 'file' }, 'service1/Dockerfile.template': { fileSize: 144, type: 'file' },
'service1/file1.sh': { fileSize: 12, type: 'file' }, 'service1/file1.sh': { fileSize: 12, type: 'file' },
@ -214,6 +405,7 @@ describe('balena push', function() {
type: 'file', type: 'file',
}, },
}; };
const regSecretsPath = await addRegSecretsEntries(expectedFiles);
const responseFilename = 'build-POST-v3.json'; const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile( const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename), path.join(builderResponsePath, responseFilename),
@ -234,7 +426,7 @@ describe('balena push', function() {
await testPushBuildStream({ await testPushBuildStream({
builderMock: builder, builderMock: builder,
commandLine: `push testApp --source ${projectPath} --convert-eol`, commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -l -G`,
expectedFiles, expectedFiles,
expectedQueryParams: commonQueryParams, expectedQueryParams: commonQueryParams,
expectedResponseLines, expectedResponseLines,
@ -258,7 +450,7 @@ describe('balena push: project validation', function() {
]; ];
const { out, err } = await runCommand( const { out, err } = await runCommand(
`push testApp --source ${projectPath}`, `push testApp --source ${projectPath} --nogitignore`,
); );
expect( expect(
cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')), cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')),

View File

@ -29,25 +29,11 @@ import { URL } from 'url';
import { BuilderMock } from './builder-mock'; import { BuilderMock } from './builder-mock';
import { DockerMock } from './docker-mock'; import { DockerMock } from './docker-mock';
import { cleanOutput, fillTemplateArray, runCommand } from './helpers'; import { cleanOutput, fillTemplateArray, runCommand } from './helpers';
import {
export interface ExpectedTarStreamFile { ExpectedTarStreamFile,
contents?: string; ExpectedTarStreamFiles,
fileSize: number; ExpectedTarStreamFilesByService,
testStream?: ( } from './projects';
header: tar.Headers,
stream: Readable,
expected?: ExpectedTarStreamFile,
) => Promise<void>;
type: tar.Headers['type'];
}
export interface ExpectedTarStreamFiles {
[filePath: string]: ExpectedTarStreamFile;
}
export interface ExpectedTarStreamFilesByService {
[service: string]: ExpectedTarStreamFiles;
}
/** /**
* Run a few chai.expect() test assertions on a tar stream/buffer produced by * Run a few chai.expect() test assertions on a tar stream/buffer produced by
@ -77,10 +63,6 @@ export async function inspectTarStream(
'entry', 'entry',
async (header: tar.Headers, stream: Readable, next: tar.Callback) => { async (header: tar.Headers, stream: Readable, next: tar.Callback) => {
try { try {
// TODO: test the .balena folder instead of ignoring it
if (header.name.startsWith('.balena/')) {
stream.resume();
} else {
expect(foundFiles).to.not.have.property(header.name); expect(foundFiles).to.not.have.property(header.name);
foundFiles[header.name] = { foundFiles[header.name] = {
fileSize: header.size || 0, fileSize: header.size || 0,
@ -92,7 +74,6 @@ export async function inspectTarStream(
} else { } else {
await defaultTestStream(header, stream, expected, projectPath); await defaultTestStream(header, stream, expected, projectPath);
} }
}
} catch (err) { } catch (err) {
reject(err); reject(err);
} }
@ -122,6 +103,11 @@ async function defaultTestStream(
if (expected?.contents) { if (expected?.contents) {
expectedContents = Buffer.from(expected.contents); expectedContents = Buffer.from(expected.contents);
} }
if (header.name === '.balena/registry-secrets.json') {
expectedContents = await fs.readFile(
path.join(__dirname, 'test-data', 'projects', 'registry-secrets.json'),
);
}
const [buf, buf2] = await Promise.all([ const [buf, buf2] = await Promise.all([
streamToBuffer(stream), streamToBuffer(stream),
expectedContents || expectedContents ||

85
tests/projects.ts Normal file
View File

@ -0,0 +1,85 @@
/**
* @license
* Copyright 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.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Headers } from 'tar-stream';
import { promisify } from 'util';
const statAsync = promisify(fs.stat);
export interface ExpectedTarStreamFile {
contents?: string;
fileSize: number;
testStream?: (
header: Headers,
stream: import('stream').Readable,
expected?: ExpectedTarStreamFile,
) => Promise<void>;
type: Headers['type'];
}
export interface ExpectedTarStreamFiles {
[filePath: string]: ExpectedTarStreamFile;
}
export interface ExpectedTarStreamFilesByService {
[service: string]: ExpectedTarStreamFiles;
}
export const repoPath = path.normalize(path.join(__dirname, '..'));
export const projectsPath = path.join(
repoPath,
'tests',
'test-data',
'projects',
);
export async function setupDockerignoreTestData({ cleanup = false } = {}) {
const { copy, remove } = await import('fs-extra');
const dockerignoreProjDir = path.join(
__dirname,
'test-data',
'projects',
'no-docker-compose',
'dockerignore1',
);
const subdirs = ['', 'vendor'];
for (const subdir of subdirs) {
// A git repo cannot store a '.git' subfolder, even under tests/test-data/,
// so we store a 'dot.git' folder instead, and copy it as '.git' before
// running the tests. (Interestingly, 'git status' also ignores the '.git'
// folder, and shows a "clean repo" even after this copy is executed.)
const aliasDir = path.join(dockerignoreProjDir, subdir, 'dot.git');
const gitDir = path.join(dockerignoreProjDir, subdir, '.git');
await remove(gitDir);
if (!cleanup) {
await copy(aliasDir, gitDir);
}
}
}
export async function addRegSecretsEntries(
expectedFiles: ExpectedTarStreamFiles,
): Promise<string> {
const regSecretsPath = path.join(projectsPath, 'registry-secrets.json');
expectedFiles['.balena/registry-secrets.json'] = {
fileSize: (await statAsync(regSecretsPath)).size,
type: 'file',
};
return regSecretsPath;
}

View File

@ -0,0 +1,7 @@
build-variables:
global:
- MY_VAR_1=This is a variable
- MY_VAR_2=Also a variable
services:
service1:
- SERVICE1_VAR=This is a service specific variable

View File

@ -0,0 +1 @@
service1/test-ignore*

View File

@ -0,0 +1 @@
test-ignore

View File

@ -0,0 +1 @@
"balena.yml"

View File

@ -0,0 +1,19 @@
# Note that the CLI "hardcodes" some dockerignore patterns in its source code,
# which get "merged" with the user's patterns in their .dockerginore file as follows:
#
# **/.git
# <user's patterns from their .dockerignore file go here>
# !**/.balena
# !**/.resin
# !**/Dockerfile
# !**/Dockerfile.*
# !**/docker-compose.yml
#
b.txt
Dockerfile
registry-secrets.json
src/*b.txt
**/.balena
**/dot.git
!.git/foo.txt
!vendor/.git

View File

@ -0,0 +1,2 @@
a.txt
src/src-a.txt

View File

@ -0,0 +1 @@
FROM busybox

View File

@ -0,0 +1 @@
a

View File

@ -0,0 +1 @@
b

View File

@ -0,0 +1 @@
c

View File

@ -0,0 +1 @@
"src-balena.yml"

View File

@ -0,0 +1 @@
src-c.txt

View File

@ -0,0 +1 @@
vendor-git-contents

View File

@ -0,0 +1,3 @@
a.txt
src/src-a.txt
symlink-b.txt

View File

@ -0,0 +1 @@
FROM busybox

View File

@ -0,0 +1 @@
a

View File

@ -0,0 +1 @@
b

View File

@ -0,0 +1 @@
src/src-a.txt

View File

@ -0,0 +1 @@
src/src-b.txt

View File

@ -0,0 +1 @@
{"https://index.docker.io/v1/":{"username":"test","password":"test"}}

View File

@ -0,0 +1,192 @@
/**
* @license
* Copyright 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.
*/
// tslint:disable-next-line:no-var-requires
require('./../config-tests'); // required for side effects
import { expect } from 'chai';
import * as _ from 'lodash';
import * as path from 'path';
import * as tar from 'tar-stream';
import { tarDirectory } from '../../build/utils/compose';
import { setupDockerignoreTestData } from '../projects';
const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
interface TarFiles {
[name: string]: {
fileSize?: number;
type?: string | null;
};
}
const itSkipWindows = process.platform === 'win32' ? it.skip : it;
describe('compare new and old tarDirectory implementations', async function() {
const extraContent = 'extra';
const extraEntry: tar.Headers = {
name: 'extra.txt',
size: extraContent.length,
type: 'file',
};
const preFinalizeCallback = (pack: tar.Pack) => {
pack.entry(extraEntry, extraContent);
};
this.beforeAll(async () => {
await setupDockerignoreTestData();
});
this.afterAll(async () => {
await setupDockerignoreTestData({ cleanup: true });
});
it('should produce the expected file list', async function() {
const dockerignoreProjDir = path.join(
projectsPath,
'no-docker-compose',
'dockerignore1',
);
const expectedFiles = {
'.balena/balena.yml': { fileSize: 12, type: 'file' },
'.dockerignore': { fileSize: 438, type: 'file' },
'.gitignore': { fileSize: 20, type: 'file' },
'.git/foo.txt': { fileSize: 4, type: 'file' },
'a.txt': { fileSize: 1, type: 'file' },
'c.txt': { fileSize: 1, type: 'file' },
Dockerfile: { fileSize: 13, type: 'file' },
'extra.txt': { fileSize: 5, type: 'file' },
'src/.balena/balena.yml': { fileSize: 16, type: 'file' },
'src/.gitignore': { fileSize: 10, type: 'file' },
'src/src-a.txt': { fileSize: 5, type: 'file' },
'src/src-c.txt': { fileSize: 5, type: 'file' },
'vendor/.git/vendor-git-contents': { fileSize: 20, type: 'file' },
};
const tarPack = await tarDirectory(dockerignoreProjDir, {
preFinalizeCallback,
nogitignore: true,
});
const fileList = await getTarPackFiles(tarPack);
expect(fileList).to.deep.equal(expectedFiles);
});
it('should produce the expected file list (symbolic links)', async function() {
const projectPath = path.join(
projectsPath,
'no-docker-compose',
'dockerignore2',
);
const expectedFiles = {
'.dockerignore': { fileSize: 34, type: 'file' },
'b.txt': { fileSize: 1, type: 'file' },
Dockerfile: { fileSize: 13, type: 'file' },
'src/src-b.txt': { fileSize: 5, type: 'file' },
'symlink-a.txt': { fileSize: 5, type: 'file' },
};
const tarPack = await tarDirectory(projectPath, { nogitignore: true });
const fileList = await getTarPackFiles(tarPack);
expect(fileList).to.deep.equal(expectedFiles);
});
// Skip Windows because the old tarDirectory() implementation (still used when
// '--nogitignore' is not provided) uses the old `zeit/dockerignore` npm package
// that is broken on Windows (reason why we created `@balena/dockerignore`).
itSkipWindows('should produce a compatible tar stream', async function() {
const dockerignoreProjDir = path.join(
projectsPath,
'no-docker-compose',
'dockerignore1',
);
const oldTarPack = await tarDirectory(dockerignoreProjDir, {
preFinalizeCallback,
nogitignore: false,
});
const oldFileList = await getTarPackFiles(oldTarPack);
const newTarPack = await tarDirectory(dockerignoreProjDir, {
preFinalizeCallback,
nogitignore: true,
});
const newFileList = await getTarPackFiles(newTarPack);
const gitIgnored = ['a.txt', 'src/src-a.txt', 'src/src-c.txt'];
expect({
...newFileList,
..._.pick(oldFileList, ['.git/bar.txt']),
}).to.deep.equal({
...oldFileList,
..._.pick(newFileList, gitIgnored),
});
});
itSkipWindows(
'should produce a compatible tar stream (symbolic links)',
async function() {
const dockerignoreProjDir = path.join(
projectsPath,
'no-docker-compose',
'dockerignore2',
);
const oldTarPack = await tarDirectory(dockerignoreProjDir, {
preFinalizeCallback,
nogitignore: false,
});
const oldFileList = await getTarPackFiles(oldTarPack);
const newTarPack = await tarDirectory(dockerignoreProjDir, {
preFinalizeCallback,
nogitignore: true,
});
const newFileList = await getTarPackFiles(newTarPack);
expect(newFileList).to.deep.equal(oldFileList);
},
);
});
async function getTarPackFiles(
pack: import('stream').Readable,
): Promise<TarFiles> {
const { drainStream } = await import('tar-utils');
const fileList: TarFiles = {};
const extract = tar.extract();
return await new Promise((resolve, reject) => {
extract
.on('error', reject)
.on('entry', async function(header, stream, next) {
expect(fileList).to.not.have.property(header.name);
fileList[header.name] = {
fileSize: header.size,
type: header.type,
};
await drainStream(stream);
next();
})
.on('finish', function() {
resolve(fileList);
});
pack.pipe(extract);
});
}