From 9d2884aab7544d8b7330cdd6fa1a020344f6309c Mon Sep 17 00:00:00 2001 From: Scott Lowe Date: Wed, 16 Dec 2020 16:57:25 +0100 Subject: [PATCH] Update commands ssh, tunnel to support orgs Change-type: patch Connects-to: #2119 Signed-off-by: Scott Lowe --- doc/cli.markdown | 12 +-- lib/commands/ssh.ts | 70 +++++++---------- lib/commands/tunnel.ts | 24 +++--- lib/utils/patterns.ts | 154 ++++++++++++++++++------------------- lib/utils/ssh.ts | 1 + tests/balena-api-mock.ts | 2 + tests/commands/ssh.spec.ts | 20 ++++- 7 files changed, 142 insertions(+), 141 deletions(-) diff --git a/doc/cli.markdown b/doc/cli.markdown index 0c45b3c5..7099722d 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -1768,7 +1768,7 @@ produce JSON output instead of tabular output Start a shell on a local or remote device. If a service name is not provided, a shell will be opened on the host OS. -If an application name is provided, an interactive menu will be presented +If an application is provided, an interactive menu will be presented for the selection of an online device. A shell will then be opened for the host OS or service container of the chosen device. @@ -1803,7 +1803,7 @@ Examples: #### APPLICATIONORDEVICE -application name, device uuid, or address of local device +application name/slug/id, device uuid, or address of local device #### SERVICE @@ -1818,15 +1818,15 @@ hostname. Otherwise, port number for the balenaCloud gateway (default 22). #### -t, --tty -Force pseudo-terminal allocation (bypass TTY autodetection for stdin) +force pseudo-terminal allocation (bypass TTY autodetection for stdin) #### -v, --verbose -Increase verbosity +increase verbosity #### --noproxy -Bypass global proxy configuration for the ssh connection +bypass global proxy configuration for the ssh connection ## tunnel <deviceOrApplication> @@ -1863,7 +1863,7 @@ Examples: #### DEVICEORAPPLICATION -device uuid or application name/id +device uuid or application name/slug/id ### Options diff --git a/lib/commands/ssh.ts b/lib/commands/ssh.ts index 61cf5f88..0ebcddf7 100644 --- a/lib/commands/ssh.ts +++ b/lib/commands/ssh.ts @@ -19,11 +19,7 @@ import { flags } from '@oclif/command'; import Command from '../command'; import * as cf from '../utils/common-flags'; import { getBalenaSdk, stripIndent } from '../utils/lazy'; -import { - parseAsInteger, - validateDotLocalUrl, - validateIPAddress, -} from '../utils/validation'; +import { parseAsInteger, validateLocalHostnameOrIp } from '../utils/validation'; import * as BalenaSdk from 'balena-sdk'; interface FlagsDef { @@ -39,14 +35,14 @@ interface ArgsDef { service?: string; } -export default class NoteCmd extends Command { +export default class SshCmd extends Command { public static description = stripIndent` SSH into the host or application container of a device. Start a shell on a local or remote device. If a service name is not provided, a shell will be opened on the host OS. - If an application name is provided, an interactive menu will be presented + If an application is provided, an interactive menu will be presented for the selection of an online device. A shell will then be opened for the host OS or service container of the chosen device. @@ -81,7 +77,8 @@ export default class NoteCmd extends Command { public static args = [ { name: 'applicationOrDevice', - description: 'application name, device uuid, or address of local device', + description: + 'application name/slug/id, device uuid, or address of local device', required: true, }, { @@ -104,17 +101,17 @@ export default class NoteCmd extends Command { tty: flags.boolean({ default: false, description: - 'Force pseudo-terminal allocation (bypass TTY autodetection for stdin)', + 'force pseudo-terminal allocation (bypass TTY autodetection for stdin)', char: 't', }), verbose: flags.boolean({ default: false, - description: 'Increase verbosity', + description: 'increase verbosity', char: 'v', }), noproxy: flags.boolean({ default: false, - description: 'Bypass global proxy configuration for the ssh connection', + description: 'bypass global proxy configuration for the ssh connection', }), help: cf.help, }; @@ -123,14 +120,11 @@ export default class NoteCmd extends Command { public async run() { const { args: params, flags: options } = this.parse( - NoteCmd, + SshCmd, ); - // if we're doing a direct SSH connection locally... - if ( - validateDotLocalUrl(params.applicationOrDevice) || - validateIPAddress(params.applicationOrDevice) - ) { + // Local connection + if (validateLocalHostnameOrIp(params.applicationOrDevice)) { const { performLocalDeviceSSH } = await import('../utils/device/ssh'); return await performLocalDeviceSSH({ address: params.applicationOrDevice, @@ -141,26 +135,27 @@ export default class NoteCmd extends Command { }); } + // Remote connection const { getProxyConfig, which } = await import('../utils/helpers'); - const { checkLoggedIn, getOnlineTargetUuid } = await import( - '../utils/patterns' - ); + const { getOnlineTargetDeviceUuid } = await import('../utils/patterns'); const sdk = getBalenaSdk(); const proxyConfig = getProxyConfig(); const useProxy = !!proxyConfig && !options.noproxy; // this will be a tunnelled SSH connection... - await checkLoggedIn(); - const uuid = await getOnlineTargetUuid(sdk, params.applicationOrDevice); - let version: string | undefined; - let id: number | undefined; + await Command.checkLoggedIn(); + const deviceUuid = await getOnlineTargetDeviceUuid( + sdk, + params.applicationOrDevice, + ); - const device = await sdk.models.device.get(uuid, { + const device = await sdk.models.device.get(deviceUuid, { $select: ['id', 'supervisor_version', 'is_online'], }); - id = device.id; - version = device.supervisor_version; + + const deviceId = device.id; + const supervisorVersion = device.supervisor_version; const [whichProxytunnel, username, proxyUrl] = await Promise.all([ useProxy ? which('proxytunnel', false) : undefined, @@ -207,20 +202,13 @@ export default class NoteCmd extends Command { const proxyCommand = useProxy ? getSshProxyCommand() : undefined; - if (username == null) { - const { ExpectedError } = await import('../errors'); - throw new ExpectedError( - `Opening an SSH connection to a remote device requires you to be logged in.`, - ); - } - - // At this point, we have a long uuid with a device + // At this point, we have a long uuid of a device // that we know exists and is accessible let containerId: string | undefined; if (params.service != null) { containerId = await this.getContainerId( sdk, - uuid, + deviceUuid, params.service, { port: options.port, @@ -228,20 +216,20 @@ export default class NoteCmd extends Command { proxyUrl: proxyUrl || '', username: username!, }, - version, - id, + supervisorVersion, + deviceId, ); } let accessCommand: string; if (containerId != null) { - accessCommand = `enter ${uuid} ${containerId}`; + accessCommand = `enter ${deviceUuid} ${containerId}`; } else { - accessCommand = `host ${uuid}`; + accessCommand = `host ${deviceUuid}`; } const command = this.generateVpnSshCommand({ - uuid, + uuid: deviceUuid, command: accessCommand, verbose: options.verbose, port: options.port, diff --git a/lib/commands/tunnel.ts b/lib/commands/tunnel.ts index 6cd38845..84f17676 100644 --- a/lib/commands/tunnel.ts +++ b/lib/commands/tunnel.ts @@ -24,11 +24,7 @@ import { } from '../errors'; import * as cf from '../utils/common-flags'; import { getBalenaSdk, stripIndent } from '../utils/lazy'; -import { getOnlineTargetUuid } from '../utils/patterns'; -import * as _ from 'lodash'; -import { tunnelConnectionToDevice } from '../utils/tunnel'; -import { createServer, Server, Socket } from 'net'; -import { IArg } from '@oclif/parser/lib/args'; +import type { Server, Socket } from 'net'; interface FlagsDef { port: string[]; @@ -73,10 +69,10 @@ export default class TunnelCmd extends Command { '$ balena tunnel myApp -p 8080:3000 -p 8081:9000', ]; - public static args: Array> = [ + public static args = [ { name: 'deviceOrApplication', - description: 'device uuid or application name/id', + description: 'device uuid or application name/slug/id', required: true, }, ]; @@ -101,8 +97,7 @@ export default class TunnelCmd extends Command { TunnelCmd, ); - const Logger = await import('../utils/logger'); - const logger = Logger.getLogger(); + const logger = await Command.getLogger(); const sdk = getBalenaSdk(); const logConnection = ( @@ -127,23 +122,30 @@ export default class TunnelCmd extends Command { throw new NoPortsDefinedError(); } - const uuid = await getOnlineTargetUuid(sdk, params.deviceOrApplication); + // Ascertain device uuid + const { getOnlineTargetDeviceUuid } = await import('../utils/patterns'); + const uuid = await getOnlineTargetDeviceUuid( + sdk, + params.deviceOrApplication, + ); const device = await sdk.models.device.get(uuid); - logger.logInfo(`Opening a tunnel to ${device.uuid}...`); + const _ = await import('lodash'); const localListeners = _.chain(options.port) .map((mapping) => { return this.parsePortMapping(mapping); }) .map(async ({ localPort, localAddress, remotePort }) => { try { + const { tunnelConnectionToDevice } = await import('../utils/tunnel'); const handler = await tunnelConnectionToDevice( device.uuid, remotePort, sdk, ); + const { createServer } = await import('net'); const server = createServer(async (client: Socket) => { try { await handler(client); diff --git a/lib/utils/patterns.ts b/lib/utils/patterns.ts index 5d298004..3fe09c27 100644 --- a/lib/utils/patterns.ts +++ b/lib/utils/patterns.ts @@ -26,7 +26,8 @@ import { getBalenaSdk, getVisuals, stripIndent, getCliForm } from './lazy'; import validation = require('./validation'); import { delay } from './helpers'; import { isV13 } from './version'; -import { Organization } from 'balena-sdk'; +import type { Application, Device, Organization } from 'balena-sdk'; +import { getApplication } from './sdk'; export function authenticate(options: {}): Promise { const balena = getBalenaSdk(); @@ -329,99 +330,94 @@ export function inferOrSelectDevice(preferredUuid: string) { }); } -async function getApplicationByIdOrName( - sdk: BalenaSdk.BalenaSDK, - idOrName: string, -) { - if (validation.looksLikeInteger(idOrName)) { - try { - return await sdk.models.application.get(Number(idOrName)); - } catch (error) { - const { BalenaApplicationNotFound } = await import('balena-errors'); - if (!instanceOf(error, BalenaApplicationNotFound)) { - throw error; - } - } - } - return await sdk.models.application.get(idOrName); -} - -export async function getOnlineTargetUuid( +/* + * Given applicationOrDevice, which may be + * - an application name + * - an application slug + * - an application id (integer) + * - a device uuid + * Either: + * - in case of device uuid, return uuid of device after verifying that it exists and is online. + * - in case of application, return uuid of device user selects from list of online devices. + * + * TODO: Modify this when app IDs dropped. + */ +export async function getOnlineTargetDeviceUuid( sdk: BalenaSdk.BalenaSDK, applicationOrDevice: string, ) { - // applicationOrDevice can be: - // * an application name - // * an application ID (integer) - // * a device uuid - const Logger = await import('../utils/logger'); - const logger = Logger.getLogger(); - const appTest = validation.validateApplicationName(applicationOrDevice); - const uuidTest = validation.validateUuid(applicationOrDevice); + const logger = (await import('../utils/logger')).getLogger(); - if (!appTest && !uuidTest) { - throw new ExpectedError( - `Device or application not found: ${applicationOrDevice}`, - ); + // If looks like UUID, probably device + if (validation.validateUuid(applicationOrDevice)) { + let device: Device; + try { + logger.logDebug( + `Trying to fetch device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`, + ); + device = await sdk.models.device.get(applicationOrDevice, { + $select: ['uuid', 'is_online'], + }); + + if (!device.is_online) { + throw new ExpectedError( + `Device with UUID ${applicationOrDevice} is offline`, + ); + } + + return device.uuid; + } catch (err) { + const { BalenaDeviceNotFound } = await import('balena-errors'); + if (instanceOf(err, BalenaDeviceNotFound)) { + logger.logDebug(`Device with UUID ${applicationOrDevice} not found`); + // Now try app + } else { + throw err; + } + } } - // if we have a definite device UUID... - if (uuidTest && !appTest) { - logger.logDebug( - `Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`, - ); - return ( - await sdk.models.device.get(applicationOrDevice, { - $select: ['uuid'], - $filter: { is_online: true }, - }) - ).uuid; - } - - // otherwise, it may be a device OR an application... + // Not a device UUID, try app + let app: Application; try { logger.logDebug( - `Fetching application by ID or name ${applicationOrDevice} (${typeof applicationOrDevice})`, + `Trying to fetch application by name/slug/ID: ${applicationOrDevice}`, ); - const app = await getApplicationByIdOrName(sdk, applicationOrDevice); - const devices = await sdk.models.device.getAllByApplication(app.id, { - $filter: { is_online: true }, - }); - - if (_.isEmpty(devices)) { - throw new ExpectedError('No accessible devices are online'); - } - - return await getCliForm().ask({ - message: 'Select a device', - type: 'list', - default: devices[0].uuid, - choices: _.map(devices, (device) => ({ - name: `${device.device_name || 'Untitled'} (${device.uuid.slice( - 0, - 7, - )})`, - value: device.uuid, - })), - }); + app = await getApplication(sdk, applicationOrDevice); } catch (err) { const { BalenaApplicationNotFound } = await import('balena-errors'); - if (!instanceOf(err, BalenaApplicationNotFound)) { + if (instanceOf(err, BalenaApplicationNotFound)) { + throw new ExpectedError( + `Application or Device not found: ${applicationOrDevice}`, + ); + } else { throw err; } - logger.logDebug(`Application not found`); } - // it wasn't an application, maybe it's a device... - logger.logDebug( - `Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`, - ); - return ( - await sdk.models.device.get(applicationOrDevice, { - $select: ['uuid'], - $filter: { is_online: true }, - }) - ).uuid; + // App found, load its devices + const devices = await sdk.models.device.getAllByApplication(app.id, { + $select: ['device_name', 'uuid'], + $filter: { is_online: true }, + }); + + // Throw if no devices online + if (_.isEmpty(devices)) { + throw new ExpectedError( + `Application ${app.slug} found, but has no devices online.`, + ); + } + + // Ask user to select from online devices for application + return getCliForm().ask({ + message: `Select a device on application ${app.slug}`, + type: 'list', + default: devices[0].uuid, + choices: _.map(devices, (device) => ({ + name: `${device.device_name || 'Untitled'} (${device.uuid.slice(0, 7)})`, + value: device.uuid, + })), + }); } export function selectFromList( diff --git a/lib/utils/ssh.ts b/lib/utils/ssh.ts index 3bf36f3b..6ca5b9fc 100644 --- a/lib/utils/ssh.ts +++ b/lib/utils/ssh.ts @@ -162,6 +162,7 @@ function sshErrorMessage(exitSignal?: string, exitCode?: number) { msg.push(` Are the SSH keys correctly configured in balenaCloud? See: https://www.balena.io/docs/learn/manage/ssh-access/#add-an-ssh-key-to-balenacloud`); + msg.push('Are you accidentally using `sudo`?'); } } return msg.join('\n'); diff --git a/tests/balena-api-mock.ts b/tests/balena-api-mock.ts index 488f9228..d5099bfd 100644 --- a/tests/balena-api-mock.ts +++ b/tests/balena-api-mock.ts @@ -185,6 +185,7 @@ export class BalenaAPIMock extends NockMock { public expectGetDevice(opts: { fullUUID: string; inaccessibleApp?: boolean; + isOnline?: boolean; optional?: boolean; persist?: boolean; }) { @@ -194,6 +195,7 @@ export class BalenaAPIMock extends NockMock { { id, uuid: opts.fullUUID, + is_online: opts.isOnline, belongs_to__application: opts.inaccessibleApp ? [] : [{ app_name: 'test' }], diff --git a/tests/commands/ssh.spec.ts b/tests/commands/ssh.spec.ts index d617cf48..46ccdcfd 100644 --- a/tests/commands/ssh.spec.ts +++ b/tests/commands/ssh.spec.ts @@ -66,11 +66,11 @@ describe('balena ssh', function () { itSS('should succeed (mocked, device UUID)', async () => { const deviceUUID = 'abc1234'; api.expectGetWhoAmI({ optional: true, persist: true }); - api.expectGetApplication({ notFound: true }); - api.expectGetDevice({ fullUUID: deviceUUID }); + api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true }); mockedExitCode = 0; const { err, out } = await runCommand(`ssh ${deviceUUID}`); + expect(err).to.be.empty; expect(out).to.be.empty; }); @@ -90,8 +90,7 @@ describe('balena ssh', function () { 'Warning: ssh process exited with non-zero code "255"', ]; api.expectGetWhoAmI({ optional: true, persist: true }); - api.expectGetApplication({ notFound: true }); - api.expectGetDevice({ fullUUID: deviceUUID }); + api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true }); mockedExitCode = 255; const { err, out } = await runCommand(`ssh ${deviceUUID}`); @@ -114,6 +113,19 @@ describe('balena ssh', function () { expect(cleanOutput(err, true)).to.include.members(expectedErrLines); expect(out).to.be.empty; }); + + it('should fail if device not online (mocked, device UUID)', async () => { + const deviceUUID = 'abc1234'; + const expectedErrLines = ['Device with UUID abc1234 is offline']; + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetDevice({ fullUUID: deviceUUID, isOnline: false }); + mockedExitCode = 0; + + const { err, out } = await runCommand(`ssh ${deviceUUID}`); + + expect(cleanOutput(err, true)).to.include.members(expectedErrLines); + expect(out).to.be.empty; + }); }); /** Check whether the 'ssh' tool (executable) exists in the PATH */