2019-04-11 11:49:19 +00:00
|
|
|
/**
|
|
|
|
* @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.
|
|
|
|
*/
|
2018-08-15 12:13:06 +00:00
|
|
|
import * as _ from 'lodash';
|
2020-06-23 10:57:57 +00:00
|
|
|
import { promises as fs, Stats } from 'fs';
|
2018-08-15 12:13:06 +00:00
|
|
|
import * as path from 'path';
|
2019-04-11 11:49:19 +00:00
|
|
|
import * as MultiBuild from 'resin-multibuild';
|
2018-08-15 12:13:06 +00:00
|
|
|
|
|
|
|
import dockerIgnore = require('@zeit/dockerignore');
|
|
|
|
import ignore from 'ignore';
|
|
|
|
|
2020-05-01 11:18:11 +00:00
|
|
|
import { ExpectedError } from '../errors';
|
|
|
|
|
2019-04-11 11:49:19 +00:00
|
|
|
const { toPosixPath } = MultiBuild.PathUtils;
|
2019-04-12 12:52:41 +00:00
|
|
|
|
2018-08-15 12:13:06 +00:00
|
|
|
export enum IgnoreFileType {
|
|
|
|
DockerIgnore,
|
|
|
|
GitIgnore,
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IgnoreEntry {
|
|
|
|
pattern: string;
|
|
|
|
// The relative file path from the base path of the build context
|
|
|
|
filePath: string;
|
|
|
|
}
|
|
|
|
|
2020-06-16 23:38:03 +00:00
|
|
|
/**
|
|
|
|
* This class is used by the CLI v10 / v11 "original" tarDirectory function
|
|
|
|
* in `compose.js`. It is still around for the benefit of the `--gitignore`
|
|
|
|
* option, but is expected to be deleted in CLI v13.
|
|
|
|
*/
|
2018-08-15 12:13:06 +00:00
|
|
|
export class FileIgnorer {
|
|
|
|
private dockerIgnoreEntries: IgnoreEntry[];
|
|
|
|
private gitIgnoreEntries: IgnoreEntry[];
|
|
|
|
|
|
|
|
private static ignoreFiles: Array<{
|
|
|
|
pattern: string;
|
|
|
|
type: IgnoreFileType;
|
|
|
|
allowSubdirs: boolean;
|
|
|
|
}> = [
|
|
|
|
{
|
|
|
|
pattern: '.gitignore',
|
|
|
|
type: IgnoreFileType.GitIgnore,
|
|
|
|
allowSubdirs: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
pattern: '.dockerignore',
|
|
|
|
type: IgnoreFileType.DockerIgnore,
|
|
|
|
allowSubdirs: false,
|
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
public constructor(public basePath: string) {
|
|
|
|
this.dockerIgnoreEntries = [];
|
|
|
|
this.gitIgnoreEntries = [];
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @param {string} relativePath
|
|
|
|
* The relative pathname from the build context, for example a root level .gitignore should be
|
|
|
|
* ./.gitignore
|
|
|
|
* @returns IgnoreFileType
|
|
|
|
* The type of ignore file, or null
|
|
|
|
*/
|
|
|
|
public getIgnoreFileType(relativePath: string): IgnoreFileType | null {
|
|
|
|
for (const { pattern, type, allowSubdirs } of FileIgnorer.ignoreFiles) {
|
|
|
|
if (
|
|
|
|
path.basename(relativePath) === pattern &&
|
|
|
|
(allowSubdirs || path.dirname(relativePath) === '.')
|
|
|
|
) {
|
|
|
|
return type;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @param {string} fullPath
|
|
|
|
* The full path on disk of the ignore file
|
|
|
|
* @param {IgnoreFileType} type
|
|
|
|
* @returns Promise
|
|
|
|
*/
|
|
|
|
public async addIgnoreFile(
|
|
|
|
fullPath: string,
|
|
|
|
type: IgnoreFileType,
|
|
|
|
): Promise<void> {
|
|
|
|
const contents = await fs.readFile(fullPath, 'utf8');
|
|
|
|
|
2020-06-15 22:53:07 +00:00
|
|
|
contents.split('\n').forEach((line) => {
|
2018-08-15 12:13:06 +00:00
|
|
|
// ignore empty lines and comments
|
|
|
|
if (/\s*#/.test(line) || _.isEmpty(line)) {
|
|
|
|
return;
|
|
|
|
}
|
2018-11-01 12:21:56 +00:00
|
|
|
|
2018-08-15 12:13:06 +00:00
|
|
|
this.addEntry(line, fullPath, type);
|
|
|
|
});
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Pass this function as a predicate to a filter function, and it will filter
|
|
|
|
// any ignored files
|
|
|
|
public filter = (filename: string): boolean => {
|
2018-11-01 12:21:56 +00:00
|
|
|
const relFile = path.relative(this.basePath, filename);
|
|
|
|
|
|
|
|
// Don't ignore any metadata files
|
2019-04-12 12:52:41 +00:00
|
|
|
// The regex below matches `.balena/qemu` and `myservice/.balena/qemu`
|
|
|
|
// but not `some.dir.for.balena/qemu`.
|
|
|
|
if (/(^|\/)\.(balena|resin)\//.test(toPosixPath(relFile))) {
|
2018-11-01 12:21:56 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2020-01-08 14:50:13 +00:00
|
|
|
// Don't ignore Dockerfile (with or without extension) or docker-compose.yml
|
2020-01-20 21:21:05 +00:00
|
|
|
if (
|
|
|
|
/^Dockerfile$|^Dockerfile\.\S+/.test(path.basename(relFile)) ||
|
|
|
|
path.basename(relFile) === 'docker-compose.yml'
|
|
|
|
) {
|
2020-01-08 14:50:13 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2018-08-15 12:13:06 +00:00
|
|
|
const dockerIgnoreHandle = dockerIgnore();
|
|
|
|
const gitIgnoreHandle = ignore();
|
|
|
|
|
|
|
|
interface IgnoreHandle {
|
|
|
|
add: (pattern: string) => void;
|
|
|
|
ignores: (file: string) => boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
const ignoreTypes: Array<{
|
|
|
|
handle: IgnoreHandle;
|
|
|
|
entries: IgnoreEntry[];
|
|
|
|
}> = [
|
|
|
|
{ handle: dockerIgnoreHandle, entries: this.dockerIgnoreEntries },
|
|
|
|
{ handle: gitIgnoreHandle, entries: this.gitIgnoreEntries },
|
|
|
|
];
|
|
|
|
|
|
|
|
_.each(ignoreTypes, ({ handle, entries }) => {
|
|
|
|
_.each(entries, ({ pattern, filePath }) => {
|
|
|
|
if (FileIgnorer.contains(path.posix.dirname(filePath), filename)) {
|
|
|
|
handle.add(pattern);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
return !_.some(ignoreTypes, ({ handle }) => handle.ignores(relFile));
|
2019-03-12 22:07:57 +00:00
|
|
|
}; // tslint:disable-line:semicolon
|
2018-08-15 12:13:06 +00:00
|
|
|
|
|
|
|
private addEntry(
|
|
|
|
pattern: string,
|
|
|
|
filePath: string,
|
|
|
|
type: IgnoreFileType,
|
|
|
|
): void {
|
|
|
|
const entry: IgnoreEntry = { pattern, filePath };
|
|
|
|
switch (type) {
|
|
|
|
case IgnoreFileType.DockerIgnore:
|
|
|
|
this.dockerIgnoreEntries.push(entry);
|
|
|
|
break;
|
|
|
|
case IgnoreFileType.GitIgnore:
|
|
|
|
this.gitIgnoreEntries.push(entry);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given two paths, check whether the first contains the second
|
|
|
|
* @param path1 The potentially containing path
|
|
|
|
* @param path2 The potentially contained path
|
|
|
|
* @return A boolean indicating whether `path1` contains `path2`
|
|
|
|
*/
|
|
|
|
private static contains(path1: string, path2: string): boolean {
|
|
|
|
// First normalise the input, to remove any path weirdness
|
|
|
|
path1 = path.posix.normalize(path1);
|
|
|
|
path2 = path.posix.normalize(path2);
|
|
|
|
|
|
|
|
// Now test if the start of the relative path contains ../ ,
|
|
|
|
// which would tell us that path1 is not part of path2
|
|
|
|
return !/^\.\.\//.test(path.posix.relative(path1, path2));
|
|
|
|
}
|
|
|
|
}
|
2020-05-01 11:18:11 +00:00
|
|
|
|
2020-06-20 22:05:59 +00:00
|
|
|
export interface FileStats {
|
2020-05-01 11:18:11 +00:00
|
|
|
filePath: string;
|
|
|
|
relPath: string;
|
2020-06-23 10:57:57 +00:00
|
|
|
stats: Stats;
|
2020-05-01 11:18:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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(
|
2020-06-15 22:53:07 +00:00
|
|
|
dirEntries.map(async (entry) => {
|
2020-05-01 11:18:11 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2020-06-24 23:01:53 +00:00
|
|
|
* Create an instance of '@balena/dockerignore', initialized with the contents
|
|
|
|
* of a .dockerignore file (if any) found at the given directory argument, plus
|
|
|
|
* a set of default/hardcoded patterns.
|
|
|
|
* @param directory Directory where to look for a .dockerignore file
|
2020-05-01 11:18:11 +00:00
|
|
|
*/
|
2020-06-24 23:01:53 +00:00
|
|
|
async function getDockerIgnoreInstance(
|
|
|
|
directory: string,
|
|
|
|
): Promise<import('@balena/dockerignore').Ignore> {
|
|
|
|
const dockerIgnoreStr = await readDockerIgnoreFile(directory);
|
2020-05-01 11:18:11 +00:00
|
|
|
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',
|
|
|
|
]);
|
2020-06-24 23:01:53 +00:00
|
|
|
return ig;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface ServiceDirs {
|
|
|
|
[service: string]: string;
|
|
|
|
}
|
2020-05-01 11:18:11 +00:00
|
|
|
|
2020-06-24 23:01:53 +00:00
|
|
|
/**
|
|
|
|
* Create a list of files (FileStats[]) for the filesystem subtree rooted at
|
|
|
|
* projectDir, filtered against the applicable .dockerignore files, including
|
|
|
|
* a few default/hardcoded dockerignore patterns.
|
|
|
|
* @param projectDir Source directory to
|
|
|
|
* @param serviceDirsByService Map of service names to their subdirectories.
|
|
|
|
* The service directory names/paths must be relative to the project root dir
|
|
|
|
* and be "normalized" (path.normalize()) before the call to this function:
|
|
|
|
* they should use backslashes on Windows, not contain '.' or '..' segments and
|
|
|
|
* not contain multiple consecutive path separators like '//'. Also, relative
|
|
|
|
* paths must not start with './' (e.g. 'a/b' instead of './a/b').
|
|
|
|
*/
|
|
|
|
export async function filterFilesWithDockerignore(
|
|
|
|
projectDir: string,
|
|
|
|
serviceDirsByService?: ServiceDirs,
|
|
|
|
): Promise<{ filteredFileList: FileStats[]; dockerignoreFiles: FileStats[] }> {
|
|
|
|
// path.resolve() also converts forward slashes to backslashes on Windows
|
|
|
|
projectDir = path.resolve(projectDir);
|
|
|
|
// ignoreByDir stores an instance of the dockerignore filter for each service dir
|
|
|
|
const ignoreByDir: {
|
|
|
|
[serviceDir: string]: import('@balena/dockerignore').Ignore;
|
|
|
|
} = {
|
|
|
|
'.': await getDockerIgnoreInstance(projectDir),
|
|
|
|
};
|
|
|
|
const serviceDirs: string[] = Object.values(serviceDirsByService || {})
|
|
|
|
// filter out the project source/root dir
|
|
|
|
.filter((dir) => dir && dir !== '.')
|
|
|
|
// add a trailing '/' (or '\' on Windows) to the path
|
|
|
|
.map((dir) => (dir.endsWith(path.sep) ? dir : dir + path.sep));
|
|
|
|
|
|
|
|
for (const serviceDir of serviceDirs) {
|
|
|
|
ignoreByDir[serviceDir] = await getDockerIgnoreInstance(
|
|
|
|
path.join(projectDir, serviceDir),
|
|
|
|
);
|
|
|
|
}
|
2020-05-01 11:18:11 +00:00
|
|
|
const files = await listFiles(projectDir);
|
2020-06-20 22:05:59 +00:00
|
|
|
const dockerignoreFiles: FileStats[] = [];
|
|
|
|
const filteredFileList = files.filter((file: FileStats) => {
|
|
|
|
if (path.basename(file.relPath) === '.dockerignore') {
|
|
|
|
dockerignoreFiles.push(file);
|
|
|
|
}
|
2020-06-24 23:01:53 +00:00
|
|
|
for (const dir of serviceDirs) {
|
|
|
|
if (file.relPath.startsWith(dir)) {
|
|
|
|
return !ignoreByDir[dir].ignores(file.relPath.substring(dir.length));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return !ignoreByDir['.'].ignores(file.relPath);
|
2020-06-20 22:05:59 +00:00
|
|
|
});
|
|
|
|
return { filteredFileList, dockerignoreFiles };
|
2020-05-01 11:18:11 +00:00
|
|
|
}
|