/** * @license * Copyright 2016-2021 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 { Flags } from '@oclif/core'; import type { Interfaces } from '@oclif/core'; import type * as SDK from 'balena-sdk'; import * as _ from 'lodash'; import Command from '../../command'; import { ExpectedError } from '../../errors'; import * as cf from '../../utils/common-flags'; import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy'; import { applicationIdInfo } from '../../utils/messages'; type FlagsDef = Interfaces.InferredFlags; interface EnvironmentVariableInfo extends SDK.EnvironmentVariableBase { fleet?: string | null; // fleet slug deviceUUID?: string; // device UUID serviceName?: string; // service name } interface DeviceServiceEnvironmentVariableInfo extends SDK.DeviceServiceEnvironmentVariable { fleet?: string; // fleet slug deviceUUID?: string; // device UUID serviceName?: string; // service name } interface ServiceEnvironmentVariableInfo extends SDK.ServiceEnvironmentVariable { fleet?: string; // fleet slug 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 a fleet, device or service. List the environment or configuration variables of a fleet, device or service, as selected by the respective command-line options. (A service corresponds to a Docker image/container in a microservices fleet.) The results include fleet-wide (multiple devices), device-specific (multiple services on a specific device) and service-specific variables that apply to the selected fleet, device or service. It can be thought of as including inherited variables; for example, a service inherits device-wide variables, and a device inherits fleet-wide variables. The printed output may include DEVICE and/or SERVICE columns to distinguish between fleet-wide, device-specific and service-specific variables. An asterisk in these columns indicates that the variable applies to "all devices" or "all services". 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. The --json option is recommended when scripting the output of this command, because the JSON format is less likely to change and it better represents data types like lists and empty strings. The 'jq' utility may be helpful in shell scripts (https://stedolan.github.io/jq/manual/). When --json is used, 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 fleet name may be null in JSON output (or 'N/A' in tabular output) if the fleet that the device belonged to is no longer accessible by the current user (for example, in case the current user was removed from the fleet by the fleet's owner). ${applicationIdInfo.split('\n').join('\n\t\t')} `; public static examples = [ '$ balena envs --fleet myorg/myfleet', '$ balena envs --fleet MyFleet --json', '$ balena envs --fleet MyFleet --service MyService', '$ balena envs --fleet MyFleet --config', '$ balena envs --device 7cf02a6', '$ balena envs --device 7cf02a6 --json', '$ balena envs --device 7cf02a6 --config --json', '$ balena envs --device 7cf02a6 --service MyService', ]; public static usage = 'envs'; public static flags = { fleet: { ...cf.fleet, exclusive: ['device'] }, config: Flags.boolean({ default: false, char: 'c', description: 'show configuration variables only', exclusive: ['service'], }), device: { ...cf.device, exclusive: ['fleet'] }, help: cf.help, json: cf.json, service: { ...cf.service, exclusive: ['config'] }, }; public async run() { const { flags: options } = await this.parse(EnvsCmd); const variables: EnvironmentVariableInfo[] = []; await Command.checkLoggedIn(); if (!options.fleet && !options.device) { throw new ExpectedError('Missing --fleet or --device option'); } const balena = getBalenaSdk(); let fleetSlug: string | undefined = options.fleet ? await ( await import('../../utils/sdk') ).getFleetSlug(balena, options.fleet) : undefined; let fullUUID: string | undefined; // as oppposed to the short, 7-char UUID if (options.device) { const { getDeviceAndMaybeAppFromUUID } = await import( '../../utils/cloud' ); const [device, app] = await getDeviceAndMaybeAppFromUUID( balena, options.device, ['uuid'], ['slug'], ); fullUUID = device.uuid; if (app) { fleetSlug = app.slug; } } if (fleetSlug && options.service) { await validateServiceName(balena, options.service, fleetSlug); } variables.push(...(await getAppVars(balena, fleetSlug, options))); if (fullUUID) { variables.push( ...(await getDeviceVars(balena, fullUUID, fleetSlug, options)), ); } if (!options.json && variables.length === 0) { const target = (options.service ? `service "${options.service}" of ` : '') + (options.fleet ? `fleet "${options.fleet}"` : `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 fields = ['id', 'name', 'value', 'fleet']; // Replace undefined app names with 'N/A' or null varArray = varArray.map((i: EnvironmentVariableInfo) => { i.fleet ||= options.json ? null : 'N/A'; return i; }); 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, ), ); } } } async function validateServiceName( sdk: SDK.BalenaSDK, serviceName: string, fleetSlug: string, ) { const services = await sdk.models.service.getAllByApplication(fleetSlug, { $select: 'id', $filter: { service_name: serviceName }, }); if (services.length === 0) { throw new ExpectedError( `Service "${serviceName}" not found for fleet "${fleetSlug}"`, ); } } /** * 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, fleetSlug: string | undefined, options: FlagsDef, ): Promise { const appVars: EnvironmentVariableInfo[] = []; if (!fleetSlug) { return appVars; } const vars = await sdk.models.application[ options.config ? 'configVar' : 'envVar' ].getAllByApplication(fleetSlug); fillInInfoFields(vars, fleetSlug); appVars.push(...vars); if (!options.config) { const pineOpts: SDK.PineOptions = { $expand: { service: {}, }, }; if (options.service) { pineOpts.$filter = { service: { service_name: options.service, }, }; } const serviceVars = await sdk.models.service.var.getAllByApplication( fleetSlug, pineOpts, ); fillInInfoFields(serviceVars, fleetSlug); 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, fleetSlug: string | undefined, options: FlagsDef, ): Promise { const printedUUID = options.json ? fullUUID : options.device!; const deviceVars: EnvironmentVariableInfo[] = []; if (options.config) { const deviceConfigVars = await sdk.models.device.configVar.getAllByDevice(fullUUID); fillInInfoFields(deviceConfigVars, fleetSlug, printedUUID); deviceVars.push(...deviceConfigVars); } else { const pineOpts: SDK.PineOptions = { $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, fleetSlug, printedUUID); deviceVars.push(...deviceServiceVars); const deviceEnvVars = await sdk.models.device.envVar.getAllByDevice(fullUUID); fillInInfoFields(deviceEnvVars, fleetSlug, 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[], fleetSlug?: string, deviceUUID?: string, ) { for (const envVar of varArray) { if ('service' in envVar) { // envVar is of type ServiceEnvironmentVariableInfo envVar.serviceName = (envVar.service as SDK.Service[])[0]?.service_name; } else if ('service_install' in envVar) { // envVar is of type DeviceServiceEnvironmentVariableInfo envVar.serviceName = ( (envVar.service_install as SDK.ServiceInstall[])[0] ?.installs__service as SDK.Service[] )[0]?.service_name; } envVar.fleet = fleetSlug; envVar.serviceName = envVar.serviceName || '*'; envVar.deviceUUID = deviceUUID || '*'; } }