Add and refactor tests for push/build/deploy commands (docker-compose)

Change-type: patch
This commit is contained in:
Paulo Castro 2020-02-15 20:18:00 +00:00
parent 5dbace353d
commit 0738dd1520
11 changed files with 525 additions and 328 deletions

3
.gitattributes vendored
View File

@ -7,5 +7,6 @@
# lf for the docs as it's auto-generated and will otherwise trigger an uncommited error on windows
doc/cli.markdown text eol=lf
# crlf for the for the windows-crlf test file
# crlf for the eol conversion test files
tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh eol=crlf
tests/test-data/projects/no-docker-compose/basic/src/windows-crlf.sh eol=crlf

View File

@ -18,44 +18,42 @@
// tslint:disable-next-line:no-var-requires
require('../config-tests'); // required for side effects
import { expect } from 'chai';
import * as _ from 'lodash';
import { fs } from 'mz';
import * as path from 'path';
import { URL } from 'url';
import { BalenaAPIMock } from '../balena-api-mock';
import { DockerMock, dockerResponsePath } from '../docker-mock';
import {
cleanOutput,
ExpectedTarStreamFiles,
ExpectedTarStreamFilesByService,
expectStreamNoCRLF,
inspectTarStream,
runCommand,
TarStreamFiles,
} from '../helpers';
testDockerBuildStream,
} from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock';
const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
const expectedResponses = {
const commonResponseLines: { [key: string]: string[] } = {
'build-POST.json': [
'[Info] Building for amd64/nuc',
'[Info] Docker Desktop detected (daemon architecture: "x86_64")',
'[Info] Docker itself will determine and enable architecture emulation if required,',
'[Info] without balena-cli intervention and regardless of the --emulated option.',
'[Build] main Image size: 1.14 MB',
'[Success] Build succeeded!',
],
};
const commonQueryParams = [
['t', '${tag}'],
['buildargs', '{}'],
['labels', ''],
];
describe('balena build', function() {
let api: BalenaAPIMock;
let docker: DockerMock;
const commonQueryParams = [
['t', 'basic_main'],
['buildargs', '{}'],
['labels', ''],
];
const isWindows = process.platform === 'win32';
this.beforeEach(() => {
api = new BalenaAPIMock();
@ -65,7 +63,6 @@ describe('balena build', function() {
docker.expectGetPing();
docker.expectGetInfo();
docker.expectGetVersion();
docker.expectGetImages();
});
this.afterEach(() => {
@ -76,7 +73,7 @@ describe('balena build', function() {
it('should create the expected tar stream (single container)', async () => {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: TarStreamFiles = {
const expectedFiles: ExpectedTarStreamFiles = {
'src/start.sh': { fileSize: 89, type: 'file' },
'src/windows-crlf.sh': { fileSize: 70, type: 'file' },
Dockerfile: { fileSize: 88, type: 'file' },
@ -87,55 +84,43 @@ describe('balena build', function() {
path.join(dockerResponsePath, responseFilename),
'utf8',
);
docker.expectPostBuild({
tag: 'basic_main',
responseCode: 200,
responseBody,
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(commonQueryParams);
},
checkBuildRequestBody: (buildRequestBody: string) =>
inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect),
});
const { out, err } = await runCommand(
`build ${projectPath} --deviceType nuc --arch amd64`,
);
const extraLines = [
const expectedResponseLines = [
...commonResponseLines[responseFilename],
`[Info] Creating default composition with source: ${projectPath}`,
'[Build] main Image size: 1.14 MB',
];
if (process.platform === 'win32') {
extraLines.push(
if (isWindows) {
expectedResponseLines.push(
`[Warn] CRLF (Windows) line endings detected in file: ${path.join(
projectPath,
'src',
'windows-crlf.sh',
)}`,
'[Warn] Windows-format line endings were detected in some files. Consider using the `--convert-eol` option.',
);
}
expect(err).to.have.members([]);
expect(
cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members([
...expectedResponses[responseFilename],
...extraLines,
]);
await testDockerBuildStream({
commandLine: `build ${projectPath} --deviceType nuc --arch amd64`,
dockerMock: docker,
expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams },
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
services: ['main'],
});
});
it('should create the expected tar stream (single container, --convert-eol)', async () => {
const windows = process.platform === 'win32';
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: TarStreamFiles = {
const expectedFiles: ExpectedTarStreamFiles = {
'src/start.sh': { fileSize: 89, type: 'file' },
'src/windows-crlf.sh': {
fileSize: windows ? 68 : 70,
fileSize: isWindows ? 68 : 70,
testStream: isWindows ? expectStreamNoCRLF : undefined,
type: 'file',
testStream: windows ? expectStreamNoCRLF : undefined,
},
Dockerfile: { fileSize: 88, type: 'file' },
'Dockerfile-alt': { fileSize: 30, type: 'file' },
@ -145,29 +130,13 @@ describe('balena build', function() {
path.join(dockerResponsePath, responseFilename),
'utf8',
);
docker.expectPostBuild({
tag: 'basic_main',
responseCode: 200,
responseBody,
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(commonQueryParams);
},
checkBuildRequestBody: (buildRequestBody: string) =>
inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect),
});
const { out, err } = await runCommand(
`build ${projectPath} --deviceType nuc --arch amd64 --convert-eol`,
);
const extraLines = [
const expectedResponseLines = [
...commonResponseLines[responseFilename],
`[Info] Creating default composition with source: ${projectPath}`,
'[Build] main Image size: 1.14 MB',
];
if (windows) {
extraLines.push(
if (isWindows) {
expectedResponseLines.push(
`[Info] Converting line endings CRLF -> LF for file: ${path.join(
projectPath,
'src',
@ -176,12 +145,80 @@ describe('balena build', function() {
);
}
expect(err).to.have.members([]);
expect(
cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members([
...expectedResponses[responseFilename],
...extraLines,
]);
await testDockerBuildStream({
commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol`,
dockerMock: docker,
expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams },
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
services: ['main'],
});
});
it('should create the expected tar stream (docker-compose)', async () => {
const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
const service1Dockerfile = (
await fs.readFile(
path.join(projectPath, 'service1', 'Dockerfile.template'),
'utf8',
)
).replace('%%BALENA_MACHINE_NAME%%', 'nuc');
const expectedFilesByService: ExpectedTarStreamFilesByService = {
service1: {
Dockerfile: {
contents: service1Dockerfile,
fileSize: service1Dockerfile.length,
type: 'file',
},
'Dockerfile.template': { fileSize: 144, type: 'file' },
'file1.sh': { fileSize: 12, type: 'file' },
},
service2: {
'Dockerfile-alt': { fileSize: 40, type: 'file' },
'file2-crlf.sh': {
fileSize: isWindows ? 12 : 14,
testStream: isWindows ? expectStreamNoCRLF : undefined,
type: 'file',
},
},
};
const responseFilename = 'build-POST.json';
const responseBody = await fs.readFile(
path.join(dockerResponsePath, responseFilename),
'utf8',
);
const expectedQueryParamsByService = {
service1: commonQueryParams,
service2: [...commonQueryParams, ['dockerfile', 'Dockerfile-alt']],
};
const expectedResponseLines: string[] = [
...commonResponseLines[responseFilename],
`[Build] service1 Image size: 1.14 MB`,
`[Build] service2 Image size: 1.14 MB`,
];
if (isWindows) {
expectedResponseLines.push(
`[Info] Converting line endings CRLF -> LF for file: ${path.join(
projectPath,
'service2',
'file2-crlf.sh',
)}`,
);
}
await testDockerBuildStream({
commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol`,
dockerMock: docker,
expectedFilesByService,
expectedQueryParamsByService,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
services: ['service1', 'service2'],
});
});
});

View File

@ -18,23 +18,17 @@
// tslint:disable-next-line:no-var-requires
require('../config-tests'); // required for side effects
import { expect } from 'chai';
import { fs } from 'mz';
import * as path from 'path';
import { URL } from 'url';
import { BalenaAPIMock } from '../balena-api-mock';
import { ExpectedTarStreamFiles, testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock';
import {
cleanOutput,
inspectTarStream,
runCommand,
TarStreamFiles,
} from '../helpers';
const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
const expectedResponses = {
const commonResponseLines = {
'build-POST.json': [
'[Info] Building for armv7hf/raspberrypi3',
'[Info] Docker Desktop detected (daemon architecture: "x86_64")',
@ -49,15 +43,16 @@ const expectedResponses = {
],
};
const commonQueryParams = [
['t', '${tag}'],
['buildargs', '{}'],
['labels', ''],
];
describe('balena deploy', function() {
let api: BalenaAPIMock;
let docker: DockerMock;
const commonQueryParams = [
['t', 'basic_main'],
['buildargs', '{}'],
['labels', ''],
];
const isWindows = process.platform === 'win32';
this.beforeEach(() => {
api = new BalenaAPIMock();
@ -80,8 +75,7 @@ describe('balena deploy', function() {
docker.expectGetPing();
docker.expectGetInfo();
docker.expectGetVersion();
docker.expectGetImages({ persist: true });
docker.expectGetVersion({ persist: true });
docker.expectPostImagesTag();
docker.expectPostImagesPush();
docker.expectDeleteImages();
@ -95,7 +89,7 @@ describe('balena deploy', function() {
it('should create the expected --build tar stream (single container)', async () => {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: TarStreamFiles = {
const expectedFiles: ExpectedTarStreamFiles = {
'src/start.sh': { fileSize: 89, type: 'file' },
'src/windows-crlf.sh': { fileSize: 70, type: 'file' },
Dockerfile: { fileSize: 88, type: 'file' },
@ -106,43 +100,31 @@ describe('balena deploy', function() {
path.join(dockerResponsePath, responseFilename),
'utf8',
);
docker.expectPostBuild({
tag: 'basic_main',
responseCode: 200,
responseBody,
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(commonQueryParams);
},
checkBuildRequestBody: (buildRequestBody: string) =>
inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect),
});
const { out, err } = await runCommand(
`deploy testApp --build --source ${projectPath}`,
);
const extraLines = [
const expectedResponseLines = [
...commonResponseLines[responseFilename],
`[Info] Creating default composition with source: ${projectPath}`,
];
if (process.platform === 'win32') {
extraLines.push(
if (isWindows) {
expectedResponseLines.push(
`[Warn] CRLF (Windows) line endings detected in file: ${path.join(
projectPath,
'src',
'windows-crlf.sh',
)}`,
'[Warn] Windows-format line endings were detected in some files. Consider using the `--convert-eol` option.',
);
}
expect(err).to.have.members([]);
expect(
cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members([
...expectedResponses[responseFilename],
...extraLines,
]);
await testDockerBuildStream({
commandLine: `deploy testApp --build --source ${projectPath}`,
dockerMock: docker,
expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams },
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
services: ['main'],
});
});
});

View File

@ -18,25 +18,21 @@
// tslint:disable-next-line:no-var-requires
require('../config-tests'); // required for side effects
import { expect } from 'chai';
import { fs } from 'mz';
import * as path from 'path';
import { URL } from 'url';
import { BalenaAPIMock } from '../balena-api-mock';
import { BuilderMock, builderResponsePath } from '../builder-mock';
import {
cleanOutput,
ExpectedTarStreamFiles,
expectStreamNoCRLF,
inspectTarStream,
runCommand,
TarStreamFiles,
} from '../helpers';
testPushBuildStream,
} from '../docker-build';
const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');
const expectedResponses = {
const commonResponseLines = {
'build-POST-v3.json': [
'[Info] Starting build for testApp, user gh_user',
'[Info] Dashboard link: https://dashboard.balena-cloud.com/apps/1301645/devices',
@ -66,28 +62,22 @@ const expectedResponses = {
'[Info] ├─────────┼────────────┼────────────┤',
'[Info] │ main │ 1.32 MB │ 11 seconds │',
'[Info] └─────────┴────────────┴────────────┘',
'[Info] Build finished in 20 seconds',
],
};
function tweakOutput(out: string[]): string[] {
return cleanOutput(out).map(line =>
line.replace(/\s{2,}/g, ' ').replace(/in \d+? seconds/, 'in 20 seconds'),
);
}
const commonQueryParams = [
['owner', 'bob'],
['app', 'testApp'],
['dockerfilePath', ''],
['emulated', 'false'],
['nocache', 'false'],
['headless', 'false'],
];
describe('balena push', function() {
let api: BalenaAPIMock;
let builder: BuilderMock;
const commonQueryParams = [
['owner', 'bob'],
['app', 'testApp'],
['dockerfilePath', ''],
['emulated', 'false'],
['nocache', 'false'],
['headless', 'false'],
];
const isWindows = process.platform === 'win32';
this.beforeEach(() => {
api = new BalenaAPIMock();
@ -105,7 +95,7 @@ describe('balena push', function() {
it('should create the expected tar stream (single container)', async () => {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: TarStreamFiles = {
const expectedFiles: ExpectedTarStreamFiles = {
'src/start.sh': { fileSize: 89, type: 'file' },
'src/windows-crlf.sh': { fileSize: 70, type: 'file' },
Dockerfile: { fileSize: 88, type: 'file' },
@ -116,44 +106,33 @@ describe('balena push', function() {
path.join(builderResponsePath, responseFilename),
'utf8',
);
builder.expectPostBuild({
responseCode: 200,
responseBody,
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(commonQueryParams);
},
checkBuildRequestBody: (buildRequestBody: string | Buffer) =>
inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect),
});
const { out, err } = await runCommand(
`push testApp --source ${projectPath}`,
);
const extraLines = [];
if (process.platform === 'win32') {
extraLines.push(
const expectedResponseLines = [...commonResponseLines[responseFilename]];
if (isWindows) {
expectedResponseLines.push(
`[Warn] CRLF (Windows) line endings detected in file: ${path.join(
projectPath,
'src',
'windows-crlf.sh',
)}`,
'[Warn] Windows-format line endings were detected in some files. Consider using the `--convert-eol` option.',
);
}
expect(err).to.have.members([]);
expect(tweakOutput(out)).to.include.members([
...expectedResponses[responseFilename],
...extraLines,
]);
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp --source ${projectPath}`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
});
});
it('should create the expected tar stream (alternative Dockerfile)', async () => {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: TarStreamFiles = {
const expectedFiles: ExpectedTarStreamFiles = {
'src/start.sh': { fileSize: 89, type: 'file' },
'src/windows-crlf.sh': { fileSize: 70, type: 'file' },
Dockerfile: { fileSize: 88, type: 'file' },
@ -164,44 +143,30 @@ describe('balena push', function() {
path.join(builderResponsePath, responseFilename),
'utf8',
);
const expectedQueryParams = commonQueryParams.map(i =>
i[0] === 'dockerfilePath' ? ['dockerfilePath', 'Dockerfile-alt'] : i,
);
builder.expectPostBuild({
responseCode: 200,
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp --source ${projectPath} --dockerfile Dockerfile-alt`,
expectedFiles,
expectedQueryParams,
expectedResponseLines: commonResponseLines[responseFilename],
projectPath,
responseBody,
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(
commonQueryParams.map(i =>
i[0] === 'dockerfilePath'
? ['dockerfilePath', 'Dockerfile-alt']
: i,
),
);
},
checkBuildRequestBody: (buildRequestBody: string | Buffer) =>
inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect),
responseCode: 200,
});
const { out, err } = await runCommand(
`push testApp --source ${projectPath} --dockerfile Dockerfile-alt`,
);
expect(err).to.have.members([]);
expect(tweakOutput(out)).to.include.members(
expectedResponses[responseFilename],
);
});
it('should create the expected tar stream (single container, --convert-eol)', async () => {
const windows = process.platform === 'win32';
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: TarStreamFiles = {
const expectedFiles: ExpectedTarStreamFiles = {
'src/start.sh': { fileSize: 89, type: 'file' },
'src/windows-crlf.sh': {
fileSize: windows ? 68 : 70,
fileSize: isWindows ? 68 : 70,
type: 'file',
testStream: windows ? expectStreamNoCRLF : undefined,
testStream: isWindows ? expectStreamNoCRLF : undefined,
},
Dockerfile: { fileSize: 88, type: 'file' },
'Dockerfile-alt': { fileSize: 30, type: 'file' },
@ -211,26 +176,9 @@ describe('balena push', function() {
path.join(builderResponsePath, responseFilename),
'utf8',
);
builder.expectPostBuild({
responseCode: 200,
responseBody,
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(commonQueryParams);
},
checkBuildRequestBody: (buildRequestBody: string | Buffer) =>
inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect),
});
const { out, err } = await runCommand(
`push testApp --source ${projectPath} --convert-eol`,
);
const extraLines = [];
if (windows) {
extraLines.push(
const expectedResponseLines = [...commonResponseLines[responseFilename]];
if (isWindows) {
expectedResponseLines.push(
`[Info] Converting line endings CRLF -> LF for file: ${path.join(
projectPath,
'src',
@ -239,10 +187,58 @@ describe('balena push', function() {
);
}
expect(err).to.have.members([]);
expect(tweakOutput(out)).to.include.members([
...expectedResponses[responseFilename],
...extraLines,
]);
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp --source ${projectPath} --convert-eol`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
});
});
it('should create the expected tar stream (docker-compose)', async () => {
const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
const expectedFiles: ExpectedTarStreamFiles = {
'docker-compose.yml': { fileSize: 245, type: 'file' },
'service1/Dockerfile.template': { fileSize: 144, type: 'file' },
'service1/file1.sh': { fileSize: 12, type: 'file' },
'service2/Dockerfile-alt': { fileSize: 40, type: 'file' },
'service2/file2-crlf.sh': {
fileSize: isWindows ? 12 : 14,
testStream: isWindows ? expectStreamNoCRLF : undefined,
type: 'file',
},
};
const responseFilename = 'build-POST-v3.json';
const responseBody = await fs.readFile(
path.join(builderResponsePath, responseFilename),
'utf8',
);
const expectedResponseLines: string[] = [
...commonResponseLines[responseFilename],
];
if (isWindows) {
expectedResponseLines.push(
`[Info] Converting line endings CRLF -> LF for file: ${path.join(
projectPath,
'service2',
'file2-crlf.sh',
)}`,
);
}
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp --source ${projectPath} --convert-eol`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
});
});
});

232
tests/docker-build.ts Normal file
View File

@ -0,0 +1,232 @@
/**
* @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 { expect } from 'chai';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import { fs } from 'mz';
import * as path from 'path';
import { PathUtils } from 'resin-multibuild';
import { Readable } from 'stream';
import * as tar from 'tar-stream';
import { streamToBuffer } from 'tar-utils';
import { URL } from 'url';
import { BuilderMock } from './builder-mock';
import { DockerMock } from './docker-mock';
import { cleanOutput, fillTemplateArray, runCommand } from './helpers';
export interface ExpectedTarStreamFile {
contents?: string;
fileSize: number;
testStream?: (
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
* the balena push, build and deploy commands, intercepted at HTTP level on
* their way from the CLI to the Docker daemon or balenaCloud builders.
*
* @param tarRequestBody Intercepted buffer of tar stream to be sent to builders/Docker
* @param expectedFiles Details of files expected to be found in the buffer
* @param projectPath Path of test project that was tarred, to compare file contents
*/
export async function inspectTarStream(
tarRequestBody: string | Buffer,
expectedFiles: ExpectedTarStreamFiles,
projectPath: string,
): Promise<void> {
// string to stream: https://stackoverflow.com/a/22085851
const sourceTarStream = new Readable();
sourceTarStream._read = () => undefined;
sourceTarStream.push(tarRequestBody);
sourceTarStream.push(null);
const found: ExpectedTarStreamFiles = await new Promise((resolve, reject) => {
const foundFiles: ExpectedTarStreamFiles = {};
const extract = tar.extract();
extract.on('error', reject);
extract.on(
'entry',
async (header: tar.Headers, stream: Readable, next: tar.Callback) => {
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);
foundFiles[header.name] = {
fileSize: header.size || 0,
type: header.type,
};
const expected = expectedFiles[header.name];
if (expected && expected.testStream) {
await expected.testStream(header, stream, expected);
} else {
await defaultTestStream(header, stream, expected, projectPath);
}
}
} catch (err) {
reject(err);
}
next();
},
);
extract.once('finish', () => {
resolve(foundFiles);
});
sourceTarStream.on('error', reject);
sourceTarStream.pipe(extract);
});
expect(found).to.deep.equal(
_.mapValues(expectedFiles, v => _.omit(v, 'testStream', 'contents')),
);
}
/** Check that a tar stream entry matches the project contents in the filesystem */
async function defaultTestStream(
header: tar.Headers,
stream: Readable,
expected: ExpectedTarStreamFile | undefined,
projectPath: string,
): Promise<void> {
let expectedContents: Buffer | undefined;
if (expected?.contents) {
expectedContents = Buffer.from(expected.contents);
}
const [buf, buf2] = await Promise.all([
streamToBuffer(stream),
expectedContents ||
fs.readFile(path.join(projectPath, PathUtils.toNativePath(header.name))),
]);
const msg = stripIndent`
contents mismatch for tar stream entry "${header.name}"
stream length=${buf.length}, filesystem length=${buf2.length}`;
expect(buf.equals(buf2), msg).to.be.true;
}
/** Test a tar stream entry for the absence of Windows CRLF line breaks */
export async function expectStreamNoCRLF(
_header: tar.Headers,
stream: Readable,
): Promise<void> {
const chai = await import('chai');
const buf = await streamToBuffer(stream);
await chai.expect(buf.includes('\r\n')).to.be.false;
}
/**
* Common test logic for the 'build' and 'deploy' commands
*/
export async function testDockerBuildStream(o: {
commandLine: string;
dockerMock: DockerMock;
expectedFilesByService: ExpectedTarStreamFilesByService;
expectedQueryParamsByService: { [service: string]: string[][] };
expectedResponseLines: string[];
projectPath: string;
responseCode: number;
responseBody: string;
services: string[]; // e.g. ['main'] or ['service1', 'service2']
}) {
const expectedResponseLines = fillTemplateArray(o.expectedResponseLines, o);
for (const service of o.services) {
// tagPrefix is, for example, 'myApp' if the path is 'path/to/myApp'
const tagPrefix = o.projectPath.split(path.sep).pop();
const tag = `${tagPrefix}_${service}`;
const expectedFiles = o.expectedFilesByService[service];
const expectedQueryParams = fillTemplateArray(
o.expectedQueryParamsByService[service],
_.assign({ tag }, o),
);
const projectPath =
service === 'main' ? o.projectPath : path.join(o.projectPath, service);
o.dockerMock.expectPostBuild(
_.assign({}, o, {
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(expectedQueryParams);
},
checkBuildRequestBody: (buildRequestBody: string) =>
inspectTarStream(buildRequestBody, expectedFiles, projectPath),
tag,
}),
);
o.dockerMock.expectGetImages();
}
const { out, err } = await runCommand(o.commandLine);
expect(err).to.be.empty;
expect(
cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedResponseLines);
}
/**
* Common test logic for the 'push' command
*/
export async function testPushBuildStream(o: {
commandLine: string;
builderMock: BuilderMock;
expectedFiles: ExpectedTarStreamFiles;
expectedQueryParams: string[][];
expectedResponseLines: string[];
projectPath: string;
responseCode: number;
responseBody: string;
}) {
const expectedQueryParams = fillTemplateArray(o.expectedQueryParams, o);
const expectedResponseLines = fillTemplateArray(o.expectedResponseLines, o);
o.builderMock.expectPostBuild(
_.assign({}, o, {
checkURI: async (uri: string) => {
const url = new URL(uri, 'http://test.net/');
const queryParams = Array.from(url.searchParams.entries());
expect(queryParams).to.have.deep.members(expectedQueryParams);
},
checkBuildRequestBody: (buildRequestBody: string) =>
inspectTarStream(buildRequestBody, o.expectedFiles, o.projectPath),
}),
);
const { out, err } = await runCommand(o.commandLine);
expect(err).to.be.empty;
expect(
cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedResponseLines);
}

View File

@ -18,16 +18,10 @@
// tslint:disable-next-line:no-var-requires
require('./config-tests'); // required for side effects
import { stripIndent } from 'common-tags';
import intercept = require('intercept-stdout');
import * as _ from 'lodash';
import { fs } from 'mz';
import * as nock from 'nock';
import * as path from 'path';
import { PathUtils } from 'resin-multibuild';
import { Readable } from 'stream';
import * as tar from 'tar-stream';
import { streamToBuffer } from 'tar-utils';
import * as balenaCLI from '../build/app';
@ -114,101 +108,34 @@ export function monochrome(text: string): string {
return text.replace(/\u001b\[\??\d+?[a-zA-Z]\r?/g, '');
}
export interface TarStreamFiles {
[filePath: string]: {
fileSize: number;
type: tar.Headers['type'];
testStream?: (header: tar.Headers, stream: Readable) => Promise<void>;
};
/**
* Dynamic template string resolution.
* Usage example:
* const templateString = 'hello ${name}!';
* const templateVars = { name: 'world' };
* console.log( fillTemplate(templateString, templateVars) );
* // hello world!
*/
export function fillTemplate(
templateString: string,
templateVars: object,
): string {
const escaped = templateString.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
const resolved = new Function(
...Object.keys(templateVars),
`return \`${escaped}\`;`,
).call(null, ...Object.values(templateVars));
const unescaped = resolved.replace(/\\`/g, '`').replace(/\\\\/g, '\\');
return unescaped;
}
/**
* Run a few chai.expect() test assertions on a tar stream/buffer produced by
* the balena push, build and deploy commands, intercepted at HTTP level on
* their way from the CLI to the Docker daemon or balenaCloud builders.
*
* @param tarRequestBody Intercepted buffer of tar stream to be sent to builders/Docker
* @param expectedFiles Details of files expected to be found in the buffer
* @param projectPath Path of test project that was tarred, to compare file contents
* @param expect chai.expect function
*/
export async function inspectTarStream(
tarRequestBody: string | Buffer,
expectedFiles: TarStreamFiles,
projectPath: string,
expect: Chai.ExpectStatic,
): Promise<void> {
// string to stream: https://stackoverflow.com/a/22085851
const sourceTarStream = new Readable();
sourceTarStream._read = () => undefined;
sourceTarStream.push(tarRequestBody);
sourceTarStream.push(null);
const found: TarStreamFiles = await new Promise((resolve, reject) => {
const foundFiles: TarStreamFiles = {};
const extract = tar.extract();
extract.on('error', reject);
extract.on(
'entry',
async (header: tar.Headers, stream: Readable, next: tar.Callback) => {
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);
foundFiles[header.name] = {
fileSize: header.size || 0,
type: header.type,
};
const expected = expectedFiles[header.name];
if (expected && expected.testStream) {
await expected.testStream(header, stream);
} else {
await defaultTestStream(header, stream, projectPath, expect);
}
}
} catch (err) {
reject(err);
}
next();
},
);
extract.once('finish', () => {
resolve(foundFiles);
});
sourceTarStream.on('error', reject);
sourceTarStream.pipe(extract);
});
expect(found).to.deep.equal(
_.mapValues(expectedFiles, v => _.omit(v, 'testStream')),
export function fillTemplateArray(
templateStringArray: Array<string | string[]>,
templateVars: object,
) {
return templateStringArray.map(i =>
Array.isArray(i)
? fillTemplateArray(i, templateVars)
: fillTemplate(i, templateVars),
);
}
/** Check that a tar stream entry matches the project contents in the filesystem */
async function defaultTestStream(
header: tar.Headers,
stream: Readable,
projectPath: string,
expect: Chai.ExpectStatic,
): Promise<void> {
const [buf, buf2] = await Promise.all([
streamToBuffer(stream),
fs.readFile(path.join(projectPath, PathUtils.toNativePath(header.name))),
]);
const msg = stripIndent`
contents mismatch for tar stream entry "${header.name}"
stream length=${buf.length}, filesystem length=${buf2.length}`;
expect(buf.equals(buf2), msg).to.be.true;
}
/** Test a tar stream entry for the absence of Windows CRLF line breaks */
export async function expectStreamNoCRLF(
_header: tar.Headers,
stream: Readable,
): Promise<void> {
const chai = await import('chai');
const buf = await streamToBuffer(stream);
await chai.expect(buf.includes('\r\n')).to.be.false;
}

View File

@ -0,0 +1,14 @@
version: '2'
volumes:
resin-data:
services:
service1:
volumes:
- 'resin-data:/data'
build: ./service1
service2:
volumes:
- 'resin-data:/data'
build:
context: ./service2
dockerfile: Dockerfile-alt

View File

@ -0,0 +1,3 @@
FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine
COPY ./file1.sh /
CMD i=1; while :; do echo "service1 $i $(uname -a)"; sleep 10; i=$((i+1)); done

View File

@ -0,0 +1,2 @@
line1
line2

View File

@ -0,0 +1 @@
alternative Dockerfile (basic/service2)

View File

@ -0,0 +1,2 @@
line1
line2