diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index d4650cf6..a5eae670 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -137,7 +137,7 @@ const capitanoDoc = { }, { title: 'Preload', - files: ['build/actions/preload.js'], + files: ['build/actions-oclif/preload.js'], }, { title: 'Push', diff --git a/doc/cli.markdown b/doc/cli.markdown index c2c64acc..0a973903 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -1966,58 +1966,64 @@ Examples: $ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png $ balena preload balena.img +### Arguments + +#### IMAGE + +the image file path + ### Options -#### --app, -a <appId> +#### -a, --app APP -Name, slug or numeric ID of the application to preload +name, slug or numeric ID of the application to preload -#### --commit, -c <hash> +#### -c, --commit COMMIT The commit hash for a specific application release to preload, use "current" to specify the current release (ignored if no appId is given). The current release is usually also the latest, but can be manually pinned using https://github.com/balena-io-projects/staged-releases . -#### --splash-image, -s <splashImage.png> +#### -s, --splash-image SPLASH-IMAGE path to a png image to replace the splash screen #### --dont-check-arch -Disables check for matching architecture in image and application +disables check for matching architecture in image and application -#### --pin-device-to-release, -p +#### -p, --pin-device-to-release -Pin the preloaded device to the preloaded release on provision +pin the preloaded device to the preloaded release on provision -#### --add-certificate <certificate.crt> +#### --add-certificate ADD-CERTIFICATE Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container. The file name must end with '.crt' and must not be already contained in the preloader's /etc/ssl/certs folder. Can be repeated to add multiple certificates. -#### --docker, -P <docker> +#### -P, --docker DOCKER Path to a local docker socket (e.g. /var/run/docker.sock) -#### --dockerHost, -h <dockerHost> +#### -h, --dockerHost DOCKERHOST Docker daemon hostname or IP address (dev machine or balena device) -#### --dockerPort <dockerPort> +#### --dockerPort DOCKERPORT Docker daemon TCP port number (hint: 2375 for balena devices) -#### --ca <ca> +#### --ca CA Docker host TLS certificate authority file -#### --cert <cert> +#### --cert CERT Docker host TLS certificate file -#### --key <key> +#### --key KEY Docker host TLS key file diff --git a/lib/actions-oclif/preload.ts b/lib/actions-oclif/preload.ts new file mode 100644 index 00000000..03d7c12a --- /dev/null +++ b/lib/actions-oclif/preload.ts @@ -0,0 +1,552 @@ +/** + * @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 { + getBalenaSdk, + getCliForm, + getVisuals, + stripIndent, +} from '../utils/lazy'; +import type { DockerConnectionCliFlags } from '../utils/docker'; +import { dockerConnectionCliFlags } from '../utils/docker'; +import * as _ from 'lodash'; +import type { + Application, + BalenaSDK, + DeviceTypeJson, + PineOptions, + Release, +} from 'balena-sdk'; +import type { Preloader } from 'balena-preload'; +import { parseAsInteger } from '../utils/validation'; +import { ExpectedError } from '../errors'; +import type { ResourceExpand } from 'pinejs-client-core'; + +interface FlagsDef extends DockerConnectionCliFlags { + app?: string; + appId?: string; // Internal use. + commit?: string; + 'splash-image'?: string; + splashImage?: string; // Internal use. + 'dont-check-arch': boolean; + dontCheckArch?: boolean; // Internal use. + 'pin-device-to-release': boolean; + pinDevice?: boolean; // Internal use. + 'add-certificate'?: string; + image?: string; // Internal use. + proxy?: string; // Internal use. + help: void; +} + +interface ArgsDef { + image: string; +} + +export default class PreloadCmd extends Command { + public static description = stripIndent` + Preload an app on a disk image (or Edison zip archive). + + Preload a balena application release (app images/containers), and optionally + a balenaOS splash screen, in a previously downloaded '.img' balenaOS image file + in the local disk (a zip file is only accepted for the Intel Edison device type). + After preloading, the balenaOS image file can be flashed to a device's SD card. + When the device boots, it will not need to download the application, as it was + preloaded. + + Warning: "balena preload" requires Docker to be correctly installed in + your shell environment. For more information (including Windows support) + check: https://github.com/balena-io/balena-cli/blob/master/INSTALL.md + `; + + public static examples = [ + '$ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png', + '$ balena preload balena.img', + ]; + + public static args = [ + { + name: 'image', + description: 'the image file path', + required: true, + }, + ]; + + public static usage = 'preload '; + + public static flags: flags.Input = { + app: flags.string({ + description: 'name, slug or numeric ID of the application to preload', + char: 'a', + }), + commit: flags.string({ + description: `\ +The commit hash for a specific application release to preload, use "current" to specify the current +release (ignored if no appId is given). The current release is usually also the latest, but can be +manually pinned using https://github.com/balena-io-projects/staged-releases .\ +`, + char: 'c', + }), + 'splash-image': flags.string({ + description: 'path to a png image to replace the splash screen', + char: 's', + }), + 'dont-check-arch': flags.boolean({ + description: + 'disables check for matching architecture in image and application', + }), + 'pin-device-to-release': flags.boolean({ + description: + 'pin the preloaded device to the preloaded release on provision', + char: 'p', + }), + 'add-certificate': flags.string({ + description: `\ +Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container. +The file name must end with '.crt' and must not be already contained in the preloader's +/etc/ssl/certs folder. +Can be repeated to add multiple certificates.\ +`, + }), + ...dockerConnectionCliFlags, + // Redefining --dockerPort here (defined already in dockerConnectionCliFlags) + // without -p alias, to avoid clash with -p alias of pin-device-to-release + dockerPort: flags.integer({ + description: + 'Docker daemon TCP port number (hint: 2375 for balena devices)', + parse: (p) => parseAsInteger(p, 'dockerPort'), + }), + // Not supporting -h for help, because of clash with -h in DockerCliFlags + // Revisit this in future release. + help: flags.help({}), + }; + + public static authenticated = true; + + public static primary = true; + + public async run() { + const { args: params, flags: options } = this.parse( + PreloadCmd, + ); + + const balena = getBalenaSdk(); + const balenaPreload = await import('balena-preload'); + const visuals = getVisuals(); + const nodeCleanup = await import('node-cleanup'); + const { instanceOf } = await import('../errors'); + + // Check image file exists + try { + const fs = await import('fs'); + await fs.promises.access(params.image); + } catch (error) { + throw new ExpectedError( + `The provided image path does not exist: ${params.image}`, + ); + } + + const progressBars: { + [key: string]: ReturnType['Progress']; + } = {}; + + const progressHandler = function (event: { + name: string; + percentage: number; + }) { + let progressBar = progressBars[event.name]; + if (!progressBar) { + progressBar = progressBars[event.name] = new visuals.Progress( + event.name, + ); + } + return progressBar.update({ percentage: event.percentage }); + }; + + const spinners: { + [key: string]: ReturnType['Spinner']; + } = {}; + + const spinnerHandler = function (event: { name: string; action: string }) { + let spinner = spinners[event.name]; + if (!spinner) { + spinner = spinners[event.name] = new visuals.Spinner(event.name); + } + if (event.action === 'start') { + return spinner.start(); + } else { + console.log(); + return spinner.stop(); + } + }; + + options.commit = this.isCurrentCommit(options.commit || '') + ? 'latest' + : options.commit; + options.image = params.image; + options.appId = options.app; + delete options.app; + + options.splashImage = options['splash-image']; + delete options['splash-image']; + + options.dontCheckArch = options['dont-check-arch'] || false; + delete options['dont-check-arch']; + if (options.dontCheckArch && !options.appId) { + throw new ExpectedError( + 'You need to specify an app id if you disable the architecture check.', + ); + } + + options.pinDevice = options['pin-device-to-release'] || false; + delete options['pin-device-to-release']; + + let certificates: string[]; + if (Array.isArray(options['add-certificate'])) { + certificates = options['add-certificate']; + } else if (options['add-certificate'] === undefined) { + certificates = []; + } else { + certificates = [options['add-certificate']]; + } + for (const certificate of certificates) { + if (!certificate.endsWith('.crt')) { + throw new ExpectedError('Certificate file name must end with ".crt"'); + } + } + + // Get a configured dockerode instance + const dockerUtils = await import('../utils/docker'); + const docker = await dockerUtils.getDocker(options); + const preloader = new balenaPreload.Preloader( + null, + docker, + options.appId, + options.commit, + options.image, + options.splashImage, + options.proxy, // TODO: Currently always undefined, investigate approach in ssh command. + options.dontCheckArch, + options.pinDevice, + certificates, + ); + + let gotSignal = false; + + nodeCleanup(function (_exitCode, signal) { + if (signal) { + gotSignal = true; + nodeCleanup.uninstall(); // don't call cleanup handler again + preloader.cleanup().then(() => { + // calling process.exit() won't inform parent process of signal + process.kill(process.pid, signal); + }); + return false; + } + }); + + if (process.env.DEBUG) { + preloader.stderr.pipe(process.stderr); + } + + preloader.on('progress', progressHandler); + preloader.on('spinner', spinnerHandler); + + try { + await new Promise((resolve, reject) => { + preloader.on('error', reject); + resolve(this.prepareAndPreload(preloader, balena, options)); + }); + } catch (err) { + if (instanceOf(err, balena.errors.BalenaError)) { + const code = err.code ? `(${err.code})` : ''; + throw new ExpectedError(`${err.message} ${code}`); + } else { + throw err; + } + } finally { + if (!gotSignal) { + await preloader.cleanup(); + } + } + } + + readonly applicationExpandOptions: ResourceExpand = { + owns__release: { + $select: ['id', 'commit', 'end_timestamp', 'composition'], + $orderby: [{ end_timestamp: 'desc' }, { id: 'desc' }], + $expand: { + contains__image: { + $select: ['image'], + $expand: { + image: { + $select: ['image_size', 'is_stored_at__image_location'], + }, + }, + }, + }, + $filter: { + status: 'success', + }, + }, + should_be_running__release: { + $select: 'commit', + }, + }; + + allDeviceTypes: DeviceTypeJson.DeviceType[]; + async getDeviceTypes() { + if (this.allDeviceTypes !== undefined) { + return this.allDeviceTypes; + } + const balena = getBalenaSdk(); + return balena.models.config + .getDeviceTypes() + .then((deviceTypes) => _.sortBy(deviceTypes, 'name')) + .then((deviceTypes) => { + this.allDeviceTypes = deviceTypes; + return deviceTypes; + }); + } + + isCurrentCommit(commit: string) { + return commit === 'latest' || commit === 'current'; + } + + getDeviceTypesWithSameArch(deviceTypeSlug: string) { + return this.getDeviceTypes().then((deviceTypes) => { + const deviceType = _.find(deviceTypes, { slug: deviceTypeSlug }); + if (!deviceType) { + throw new Error( + `Device type "${deviceTypeSlug}" not found in API query`, + ); + } + return _(deviceTypes) + .filter({ arch: deviceType.arch }) + .map('slug') + .value(); + }); + } + + getApplicationsWithSuccessfulBuilds(deviceTypeSlug: string) { + const balena = getBalenaSdk(); + + return this.getDeviceTypesWithSameArch(deviceTypeSlug).then( + (deviceTypes) => { + const options: PineOptions< + ApplicationWithDeviceType & { should_be_running__release: [Release?] } + > = { + $filter: { + is_for__device_type: { + $any: { + $alias: 'dt', + $expr: { + dt: { + slug: { $in: deviceTypes }, + }, + }, + }, + }, + owns__release: { + $any: { + $alias: 'r', + $expr: { + r: { + status: 'success', + }, + }, + }, + }, + }, + $expand: this.applicationExpandOptions, + $select: ['id', 'app_name', 'should_track_latest_release'], + $orderby: 'app_name asc', + }; + return balena.pine.get({ + resource: 'my_application', + options, + }); + }, + ); + } + + selectApplication(deviceTypeSlug: string) { + const visuals = getVisuals(); + + const applicationInfoSpinner = new visuals.Spinner( + 'Downloading list of applications and releases.', + ); + applicationInfoSpinner.start(); + + return this.getApplicationsWithSuccessfulBuilds(deviceTypeSlug).then( + (applications) => { + applicationInfoSpinner.stop(); + if (applications.length === 0) { + throw new ExpectedError( + `You have no apps with successful releases for a '${deviceTypeSlug}' device type.`, + ); + } + return getCliForm().ask({ + message: 'Select an application', + type: 'list', + choices: applications.map((app) => ({ + name: app.app_name, + value: app, + })), + }); + }, + ); + } + + selectApplicationCommit(releases: Release[]) { + if (releases.length === 0) { + throw new ExpectedError('This application has no successful releases.'); + } + const DEFAULT_CHOICE = { name: 'current', value: 'current' }; + const choices = [DEFAULT_CHOICE].concat( + releases.map((release) => ({ + name: `${release.end_timestamp} - ${release.commit}`, + value: release.commit, + })), + ); + return getCliForm().ask({ + message: 'Select a release', + type: 'list', + default: 'current', + choices, + }); + } + + async offerToDisableAutomaticUpdates( + application: Application, + commit: string, + pinDevice: boolean, + ) { + const balena = getBalenaSdk(); + + if ( + this.isCurrentCommit(commit) || + !application.should_track_latest_release || + pinDevice + ) { + return; + } + const message = `\ + +This application is set to track the latest release, and non-pinned devices +are automatically updated when a new release is available. This may lead to +unexpected behavior: The preloaded device will download and install the latest +release once it is online. + +This prompt gives you the opportunity to disable automatic updates for this +application now. Note that this would result in the application being pinned +to the current latest release, rather than some other release that may have +been selected for preloading. The pinned released may be further managed +through the web dashboard or programatically through the balena API / SDK. +Documentation about release policies and app/device pinning can be found at: +https://www.balena.io/docs/learn/deploy/release-strategy/release-policy/ + +Alternatively, the --pin-device-to-release flag may be used to pin only the +preloaded device to the selected release. + +Would you like to disable automatic updates for this application now?\ +`; + return getCliForm() + .ask({ + message, + type: 'confirm', + }) + .then(function (update) { + if (!update) { + return; + } + return balena.pine.patch({ + resource: 'application', + id: application.id, + body: { + should_track_latest_release: false, + }, + }); + }); + } + + getAppWithReleases(balenaSdk: BalenaSDK, appId: string | number) { + return balenaSdk.models.application.get(appId, { + $expand: this.applicationExpandOptions, + }) as Promise; + } + + async prepareAndPreload( + preloader: Preloader, + balenaSdk: BalenaSDK, + options: FlagsDef, + ) { + await preloader.prepare(); + + const application = options.appId + ? await this.getAppWithReleases(balenaSdk, options.appId) + : await this.selectApplication(preloader.config.deviceType); + + let commit: string; // commit hash or the strings 'latest' or 'current' + + const appCommit = application.should_be_running__release[0]?.commit; + + // Use the commit given as --commit or show an interactive commit selection menu + if (options.commit) { + if (this.isCurrentCommit(options.commit)) { + if (!appCommit) { + throw new Error( + `Unexpected empty commit hash for app ID "${application.id}"`, + ); + } + // handle `--commit current` (and its `--commit latest` synonym) + commit = 'latest'; + } else { + const release = _.find(application.owns__release, (r) => + r.commit.startsWith(options.commit!), + ); + if (!release) { + throw new ExpectedError( + `There is no release matching commit "${options.commit}"`, + ); + } + commit = release.commit; + } + } else { + // this could have the value 'current' + commit = await this.selectApplicationCommit( + application.owns__release as Release[], + ); + } + + await preloader.setAppIdAndCommit( + application.id, + this.isCurrentCommit(commit) ? appCommit! : commit, + ); + + // Propose to disable automatic app updates if the commit is not the current release + await this.offerToDisableAutomaticUpdates( + application, + commit, + options.pinDevice!, + ); + + // All options are ready: preload the image. + await preloader.preload(); + } +} diff --git a/lib/actions/command-options.ts b/lib/actions/command-options.ts deleted file mode 100644 index 04edb586..00000000 --- a/lib/actions/command-options.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* -Copyright 2016-2017 Balena - -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 const yes = { - signature: 'yes', - description: 'confirm non interactively', - boolean: true, - alias: 'y', -}; - -export interface YesOption { - yes: boolean; -} - -export const optionalApplication = { - signature: 'application', - parameter: 'application', - description: 'application name', - alias: ['a', 'app'], -}; - -export const application = { - ...optionalApplication, - required: 'You have to specify an application', -}; - -export const optionalRelease = { - signature: 'release', - parameter: 'release', - description: 'release id', - alias: 'r', -}; - -export const optionalDevice = { - signature: 'device', - parameter: 'device', - description: 'device uuid', - alias: 'd', -}; - -export const optionalDeviceApiKey = { - signature: 'deviceApiKey', - description: - 'custom device key - note that this is only supported on balenaOS 2.0.3+', - parameter: 'device-api-key', - alias: 'k', -}; - -export const optionalDeviceType = { - signature: 'deviceType', - description: 'device type slug', - parameter: 'device-type', -}; - -export const optionalOsVersion = { - signature: 'version', - description: 'a balenaOS version', - parameter: 'version', -}; - -export type OptionalOsVersionOption = Partial; - -export const osVersion = { - ...exports.optionalOsVersion, - required: 'You have to specify an exact os version', -}; - -export interface OsVersionOption { - version?: string; -} - -export const booleanDevice = { - signature: 'device', - description: 'device', - boolean: true, - alias: 'd', -}; - -export const osVersionOrSemver = { - signature: 'version', - description: `\ -exact version number, or a valid semver range, -or 'latest' (includes pre-releases), -or 'default' (excludes pre-releases if at least one stable version is available), -or 'recommended' (excludes pre-releases, will fail if only pre-release versions are available), -or 'menu' (will show the interactive menu)\ -`, - parameter: 'version', -}; - -export const network = { - signature: 'network', - parameter: 'network', - description: 'network type', - alias: 'n', -}; - -export const wifiSsid = { - signature: 'ssid', - parameter: 'ssid', - description: 'wifi ssid, if network is wifi', - alias: 's', -}; - -export const wifiKey = { - signature: 'key', - parameter: 'key', - description: 'wifi key, if network is wifi', - alias: 'k', -}; - -export const forceUpdateLock = { - signature: 'force', - description: 'force action if the update lock is set', - boolean: true, - alias: 'f', -}; - -export const drive = { - signature: 'drive', - description: `the drive to write the image to, like \`/dev/sdb\` or \`/dev/mmcblk0\`. \ -Careful with this as you can erase your hard drive. \ -Check \`balena util available-drives\` for available options.`, - parameter: 'drive', - alias: 'd', -}; - -export const advancedConfig = { - signature: 'advanced', - description: 'show advanced configuration options', - boolean: true, - alias: 'v', -}; - -export const hostOSAccess = { - signature: 'host', - boolean: true, - description: 'access host OS (for devices with balenaOS >= 2.0.0+rev1)', - alias: 's', -}; diff --git a/lib/actions/index.ts b/lib/actions/index.ts index e0eb52aa..fdddf548 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -15,5 +15,3 @@ limitations under the License. */ export * as help from './help'; - -export { preload } from './preload'; diff --git a/lib/actions/preload.js b/lib/actions/preload.js deleted file mode 100644 index df8634af..00000000 --- a/lib/actions/preload.js +++ /dev/null @@ -1,472 +0,0 @@ -/* -Copyright 2016-2020 Balena - -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 * as _ from 'lodash'; -import { getBalenaSdk, getVisuals, getCliForm } from '../utils/lazy'; -import * as dockerUtils from '../utils/docker'; - -const isCurrent = (commit) => commit === 'latest' || commit === 'current'; - -/** @type {any} */ -const applicationExpandOptions = { - owns__release: { - $select: ['id', 'commit', 'end_timestamp', 'composition'], - $orderby: [{ end_timestamp: 'desc' }, { id: 'desc' }], - $expand: { - contains__image: { - $select: ['image'], - $expand: { - image: { - $select: ['image_size', 'is_stored_at__image_location'], - }, - }, - }, - }, - $filter: { - status: 'success', - }, - }, - should_be_running__release: { - $select: 'commit', - }, -}; - -let allDeviceTypes; -const getDeviceTypes = async function () { - if (allDeviceTypes !== undefined) { - return allDeviceTypes; - } - const balena = getBalenaSdk(); - return balena.models.config - .getDeviceTypes() - .then((deviceTypes) => _.sortBy(deviceTypes, 'name')) - .then((dt) => { - allDeviceTypes = dt; - return dt; - }); -}; - -const getDeviceTypesWithSameArch = function (deviceTypeSlug) { - return getDeviceTypes().then(function (deviceTypes) { - const deviceType = _.find(deviceTypes, { slug: deviceTypeSlug }); - if (!deviceType) { - throw new Error(`Device type "${deviceTypeSlug}" not found in API query`); - } - return _(deviceTypes).filter({ arch: deviceType.arch }).map('slug').value(); - }); -}; - -const getApplicationsWithSuccessfulBuilds = function (deviceType) { - const balena = getBalenaSdk(); - - return getDeviceTypesWithSameArch(deviceType).then((deviceTypes) => { - /** @type {import('balena-sdk').PineOptions} */ - const options = { - $filter: { - is_for__device_type: { - $any: { - $alias: 'dt', - $expr: { - dt: { - slug: { $in: deviceTypes }, - }, - }, - }, - }, - owns__release: { - $any: { - $alias: 'r', - $expr: { - r: { - status: 'success', - }, - }, - }, - }, - }, - $expand: applicationExpandOptions, - $select: ['id', 'app_name', 'should_track_latest_release'], - $orderby: 'app_name asc', - }; - return balena.pine.get({ - resource: 'my_application', - options, - }); - }); -}; - -const selectApplication = function (deviceType) { - const visuals = getVisuals(); - const { exitWithExpectedError } = require('../errors'); - - const applicationInfoSpinner = new visuals.Spinner( - 'Downloading list of applications and releases.', - ); - applicationInfoSpinner.start(); - - return getApplicationsWithSuccessfulBuilds(deviceType).then(function ( - applications, - ) { - applicationInfoSpinner.stop(); - if (applications.length === 0) { - exitWithExpectedError( - `You have no apps with successful releases for a '${deviceType}' device type.`, - ); - } - return getCliForm().ask({ - message: 'Select an application', - type: 'list', - choices: applications.map((app) => ({ - name: app.app_name, - value: app, - })), - }); - }); -}; - -const selectApplicationCommit = function (releases) { - const { exitWithExpectedError } = require('../errors'); - - if (releases.length === 0) { - exitWithExpectedError('This application has no successful releases.'); - } - const DEFAULT_CHOICE = { name: 'current', value: 'current' }; - const choices = [DEFAULT_CHOICE].concat( - releases.map((release) => ({ - name: `${release.end_timestamp} - ${release.commit}`, - value: release.commit, - })), - ); - return getCliForm().ask({ - message: 'Select a release', - type: 'list', - default: 'current', - choices, - }); -}; - -const offerToDisableAutomaticUpdates = async function ( - application, - commit, - pinDevice, -) { - const balena = getBalenaSdk(); - - if ( - isCurrent(commit) || - !application.should_track_latest_release || - pinDevice - ) { - return; - } - const message = `\ - -This application is set to track the latest release, and non-pinned devices -are automatically updated when a new release is available. This may lead to -unexpected behavior: The preloaded device will download and install the latest -release once it is online. - -This prompt gives you the opportunity to disable automatic updates for this -application now. Note that this would result in the application being pinned -to the current latest release, rather than some other release that may have -been selected for preloading. The pinned released may be further managed -through the web dashboard or programatically through the balena API / SDK. -Documentation about release policies and app/device pinning can be found at: -https://www.balena.io/docs/learn/deploy/release-strategy/release-policy/ - -Alternatively, the --pin-device-to-release flag may be used to pin only the -preloaded device to the selected release. - -Would you like to disable automatic updates for this application now?\ -`; - return getCliForm() - .ask({ - message, - type: 'confirm', - }) - .then(function (update) { - if (!update) { - return; - } - return balena.pine.patch({ - resource: 'application', - id: application.id, - body: { - should_track_latest_release: false, - }, - }); - }); -}; - -/** - * @param {import('balena-sdk').BalenaSDK} balenaSdk - * @param {string | number} appId - * @returns {Promise} - */ -async function getAppWithReleases(balenaSdk, appId) { - // @ts-ignore - return balenaSdk.models.application.get(appId, { - $expand: applicationExpandOptions, - }); -} - -async function prepareAndPreload(preloader, balenaSdk, options) { - const { ExpectedError } = require('../errors'); - - await preloader.prepare(); - - const application = options.appId - ? await getAppWithReleases(balenaSdk, options.appId) - : await selectApplication(preloader.config.deviceType); - - /** @type {string} commit hash or the strings 'latest' or 'current' */ - let commit; - - const appCommit = application.should_be_running__release[0]?.commit; - - // Use the commit given as --commit or show an interactive commit selection menu - if (options.commit) { - if (isCurrent(options.commit)) { - if (!appCommit) { - throw new Error( - `Unexpected empty commit hash for app ID "${application.id}"`, - ); - } - // handle `--commit current` (and its `--commit latest` synonym) - commit = 'latest'; - } else { - const release = _.find(application.owns__release, (r) => - r.commit.startsWith(options.commit), - ); - if (!release) { - throw new ExpectedError( - `There is no release matching commit "${options.commit}"`, - ); - } - commit = release.commit; - } - } else { - // this could have the value 'current' - commit = await selectApplicationCommit(application.owns__release); - } - - await preloader.setAppIdAndCommit( - application.id, - isCurrent(commit) ? appCommit : commit, - ); - - // Propose to disable automatic app updates if the commit is not the current release - await offerToDisableAutomaticUpdates(application, commit, options.pinDevice); - - // All options are ready: preload the image. - await preloader.preload(); -} - -const preloadOptions = dockerUtils.appendConnectionOptions([ - { - signature: 'app', - parameter: 'appId', - description: 'Name, slug or numeric ID of the application to preload', - alias: 'a', - }, - { - signature: 'commit', - parameter: 'hash', - description: `\ -The commit hash for a specific application release to preload, use "current" to specify the current -release (ignored if no appId is given). The current release is usually also the latest, but can be -manually pinned using https://github.com/balena-io-projects/staged-releases .\ -`, - alias: 'c', - }, - { - signature: 'splash-image', - parameter: 'splashImage.png', - description: 'path to a png image to replace the splash screen', - alias: 's', - }, - { - signature: 'dont-check-arch', - boolean: true, - description: - 'Disables check for matching architecture in image and application', - }, - { - signature: 'pin-device-to-release', - boolean: true, - description: - 'Pin the preloaded device to the preloaded release on provision', - alias: 'p', - }, - { - signature: 'add-certificate', - parameter: 'certificate.crt', - description: `\ -Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container. -The file name must end with '.crt' and must not be already contained in the preloader's -/etc/ssl/certs folder. -Can be repeated to add multiple certificates.\ -`, - }, -]); -// Remove dockerPort `-p` alias as it conflicts with pin-device-to-release -delete _.find(preloadOptions, { signature: 'dockerPort' }).alias; - -export const preload = { - signature: 'preload ', - description: 'preload an app on a disk image (or Edison zip archive)', - help: `\ -Preload a balena application release (app images/containers), and optionally -a balenaOS splash screen, in a previously downloaded '.img' balenaOS image file -in the local disk (a zip file is only accepted for the Intel Edison device type). -After preloading, the balenaOS image file can be flashed to a device's SD card. -When the device boots, it will not need to download the application, as it was -preloaded. - -Warning: "balena preload" requires Docker to be correctly installed in -your shell environment. For more information (including Windows support) -check: https://github.com/balena-io/balena-cli/blob/master/INSTALL.md - -Examples: - - $ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png - $ balena preload balena.img\ -`, - permission: 'user', - primary: true, - options: preloadOptions, - async action(params, options) { - const balena = getBalenaSdk(); - const balenaPreload = require('balena-preload'); - const visuals = getVisuals(); - const nodeCleanup = require('node-cleanup'); - const { ExpectedError, instanceOf } = require('../errors'); - - const progressBars = {}; - - const progressHandler = function (event) { - let progressBar = progressBars[event.name]; - if (!progressBar) { - progressBar = progressBars[event.name] = new visuals.Progress( - event.name, - ); - } - return progressBar.update({ percentage: event.percentage }); - }; - - const spinners = {}; - - const spinnerHandler = function (event) { - let spinner = spinners[event.name]; - if (!spinner) { - spinner = spinners[event.name] = new visuals.Spinner(event.name); - } - if (event.action === 'start') { - return spinner.start(); - } else { - console.log(); - return spinner.stop(); - } - }; - - options.commit = isCurrent(options.commit) ? 'latest' : options.commit; - options.image = params.image; - options.appId = options.app; - delete options.app; - - options.splashImage = options['splash-image']; - delete options['splash-image']; - - options.dontCheckArch = options['dont-check-arch'] || false; - delete options['dont-check-arch']; - if (options.dontCheckArch && !options.appId) { - throw new ExpectedError( - 'You need to specify an app id if you disable the architecture check.', - ); - } - - options.pinDevice = options['pin-device-to-release'] || false; - delete options['pin-device-to-release']; - - let certificates; - if (Array.isArray(options['add-certificate'])) { - certificates = options['add-certificate']; - } else if (options['add-certificate'] === undefined) { - certificates = []; - } else { - certificates = [options['add-certificate']]; - } - for (let certificate of certificates) { - if (!certificate.endsWith('.crt')) { - throw new ExpectedError('Certificate file name must end with ".crt"'); - } - } - - // Get a configured dockerode instance - const docker = await dockerUtils.getDocker(options); - const preloader = new balenaPreload.Preloader( - null, - docker, - options.appId, - options.commit, - options.image, - options.splashImage, - options.proxy, - options.dontCheckArch, - options.pinDevice, - certificates, - ); - - let gotSignal = false; - - nodeCleanup(function (_exitCode, signal) { - if (signal) { - gotSignal = true; - nodeCleanup.uninstall(); // don't call cleanup handler again - preloader.cleanup().then(() => { - // calling process.exit() won't inform parent process of signal - process.kill(process.pid, signal); - }); - return false; - } - }); - - if (process.env.DEBUG) { - preloader.stderr.pipe(process.stderr); - } - - preloader.on('progress', progressHandler); - preloader.on('spinner', spinnerHandler); - - try { - await new Promise(function (resolve, reject) { - preloader.on('error', reject); - resolve(prepareAndPreload(preloader, balena, options)); - }); - } catch (err) { - if (instanceOf(err, balena.errors.BalenaError)) { - const code = err.code ? `(${err.code})` : ''; - throw new ExpectedError(`${err.message} ${code}`); - } else { - throw err; - } - } finally { - if (!gotSignal) { - await preloader.cleanup(); - } - } - }, -}; diff --git a/lib/app-capitano.ts b/lib/app-capitano.ts index 28995e27..08faef2e 100644 --- a/lib/app-capitano.ts +++ b/lib/app-capitano.ts @@ -46,9 +46,6 @@ capitano.globalOption({ // ---------- Help Module ---------- capitano.command(actions.help.help); -// ---------- Preload Module ---------- -capitano.command(actions.preload); - export function run(argv: string[]) { const cli = capitano.parse(argv.slice(2)); const runCommand = function () { diff --git a/lib/preparser.ts b/lib/preparser.ts index 9a70101c..e563f6bf 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -191,6 +191,7 @@ export const convertedCommands = [ 'os:versions', 'os:download', 'os:initialize', + 'preload', 'push', 'scan', 'settings', diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 8daff53c..c76a368a 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -12302,9 +12302,9 @@ "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" }, "pinejs-client-core": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/pinejs-client-core/-/pinejs-client-core-6.7.1.tgz", - "integrity": "sha512-xFNgRtJQmpwUXV1Ze7LqtGzLAxhgDJa+T5O8Ys2v8t3cnoBg6gYdpCd6R8UF46+8vXRBOtP5fCwo7hSQPaBpPA==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/pinejs-client-core/-/pinejs-client-core-6.7.3.tgz", + "integrity": "sha512-VXX/EpbDC/LiEPix/S9gENsaruoa2N0GHzmEbf9jYZ7qhwGNJjGlnQXQ1AnE/cjHYgHOo2RnMYKJ+iBakFgnWQ==", "requires": { "@balena/es-version": "^1.0.0" } diff --git a/package.json b/package.json index e3a029a1..719e5ea4 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "nock": "^12.0.3", "parse-link-header": "~1.0.1", "pkg": "^4.4.9", + "pinejs-client-core": "^6.7.3", "publish-release": "^1.6.1", "rewire": "^4.0.1", "simple-git": "^1.132.0", diff --git a/typings/balena-preload/index.d.ts b/typings/balena-preload/index.d.ts new file mode 100644 index 00000000..144136fa --- /dev/null +++ b/typings/balena-preload/index.d.ts @@ -0,0 +1,19 @@ +declare module 'balena-preload' { + export class Preloader { + constructor(...args: any[]); + + cleanup(): Promise; + + on(...args: any[]): void; + + preload(): Promise; + + prepare(): Promise; + + setAppIdAndCommit(appId: string | number, commit: string): Promise; + + config: any; + + stderr: any; + } +}