diff --git a/lib/app.ts b/lib/app.ts index 9238b77c..972f6330 100644 --- a/lib/app.ts +++ b/lib/app.ts @@ -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 diff --git a/lib/command.ts b/lib/command.ts index 8c0bb12d..1755a76b 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -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(); } diff --git a/lib/commands/config/inject.ts b/lib/commands/config/inject.ts index efa79078..72f1687e 100644 --- a/lib/commands/config/inject.ts +++ b/lib/commands/config/inject.ts @@ -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( diff --git a/lib/commands/config/read.ts b/lib/commands/config/read.ts index 33d8da70..f637b176 100644 --- a/lib/commands/config/read.ts +++ b/lib/commands/config/read.ts @@ -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(ConfigReadCmd); diff --git a/lib/commands/config/reconfigure.ts b/lib/commands/config/reconfigure.ts index 98621359..7f0252af 100644 --- a/lib/commands/config/reconfigure.ts +++ b/lib/commands/config/reconfigure.ts @@ -63,7 +63,6 @@ export default class ConfigReconfigureCmd extends Command { }; public static authenticated = true; - public static root = true; public async run() { diff --git a/lib/commands/config/write.ts b/lib/commands/config/write.ts index 68525fb6..964d97ca 100644 --- a/lib/commands/config/write.ts +++ b/lib/commands/config/write.ts @@ -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( diff --git a/lib/commands/internal/osinit.ts b/lib/commands/internal/osinit.ts index 1a523b9a..197202be 100644 --- a/lib/commands/internal/osinit.ts +++ b/lib/commands/internal/osinit.ts @@ -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); diff --git a/lib/commands/local/configure.ts b/lib/commands/local/configure.ts index 4255b00f..2f8203e3 100644 --- a/lib/commands/local/configure.ts +++ b/lib/commands/local/configure.ts @@ -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(LocalConfigureCmd); diff --git a/lib/commands/local/flash.ts b/lib/commands/local/flash.ts index 147da4c7..cb2f445e 100644 --- a/lib/commands/local/flash.ts +++ b/lib/commands/local/flash.ts @@ -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( LocalFlashCmd, diff --git a/lib/commands/scan.ts b/lib/commands/scan.ts index a7462a76..89fbc8aa 100644 --- a/lib/commands/scan.ts +++ b/lib/commands/scan.ts @@ -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'); diff --git a/lib/commands/ssh.ts b/lib/commands/ssh.ts index b61e20bb..a902369d 100644 --- a/lib/commands/ssh.ts +++ b/lib/commands/ssh.ts @@ -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( @@ -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, diff --git a/lib/commands/util/available-drives.ts b/lib/commands/util/available-drives.ts index 23f51b07..cf487390 100644 --- a/lib/commands/util/available-drives.ts +++ b/lib/commands/util/available-drives.ts @@ -38,6 +38,8 @@ export default class UtilAvailableDrivesCmd extends Command { help: cf.help, }; + public static offlineCompatible = true; + public async run() { this.parse(UtilAvailableDrivesCmd); diff --git a/lib/commands/version.ts b/lib/commands/version.ts index ffa9637f..6eeb8563 100644 --- a/lib/commands/version.ts +++ b/lib/commands/version.ts @@ -57,6 +57,8 @@ export default class VersionCmd extends Command { public static usage = 'version'; + public static offlineCompatible = true; + public static flags: flags.Input = { all: flags.boolean({ default: false, diff --git a/lib/deprecation.ts b/lib/deprecation.ts index 183ee233..44239dc4 100644 --- a/lib/deprecation.ts +++ b/lib/deprecation.ts @@ -104,7 +104,11 @@ export class DeprecationChecker { const url = this.getNpmUrl(version); let response: import('got').Response> | 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) { diff --git a/lib/errors.ts b/lib/errors.ts index 29ace365..2552b7e0 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -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.`); diff --git a/lib/events.ts b/lib/events.ts index 5f970164..8dd12d59 100644 --- a/lib/events.ts +++ b/lib/events.ts @@ -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 } } diff --git a/lib/utils/bootstrap.ts b/lib/utils/bootstrap.ts index b1bcf64d..6d15f844 100644 --- a/lib/utils/bootstrap.ts +++ b/lib/utils/bootstrap.ts @@ -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