Compare commits

..

1 Commits

Author SHA1 Message Date
ab1d8aa6ba (v14) Migrate tabular commands to new output framework
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2022-03-25 18:14:57 +01:00
40 changed files with 1804 additions and 1327 deletions

View File

@ -7,14 +7,9 @@ npm:
node_versions:
- "12"
- "14"
##
## Temporarily skip Alpine tests until the following issues are resolved:
## * https://github.com/concourse/concourse/issues/7905
## * https://github.com/product-os/balena-concourse/issues/631
##
# - name: linux
# os: alpine
# architecture: x86_64
# node_versions:
# - "12"
# - "14"
- name: linux
os: alpine
architecture: x86_64
node_versions:
- "12"
- "14"

View File

@ -1,116 +1,3 @@
- commits:
- subject: Skip Alpine tests until Concourse + Alpine v3.14 issues are resolved
hash: a4ab07cd085d84d75bc668390823bf72604730e0
body: |
See:
* https://github.com/concourse/concourse/issues/7905
* https://github.com/product-os/balena-concourse/issues/631
* https://github.com/product-os/ci-images/pull/116/files#r844508619
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
- subject: "build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key"
hash: 9185eaa2b742bb694abe8b300221bf7437e7e93f
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.3.2
title: "'build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key'"
date: 2022-04-07T09:15:48.137Z
- commits:
- subject: Include link to Wiki release notes in version update notifications
hash: e9461789531df561165ea2ca90a00d6fe9a0f9b6
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.3.1
title: "'Include link to Wiki release notes in version update notifications'"
date: 2022-03-08T18:34:21.932Z
- commits:
- subject: "ssh: Allow ssh to service with IP address and production balenaOS image"
hash: 915f7e3763991700d4746e3581099d5793a58648
body: |
Also remove 'balena ssh' dependency on the device supervisor (that may
be down because of device issues or a supervisor bug) when opening a
ssh shell on a container (#1560).
footer:
Resolves: "#1560"
resolves: "#1560"
Change-type: minor
change-type: minor
author: Paulo Castro
nested: []
- subject: "ssh: Advise use of 'balena login' if root authentication fails"
hash: cd17d790673229ca0dfa42666a1800916a987578
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.3.0
title: "'ssh: Allow ssh to service with IP address and production balenaOS image'"
date: 2022-02-28T21:42:42.420Z
- commits:
- subject: Remove unnecessary fetch of device info in `balena tunnel`
hash: bd1bf8153d5c58be31a9fef44da4b13c20a3e036
body: ""
footer:
Change-type: patch
change-type: patch
author: Pagan Gazzard
nested: []
- subject: Correctly use the device uuid when logging the tunnel target
hash: f2528dcd1827e0529095bac1c346072b237da848
body: |
The "vpn address" is only relevant on the device/vpn server themselves
and makes no sense from a CLI context as it uses the uuid to specify
the target
footer:
Change-type: patch
change-type: patch
author: Pagan Gazzard
nested: []
version: 13.2.1
title: "'Correctly use the device uuid when logging the tunnel target'"
date: 2022-02-24T21:05:36.396Z
- commits:
- subject: "ssh: Attempt cloud username if 'root' authentication fails"
hash: eeb2be29120d752df0d902087c06ca1586fe26d4
body: |
Also refactor several files to avoid code duplication.
footer:
Change-type: minor
change-type: minor
author: Paulo Castro
nested: []
- subject: Replace occurrence of through2 dependency with standard stream module
hash: 3bf8befb1d99d66ccd258bc64b53cd04f7f91896
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
- subject: Refactor cached username logic from events.ts to bootstrap.ts for reuse
hash: 948095ce4d3a3cf500b426a199cc52124c1f88fd
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.2.0
title: "'ssh: Attempt cloud username if 'root' authentication fails'"
date: 2022-02-12T02:44:44.074Z
- commits:
- subject: Drop unused awaitDevice utility function
hash: eccadbdcb9b5340d0e9d42fd9b9063255cad9fb0

View File

@ -4,31 +4,6 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
## 13.3.2 - 2022-04-07
* Skip Alpine tests until Concourse + Alpine v3.14 issues are resolved [Paulo Castro]
* build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key [Paulo Castro]
## 13.3.1 - 2022-03-08
* Include link to Wiki release notes in version update notifications [Paulo Castro]
## 13.3.0 - 2022-02-28
* ssh: Allow ssh to service with IP address and production balenaOS image [Paulo Castro]
* ssh: Advise use of 'balena login' if root authentication fails [Paulo Castro]
## 13.2.1 - 2022-02-24
* Remove unnecessary fetch of device info in `balena tunnel` [Pagan Gazzard]
* Correctly use the device uuid when logging the tunnel target [Pagan Gazzard]
## 13.2.0 - 2022-02-12
* ssh: Attempt cloud username if 'root' authentication fails [Paulo Castro]
* Replace occurrence of through2 dependency with standard stream module [Paulo Castro]
* Refactor cached username logic from events.ts to bootstrap.ts for reuse [Paulo Castro]
## 13.1.13 - 2022-02-10
* Drop unused awaitDevice utility function [Lucian Buzzo]

View File

@ -333,6 +333,30 @@ Examples:
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
#### --filter FILTER
filter results by substring matching of a given field, eg: --filter field=foo
#### --no-header
hide table header from output
#### --no-truncate
do not truncate output to fit screen
#### --sort SORT
field to sort by (prepend '-' for descending order)
## fleet &#60;fleet&#62;
Display detailed information about a single fleet.
@ -362,6 +386,14 @@ fleet name, slug (preferred), or numeric ID (deprecated)
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
## fleet create &#60;name&#62;
Create a new balena fleet.
@ -648,9 +680,29 @@ Examples:
fleet name, slug (preferred), or numeric ID (deprecated)
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
produce JSON output instead of tabular output
output in json format
#### --filter FILTER
filter results by substring matching of a given field, eg: --filter field=foo
#### --no-header
hide table header from output
#### --no-truncate
do not truncate output to fit screen
#### --sort SORT
field to sort by (prepend '-' for descending order)
## devices supported
@ -669,9 +721,29 @@ Examples:
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
produce JSON output instead of tabular output
output in json format
#### --filter FILTER
filter results by substring matching of a given field, eg: --filter field=foo
#### --no-header
hide table header from output
#### --no-truncate
do not truncate output to fit screen
#### --sort SORT
field to sort by (prepend '-' for descending order)
## device &#60;uuid&#62;
@ -689,6 +761,14 @@ the device uuid
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
## device deactivate &#60;uuid&#62;
Deactivate a device.
@ -1152,6 +1232,14 @@ fleet name or slug (preferred)
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
## release &#60;commitOrId&#62;
@ -1173,6 +1261,14 @@ the commit or ID of the release to get information
Return the release composition
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
## release finalize &#60;commitOrId&#62;
Finalize a release. Releases can be "draft" or "final", and this command
@ -1271,9 +1367,29 @@ show configuration variables only
device UUID
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
produce JSON output instead of tabular output
output in json format
#### --filter FILTER
filter results by substring matching of a given field, eg: --filter field=foo
#### --no-header
hide table header from output
#### --no-truncate
do not truncate output to fit screen
#### --sort SORT
field to sort by (prepend '-' for descending order)
#### -s, --service SERVICE
@ -1526,6 +1642,30 @@ device UUID
release id
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
#### --filter FILTER
filter results by substring matching of a given field, eg: --filter field=foo
#### --no-header
hide table header from output
#### --no-truncate
do not truncate output to fit screen
#### --sort SORT
field to sort by (prepend '-' for descending order)
## tag rm &#60;tagKey&#62;
Remove a tag from a fleet, device or release.
@ -1694,6 +1834,30 @@ Examples:
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
#### --filter FILTER
filter results by substring matching of a given field, eg: --filter field=foo
#### --no-header
hide table header from output
#### --no-truncate
do not truncate output to fit screen
#### --sort SORT
field to sort by (prepend '-' for descending order)
## key &#60;id&#62;
Display a single SSH key registered in balenaCloud for the logged in user.
@ -1710,6 +1874,14 @@ balenaCloud ID for the SSH key
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
## key add &#60;name&#62; [path]
Add an SSH key to the balenaCloud account of the logged in user.
@ -2394,10 +2566,6 @@ the path to the config.json file to inject
### Options
#### -t, --type TYPE
ignored - no longer required
#### -d, --drive DRIVE
path to OS image file (e.g. balena.img) or block device (e.g. /dev/disk2)
@ -2418,10 +2586,6 @@ Examples:
### Options
#### -t, --type TYPE
ignored - no longer required
#### -d, --drive DRIVE
path to OS image file (e.g. balena.img) or block device (e.g. /dev/disk2)
@ -2449,10 +2613,6 @@ Examples:
### Options
#### -t, --type TYPE
ignored - no longer required
#### -d, --drive DRIVE
path to OS image file (e.g. balena.img) or block device (e.g. /dev/disk2)
@ -2491,10 +2651,6 @@ the value of the config parameter to write
### Options
#### -t, --type TYPE
ignored - no longer required
#### -d, --drive DRIVE
path to OS image file (e.g. balena.img) or block device (e.g. /dev/disk2)
@ -2843,6 +2999,14 @@ Examples:
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
# Local
## local configure &#60;target&#62;

View File

@ -171,4 +171,5 @@ export default abstract class BalenaCommand extends Command {
protected outputMessage = output.outputMessage;
protected outputData = output.outputData;
protected printTitle = output.printTitle;
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -22,8 +22,10 @@ import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import type { Application, Release } from 'balena-sdk';
import type { DataOutputOptions } from '../../framework';
import { isV14 } from '../../utils/version';
interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string;
@ -42,7 +44,7 @@ interface ExtendedDevice extends DeviceWithDeviceType {
undervoltage_detected?: boolean;
}
interface FlagsDef {
interface FlagsDef extends DataOutputOptions {
help: void;
}
@ -71,13 +73,16 @@ export default class DeviceCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
...(isV14() ? cf.dataOutputFlags : {}),
};
public static authenticated = true;
public static primary = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(DeviceCmd);
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceCmd,
);
const balena = getBalenaSdk();
@ -163,37 +168,52 @@ export default class DeviceCmd extends Command {
);
}
console.log(
getVisuals().table.vertical(device, [
`$${device.device_name}$`,
'id',
'device_type',
'status',
'is_online',
'ip_address',
'public_address',
'mac_address',
'fleet',
'last_seen',
'uuid',
'commit',
'supervisor_version',
'is_web_accessible',
'note',
'os_version',
'dashboard_url',
'cpu_usage_percent',
'cpu_temp_c',
'cpu_id',
'memory_usage_mb',
'memory_total_mb',
'memory_usage_percent',
'storage_block_device',
'storage_usage_mb',
'storage_total_mb',
'storage_usage_percent',
'undervoltage_detected',
]),
);
const outputFields = [
'device_name',
'id',
'device_type',
'status',
'is_online',
'ip_address',
'public_address',
'mac_address',
'fleet',
'last_seen',
'uuid',
'commit',
'supervisor_version',
'is_web_accessible',
'note',
'os_version',
'dashboard_url',
'cpu_usage_percent',
'cpu_temp_c',
'cpu_id',
'memory_usage_mb',
'memory_total_mb',
'memory_usage_percent',
'storage_block_device',
'storage_usage_mb',
'storage_total_mb',
'storage_usage_percent',
'undervoltage_detected',
];
if (isV14()) {
await this.outputData(device, outputFields, {
...options,
hideNullOrUndefinedValues: true,
titleField: 'device_name',
});
} else {
// Old output implementation
outputFields.unshift(`$${device.device_name}$`);
console.log(
getVisuals().table.vertical(
device,
outputFields.filter((f) => f !== 'device_name'),
),
);
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,8 +21,10 @@ import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { applicationIdInfo, jsonInfo } from '../../utils/messages';
import type { Application } from 'balena-sdk';
import type { DataSetOutputOptions } from '../../framework';
import { isV14 } from '../../utils/version';
interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string;
@ -30,10 +32,10 @@ interface ExtendedDevice extends DeviceWithDeviceType {
device_type?: string | null;
}
interface FlagsDef {
interface FlagsDef extends DataSetOutputOptions {
fleet?: string;
help: void;
json: boolean;
json?: boolean;
}
export default class DevicesCmd extends Command {
@ -58,12 +60,11 @@ export default class DevicesCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
fleet: cf.fleet,
json: cf.json,
...(isV14() ? cf.dataSetOutputFlags : { json: cf.json }),
help: cf.help,
};
public static primary = true;
public static authenticated = true;
public async run() {
@ -99,31 +100,52 @@ export default class DevicesCmd extends Command {
return device;
});
const fields = [
'id',
'uuid',
'device_name',
'device_type',
'fleet',
'status',
'is_online',
'supervisor_version',
'os_version',
'dashboard_url',
];
if (isV14()) {
const outputFields = [
'id',
'uuid',
'device_name',
'device_type',
'fleet',
'status',
'is_online',
'supervisor_version',
'os_version',
'dashboard_url',
];
if (options.json) {
const { pickAndRename } = await import('../../utils/helpers');
const mapped = devices.map((device) => pickAndRename(device, fields));
console.log(JSON.stringify(mapped, null, 4));
await this.outputData(devices, outputFields, {
...options,
displayNullValuesAs: 'N/a',
});
} else {
const _ = await import('lodash');
console.log(
getVisuals().table.horizontal(
devices.map((dev) => _.mapValues(dev, (val) => val ?? 'N/a')),
fields,
),
);
// Old output implementation
const fields = [
'id',
'uuid',
'device_name',
'device_type',
'fleet',
'status',
'is_online',
'supervisor_version',
'os_version',
'dashboard_url',
];
if (options.json) {
const { pickAndRename } = await import('../../utils/helpers');
const mapped = devices.map((device) => pickAndRename(device, fields));
console.log(JSON.stringify(mapped, null, 4));
} else {
const _ = await import('lodash');
console.log(
getVisuals().table.horizontal(
devices.map((dev) => _.mapValues(dev, (val) => val ?? 'N/a')),
fields,
),
);
}
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -17,12 +17,14 @@
import { flags } from '@oclif/command';
import * as _ from 'lodash';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
import type { DataSetOutputOptions } from '../../framework';
interface FlagsDef {
import { isV14 } from '../../utils/version';
interface FlagsDef extends DataSetOutputOptions {
help: void;
json?: boolean;
}
@ -51,10 +53,7 @@ export default class DevicesSupportedCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
...(isV14() ? cf.dataSetOutputFlags : { json: cf.json }),
};
public async run() {
@ -70,7 +69,7 @@ export default class DevicesSupportedCmd extends Command {
const configDTsBySlug = _.keyBy(configDTs, (dt) => dt.slug);
interface DT {
slug: string;
aliases: string[];
aliases: string[] | string;
arch: string;
name: string;
}
@ -84,19 +83,25 @@ export default class DevicesSupportedCmd extends Command {
const dt: Partial<typeof dts[0]> = dtsBySlug[slug] || {};
deviceTypes.push({
slug,
aliases: options.json ? aliases : [aliases.join(', ')],
aliases: options.json ? aliases : aliases.join(', '),
arch: (dt.is_of__cpu_architecture as any)?.[0]?.slug || 'n/a',
name: dt.name || 'N/A',
});
}
const fields = ['slug', 'aliases', 'arch', 'name'];
deviceTypes = _.sortBy(deviceTypes, fields);
if (options.json) {
console.log(JSON.stringify(deviceTypes, null, 4));
if (isV14()) {
await this.outputData(deviceTypes, fields, options);
} else {
const visuals = getVisuals();
const output = await visuals.table.horizontal(deviceTypes, fields);
console.log(output);
// Old output implementation
if (options.json) {
console.log(JSON.stringify(deviceTypes, null, 4));
} else {
const visuals = getVisuals();
const output = await visuals.table.horizontal(deviceTypes, fields);
console.log(output);
}
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -22,12 +22,15 @@ import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { applicationIdInfo } from '../utils/messages';
import type { DataSetOutputOptions } from '../framework';
interface FlagsDef {
import { isV14 } from '../utils/version';
interface FlagsDef extends DataSetOutputOptions {
fleet?: string;
config: boolean;
device?: string; // device UUID
json: boolean;
json?: boolean;
help: void;
service?: string; // service name
}
@ -113,7 +116,7 @@ export default class EnvsCmd extends Command {
}),
device: { ...cf.device, exclusive: ['fleet'] },
help: cf.help,
json: cf.json,
...(isV14() ? cf.dataSetOutputFlags : { json: cf.json }),
service: { ...cf.service, exclusive: ['config'] },
};
@ -181,24 +184,59 @@ export default class EnvsCmd extends Command {
return i;
});
if (options.device) {
fields.push(options.json ? 'deviceUUID' : 'deviceUUID => DEVICE');
}
if (!options.config) {
fields.push(options.json ? 'serviceName' : 'serviceName => SERVICE');
}
if (isV14()) {
const results = [...varArray] as any;
if (options.json) {
const { pickAndRename } = await import('../utils/helpers');
const mapped = varArray.map((o) => pickAndRename(o, fields));
this.log(JSON.stringify(mapped, null, 4));
// Rename fields
if (options.device) {
if (options.json) {
fields.push('deviceUUID');
} else {
results.forEach((r: any) => {
r.device = r.deviceUUID;
delete r.deviceUUID;
});
fields.push('device');
}
}
if (!options.config) {
if (options.json) {
fields.push('serviceName');
} else {
results.forEach((r: any) => {
r.service = r.serviceName;
delete r.serviceName;
});
fields.push('service');
}
}
await this.outputData(results, fields, {
...options,
sort: options.sort || 'name',
});
} else {
this.log(
getVisuals().table.horizontal(
_.sortBy(varArray, (v: SDK.EnvironmentVariableBase) => v.name),
fields,
),
);
// Old output implementation
if (options.device) {
fields.push(options.json ? 'deviceUUID' : 'deviceUUID => DEVICE');
}
if (!options.config) {
fields.push(options.json ? 'serviceName' : 'serviceName => SERVICE');
}
if (options.json) {
const { pickAndRename } = await import('../utils/helpers');
const mapped = varArray.map((o) => pickAndRename(o, fields));
this.log(JSON.stringify(mapped, null, 4));
} else {
this.log(
getVisuals().table.horizontal(
_.sortBy(varArray, (v: SDK.EnvironmentVariableBase) => v.name),
fields,
),
);
}
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,10 +20,13 @@ import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { parseAsInteger } from '../../utils/validation';
import type { DataOutputOptions } from '../../framework';
import { isV14 } from '../../utils/version';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
interface FlagsDef {
interface FlagsDef extends DataOutputOptions {
help: void;
}
@ -52,27 +55,52 @@ export default class KeyCmd extends Command {
public static usage = 'key <id>';
public static flags: flags.Input<FlagsDef> = {
...(isV14() ? cf.dataOutputFlags : {}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params } = this.parse<{}, ArgsDef>(KeyCmd);
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
KeyCmd,
);
const key = await getBalenaSdk().models.key.get(params.id);
// Use 'name' instead of 'title' to match dashboard.
const displayKey = {
id: key.id,
name: key.title,
};
if (isV14()) {
// Use 'name' instead of 'title' to match dashboard.
const displayKey = {
id: key.id,
name: key.title,
public_key: key.public_key,
};
console.log(getVisuals().table.vertical(displayKey, ['id', 'name']));
if (!options.json) {
// Id is redundant, since user must have provided it in command call
this.printTitle(displayKey.name);
this.outputMessage(displayKey.public_key);
} else {
await this.outputData(
displayKey,
['id', 'name', 'public_key'],
options,
);
}
} else {
// Old output implementation
// Use 'name' instead of 'title' to match dashboard.
const displayKey = {
id: key.id,
name: key.title,
};
// Since the public key string is long, it might
// wrap to lines below, causing the table layout to break.
// See https://github.com/balena-io/balena-cli/issues/151
console.log('\n' + key.public_key);
console.log(getVisuals().table.vertical(displayKey, ['id', 'name']));
// Since the public key string is long, it might
// wrap to lines below, causing the table layout to break.
// See https://github.com/balena-io/balena-cli/issues/151
console.log('\n' + key.public_key);
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016-2022 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,8 +19,11 @@ import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import type { DataSetOutputOptions } from '../framework';
interface FlagsDef {
import { isV14 } from '../utils/version';
interface FlagsDef extends DataSetOutputOptions {
help: void;
}
@ -35,13 +38,14 @@ export default class KeysCmd extends Command {
public static usage = 'keys';
public static flags: flags.Input<FlagsDef> = {
...(isV14() ? cf.dataSetOutputFlags : {}),
help: cf.help,
};
public static authenticated = true;
public async run() {
this.parse<FlagsDef, {}>(KeysCmd);
const { flags: options } = this.parse<FlagsDef, {}>(KeysCmd);
const keys = await getBalenaSdk().models.key.getAll();
@ -50,6 +54,12 @@ export default class KeysCmd extends Command {
return { id: k.id, name: k.title };
});
console.log(getVisuals().table.horizontal(displayKeys, ['id', 'name']));
// Display
if (isV14()) {
await this.outputData(displayKeys, ['id', 'name'], options);
} else {
// Old output implementation
console.log(getVisuals().table.horizontal(displayKeys, ['id', 'name']));
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016-2022 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,8 +19,11 @@ import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import type { DataSetOutputOptions } from '../framework';
interface FlagsDef {
import { isV14 } from '../utils/version';
interface FlagsDef extends DataSetOutputOptions {
help: void;
}
@ -36,12 +39,13 @@ export default class OrgsCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
...(isV14() ? cf.dataSetOutputFlags : {}),
};
public static authenticated = true;
public async run() {
this.parse<FlagsDef, {}>(OrgsCmd);
const { flags: options } = this.parse<FlagsDef, {}>(OrgsCmd);
const { getOwnOrganizations } = await import('../utils/sdk');
@ -49,8 +53,13 @@ export default class OrgsCmd extends Command {
const organizations = await getOwnOrganizations(getBalenaSdk());
// Display
console.log(
getVisuals().table.horizontal(organizations, ['name', 'handle']),
);
if (isV14()) {
await this.outputData(organizations, ['name', 'handle'], options);
} else {
// Old output implementation
console.log(
getVisuals().table.horizontal(organizations, ['name', 'handle']),
);
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -22,8 +22,11 @@ import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import type * as BalenaSdk from 'balena-sdk';
import jsyaml = require('js-yaml');
import { tryAsInteger } from '../../utils/validation';
import type { DataOutputOptions } from '../../framework';
interface FlagsDef {
import { isV14 } from '../../utils/version';
interface FlagsDef extends DataOutputOptions {
help: void;
composition?: boolean;
}
@ -49,7 +52,9 @@ export default class ReleaseCmd extends Command {
default: false,
char: 'c',
description: 'Return the release composition',
exclusive: ['json', 'fields'],
}),
...(isV14() ? cf.dataOutputFlags : {}),
};
public static args = [
@ -68,29 +73,27 @@ export default class ReleaseCmd extends Command {
ReleaseCmd,
);
const balena = getBalenaSdk();
if (options.composition) {
await this.showComposition(params.commitOrId, balena);
await this.showComposition(params.commitOrId);
} else {
await this.showReleaseInfo(params.commitOrId, balena);
await this.showReleaseInfo(params.commitOrId, options);
}
}
async showComposition(
commitOrId: string | number,
balena: BalenaSdk.BalenaSDK,
) {
const release = await balena.models.release.get(commitOrId, {
async showComposition(commitOrId: string | number) {
const release = await getBalenaSdk().models.release.get(commitOrId, {
$select: 'composition',
});
console.log(jsyaml.dump(release.composition));
if (isV14()) {
this.outputMessage(jsyaml.dump(release.composition));
} else {
// Old output implementation
console.log(jsyaml.dump(release.composition));
}
}
async showReleaseInfo(
commitOrId: string | number,
balena: BalenaSdk.BalenaSDK,
) {
async showReleaseInfo(commitOrId: string | number, options: FlagsDef) {
const fields: Array<keyof BalenaSdk.Release> = [
'id',
'commit',
@ -103,7 +106,7 @@ export default class ReleaseCmd extends Command {
'end_timestamp',
];
const release = await balena.models.release.get(commitOrId, {
const release = await getBalenaSdk().models.release.get(commitOrId, {
$select: fields,
$expand: {
release_tag: {
@ -116,13 +119,28 @@ export default class ReleaseCmd extends Command {
.release_tag!.map((t) => `${t.tag_key}=${t.value}`)
.join('\n');
const _ = await import('lodash');
const values = _.mapValues(
release,
(val) => val ?? 'N/a',
) as Dictionary<string>;
values['tags'] = tagStr;
if (isV14()) {
await this.outputData(
{
tags: tagStr,
...release,
},
fields,
{
displayNullValuesAs: 'N/a',
...options,
},
);
} else {
// Old output implementation
const _ = await import('lodash');
const values = _.mapValues(
release,
(val) => val ?? 'N/a',
) as Dictionary<string>;
values['tags'] = tagStr;
console.log(getVisuals().table.vertical(values, [...fields, 'tags']));
console.log(getVisuals().table.vertical(values, [...fields, 'tags']));
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,8 +21,11 @@ import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { applicationNameNote } from '../utils/messages';
import type * as BalenaSdk from 'balena-sdk';
import type { DataSetOutputOptions } from '../framework';
interface FlagsDef {
import { isV14 } from '../utils/version';
interface FlagsDef extends DataSetOutputOptions {
help: void;
}
@ -43,6 +46,7 @@ export default class ReleasesCmd extends Command {
public static usage = 'releases <fleet>';
public static flags: flags.Input<FlagsDef> = {
...(isV14() ? cf.dataOutputFlags : {}),
help: cf.help,
};
@ -57,7 +61,9 @@ export default class ReleasesCmd extends Command {
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(ReleasesCmd);
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ReleasesCmd,
);
const fields: Array<keyof BalenaSdk.Release> = [
'id',
@ -76,12 +82,20 @@ export default class ReleasesCmd extends Command {
{ $select: fields },
);
const _ = await import('lodash');
console.log(
getVisuals().table.horizontal(
releases.map((rel) => _.mapValues(rel, (val) => val ?? 'N/a')),
fields,
),
);
if (isV14()) {
await this.outputData(releases, fields, {
displayNullValuesAs: 'N/a',
...options,
});
} else {
// Old output implementation
const _ = await import('lodash');
console.log(
getVisuals().table.horizontal(
releases.map((rel) => _.mapValues(rel, (val) => val ?? 'N/a')),
fields,
),
);
}
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,8 +19,11 @@ import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import type { DataOutputOptions } from '../framework';
interface FlagsDef {
import { isV14 } from '../utils/version';
interface FlagsDef extends DataOutputOptions {
help: void;
}
@ -35,15 +38,27 @@ export default class SettingsCmd extends Command {
public static usage = 'settings';
public static flags: flags.Input<FlagsDef> = {
...(isV14() ? cf.dataOutputFlags : {}),
help: cf.help,
};
public async run() {
this.parse<FlagsDef, {}>(SettingsCmd);
const { flags: options } = this.parse<FlagsDef, {}>(SettingsCmd);
const settings = await getBalenaSdk().settings.getAll();
const prettyjson = await import('prettyjson');
console.log(prettyjson.render(settings));
if (isV14()) {
// Select all available fields for display
const fields = Object.keys(settings);
await this.outputData(settings, fields, {
noCapitalizeKeys: true,
...options,
});
} else {
// Old output implementation
const prettyjson = await import('prettyjson');
console.log(prettyjson.render(settings));
}
}
}

View File

@ -20,6 +20,7 @@ import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { parseAsInteger, validateLocalHostnameOrIp } from '../utils/validation';
import * as BalenaSdk from 'balena-sdk';
interface FlagsDef {
port?: number;
@ -127,8 +128,8 @@ export default class SshCmd extends Command {
if (validateLocalHostnameOrIp(params.fleetOrDevice)) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
hostname: params.fleetOrDevice,
port: options.port || 'local',
address: params.fleetOrDevice,
port: options.port,
forceTTY: options.tty,
verbose: options.verbose,
service: params.service,
@ -151,6 +152,12 @@ export default class SshCmd extends Command {
params.fleetOrDevice,
);
const device = await sdk.models.device.get(deviceUuid, {
$select: ['id', 'supervisor_version', 'is_online'],
});
const deviceId = device.id;
const supervisorVersion = device.supervisor_version;
const { which } = await import('../utils/which');
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
@ -202,15 +209,19 @@ export default class SshCmd extends Command {
// that we know exists and is accessible
let containerId: string | undefined;
if (params.service != null) {
const { getContainerIdForService } = await import('../utils/device/ssh');
containerId = await getContainerIdForService({
containerId = await this.getContainerId(
sdk,
deviceUuid,
hostname: `ssh.${proxyUrl}`,
port: options.port || 'cloud',
proxyCommand,
service: params.service,
username: username!,
});
params.service,
{
port: options.port,
proxyCommand,
proxyUrl: proxyUrl || '',
username: username!,
},
supervisorVersion,
deviceId,
);
}
let accessCommand: string;
@ -219,14 +230,158 @@ export default class SshCmd extends Command {
} else {
accessCommand = `host ${deviceUuid}`;
}
const { runRemoteCommand } = await import('../utils/ssh');
await runRemoteCommand({
cmd: accessCommand,
hostname: `ssh.${proxyUrl}`,
port: options.port || 'cloud',
proxyCommand,
username,
const command = this.generateVpnSshCommand({
uuid: deviceUuid,
command: accessCommand,
verbose: options.verbose,
port: options.port,
proxyCommand,
proxyUrl: proxyUrl || '',
username: username!,
});
const { spawnSshAndThrowOnError } = await import('../utils/ssh');
return spawnSshAndThrowOnError(command);
}
async getContainerId(
sdk: BalenaSdk.BalenaSDK,
uuid: string,
serviceName: string,
sshOpts: {
port?: number;
proxyCommand?: string[];
proxyUrl: string;
username: string;
},
version?: string,
id?: number,
): Promise<string> {
const semver = await import('balena-semver');
if (version == null || id == null) {
const device = await sdk.models.device.get(uuid, {
$select: ['id', 'supervisor_version'],
});
version = device.supervisor_version;
id = device.id;
}
let containerId: string | undefined;
if (semver.gte(version, '8.6.0')) {
const apiUrl = await sdk.settings.get('apiUrl');
// TODO: Move this into the SDKs device model
const request = await sdk.request.send({
method: 'POST',
url: '/supervisor/v2/containerId',
baseUrl: apiUrl,
body: {
method: 'GET',
deviceId: id,
},
});
if (request.status !== 200) {
throw new Error(
`There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`,
);
}
const body = request.body;
if (body.status !== 'success') {
throw new Error(
`There was an error communicating with device ${uuid}.\n\tError: ${body.message}`,
);
}
containerId = body.services[serviceName];
} else {
console.error(stripIndent`
Using legacy method to detect container ID. This will be slow.
To speed up this process, please update your device to an OS
which has a supervisor version of at least v8.6.0.
`);
// We need to execute a balena ps command on the device,
// and parse the output, looking for a specific
// container
const childProcess = await import('child_process');
const { escapeRegExp } = await import('lodash');
const { which } = await import('../utils/which');
const { deviceContainerEngineBinary } = await import(
'../utils/device/ssh'
);
const sshBinary = await which('ssh');
const sshArgs = this.generateVpnSshCommand({
uuid,
verbose: false,
port: sshOpts.port,
command: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
proxyCommand: sshOpts.proxyCommand,
proxyUrl: sshOpts.proxyUrl,
username: sshOpts.username,
});
if (process.env.DEBUG) {
console.error(`[debug] [${sshBinary}, ${sshArgs.join(', ')}]`);
}
const subProcess = childProcess.spawn(sshBinary, sshArgs, {
stdio: [null, 'pipe', null],
});
const containers = await new Promise<string>((resolve, reject) => {
const output: string[] = [];
subProcess.stdout.on('data', (chunk) => output.push(chunk.toString()));
subProcess.on('close', (code: number) => {
if (code !== 0) {
reject(
new Error(
`Non-zero error code when looking for service container: ${code}`,
),
);
} else {
resolve(output.join(''));
}
});
});
const lines = containers.split('\n');
const regex = new RegExp(`\\/?${escapeRegExp(serviceName)}_\\d+_\\d+`);
for (const container of lines) {
const [cId, name] = container.split(' ');
if (regex.test(name)) {
containerId = cId;
break;
}
}
}
if (containerId == null) {
throw new Error(
`Could not find a service ${serviceName} on device ${uuid}.`,
);
}
return containerId;
}
generateVpnSshCommand(opts: {
uuid: string;
command: string;
verbose: boolean;
port?: number;
username: string;
proxyUrl: string;
proxyCommand?: string[];
}) {
return [
...(opts.verbose ? ['-vvv'] : []),
'-t',
...['-o', 'LogLevel=ERROR'],
...['-o', 'StrictHostKeyChecking=no'],
...['-o', 'UserKnownHostsFile=/dev/null'],
...(opts.proxyCommand && opts.proxyCommand.length
? ['-o', `ProxyCommand=${opts.proxyCommand.join(' ')}`]
: []),
...(opts.port ? ['-p', opts.port.toString()] : []),
`${opts.username}@ssh.${opts.proxyUrl}`,
opts.command,
];
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
* Copyright 2016 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,8 +21,12 @@ import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { applicationIdInfo } from '../utils/messages';
import type { ApplicationTag, DeviceTag, ReleaseTag } from 'balena-sdk';
import type { DataSetOutputOptions } from '../framework';
interface FlagsDef {
import { isV14 } from '../utils/version';
interface FlagsDef extends DataSetOutputOptions {
fleet?: string;
device?: string;
release?: string;
@ -61,6 +65,7 @@ export default class TagsCmd extends Command {
...cf.release,
exclusive: ['fleet', 'device'],
},
...(isV14() ? cf.dataSetOutputFlags : {}),
help: cf.help,
};
@ -78,7 +83,7 @@ export default class TagsCmd extends Command {
const { tryAsInteger } = await import('../utils/validation');
let tags;
let tags: ApplicationTag[] | DeviceTag[] | ReleaseTag[] = [];
if (options.fleet) {
const { getFleetSlug } = await import('../utils/sdk');
@ -103,11 +108,17 @@ export default class TagsCmd extends Command {
tags = await balena.models.release.tags.getAllByRelease(releaseParam);
}
if (!tags || tags.length === 0) {
if (tags.length === 0 && !options.json) {
// TODO: Later change to output message
throw new ExpectedError('No tags found');
}
console.log(getVisuals().table.horizontal(tags, ['tag_key', 'value']));
if (isV14()) {
await this.outputData(tags, ['tag_key', 'value'], options);
} else {
// Old output implementation
console.log(getVisuals().table.horizontal(tags, ['tag_key', 'value']));
}
}
protected missingResourceMessage = stripIndent`

View File

@ -136,7 +136,8 @@ export default class TunnelCmd extends Command {
// Ascertain device uuid
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const uuid = await getOnlineTargetDeviceUuid(sdk, params.deviceOrFleet);
logger.logInfo(`Opening a tunnel to ${uuid}...`);
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)
@ -146,7 +147,11 @@ export default class TunnelCmd extends Command {
.map(async ({ localPort, localAddress, remotePort }) => {
try {
const { tunnelConnectionToDevice } = await import('../utils/tunnel');
const handler = await tunnelConnectionToDevice(uuid, remotePort, sdk);
const handler = await tunnelConnectionToDevice(
device.uuid,
remotePort,
sdk,
);
const { createServer } = await import('net');
const server = createServer(async (client: Socket) => {
@ -157,7 +162,7 @@ export default class TunnelCmd extends Command {
client.remotePort || 0,
client.localAddress,
client.localPort,
uuid,
device.vpn_address || '',
remotePort,
);
} catch (err) {
@ -166,7 +171,7 @@ export default class TunnelCmd extends Command {
client.remotePort || 0,
client.localAddress,
client.localPort,
uuid,
device.vpn_address || '',
remotePort,
err,
);
@ -181,15 +186,15 @@ export default class TunnelCmd extends Command {
});
logger.logInfo(
` - tunnelling ${localAddress}:${localPort} to ${uuid}:${remotePort}`,
` - tunnelling ${localAddress}:${localPort} to ${device.uuid}:${remotePort}`,
);
return true;
} catch (err) {
logger.logWarn(
` - not tunnelling ${localAddress}:${localPort} to ${uuid}:${remotePort}, failed ${JSON.stringify(
err.message,
)}`,
` - not tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
);
return false;

View File

@ -16,7 +16,12 @@
*/
import * as packageJSON from '../package.json';
import { stripIndent } from './utils/lazy';
import { getBalenaSdk, stripIndent } from './utils/lazy';
interface CachedUsername {
token: string;
username: string;
}
/**
* Track balena CLI usage events (product improvement analytics).
@ -44,13 +49,40 @@ export async function trackCommand(commandSignature: string) {
scope.setExtra('command', commandSignature);
});
}
const { getCachedUsername } = await import('./utils/bootstrap');
let username: string | undefined;
try {
username = (await getCachedUsername())?.username;
} catch {
// ignore
}
const settings = await import('balena-settings-client');
const username = await (async () => {
const getStorage = await import('balena-settings-storage');
const dataDirectory = settings.get<string>('dataDirectory');
const storage = getStorage({ dataDirectory });
let token;
try {
token = await storage.get('token');
} catch {
// If we can't get a token then we can't get a username
return;
}
try {
const result = (await storage.get('cachedUsername')) as CachedUsername;
if (result.token === token) {
return result.username;
}
} catch {
// ignore
}
try {
const balena = getBalenaSdk();
const $username = await balena.auth.whoami();
await storage.set('cachedUsername', {
token,
username: $username,
} as CachedUsername);
return $username;
} catch {
return;
}
})();
if (!process.env.BALENARC_NO_SENTRY) {
Sentry!.configureScope((scope) => {
scope.setUser({
@ -64,7 +96,6 @@ export async function trackCommand(commandSignature: string) {
!process.env.BALENA_CLI_TEST_TYPE &&
!process.env.BALENARC_NO_ANALYTICS
) {
const settings = await import('balena-settings-client');
const balenaUrl = settings.get<string>('balenaUrl');
await sendEvent(balenaUrl, `[CLI] ${commandSignature}`, username);
}

View File

@ -1,26 +1,35 @@
/*
Copyright 2020 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.
*/
/**
* @license
* Copyright 2020 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 { getCliUx, getChalk } from '../utils/lazy';
/**
* Used to extend FlagsDef for commands that output single-record data.
* Exposed to user in command options.
*/
export interface DataOutputOptions {
fields?: string;
json?: boolean;
}
/**
* Used to extend FlagsDef for commands that output multi-record data.
* Exposed to user in command options.
*/
export interface DataSetOutputOptions extends DataOutputOptions {
filter?: string;
'no-header'?: boolean;
@ -28,6 +37,14 @@ export interface DataSetOutputOptions extends DataOutputOptions {
sort?: string;
}
// Not exposed to user
export interface InternalOutputOptions {
displayNullValuesAs?: string;
hideNullOrUndefinedValues?: boolean;
titleField?: string;
noCapitalizeKeys?: boolean;
}
/**
* Output message to STDERR
*/
@ -49,7 +66,7 @@ export function outputMessage(msg: string) {
export async function outputData(
data: any[] | {},
fields: string[],
options: DataOutputOptions | DataSetOutputOptions,
options: (DataOutputOptions | DataSetOutputOptions) & InternalOutputOptions,
) {
if (Array.isArray(data)) {
await outputDataSet(data, fields, options as DataSetOutputOptions);
@ -68,7 +85,7 @@ export async function outputData(
async function outputDataSet(
data: any[],
fields: string[],
options: DataSetOutputOptions,
options: DataSetOutputOptions & InternalOutputOptions,
) {
// Oclif expects fields to be specified in the format used in table headers (though lowercase)
// By replacing underscores with spaces here, we can support both header format and actual field name
@ -77,6 +94,12 @@ async function outputDataSet(
options.filter = options.filter?.replace(/_/g, ' ');
options.sort = options.sort?.replace(/_/g, ' ');
if (!options.json) {
data = data.map((d) => {
return processNullValues(d, options);
});
}
getCliUx().table(
data,
// Convert fields array to column object keys
@ -97,7 +120,7 @@ async function outputDataSet(
}
/**
* Outputs a single data object (like `resin-cli-visuals table.vertical`),
* Outputs a single data object (similar to `resin-cli-visuals table.vertical`),
* but supporting a subset of options from `cli-ux table` (--json and --fields)
*
* @param data Array of data objects to output
@ -107,9 +130,9 @@ async function outputDataSet(
async function outputDataItem(
data: any,
fields: string[],
options: DataOutputOptions,
options: DataOutputOptions & InternalOutputOptions,
) {
const outData: typeof data = {};
let outData: typeof data = {};
// Convert comma separated list of fields in `options.fields` to array of correct format.
// Note, user may have specified the true field name (e.g. `some_field`),
@ -125,30 +148,83 @@ async function outputDataItem(
}
});
if (
(options.displayNullValuesAs || options.hideNullOrUndefinedValues) &&
!options.json
) {
outData = processNullValues(outData, options);
}
if (options.json) {
printLine(JSON.stringify(outData, undefined, 2));
} else {
const chalk = getChalk();
const { capitalize } = await import('lodash');
// Find longest key, so we can align results
const longestKeyLength = getLongestObjectKeyLength(outData);
if (options.titleField) {
printTitle(data[options.titleField as keyof any[]], options);
}
// Output one field per line
for (const [k, v] of Object.entries(outData)) {
for (let [k, v] of Object.entries(outData)) {
const shim = ' '.repeat(longestKeyLength - k.length);
const kDisplay = capitalize(k.replace(/_/g, ' '));
printLine(`${chalk.bold(kDisplay) + shim} : ${v}`);
let kDisplay = k.replace(/_/g, ' ');
// Start multiline values on the line below the field name
if (typeof v === 'string' && v.includes('\n')) {
v = `\n${v}`;
}
if (!options.noCapitalizeKeys) {
kDisplay = capitalize(kDisplay);
}
if (k !== options.titleField) {
printLine(` ${bold(kDisplay) + shim} : ${v}`);
}
}
}
}
function getLongestObjectKeyLength(o: any): number {
return Object.keys(o).length >= 1
? Object.keys(o).reduce((a, b) => {
return a.length > b.length ? a : b;
}).length
: 0;
/**
* Amend null/undefined values in data as per options:
* - options.displayNullValuesAs will replace the value with the specified string
* - options.hideNullOrUndefinedValues will remove the property from the data
*
* @param data The data object to process
* @param options Output options
*
* @returns a copy of the data with amended values.
*/
function processNullValues(data: any, options: InternalOutputOptions) {
const dataCopy = { ...data };
Object.entries(dataCopy).forEach(([k, v]) => {
if (v == null) {
if (options.displayNullValuesAs) {
dataCopy[k] = options.displayNullValuesAs;
} else if (options.hideNullOrUndefinedValues) {
delete dataCopy[k];
}
}
});
return dataCopy;
}
/**
* Print a title with underscore
*
* @param title The title string to print
* @param options Output options
*/
export function printTitle(
title: string,
options?: InternalOutputOptions & DataSetOutputOptions,
) {
if (!options?.['no-header']) {
printLine(` ${capitalize(bold(title))}`);
printLine(` ${bold('─'.repeat(title.length))}`);
}
}
function printLine(s: any) {
@ -156,3 +232,15 @@ function printLine(s: any) {
// but using this one explicitly for ease of testing
process.stdout.write(s + '\n');
}
function capitalize(s: string) {
return `${s[0].toUpperCase()}${s.slice(1)}`;
}
function bold(s: string) {
return getChalk().bold(s);
}
function getLongestObjectKeyLength(o: any): number {
return Math.max(0, ...Object.keys(o).map((k) => k.length));
}

View File

@ -53,6 +53,12 @@ export async function preparseArgs(argv: string[]): Promise<string[]> {
if (extractBooleanFlag(cmdSlice, '--debug')) {
process.env.DEBUG = '1';
}
// support global --v-next flag
if (extractBooleanFlag(cmdSlice, '--v-next')) {
const { version } = await import('../package.json');
const { inc } = await import('semver');
process.env.BALENA_CLI_VERSION_OVERRIDE = inc(version, 'major') || '';
}
unsupportedFlag = extractBooleanFlag(cmdSlice, '--unsupported');
}

View File

@ -119,61 +119,3 @@ export async function pkgExec(modFunc: string, args: string[]) {
console.error(err);
}
}
export interface CachedUsername {
token: string;
username: string;
}
let cachedUsername: CachedUsername | undefined;
/**
* Return the parsed contents of the `~/.balena/cachedUsername` file. If the file
* does not exist, create it with the details from the cloud. If not connected
* to the internet, return undefined. This function is used by `lib/events.ts`
* (event tracking) and `lib/utils/device/ssh.ts` and needs to gracefully handle
* the scenario of not being connected to the internet.
*/
export async function getCachedUsername(): Promise<CachedUsername | undefined> {
if (cachedUsername) {
return cachedUsername;
}
const [{ getBalenaSdk }, getStorage, settings] = await Promise.all([
import('./lazy'),
import('balena-settings-storage'),
import('balena-settings-client'),
]);
const dataDirectory = settings.get<string>('dataDirectory');
const storage = getStorage({ dataDirectory });
let token: string | undefined;
try {
token = (await storage.get('token')) as string | undefined;
} catch {
// ignore
}
if (!token) {
// If we can't get a token then we can't get a username
return;
}
try {
const result = (await storage.get('cachedUsername')) as
| CachedUsername
| undefined;
if (result && result.token === token && result.username) {
cachedUsername = result;
return cachedUsername;
}
} catch {
// ignore
}
try {
const username = await getBalenaSdk().auth.whoami();
if (username) {
cachedUsername = { token, username };
await storage.set('cachedUsername', cachedUsername);
}
} catch {
// ignore (not connected to the internet?)
}
return cachedUsername;
}

View File

@ -13,140 +13,89 @@ 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 { ExpectedError } from '../../errors';
import type { ContainerInfo } from 'dockerode';
import { stripIndent } from '../lazy';
import {
findBestUsernameForDevice,
getRemoteCommandOutput,
runRemoteCommand,
SshRemoteCommandOpts,
} from '../ssh';
export interface DeviceSSHOpts extends SshRemoteCommandOpts {
export interface DeviceSSHOpts {
address: string;
port?: number;
forceTTY?: boolean;
verbose: boolean;
service?: string;
}
const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
/**
* List the running containers on the device over ssh, and return the full
* container name that matches the given service name.
*
* Note: In the past, two other approaches were implemented for this function:
*
* - Obtaining container IDs through a supervisor API call:
* '/supervisor/v2/containerId' endpoint, via cloud.
* - Obtaining container IDs using 'dockerode' connected directly to
* balenaEngine on a device, TCP port 2375.
*
* The problem with using the supervisor API is that it means that 'balena ssh'
* becomes dependent on the supervisor being up an running, but sometimes ssh
* is needed to investigate devices issues where the supervisor has got into
* trouble (e.g. supervisor in restart loop). This is the subject of CLI issue
* https://github.com/balena-io/balena-cli/issues/1560 .
*
* The problem with using dockerode to connect directly to port 2375 (balenaEngine)
* is that it only works with development variants of balenaOS. Production variants
* block access to port 2375 for security reasons. 'balena ssh' should support
* production variants as well, especially after balenaOS v2.44.0 that introduced
* support for using the cloud account username for ssh authentication.
*
* Overall, the most reliable approach is to run 'balena-engine ps' over ssh.
* It is OK to depend on balenaEngine because ssh to a container is implemented
* through 'balena-engine exec' anyway, and of course it is OK to depend on ssh
* itself.
*/
export async function getContainerIdForService(
opts: SshRemoteCommandOpts & { service: string; deviceUuid?: string },
): Promise<string> {
opts.cmd = `"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`;
if (opts.deviceUuid) {
// If a device UUID is given, perform ssh via cloud proxy 'host' command
opts.cmd = `host ${opts.deviceUuid} ${opts.cmd}`;
}
const psLines: string[] = (
await getRemoteCommandOutput({ ...opts, stderr: 'inherit' })
).stdout
.toString()
.split('\n')
.filter((l) => l);
const { escapeRegExp } = await import('lodash');
const regex = new RegExp(`(?:^|\\/)${escapeRegExp(opts.service)}_\\d+_\\d+`);
// Old balenaOS container name pattern:
// main_1234567_2345678
// New balenaOS container name patterns:
// main_1234567_2345678_a000b111c222d333e444f555a666b777
// main_1_1_localrelease
const nameRegex = /(?:^|\/)([a-zA-Z0-9_-]+)_\d+_\d+(?:_.+)?$/;
const serviceNames: string[] = [];
const containerNames: string[] = [];
let containerId: string | undefined;
// sample psLine: 'b603c74e951e bar_4587562_2078151_3261c9d4c22f2c53a5267be459c89990'
for (const psLine of psLines) {
const [cId, name] = psLine.split(' ');
if (cId && name) {
if (regex.test(name)) {
containerNames.push(name);
containerId = cId;
}
const match = name.match(nameRegex);
if (match) {
serviceNames.push(match[1]);
}
}
}
if (containerNames.length > 1) {
const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
throw new ExpectedError(stripIndent`
Found more than one container matching service name "${s}" on device "${d}":
${containerNames.join(', ')}
Use different service names to avoid ambiguity.
`);
}
if (!containerId) {
const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
throw new ExpectedError(
`Could not find a container matching service name "${s}" on device "${d}".${
serviceNames.length > 0
? `\nAvailable services:\n\t${serviceNames.join('\n\t')}`
: ''
}`,
);
}
return containerId;
}
export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
export async function performLocalDeviceSSH(
opts: DeviceSSHOpts,
): Promise<void> {
// Before we started using `findBestUsernameForDevice`, we tried the approach
// of attempting ssh with the 'root' username first and, if that failed, then
// attempting ssh with a regular user (balenaCloud username). The problem with
// that approach was that it would print the following message to the console:
// "root@192.168.1.36: Permission denied (publickey)"
// ... right before having success as a regular user, which looked broken or
// confusing from users' point of view. Capturing stderr to prevent that
// message from being printed is tricky because the messages printed to stderr
// may include the stderr output of remote commands that are of interest to
// the user.
const username = await findBestUsernameForDevice(opts.hostname, opts.port);
let cmd = '';
const { escapeRegExp, reduce } = await import('lodash');
const { spawnSshAndThrowOnError } = await import('../ssh');
const { ExpectedError } = await import('../../errors');
if (opts.service) {
const containerId = await getContainerIdForService({
...opts,
service: opts.service,
username,
let command = '';
if (opts.service != null) {
// Get the containers which are on-device. Currently we
// are single application, which means we can assume any
// container which fulfills the form of
// $serviceName_$appId_$releaseId is what we want. Once
// we have multi-app, we should show a dialog which
// allows the user to choose the correct container
const Docker = await import('dockerode');
const docker = new Docker({
host: opts.address,
port: 2375,
});
const regex = new RegExp(`(^|\\/)${escapeRegExp(opts.service)}_\\d+_\\d+`);
const nameRegex = /\/?([a-zA-Z0-9_-]+)_\d+_\d+/;
let allContainers: ContainerInfo[];
try {
allContainers = await docker.listContainers();
} catch (_e) {
throw new ExpectedError(stripIndent`
Could not access docker daemon on device ${opts.address}.
Please ensure the device is in local mode.`);
}
const serviceNames: string[] = [];
const containers: Array<{ id: string; name: string }> = [];
for (const container of allContainers) {
for (const name of container.Names) {
if (regex.test(name)) {
containers.push({ id: container.Id, name });
break;
}
const match = name.match(nameRegex);
if (match) {
serviceNames.push(match[1]);
}
}
}
if (containers.length === 0) {
throw new ExpectedError(
`Could not find a service on device with name ${opts.service}. ${
serviceNames.length > 0
? `Available services:\n${reduce(
serviceNames,
(str, name) => `${str}\t${name}\n`,
'',
)}`
: ''
}`,
);
}
if (containers.length > 1) {
throw new ExpectedError(stripIndent`
Found more than one container matching service name "${opts.service}":
${containers.map((container) => container.name).join(', ')}
Use different service names to avoid ambiguity.
`);
}
const containerId = containers[0].id;
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
// stdin (fd=0) is not a tty when data is piped in, for example
// echo 'ls -la; exit;' | balena ssh 192.168.0.20 service1
@ -154,8 +103,17 @@ export async function performLocalDeviceSSH(
// https://assets.balena.io/newsletter/2020-01/pipe.png
const isTTY = !!opts.forceTTY || (await import('tty')).isatty(0);
const ttyFlag = isTTY ? '-t' : '';
cmd = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
command = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
}
await runRemoteCommand({ ...opts, cmd, username });
return spawnSshAndThrowOnError([
...(opts.verbose ? ['-vvv'] : []),
'-t',
...['-p', opts.port ? opts.port.toString() : '22222'],
...['-o', 'LogLevel=ERROR'],
...['-o', 'StrictHostKeyChecking=no'],
...['-o', 'UserKnownHostsFile=/dev/null'],
`root@${opts.address}`,
...(command ? [command] : []),
]);
}

View File

@ -174,8 +174,14 @@ export async function isBalenaEngine(docker: dockerode): Promise<boolean> {
);
}
export interface ExtendedDockerOptions extends dockerode.DockerOptions {
docker?: string; // socket path, e.g. /var/run/docker.sock
dockerHost?: string; // host name or IP address
dockerPort?: number; // TCP port number, e.g. 2375
}
export async function getDocker(
options: DockerConnectionCliFlags,
options: ExtendedDockerOptions,
): Promise<dockerode> {
const connectOpts = await generateConnectOpts(options);
const client = await createClient(connectOpts);
@ -190,18 +196,14 @@ export async function createClient(
return new Docker(opts);
}
/**
* Initialize Docker connection options with the default values from the
* 'docker-modem' package, which takes several env vars into account,
* including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK
* https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70
*
* @param opts Command line options like --dockerHost and --dockerPort
*/
export function getDefaultDockerModemOpts(
opts: DockerConnectionCliFlags,
): dockerode.DockerOptions {
const connectOpts: dockerode.DockerOptions = {};
async function generateConnectOpts(opts: ExtendedDockerOptions) {
let connectOpts: dockerode.DockerOptions = {};
// Start with docker-modem defaults which take several env vars into account,
// including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK
// https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70
const Modem = require('docker-modem');
const defaultOpts = new Modem();
const optsOfInterest: Array<keyof dockerode.DockerOptions> = [
'ca',
'cert',
@ -213,33 +215,9 @@ export function getDefaultDockerModemOpts(
'username',
'timeout',
];
const Modem = require('docker-modem');
const originalDockerHost = process.env.DOCKER_HOST;
try {
if (opts.dockerHost) {
process.env.DOCKER_HOST ||= opts.dockerPort
? `${opts.dockerHost}:${opts.dockerPort}`
: opts.dockerHost;
}
const defaultOpts = new Modem();
for (const opt of optsOfInterest) {
connectOpts[opt] = defaultOpts[opt];
}
} finally {
// Did you know? Any value assigned to `process.env.XXX` becomes a string.
// For example, `process.env.DOCKER_HOST = undefined` results in
// value 'undefined' (a 9-character string) being assigned.
if (originalDockerHost) {
process.env.DOCKER_HOST = originalDockerHost;
} else {
delete process.env.DOCKER_HOST;
}
for (const opt of optsOfInterest) {
connectOpts[opt] = defaultOpts[opt];
}
return connectOpts;
}
export async function generateConnectOpts(opts: DockerConnectionCliFlags) {
let connectOpts = getDefaultDockerModemOpts(opts);
// Now override the default options with any explicit command line options
if (opts.docker != null && opts.dockerHost == null) {
@ -263,9 +241,9 @@ export async function generateConnectOpts(opts: DockerConnectionCliFlags) {
// These should be file paths (strings)
const tlsOpts = [opts.ca, opts.cert, opts.key];
// If any tlsOpts are set...
// If any are set...
if (tlsOpts.some((opt) => opt)) {
// but not all
// but not all ()
if (!tlsOpts.every((opt) => opt)) {
throw new ExpectedError(
'You must provide a CA, certificate and key in order to use TLS',
@ -280,11 +258,7 @@ export async function generateConnectOpts(opts: DockerConnectionCliFlags) {
const [ca, cert, key] = await Promise.all(
tlsOpts.map((opt: string) => fs.readFile(opt, 'utf8')),
);
// Also ensure that the protocol is 'https' like 'docker-modem' does:
// https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L101-L103
// TODO: delete redundant logic from this function now that similar logic
// exists in the 'docker-modem' package.
connectOpts = { ...connectOpts, ca, cert, key, protocol: 'https' };
connectOpts = { ...connectOpts, ca, cert, key };
}
return connectOpts;

View File

@ -20,7 +20,7 @@ import { ExpectedError, printErrorMessage } from '../errors';
import { getVisuals, stripIndent, getCliForm } from './lazy';
import Logger = require('./logger');
import { confirm } from './patterns';
import { getLocalDeviceCmdStdout, getDeviceOsRelease } from './ssh';
import { exec, execBuffered, getDeviceOsRelease } from './ssh';
const MIN_BALENAOS_VERSION = 'v2.14.0';
@ -88,25 +88,20 @@ async function execCommand(
cmd: string,
msg: string,
): Promise<void> {
const { Writable } = await import('stream');
const through = await import('through2');
const visuals = getVisuals();
const spinner = new visuals.Spinner(`[${deviceIp}] Connecting...`);
const innerSpinner = spinner.spinner;
const stream = new Writable({
write(_chunk: Buffer, _enc, callback) {
innerSpinner.setSpinnerTitle(`%s [${deviceIp}] ${msg}`);
callback();
},
const stream = through(function (data, _enc, cb) {
innerSpinner.setSpinnerTitle(`%s [${deviceIp}] ${msg}`);
cb(null, data);
});
spinner.start();
try {
await getLocalDeviceCmdStdout(deviceIp, cmd, stream);
} finally {
spinner.stop();
}
await exec(deviceIp, cmd, stream);
spinner.stop();
}
async function configure(deviceIp: string, config: any): Promise<void> {
@ -126,7 +121,7 @@ async function deconfigure(deviceIp: string): Promise<void> {
async function assertDeviceIsCompatible(deviceIp: string): Promise<void> {
const cmd = 'os-config --version';
try {
await getLocalDeviceCmdStdout(deviceIp, cmd);
await execBuffered(deviceIp, cmd);
} catch (err) {
if (err instanceof ExpectedError) {
throw err;

View File

@ -16,314 +16,147 @@
*/
import { spawn, StdioOptions } from 'child_process';
import * as _ from 'lodash';
import { TypedError } from 'typed-error';
import { ExpectedError } from '../errors';
export class SshPermissionDeniedError extends ExpectedError {}
export class ExecError extends TypedError {
public cmd: string;
public exitCode: number;
export class RemoteCommandError extends ExpectedError {
cmd: string;
exitCode?: number;
exitSignal?: NodeJS.Signals;
constructor(cmd: string, exitCode?: number, exitSignal?: NodeJS.Signals) {
super(sshErrorMessage(cmd, exitSignal, exitCode));
constructor(cmd: string, exitCode: number) {
super(`Command '${cmd}' failed with error: ${exitCode}`);
this.cmd = cmd;
this.exitCode = exitCode;
this.exitSignal = exitSignal;
}
}
export interface SshRemoteCommandOpts {
cmd?: string;
hostname: string;
ignoreStdin?: boolean;
port?: number | 'cloud' | 'local';
proxyCommand?: string[];
username?: string;
verbose?: boolean;
}
export const stdioIgnore: {
stdin: 'ignore';
stdout: 'ignore';
stderr: 'ignore';
} = {
stdin: 'ignore',
stdout: 'ignore',
stderr: 'ignore',
};
export function sshArgsForRemoteCommand({
cmd = '',
hostname,
ignoreStdin = false,
port,
proxyCommand,
username = 'root',
verbose = false,
}: SshRemoteCommandOpts): string[] {
port = port === 'local' ? 22222 : port === 'cloud' ? 22 : port;
return [
...(verbose ? ['-vvv'] : []),
...(ignoreStdin ? ['-n'] : []),
'-t',
...(port ? ['-p', port.toString()] : []),
...['-o', 'LogLevel=ERROR'],
...['-o', 'StrictHostKeyChecking=no'],
...['-o', 'UserKnownHostsFile=/dev/null'],
...(proxyCommand && proxyCommand.length
? ['-o', `ProxyCommand=${proxyCommand.join(' ')}`]
: []),
`${username}@${hostname}`,
...(cmd ? [cmd] : []),
];
}
/**
* Execute the given command on a local balenaOS device over ssh.
* @param cmd Shell command to execute on the device
* @param hostname Device's hostname or IP address
* @param port SSH server TCP port number or 'local' (22222) or 'cloud' (22)
* @param stdin Readable stream to pipe to the remote command stdin,
* or 'ignore' or 'inherit' as documented in the child_process.spawn function.
* @param stdout Writeable stream to pipe from the remote command stdout,
* or 'ignore' or 'inherit' as documented in the child_process.spawn function.
* @param stderr Writeable stream to pipe from the remote command stdout,
* or 'ignore' or 'inherit' as documented in the child_process.spawn function.
* @param username SSH username for authorization. With balenaOS 2.44.0 or
* later, it can be a balenaCloud username.
* @param verbose Produce debugging output
*/
export async function runRemoteCommand({
cmd = '',
hostname,
port,
proxyCommand,
stdin = 'inherit',
stdout = 'inherit',
stderr = 'inherit',
username = 'root',
verbose = false,
}: SshRemoteCommandOpts & {
stdin?: 'ignore' | 'inherit' | NodeJS.ReadableStream;
stdout?: 'ignore' | 'inherit' | NodeJS.WritableStream;
stderr?: 'ignore' | 'inherit' | NodeJS.WritableStream;
}): Promise<void> {
let ignoreStdin: boolean;
if (stdin === 'ignore') {
// Set ignoreStdin=true in order for the "ssh -n" option to be used to
// prevent the ssh client from using the CLI process stdin. In addition,
// stdin must be forced to 'inherit' (if it is not a readable stream) in
// order to work around a bug in older versions of the built-in Windows
// 10 ssh client that otherwise prints the following to stderr and
// hangs: "GetConsoleMode on STD_INPUT_HANDLE failed with 6"
// They actually fixed the bug in newer versions of the ssh client:
// https://github.com/PowerShell/Win32-OpenSSH/issues/856 but users
// have to manually download and install a new client.
ignoreStdin = true;
stdin = 'inherit';
} else {
ignoreStdin = false;
}
export async function exec(
deviceIp: string,
cmd: string,
stdout?: NodeJS.WritableStream,
): Promise<void> {
const { which } = await import('./which');
const program = await which('ssh');
const args = sshArgsForRemoteCommand({
const args = [
'-n',
'-t',
'-p',
'22222',
'-o',
'LogLevel=ERROR',
'-o',
'StrictHostKeyChecking=no',
'-o',
'UserKnownHostsFile=/dev/null',
`root@${deviceIp}`,
cmd,
hostname,
ignoreStdin,
port,
proxyCommand,
username,
verbose,
});
];
if (process.env.DEBUG) {
const logger = (await import('./logger')).getLogger();
logger.logDebug(`Executing [${program},${args}]`);
}
// Note: stdin must be 'inherit' to workaround a bug in older versions of
// the built-in Windows 10 ssh client that otherwise prints the following
// to stderr and hangs: "GetConsoleMode on STD_INPUT_HANDLE failed with 6"
// They fixed the bug in newer versions of the ssh client:
// https://github.com/PowerShell/Win32-OpenSSH/issues/856
// but users whould have to manually download and install a new client.
// Note that "ssh -n" does not solve the problem, but should in theory
// prevent the ssh client from using the CLI process stdin, even if it
// is connected with 'inherit'.
const stdio: StdioOptions = [
typeof stdin === 'string' ? stdin : 'pipe',
typeof stdout === 'string' ? stdout : 'pipe',
typeof stderr === 'string' ? stderr : 'pipe',
'inherit',
stdout ? 'pipe' : 'inherit',
'inherit',
];
let exitCode: number | undefined;
let exitSignal: NodeJS.Signals | undefined;
try {
[exitCode, exitSignal] = await new Promise<[number, NodeJS.Signals]>(
(resolve, reject) => {
const ps = spawn(program, args, { stdio })
.on('error', reject)
.on('close', (code, signal) => resolve([code, signal]));
if (ps.stdin && stdin && typeof stdin !== 'string') {
stdin.pipe(ps.stdin);
}
if (ps.stdout && stdout && typeof stdout !== 'string') {
ps.stdout.pipe(stdout);
}
if (ps.stderr && stderr && typeof stderr !== 'string') {
ps.stderr.pipe(stderr);
}
},
);
} catch (error) {
const msg = [
`ssh failed with exit code=${exitCode} signal=${exitSignal}:`,
`[${program}, ${args.join(', ')}]`,
...(error ? [`${error}`] : []),
];
throw new ExpectedError(msg.join('\n'));
}
if (exitCode || exitSignal) {
throw new RemoteCommandError(cmd, exitCode, exitSignal);
const exitCode = await new Promise<number>((resolve, reject) => {
const ps = spawn(program, args, { stdio })
.on('error', reject)
.on('close', resolve);
if (stdout && ps.stdout) {
ps.stdout.pipe(stdout);
}
});
if (exitCode !== 0) {
throw new ExecError(cmd, exitCode);
}
}
/**
* Execute the given command on a local balenaOS device over ssh.
* Capture stdout and/or stderr to Buffers and return them.
*
* @param deviceIp IP address of the local device
* @param cmd Shell command to execute on the device
* @param opts Options
* @param opts.username SSH username for authorization. With balenaOS 2.44.0 or
* later, it may be a balenaCloud username. Otherwise, 'root'.
* @param opts.stdin Passed through to the runRemoteCommand function
* @param opts.stdout If 'capture', capture stdout to a Buffer.
* @param opts.stderr If 'capture', capture stdout to a Buffer.
*/
export async function getRemoteCommandOutput({
cmd,
hostname,
port,
proxyCommand,
stdin = 'ignore',
stdout = 'capture',
stderr = 'capture',
username = 'root',
verbose = false,
}: SshRemoteCommandOpts & {
stdin?: 'ignore' | 'inherit' | NodeJS.ReadableStream;
stdout?: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream;
stderr?: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream;
}): Promise<{ stdout: Buffer; stderr: Buffer }> {
const { Writable } = await import('stream');
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutStream = new Writable({
write(chunk: Buffer, _enc, callback) {
stdoutChunks.push(chunk);
callback();
},
});
const stderrStream = new Writable({
write(chunk: Buffer, _enc, callback) {
stderrChunks.push(chunk);
callback();
},
});
await runRemoteCommand({
cmd,
hostname,
port,
proxyCommand,
stdin,
stdout: stdout === 'capture' ? stdoutStream : stdout,
stderr: stderr === 'capture' ? stderrStream : stderr,
username,
verbose,
});
return {
stdout: Buffer.concat(stdoutChunks),
stderr: Buffer.concat(stderrChunks),
};
}
/** Convenience wrapper for getRemoteCommandOutput */
export async function getLocalDeviceCmdStdout(
hostname: string,
export async function execBuffered(
deviceIp: string,
cmd: string,
stdout: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream = 'capture',
): Promise<Buffer> {
const port = 'local';
return (
await getRemoteCommandOutput({
cmd,
hostname,
port,
stdout,
stderr: 'inherit',
username: await findBestUsernameForDevice(hostname, port),
})
).stdout;
enc?: string,
): Promise<string> {
const through = await import('through2');
const buffer: string[] = [];
await exec(
deviceIp,
cmd,
through(function (data, _enc, cb) {
buffer.push(data.toString(enc));
cb();
}),
);
return buffer.join('');
}
/**
* Run a trivial 'exit 0' command over ssh on the target hostname (typically the
* IP address of a local device) with the 'root' username, in order to determine
* whether root authentication suceeds. It should succeed with development
* variants of balenaOS and fail with production variants, unless a ssh key was
* added to the device's 'config.json' file.
* @return True if succesful, false on any errors.
*/
export const isRootUserGood = _.memoize(async (hostname: string, port) => {
try {
await runRemoteCommand({ cmd: 'exit 0', hostname, port, ...stdioIgnore });
} catch (e) {
return false;
}
return true;
});
/**
* Determine whether the given local device (hostname or IP address) should be
* accessed as the 'root' user or as a regular cloud user (balenaCloud or
* openBalena). Where possible, the root user is preferable because:
* - It allows ssh to be used in air-gapped scenarios (no internet access).
* Logging in as a regular user requires the device to fetch public keys from
* the cloud backend.
* - Root authentication is significantly faster for local devices (a fraction
* of a second versus 5+ seconds).
* - Non-root authentication requires balenaOS v2.44.0 or later, so not (yet)
* universally possible.
*/
export const findBestUsernameForDevice = _.memoize(
async (hostname: string, port): Promise<string> => {
let username: string | undefined;
if (await isRootUserGood(hostname, port)) {
username = 'root';
} else {
const { getCachedUsername } = await import('./bootstrap');
username = (await getCachedUsername())?.username;
}
if (!username) {
const { stripIndent } = await import('./lazy');
throw new ExpectedError(stripIndent`
SSH authentication failed for 'root@${hostname}'.
Please login with 'balena login' for alternative authentication.`);
}
return username;
},
);
/**
* Return a device's balenaOS release by executing 'cat /etc/os-release'
* over ssh to the given deviceIp address. The result is cached with
* lodash's memoize.
*/
export const getDeviceOsRelease = _.memoize(async (hostname: string) =>
(await getLocalDeviceCmdStdout(hostname, 'cat /etc/os-release')).toString(),
export const getDeviceOsRelease = _.memoize(async (deviceIp: string) =>
execBuffered(deviceIp, 'cat /etc/os-release'),
);
function sshErrorMessage(cmd: string, exitSignal?: string, exitCode?: number) {
// TODO: consolidate the various forms of executing ssh child processes
// in the CLI, like exec and spawn, starting with the files:
// lib/actions/ssh.ts
// lib/utils/ssh.ts
// lib/utils/device/ssh.ts
/**
* Obtain the full path for ssh using which, then spawn a child process.
* - If the child process returns error code 0, return the function normally
* (do not throw an error).
* - If the child process returns a non-zero error code, set process.exitCode
* to that error code, and throw ExpectedError with a warning message.
* - If the child process is terminated by a process signal, set
* process.exitCode = 1, and throw ExpectedError with a warning message.
*/
export async function spawnSshAndThrowOnError(
args: string[],
options?: import('child_process').SpawnOptions,
) {
const { whichSpawn } = await import('./which');
const [exitCode, exitSignal] = await whichSpawn(
'ssh',
args,
options,
true, // returnExitCodeOrSignal
);
if (exitCode || exitSignal) {
// ssh returns a wide range of exit codes, including return codes of
// interactive shells. For example, if the user types CTRL-C on an
// interactive shell and then `exit`, ssh returns error code 130.
// Another example, typing "exit 1" on an interactive shell causes ssh
// to return exit code 1. In these cases, print a short one-line warning
// message, and exits the CLI process with the same error code.
process.exitCode = exitCode;
throw new ExpectedError(sshErrorMessage(exitSignal, exitCode));
}
}
function sshErrorMessage(exitSignal?: string, exitCode?: number) {
const msg: string[] = [];
cmd = cmd ? `Remote command "${cmd}"` : 'Process';
if (exitSignal) {
msg.push(`SSH: ${cmd} terminated with signal "${exitSignal}"`);
msg.push(`Warning: ssh process was terminated with signal "${exitSignal}"`);
} else {
msg.push(`SSH: ${cmd} exited with non-zero status code "${exitCode}"`);
msg.push(`Warning: ssh process exited with non-zero code "${exitCode}"`);
switch (exitCode) {
case 255:
msg.push(`

View File

@ -1,5 +1,5 @@
/*
Copyright 2016-2022 Balena
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.
@ -40,35 +40,14 @@ export function notify() {
}
}
const up = notifier.update;
const message = up && getNotifierMessage(up);
if (message) {
notifier.notify({ defer: false, message });
if (
up &&
(require('semver') as typeof import('semver')).lt(up.current, up.latest)
) {
notifier.notify({
defer: false,
message: `Update available ${up.current}${up.latest}\n
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md`,
});
}
}
export function getNotifierMessage(updateInfo: UpdateNotifier.UpdateInfo) {
const semver = require('semver') as typeof import('semver');
const message: string[] = [];
const [current, latest] = [updateInfo.current, updateInfo.latest];
if (semver.lt(current, latest)) {
message.push(
`Update available ${current}${latest}`,
'https://github.com/balena-io/balena-cli/blob/master/INSTALL.md',
);
const currentMajor = semver.major(current);
const latestMajor = semver.major(latest);
if (currentMajor !== latestMajor) {
message.push(
'',
`Check the v${latestMajor} release notes at:`,
getReleaseNotesUrl(latestMajor),
);
}
}
return message.join('\n');
}
function getReleaseNotesUrl(majorVersion: number) {
return `https://github.com/balena-io/balena-cli/wiki/CLI-v${majorVersion}-Release-Notes`;
}

View File

@ -95,3 +95,52 @@ export async function which(
}
return programPath;
}
/**
* Call which(programName) and spawn() with the given arguments.
*
* If returnExitCodeOrSignal is true, the returned promise will resolve to
* an array [code, signal] with the child process exit code number or exit
* signal string respectively (as provided by the spawn close event).
*
* If returnExitCodeOrSignal is false, the returned promise will reject with
* a custom error if the child process returns a non-zero exit code or a
* non-empty signal string (as reported by the spawn close event).
*
* In either case and if spawn itself emits an error event or fails synchronously,
* the returned promise will reject with a custom error that includes the error
* message of spawn's error.
*/
export async function whichSpawn(
programName: string,
args: string[],
options: import('child_process').SpawnOptions = { stdio: 'inherit' },
returnExitCodeOrSignal = false,
): Promise<[number | undefined, string | undefined]> {
const { spawn } = await import('child_process');
const program = await which(programName);
if (process.env.DEBUG) {
console.error(`[debug] [${program}, ${args.join(', ')}]`);
}
let error: Error | undefined;
let exitCode: number | undefined;
let exitSignal: string | undefined;
try {
[exitCode, exitSignal] = await new Promise((resolve, reject) => {
spawn(program, args, options)
.on('error', reject)
.on('close', (code, signal) => resolve([code, signal]));
});
} catch (err) {
error = err;
}
if (error || (!returnExitCodeOrSignal && (exitCode || exitSignal))) {
const msg = [
`${programName} failed with exit code=${exitCode} signal=${exitSignal}:`,
`[${program}, ${args.join(', ')}]`,
...(error ? [`${error}`] : []),
];
throw new Error(msg.join('\n'));
}
return [exitCode, exitSignal];
}

245
npm-shrinkwrap.json generated
View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "13.3.2",
"version": "13.1.13",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1559,6 +1559,202 @@
"tslib": "^2.0.0"
}
},
"@oclif/core": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@oclif/core/-/core-1.0.10.tgz",
"integrity": "sha512-L+IcNU3NoYxwz5hmHfcUlOJ3dpgHRsIj1kAmI9CKEJHq5gBVKlP44Ot179Jke1jKRKX2g9N42izbmlh0SNpkkw==",
"requires": {
"@oclif/linewrap": "^1.0.0",
"chalk": "^4.1.2",
"clean-stack": "^3.0.1",
"cli-ux": "6.0.5",
"debug": "^4.3.3",
"fs-extra": "^9.1.0",
"get-package-type": "^0.1.0",
"globby": "^11.0.4",
"indent-string": "^4.0.0",
"is-wsl": "^2.2.0",
"lodash": "^4.17.21",
"semver": "^7.3.5",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"tslib": "^2.3.1",
"widest-line": "^3.1.0",
"wrap-ansi": "^7.0.0"
},
"dependencies": {
"ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
"requires": {
"type-fest": "^0.21.3"
}
},
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"clean-stack": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz",
"integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==",
"requires": {
"escape-string-regexp": "4.0.0"
}
},
"cli-progress": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.9.1.tgz",
"integrity": "sha512-AXxiCe2a0Lm0VN+9L0jzmfQSkcZm5EYspfqXKaSIQKqIk+0hnkZ3/v1E9B39mkD6vYhKih3c/RPsJBSwq9O99Q==",
"requires": {
"colors": "^1.1.2",
"string-width": "^4.2.0"
}
},
"cli-ux": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cli-ux/-/cli-ux-6.0.5.tgz",
"integrity": "sha512-q2pvzDiXMNISMqCBh0P2dkofQ/8OiWlEAjl6MDNk5oUZ6p54Fnk1rOaXxohYm+YkLX5YNUonGOrwkvuiwVreIg==",
"requires": {
"@oclif/core": "^1.0.8",
"@oclif/linewrap": "^1.0.0",
"@oclif/screen": "^1.0.4 ",
"ansi-escapes": "^4.3.0",
"ansi-styles": "^4.2.0",
"cardinal": "^2.1.1",
"chalk": "^4.1.0",
"clean-stack": "^3.0.0",
"cli-progress": "^3.9.1",
"extract-stack": "^2.0.0",
"fs-extra": "^8.1",
"hyperlinker": "^1.0.0",
"indent-string": "^4.0.0",
"is-wsl": "^2.2.0",
"js-yaml": "^3.13.1",
"lodash": "^4.17.21",
"natural-orderby": "^2.0.1",
"object-treeify": "^1.1.4",
"password-prompt": "^1.1.2",
"semver": "^7.3.2",
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"supports-color": "^8.1.0",
"supports-hyperlinks": "^2.1.0",
"tslib": "^2.0.0"
},
"dependencies": {
"fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
}
},
"supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"debug": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
"integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
"requires": {
"ms": "2.1.2"
}
},
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
"globby": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz",
"integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==",
"requires": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.1.1",
"ignore": "^5.1.4",
"merge2": "^1.3.0",
"slash": "^3.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
},
"jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
"requires": {
"graceful-fs": "^4.1.6"
}
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"requires": {
"ansi-regex": "^5.0.1"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"requires": {
"has-flag": "^4.0.0"
}
},
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
}
}
},
"@oclif/errors": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@oclif/errors/-/errors-1.3.3.tgz",
@ -2557,9 +2753,9 @@
}
},
"@types/dockerode": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.8.tgz",
"integrity": "sha512-/Hip29GzPBWfbSS87lyQDVoB7Ja+kr8oOFWXsySxNFa7jlyj3Yws8LaZRmn1xZl7uJH3Xxsg0oI09GHpT1pIBw==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.0.tgz",
"integrity": "sha512-3Mc0b2gnypJB8Gwmr+8UVPkwjpf4kg1gVxw8lAI4Y/EzpK50LixU1wBSPN9D+xqiw2Ubb02JO8oM0xpwzvi2mg==",
"dev": true,
"requires": {
"@types/docker-modem": "*",
@ -2989,9 +3185,9 @@
}
},
"@types/ssh2": {
"version": "0.5.52",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
"integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
"version": "0.5.49",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.49.tgz",
"integrity": "sha512-ffxhQhJqgTzrw8NxHTgkaDtAmAj2qxCyoves7ztpRgqvzbHcZTpTcm+ATWuuCbPQzxnnF4F3SGGTLGEWTZpwqA==",
"dev": true,
"requires": {
"@types/node": "*",
@ -4917,27 +5113,26 @@
}
},
"cli-ux": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/cli-ux/-/cli-ux-5.6.3.tgz",
"integrity": "sha512-/oDU4v8BiDjX2OKcSunGH0iGDiEtj2rZaGyqNuv9IT4CgcSMyVWAMfn0+rEHaOc4n9ka78B0wo1+N1QX89f7mw==",
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/cli-ux/-/cli-ux-6.0.6.tgz",
"integrity": "sha512-CvL4qmV78VhnbyHTswGjpDSQtU+oj3hT9DP9L6yMOwiTiNv0nMjMEV/8zou4CSqO6PtZ2A8qnlZDgAc07Js+aw==",
"requires": {
"@oclif/command": "^1.6.0",
"@oclif/errors": "^1.2.1",
"@oclif/core": "1.0.10",
"@oclif/linewrap": "^1.0.0",
"@oclif/screen": "^1.0.3",
"@oclif/screen": "^1.0.4 ",
"ansi-escapes": "^4.3.0",
"ansi-styles": "^4.2.0",
"cardinal": "^2.1.1",
"chalk": "^4.1.0",
"clean-stack": "^3.0.0",
"cli-progress": "^3.4.0",
"cli-progress": "^3.9.1",
"extract-stack": "^2.0.0",
"fs-extra": "^8.1",
"hyperlinker": "^1.0.0",
"indent-string": "^4.0.0",
"is-wsl": "^2.2.0",
"js-yaml": "^3.13.1",
"lodash": "^4.17.11",
"lodash": "^4.17.21",
"natural-orderby": "^2.0.1",
"object-treeify": "^1.1.4",
"password-prompt": "^1.1.2",
@ -4958,9 +5153,9 @@
}
},
"chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@ -4976,6 +5171,15 @@
}
}
},
"cli-progress": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.9.1.tgz",
"integrity": "sha512-AXxiCe2a0Lm0VN+9L0jzmfQSkcZm5EYspfqXKaSIQKqIk+0hnkZ3/v1E9B39mkD6vYhKih3c/RPsJBSwq9O99Q==",
"requires": {
"colors": "^1.1.2",
"string-width": "^4.2.0"
}
},
"fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@ -8117,6 +8321,11 @@
"has-symbols": "^1.0.1"
}
},
"get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="
},
"get-port": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "13.3.2",
"version": "13.1.13",
"description": "The official balena Command Line Interface",
"main": "./build/app.js",
"homepage": "https://github.com/balena-io/balena-cli",
@ -127,7 +127,7 @@
"@types/chai-as-promised": "^7.1.4",
"@types/cli-truncate": "^2.0.0",
"@types/common-tags": "^1.8.1",
"@types/dockerode": "^3.3.8",
"@types/dockerode": "^3.3.0",
"@types/ejs": "^3.1.0",
"@types/express": "^4.17.13",
"@types/fs-extra": "^9.0.13",
@ -218,8 +218,8 @@
"chalk": "^3.0.0",
"chokidar": "^3.5.2",
"cli-truncate": "^2.1.0",
"cli-ux": "^5.5.1",
"color-hash": "^1.1.1",
"cli-ux": "^6.0.5",
"columnify": "^1.5.2",
"common-tags": "^1.7.2",
"denymount": "^2.3.0",
@ -287,6 +287,6 @@
"windosu": "^0.3.0"
},
"versionist": {
"publishedAt": "2022-04-07T09:15:48.876Z"
"publishedAt": "2022-02-10T11:50:34.458Z"
}
}

View File

@ -21,6 +21,8 @@ import * as path from 'path';
import { apiResponsePath, BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
import { isV14 } from '../../../lib/utils/version';
describe('balena device', function () {
let api: BalenaAPIMock;
@ -57,9 +59,16 @@ describe('balena device', function () {
const lines = cleanOutput(out);
expect(lines).to.have.lengthOf(25);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('org/test app');
if (isV14()) {
expect(lines).to.have.lengthOf(26);
expect(lines[0]).to.equal('sparkling-wood');
expect(lines[2].split(':')[0].trim()).to.equal('Id');
expect(lines[2].split(':')[1].trim()).to.equal('1747415');
} else {
expect(lines).to.have.lengthOf(25);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('org/test app');
}
});
it.skip('correctly handles devices with missing fields', async () => {
@ -79,14 +88,20 @@ describe('balena device', function () {
const lines = cleanOutput(out);
expect(lines).to.have.lengthOf(14);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('org/test app');
if (isV14()) {
expect(lines).to.have.lengthOf(15);
expect(lines[0]).to.equal('sparkling-wood');
expect(lines[7].split(':')[1].trim()).to.equal('org/test app');
} else {
expect(lines).to.have.lengthOf(14);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('org/test app');
}
});
it('correctly handles devices with missing application', async () => {
// Devices with missing applications will have application name set to `N/a`.
// e.g. When user has a device associated with app that user is no longer a collaborator of.
it.skip('correctly handles devices with missing fleet', async () => {
// Devices with missing fleets will have fleet name set to `N/a`.
// e.g. When user has a device associated with fleet that user is no longer a collaborator of.
api.scope
.get(
/^\/v6\/device\?.+&\$expand=belongs_to__application\(\$select=app_name,slug\)/,
@ -103,8 +118,15 @@ describe('balena device', function () {
const lines = cleanOutput(out);
expect(lines).to.have.lengthOf(25);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('N/a');
if (isV14()) {
expect(lines).to.have.lengthOf(26);
expect(lines[0]).to.equal('sparkling-wood');
expect(lines[9].split(':')[0].trim()).to.equal('Fleet');
expect(lines[9].split(':')[1].trim()).to.equal('N/a');
} else {
expect(lines).to.have.lengthOf(25);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('N/a');
}
});
});

View File

@ -21,6 +21,8 @@ import * as path from 'path';
import { apiResponsePath, BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
import { isV14 } from '../../../lib/utils/version';
describe('balena devices', function () {
let api: BalenaAPIMock;
@ -48,15 +50,24 @@ describe('balena devices', function () {
const lines = cleanOutput(out);
expect(lines[0].replace(/ +/g, ' ')).to.equal(
'ID UUID DEVICE NAME DEVICE TYPE FLEET STATUS IS ONLINE SUPERVISOR VERSION OS VERSION DASHBOARD URL',
);
expect(lines).to.have.lengthOf.at.least(2);
expect(lines.some((l) => l.includes('org/test app'))).to.be.true;
// Devices with missing applications will have application name set to `N/a`.
// e.g. When user has a device associated with app that user is no longer a collaborator of.
expect(lines.some((l) => l.includes('N/a'))).to.be.true;
if (isV14()) {
expect(lines[0].replace(/ +/g, ' ')).to.equal(
' Id Uuid Device name Device type Fleet Status Is online Supervisor version Os version Dashboard url ',
);
expect(lines).to.have.lengthOf.at.least(3);
expect(lines.some((l) => l.includes('org/test app'))).to.be.true;
// Devices with missing applications will have application name set to `N/a`.
// e.g. When user has a device associated with app that user is no longer a collaborator of.
expect(lines.some((l) => l.includes('N/a'))).to.be.true;
} else {
expect(lines[0].replace(/ +/g, ' ')).to.equal(
'ID UUID DEVICE NAME DEVICE TYPE FLEET STATUS IS ONLINE SUPERVISOR VERSION OS VERSION DASHBOARD URL',
);
expect(lines).to.have.lengthOf.at.least(2);
expect(lines.some((l) => l.includes('org/test app'))).to.be.true;
// Devices with missing applications will have application name set to `N/a`.
// e.g. When user has a device associated with app that user is no longer a collaborator of.
expect(lines.some((l) => l.includes('N/a'))).to.be.true;
}
});
});

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2019-2021 Balena Ltd.
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,6 +19,7 @@ import { expect } from 'chai';
import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
import { isV14 } from '../../../lib/utils/version';
describe('balena devices supported', function () {
let api: BalenaAPIMock;
@ -50,7 +51,10 @@ describe('balena devices supported', function () {
const lines = cleanOutput(out, true);
expect(lines[0]).to.equal('SLUG ALIASES ARCH NAME');
expect(lines[0]).to.equal(
isV14() ? ' Slug Aliases Arch Name ' : 'SLUG ALIASES ARCH NAME',
);
expect(lines).to.have.lengthOf.at.least(2);
expect(lines).to.contain('intel-nuc nuc amd64 Intel NUC');
expect(lines).to.contain(

View File

@ -19,7 +19,9 @@ import { expect } from 'chai';
import { stripIndent } from '../../../build/utils/lazy';
import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { runCommand } from '../../helpers';
import { runCommand, removeFirstNLines, trimLines } from '../../helpers';
import { isV14 } from '../../../lib/utils/version';
describe('balena envs', function () {
const appName = 'test';
@ -48,15 +50,30 @@ describe('balena envs', function () {
const { out, err } = await runCommand(`envs -f ${appName}`);
expect(out.join('')).to.equal(
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120110 svar1 svar1-value gh_user/testApp service1
120111 svar2 svar2-value gh_user/testApp service2
120101 var1 var1-val gh_user/testApp *
120102 var2 22 gh_user/testApp *
` + '\n';
expect(output).to.equal(expected);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE FLEET SERVICE
120110 svar1 svar1-value gh_user/testApp service1
120111 svar2 svar2-value gh_user/testApp service2
120101 var1 var1-val gh_user/testApp *
120102 var2 22 gh_user/testApp *
` + '\n',
);
);
}
expect(err.join('')).to.equal('');
});
@ -66,12 +83,24 @@ describe('balena envs', function () {
const { out, err } = await runCommand(`envs -f ${appName} --config`);
expect(out.join('')).to.equal(
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false gh_user/testApp
` + '\n';
expect(output).to.equal(expected);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE FLEET
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false gh_user/testApp
` + '\n',
);
);
}
expect(err.join('')).to.equal('');
});
@ -82,15 +111,19 @@ describe('balena envs', function () {
const { out, err } = await runCommand(`envs -cjf ${appName}`);
expect(JSON.parse(out.join(''))).to.deep.equal([
{
fleet: 'gh_user/testApp',
id: 120300,
name: 'RESIN_SUPERVISOR_NATIVE_LOGGER',
value: 'false',
},
]);
expect(err.join('')).to.equal('');
if (isV14()) {
// TODO: Add tests once oclif json issue resolved.
} else {
expect(JSON.parse(out.join(''))).to.deep.equal([
{
fleet: 'gh_user/testApp',
id: 120300,
name: 'RESIN_SUPERVISOR_NATIVE_LOGGER',
value: 'false',
},
]);
expect(err.join('')).to.equal('');
}
});
it('should successfully list service variables for a test fleet (-s flag)', async () => {
@ -104,14 +137,28 @@ describe('balena envs', function () {
`envs -f ${appName} -s ${serviceName}`,
);
expect(out.join('')).to.equal(
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120111 svar2 svar2-value gh_user/testApp service2
120101 var1 var1-val gh_user/testApp *
120102 var2 22 gh_user/testApp *
` + '\n';
expect(output).to.equal(expected);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE FLEET SERVICE
120111 svar2 svar2-value gh_user/testApp service2
120101 var1 var1-val gh_user/testApp *
120102 var2 22 gh_user/testApp *
` + '\n',
);
);
}
expect(err.join('')).to.equal('');
});
@ -126,14 +173,28 @@ describe('balena envs', function () {
`envs -f ${appName} -s ${serviceName}`,
);
expect(out.join('')).to.equal(
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120110 svar1 svar1-value gh_user/testApp ${serviceName}
120101 var1 var1-val gh_user/testApp *
120102 var2 22 gh_user/testApp *
` + '\n';
expect(output).to.equal(expected);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE FLEET SERVICE
120110 svar1 svar1-value gh_user/testApp ${serviceName}
120101 var1 var1-val gh_user/testApp *
120102 var2 22 gh_user/testApp *
` + '\n',
);
);
}
expect(err.join('')).to.equal('');
});
@ -148,8 +209,27 @@ describe('balena envs', function () {
const uuid = shortUUID;
const result = await runCommand(`envs -d ${uuid}`);
let { out } = result;
let expected =
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120110 svar1 svar1-value org/test * service1
120111 svar2 svar2-value org/test * service2
120120 svar3 svar3-value org/test ${uuid} service1
120121 svar4 svar4-value org/test ${uuid} service2
120101 var1 var1-val org/test * *
120102 var2 22 org/test * *
120203 var3 var3-val org/test ${uuid} *
120204 var4 44 org/test ${uuid} *
` + '\n';
expect(output).to.equal(expected);
} else {
let expected =
stripIndent`
ID NAME VALUE FLEET DEVICE SERVICE
120110 svar1 svar1-value org/test * service1
120111 svar2 svar2-value org/test * service2
@ -161,10 +241,10 @@ describe('balena envs', function () {
120204 var4 44 org/test ${uuid} *
` + '\n';
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
expect(out.join('')).to.equal(expected);
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
expect(out.join('')).to.equal(expected);
}
});
it('should successfully list env variables for a test device (JSON output)', async () => {
@ -176,7 +256,11 @@ describe('balena envs', function () {
api.expectGetDeviceServiceVars();
const { out, err } = await runCommand(`envs -jd ${shortUUID}`);
const expected = `[
if (isV14()) {
// TODO: Add tests once oclif json issue resolved.
} else {
const expected = `[
{ "id": 120101, "fleet": "org/test", "deviceUUID": "*", "name": "var1", "value": "var1-val", "serviceName": "*" },
{ "id": 120102, "fleet": "org/test", "deviceUUID": "*", "name": "var2", "value": "22", "serviceName": "*" },
{ "id": 120110, "fleet": "org/test", "deviceUUID": "*", "name": "svar1", "value": "svar1-value", "serviceName": "service1" },
@ -187,7 +271,9 @@ describe('balena envs', function () {
{ "id": 120204, "fleet": "org/test", "deviceUUID": "${fullUUID}", "name": "var4", "value": "44", "serviceName": "*" }
]`;
expect(JSON.parse(out.join(''))).to.deep.equal(JSON.parse(expected));
expect(JSON.parse(out.join(''))).to.deep.equal(JSON.parse(expected));
}
expect(err.join('')).to.equal('');
});
@ -199,17 +285,30 @@ describe('balena envs', function () {
const result = await runCommand(`envs -d ${shortUUID} --config`);
let { out } = result;
let expected =
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false org/test *
120400 RESIN_SUPERVISOR_POLL_INTERVAL 900900 org/test ${shortUUID}
` + '\n';
expect(output).to.equal(expected);
} else {
let expected =
stripIndent`
ID NAME VALUE FLEET DEVICE
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false org/test *
120400 RESIN_SUPERVISOR_POLL_INTERVAL 900900 org/test ${shortUUID}
` + '\n';
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
expect(out.join('')).to.equal(expected);
expect(out.join('')).to.equal(expected);
}
});
it('should successfully list service variables for a test device (-s flag)', async () => {
@ -225,8 +324,25 @@ describe('balena envs', function () {
const uuid = shortUUID;
const result = await runCommand(`envs -d ${uuid} -s ${serviceName}`);
let { out } = result;
let expected =
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120111 svar2 svar2-value org/test * service2
120121 svar4 svar4-value org/test ${uuid} service2
120101 var1 var1-val org/test * *
120102 var2 22 org/test * *
120203 var3 var3-val org/test ${uuid} *
120204 var4 44 org/test ${uuid} *
` + '\n';
expect(output).to.equal(expected);
} else {
let expected =
stripIndent`
ID NAME VALUE FLEET DEVICE SERVICE
120111 svar2 svar2-value org/test * service2
120121 svar4 svar4-value org/test ${uuid} service2
@ -236,10 +352,11 @@ describe('balena envs', function () {
120204 var4 44 org/test ${uuid} *
` + '\n';
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
expect(out.join('')).to.equal(expected);
expect(out.join('')).to.equal(expected);
}
});
it('should successfully list env and service variables for a test device (unknown fleet)', async () => {
@ -250,8 +367,23 @@ describe('balena envs', function () {
const uuid = shortUUID;
const result = await runCommand(`envs -d ${uuid}`);
let { out } = result;
let expected =
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120120 svar3 svar3-value N/A ${uuid} service1
120121 svar4 svar4-value N/A ${uuid} service2
120203 var3 var3-val N/A ${uuid} *
120204 var4 44 N/A ${uuid} *
` + '\n';
expect(output).to.equal(expected);
} else {
let expected =
stripIndent`
ID NAME VALUE FLEET DEVICE SERVICE
120120 svar3 svar3-value N/A ${uuid} service1
120121 svar4 svar4-value N/A ${uuid} service2
@ -259,10 +391,11 @@ describe('balena envs', function () {
120204 var4 44 N/A ${uuid} *
` + '\n';
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
expect(out.join('')).to.equal(expected);
expect(out.join('')).to.equal(expected);
}
});
it('should successfully list env and service vars for a test device (-s flags)', async () => {
@ -278,8 +411,24 @@ describe('balena envs', function () {
const uuid = shortUUID;
const result = await runCommand(`envs -d ${uuid} -s ${serviceName}`);
let { out } = result;
let expected =
stripIndent`
if (isV14()) {
let output = out.join('');
output = trimLines(removeFirstNLines(output, 2));
const expected =
stripIndent`
120110 svar1 svar1-value org/test * ${serviceName}
120120 svar3 svar3-value org/test ${uuid} ${serviceName}
120101 var1 var1-val org/test * *
120102 var2 22 org/test * *
120203 var3 var3-val org/test ${uuid} *
120204 var4 44 org/test ${uuid} *
` + '\n';
expect(output).to.equal(expected);
} else {
let expected =
stripIndent`
ID NAME VALUE FLEET DEVICE SERVICE
120110 svar1 svar1-value org/test * ${serviceName}
120120 svar3 svar3-value org/test ${uuid} ${serviceName}
@ -289,10 +438,11 @@ describe('balena envs', function () {
120204 var4 44 org/test ${uuid} *
` + '\n';
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
out = out.map((l) => l.replace(/ +/g, ' '));
expected = expected.replace(/ +/g, ' ');
expect(out.join('')).to.equal(expected);
expect(out.join('')).to.equal(expected);
}
});
it('should successfully list env and service vars for a test device (-js flags)', async () => {
@ -308,7 +458,11 @@ describe('balena envs', function () {
const { out, err } = await runCommand(
`envs -d ${shortUUID} -js ${serviceName}`,
);
const expected = `[
if (isV14()) {
// TODO: Add tests once oclif json issue resolved.
} else {
const expected = `[
{ "id": 120101, "fleet": "org/test", "deviceUUID": "*", "name": "var1", "value": "var1-val", "serviceName": "*" },
{ "id": 120102, "fleet": "org/test", "deviceUUID": "*", "name": "var2", "value": "22", "serviceName": "*" },
{ "id": 120110, "fleet": "org/test", "deviceUUID": "*", "name": "svar1", "value": "svar1-value", "serviceName": "${serviceName}" },
@ -317,7 +471,8 @@ describe('balena envs', function () {
{ "id": 120204, "fleet": "org/test", "deviceUUID": "${fullUUID}", "name": "var4", "value": "44", "serviceName": "*" }
]`;
expect(JSON.parse(out.join(''))).to.deep.equal(JSON.parse(expected));
expect(err.join('')).to.equal('');
expect(JSON.parse(out.join(''))).to.deep.equal(JSON.parse(expected));
expect(err.join('')).to.equal('');
}
});
});

View File

@ -20,6 +20,8 @@ import { expect } from 'chai';
import { BalenaAPIMock } from '../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../helpers';
import { isV14 } from '../../lib/utils/version';
describe('balena release', function () {
let api: BalenaAPIMock;
@ -34,7 +36,7 @@ describe('balena release', function () {
api.done();
});
it('should show release details', async () => {
it.skip('should show release details', async () => {
api.expectGetRelease();
const { out } = await runCommand('release 27fda508c');
const lines = cleanOutput(out);
@ -44,7 +46,7 @@ describe('balena release', function () {
expect(lines[1]).to.contain(' 90247b54de4fa7a0a3cbc85e73c68039');
});
it('should return release composition', async () => {
it.skip('should return release composition', async () => {
api.expectGetRelease();
const { out } = await runCommand('release 27fda508c --composition');
const lines = cleanOutput(out);
@ -61,8 +63,14 @@ describe('balena release', function () {
api.expectGetApplication();
const { out } = await runCommand('releases someapp');
const lines = cleanOutput(out);
expect(lines.length).to.be.equal(2);
expect(lines[1]).to.contain('142334');
expect(lines[1]).to.contain('90247b54de4fa7a0a3cbc85e73c68039');
if (isV14()) {
expect(lines.length).to.be.equal(3);
expect(lines[2]).to.contain('142334');
expect(lines[2]).to.contain('90247b54de4fa7a0a3cbc85e73c68039');
} else {
expect(lines.length).to.be.equal(2);
expect(lines[1]).to.contain('142334');
expect(lines[1]).to.contain('90247b54de4fa7a0a3cbc85e73c68039');
}
});
});

View File

@ -32,53 +32,33 @@ describe('balena ssh', function () {
let hasSshExecutable = false;
let mockedExitCode = 0;
async function mockSpawn({ revert = false } = {}) {
const childProcessPath = 'child_process';
if (revert) {
mock.stop(childProcessPath);
mock.reRequire('../../build/utils/ssh');
mock.reRequire('../../build/utils/device/ssh');
return;
}
const { EventEmitter } = await import('stream');
const childProcessMod = await import(childProcessPath);
const originalSpawn = childProcessMod.spawn;
mock(childProcessPath, {
...childProcessMod,
spawn: (program: string, ...args: any[]) => {
if (program.includes('ssh')) {
const emitter = new EventEmitter();
setTimeout(() => emitter.emit('close', mockedExitCode), 1);
return emitter;
}
return originalSpawn(program, ...args);
},
});
}
this.beforeAll(async function () {
hasSshExecutable = await checkSsh();
if (!hasSshExecutable) {
this.skip();
if (hasSshExecutable) {
[sshServer, sshServerPort] = await startMockSshServer();
}
[sshServer, sshServerPort] = await startMockSshServer();
await mockSpawn();
const modPath = '../../build/utils/which';
const mod = await import(modPath);
mock(modPath, {
...mod,
whichSpawn: async () => [mockedExitCode, undefined],
});
});
this.afterAll(async function () {
this.afterAll(function () {
if (sshServer) {
sshServer.close();
sshServer = undefined;
}
await mockSpawn({ revert: true });
mock.stopAll();
});
this.beforeEach(function () {
this.beforeEach(() => {
api = new BalenaAPIMock();
api.expectGetMixpanel({ optional: true });
});
this.afterEach(function () {
this.afterEach(() => {
// Check all expected api calls have been made and clean up.
api.done();
});
@ -107,7 +87,7 @@ describe('balena ssh', function () {
async () => {
const deviceUUID = 'abc1234';
const expectedErrLines = [
'SSH: Remote command "host abc1234" exited with non-zero status code "255"',
'Warning: ssh process exited with non-zero code "255"',
];
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
@ -119,7 +99,22 @@ describe('balena ssh', function () {
},
);
itSS('should fail if device not online (mocked, device UUID)', async () => {
it('should produce the expected error message (real ssh, device IP address)', async function () {
if (!hasSshExecutable) {
this.skip();
}
mock.stop('../../build/utils/helpers');
const expectedErrLines = [
'Warning: ssh process exited with non-zero code "255"',
];
const { err, out } = await runCommand(
`ssh 127.0.0.1 -p ${sshServerPort} --noproxy`,
);
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 });
@ -131,19 +126,6 @@ describe('balena ssh', function () {
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
expect(out).to.be.empty;
});
it('should produce the expected error message (real ssh, device IP address)', async function () {
await mockSpawn({ revert: true });
api.expectGetWhoAmI({ optional: true, persist: true });
const expectedErrLines = [
'SSH: Process exited with non-zero status code "255"',
];
const { err, out } = await runCommand(
`ssh 127.0.0.1 -p ${sshServerPort} --noproxy`,
);
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
expect(out).to.be.empty;
});
});
/** Check whether the 'ssh' tool (executable) exists in the PATH */
@ -177,7 +159,7 @@ async function startMockSshServer(): Promise<[Server, number]> {
console.error(`mock ssh server error:\n${err}`);
});
return await new Promise<[Server, number]>((resolve, reject) => {
return new Promise<[Server, number]>((resolve, reject) => {
// TODO: remove 'as any' below. According to @types/node v12.20.42, the
// callback type is `() => void`, but our code assumes `(err: Error) => void`
const listener = (server.listen as any)(0, '127.0.0.1', (err: Error) => {

View File

@ -106,21 +106,6 @@ describe('outputDataSet', function () {
expect(splitHeader[1]).to.include('thing');
});
/*
it('should output fields in the order specified in `fields` param', async () => {
const fields = ['thing_color', 'id', 'name'];
const options = {};
await outputDataSet(dataSet, fields, options);
const headerLine = printLineSpy.firstCall.firstArg.toLowerCase();
// split header using the `it` column as delimiter
const splitHeader = headerLine.split('id');
expect(splitHeader[0]).to.include('thing');
expect(splitHeader[1]).to.include('name');
});
*/
it('should only output fields specified in `options.fields` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
@ -167,13 +152,41 @@ describe('outputDataSet', function () {
expect(printLineSpy.getCall(0).firstArg).to.include('red');
});
it(
'should output `null` values using the provided value, ' +
'if `options.displayNullValuesAs` is present',
async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const nullValue = 'N/a';
const options = {
'no-header': true,
displayNullValuesAs: nullValue,
};
const extendedDataSet = [
...dataSet,
{
name: 'item3',
id: 3,
thing_color: null,
thing_shape: 'round',
},
];
await outputDataSet(extendedDataSet, fields, options);
expect(printLineSpy.callCount).to.equal(3);
expect(printLineSpy.getCall(2).firstArg).to.include(nullValue);
},
);
it('should output data in json format, if `options.json` true', async () => {
const fields = ['name', 'thing_color', 'thing_shape'];
const options = {
json: true,
};
// TODO: I've run into an oclif cli-ux bug, where numbers are output as strings in json
// TODO: I've run into an oclif cli-ux bug, where all types (number. bool etc.) are output as strings in json
// (this can be seen by including 'id' in the fields list above).
// Issue opened: https://github.com/oclif/cli-ux/issues/309
// For now removing id for this test.

View File

@ -17,8 +17,8 @@
import * as _ from 'lodash';
import * as path from 'path';
import * as packageJSON from '../package.json';
import { getChalk } from '../lib/utils/lazy';
const balenaExe = process.platform === 'win32' ? 'balena.exe' : 'balena';
const standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe);
@ -353,3 +353,65 @@ export async function switchSentry(
return sentryStatus;
}
}
/**
* Convert a string to an array of character codes
* @param text the text to convert.
* @returns an array of character codes representing the text.
*/
export function stringToCharCodes(text: string) {
return text.split('').map((c) => {
return c.charCodeAt(0);
});
}
/**
* Remove leaving and trailing whitespace from each lime of a string.
* @param text the text to process
* @returns a copy of the text with the lines trimmed.
*/
export function trimLines(text: string) {
let lines = text.split('\n');
lines = lines.map((l) => l.trim());
return lines.join('\n');
}
/**
* Pad each line with characters at beginning and end.
* @param text the text to pad.
* @param startPad the string to prepend each line with.
* @param endPad the string to append each line with.
* @returns a copy of the text with the specified padding.
*/
export function padLines(text: string, startPad: string, endPad: string = '') {
let lines = text.split('\n');
lines = lines.map((l) => {
return l === '' ? '' : `${startPad}${l}${endPad}`;
});
return lines.join('\n');
}
/**
* Format first nLines bold.
* @param text the text to format
* @param nLines number of liens to format (from top)
* @returns a copy of the text with the specified number of top lines formatted bold.
*/
export function boldFirstNLines(text: string, nLines: number) {
const chalk = getChalk();
let lines = text.split('\n');
lines = lines.map((l, i) => {
return i < nLines ? chalk.bold(l) : l;
});
return lines.join('\n');
}
/**
* Returns first nLines bold.
* @param text the text to format
* @param nLines number of liens to format (from top)
* @returns a copy of the text with the first nLines removed.
*/
export function removeFirstNLines(text: string, nLines: number) {
return text.split('\n').slice(nLines).join(`\n`);
}

View File

@ -1,137 +0,0 @@
/**
* @license
* Copyright 2022 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 { expect } from 'chai';
import {
DockerConnectionCliFlags,
generateConnectOpts,
getDefaultDockerModemOpts,
} from '../../build/utils/docker';
const defaultSocketPath =
process.platform === 'win32'
? '//./pipe/docker_engine'
: '/var/run/docker.sock';
describe('getDefaultDockerModemOpts() function', function () {
it('should use a Unix socket when --dockerHost is not used', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerPort: 2376,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: undefined,
port: undefined,
protocol: 'http',
socketPath: defaultSocketPath,
});
});
it('should use the HTTP protocol when --dockerPort is 2375', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2375,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: 'foo',
port: '2375',
protocol: 'http',
socketPath: undefined,
});
});
it('should use the HTTPS protocol when --dockerPort is 2376', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2376,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: 'foo',
port: '2376',
protocol: 'https',
socketPath: undefined,
});
});
});
describe('generateConnectOpts() function', function () {
it('should use a Unix socket when --docker is used', async () => {
const cliFlags: DockerConnectionCliFlags = {
docker: 'foo',
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
protocol: 'http',
socketPath: 'foo',
});
expect(connectOpts).to.not.have.any.keys('host', 'port');
});
it('should use the HTTP protocol when --dockerPort is 2375', async () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2375,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
host: 'foo',
port: 2375,
protocol: 'http',
});
expect(connectOpts).to.not.have.any.keys('socketPath');
});
it('should use the HTTPS protocol when --dockerPort is 2376', async () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2376,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
host: 'foo',
port: 2376,
protocol: 'https',
});
expect(connectOpts).to.not.have.any.keys('socketPath');
});
it('should use the HTTPS protocol when ca/cert/key are used', async () => {
const path = await import('path');
const aFile = path.join(
__dirname,
'../test-data/projects/no-docker-compose/dockerignore1/a.txt',
);
const cliFlags: DockerConnectionCliFlags = {
ca: aFile,
cert: aFile,
key: aFile,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
ca: 'a',
cert: 'a',
key: 'a',
host: undefined,
port: undefined,
protocol: 'https',
socketPath: defaultSocketPath,
});
});
});

View File

@ -1,79 +0,0 @@
/**
* @license
* Copyright 2022 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 { expect } from 'chai';
import * as stripIndent from 'common-tags/lib/stripIndent';
import { getNotifierMessage } from '../../build/utils/update';
import type { UpdateInfo } from 'update-notifier';
describe('getNotifierMessage() unit test', function () {
const template: UpdateInfo = {
current: '',
latest: '',
type: 'latest',
name: '',
};
it('should return a simple update message including installation instructions', () => {
const mockUpdateInfo = {
...template,
current: '12.1.1',
latest: '12.3.0',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal(stripIndent`
Update available 12.1.1 → 12.3.0
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md`);
});
it('should include a release notes link when a new major version is available', () => {
const mockUpdateInfo = {
...template,
current: '12.1.1',
latest: '13.3.0',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal(stripIndent`
Update available 12.1.1 → 13.3.0
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
Check the v13 release notes at:
https://github.com/balena-io/balena-cli/wiki/CLI-v13-Release-Notes`);
});
it('should return an empty string if no updates are available', () => {
const mockUpdateInfo = {
...template,
current: '12.1.1',
latest: '12.1.1',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal('');
});
it('should return an empty string if no updates are available', () => {
const mockUpdateInfo = {
...template,
current: '14.1.1',
latest: '12.1.1',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal('');
});
});