Convert local configure to oclif, typescript

Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2020-07-10 13:27:55 +02:00
parent 457eff1d43
commit 4f831ef443
11 changed files with 369 additions and 416 deletions

View File

@ -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',
],
},
{

View File

@ -250,8 +250,8 @@ Users are encouraged to regularly update the balena CLI to the latest version.
- Local
- [local flash &#60;image&#62;](#local-flash-image)
- [local configure &#60;target&#62;](#local-configure-target)
- [local flash &#60;image&#62;](#local-flash-image)
- Deploy
@ -2161,6 +2161,23 @@ Examples:
# Local
## local configure &#60;target&#62;
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 &#60;image&#62;
Flash a balenaOS image to a drive.
@ -2191,15 +2208,6 @@ drive to flash
answer "yes" to all questions (non interactive use)
## local configure &#60;target&#62;
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]

View File

@ -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 <target>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static root = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(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']),
);
}
}

View File

@ -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<FlagsDef, {}>(ScanCmd);
const discoverTimeout =

View File

@ -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';

View File

@ -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;
});
});

View File

@ -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 <target>',
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!');
});
},
};

View File

@ -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';

View File

@ -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);

View File

@ -173,6 +173,7 @@ export const convertedCommands = [
'key:add',
'key:rm',
'leave',
'local:configure',
'local:flash',
'login',
'logout',

18
typings/reconfix/index.d.ts vendored Normal file
View File

@ -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';