/** * @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 { 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( 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); }