mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-01-31 08:25:36 +00:00
Merge pull request #2093 from balena-io/2091-livepush-use-dockerignore
Livepush: Ignore paths set in .dockerignore files
This commit is contained in:
commit
e9b5773bcb
@ -543,16 +543,14 @@ async function loadBuildMetatada(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a map of service name to service subdirectory, obtained from the given
|
* Return a map of service name to service subdirectory (relative to sourceDir),
|
||||||
* composition object. If a composition object is not provided, an attempt will
|
* obtained from the given composition object. If a composition object is not
|
||||||
* be made to parse a 'docker-compose.yml' file at the given sourceDir.
|
* provided, an attempt will be made to parse a 'docker-compose.yml' file at
|
||||||
* Entries will be NOT be returned for subdirectories equal to '.' (e.g. the
|
* the given sourceDir.
|
||||||
* 'main' "service" of a single-container application).
|
|
||||||
*
|
|
||||||
* @param sourceDir Project source directory (project root)
|
* @param sourceDir Project source directory (project root)
|
||||||
* @param composition Optional previously parsed composition object
|
* @param composition Optional previously parsed composition object
|
||||||
*/
|
*/
|
||||||
async function getServiceDirsFromComposition(
|
export async function getServiceDirsFromComposition(
|
||||||
sourceDir: string,
|
sourceDir: string,
|
||||||
composition?: Composition,
|
composition?: Composition,
|
||||||
): Promise<Dictionary<string>> {
|
): Promise<Dictionary<string>> {
|
||||||
@ -585,11 +583,8 @@ async function getServiceDirsFromComposition(
|
|||||||
dir = dir.endsWith(path.sep) ? dir.slice(0, -1) : dir;
|
dir = dir.endsWith(path.sep) ? dir.slice(0, -1) : dir;
|
||||||
// remove './' prefix (or '.\\' on Windows)
|
// remove './' prefix (or '.\\' on Windows)
|
||||||
dir = dir.startsWith(relPrefix) ? dir.slice(2) : dir;
|
dir = dir.startsWith(relPrefix) ? dir.slice(2) : dir;
|
||||||
// filter out a '.' service directory (e.g. for the 'main' service
|
|
||||||
// of a single-container application)
|
serviceDirs[serviceName] = dir || '.';
|
||||||
if (dir && dir !== '.') {
|
|
||||||
serviceDirs[serviceName] = dir;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return serviceDirs;
|
return serviceDirs;
|
||||||
@ -660,10 +655,6 @@ async function newTarDirectory(
|
|||||||
const { filterFilesWithDockerignore } = await import('./ignore');
|
const { filterFilesWithDockerignore } = await import('./ignore');
|
||||||
const { toPosixPath } = (await import('resin-multibuild')).PathUtils;
|
const { toPosixPath } = (await import('resin-multibuild')).PathUtils;
|
||||||
|
|
||||||
const serviceDirs = multiDockerignore
|
|
||||||
? await getServiceDirsFromComposition(dir, composition)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
let readFile: (file: string) => Promise<Buffer>;
|
let readFile: (file: string) => Promise<Buffer>;
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const { readFileWithEolConversion } = require('./eol-conversion');
|
const { readFileWithEolConversion } = require('./eol-conversion');
|
||||||
@ -673,10 +664,11 @@ async function newTarDirectory(
|
|||||||
}
|
}
|
||||||
const tar = await import('tar-stream');
|
const tar = await import('tar-stream');
|
||||||
const pack = tar.pack();
|
const pack = tar.pack();
|
||||||
|
const serviceDirs = await getServiceDirsFromComposition(dir, composition);
|
||||||
const {
|
const {
|
||||||
filteredFileList,
|
filteredFileList,
|
||||||
dockerignoreFiles,
|
dockerignoreFiles,
|
||||||
} = await filterFilesWithDockerignore(dir, serviceDirs);
|
} = await filterFilesWithDockerignore(dir, multiDockerignore, serviceDirs);
|
||||||
printDockerignoreWarn(dockerignoreFiles, serviceDirs, multiDockerignore);
|
printDockerignoreWarn(dockerignoreFiles, serviceDirs, multiDockerignore);
|
||||||
for (const fileStats of filteredFileList) {
|
for (const fileStats of filteredFileList) {
|
||||||
pack.entry(
|
pack.entry(
|
||||||
@ -703,7 +695,7 @@ async function newTarDirectory(
|
|||||||
* @param serviceDirsByService Map of service names to service subdirectories
|
* @param serviceDirsByService Map of service names to service subdirectories
|
||||||
* @param multiDockerignore Whether --multi-dockerignore (-m) was provided
|
* @param multiDockerignore Whether --multi-dockerignore (-m) was provided
|
||||||
*/
|
*/
|
||||||
export function printDockerignoreWarn(
|
function printDockerignoreWarn(
|
||||||
dockerignoreFiles: Array<import('./ignore').FileStats>,
|
dockerignoreFiles: Array<import('./ignore').FileStats>,
|
||||||
serviceDirsByService: Dictionary<string>,
|
serviceDirsByService: Dictionary<string>,
|
||||||
multiDockerignore: boolean,
|
multiDockerignore: boolean,
|
||||||
|
@ -1,5 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2019-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 chokidar from 'chokidar';
|
import * as chokidar from 'chokidar';
|
||||||
import type * as Dockerode from 'dockerode';
|
import type * as Dockerode from 'dockerode';
|
||||||
|
import * as fs from 'fs';
|
||||||
import Livepush, { ContainerNotRunningError } from 'livepush';
|
import Livepush, { ContainerNotRunningError } from 'livepush';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@ -92,8 +110,22 @@ export class LivepushManager {
|
|||||||
// Split the composition into a load of differents paths
|
// Split the composition into a load of differents paths
|
||||||
// which we can
|
// which we can
|
||||||
this.logger.logLivepush('Device state settled');
|
this.logger.logLivepush('Device state settled');
|
||||||
// create livepush instances for
|
|
||||||
|
|
||||||
|
// Prepare dockerignore data for file watcher
|
||||||
|
const { getDockerignoreByService } = await import('../ignore');
|
||||||
|
const { getServiceDirsFromComposition } = await import('../compose_ts');
|
||||||
|
const rootContext = path.resolve(this.buildContext);
|
||||||
|
const serviceDirsByService = await getServiceDirsFromComposition(
|
||||||
|
this.deployOpts.source,
|
||||||
|
this.composition,
|
||||||
|
);
|
||||||
|
const dockerignoreByService = await getDockerignoreByService(
|
||||||
|
this.deployOpts.source,
|
||||||
|
this.deployOpts.multiDockerignore,
|
||||||
|
serviceDirsByService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// create livepush instances for each service
|
||||||
for (const serviceName of _.keys(this.composition.services)) {
|
for (const serviceName of _.keys(this.composition.services)) {
|
||||||
const service = this.composition.services[serviceName];
|
const service = this.composition.services[serviceName];
|
||||||
const buildTask = _.find(this.buildTasks, { serviceName });
|
const buildTask = _.find(this.buildTasks, { serviceName });
|
||||||
@ -106,7 +138,6 @@ export class LivepushManager {
|
|||||||
|
|
||||||
// We only care about builds
|
// We only care about builds
|
||||||
if (service.build != null) {
|
if (service.build != null) {
|
||||||
const context = path.join(this.buildContext, service.build.context);
|
|
||||||
if (buildTask.dockerfile == null) {
|
if (buildTask.dockerfile == null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not detect dockerfile for service: ${serviceName}`,
|
`Could not detect dockerfile for service: ${serviceName}`,
|
||||||
@ -137,6 +168,10 @@ export class LivepushManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// path.resolve() converts to an absolute path, removes trailing slashes,
|
||||||
|
// and also converts forward slashes to backslashes on Windows.
|
||||||
|
const context = path.resolve(rootContext, service.build.context);
|
||||||
|
|
||||||
const livepush = await Livepush.init({
|
const livepush = await Livepush.init({
|
||||||
dockerfile,
|
dockerfile,
|
||||||
context,
|
context,
|
||||||
@ -155,27 +190,22 @@ export class LivepushManager {
|
|||||||
|
|
||||||
this.updateEventsWaiting[serviceName] = [];
|
this.updateEventsWaiting[serviceName] = [];
|
||||||
this.deleteEventsWaiting[serviceName] = [];
|
this.deleteEventsWaiting[serviceName] = [];
|
||||||
const addEvent = (eventQueue: string[], changedPath: string) => {
|
const addEvent = ($serviceName: string, changedPath: string) => {
|
||||||
this.logger.logDebug(
|
this.logger.logDebug(
|
||||||
`Got an add filesystem event for service: ${serviceName}. File: ${changedPath}`,
|
`Got an add filesystem event for service: ${$serviceName}. File: ${changedPath}`,
|
||||||
);
|
);
|
||||||
|
const eventQueue = this.updateEventsWaiting[$serviceName];
|
||||||
eventQueue.push(changedPath);
|
eventQueue.push(changedPath);
|
||||||
this.getDebouncedEventHandler(serviceName)();
|
this.getDebouncedEventHandler($serviceName)();
|
||||||
};
|
};
|
||||||
// TODO: Memoize this for containers which share a context
|
|
||||||
const monitor = chokidar.watch('.', {
|
const monitor = this.setupFilesystemWatcher(
|
||||||
cwd: context,
|
serviceName,
|
||||||
ignoreInitial: true,
|
rootContext,
|
||||||
ignored: '.git',
|
context,
|
||||||
});
|
addEvent,
|
||||||
monitor.on('add', (changedPath: string) =>
|
dockerignoreByService,
|
||||||
addEvent(this.updateEventsWaiting[serviceName], changedPath),
|
this.deployOpts.multiDockerignore,
|
||||||
);
|
|
||||||
monitor.on('change', (changedPath: string) =>
|
|
||||||
addEvent(this.updateEventsWaiting[serviceName], changedPath),
|
|
||||||
);
|
|
||||||
monitor.on('unlink', (changedPath: string) =>
|
|
||||||
addEvent(this.deleteEventsWaiting[serviceName], changedPath),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.containers[serviceName] = {
|
this.containers[serviceName] = {
|
||||||
@ -209,6 +239,57 @@ export class LivepushManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected setupFilesystemWatcher(
|
||||||
|
serviceName: string,
|
||||||
|
rootContext: string,
|
||||||
|
serviceContext: string,
|
||||||
|
changedPathHandler: (serviceName: string, changedPath: string) => void,
|
||||||
|
dockerignoreByService: {
|
||||||
|
[serviceName: string]: import('@balena/dockerignore').Ignore;
|
||||||
|
},
|
||||||
|
multiDockerignore: boolean,
|
||||||
|
): chokidar.FSWatcher {
|
||||||
|
const contextForDockerignore = multiDockerignore
|
||||||
|
? serviceContext
|
||||||
|
: rootContext;
|
||||||
|
const dockerignore = dockerignoreByService[serviceName];
|
||||||
|
// TODO: Memoize this for services that share a context
|
||||||
|
const monitor = chokidar.watch('.', {
|
||||||
|
cwd: serviceContext,
|
||||||
|
followSymlinks: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
ignored: (filePath: string, stats: fs.Stats | undefined) => {
|
||||||
|
if (!stats) {
|
||||||
|
try {
|
||||||
|
// sync because chokidar defines a sync interface
|
||||||
|
stats = fs.lstatSync(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
// OK: the file may have been deleted. See also:
|
||||||
|
// https://github.com/paulmillr/chokidar/blob/3.4.3/lib/fsevents-handler.js#L326-L328
|
||||||
|
// https://github.com/paulmillr/chokidar/blob/3.4.3/lib/nodefs-handler.js#L364
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stats && !stats.isFile() && !stats.isSymbolicLink()) {
|
||||||
|
// never ignore directories for compatibility with
|
||||||
|
// dockerignore exclusion patterns
|
||||||
|
return !stats.isDirectory();
|
||||||
|
}
|
||||||
|
const relPath = path.relative(contextForDockerignore, filePath);
|
||||||
|
return dockerignore.ignores(relPath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
monitor.on('add', (changedPath: string) =>
|
||||||
|
changedPathHandler(serviceName, changedPath),
|
||||||
|
);
|
||||||
|
monitor.on('change', (changedPath: string) =>
|
||||||
|
changedPathHandler(serviceName, changedPath),
|
||||||
|
);
|
||||||
|
monitor.on('unlink', (changedPath: string) =>
|
||||||
|
changedPathHandler(serviceName, changedPath),
|
||||||
|
);
|
||||||
|
return monitor;
|
||||||
|
}
|
||||||
|
|
||||||
public static preprocessDockerfile(content: string): string {
|
public static preprocessDockerfile(content: string): string {
|
||||||
return new Dockerfile(content).generateLiveDockerfile();
|
return new Dockerfile(content).generateLiveDockerfile();
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ 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 type { Ignore } from '@balena/dockerignore';
|
||||||
|
|
||||||
import { ExpectedError } from '../errors';
|
import { ExpectedError } from '../errors';
|
||||||
|
|
||||||
@ -196,37 +197,6 @@ export interface FileStats {
|
|||||||
stats: Stats;
|
stats: 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 the contents of a .dockerignore file at projectDir, as a string.
|
||||||
* Return an empty string if a .dockerignore file does not exist.
|
* Return an empty string if a .dockerignore file does not exist.
|
||||||
@ -254,9 +224,9 @@ async function readDockerIgnoreFile(projectDir: string): Promise<string> {
|
|||||||
* a set of default/hardcoded patterns.
|
* a set of default/hardcoded patterns.
|
||||||
* @param directory Directory where to look for a .dockerignore file
|
* @param directory Directory where to look for a .dockerignore file
|
||||||
*/
|
*/
|
||||||
async function getDockerIgnoreInstance(
|
export async function getDockerIgnoreInstance(
|
||||||
directory: string,
|
directory: string,
|
||||||
): Promise<import('@balena/dockerignore').Ignore> {
|
): Promise<Ignore> {
|
||||||
const dockerIgnoreStr = await readDockerIgnoreFile(directory);
|
const dockerIgnoreStr = await readDockerIgnoreFile(directory);
|
||||||
const $dockerIgnore = (await import('@balena/dockerignore')).default;
|
const $dockerIgnore = (await import('@balena/dockerignore')).default;
|
||||||
const ig = $dockerIgnore({ ignorecase: false });
|
const ig = $dockerIgnore({ ignorecase: false });
|
||||||
@ -283,7 +253,8 @@ export interface ServiceDirs {
|
|||||||
* Create a list of files (FileStats[]) for the filesystem subtree rooted at
|
* Create a list of files (FileStats[]) for the filesystem subtree rooted at
|
||||||
* projectDir, filtered against the applicable .dockerignore files, including
|
* projectDir, filtered against the applicable .dockerignore files, including
|
||||||
* a few default/hardcoded dockerignore patterns.
|
* a few default/hardcoded dockerignore patterns.
|
||||||
* @param projectDir Source directory to
|
* @param projectDir Source directory
|
||||||
|
* @param multiDockerignore The --multi-dockerignore (-m) option
|
||||||
* @param serviceDirsByService Map of service names to their subdirectories.
|
* @param serviceDirsByService Map of service names to their subdirectories.
|
||||||
* The service directory names/paths must be relative to the project root dir
|
* The service directory names/paths must be relative to the project root dir
|
||||||
* and be "normalized" (path.normalize()) before the call to this function:
|
* and be "normalized" (path.normalize()) before the call to this function:
|
||||||
@ -293,39 +264,106 @@ export interface ServiceDirs {
|
|||||||
*/
|
*/
|
||||||
export async function filterFilesWithDockerignore(
|
export async function filterFilesWithDockerignore(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
serviceDirsByService?: ServiceDirs,
|
multiDockerignore: boolean,
|
||||||
|
serviceDirsByService: ServiceDirs,
|
||||||
): Promise<{ filteredFileList: FileStats[]; dockerignoreFiles: FileStats[] }> {
|
): Promise<{ filteredFileList: FileStats[]; dockerignoreFiles: FileStats[] }> {
|
||||||
// path.resolve() also converts forward slashes to backslashes on Windows
|
// path.resolve() also converts forward slashes to backslashes on Windows
|
||||||
projectDir = path.resolve(projectDir);
|
projectDir = path.resolve(projectDir);
|
||||||
// ignoreByDir stores an instance of the dockerignore filter for each service dir
|
const root = '.' + path.sep;
|
||||||
const ignoreByDir: {
|
const ignoreByService = await getDockerignoreByService(
|
||||||
[serviceDir: string]: import('@balena/dockerignore').Ignore;
|
projectDir,
|
||||||
} = {
|
multiDockerignore,
|
||||||
'.': await getDockerIgnoreInstance(projectDir),
|
serviceDirsByService,
|
||||||
};
|
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
|
// Sample contents of ignoreByDir:
|
||||||
|
// { './': (dockerignore instance), 'foo/': (dockerignore instance) }
|
||||||
|
const ignoreByDir: { [serviceDir: string]: Ignore } = {};
|
||||||
|
for (let [serviceName, dir] of Object.entries(serviceDirsByService)) {
|
||||||
|
// convert slashes to backslashes on Windows, resolve '..' segments
|
||||||
|
dir = path.normalize(dir);
|
||||||
|
// add a trailing '/' (or '\' on Windows) to the path
|
||||||
|
dir = dir.endsWith(path.sep) ? dir : dir + path.sep;
|
||||||
|
ignoreByDir[dir] = ignoreByService[serviceName];
|
||||||
}
|
}
|
||||||
const files = await listFiles(projectDir);
|
if (!ignoreByDir[root]) {
|
||||||
|
ignoreByDir[root] = await getDockerIgnoreInstance(projectDir);
|
||||||
|
}
|
||||||
|
const dockerignoreServiceDirs: string[] = multiDockerignore
|
||||||
|
? Object.keys(ignoreByDir).filter((dir) => dir && dir !== root)
|
||||||
|
: [];
|
||||||
const dockerignoreFiles: FileStats[] = [];
|
const dockerignoreFiles: FileStats[] = [];
|
||||||
const filteredFileList = files.filter((file: FileStats) => {
|
const filteredFileList: FileStats[] = [];
|
||||||
if (path.basename(file.relPath) === '.dockerignore') {
|
const klaw = await import('klaw');
|
||||||
dockerignoreFiles.push(file);
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
// Looking at klaw's source code, `preserveSymlinks` appears to only
|
||||||
|
// afect the `stats` argument to the `data` event handler
|
||||||
|
klaw(projectDir, { preserveSymlinks: false })
|
||||||
|
.on('error', reject)
|
||||||
|
.on('end', resolve)
|
||||||
|
.on('data', (item: { path: string; stats: Stats }) => {
|
||||||
|
const { path: filePath, stats } = item;
|
||||||
|
// With `preserveSymlinks: false`, filePath cannot be a symlink.
|
||||||
|
// filePath may be a directory or a regular or special file
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
for (const dir of serviceDirs) {
|
const relPath = path.relative(projectDir, filePath);
|
||||||
if (file.relPath.startsWith(dir)) {
|
const fileInfo = {
|
||||||
return !ignoreByDir[dir].ignores(file.relPath.substring(dir.length));
|
filePath,
|
||||||
|
relPath,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
if (path.basename(relPath) === '.dockerignore') {
|
||||||
|
dockerignoreFiles.push(fileInfo);
|
||||||
|
}
|
||||||
|
for (const dir of dockerignoreServiceDirs) {
|
||||||
|
if (relPath.startsWith(dir)) {
|
||||||
|
if (!ignoreByDir[dir].ignores(relPath.substring(dir.length))) {
|
||||||
|
filteredFileList.push(fileInfo);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return !ignoreByDir['.'].ignores(file.relPath);
|
if (!ignoreByDir[root].ignores(relPath)) {
|
||||||
|
filteredFileList.push(fileInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return { filteredFileList, dockerignoreFiles };
|
return { filteredFileList, dockerignoreFiles };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dockerignoreByService: { [serviceName: string]: Ignore } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dockerignore instances for each service in serviceDirsByService.
|
||||||
|
* Dockerignore instances are cached and may be shared between services.
|
||||||
|
* @param projectDir Source directory
|
||||||
|
* @param multiDockerignore The --multi-dockerignore (-m) option
|
||||||
|
* @param serviceDirsByService Map of service names to their subdirectories
|
||||||
|
*/
|
||||||
|
export async function getDockerignoreByService(
|
||||||
|
projectDir: string,
|
||||||
|
multiDockerignore: boolean,
|
||||||
|
serviceDirsByService: ServiceDirs,
|
||||||
|
): Promise<{ [serviceName: string]: Ignore }> {
|
||||||
|
if (dockerignoreByService) {
|
||||||
|
return dockerignoreByService;
|
||||||
|
}
|
||||||
|
const cachedDirs: { [dir: string]: Ignore } = {};
|
||||||
|
// path.resolve() converts to an absolute path, removes trailing slashes,
|
||||||
|
// and also converts forward slashes to backslashes on Windows.
|
||||||
|
projectDir = path.resolve(projectDir);
|
||||||
|
dockerignoreByService = {};
|
||||||
|
|
||||||
|
for (let [serviceName, dir] of Object.entries(serviceDirsByService)) {
|
||||||
|
dir = multiDockerignore ? dir : '.';
|
||||||
|
const absDir = path.resolve(projectDir, dir);
|
||||||
|
if (!cachedDirs[absDir]) {
|
||||||
|
cachedDirs[absDir] = await getDockerIgnoreInstance(absDir);
|
||||||
|
}
|
||||||
|
dockerignoreByService[serviceName] = cachedDirs[absDir];
|
||||||
|
}
|
||||||
|
|
||||||
|
return dockerignoreByService;
|
||||||
|
}
|
||||||
|
825
npm-shrinkwrap.json
generated
825
npm-shrinkwrap.json
generated
File diff suppressed because it is too large
Load Diff
@ -149,7 +149,7 @@
|
|||||||
"@types/rewire": "^2.5.28",
|
"@types/rewire": "^2.5.28",
|
||||||
"@types/rimraf": "^3.0.0",
|
"@types/rimraf": "^3.0.0",
|
||||||
"@types/shell-escape": "^0.2.0",
|
"@types/shell-escape": "^0.2.0",
|
||||||
"@types/sinon": "^9.0.4",
|
"@types/sinon": "^9.0.8",
|
||||||
"@types/split": "^1.0.0",
|
"@types/split": "^1.0.0",
|
||||||
"@types/stream-to-promise": "2.2.0",
|
"@types/stream-to-promise": "2.2.0",
|
||||||
"@types/tar-stream": "^2.1.0",
|
"@types/tar-stream": "^2.1.0",
|
||||||
@ -179,9 +179,9 @@
|
|||||||
"parse-link-header": "~1.0.1",
|
"parse-link-header": "~1.0.1",
|
||||||
"pkg": "^4.4.9",
|
"pkg": "^4.4.9",
|
||||||
"publish-release": "^1.6.1",
|
"publish-release": "^1.6.1",
|
||||||
"rewire": "^4.0.1",
|
"rewire": "^5.0.0",
|
||||||
"simple-git": "^1.132.0",
|
"simple-git": "^1.132.0",
|
||||||
"sinon": "^9.0.3",
|
"sinon": "^9.2.1",
|
||||||
"ts-node": "^8.10.2",
|
"ts-node": "^8.10.2",
|
||||||
"typescript": "^4.0.2"
|
"typescript": "^4.0.2"
|
||||||
},
|
},
|
||||||
@ -209,7 +209,7 @@
|
|||||||
"bluebird": "^3.7.2",
|
"bluebird": "^3.7.2",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
"chalk": "^3.0.0",
|
"chalk": "^3.0.0",
|
||||||
"chokidar": "^3.3.1",
|
"chokidar": "^3.4.3",
|
||||||
"cli-truncate": "^2.1.0",
|
"cli-truncate": "^2.1.0",
|
||||||
"color-hash": "^1.0.3",
|
"color-hash": "^1.0.3",
|
||||||
"columnify": "^1.5.2",
|
"columnify": "^1.5.2",
|
||||||
|
@ -20,6 +20,7 @@ import * as _ from 'lodash';
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { PathUtils } from 'resin-multibuild';
|
import { PathUtils } from 'resin-multibuild';
|
||||||
|
import rewire = require('rewire');
|
||||||
import * as sinon from 'sinon';
|
import * as sinon from 'sinon';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import * as tar from 'tar-stream';
|
import * as tar from 'tar-stream';
|
||||||
@ -199,6 +200,8 @@ export async function testDockerBuildStream(o: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetDockerignoreCache();
|
||||||
|
|
||||||
const { exitCode, out, err } = await runCommand(o.commandLine);
|
const { exitCode, out, err } = await runCommand(o.commandLine);
|
||||||
|
|
||||||
if (expectedErrorLines.length) {
|
if (expectedErrorLines.length) {
|
||||||
@ -249,8 +252,20 @@ export async function testPushBuildStream(o: {
|
|||||||
inspectTarStream(buildRequestBody, o.expectedFiles, o.projectPath),
|
inspectTarStream(buildRequestBody, o.expectedFiles, o.projectPath),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resetDockerignoreCache();
|
||||||
|
|
||||||
const { out, err } = await runCommand(o.commandLine);
|
const { out, err } = await runCommand(o.commandLine);
|
||||||
|
|
||||||
expect(err).to.be.empty;
|
expect(err).to.be.empty;
|
||||||
expect(cleanOutput(out, true)).to.include.members(expectedResponseLines);
|
expect(cleanOutput(out, true)).to.include.members(expectedResponseLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetDockerignoreCache() {
|
||||||
|
if (process.env.BALENA_CLI_TEST_TYPE !== 'source') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ignorePath = '../build/utils/ignore';
|
||||||
|
delete require.cache[require.resolve(ignorePath)];
|
||||||
|
const ignoreMod = rewire(ignorePath);
|
||||||
|
ignoreMod.__set__('dockerignoreByService', null);
|
||||||
|
}
|
||||||
|
374
tests/utils/device/live.spec.ts
Normal file
374
tests/utils/device/live.spec.ts
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* @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 { expect } from 'chai';
|
||||||
|
import * as chokidar from 'chokidar';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
import { LivepushManager } from '../../../lib/utils/device/live';
|
||||||
|
import { resetDockerignoreCache } from '../../docker-build';
|
||||||
|
import { setupDockerignoreTestData } from '../../projects';
|
||||||
|
|
||||||
|
const delay = promisify(setTimeout);
|
||||||
|
const FS_WATCH_DURATION_MS = 500;
|
||||||
|
|
||||||
|
const repoPath = path.normalize(path.join(__dirname, '..', '..', '..'));
|
||||||
|
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
|
||||||
|
|
||||||
|
interface ByService<T> {
|
||||||
|
[serviceName: string]: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockLivepushManager extends LivepushManager {
|
||||||
|
public constructor() {
|
||||||
|
super({
|
||||||
|
buildContext: '',
|
||||||
|
composition: { version: '2.1', services: {} },
|
||||||
|
buildTasks: [],
|
||||||
|
docker: {} as import('dockerode'),
|
||||||
|
api: {} as import('../../../lib/utils/device/api').DeviceAPI,
|
||||||
|
logger: {} as import('../../../lib/utils/logger'),
|
||||||
|
buildLogs: {},
|
||||||
|
deployOpts: {} as import('../../../lib/utils/device/deploy').DeviceDeployOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public testSetupFilesystemWatcher(
|
||||||
|
serviceName: string,
|
||||||
|
rootContext: string,
|
||||||
|
serviceContext: string,
|
||||||
|
changedPathHandler: (serviceName: string, changedPath: string) => void,
|
||||||
|
dockerignoreByService: ByService<import('@balena/dockerignore').Ignore>,
|
||||||
|
multiDockerignore: boolean,
|
||||||
|
): import('chokidar').FSWatcher {
|
||||||
|
return super.setupFilesystemWatcher(
|
||||||
|
serviceName,
|
||||||
|
rootContext,
|
||||||
|
serviceContext,
|
||||||
|
changedPathHandler,
|
||||||
|
dockerignoreByService,
|
||||||
|
multiDockerignore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "describeSS" stands for "describe Skip Standalone"
|
||||||
|
const describeSS =
|
||||||
|
process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? describe.skip : describe;
|
||||||
|
|
||||||
|
describeSS('LivepushManager::setupFilesystemWatcher', function () {
|
||||||
|
const manager = new MockLivepushManager();
|
||||||
|
|
||||||
|
async function createMonitors(
|
||||||
|
projectPath: string,
|
||||||
|
composition: import('resin-compose-parse').Composition,
|
||||||
|
multiDockerignore: boolean,
|
||||||
|
changedPathHandler: (serviceName: string, changedPath: string) => void,
|
||||||
|
): Promise<ByService<chokidar.FSWatcher>> {
|
||||||
|
const { getServiceDirsFromComposition } = await import(
|
||||||
|
'../../../build/utils/compose_ts'
|
||||||
|
);
|
||||||
|
const { getDockerignoreByService } = await import(
|
||||||
|
'../../../build/utils/ignore'
|
||||||
|
);
|
||||||
|
const rootContext = path.resolve(projectPath);
|
||||||
|
|
||||||
|
const monitors: ByService<chokidar.FSWatcher> = {};
|
||||||
|
|
||||||
|
const serviceDirsByService = await getServiceDirsFromComposition(
|
||||||
|
projectPath,
|
||||||
|
composition,
|
||||||
|
);
|
||||||
|
const dockerignoreByService = await getDockerignoreByService(
|
||||||
|
projectPath,
|
||||||
|
multiDockerignore,
|
||||||
|
serviceDirsByService,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const serviceName of Object.keys(composition.services)) {
|
||||||
|
const service = composition.services[serviceName];
|
||||||
|
const serviceContext = path.resolve(rootContext, service.build!.context);
|
||||||
|
|
||||||
|
const monitor = manager.testSetupFilesystemWatcher(
|
||||||
|
serviceName,
|
||||||
|
rootContext,
|
||||||
|
serviceContext,
|
||||||
|
changedPathHandler,
|
||||||
|
dockerignoreByService,
|
||||||
|
multiDockerignore,
|
||||||
|
);
|
||||||
|
monitors[serviceName] = monitor;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
monitor.on('error', reject);
|
||||||
|
monitor.on('ready', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return monitors;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.beforeAll(async () => {
|
||||||
|
await setupDockerignoreTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.afterAll(async () => {
|
||||||
|
await setupDockerignoreTestData({ cleanup: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.beforeEach(() => {
|
||||||
|
resetDockerignoreCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for project no-docker-compose/basic', function () {
|
||||||
|
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
|
||||||
|
const composition = {
|
||||||
|
version: '2.1',
|
||||||
|
services: {
|
||||||
|
main: { build: { context: '.' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should trigger change events for paths that are not ignored', async () => {
|
||||||
|
const changedPaths: ByService<string[]> = { main: [] };
|
||||||
|
const multiDockerignore = true;
|
||||||
|
const monitors = await createMonitors(
|
||||||
|
projectPath,
|
||||||
|
composition,
|
||||||
|
multiDockerignore,
|
||||||
|
(serviceName: string, changedPath: string) => {
|
||||||
|
changedPaths[serviceName].push(changedPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
touch(path.join(projectPath, 'Dockerfile')),
|
||||||
|
touch(path.join(projectPath, 'src', 'start.sh')),
|
||||||
|
touch(path.join(projectPath, 'src', 'windows-crlf.sh')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// wait a bit so that filesystem modifications are notified
|
||||||
|
await delay(FS_WATCH_DURATION_MS);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(monitors).map((monitor) => monitor.close()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(changedPaths['main']).to.have.members([
|
||||||
|
'Dockerfile',
|
||||||
|
path.join('src', 'start.sh'),
|
||||||
|
path.join('src', 'windows-crlf.sh'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for project no-docker-compose/dockerignore1', function () {
|
||||||
|
const projectPath = path.join(
|
||||||
|
projectsPath,
|
||||||
|
'no-docker-compose',
|
||||||
|
'dockerignore1',
|
||||||
|
);
|
||||||
|
const composition = {
|
||||||
|
version: '2.1',
|
||||||
|
services: {
|
||||||
|
main: { build: { context: '.' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should trigger change events for paths that are not ignored', async () => {
|
||||||
|
const changedPaths: ByService<string[]> = { main: [] };
|
||||||
|
const multiDockerignore = true;
|
||||||
|
const monitors = await createMonitors(
|
||||||
|
projectPath,
|
||||||
|
composition,
|
||||||
|
multiDockerignore,
|
||||||
|
(serviceName: string, changedPath: string) => {
|
||||||
|
changedPaths[serviceName].push(changedPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
touch(path.join(projectPath, 'a.txt')),
|
||||||
|
touch(path.join(projectPath, 'b.txt')),
|
||||||
|
touch(path.join(projectPath, 'vendor', '.git', 'vendor-git-contents')),
|
||||||
|
touch(path.join(projectPath, 'src', 'src-a.txt')),
|
||||||
|
touch(path.join(projectPath, 'src', 'src-b.txt')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// wait a bit so that filesystem modifications are notified
|
||||||
|
await delay(FS_WATCH_DURATION_MS);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(monitors).map((monitor) => monitor.close()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(changedPaths['main']).to.have.members([
|
||||||
|
'a.txt',
|
||||||
|
path.join('src', 'src-a.txt'),
|
||||||
|
path.join('vendor', '.git', 'vendor-git-contents'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for project no-docker-compose/dockerignore2', function () {
|
||||||
|
const projectPath = path.join(
|
||||||
|
projectsPath,
|
||||||
|
'no-docker-compose',
|
||||||
|
'dockerignore2',
|
||||||
|
);
|
||||||
|
const composition = {
|
||||||
|
version: '2.1',
|
||||||
|
services: {
|
||||||
|
main: { build: { context: '.' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should trigger change events for paths that are not ignored', async () => {
|
||||||
|
const changedPaths: ByService<string[]> = { main: [] };
|
||||||
|
const multiDockerignore = true;
|
||||||
|
const monitors = await createMonitors(
|
||||||
|
projectPath,
|
||||||
|
composition,
|
||||||
|
multiDockerignore,
|
||||||
|
(serviceName: string, changedPath: string) => {
|
||||||
|
changedPaths[serviceName].push(changedPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
touch(path.join(projectPath, 'a.txt')),
|
||||||
|
touch(path.join(projectPath, 'b.txt')),
|
||||||
|
touch(path.join(projectPath, 'lib', 'src-a.txt')),
|
||||||
|
touch(path.join(projectPath, 'lib', 'src-b.txt')),
|
||||||
|
touch(path.join(projectPath, 'src', 'src-a.txt')),
|
||||||
|
touch(path.join(projectPath, 'src', 'src-b.txt')),
|
||||||
|
touch(path.join(projectPath, 'symlink-a.txt')),
|
||||||
|
touch(path.join(projectPath, 'symlink-b.txt')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// wait a bit so that filesystem modifications are notified
|
||||||
|
await delay(FS_WATCH_DURATION_MS);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(monitors).map((monitor) => monitor.close()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// chokidar appears to treat symbolic links differently on different
|
||||||
|
// platforms like Linux and macOS. On Linux only, change events are
|
||||||
|
// reported for symlinks when the target file they point to is changed.
|
||||||
|
// We tolerate this difference in this test case.
|
||||||
|
const expectedNoSymlink = [
|
||||||
|
'b.txt',
|
||||||
|
path.join('lib', 'src-b.txt'),
|
||||||
|
path.join('src', 'src-b.txt'),
|
||||||
|
];
|
||||||
|
const expectedWithSymlink = [...expectedNoSymlink, 'symlink-a.txt'];
|
||||||
|
expect(changedPaths['main']).to.include.members(expectedNoSymlink);
|
||||||
|
expect(expectedWithSymlink).to.include.members(changedPaths['main']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for project docker-compose/basic', function () {
|
||||||
|
const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
|
||||||
|
const composition = {
|
||||||
|
version: '2.1',
|
||||||
|
services: {
|
||||||
|
service1: { build: { context: 'service1' } },
|
||||||
|
service2: { build: { context: 'service2' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should trigger change events for paths that are not ignored (docker-compose)', async () => {
|
||||||
|
const changedPaths: ByService<string[]> = {
|
||||||
|
service1: [],
|
||||||
|
service2: [],
|
||||||
|
};
|
||||||
|
const multiDockerignore = false;
|
||||||
|
const monitors = await createMonitors(
|
||||||
|
projectPath,
|
||||||
|
composition,
|
||||||
|
multiDockerignore,
|
||||||
|
(serviceName: string, changedPath: string) => {
|
||||||
|
changedPaths[serviceName].push(changedPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
touch(path.join(projectPath, 'service1', 'test-ignore.txt')),
|
||||||
|
touch(path.join(projectPath, 'service1', 'file1.sh')),
|
||||||
|
touch(path.join(projectPath, 'service2', 'src', 'file1.sh')),
|
||||||
|
touch(path.join(projectPath, 'service2', 'file2-crlf.sh')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// wait a bit so that filesystem modifications are notified
|
||||||
|
await delay(FS_WATCH_DURATION_MS);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(monitors).map((monitor) => monitor.close()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(changedPaths['service1']).to.have.members(['file1.sh']);
|
||||||
|
expect(changedPaths['service2']).to.have.members([
|
||||||
|
path.join('src', 'file1.sh'),
|
||||||
|
'file2-crlf.sh',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger change events for paths that are not ignored (docker-compose, multi-dockerignore)', async () => {
|
||||||
|
const changedPaths: ByService<string[]> = {
|
||||||
|
service1: [],
|
||||||
|
service2: [],
|
||||||
|
};
|
||||||
|
const multiDockerignore = true;
|
||||||
|
const monitors = await createMonitors(
|
||||||
|
projectPath,
|
||||||
|
composition,
|
||||||
|
multiDockerignore,
|
||||||
|
(serviceName: string, changedPath: string) => {
|
||||||
|
changedPaths[serviceName].push(changedPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
touch(path.join(projectPath, 'service1', 'test-ignore.txt')),
|
||||||
|
touch(path.join(projectPath, 'service1', 'file1.sh')),
|
||||||
|
touch(path.join(projectPath, 'service2', 'src', 'file1.sh')),
|
||||||
|
touch(path.join(projectPath, 'service2', 'file2-crlf.sh')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// wait a bit so that filesystem modifications are notified
|
||||||
|
await delay(FS_WATCH_DURATION_MS);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(monitors).map((monitor) => monitor.close()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(changedPaths['service1']).to.have.members([
|
||||||
|
'file1.sh',
|
||||||
|
'test-ignore.txt',
|
||||||
|
]);
|
||||||
|
expect(changedPaths['service2']).to.have.members(['file2-crlf.sh']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function touch(filePath: string) {
|
||||||
|
const time = new Date();
|
||||||
|
return fs.utimes(filePath, time, time);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user