mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-20 22:23:07 +00:00
adb460b270
Resolves: #2608 Change-type: major
192 lines
5.2 KiB
TypeScript
192 lines
5.2 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2018-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 ColorHash = require('color-hash');
|
|
import * as _ from 'lodash';
|
|
import type { Readable } from 'stream';
|
|
|
|
import { getChalk } from '../lazy';
|
|
import Logger = require('../logger');
|
|
import { ExpectedError, SIGINTError } from '../../errors';
|
|
|
|
class DeviceConnectionLostError extends ExpectedError {
|
|
public static defaultMsg = 'Connection to device lost';
|
|
constructor(msg?: string) {
|
|
super(msg || DeviceConnectionLostError.defaultMsg);
|
|
}
|
|
}
|
|
|
|
interface Log {
|
|
message: string;
|
|
timestamp?: number;
|
|
serviceName?: string;
|
|
|
|
// There's also a serviceId and imageId, but they're
|
|
// meaningless in local mode
|
|
}
|
|
|
|
interface BuildLog {
|
|
serviceName: string;
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* Display logs from a device logging stream. This function will return
|
|
* when the log stream ends.
|
|
*
|
|
* @param logs A stream which produces newline seperated log
|
|
* objects
|
|
* @param logger A Logger instance which the logs will be
|
|
* displayed through
|
|
* @param system Only show system (and potentially the
|
|
* filterService) logs
|
|
* @param filterService Filter the logs so that only logs
|
|
* from a single service will be displayed
|
|
*/
|
|
async function displayDeviceLogs(
|
|
logs: Readable,
|
|
logger: Logger,
|
|
system: boolean,
|
|
filterServices?: string[],
|
|
): Promise<void> {
|
|
const { addSIGINTHandler } = await import('../helpers');
|
|
const { parse: ndjsonParse } = await import('ndjson');
|
|
let gotSignal = false;
|
|
const handleSignal = () => {
|
|
gotSignal = true;
|
|
logs.emit('close');
|
|
};
|
|
addSIGINTHandler(handleSignal);
|
|
process.once('SIGTERM', handleSignal);
|
|
try {
|
|
await new Promise((_resolve, reject) => {
|
|
const jsonStream = ndjsonParse();
|
|
jsonStream.on('data', (log) => {
|
|
displayLogObject(log, logger, system, filterServices);
|
|
});
|
|
jsonStream.on('error', (e) => {
|
|
logger.logWarn(`Error parsing NDJSON log chunk: ${e}`);
|
|
});
|
|
logs.once('error', handleError);
|
|
logs.once('end', handleError);
|
|
logs.pipe(jsonStream);
|
|
|
|
function handleError(error?: Error | string) {
|
|
logger.logWarn(DeviceConnectionLostError.defaultMsg);
|
|
if (gotSignal) {
|
|
reject(new SIGINTError('Log streaming aborted on SIGINT signal'));
|
|
} else {
|
|
const msg = typeof error === 'string' ? error : error?.message;
|
|
reject(new DeviceConnectionLostError(msg));
|
|
}
|
|
}
|
|
});
|
|
} finally {
|
|
process.removeListener('SIGINT', handleSignal);
|
|
process.removeListener('SIGTERM', handleSignal);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a TCP connection to the device's supervisor (TCP port 48484) and tail
|
|
* (display) device logs. Retry (reconnect) up to maxAttempts times if the
|
|
* TCP connection drops. Don't retry on SIGINT (CTRL-C).
|
|
* See function `displayDeviceLogs` for parameter documentation.
|
|
*/
|
|
export async function connectAndDisplayDeviceLogs({
|
|
deviceApi,
|
|
logger,
|
|
system,
|
|
filterServices,
|
|
maxAttempts = 3,
|
|
}: {
|
|
deviceApi: import('./api').DeviceAPI;
|
|
logger: Logger;
|
|
system: boolean;
|
|
filterServices?: string[];
|
|
maxAttempts?: number;
|
|
}) {
|
|
async function connectAndDisplay() {
|
|
// Open a new connection to the device's supervisor, TCP port 48484
|
|
const logStream = await deviceApi.getLogStream();
|
|
return displayDeviceLogs(logStream, logger, system, filterServices);
|
|
}
|
|
|
|
const { retry } = await import('../../utils/helpers');
|
|
try {
|
|
await retry({
|
|
func: connectAndDisplay,
|
|
maxAttempts,
|
|
label: 'Streaming logs',
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof DeviceConnectionLostError) {
|
|
err.message = `Max retry count (${
|
|
maxAttempts - 1
|
|
}) exceeded while attempting to reconnect to the device`;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function displayBuildLog(log: BuildLog, logger: Logger): void {
|
|
const toPrint = `${getServiceColourFn(log.serviceName)(
|
|
`[${log.serviceName}]`,
|
|
)} ${log.message}`;
|
|
logger.logBuild(toPrint);
|
|
}
|
|
|
|
export function displayLogObject<T extends Log>(
|
|
obj: T,
|
|
logger: Logger,
|
|
system: boolean,
|
|
filterServices?: string[],
|
|
): void {
|
|
const d = obj.timestamp != null ? new Date(obj.timestamp) : new Date();
|
|
let toPrint = `[${d.toISOString()}]`;
|
|
|
|
if (obj.serviceName != null) {
|
|
if (filterServices) {
|
|
if (!_.includes(filterServices, obj.serviceName)) {
|
|
return;
|
|
}
|
|
} else if (system) {
|
|
return;
|
|
}
|
|
|
|
const colourFn = getServiceColourFn(obj.serviceName);
|
|
|
|
toPrint += ` ${colourFn(`[${obj.serviceName}]`)}`;
|
|
} else if (filterServices != null && !system) {
|
|
// We have a system log here but we are filtering based
|
|
// on a service, so drop this too
|
|
return;
|
|
}
|
|
|
|
toPrint += ` ${obj.message}`;
|
|
|
|
logger.logLogs(toPrint);
|
|
}
|
|
|
|
export const getServiceColourFn = _.memoize(_getServiceColourFn);
|
|
|
|
const colorHash = new ColorHash();
|
|
function _getServiceColourFn(serviceName: string): (msg: string) => string {
|
|
const [r, g, b] = colorHash.rgb(serviceName);
|
|
|
|
return getChalk().rgb(r, g, b);
|
|
}
|