Improve UX for offline usage

Change-type: minor
Resolves: #2372
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2021-11-12 16:03:29 +00:00
parent 0d1ca67d5b
commit 2b6a2142eb
17 changed files with 101 additions and 10 deletions

View File

@ -77,11 +77,13 @@ export function setMaxListeners(maxListeners: number) {
/** Selected CLI initialization steps */
async function init() {
if (process.env.BALENARC_NO_SENTRY) {
console.error(`WARN: disabling Sentry.io error reporting`);
if (process.env.DEBUG) {
console.error(`WARN: disabling Sentry.io error reporting`);
}
} else {
await setupSentry();
}
checkNodeVersion();
await checkNodeVersion();
const settings = new CliSettings();
@ -91,7 +93,9 @@ async function init() {
setupBalenaSdkSharedOptions(settings);
// check for CLI updates once a day
(await import('./utils/update')).notify();
if (!process.env.BALENARC_OFFLINE_MODE) {
(await import('./utils/update')).notify();
}
}
/** Execute the oclif parser and the CLI command. */
@ -149,7 +153,10 @@ async function oclifRun(command: string[], options: AppOptions) {
/** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */
export async function run(cliArgs = process.argv, options: AppOptions = {}) {
try {
const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
const { setOfflineModeEnvVars, normalizeEnvVars, pkgExec } = await import(
'./utils/bootstrap'
);
setOfflineModeEnvVars();
normalizeEnvVars();
// The 'pkgExec' special/internal command provides a Node.js interpreter

View File

@ -16,7 +16,11 @@
*/
import Command from '@oclif/command';
import { InsufficientPrivilegesError } from './errors';
import {
InsufficientPrivilegesError,
NotAvailableInOfflineModeError,
} from './errors';
import { stripIndent } from './utils/lazy';
export default abstract class BalenaCommand extends Command {
/**
@ -40,6 +44,13 @@ export default abstract class BalenaCommand extends Command {
*/
public static authenticated = false;
/**
* Require an internet connection to run.
* When set to true, command will exit with an error
* if user is running in offline mode (BALENARC_OFFLINE_MODE).
*/
public static offlineCompatible = false;
/**
* Accept piped input.
* When set to true, command will read from stdin during init
@ -97,6 +108,29 @@ export default abstract class BalenaCommand extends Command {
}
}
/**
* Throw NotAvailableInOfflineModeError if in offline mode.
*
* Called automatically if `onlineOnly=true`.
* Can be called explicitly by command implementation, if e.g.:
* - check should only be done conditionally
* - other code needs to execute before check
*
* Note, currently public to allow use outside of derived commands
* (as some command implementations require this. Can be made protected
* if this changes).
*
* @throws {NotAvailableInOfflineModeError}
*/
public static checkNotUsingOfflineMode() {
if (process.env.BALENARC_OFFLINE_MODE) {
throw new NotAvailableInOfflineModeError(stripIndent`
This command requires an internet connection, and cannot be used in offline mode.
To leave offline mode, unset the BALENARC_OFFLINE_MODE environment variable.
`);
}
}
/**
* Read stdin contents and make available to command.
*
@ -125,6 +159,10 @@ export default abstract class BalenaCommand extends Command {
await BalenaCommand.checkLoggedIn();
}
if (!ctr.offlineCompatible) {
BalenaCommand.checkNotUsingOfflineMode();
}
if (ctr.readStdin) {
await this.getStdin();
}

View File

@ -63,6 +63,7 @@ export default class ConfigInjectCmd extends Command {
};
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(

View File

@ -54,6 +54,7 @@ export default class ConfigReadCmd extends Command {
};
public static root = true;
public static offlineCompatible = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReadCmd);

View File

@ -63,7 +63,6 @@ export default class ConfigReconfigureCmd extends Command {
};
public static authenticated = true;
public static root = true;
public async run() {

View File

@ -70,6 +70,7 @@ export default class ConfigWriteCmd extends Command {
};
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(

View File

@ -63,6 +63,7 @@ export default class OsinitCmd extends Command {
public static hidden = true;
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params } = this.parse<{}, ArgsDef>(OsinitCmd);

View File

@ -56,6 +56,7 @@ export default class LocalConfigureCmd extends Command {
};
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(LocalConfigureCmd);

View File

@ -65,6 +65,8 @@ export default class LocalFlashCmd extends Command {
help: cf.help,
};
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
LocalFlashCmd,

View File

@ -68,6 +68,7 @@ export default class ScanCmd extends Command {
public static primary = true;
public static root = true;
public static offlineCompatible = true;
public async run() {
const _ = await import('lodash');

View File

@ -117,6 +117,7 @@ export default class SshCmd extends Command {
};
public static primary = true;
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
@ -144,6 +145,7 @@ export default class SshCmd extends Command {
const useProxy = !!proxyConfig && !options.noproxy;
// this will be a tunnelled SSH connection...
await Command.checkNotUsingOfflineMode();
await Command.checkLoggedIn();
const deviceUuid = await getOnlineTargetDeviceUuid(
sdk,

View File

@ -38,6 +38,8 @@ export default class UtilAvailableDrivesCmd extends Command {
help: cf.help,
};
public static offlineCompatible = true;
public async run() {
this.parse<FlagsDef, {}>(UtilAvailableDrivesCmd);

View File

@ -57,6 +57,8 @@ export default class VersionCmd extends Command {
public static usage = 'version';
public static offlineCompatible = true;
public static flags: flags.Input<FlagsDef> = {
all: flags.boolean({
default: false,

View File

@ -104,7 +104,11 @@ export class DeprecationChecker {
const url = this.getNpmUrl(version);
let response: import('got').Response<Dictionary<any>> | undefined;
try {
response = await got(url, { responseType: 'json', retry: 0 });
response = await got(url, {
responseType: 'json',
retry: 0,
timeout: 4000,
});
} catch (e) {
// 404 is expected if `version` hasn't been published yet
if (e.response?.statusCode !== 404) {

View File

@ -31,6 +31,8 @@ export class NotLoggedInError extends ExpectedError {}
export class InsufficientPrivilegesError extends ExpectedError {}
export class NotAvailableInOfflineModeError extends ExpectedError {}
export class InvalidPortMappingError extends ExpectedError {
constructor(mapping: string) {
super(`'${mapping}' is not a valid port mapping.`);

View File

@ -16,7 +16,7 @@
*/
import * as packageJSON from '../package.json';
import { getBalenaSdk } from './utils/lazy';
import { getBalenaSdk, stripIndent } from './utils/lazy';
interface CachedUsername {
token: string;
@ -129,10 +129,20 @@ async function sendEvent(balenaUrl: string, event: string, username?: string) {
data: Buffer.from(JSON.stringify(trackData)).toString('base64'),
};
try {
await got(url, { searchParams, retry: 0 });
await got(url, { searchParams, retry: 0, timeout: 4000 });
} catch (e) {
if (process.env.DEBUG) {
console.error(`[debug] Event tracking error: ${e.message || e}`);
}
if (e instanceof got.TimeoutError) {
console.error(stripIndent`
Timeout submitting analytics event to balenaCloud/openBalena.
If you are using the balena CLI in an air-gapped environment with a filtered
internet connection, set the BALENARC_OFFLINE_MODE=1 environment variable
when using CLI commands that do not strictly require access to balenaCloud.
`);
}
// Note: You can simulate a timeout using non-routable address 10.0.0.0
}
}

View File

@ -58,7 +58,13 @@ export function normalizeEnvVar(varName: string) {
process.env[varName] = parseBoolEnvVar(varName) ? '1' : '';
}
const bootstrapVars = ['DEBUG', 'BALENARC_NO_SENTRY'];
const bootstrapVars = [
'BALENARC_NO_SENTRY',
'BALENARC_NO_ANALYTICS',
'BALENARC_OFFLINE_MODE',
'BALENARC_UNSUPPORTED',
'DEBUG',
];
export function normalizeEnvVars(varNames: string[] = bootstrapVars) {
for (const varName of varNames) {
@ -66,6 +72,17 @@ export function normalizeEnvVars(varNames: string[] = bootstrapVars) {
}
}
/**
* Set the individual env vars implied by BALENARC_OFFLINE_MODE.
*/
export function setOfflineModeEnvVars() {
if (process.env.BALENARC_OFFLINE_MODE) {
process.env.BALENARC_UNSUPPORTED = '1';
process.env.BALENARC_NO_SENTRY = '1';
process.env.BALENARC_NO_ANALYTICS = '1';
}
}
/**
* Implements the 'pkgExec' command, used as a way to provide a Node.js
* interpreter for child_process.spawn()-like operations when the CLI is