Add multicontainer (microservices) support for 'balena envs'

Connects-to: #1153
Change-type: minor
Signed-off-by: Paulo Castro <paulo@balena.io>
This commit is contained in:
Paulo Castro 2019-12-04 18:04:57 +00:00
parent df58ac7673
commit 38920a1c59
13 changed files with 815 additions and 136 deletions

View File

@ -586,30 +586,63 @@ Examples:
## envs
List the environment or config variables of an application or device,
as selected by the respective command-line options.
List the environment or configuration variables of an application, device or
service, as selected by the respective command-line options. (A service is
an application container in a "microservices" application.)
The --config option is used to list "configuration variables" that
control balena features.
The --config option is used to list "configuration variables" that control
balena platform features, as opposed to custom environment variables defined
by the user. The --config and the --service options are mutually exclusive
because configuration variables cannot be set for specific services.
Service-specific variables are not currently supported. The following
examples list variables that apply to all services in an app or device.
The --all option is used to include application-wide (fleet), device-wide
(multiple services on a device) and service-specific variables that apply to
the selected application, device or service. It can be thought of as including
"inherited" variables: for example, a service inherits device-wide variables,
and a device inherits application-wide variables. Variables are still filtered
out by type with the --config option, such that configuration and non-
configuration variables are never listed together.
When the --all option is used, the printed output may include DEVICE and/or
SERVICE columns to distinguish between application-wide, device-specific and
service-specific variables. As asterisk in these columns indicates that the
variable applies to "all devices" or "all services".
If you are parsing the output in a script, please select the JSON format with
the '-j' option. This avoids future compatibility issues if columns are added,
renamed or reordered. Also, when the JSON format is selected, an empty JSON
array ([]) is printed instead of an error message when no variables exist for
the given query. When querying variables for a device, note that the application
name may be null in JSON output (or 'N/A' in tabular output) if the application
linked to the device is no longer accessible by the current user (for example,
in case the current user has been removed from the application by its owner).
Examples:
$ balena envs --application MyApp
$ balena envs --application MyApp --all --json
$ balena envs --application MyApp --service MyService
$ balena envs --application MyApp --all --service MyService
$ balena envs --application MyApp --config
$ balena envs --device 7cf02a6
$ balena envs --device 7cf02a6 --all --json
$ balena envs --device 7cf02a6 --config --all --json
$ balena envs --device 7cf02a6 --all --service MyService
### Options
#### --all
include app-wide, device-wide variables that apply to the selected device or service.
Variables are still filtered out by type with the --config option.
#### -a, --application APPLICATION
application name
#### -c, --config
show config variables
show configuration variables only
#### -d, --device DEVICE
@ -623,6 +656,10 @@ produce JSON output instead of tabular output
produce verbose output
#### -s, --service SERVICE
service name
## env rm ID
Remove a configuration or environment variable from an application or device,

View File

