Skip to content
This repository has been archived by the owner on Sep 22, 2022. It is now read-only.

Commit

Permalink
feat(logcat): add connect-timeout flag and relax default timeout
Browse files Browse the repository at this point in the history
  • Loading branch information
john-u committed Mar 1, 2022
1 parent acc961f commit df55787
Show file tree
Hide file tree
Showing 6 changed files with 436 additions and 138 deletions.
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,18 +653,19 @@ stream logs from installed drivers
```
USAGE
$ smartthings edge:drivers:logcat [DRIVERID] [-h] [-p <value>] [-t <value>] [--language <value>] [-a] [--hub-address
<value>]
<value>] [--connect-timeout <value>]
ARGUMENTS
DRIVERID a specific driver to stream logs from
FLAGS
-a, --all stream from all installed drivers
-h, --help Show CLI help.
-p, --profile=<value> [default: default] configuration profile
-t, --token=<value> the auth token to use
--hub-address=<value> IPv4 address of hub with optionally appended port number
--language=<value> ISO language code or "NONE" to not specify a language. Defaults to the OS locale
-a, --all stream from all installed drivers
-h, --help Show CLI help.
-p, --profile=<value> [default: default] configuration profile
-t, --token=<value> the auth token to use
--connect-timeout=<milliseconds> [default: 30000] max time allowed when connecting to hub
--hub-address=<value> IPv4 address of hub with optionally appended port number
--language=<value> ISO language code or "NONE" to not specify a language. Defaults to the OS locale
DESCRIPTION
stream logs from installed drivers
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@semantic-release/git": "^10.0.1",
"@smartthings/cli-testlib": "0.0.0-pre.39",
"@types/cli-table": "^0.3.0",
"@types/eventsource": "^1.1.8",
"@types/inquirer": "^8.2.0",
"@types/jest": "^27.4.0",
"@types/js-yaml": "^4.0.5",
Expand Down
91 changes: 65 additions & 26 deletions src/commands/edge/drivers/logcat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DriverInfo,
handleConnectionErrors,
LiveLogClient,
LiveLogClientConfig,
LiveLogMessage,
liveLogMessageFormatter,
parseIpAndPort,
Expand All @@ -25,6 +26,7 @@ import { inspect } from 'util'

const DEFAULT_ALL_TEXT = 'all'
const DEFAULT_LIVE_LOG_PORT = 9495
const DEFAULT_LIVE_LOG_TIMEOUT = 30_000 // milliseconds

/**
* Define labels to stay consistent with other driver commands
Expand Down Expand Up @@ -84,6 +86,11 @@ export default class LogCatCommand extends SseCommand {
'hub-address': Flags.string({
description: 'IPv4 address of hub with optionally appended port number',
}),
'connect-timeout': Flags.integer({
description: 'max time allowed when connecting to hub',
helpValue: '<milliseconds>',
default: DEFAULT_LIVE_LOG_TIMEOUT,
}),
}

static args = [
Expand All @@ -104,34 +111,36 @@ export default class LogCatCommand extends SseCommand {

const known = knownHubs[this.authority]
if (!known || known.fingerprint !== cert.fingerprint) {
this.warn(`The authenticity of ${this.authority} can't be established. Certificate fingerprint is ${cert.fingerprint}`)
const verified = (await inquirer.prompt({
type: 'confirm',
name: 'connect',
message: 'Are you sure you want to continue connecting?',
default: false,
})).connect

if (!verified) {
this.error('Hub verification failed.')
}
await CliUx.ux.action.pauseAsync(async () => {
this.warn(`The authenticity of ${this.authority} can't be established. Certificate fingerprint is ${cert.fingerprint}`)
const verified = (await inquirer.prompt({
type: 'confirm',
name: 'connect',
message: 'Are you sure you want to continue connecting?',
default: false,
})).connect

if (!verified) {
this.error('Hub verification failed.')
}

knownHubs[this.authority] = { hostname: this.authority, fingerprint: cert.fingerprint }
await fs.writeFile(knownHubsPath, JSON.stringify(knownHubs))
knownHubs[this.authority] = { hostname: this.authority, fingerprint: cert.fingerprint }
await fs.writeFile(knownHubsPath, JSON.stringify(knownHubs))

this.warn(`Permanently added ${this.authority} to the list of known hubs.`)
this.warn(`Permanently added ${this.authority} to the list of known hubs.`)
})
}
}

