diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 09524c52..d3bd1432 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -138,8 +138,8 @@ const capitanoDoc = { { title: 'Local', files: [ + 'build/actions-oclif/local/configure.js', 'build/actions-oclif/local/flash.js', - 'build/actions/local/index.js', ], }, { diff --git a/doc/cli.markdown b/doc/cli.markdown index 44e9af8d..61f8e469 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -250,8 +250,8 @@ Users are encouraged to regularly update the balena CLI to the latest version. - Local - - [local flash <image>](#local-flash-image) - [local configure <target>](#local-configure-target) + - [local flash <image>](#local-flash-image) - Deploy @@ -2161,6 +2161,23 @@ Examples: # Local +## local configure <target> + +Configure or reconfigure a balenaOS drive or image. + +Examples: + + $ balena local configure /dev/sdc + $ balena local configure path/to/image.img + +### Arguments + +#### TARGET + +path of drive or image to configure + +### Options + ## local flash <image> Flash a balenaOS image to a drive. @@ -2191,15 +2208,6 @@ drive to flash answer "yes" to all questions (non interactive use) -## local configure <target> - -Use this command to configure or reconfigure a balenaOS drive or image. - -Examples: - - $ balena local configure /dev/sdc - $ balena local configure path/to/image.img - # Deploy ## build [source] diff --git a/lib/actions-oclif/local/configure.ts b/lib/actions-oclif/local/configure.ts new file mode 100644 index 00000000..b5849515 --- /dev/null +++ b/lib/actions-oclif/local/configure.ts @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2016-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 { flags } from '@oclif/command'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { stripIndent } from '../../utils/lazy'; + +interface FlagsDef { + help: void; +} + +interface ArgsDef { + target: string; +} + +export default class LocalConfigureCmd extends Command { + public static description = stripIndent` + (Re)configure a balenaOS drive or image. + + Configure or reconfigure a balenaOS drive or image. + `; + + public static examples = [ + '$ balena local configure /dev/sdc', + '$ balena local configure path/to/image.img', + ]; + + public static args = [ + { + name: 'target', + description: 'path of drive or image to configure', + required: true, + }, + ]; + + public static usage = 'local configure '; + + public static flags: flags.Input = { + help: cf.help, + }; + + public static root = true; + + public async run() { + const { args: params } = this.parse(LocalConfigureCmd); + + const Bluebird = await import('bluebird'); + const path = await import('path'); + const umount = await import('umount'); + const umountAsync = Bluebird.promisify(umount.umount); + const isMountedAsync = Bluebird.promisify(umount.isMounted); + const reconfix = await import('reconfix'); + const denymount = Bluebird.promisify(await import('denymount')); + const Logger = await import('../../utils/logger'); + + const logger = Logger.getLogger(); + + const configurationSchema = await this.prepareConnectionFile(params.target); + + if (await isMountedAsync(params.target)) { + await umountAsync(params.target); + } + + const dmOpts: any = {}; + if (process.pkg) { + // when running in a standalone pkg install, the 'denymount' + // executable is placed on the same folder as process.execPath + dmOpts.executablePath = path.join( + path.dirname(process.execPath), + 'denymount', + ); + } + + const dmHandler = (cb: () => void) => + reconfix + .readConfiguration(configurationSchema, params.target) + .tap((config: any) => { + logger.logDebug('Current config:'); + logger.logDebug(JSON.stringify(config)); + }) + .then((config: any) => this.getConfiguration(config)) + .tap((config: any) => { + logger.logDebug('New config:'); + logger.logDebug(JSON.stringify(config)); + }) + .then(async (answers: any) => { + if (!answers.hostname) { + await this.removeHostname(configurationSchema); + } + return reconfix.writeConfiguration( + configurationSchema, + answers, + params.target, + ); + }) + .asCallback(cb); + + await denymount(params.target, dmHandler, dmOpts); + + console.log('Done!'); + } + + readonly BOOT_PARTITION = 1; + readonly CONNECTIONS_FOLDER = '/system-connections'; + + getConfigurationSchema(connectionFileName?: string) { + if (connectionFileName == null) { + connectionFileName = 'resin-wifi'; + } + return { + mapper: [ + { + template: { + persistentLogging: '{{persistentLogging}}', + }, + domain: [['config_json', 'persistentLogging']], + }, + { + template: { + hostname: '{{hostname}}', + }, + domain: [['config_json', 'hostname']], + }, + { + template: { + wifi: { + ssid: '{{networkSsid}}', + }, + 'wifi-security': { + psk: '{{networkKey}}', + }, + }, + domain: [ + ['system_connections', connectionFileName, 'wifi'], + ['system_connections', connectionFileName, 'wifi-security'], + ], + }, + ], + files: { + system_connections: { + fileset: true, + type: 'ini', + location: { + path: this.CONNECTIONS_FOLDER.slice(1), + // Reconfix still uses the older resin-image-fs, so still needs an + // object-based partition definition. + partition: this.BOOT_PARTITION, + }, + }, + config_json: { + type: 'json', + location: { + path: 'config.json', + partition: this.BOOT_PARTITION, + }, + }, + }, + }; + } + + inquirerOptions = (data: any) => [ + { + message: 'Network SSID', + type: 'input', + name: 'networkSsid', + default: data.networkSsid, + }, + { + message: 'Network Key', + type: 'input', + name: 'networkKey', + default: data.networkKey, + }, + { + message: 'Do you want to set advanced settings?', + type: 'confirm', + name: 'advancedSettings', + default: false, + }, + { + message: 'Device Hostname', + type: 'input', + name: 'hostname', + default: data.hostname, + when(answers: any) { + return answers.advancedSettings; + }, + }, + { + message: 'Do you want to enable persistent logging?', + type: 'confirm', + name: 'persistentLogging', + default: data.persistentLogging, + when(answers: any) { + return answers.advancedSettings; + }, + }, + ]; + + getConfiguration = async (data: any) => { + const _ = await import('lodash'); + const inquirer = await import('inquirer'); + + // `persistentLogging` can be `undefined`, so we want + // to make sure that case defaults to `false` + data = _.assign(data, { + persistentLogging: data.persistentLogging || false, + }); + + return inquirer + .prompt(this.inquirerOptions(data)) + .then((answers: any) => _.merge(data, answers)); + }; + + // Taken from https://goo.gl/kr1kCt + readonly CONNECTION_FILE = stripIndent` + [connection] + id=resin-wifi + type=wifi + + [wifi] + hidden=true + mode=infrastructure + ssid=My_Wifi_Ssid + + [wifi-security] + auth-alg=open + key-mgmt=wpa-psk + psk=super_secret_wifi_password + + [ipv4] + method=auto + + [ipv6] + addr-gen-mode=stable-privacy + method=auto\ + `; + + /* + * if the `resin-wifi` file exists (previously configured image or downloaded from the UI) it's used and reconfigured + * if the `resin-sample.ignore` exists it's copied to `resin-wifi` + * if the `resin-sample` exists it's reconfigured (legacy mode, will be removed eventually) + * otherwise, the new file is created + */ + async prepareConnectionFile(target: string) { + const _ = await import('lodash'); + const imagefs = await import('resin-image-fs'); + + return imagefs + .listDirectory({ + image: target, + partition: this.BOOT_PARTITION, + path: this.CONNECTIONS_FOLDER, + }) + .then((files: string[]) => { + // The required file already exists + if (_.includes(files, 'resin-wifi')) { + return null; + } + + // Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files + if (_.includes(files, 'resin-sample.ignore')) { + return imagefs + .copy( + { + image: target, + partition: this.BOOT_PARTITION, + path: `${this.CONNECTIONS_FOLDER}/resin-sample.ignore`, + }, + { + image: target, + partition: this.BOOT_PARTITION, + path: `${this.CONNECTIONS_FOLDER}/resin-wifi`, + }, + ) + .thenReturn(null); + } + + // Legacy mode, to be removed later + // We return the file name override from this branch + // When it is removed the following cleanup should be done: + // * delete all the null returns from this method + // * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi` + // * drop the final `then` from this method + // * adapt the code in the main listener to not receive the config from this method, and use that constant instead + if (_.includes(files, 'resin-sample')) { + return 'resin-sample'; + } + + // In case there's no file at all (shouldn't happen normally, but the file might have been removed) + return imagefs + .writeFile( + { + image: target, + partition: this.BOOT_PARTITION, + path: `${this.CONNECTIONS_FOLDER}/resin-wifi`, + }, + this.CONNECTION_FILE, + ) + .thenReturn(null); + }) + .then((connectionFileName) => + this.getConfigurationSchema(connectionFileName || undefined), + ); + } + + async removeHostname(schema: any) { + const _ = await import('lodash'); + schema.mapper = _.reject(schema.mapper, (mapper: any) => + _.isEqual(Object.keys(mapper.template), ['hostname']), + ); + } +} diff --git a/lib/actions-oclif/scan.ts b/lib/actions-oclif/scan.ts index 0740a47e..47f96217 100644 --- a/lib/actions-oclif/scan.ts +++ b/lib/actions-oclif/scan.ts @@ -65,11 +65,11 @@ export default class ScanCmd extends Command { const { discover } = await import('balena-sync'); const prettyjson = await import('prettyjson'); const { ExpectedError } = await import('../errors'); - const { dockerPort, dockerTimeout } = await import( - '../actions/local/common' - ); const dockerUtils = await import('../utils/docker'); + const dockerPort = 2375; + const dockerTimeout = 2000; + const { flags: options } = this.parse(ScanCmd); const discoverTimeout = diff --git a/lib/actions/index.ts b/lib/actions/index.ts index 5d0f1275..ad78fa66 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -16,7 +16,6 @@ limitations under the License. export * as config from './config'; export * as help from './help'; -export * as local from './local'; export * as os from './os'; export * as push from './push'; diff --git a/lib/actions/local/common.js b/lib/actions/local/common.js deleted file mode 100644 index f299d7e8..00000000 --- a/lib/actions/local/common.js +++ /dev/null @@ -1,96 +0,0 @@ -import * as Bluebird from 'bluebird'; -import * as _ from 'lodash'; -import * as dockerUtils from '../../utils/docker'; -import { exitWithExpectedError } from '../../errors'; -import { getChalk, getCliForm } from '../../utils/lazy'; - -export const dockerPort = 2375; -export const dockerTimeout = 2000; - -export const filterOutSupervisorContainer = function (container) { - for (const name of container.Names) { - if ( - name.includes('resin_supervisor') || - name.includes('balena_supervisor') - ) { - return false; - } - } - return true; -}; - -export const selectContainerFromDevice = Bluebird.method(function ( - deviceIp, - filterSupervisor, -) { - if (filterSupervisor == null) { - filterSupervisor = false; - } - const docker = dockerUtils.createClient({ - host: deviceIp, - port: dockerPort, - timeout: dockerTimeout, - }); - - // List all containers, including those not running - return docker.listContainers({ all: true }).then(function (containers) { - containers = containers.filter(function (container) { - if (!filterSupervisor) { - return true; - } - return filterOutSupervisorContainer(container); - }); - if (_.isEmpty(containers)) { - exitWithExpectedError(`No containers found in ${deviceIp}`); - } - - return getCliForm().ask({ - message: 'Select a container', - type: 'list', - choices: _.map(containers, function (container) { - const containerName = container.Names?.[0] || 'Untitled'; - const shortContainerId = ('' + container.Id).substr(0, 11); - - return { - name: `${containerName} (${shortContainerId})`, - value: container.Id, - }; - }), - }); - }); -}); - -export const pipeContainerStream = Bluebird.method(function ({ - deviceIp, - name, - outStream, - follow, -}) { - if (follow == null) { - follow = false; - } - const docker = dockerUtils.createClient({ host: deviceIp, port: dockerPort }); - - const container = docker.getContainer(name); - return container - .inspect() - .then((containerInfo) => containerInfo?.State?.Running) - .then((isRunning) => - container.attach({ - logs: !follow || !isRunning, - stream: follow && isRunning, - stdout: true, - stderr: true, - }), - ) - .then((containerStream) => containerStream.pipe(outStream)) - .catch(function (err) { - err = '' + err.statusCode; - if (err === '404') { - return console.log( - getChalk().red.bold(`Container '${name}' not found.`), - ); - } - throw err; - }); -}); diff --git a/lib/actions/local/configure.js b/lib/actions/local/configure.js deleted file mode 100644 index de656ef0..00000000 --- a/lib/actions/local/configure.js +++ /dev/null @@ -1,285 +0,0 @@ -/* -Copyright 2017-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. -*/ - -const BOOT_PARTITION = 1; -const CONNECTIONS_FOLDER = '/system-connections'; - -const getConfigurationSchema = function (connnectionFileName) { - if (connnectionFileName == null) { - connnectionFileName = 'resin-wifi'; - } - return { - mapper: [ - { - template: { - persistentLogging: '{{persistentLogging}}', - }, - domain: [['config_json', 'persistentLogging']], - }, - { - template: { - hostname: '{{hostname}}', - }, - domain: [['config_json', 'hostname']], - }, - { - template: { - wifi: { - ssid: '{{networkSsid}}', - }, - 'wifi-security': { - psk: '{{networkKey}}', - }, - }, - domain: [ - ['system_connections', connnectionFileName, 'wifi'], - ['system_connections', connnectionFileName, 'wifi-security'], - ], - }, - ], - files: { - system_connections: { - fileset: true, - type: 'ini', - location: { - path: CONNECTIONS_FOLDER.slice(1), - // Reconfix still uses the older resin-image-fs, so still needs an - // object-based partition definition. - partition: BOOT_PARTITION, - }, - }, - config_json: { - type: 'json', - location: { - path: 'config.json', - partition: BOOT_PARTITION, - }, - }, - }, - }; -}; - -const inquirerOptions = (data) => [ - { - message: 'Network SSID', - type: 'input', - name: 'networkSsid', - default: data.networkSsid, - }, - { - message: 'Network Key', - type: 'input', - name: 'networkKey', - default: data.networkKey, - }, - { - message: 'Do you want to set advanced settings?', - type: 'confirm', - name: 'advancedSettings', - default: false, - }, - { - message: 'Device Hostname', - type: 'input', - name: 'hostname', - default: data.hostname, - when(answers) { - return answers.advancedSettings; - }, - }, - { - message: 'Do you want to enable persistent logging?', - type: 'confirm', - name: 'persistentLogging', - default: data.persistentLogging, - when(answers) { - return answers.advancedSettings; - }, - }, -]; - -const getConfiguration = function (data) { - const _ = require('lodash'); - const inquirer = require('inquirer'); - - // `persistentLogging` can be `undefined`, so we want - // to make sure that case defaults to `false` - data = _.assign(data, { persistentLogging: data.persistentLogging || false }); - - return inquirer - .prompt(inquirerOptions(data)) - .then((answers) => _.merge(data, answers)); -}; - -// Taken from https://goo.gl/kr1kCt -const CONNECTION_FILE = `\ -[connection] -id=resin-wifi -type=wifi - -[wifi] -hidden=true -mode=infrastructure -ssid=My_Wifi_Ssid - -[wifi-security] -auth-alg=open -key-mgmt=wpa-psk -psk=super_secret_wifi_password - -[ipv4] -method=auto - -[ipv6] -addr-gen-mode=stable-privacy -method=auto\ -`; - -/* - * if the `resin-wifi` file exists (previously configured image or downloaded from the UI) it's used and reconfigured - * if the `resin-sample.ignore` exists it's copied to `resin-wifi` - * if the `resin-sample` exists it's reconfigured (legacy mode, will be removed eventually) - * otherwise, the new file is created - */ -const prepareConnectionFile = function (target) { - const _ = require('lodash'); - const imagefs = require('resin-image-fs'); - - return imagefs - .listDirectory({ - image: target, - partition: BOOT_PARTITION, - path: CONNECTIONS_FOLDER, - }) - .then(function (files) { - // The required file already exists - if (_.includes(files, 'resin-wifi')) { - return null; - } - - // Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files - if (_.includes(files, 'resin-sample.ignore')) { - return imagefs - .copy( - { - image: target, - partition: BOOT_PARTITION, - path: `${CONNECTIONS_FOLDER}/resin-sample.ignore`, - }, - { - image: target, - partition: BOOT_PARTITION, - path: `${CONNECTIONS_FOLDER}/resin-wifi`, - }, - ) - .thenReturn(null); - } - - // Legacy mode, to be removed later - // We return the file name override from this branch - // When it is removed the following cleanup should be done: - // * delete all the null returns from this method - // * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi` - // * drop the final `then` from this method - // * adapt the code in the main listener to not receive the config from this method, and use that constant instead - if (_.includes(files, 'resin-sample')) { - return 'resin-sample'; - } - - // In case there's no file at all (shouldn't happen normally, but the file might have been removed) - return imagefs - .writeFile( - { - image: target, - partition: BOOT_PARTITION, - path: `${CONNECTIONS_FOLDER}/resin-wifi`, - }, - CONNECTION_FILE, - ) - .thenReturn(null); - }) - .then((connectionFileName) => getConfigurationSchema(connectionFileName)); -}; - -const removeHostname = function (schema) { - const _ = require('lodash'); - schema.mapper = _.reject(schema.mapper, (mapper) => - _.isEqual(Object.keys(mapper.template), ['hostname']), - ); -}; - -export const configure = { - signature: 'local configure ', - description: '(Re)configure a balenaOS drive or image', - help: `\ -Use this command to configure or reconfigure a balenaOS drive or image. - -Examples: - - $ balena local configure /dev/sdc - $ balena local configure path/to/image.img\ -`, - root: true, - action(params) { - const Bluebird = require('bluebird'); - const path = require('path'); - const umount = require('umount'); - const umountAsync = Bluebird.promisify(umount.umount); - const isMountedAsync = Bluebird.promisify(umount.isMounted); - const reconfix = require('reconfix'); - const denymount = Bluebird.promisify(require('denymount')); - - return prepareConnectionFile(params.target) - .tap(() => - isMountedAsync(params.target).then(function (isMounted) { - if (!isMounted) { - return; - } - return umountAsync(params.target); - }), - ) - .then(function (configurationSchema) { - const dmOpts = {}; - if (process.pkg) { - // when running in a standalone pkg install, the 'denymount' - // executable is placed on the same folder as process.execPath - dmOpts.executablePath = path.join( - path.dirname(process.execPath), - 'denymount', - ); - } - const dmHandler = (cb) => - reconfix - .readConfiguration(configurationSchema, params.target) - .then(getConfiguration) - .then(function (answers) { - if (!answers.hostname) { - removeHostname(configurationSchema); - } - return reconfix.writeConfiguration( - configurationSchema, - answers, - params.target, - ); - }) - .asCallback(cb); - return denymount(params.target, dmHandler, dmOpts); - }) - .then(() => { - console.log('Done!'); - }); - }, -}; diff --git a/lib/actions/local/index.ts b/lib/actions/local/index.ts deleted file mode 100644 index d67b23ec..00000000 --- a/lib/actions/local/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2017-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. -*/ - -export { configure } from './configure'; diff --git a/lib/app-capitano.ts b/lib/app-capitano.ts index 72075ea5..5e07fcbe 100644 --- a/lib/app-capitano.ts +++ b/lib/app-capitano.ts @@ -62,9 +62,6 @@ capitano.command(actions.config.generate); // ---------- Preload Module ---------- capitano.command(actions.preload); -// ---------- Local balenaOS Module ---------- -capitano.command(actions.local.configure); - // ------------ Local build and deploy ------- capitano.command(actions.build); capitano.command(actions.deploy); diff --git a/lib/preparser.ts b/lib/preparser.ts index 2a6f0390..400c54cf 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -173,6 +173,7 @@ export const convertedCommands = [ 'key:add', 'key:rm', 'leave', + 'local:configure', 'local:flash', 'login', 'logout', diff --git a/typings/reconfix/index.d.ts b/typings/reconfix/index.d.ts new file mode 100644 index 00000000..6b3b929a --- /dev/null +++ b/typings/reconfix/index.d.ts @@ -0,0 +1,18 @@ +/** + * @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. + */ + +declare module 'reconfix';