Convert command preload to oclif, typescript

Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2020-08-26 10:39:47 +02:00
parent 7d3dbc2c0b
commit d21a18f353
11 changed files with 597 additions and 648 deletions

View File

@ -137,7 +137,7 @@ const capitanoDoc = {
},
{
title: 'Preload',
files: ['build/actions/preload.js'],
files: ['build/actions-oclif/preload.js'],
},
{
title: 'Push',

View File

@ -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 &#60;appId&#62;
#### -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 &#60;hash&#62;
#### -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 &#60;splashImage.png&#62;
#### -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 &#60;certificate.crt&#62;
#### --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 &#60;docker&#62;
#### -P, --docker DOCKER
Path to a local docker socket (e.g. /var/run/docker.sock)
#### --dockerHost, -h &#60;dockerHost&#62;
#### -h, --dockerHost DOCKERHOST
Docker daemon hostname or IP address (dev machine or balena device)
#### --dockerPort &#60;dockerPort&#62;
#### --dockerPort DOCKERPORT
Docker daemon TCP port number (hint: 2375 for balena devices)
#### --ca &#60;ca&#62;
#### --ca CA
Docker host TLS certificate authority file
#### --cert &#60;cert&#62;
#### --cert CERT
Docker host TLS certificate file
#### --key &#60;key&#62;
#### --key KEY
Docker host TLS key file

View File

@ -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 <image>';
public static flags: flags.Input<FlagsDef> = {
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<FlagsDef, ArgsDef>(
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<typeof getVisuals>['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<typeof getVisuals>['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<Application & { should_be_running__release: [Release?] }>;
}
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();
}
}

View File

@ -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<OsVersionOption>;
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',
};

View File

@ -15,5 +15,3 @@ limitations under the License.
*/
export * as help from './help';
export { preload } from './preload';

View File

@ -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<ApplicationWithDeviceType & { should_be_running__release: [import('balena-sdk').Release?] }>} */
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<import('balena-sdk').Application & { should_be_running__release: [import('balena-sdk').Release?] }>}
*/
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 <image>',
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();
}
}
},
};

View File

@ -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 () {

View File

@ -191,6 +191,7 @@ export const convertedCommands = [
'os:versions',
'os:download',
'os:initialize',
'preload',
'push',
'scan',
'settings',

6
npm-shrinkwrap.json generated
View File

@ -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"
}

View File

@ -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",

19
typings/balena-preload/index.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
declare module 'balena-preload' {
export class Preloader {
constructor(...args: any[]);
cleanup(): Promise<void>;
on(...args: any[]): void;
preload(): Promise<void>;
prepare(): Promise<void>;
setAppIdAndCommit(appId: string | number, commit: string): Promise<void>;
config: any;
stderr: any;
}
}