diff --git a/.gitignore b/.gitignore index 3184a91b..6b0791b8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,6 @@ package-lock.json .balenaconf resinrc.yml balenarc.yml -tslint.json .DS_Store .idea diff --git a/doc/cli.markdown b/doc/cli.markdown index 9f523de2..33336e7b 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -146,7 +146,7 @@ environment variable (in the same standard URL format). - Logs - - [logs <uuid>](#logs-uuid) + - [logs <uuidOrDevice>](#logs-uuidordevice) - Sync @@ -853,18 +853,24 @@ Examples: # Logs -## logs <uuid> +## logs <uuidOrDevice> Use this command to show logs for a specific device. -By default, the command prints all log messages and exit. +By default, the command prints all log messages and exits. To continuously stream output, and see new logs in real time, use the `--tail` option. +If an IP address is passed to this command, logs are displayed from +a local mode device with that address. Note that --tail is implied +when this command is provided an IP address. + Examples: $ balena logs 23c73a1 - $ balena logs 23c73a1 + $ balena logs 23c73a1 --tail + + $ balena logs 192.168.0.31 ### Options diff --git a/lib/actions/logs.coffee b/lib/actions/logs.coffee deleted file mode 100644 index 02fa4642..00000000 --- a/lib/actions/logs.coffee +++ /dev/null @@ -1,61 +0,0 @@ -### -Copyright 2016-2017 Balena - -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. -### - -{ normalizeUuidProp } = require('../utils/normalization') - -module.exports = - signature: 'logs ' - description: 'show device logs' - help: ''' - Use this command to show logs for a specific device. - - By default, the command prints all log messages and exit. - - To continuously stream output, and see new logs in real time, use the `--tail` option. - - Examples: - - $ balena logs 23c73a1 - $ balena logs 23c73a1 - ''' - options: [ - { - signature: 'tail' - description: 'continuously stream output' - boolean: true - alias: 't' - } - ] - permission: 'user' - primary: true - action: (params, options, done) -> - normalizeUuidProp(params) - balena = require('balena-sdk').fromSharedOptions() - moment = require('moment') - - printLine = (line) -> - timestamp = moment(line.timestamp).format('DD.MM.YY HH:mm:ss (ZZ)') - console.log("#{timestamp} #{line.message}") - - if options.tail - balena.logs.subscribe(params.uuid, { count: 100 }).then (logs) -> - logs.on('line', printLine) - logs.on('error', done) - .catch(done) - else - balena.logs.history(params.uuid) - .each(printLine) - .catch(done) diff --git a/lib/actions/logs.ts b/lib/actions/logs.ts new file mode 100644 index 00000000..1089a53b --- /dev/null +++ b/lib/actions/logs.ts @@ -0,0 +1,128 @@ +/* +Copyright 2016-2019 Balena + +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 { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; + +import { normalizeUuidProp } from '../utils/normalization'; + +type CloudLog = + | { + isSystem: false; + serviceId: number; + timestamp: number; + message: string; + } + | { + isSystem: true; + timestamp: number; + message: string; + }; + +export const logs: CommandDefinition< + { + uuidOrDevice: string; + }, + { tail: boolean } +> = { + signature: 'logs ', + description: 'show device logs', + help: stripIndent` + Use this command to show logs for a specific device. + + By default, the command prints all log messages and exits. + + To continuously stream output, and see new logs in real time, use the \`--tail\` option. + + If an IP address is passed to this command, logs are displayed from + a local mode device with that address. Note that --tail is implied + when this command is provided an IP address. + + Examples: + + $ balena logs 23c73a1 + $ balena logs 23c73a1 --tail + + $ balena logs 192.168.0.31`, + options: [ + { + signature: 'tail', + description: 'continuously stream output', + boolean: true, + alias: 't', + }, + ], + permission: 'user', + primary: true, + async action(params, options, done) { + normalizeUuidProp(params); + const balena = (await import('balena-sdk')).fromSharedOptions(); + const { serviceIdToName } = await import('../utils/cloud'); + const { displayDeviceLogs, displayLogObject } = await import( + '../utils/device/logs' + ); + const { validateIPAddress } = await import('../utils/validation'); + const { exitWithExpectedError } = await import('../utils/patterns'); + const Logger = await import('../utils/logger'); + + const logger = new Logger(); + + const displayCloudLog = async (line: CloudLog) => { + if (!line.isSystem) { + let serviceName = await serviceIdToName(balena, line.serviceId); + if (serviceName == null) { + serviceName = 'Unknown service'; + } + displayLogObject({ serviceName, ...line }, logger); + } else { + displayLogObject(line, logger); + } + }; + + if (validateIPAddress(params.uuidOrDevice)) { + const { DeviceAPI } = await import('../utils/device/api'); + const deviceApi = new DeviceAPI(logger, params.uuidOrDevice); + logger.logDebug('Checking we can access device'); + try { + await deviceApi.ping(); + } catch (e) { + exitWithExpectedError( + new Error( + `Cannot access local mode device at address ${params.uuidOrDevice}`, + ), + ); + } + + const logStream = await deviceApi.getLogStream(); + displayDeviceLogs(logStream, logger); + } else { + if (options.tail) { + return balena.logs + .subscribe(params.uuidOrDevice, { count: 100 }) + .then(function(logStream) { + logStream.on('line', displayCloudLog); + logStream.on('error', done); + }) + .catch(done); + } else { + return balena.logs + .history(params.uuidOrDevice) + .each(displayCloudLog) + .catch(done); + } + } + }, +}; diff --git a/lib/actions/push.ts b/lib/actions/push.ts index 81e09bb4..5c71e963 100644 --- a/lib/actions/push.ts +++ b/lib/actions/push.ts @@ -19,11 +19,10 @@ import { CommandDefinition } from 'capitano'; import { stripIndent } from 'common-tags'; import { registrySecretsHelp } from '../utils/messages'; - -// An regex to detect an IP address, from https://www.regular-expressions.info/ip.html -const IP_REGEX = new RegExp( - /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/, -); +import { + validateApplicationName, + validateIPAddress, +} from '../utils/validation'; enum BuildTarget { Cloud, @@ -32,11 +31,11 @@ enum BuildTarget { function getBuildTarget(appOrDevice: string): BuildTarget | null { // First try the application regex from the api - if (/^[a-zA-Z0-9_-]+$/.test(appOrDevice)) { + if (validateApplicationName(appOrDevice)) { return BuildTarget.Cloud; } - if (IP_REGEX.test(appOrDevice)) { + if (validateIPAddress(appOrDevice)) { return BuildTarget.Device; } diff --git a/lib/app.coffee b/lib/app.coffee index 62f4f596..99083be8 100644 --- a/lib/app.coffee +++ b/lib/app.coffee @@ -174,7 +174,7 @@ capitano.command(actions.config.generate) capitano.command(actions.settings.list) # ---------- Logs Module ---------- -capitano.command(actions.logs) +capitano.command(actions.logs.logs) # ---------- Sync Module ---------- capitano.command(actions.sync) diff --git a/lib/utils/cloud.ts b/lib/utils/cloud.ts new file mode 100644 index 00000000..f10dd5c7 --- /dev/null +++ b/lib/utils/cloud.ts @@ -0,0 +1,21 @@ +import { BalenaSDK } from 'balena-sdk'; +import memoize = require('lodash/memoize'); + +export const serviceIdToName = memoize( + async (sdk: BalenaSDK, serviceId: number): Promise => { + const serviceName = await sdk.pine.get({ + resource: 'service', + id: serviceId, + options: { + $select: 'service_name', + }, + }); + + if (serviceName != null) { + return serviceName.service_name; + } + return; + }, + // Memoize the call based on service id + (_sdk, id) => id.toString(), +); diff --git a/lib/utils/device/logs.ts b/lib/utils/device/logs.ts index 5f38789b..aac24917 100644 --- a/lib/utils/device/logs.ts +++ b/lib/utils/device/logs.ts @@ -51,28 +51,31 @@ export function displayBuildLog(log: BuildLog, logger: Logger): void { function displayLogLine(log: string | Buffer, logger: Logger): void { try { const obj: Log = JSON.parse(log.toString()); - - let toPrint: string; - if (obj.timestamp != null) { - toPrint = `[${new Date(obj.timestamp).toLocaleString()}]`; - } else { - toPrint = `[${new Date().toLocaleString()}]`; - } - - if (obj.serviceName != null) { - const colourFn = getServiceColourFn(obj.serviceName); - - toPrint += ` ${colourFn(`[${obj.serviceName}]`)}`; - } - - toPrint += ` ${obj.message}`; - - logger.logLogs(toPrint); + displayLogObject(obj, logger); } catch (e) { logger.logDebug(`Dropping device log due to failed parsing: ${e}`); } } +export function displayLogObject(obj: T, logger: Logger): void { + let toPrint: string; + if (obj.timestamp != null) { + toPrint = `[${new Date(obj.timestamp).toLocaleString()}]`; + } else { + toPrint = `[${new Date().toLocaleString()}]`; + } + + if (obj.serviceName != null) { + const colourFn = getServiceColourFn(obj.serviceName); + + toPrint += ` ${colourFn(`[${obj.serviceName}]`)}`; + } + + toPrint += ` ${obj.message}`; + + logger.logLogs(toPrint); +} + const getServiceColourFn = _.memoize(_getServiceColourFn); const colorHash = new ColorHash(); diff --git a/lib/utils/validation.ts b/lib/utils/validation.ts index 194571b8..7b4a42eb 100644 --- a/lib/utils/validation.ts +++ b/lib/utils/validation.ts @@ -16,6 +16,13 @@ limitations under the License. import validEmail = require('@resin.io/valid-email'); +const APPNAME_REGEX = new RegExp(/^[a-zA-Z0-9_-]+$/); +// An regex to detect an IP address, from https://www.regular-expressions.info/ip.html +const IP_REGEX = new RegExp( + /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/, +); +const DOTLOCAL_REGEX = new RegExp(/^[a-zA-Z0-9-]+\.local$/); + export function validateEmail(input: string) { if (!validEmail(input)) { return 'Email is not valid'; @@ -37,5 +44,13 @@ export function validateApplicationName(input: string) { return 'The application name should be at least 4 characters'; } - return true; + return APPNAME_REGEX.test(input); +} + +export function validateIPAddress(input: string): boolean { + return IP_REGEX.test(input); +} + +export function validateDotLocalUrl(input: string): boolean { + return DOTLOCAL_REGEX.test(input); } diff --git a/tslint.json b/tslint.json new file mode 100644 index 00000000..0be8c926 --- /dev/null +++ b/tslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./node_modules/resin-lint/config/tslint-prettier.json", + "rules": { + "ignoreDefinitionFiles": false + } +}