private async chooseHubDrivers(commandLineDriverId?: string, driversList?: Promise<DriverInfo[]>): Promise<string> {
private async chooseHubDrivers(commandLineDriverId?: string, driversList?: DriverInfo[]): Promise<string> {
const config = {
itemName: 'driver',
primaryKeyName: 'driver_id',
sortKeyName: 'driver_name',
listTableFieldDefinitions: driverFieldDefinitions,
}

const list = driversList ?? this.logClient.getDrivers()
const list = driversList !== undefined ? Promise.resolve(driversList) : this.logClient.getDrivers()
const preselectedId = await stringTranslateToId(config, commandLineDriverId, () => list)
return selectGeneric(this, config, preselectedId, () => list, promptForDrivers)
}
Expand All @@ -147,27 +156,47 @@ export default class LogCatCommand extends SseCommand {
const liveLogPort = port ?? DEFAULT_LIVE_LOG_PORT
this.authority = `${ipv4}:${liveLogPort}`

this.logClient = new LiveLogClient(this.authority, this.authenticator, this.checkServerIdentity.bind(this))
const config: LiveLogClientConfig = {
authority: this.authority,
authenticator: this.authenticator,
verifier: this.checkServerIdentity.bind(this),
timeout: flags['connect-timeout'],
}

this.logClient = new LiveLogClient(config)
}

async run(): Promise<void> {
const installedDriversPromise = this.logClient.getDrivers()
CliUx.ux.action.start('connecting')

// ensure host verification resolves before connecting to the event source
const installedDrivers = await this.logClient.getDrivers()

let sourceURL: string
if (this.flags.all) {
sourceURL = this.logClient.getLogSource()
} else {
const driverId = await this.chooseHubDrivers(this.args.driverId, installedDriversPromise)
const driverId = await CliUx.ux.action.pauseAsync(() => this.chooseHubDrivers(this.args.driverId, installedDrivers))
sourceURL = driverId == DEFAULT_ALL_TEXT ? this.logClient.getLogSource() : this.logClient.getLogSource(driverId)
}

// ensure this resolves before connecting to the event source
const installedDrivers = await installedDriversPromise

CliUx.ux.action.start('connecting')
await this.initSource(sourceURL)

const sourceTimeoutID = setTimeout(() => {
this.teardown()
CliUx.ux.action.stop('failed')
try {
handleConnectionErrors(this.authority, 'ETIMEDOUT')
} catch (error) {
if (error instanceof Error) {
Errors.handle(error)
}
}
}, this.flags['connect-timeout']).unref() // unref lets Node exit before callback is invoked

this.source.onopen = () => {
clearTimeout(sourceTimeoutID)

if (installedDrivers.length === 0) {
this.warn('No drivers currently installed.')
}
Expand All @@ -176,11 +205,10 @@ export default class LogCatCommand extends SseCommand {
}

// error Event from eventsource doesn't always overlap with MessageEvent
this.source.onerror = (error: MessageEvent & Partial<{ status: number; message: string | undefined }>) => {
CliUx.ux.action.stop('failed')
this.source.onerror = (error: MessageEvent & Partial<{ status: number; message: string }>) => {
this.teardown()
CliUx.ux.action.stop('failed')
this.logger.debug(`Error from eventsource. URL: ${sourceURL} Error: ${inspect(error)}`)

try {
if (error.status === 401 || error.status === 403) {
this.error(`Unauthorized at ${this.authority}`)
Expand All @@ -202,4 +230,15 @@ export default class LogCatCommand extends SseCommand {
logEvent(event, liveLogMessageFormatter)
}
}

async catch(error: unknown): Promise<void> {
this.teardown()
// exit gracefully for Command.exit(0)
if (error instanceof Errors.ExitError && error.oclif.exit === 0) {
return
}

CliUx.ux.action.stop('failed')
await super.catch(error)
}
}
47 changes: 27 additions & 20 deletions src/lib/live-logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,31 +190,44 @@ function scrubAuthInfo(obj: unknown): string {
*/
export type HostVerifier = (cert: PeerCertificate) => Promise<void | never>

export interface LiveLogClientConfig {
/**
* @example 192.168.0.1:9495
*/
authority: string
authenticator: Authenticator
verifier?: HostVerifier
/**
* milliseconds
*/
timeout: number
}

export class LiveLogClient {
private authority: string
private driversURL: URL
private logsURL: URL
private authenticator: Authenticator
private hostVerified: boolean
private verifier?: HostVerifier

constructor(authority: string, authenticator: Authenticator, verifier?: HostVerifier) {
const baseURL = new URL(`https://${authority}`)
constructor(private readonly config: LiveLogClientConfig) {
const baseURL = new URL(`https://${config.authority}`)

this.authority = authority
this.driversURL = new URL('drivers', baseURL)
this.logsURL = new URL('drivers/logs', baseURL)
this.authenticator = authenticator
this.hostVerified = verifier === undefined
this.verifier = verifier
this.hostVerified = config.verifier === undefined
}

private async request(url: string, method: Method = 'GET'): Promise<AxiosResponse> {
const config = await this.authenticator.authenticate({
const config = await this.config.authenticator.authenticate({
url: url,
method: method,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
timeout: 5000, // milliseconds
timeout: this.config.timeout,
transitional: {
silentJSONParsing: true,
forcedJSONParsing: true,
// throw ETIMEDOUT error instead of generic ECONNABORTED on request timeouts
clarifyTimeoutError: true,
},
})

let response
Expand All @@ -235,8 +248,8 @@ export class LiveLogClient {
throw error
}

if (!this.hostVerified && this.verifier) {
await this.verifier(this.getCertificate(response))
if (!this.hostVerified && this.config.verifier) {
await this.config.verifier(this.getCertificate(response))
this.hostVerified = true
}

Expand All @@ -245,13 +258,7 @@ export class LiveLogClient {

private handleAxiosConnectionErrors(error: AxiosError): never | void {
if (error.code) {
// hack to address https://github.com/axios/axios/issues/1543
if (error.code === 'ECONNABORTED' && error.message.toLowerCase().includes('timeout')) {
throw new Errors.CLIError(`Connection to ${this.authority} timed out. ` +
'Ensure hub address is correct and try again')
}

handleConnectionErrors(this.authority, error.code)
handleConnectionErrors(this.config.authority, error.code)
}
}

Expand Down

0 comments on commit df55787

Please sign in to comment.