diff --git a/doc/cli.markdown b/doc/cli.markdown index 20e1ca2e..da71f9a5 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -104,6 +104,7 @@ If you come across any problems or would like to get in touch: - [device rename <uuid> [newName]](#device-rename-uuid-newname) - [device move <uuid>](#device-move-uuid) - [device init](#device-init) + - [device os-update <uuid>](#device-os-update-uuid) - Environment Variables @@ -548,6 +549,28 @@ the drive to write the image to, like `/dev/sdb` or `/dev/mmcblk0`. Careful with path to the config JSON file, see `balena os build-config` +## device os-update <uuid> + +Use this command to trigger a Host OS update for a device. + +Notice this command will ask for confirmation interactively. +You can avoid this by passing the `--yes` boolean option. + +Examples: + + $ balena device os-update 23c73a1 + $ balena device os-update 23c73a1 --version 2.31.0+rev1.prod + +### Options + +#### --version <version> + +a balenaOS version + +#### --yes, -y + +confirm non interactively + # Environment Variables ## envs diff --git a/lib/actions/command-options.ts b/lib/actions/command-options.ts index 7385021e..28e73757 100644 --- a/lib/actions/command-options.ts +++ b/lib/actions/command-options.ts @@ -23,6 +23,10 @@ export const yes = { alias: 'y', }; +export interface YesOption { + yes: boolean; +} + export const optionalApplication = { signature: 'application', parameter: 'application', @@ -69,6 +73,8 @@ export const optionalOsVersion = { parameter: 'version', }; +export type OptionalOsVersionOption = Partial; + export const osVersion = _.defaults( { required: 'You have to specify an exact os version', @@ -76,6 +82,10 @@ export const osVersion = _.defaults( exports.optionalOsVersion, ); +export interface OsVersionOption { + version?: string; +} + export const booleanDevice = { signature: 'device', description: 'device', diff --git a/lib/actions/device.coffee b/lib/actions/device.coffee index 72dd09d8..cffe1343 100644 --- a/lib/actions/device.coffee +++ b/lib/actions/device.coffee @@ -455,3 +455,5 @@ exports.init = return device.uuid .nodeify(done) + +exports.osUpdate = require('./device_ts').osUpdate diff --git a/lib/actions/device_ts.ts b/lib/actions/device_ts.ts new file mode 100644 index 00000000..2aa67a76 --- /dev/null +++ b/lib/actions/device_ts.ts @@ -0,0 +1,115 @@ +/* +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. +*/ +import { Device } from 'balena-sdk'; +import { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; +import { normalizeUuidProp } from '../utils/normalization'; +import * as commandOptions from './command-options'; + +// tslint:disable-next-line:no-namespace +namespace OsUpdate { + export interface Args { + uuid: string; + } + + export type Options = commandOptions.OptionalOsVersionOption & + commandOptions.YesOption; +} + +export const osUpdate: CommandDefinition = { + signature: 'device os-update ', + description: 'Start a Host OS update for a device', + help: stripIndent` + Use this command to trigger a Host OS update for a device. + + Notice this command will ask for confirmation interactively. + You can avoid this by passing the \`--yes\` boolean option. + + Examples: + + $ balena device os-update 23c73a1 + $ balena device os-update 23c73a1 --version 2.31.0+rev1.prod + `, + options: [commandOptions.optionalOsVersion, commandOptions.yes], + permission: 'user', + async action(params, options, done) { + normalizeUuidProp(params); + const balena = await import('balena-sdk'); + const _ = await import('lodash'); + const sdk = balena.fromSharedOptions(); + const patterns = await import('../utils/patterns'); + const form = await import('resin-cli-form'); + + return sdk.models.device + .get(params.uuid, { + $select: ['uuid', 'device_type', 'os_version', 'os_variant'], + }) + .then(async ({ uuid, device_type, os_version, os_variant }) => { + const currentOsVersion = sdk.models.device.getOsVersion({ + os_version, + os_variant, + } as Device); + if (!currentOsVersion) { + patterns.exitWithExpectedError( + 'The current os version of the device is not available', + ); + // Just to make TS happy + return; + } + + return sdk.models.os + .getSupportedOsUpdateVersions(device_type, currentOsVersion) + .then(hupVersionInfo => { + if (hupVersionInfo.versions.length === 0) { + patterns.exitWithExpectedError( + 'There are no available Host OS update targets for this device', + ); + } + + if (options.version != null) { + if (!_.includes(hupVersionInfo.versions, options.version)) { + patterns.exitWithExpectedError( + 'The provided version is not in the Host OS update targets for this device', + ); + } + return options.version; + } + + return form.ask({ + message: 'Target OS version', + type: 'list', + choices: hupVersionInfo.versions.map(version => ({ + name: + hupVersionInfo.recommended === version + ? `${version} (recommended)` + : version, + value: version, + })), + }); + }) + .then(version => + patterns + .confirm( + options.yes || false, + 'Host OS updates require a device restart when they complete. Are you sure you want to proceed?', + ) + .then(() => sdk.models.device.startOsUpdate(uuid, version)) + .then(() => patterns.awaitDeviceOsUpdate(uuid, version)), + ); + }) + .nodeify(done); + }, +}; diff --git a/lib/app-capitano.coffee b/lib/app-capitano.coffee index e582a0a4..5b220eaa 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -69,6 +69,7 @@ capitano.command(actions.device.getDeviceUrl) capitano.command(actions.device.hasDeviceUrl) capitano.command(actions.device.register) capitano.command(actions.device.move) +capitano.command(actions.device.osUpdate) capitano.command(actions.device.info) # ---------- Notes Module ---------- diff --git a/lib/utils/device/progress.ts b/lib/utils/device/progress.ts new file mode 100644 index 00000000..3c60643c --- /dev/null +++ b/lib/utils/device/progress.ts @@ -0,0 +1,41 @@ +/* +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. +*/ +import * as BalenaDeviceStatus from 'balena-device-status'; +import { Device } from 'balena-sdk'; + +export const getDeviceOsProgress = (device: Device) => { + if (!device.is_online) { + return 0; + } + + const status = BalenaDeviceStatus.getStatus(device).key; + + if ( + status === BalenaDeviceStatus.status.UPDATING && + !!device.download_progress + ) { + return device.download_progress; + } + if ( + (status === BalenaDeviceStatus.status.CONFIGURING || + status === BalenaDeviceStatus.status.POST_PROVISIONING) && + device.provisioning_progress + ) { + return device.provisioning_progress; + } + + return 0; +}; diff --git a/lib/utils/patterns.ts b/lib/utils/patterns.ts index d15b6fd7..6c778055 100644 --- a/lib/utils/patterns.ts +++ b/lib/utils/patterns.ts @@ -253,6 +253,50 @@ export function awaitDevice(uuid: string) { }); } +export function awaitDeviceOsUpdate(uuid: string, targetOsVersion: string) { + const balena = getBalenaSdk(); + const { getDeviceOsProgress } = require('./device/progress'); + + return balena.models.device.getName(uuid).then(deviceName => { + const visuals = getVisuals(); + const progressBar = new visuals.Progress( + `Updating the OS of ${deviceName} to v${targetOsVersion}`, + ); + progressBar.update({ percentage: 0 }); + + const poll = (): Bluebird => { + return Bluebird.all([ + balena.models.device.getOsUpdateStatus(uuid), + balena.models.device.get(uuid).then(getDeviceOsProgress), + ]).then(([osUpdateStatus, osUpdateProgress]) => { + if ( + osUpdateStatus.status === 'update_done' || + osUpdateStatus.status === 'done' + ) { + console.info( + `The device ${deviceName} has been updated to v${targetOsVersion} and will restart shortly!`, + ); + return; + } + + if (osUpdateStatus.error) { + console.error( + `Failed to complete Host OS update on device ${deviceName}!`, + ); + exitWithExpectedError(osUpdateStatus.error); + return; + } + + progressBar.update({ percentage: osUpdateProgress }); + + return Bluebird.delay(3000).then(poll); + }); + }; + + return poll().return(uuid); + }); +} + export function inferOrSelectDevice(preferredUuid: string) { const balena = getBalenaSdk(); return balena.models.device diff --git a/package.json b/package.json index 44f876c9..a97c4d7f 100644 --- a/package.json +++ b/package.json @@ -125,9 +125,10 @@ "archiver": "^2.1.0", "balena-config-json": "^2.0.0", "balena-device-init": "^5.0.0", + "balena-device-status": "^3.1.2", "balena-image-manager": "^6.0.0", "balena-preload": "^8.2.0", - "balena-sdk": "^11.17.0", + "balena-sdk": "^11.18.0", "balena-settings-client": "^4.0.0", "balena-sync": "^10.0.3", "bash": "0.0.1",