@ -15,11 +15,7 @@
* limitations under the License.
*/
import { Command, flags } from '@oclif/command';
import {
ApplicationVariable,
DeviceVariable,
EnvironmentVariableBase,
} from 'balena-sdk';
import * as SDK from 'balena-sdk';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
@ -28,31 +24,81 @@ import * as cf from '../utils/common-flags';
import { CommandHelp } from '../utils/oclif-utils';
interface FlagsDef {
application?: string;
all?: boolean; // whether to include application-wide, device-wide variables
application?: string; // application name
config: boolean;
device?: string;
device?: string; // device UUID
json: boolean;
help: void;
service?: string; // service name
verbose: boolean;
}
interface EnvironmentVariableInfo extends SDK.EnvironmentVariableBase {
appName?: string | null; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
interface DeviceServiceEnvironmentVariableInfo
extends SDK.DeviceServiceEnvironmentVariable {
appName?: string; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
interface ServiceEnvironmentVariableInfo
extends SDK.ServiceEnvironmentVariable {
appName?: string; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
export default class EnvsCmd extends Command {
public static description = stripIndent`
List the environment or config variables of an app or device.
List the environment or config variables of an application, device or service.
List the environment or config variables of an application or device,
as selected by the respective command-line options.
List the environment or configuration variables of an application, device or
service, as selected by the respective command-line options. (A service is
an application container in a "microservices" application.)
The --config option is used to list "configuration variables" that
control balena features.
The --config option is used to list "configuration variables" that control
balena platform features, as opposed to custom environment variables defined
by the user. The --config and the --service options are mutually exclusive
because configuration variables cannot be set for specific services.
Service-specific variables are not currently supported. The following
examples list variables that apply to all services in an app or device.
The --all option is used to include application-wide (fleet), device-wide
(multiple services on a device) and service-specific variables that apply to
the selected application, device or service. It can be thought of as including
"inherited" variables: for example, a service inherits device-wide variables,
and a device inherits application-wide variables. Variables are still filtered
out by type with the --config option, such that configuration and non-
configuration variables are never listed together.
When the --all option is used, the printed output may include DEVICE and/or
SERVICE columns to distinguish between application-wide, device-specific and
service-specific variables. As asterisk in these columns indicates that the
variable applies to "all devices" or "all services".
If you are parsing the output in a script, please select the JSON format with
the '-j' option. This avoids future compatibility issues if columns are added,
renamed or reordered. Also, when the JSON format is selected, an empty JSON
array ([]) is printed instead of an error message when no variables exist for
the given query. When querying variables for a device, note that the application
name may be null in JSON output (or 'N/A' in tabular output) if the application
linked to the device is no longer accessible by the current user (for example,
in case the current user has been removed from the application by its owner).
`;
public static examples = [
'$ balena envs --application MyApp',
'$ balena envs --application MyApp --all --json',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --all --service MyService',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --all --json',
'$ balena envs --device 7cf02a6 --config --all --json',
'$ balena envs --device 7cf02a6 --all --service MyService',
];
public static usage = (
@ -60,10 +106,16 @@ export default class EnvsCmd extends Command {
).trim();
public static flags: flags.Input<FlagsDef> = {
all: flags.boolean({
description: stripIndent`
include app-wide, device-wide variables that apply to the selected device or service.
Variables are still filtered out by type with the --config option.`,
}),
application: _.assign({ exclusive: ['device'] }, cf.application),
config: flags.boolean({
char: 'c',
description: 'show config variables',
description: 'show configuration variables only',
exclusive: ['service'],
}),
device: _.assign({ exclusive: ['application'] }, cf.device),
help: cf.help,
@ -72,55 +124,251 @@ export default class EnvsCmd extends Command {
description: 'produce JSON output instead of tabular output',
}),
verbose: cf.verbose,
service: _.assign({ exclusive: ['config'] }, cf.service),
};
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(EnvsCmd);
const balena = (await import('balena-sdk')).fromSharedOptions();
const visuals = await import('resin-cli-visuals');
const balena = SDK.fromSharedOptions();
const { getDeviceAndMaybeAppFromUUID } = await import('../utils/cloud');
const { checkLoggedIn } = await import('../utils/patterns');
const cmd = this;
let environmentVariables: ApplicationVariable[] | DeviceVariable[];
const variables: EnvironmentVariableInfo[] = [];
await checkLoggedIn();
if (options.application) {
environmentVariables = await balena.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(options.application);
} else if (options.device) {
environmentVariables = await balena.models.device[
options.config ? 'configVar' : 'envVar'
].getAllByDevice(options.device);
} else {
if (!options.application && !options.device) {
throw new ExpectedError('You must specify an application or device');
}
if (_.isEmpty(environmentVariables)) {
throw new ExpectedError('No environment variables found');
let appName = options.application;
let fullUUID: string | undefined; // as oppposed to the short, 7-char UUID
if (options.device) {
const [device, app] = await getDeviceAndMaybeAppFromUUID(
balena,
options.device,
['uuid'],
['app_name'],
);
fullUUID = device.uuid;
if (app) {
appName = app.app_name;
}
}
if (appName && options.service) {
await validateServiceName(balena, options.service, appName);
}
if (options.application || options.all) {
variables.push(...(await getAppVars(balena, appName, options)));
}
if (fullUUID) {
variables.push(
...(await getDeviceVars(balena, fullUUID, appName, options)),
);
}
if (!options.json && _.isEmpty(variables)) {
const target =
(options.service ? `service "${options.service}" of ` : '') +
(options.application
? `application "${options.application}"`
: `device "${options.device}"`);
throw new ExpectedError(`No environment variables found for ${target}`);
}
await this.printVariables(variables, options);
}
protected async printVariables(
varArray: EnvironmentVariableInfo[],
options: FlagsDef,
) {
const visuals = await import('resin-cli-visuals');
const fields = ['id', 'name', 'value'];
if (options.all) {
// Replace undefined app names with 'N/A' or null
varArray = _.map(varArray, (i: EnvironmentVariableInfo) => {
i.appName = i.appName || (options.json ? null : 'N/A');
return i;
});
fields.push(options.json ? 'appName' : 'appName => APPLICATION');
if (options.device) {
fields.push(options.json ? 'deviceUUID' : 'deviceUUID => DEVICE');
}
if (!options.config) {
fields.push(options.json ? 'serviceName' : 'serviceName => SERVICE');
}
}
if (options.json) {
cmd.log(
stringifyVarArray<EnvironmentVariableBase>(
environmentVariables,
this.log(
stringifyVarArray<SDK.EnvironmentVariableBase>(varArray, fields),
);
} else {
this.log(
visuals.table.horizontal(
_.sortBy(varArray, (v: SDK.EnvironmentVariableBase) => v.name),
fields,
),
);
} else {
cmd.log(visuals.table.horizontal(environmentVariables, fields));
}
}
}
async function validateServiceName(
sdk: SDK.BalenaSDK,
serviceName: string,
appName: string,
) {
const services = await sdk.models.service.getAllByApplication(appName, {
$filter: { service_name: serviceName },
});
if (_.isEmpty(services)) {
throw new ExpectedError(
`Service "${serviceName}" not found for application "${appName}"`,
);
}
}
/**
* Fetch application-wide config / env / service vars.
* If options.application is undefined, an attempt is made to obtain the
* application name from the device UUID (options.device). If this attempt
* fails because the device does not belong to any application, an emtpy
* array is returned.
*/
async function getAppVars(
sdk: SDK.BalenaSDK,
appName: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const appVars: EnvironmentVariableInfo[] = [];
if (!appName) {
return appVars;
}
if (options.config || options.all || !options.service) {
const vars = await sdk.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(appName);
fillInInfoFields(vars, appName);
appVars.push(...vars);
}
if (!options.config && (options.service || options.all)) {
const pineOpts: SDK.PineOptionsFor<SDK.ServiceEnvironmentVariable> = {
$expand: {
service: {},
},
};
if (options.service) {
pineOpts.$filter = {
service: {
service_name: options.service,
},
};
}
const serviceVars = await sdk.models.service.var.getAllByApplication(
appName,
pineOpts,
);
fillInInfoFields(serviceVars, appName);
appVars.push(...serviceVars);
}
return appVars;
}
/**
* Fetch config / env / service vars when the '--device' option is provided.
* Precondition: options.device must be defined.
*/
async function getDeviceVars(
sdk: SDK.BalenaSDK,
fullUUID: string,
appName: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const printedUUID = options.json ? fullUUID : options.device!;
const deviceVars: EnvironmentVariableInfo[] = [];
if (options.config) {
const deviceConfigVars = await sdk.models.device.configVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceConfigVars, appName, printedUUID);
deviceVars.push(...deviceConfigVars);
} else {
if (options.service || options.all) {
const pineOpts: SDK.PineOptionsFor<
SDK.DeviceServiceEnvironmentVariable
> = {
$expand: {
service_install: {
$expand: 'installs__service',
},
},
};
if (options.service) {
pineOpts.$filter = {
service_install: {
installs__service: { service_name: options.service },
},
};
}
const deviceServiceVars = await sdk.models.device.serviceVar.getAllByDevice(
fullUUID,
pineOpts,
);
fillInInfoFields(deviceServiceVars, appName, printedUUID);
deviceVars.push(...deviceServiceVars);
}
if (!options.service || options.all) {
const deviceEnvVars = await sdk.models.device.envVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceEnvVars, appName, printedUUID);
deviceVars.push(...deviceEnvVars);
}
}
return deviceVars;
}
/**
* For each env var object in varArray, fill in its top-level serviceName
* and deviceUUID fields. An asterisk is used to indicate that the variable
* applies to "all services" or "all devices".
*/
function fillInInfoFields(
varArray:
| EnvironmentVariableInfo[]
| DeviceServiceEnvironmentVariableInfo[]
| ServiceEnvironmentVariableInfo[],
appName?: string,
deviceUUID?: string,
) {
for (const envVar of varArray) {
if ('service' in envVar) {
// envVar is of type ServiceEnvironmentVariableInfo
envVar.serviceName = _.at(envVar as any, 'service[0].service_name')[0];
} else if ('service_install' in envVar) {
// envVar is of type DeviceServiceEnvironmentVariableInfo
envVar.serviceName = _.at(
envVar as any,
'service_install[0].installs__service[0].service_name',
)[0];
}
envVar.appName = appName;
envVar.serviceName = envVar.serviceName || '*';
envVar.deviceUUID = deviceUUID || '*';
}
}
/**
* Transform each object (item) of varArray to preserve only the
* fields (keys) listed in the fields argument.
*/
function stringifyVarArray<T = Dictionary<any>>(
varArray: T[],
fields: string[],
): string {
// Transform each object (item) of varArray to preserve
// only the fields (keys) listed in the fields argument.
const transformed = _.map(varArray, (o: Dictionary<any>) =>
_.transform(
o,

View File

@ -15,12 +15,18 @@
* limitations under the License.
*/
import { BalenaSDK, Service } from 'balena-sdk';
import memoize = require('lodash/memoize');
import * as SDK from 'balena-sdk';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
export const serviceIdToName = memoize(
async (sdk: BalenaSDK, serviceId: number): Promise<string | undefined> => {
const serviceName = await sdk.pine.get<Service>({
import { ExpectedError } from '../errors';
export const serviceIdToName = _.memoize(
async (
sdk: SDK.BalenaSDK,
serviceId: number,
): Promise<string | undefined> => {
const serviceName = await sdk.pine.get<SDK.Service>({
resource: 'service',
id: serviceId,
options: {
@ -36,3 +42,67 @@ export const serviceIdToName = memoize(
// Memoize the call based on service id
(_sdk, id) => id.toString(),
);
/**
* Return Device and Application objects for the given device UUID (short UUID
* or full UUID). An error is thrown if the application is not accessible, e.g.
* if the application owner removed the current user as a collaborator (but the
* device still belongs to the current user).
*/
export const getDeviceAndAppFromUUID = _.memoize(
async (
sdk: SDK.BalenaSDK,
deviceUUID: string,
selectDeviceFields?: Array<keyof SDK.Device>,
selectAppFields?: Array<keyof SDK.Application>,
): Promise<[SDK.Device, SDK.Application]> => {
const [device, app] = await getDeviceAndMaybeAppFromUUID(
sdk,
deviceUUID,
selectDeviceFields,
selectAppFields,
);
if (app == null) {
throw new ExpectedError(stripIndent`
Unable to access the application that device ${deviceUUID} belongs to.
Hint: check whether the application owner might have withdrawn access to it.
`);
}
return [device, app];
},
// Memoize the call based on UUID
(_sdk, deviceUUID) => deviceUUID,
);
/**
* Return a Device object and maybe an Application object for the given device
* UUID (short UUID or full UUID). The Application object may be undefined if
* the user / device lost access to the application, e.g. if the application
* owner removed the user as a collaborator (but the device still belongs to
* the current user).
*/
export const getDeviceAndMaybeAppFromUUID = _.memoize(
async (
sdk: SDK.BalenaSDK,
deviceUUID: string,
selectDeviceFields?: Array<keyof SDK.Device>,
selectAppFields?: Array<keyof SDK.Application>,
): Promise<[SDK.Device, SDK.Application | undefined]> => {
const pineOpts = {
$expand: selectAppFields
? { belongs_to__application: { $select: selectAppFields } }
: 'belongs_to__application',
} as SDK.PineOptionsFor<SDK.Device>;
if (selectDeviceFields) {
pineOpts.$select = selectDeviceFields as any;
}
const device = await sdk.models.device.get(deviceUUID, pineOpts);
const apps = device.belongs_to__application as SDK.Application[];
if (_.isEmpty(apps) || _.isEmpty(apps[0])) {
return [device, undefined];
}
return [device, apps[0]];
},
// Memoize the call based on UUID
(_sdk, deviceUUID) => deviceUUID,
);

View File

@ -37,6 +37,11 @@ export const quiet: IBooleanFlag<boolean> = flags.boolean({
default: false,
});
export const service = flags.string({
char: 's',
description: 'service name',
});
export const verbose: IBooleanFlag<boolean> = flags.boolean({
char: 'v',
description: 'produce verbose output',

View File

@ -15,9 +15,10 @@
* limitations under the License.
*/
import * as _ from 'lodash';
import * as nock from 'nock';
class BalenaAPIMock {
export class BalenaAPIMock {
public static basePathPattern = /api\.balena-cloud\.com/;
public readonly scope: nock.Scope;
// Expose `scope` as `expect` to allow for better semantics in tests
@ -57,10 +58,22 @@ class BalenaAPIMock {
.reply(200, { d: [{ id: 1234567 }] });
}
public expectTestDevice() {
this.scope
.get(/^\/v\d+\/device($|\?)/)
.reply(200, { d: [{ id: 7654321 }] });
public expectTestDevice(
fullUUID = 'f63fd7d7812c34c4c14ae023fdff05f5',
inaccessibleApp = false,
) {
const id = 7654321;
this.scope.get(/^\/v\d+\/device($|\?)/).reply(200, {
d: [
{
id,
uuid: fullUUID,
belongs_to__application: inaccessibleApp
? []
: [{ app_name: 'test' }],
},
],
});
}
public expectAppEnvVars() {
@ -82,6 +95,35 @@ class BalenaAPIMock {
});
}
public expectAppConfigVars() {
this.scope.get(/^\/v\d+\/application_config_variable($|\?)/).reply(200, {
d: [
{
id: 120300,
name: 'RESIN_SUPERVISOR_NATIVE_LOGGER',
value: 'false',
},
],
});
}
public expectAppServiceVars() {
this.scope
.get(/^\/v\d+\/service_environment_variable($|\?)/)
.reply(function(uri, _requestBody) {
const match = uri.match(/service_name%20eq%20%27(.+?)%27/);
const serviceName = (match && match[1]) || undefined;
let varArray: any[];
if (serviceName) {
const varObj = appServiceVarsByService[serviceName];
varArray = varObj ? [varObj] : [];
} else {
varArray = _.map(appServiceVarsByService, value => value);
}
return [200, { d: varArray }];
});
}
public expectDeviceEnvVars() {
this.scope.get(/^\/v\d+\/device_environment_variable($|\?)/).reply(200, {
d: [
@ -99,18 +141,6 @@ class BalenaAPIMock {
});
}
public expectAppConfigVars() {
this.scope.get(/^\/v\d+\/application_config_variable($|\?)/).reply(200, {
d: [
{
id: 120300,
name: 'RESIN_SUPERVISOR_NATIVE_LOGGER',
value: 'false',
},
],
});
}
public expectDeviceConfigVars() {
this.scope.get(/^\/v\d+\/device_config_variable($|\?)/).reply(200, {
d: [
@ -123,6 +153,23 @@ class BalenaAPIMock {
});
}
public expectDeviceServiceVars() {
this.scope
.get(/^\/v\d+\/device_service_environment_variable($|\?)/)
.reply(function(uri, _requestBody) {
const match = uri.match(/service_name%20eq%20%27(.+?)%27/);
const serviceName = (match && match[1]) || undefined;
let varArray: any[];
if (serviceName) {
const varObj = deviceServiceVarsByService[serviceName];
varArray = varObj ? [varObj] : [];
} else {
varArray = _.map(deviceServiceVarsByService, value => value);
}
return [200, { d: varArray }];
});
}
public expectConfigVars() {
this.scope.get('/config/vars').reply(200, {
reservedNames: [],
@ -135,20 +182,26 @@ class BalenaAPIMock {
});
}
// User details are cached in the SDK
// so often we don't know if we can expect the whoami request
public expectOptionalWhoAmI(persist = false) {
(persist ? this.scope.persist() : this.scope)
.get('/user/v1/whoami')
.optionally()
.reply(200, {
id: 99999,
username: 'testuser',
email: 'testuser@test.com',
});
public expectService(serviceName: string, serviceId = 243768) {
this.scope.get(/^\/v\d+\/service($|\?)/).reply(200, {
d: [{ id: serviceId, service_name: serviceName }],
});
}
public expectMixpanel(optional = false) {
// User details are cached in the SDK
// so often we don't know if we can expect the whoami request
public expectWhoAmI(persist = false, optional = true) {
const get = (persist ? this.scope.persist() : this.scope).get(
'/user/v1/whoami',
);
(optional ? get.optionally() : get).reply(200, {
id: 99999,
username: 'testuser',
email: 'testuser@test.com',
});
}
public expectMixpanel(optional = true) {
const get = this.scope.get(/^\/mixpanel\/track/);
(optional ? get.optionally() : get).reply(200, {});
}
@ -162,4 +215,52 @@ class BalenaAPIMock {
}
}
export { BalenaAPIMock };
const appServiceVarsByService: { [key: string]: any } = {
service1: {
id: 120110,
name: 'svar1',
value: 'svar1-value',
service: [
{
id: 210110,
service_name: 'service1',
},
],
},
service2: {
id: 120111,
name: 'svar2',
value: 'svar2-value',
service: [
{
id: 210111,
service_name: 'service2',
},
],
},
};
const deviceServiceVarsByService: { [key: string]: any } = {
service1: {
id: 120120,
name: 'svar3',
value: 'svar3-value',
service: [
{
id: 210110,
service_name: 'service1',
},
],
},
service2: {
id: 120121,
name: 'svar4',
value: 'svar4-value',
service: [
{
id: 210111,
service_name: 'service2',
},
],
},
};

View File

@ -37,7 +37,7 @@ describe('balena app create', function() {
});
it('should print help text with the -h flag', async () => {
api.expectOptionalWhoAmI();
api.expectWhoAmI();
api.expectMixpanel();
const { out, err } = await runCommand('app create -h');

View File

@ -25,7 +25,7 @@ describe('balena devices supported', function() {
});
it('should print help text with the -h flag', async () => {
api.expectOptionalWhoAmI();
api.expectWhoAmI();
api.expectMixpanel();
const { out, err } = await runCommand('devices supported -h');
@ -36,7 +36,7 @@ describe('balena devices supported', function() {
});
it('should list currently supported devices, with correct filtering', async () => {
api.expectOptionalWhoAmI();
api.expectWhoAmI();
api.expectMixpanel();
api.scope

View File

@ -25,8 +25,8 @@ describe('balena env add', function() {
beforeEach(() => {
api = new BalenaAPIMock();
api.expectOptionalWhoAmI(true);
api.expectMixpanel(true);
api.expectWhoAmI(true);
api.expectMixpanel();
});
afterEach(() => {

View File

@ -23,13 +23,19 @@ import { runCommand } from '../../helpers';
describe('balena envs', function() {
const appName = 'test';
const deviceUUID = 'f63fd7d7812c34c4c14ae023fdff05f5';
let fullUUID: string;
let shortUUID: string;
let api: BalenaAPIMock;
beforeEach(() => {
api = new BalenaAPIMock();
api.expectOptionalWhoAmI(true);
api.expectMixpanel(true);
api.expectWhoAmI(true);
api.expectMixpanel();
// Random device UUID used to frustrate _.memoize() in utils/cloud.ts
fullUUID = require('crypto')
.randomBytes(16)
.toString('hex');
shortUUID = fullUUID.substring(0, 7);
});
afterEach(() => {
@ -48,48 +54,11 @@ describe('balena envs', function() {
ID NAME VALUE
120101 var1 var1-val
120102 var2 22
` + '\n',
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list env vars for a test device', async () => {
api.expectTestDevice();
api.expectDeviceEnvVars();
const { out, err } = await runCommand(`envs -d ${deviceUUID}`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120203 var3 var3-val
120204 var4 44
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list env vars for a test device (JSON output)', async () => {
api.expectTestDevice();
api.expectDeviceEnvVars();
const { out, err } = await runCommand(`envs -jd ${deviceUUID}`);
expect(JSON.parse(out.join(''))).to.deep.equal([
{
id: 120203,
name: 'var3',
value: 'var3-val',
},
{
id: 120204,
name: 'var4',
value: '44',
},
]);
expect(err.join('')).to.equal('');
});
it('should successfully list config vars for a test app', async () => {
api.expectTestApp();
api.expectAppConfigVars();
@ -100,7 +69,7 @@ describe('balena envs', function() {
stripIndent`
ID NAME VALUE
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false
` + '\n',
` + '\n',
);
expect(err.join('')).to.equal('');
});
@ -121,17 +90,266 @@ describe('balena envs', function() {
expect(err.join('')).to.equal('');
});
it('should successfully list config vars for a test device', async () => {
api.expectTestDevice();
it('should successfully list service variables for a test app (-s flag)', async () => {
const serviceName = 'service2';
api.expectService(serviceName);
api.expectTestApp();
api.expectAppServiceVars();
const { out, err } = await runCommand(
`envs -a ${appName} -s ${serviceName}`,
);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120111 svar2 svar2-value
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should produce an empty JSON array when no app service variables exist', async () => {
const serviceName = 'nono';
api.expectService(serviceName);
api.expectTestApp();
api.expectAppServiceVars();
const { out, err } = await runCommand(
`envs -a ${appName} -s ${serviceName} -j`,
);
expect(out.join('')).to.equal('[]\n');
expect(err.join('')).to.equal('');
});
it('should successfully list env and service vars for a test app (--all flag)', async () => {
api.expectTestApp();
api.expectAppEnvVars();
api.expectAppServiceVars();
const { out, err } = await runCommand(`envs -a ${appName} --all`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120110 svar1 svar1-value test service1
120111 svar2 svar2-value test service2
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list env and service vars for a test app (--all -s flags)', async () => {
const serviceName = 'service1';
api.expectService(serviceName);
api.expectTestApp();
api.expectAppEnvVars();
api.expectAppServiceVars();
const { out, err } = await runCommand(
`envs -a ${appName} --all -s ${serviceName}`,
);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120110 svar1 svar1-value test ${serviceName}
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list env variables for a test device', async () => {
api.expectTestDevice(fullUUID);
api.expectDeviceEnvVars();
const { out, err } = await runCommand(`envs -d ${shortUUID}`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120203 var3 var3-val
120204 var4 44
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list env variables for a test device (JSON output)', async () => {
api.expectTestDevice(fullUUID);
api.expectDeviceEnvVars();
const { out, err } = await runCommand(`envs -jd ${shortUUID}`);
expect(JSON.parse(out.join(''))).to.deep.equal([
{
id: 120203,
name: 'var3',
value: 'var3-val',
},
{
id: 120204,
name: 'var4',
value: '44',
},
]);
expect(err.join('')).to.equal('');
});
it('should successfully list config variables for a test device', async () => {
api.expectTestDevice(fullUUID);
api.expectDeviceConfigVars();
const { out, err } = await runCommand(`envs -d ${deviceUUID} --config`);
const { out, err } = await runCommand(`envs -d ${shortUUID} --config`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120400 RESIN_SUPERVISOR_POLL_INTERVAL 900900
` + '\n',
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list service variables for a test device (-s flag)', async () => {
const serviceName = 'service2';
api.expectService(serviceName);
api.expectTestApp();
api.expectTestDevice(fullUUID);
api.expectDeviceServiceVars();
const { out, err } = await runCommand(
`envs -d ${shortUUID} -s ${serviceName}`,
);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120121 svar4 svar4-value
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should produce an empty JSON array when no device service variables exist', async () => {
const serviceName = 'nono';
api.expectService(serviceName);
api.expectTestApp();
api.expectTestDevice(fullUUID);
api.expectDeviceServiceVars();
const { out, err } = await runCommand(
`envs -d ${shortUUID} -s ${serviceName} -j`,
);
expect(out.join('')).to.equal('[]\n');
expect(err.join('')).to.equal('');
});
it('should successfully list env and service variables for a test device (--all flag)', async () => {
api.expectTestApp();
api.expectAppEnvVars();
api.expectAppServiceVars();
api.expectTestDevice(fullUUID);
api.expectDeviceEnvVars();
api.expectDeviceServiceVars();
const uuid = shortUUID;
const { out, err } = await runCommand(`envs -d ${uuid} --all`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120110 svar1 svar1-value test * service1
120111 svar2 svar2-value test * service2
120120 svar3 svar3-value test ${uuid} service1
120121 svar4 svar4-value test ${uuid} service2
120101 var1 var1-val test * *
120102 var2 22 test * *
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list env and service variables for a test device (unknown app)', async () => {
api.expectTestDevice(fullUUID, true);
api.expectDeviceEnvVars();
api.expectDeviceServiceVars();
const uuid = shortUUID;
const { out, err } = await runCommand(`envs -d ${uuid} --all`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
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(err.join('')).to.equal('');
});
it('should successfully list env and service vars for a test device (--all -s flags)', async () => {
const serviceName = 'service1';
api.expectService(serviceName);
api.expectTestApp();
api.expectAppEnvVars();
api.expectAppServiceVars();
api.expectTestDevice(fullUUID);
api.expectDeviceEnvVars();
api.expectDeviceServiceVars();
const uuid = shortUUID;
const { out, err } = await runCommand(
`envs -d ${uuid} --all -s ${serviceName}`,
);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120110 svar1 svar1-value test * ${serviceName}
120120 svar3 svar3-value test ${uuid} ${serviceName}
120101 var1 var1-val test * *
120102 var2 22 test * *
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list env and service vars for a test device (--all -js flags)', async () => {
const serviceName = 'service1';
api.expectService(serviceName);
api.expectTestApp();
api.expectAppEnvVars();
api.expectAppServiceVars();
api.expectTestDevice(fullUUID);
api.expectDeviceEnvVars();
api.expectDeviceServiceVars();
const { out, err } = await runCommand(
`envs -d ${shortUUID} --all -js ${serviceName}`,
);
expect(JSON.parse(out.join(''))).to.deep.equal(
JSON.parse(`[
{ "id": 120101, "appName": "test", "deviceUUID": "*", "name": "var1", "value": "var1-val", "serviceName": "*" },
{ "id": 120102, "appName": "test", "deviceUUID": "*", "name": "var2", "value": "22", "serviceName": "*" },
{ "id": 120110, "appName": "test", "deviceUUID": "*", "name": "svar1", "value": "svar1-value", "serviceName": "${serviceName}" },
{ "id": 120120, "appName": "test", "deviceUUID": "${fullUUID}", "name": "svar3", "value": "svar3-value", "serviceName": "${serviceName}" },
{ "id": 120203, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var3", "value": "var3-val", "serviceName": "*" },
{ "id": 120204, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var4", "value": "44", "serviceName": "*" }
]`),
);
expect(err.join('')).to.equal('');
});

View File

@ -25,8 +25,8 @@ describe('balena env rename', function() {
beforeEach(() => {
api = new BalenaAPIMock();
api.expectOptionalWhoAmI(true);
api.expectMixpanel(true);
api.expectWhoAmI(true);
api.expectMixpanel();
});
afterEach(() => {

View File

@ -25,8 +25,8 @@ describe('balena env rm', function() {
beforeEach(() => {
api = new BalenaAPIMock();
api.expectOptionalWhoAmI(true);
api.expectMixpanel(true);
api.expectWhoAmI(true);
api.expectMixpanel();
});
afterEach(() => {

View File

@ -61,7 +61,7 @@ Additional commands:
env add <name> [value] add an environment or config variable to an application or device
env rename <id> <value> change the value of an environment variable for an app or device
env rm <id> remove an environment variable from an application or device
envs list the environment or config variables of an app or device
envs list the environment or config variables of an application, device or service
key <id> list a single ssh key
key add <name> [path] add a SSH key to balena
key rm <id> remove a ssh key

View File

@ -24,7 +24,7 @@ import * as balenaCLI from '../build/app';
import { configureBluebird, setMaxListeners } from '../build/app-common';
configureBluebird();
setMaxListeners(15); // it appears that using nock adds some listeners
setMaxListeners(25); // it appears that 'nock' adds a bunch of listeners - bug?
export const runCommand = async (cmd: string) => {
const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];