From 321dcef638bbc4dfd3e61950b28fd737296495d6 Mon Sep 17 00:00:00 2001 From: Zahari Petkov Date: Wed, 4 Sep 2024 14:53:30 +0300 Subject: [PATCH] WIP: Download local update bundles Change-type: minor Signed-off-by: Zahari Petkov --- completion/_balena | 2 +- completion/balena-completion.bash | 2 +- docs/balena-cli.md | 21 +++++ npm-shrinkwrap.json | 46 ++++++++-- package.json | 1 + src/commands/device/download-update.ts | 44 ++++++++++ src/utils/download-update.ts | 114 +++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 src/commands/device/download-update.ts create mode 100644 src/utils/download-update.ts diff --git a/completion/_balena b/completion/_balena index eb331943..ca5a2f1a 100644 --- a/completion/_balena +++ b/completion/_balena @@ -14,7 +14,7 @@ _balena() { app_cmds=( create ) block_cmds=( create ) config_cmds=( generate inject read reconfigure write ) - device_cmds=( deactivate identify init local-mode move os-update pin public-url purge reboot register rename restart rm shutdown start-service stop-service track-fleet ) + device_cmds=( deactivate download-update identify init local-mode move os-update pin public-url purge reboot register rename restart rm shutdown start-service stop-service track-fleet ) devices_cmds=( supported ) env_cmds=( add rename rm ) fleet_cmds=( create pin purge rename restart rm track-latest ) diff --git a/completion/balena-completion.bash b/completion/balena-completion.bash index 3c1101a1..3f77b18d 100644 --- a/completion/balena-completion.bash +++ b/completion/balena-completion.bash @@ -13,7 +13,7 @@ _balena_complete() app_cmds="create" block_cmds="create" config_cmds="generate inject read reconfigure write" - device_cmds="deactivate identify init local-mode move os-update pin public-url purge reboot register rename restart rm shutdown start-service stop-service track-fleet" + device_cmds="deactivate download-update identify init local-mode move os-update pin public-url purge reboot register rename restart rm shutdown start-service stop-service track-fleet" devices_cmds="supported" env_cmds="add rename rm" fleet_cmds="create pin purge rename restart rm track-latest" diff --git a/docs/balena-cli.md b/docs/balena-cli.md index 28a46c79..caac44ec 100644 --- a/docs/balena-cli.md +++ b/docs/balena-cli.md @@ -196,6 +196,7 @@ are encouraged to regularly update the balena CLI to the latest version. - Devices - [device deactivate <uuid>](#device-deactivate-uuid) + - [device download-update <uuid(s)>](#device-download-update-uuid-s) - [device identify <uuid>](#device-identify-uuid) - [device <uuid>](#device-uuid) - [device init](#device-init) @@ -1218,6 +1219,26 @@ the UUID of the device to be deactivated answer "yes" to all questions (non interactive use) +## device download-update <uuid(s)> + + + +Examples: + + $ balena device download-update fd3a6a1,e573ad5 + +### Arguments + +#### UUIDS + +comma-separated list (no blank spaces) of device UUIDs + +### Options + +#### -o, --output OUTPUT + +output path + ## device identify <uuid> Identify a device by making the ACT LED blink (Raspberry Pi). diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index b5be4abf..9e17ed02 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,6 +14,7 @@ "@balena/dockerignore": "^1.0.2", "@balena/env-parsing": "^1.1.8", "@balena/es-version": "^1.0.1", + "@balena/update-bundle": "^0.5.0", "@oclif/core": "^4.0.8", "@resin.io/valid-email": "^0.1.0", "@sentry/node": "^6.16.1", @@ -180,6 +181,29 @@ "windosu": "^0.3.0" } }, + "../balena-update-bundle": { + "name": "@balena/update-bundle", + "version": "0.5.0", + "license": "Apache-2.0", + "dependencies": { + "@balena/resource-bundle": "file:../balena-resource-bundle" + }, + "devDependencies": { + "@balena/lint": "^7.2.1", + "@types/chai-as-promised": "^7.1.3", + "@types/mocha": "^10.0.1", + "@types/node": "^20.0.0", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "mocha": "^10.2.0", + "rimraf": "^5.0.1", + "ts-mocha": "^10.0.0", + "typescript": "^5.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1676,6 +1700,10 @@ "through": "^2.3.8" } }, + "node_modules/@balena/update-bundle": { + "resolved": "../balena-update-bundle", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -3894,9 +3922,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.4.tgz", + "integrity": "sha512-ioyQ1zK9aGEomJ45zz8S8IdzElyxhvP1RVWnPrXDf6wFaUb+kk1tEcVVJkF7RPGM0VWI7cp5U57oCPIn5iN1qg==", "dependencies": { "undici-types": "~6.19.2" } @@ -5681,9 +5709,9 @@ } }, "node_modules/balena-sdk/node_modules/@types/node": { - "version": "18.19.48", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.48.tgz", - "integrity": "sha512-7WevbG4ekUcRQSZzOwxWgi5dZmTak7FaxXDoW7xVxPBmKx1rTzfmRLkeCgJzcbBnOV2dkhAPc8cCeT6agocpjg==", + "version": "18.19.49", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.49.tgz", + "integrity": "sha512-ALCeIR6n0nQ7j0FUF1ycOhrp6+XutJWqEu/vtdEqXFUQwkBfgUA5cEg3ZNmjWGF/ZYA/FcF9QMkL55Ar0O6UrA==", "dependencies": { "undici-types": "~5.26.4" } @@ -14324,9 +14352,9 @@ } }, "node_modules/patch-package/node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 9f2310ba..5a5faa66 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "@balena/dockerignore": "^1.0.2", "@balena/env-parsing": "^1.1.8", "@balena/es-version": "^1.0.1", + "@balena/update-bundle": "^0.5.0", "@oclif/core": "^4.0.8", "@resin.io/valid-email": "^0.1.0", "@sentry/node": "^6.16.1", diff --git a/src/commands/device/download-update.ts b/src/commands/device/download-update.ts new file mode 100644 index 00000000..7fddd254 --- /dev/null +++ b/src/commands/device/download-update.ts @@ -0,0 +1,44 @@ +import { Flags, Args } from '@oclif/core'; +import Command from '../../command'; +import { stripIndent } from '../../utils/lazy'; +import * as cf from '../../utils/common-flags'; + +import { downloadUpdateBundle } from '../../utils/download-update'; + +export default class DeviceDownloadUpdateCmd extends Command { + public static description = stripIndent` + Downloads a device local update bundle. + `; + + public static examples = ['$ balena device download-update fd3a6a1,e573ad5']; + + public static args = { + uuids: Args.string({ + description: 'comma-separated list (no blank spaces) of device UUIDs', + required: true, + }), + }; + + public static usage = 'device download-update '; + + public static flags = { + output: Flags.string({ + description: 'output path', + char: 'o', + required: true, + }), + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = await this.parse( + DeviceDownloadUpdateCmd, + ); + + // TODO: support UUIDs passed through stdin pipe + + await downloadUpdateBundle(params.uuids, options.output); + } +} diff --git a/src/utils/download-update.ts b/src/utils/download-update.ts new file mode 100644 index 00000000..c3549a2e --- /dev/null +++ b/src/utils/download-update.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as stream from 'stream'; +import * as zlib from 'zlib'; +import { pipeline } from 'stream/promises'; + +import * as bundle from '@balena/update-bundle'; + +import { getBalenaSdk } from './lazy'; + +enum UpdateBundleFormat { + CompressedTar, + Tar, +} + +export async function downloadUpdateBundle(uuids: string, output: string) { + const balena = getBalenaSdk(); + + const deviceUuids = await deviceUuidsFromParam(balena, uuids); + + const subject = (await balena.auth.getUserInfo()).username; + const token = await balena.auth.getToken(); + + const updateBundleStream = await bundle.create({ + type: 'Device', + deviceUuids, + auth: { + scheme: 'Bearer', + subject, + token, + }, + }); + + // TODO: accept fleet arguments for download update bundle as well + /* + const updateBundleStream = await bundle.create({ + type: 'Fleet', + appUuid: 'f48afafee22245209acfbaf4c2b482e8', + releaseUuid: '65a9cb86a59fbe58430715a3d687ea68', + auth: { + scheme: 'Bearer', + subject, + token, + }, + }); + */ + + await saveBundle(updateBundleStream, output); +} + +async function saveBundle( + updateBundleStream: stream.Readable, + filePath: string, +) { + const target = fs.createWriteStream(filePath); + + const format = getUpdateBundleFormat(filePath); + + if (format === UpdateBundleFormat.CompressedTar) { + const gzip = zlib.createGzip(); + + await pipeline(updateBundleStream, gzip, target); + } else { + await pipeline(updateBundleStream, target); + } +} + +function getUpdateBundleFormat(filePath: string): UpdateBundleFormat { + const ext = getCompoundExtension(filePath); + + if (ext === '.tar.gz' || ext === '.tgz') { + return UpdateBundleFormat.CompressedTar; + } else if (ext === '.tar') { + return UpdateBundleFormat.Tar; + } else { + throw new Error(`Unsupported file extension: ${ext}`); + } +} + +function getCompoundExtension(filePath: string): string { + let compoundExt = ''; + let currentPath = filePath; + + for (;;) { + const ext = path.extname(currentPath); + if (ext === '') { + break; + } + compoundExt = ext + compoundExt; + currentPath = path.basename(currentPath, ext); + } + + return compoundExt; +} + +async function deviceUuidsFromParam( + balena: ReturnType, + uuids: string, +): Promise { + const uuidsList = uuids.split(','); + + const deviceUuids = []; + for (const uuid of uuidsList) { + try { + const device = await balena.models.device.get(uuid); + deviceUuids.push(device.uuid); + } catch (err) { + // TODO: Needs proper error handling + throw new Error(`UUID error ${uuid}: ${err.message}`); + } + } + + return deviceUuids; +}