balena-cli
Version:
The official balena Command Line Interface
194 lines (174 loc) • 5.48 kB
text/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 Logger = require('../logger');
import { ExpectedError, SIGINTError } from '../../errors';
import { getCliUx } from '../lazy';
class DeviceConnectionLostError extends ExpectedError {
public static defaultMsg = 'Connection to device lost';
constructor(msg?: string) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
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 ux = getCliUx();
const color = getServiceColor(log.serviceName);
const toPrint = `${ux.colorize(color, `[${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 (!filterServices.includes(obj.serviceName)) {
return;
}
} else if (system) {
return;
}
const color = getServiceColor(obj.serviceName);
const ux = getCliUx();
toPrint += ` ${ux.colorize(color, `[${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 getServiceColor = _.memoize(_getServiceColor);
const colorHash = new ColorHash();
function _getServiceColor(serviceName: string): string {
const [r, g, b] = colorHash.rgb(serviceName);
return `rgb(${r}, ${g}, ${b})`;
}