mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-19 13:47:52 +00:00
d6faf060e6
Change-type: patch
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2019-2021 Balena Ltd.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import * as _ from 'lodash';
|
|
import * as path from 'path';
|
|
|
|
import * as packageJSON from '../package.json';
|
|
|
|
const balenaExe = process.platform === 'win32' ? 'balena.exe' : 'balena';
|
|
const standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe);
|
|
|
|
export interface TestOutput {
|
|
err: string[]; // stderr
|
|
out: string[]; // stdout
|
|
exitCode?: number; // process.exitCode
|
|
}
|
|
|
|
function matchesNodeEngineVersionWarn(msg: string) {
|
|
if (/^-----+\r?\n?$/.test(msg)) {
|
|
return true;
|
|
}
|
|
const cleanup = (line: string): string[] =>
|
|
line
|
|
.replace(/-----+/g, '')
|
|
.replace(/"\d+\.\d+\.\d+"/, '"x.y.z"')
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter((l) => l);
|
|
|
|
const { getNodeEngineVersionWarn } = require('../build/utils/messages');
|
|
let nodeEngineWarn: string = getNodeEngineVersionWarn(
|
|
'x.y.z',
|
|
packageJSON.engines.node,
|
|
);
|
|
const nodeEngineWarnArray = cleanup(nodeEngineWarn);
|
|
nodeEngineWarn = nodeEngineWarnArray.join('\n');
|
|
msg = cleanup(msg).join('\n');
|
|
return msg === nodeEngineWarn || nodeEngineWarnArray.includes(msg);
|
|
}
|
|
|
|
/**
|
|
* Filter stdout / stderr lines to remove lines that start with `[debug]` and
|
|
* other lines that can be ignored for testing purposes.
|
|
* @param testOutput
|
|
*/
|
|
export function filterCliOutputForTests({
|
|
err,
|
|
out,
|
|
}: {
|
|
err: string[];
|
|
out: string[];
|
|
}): { err: string[]; out: string[] } {
|
|
return {
|
|
err: err.filter(
|
|
(line: string) =>
|
|
line &&
|
|
!line.match(/\[debug\]/i) &&
|
|
// TODO stop this warning message from appearing when running
|
|
// sdk.setSharedOptions multiple times in the same process
|
|
!line.startsWith('Shared SDK options') &&
|
|
!line.startsWith('WARN: disabling Sentry.io error reporting') &&
|
|
!matchesNodeEngineVersionWarn(line),
|
|
),
|
|
out: out.filter((line: string) => line && !line.match(/\[debug\]/i)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run the CLI in this same process, by calling the run() function in `app.ts`.
|
|
* @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix)
|
|
*/
|
|
async function runCommandInProcess(cmd: string): Promise<TestOutput> {
|
|
const balenaCLI = await import('../build/app');
|
|
const intercept = await import('intercept-stdout');
|
|
|
|
const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];
|
|
|
|
const err: string[] = [];
|
|
const out: string[] = [];
|
|
|
|
const stdoutHook = (log: string | Buffer) => {
|
|
if (typeof log === 'string') {
|
|
out.push(log);
|
|
}
|
|
};
|
|
const stderrHook = (log: string | Buffer) => {
|
|
if (typeof log === 'string') {
|
|
err.push(log);
|
|
}
|
|
};
|
|
const unhookIntercept = intercept(stdoutHook, stderrHook);
|
|
|
|
try {
|
|
await balenaCLI.run(preArgs.concat(cmd.split(' ').filter((c) => c)), {
|
|
noFlush: true,
|
|
});
|
|
} finally {
|
|
unhookIntercept();
|
|
}
|
|
const filtered = filterCliOutputForTests({ err, out });
|
|
return {
|
|
err: filtered.err,
|
|
out: filtered.out,
|
|
// this makes sense if `process.exit()` was stubbed with sinon
|
|
exitCode: process.exitCode,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run the command (e.g. `balena xxx args`) in a child process, instead of
|
|
* the same process as mocha. This is slow and does not allow mocking the
|
|
* source code, but it is useful for testing the standalone zip package binary.
|
|
* (Every now and then, bugs surface because of missing entries in the
|
|
* `pkg.assets` section of `package.json`, usually because of updated
|
|
* dependencies that don't clearly declare the have compatibility issues
|
|
* with `pkg`.)
|
|
*
|
|
* `mocha` runs on the parent process, and many of the tests inspect network
|
|
* traffic intercepted with `nock`. But this interception only works in the
|
|
* parent process itself. To get around this, we run a HTTP proxy server on
|
|
* the parent process, and get the child process to use it (the CLI already had
|
|
* support for proxy servers as a product feature, and this testing arrangement
|
|
* also exercises the proxy capabilities).
|
|
*
|
|
* @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix)
|
|
* @param proxyPort TCP port number for the HTTP proxy server running on the
|
|
* parent process
|
|
*/
|
|
async function runCommandInSubprocess(
|
|
cmd: string,
|
|
proxyPort: number,
|
|
): Promise<TestOutput> {
|
|
let exitCode = 0;
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const addedEnvs = {
|
|
// Use http instead of https, so we can intercept and test the data,
|
|
// for example the contents of tar streams sent by the CLI to Docker
|
|
BALENARC_API_URL: 'http://api.balena-cloud.com',
|
|
BALENARC_BUILDER_URL: 'http://builder.balena-cloud.com',
|
|
BALENARC_PROXY: `http://127.0.0.1:${proxyPort}`,
|
|
// override default proxy exclusion to allow proxying of requests to 127.0.0.1
|
|
BALENARC_DO_PROXY: '127.0.0.1,localhost',
|
|
};
|
|
const { execFile } = await import('child_process');
|
|
await new Promise<void>((resolve) => {
|
|
const child = execFile(
|
|
standalonePath,
|
|
cmd.split(' ').filter((c) => c),
|
|
{ env: { ...process.env, ...addedEnvs } },
|
|
($error, $stdout, $stderr) => {
|
|
stderr = $stderr || '';
|
|
stdout = $stdout || '';
|
|
// $error will be set if the CLI child process exits with a
|
|
// non-zero exit code. Usually this is harmless/expected, as
|
|
// the CLI child process is tested for error conditions.
|
|
if ($error && process.env.DEBUG) {
|
|
const msg = `
|
|
Error (possibly expected) executing child CLI process "${standalonePath}"
|
|
${$error}`;
|
|
const { warnify } =
|
|
require('../build/utils/messages') as typeof import('../build/utils/messages');
|
|
console.error(warnify(msg, '[debug] '));
|
|
}
|
|
resolve();
|
|
},
|
|
);
|
|
child.on('exit', (code: number, signal: string) => {
|
|
if (process.env.DEBUG) {
|
|
console.error(
|
|
`CLI child process exited with code=${code} signal=${signal}`,
|
|
);
|
|
}
|
|
exitCode = code;
|
|
});
|
|
});
|
|
|
|
const splitLines = (lines: string) =>
|
|
lines
|
|
.split(/[\r\n]/) // includes '\r' in isolation, used in progress bars
|
|
.filter((l) => l)
|
|
.map((l) => l + '\n');
|
|
|
|
const filtered = filterCliOutputForTests({
|
|
err: splitLines(stderr),
|
|
out: splitLines(stdout),
|
|
});
|
|
return {
|
|
err: filtered.err,
|
|
out: filtered.out,
|
|
// this makes sense if `process.exit()` was stubbed with sinon
|
|
exitCode,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run a CLI command and capture its stdout, stderr and exit code for testing.
|
|
* If the BALENA_CLI_TEST_TYPE env var is set to 'standalone', then the command
|
|
* will be executed in a separate child process, and a proxy server will be
|
|
* started in order to intercept and test HTTP requests.
|
|
* Otherwise, simply call the CLI's run() entry point in this same process.
|
|
* @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix)
|
|
*/
|
|
export async function runCommand(cmd: string): Promise<TestOutput> {
|
|
if (process.env.BALENA_CLI_TEST_TYPE === 'standalone') {
|
|
const semver = await import('semver');
|
|
if (semver.lt(process.version, '10.16.0')) {
|
|
throw new Error(
|
|
`The standalone tests require Node.js >= v10.16.0 because of net/proxy features ('global-agent' npm package)`,
|
|
);
|
|
}
|
|
try {
|
|
const { promises: fs } = await import('fs');
|
|
await fs.access(standalonePath);
|
|
} catch {
|
|
throw new Error(`Standalone executable not found: "${standalonePath}"`);
|
|
}
|
|
const proxy = await import('./nock/proxy-server');
|
|
const [proxyPort] = await proxy.createProxyServerOnce();
|
|
return runCommandInSubprocess(cmd, proxyPort);
|
|
} else {
|
|
return runCommandInProcess(cmd);
|
|
}
|
|
}
|
|
|
|
export function cleanOutput(
|
|
output: string[] | string,
|
|
collapseBlank = false,
|
|
): string[] {
|
|
const cleanLine = collapseBlank
|
|
? (line: string) => monochrome(line.trim()).replace(/\s{2,}/g, ' ')
|
|
: (line: string) => monochrome(line.trim());
|
|
|
|
const result: string[] = [];
|
|
output = typeof output === 'string' ? [output] : output;
|
|
for (const lines of output) {
|
|
for (let line of lines.split('\n')) {
|
|
line = cleanLine(line);
|
|
if (line) {
|
|
result.push(line);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Remove text colors (ASCII escape sequences). Example:
|
|
* Input: '\u001b[2K\r\u001b[34m[Build]\u001b[39m \u001b[1mmain\u001b[22m Image size: 1.14 MB'
|
|
* Output: '[Build] main Image size: 1.14 MB'
|
|
*
|
|
* TODO: check this function against a spec (ASCII escape sequences). It was
|
|
* coded from observation of a few samples only, and may not cover all cases.
|
|
*/
|
|
export function monochrome(text: string): string {
|
|
return text.replace(/\u001b\[\??(\d+;)*\d+[a-zA-Z]\r?/g, '');
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Recursively navigate the `data` argument (if it is an array or object),
|
|
* finding and replacing "template strings" such as 'hello ${name}!' with
|
|
* the variable values given in `templateVars` such as { name: 'world' }.
|
|
*
|
|
* @param data Any data type (array, object, string) containing template
|
|
* strings to be replaced
|
|
* @param templateVars Map of template variable names to values
|
|
*/
|
|
export function deepTemplateReplace(
|
|
data: any,
|
|
templateVars: { [key: string]: any },
|
|
): any {
|
|
switch (typeof data) {
|
|
case 'string':
|
|
return fillTemplate(data, templateVars);
|
|
case 'object':
|
|
if (Array.isArray(data)) {
|
|
return data.map((i) => deepTemplateReplace(i, templateVars));
|
|
}
|
|
return _.mapValues(data, (value) =>
|
|
deepTemplateReplace(value, templateVars),
|
|
);
|
|
default:
|
|
// number, undefined, null, or something else
|
|
return data;
|
|
}
|
|
}
|
|
|
|
export const fillTemplateArray = deepTemplateReplace;
|
|
|
|
/**
|
|
* Recursively navigate the `data` argument (if it is an array or object),
|
|
* looking for strings that start with `[` or `{` which are assumed to contain
|
|
* JSON arrays or objects that are then parsed with JSON.parse().
|
|
* @param data
|
|
*/
|
|
export function deepJsonParse(data: any): any {
|
|
if (typeof data === 'string') {
|
|
const maybeJson = data.trim();
|
|
if (maybeJson.startsWith('{') || maybeJson.startsWith('[')) {
|
|
return JSON.parse(maybeJson);
|
|
}
|
|
} else if (Array.isArray(data)) {
|
|
return data.map((i) => deepJsonParse(i));
|
|
} else if (typeof data === 'object') {
|
|
return _.mapValues(data, (value) => deepJsonParse(value));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export async function switchSentry(
|
|
enabled: boolean | undefined,
|
|
): Promise<boolean | undefined> {
|
|
const balenaCLI = await import('../build/app');
|
|
const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions();
|
|
if (sentryOpts) {
|
|
const sentryStatus = sentryOpts.enabled;
|
|
sentryOpts.enabled = enabled;
|
|
return sentryStatus;
|
|
}
|
|
}
|