diff --git a/completion/_balena b/completion/_balena index eb33194366..0c62cc20c4 100644 --- a/completion/_balena +++ b/completion/_balena @@ -22,7 +22,7 @@ _balena() { key_cmds=( add rm ) local_cmds=( configure flash ) os_cmds=( build-config configure download initialize versions ) - release_cmds=( finalize invalidate validate ) + release_cmds=( export finalize import invalidate validate ) tag_cmds=( rm set ) diff --git a/completion/balena-completion.bash b/completion/balena-completion.bash index 3c1101a1eb..e6d548a772 100644 --- a/completion/balena-completion.bash +++ b/completion/balena-completion.bash @@ -21,7 +21,7 @@ _balena_complete() key_cmds="add rm" local_cmds="configure flash" os_cmds="build-config configure download initialize versions" - release_cmds="finalize invalidate validate" + release_cmds="export finalize import invalidate validate" tag_cmds="rm set" diff --git a/docs/balena-cli.md b/docs/balena-cli.md index 28a46c793b..73b5fbdc6e 100644 --- a/docs/balena-cli.md +++ b/docs/balena-cli.md @@ -282,7 +282,9 @@ are encouraged to regularly update the balena CLI to the latest version. - Releases + - [release export <commitorid>](#release-export-commitorid) - [release finalize <commitorid>](#release-finalize-commitorid) + - [release import <file>](#release-import-file) - [release <commitorid>](#release-commitorid) - [release invalidate <commitorid>](#release-invalidate-commitorid) - [release validate <commitorid>](#release-validate-commitorid) @@ -3345,6 +3347,31 @@ The notes for this release # Releases +## release export <commitOrId> + +Saving a release to a file allows you to import an exact +copy of the original release into another application. + +Only successful releases can be saved into a file. + +Examples: + + $ balena release export a777f7345fe3d655c1c981aa642e5555 -o ../path/to/release.tar + $ balena release export 1234567 -o ../path/to/release.tar + $ balena release export myOrg/myFleet:1.2.3 -o ../path/to/release.tar + +### Arguments + +#### COMMITORID + +commit, ID, or version of the release to export + +### Options + +#### -o, --output OUTPUT + +output path + ## release finalize <commitOrId> Finalize a release. Releases can be "draft" or "final", and this command @@ -3371,6 +3398,36 @@ the commit or ID of the release to finalize ### Options +## release import <file> + +To save a release into a file, use 'balena release export'. + +Use the --override-version option to specify the version +of the imported release, overriding the one saved in the file. + +Examples: + + $ balena release import ../path/to/release.tar -f 1234567 + $ balena release import ../path/to/release.tar -f myFleet + $ balena release import ../path/to/release.tar -f myOrg/myFleet + $ balena release import ../path/to/release.tar -f myOrg/myFleet -V 1.2.3 + +### Arguments + +#### BUNDLE + +path to a file, e.g. "release.tar" + +### Options + +#### -f, --fleet FLEET + +fleet name or slug (preferred) + +#### -V, --override-version OVERRIDE-VERSION + +Imports this release with the specified version overriding the version in the file. + ## release <commitOrId> The --json option is recommended when scripting the output of this command, diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index f53b0e08ed..4f7fc73228 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/release-bundle": "^0.5.2", "@oclif/core": "^4.0.8", "@resin.io/valid-email": "^0.1.0", "@sentry/node": "^6.16.1", @@ -1666,6 +1667,40 @@ "web-streams-polyfill": "^3.1.0" } }, + "node_modules/@balena/release-bundle": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@balena/release-bundle/-/release-bundle-0.5.2.tgz", + "integrity": "sha512-q2ji3Pky9RGeztApTBaoZEF2R8FSiHsFutIvvlmA0ggJKgATxNNavZd4ueYtlK/Nl53g9vUrKmiwzCVgw9rDRw==", + "dependencies": { + "@balena/resource-bundle": "^0.8.3", + "balena-semver": "^2.3.5" + }, + "peerDependencies": { + "balena-sdk": "^19.0.0" + } + }, + "node_modules/@balena/resource-bundle": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@balena/resource-bundle/-/resource-bundle-0.8.3.tgz", + "integrity": "sha512-WKkeZkZIcrey1l08G1gS60EQCYtTZsOwwmnRhvmjnmWmUAcqa3Z9WqYDqM7ePbFO/pdo9Cd0JK0Xr+pgj3A8ng==", + "dependencies": { + "auth-header": "^1.0.0", + "tar-stream": "^3.1.7" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@balena/resource-bundle/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/@balena/udif": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@balena/udif/-/udif-1.1.2.tgz", @@ -5068,6 +5103,11 @@ "node": ">= 4.0.0" } }, + "node_modules/auth-header": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/auth-header/-/auth-header-1.0.0.tgz", + "integrity": "sha512-CPPazq09YVDUNNVWo4oSPTQmtwIzHusZhQmahCKvIsk0/xH6U3QsMAv3sM+7+Q0B1K2KJ/Q38OND317uXs4NHA==" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", diff --git a/package.json b/package.json index 99601b24e6..ad9b6ae8ef 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,7 @@ "@balena/dockerignore": "^1.0.2", "@balena/env-parsing": "^1.1.8", "@balena/es-version": "^1.0.1", + "@balena/release-bundle": "^0.5.2", "@oclif/core": "^4.0.8", "@resin.io/valid-email": "^0.1.0", "@sentry/node": "^6.16.1", diff --git a/src/commands/release/export.ts b/src/commands/release/export.ts new file mode 100644 index 0000000000..2013c01ab6 --- /dev/null +++ b/src/commands/release/export.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2016-2024 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 { commitOrIdArg } from '.'; +import { Flags } from '@oclif/core'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk, stripIndent } from '../../utils/lazy'; +import { create } from '@balena/release-bundle'; +import * as fs from 'fs/promises'; +import * as semver from 'balena-semver'; +import { ExpectedError } from '../../errors'; + +export default class ReleaseExportCmd extends Command { + public static description = stripIndent` + Saves a release into a file. + + Saving a release to a file allows you to import an exact + copy of the original release into another application. + + Only successful releases can be saved into a file. +`; + public static examples = [ + '$ balena release export a777f7345fe3d655c1c981aa642e5555 -o ../path/to/release.tar', + '$ balena release export 1234567 -o ../path/to/release.tar', + '$ balena release export myOrg/myFleet:1.2.3 -o ../path/to/release.tar', + ]; + + public static usage = 'release export '; + + public static flags = { + output: Flags.string({ + description: 'output path', + char: 'o', + required: true, + }), + help: cf.help, + }; + + public static args = { + commitOrId: commitOrIdArg({ + description: 'commit, ID, or version of the release to export', + required: true, + }), + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = await this.parse(ReleaseExportCmd); + + const balena = getBalenaSdk(); + + let release: balenaSdk.Release; + if ( + typeof params.commitOrId === 'string' && + params.commitOrId.includes(':') + ) { + const fleet = params.commitOrId.split(':')[0]; + const parsedVersion = semver.parse(params.commitOrId.split(':')[1]); + if (parsedVersion == null) { + throw new ExpectedError( + `Release ${params.commitOrId} could not be exported; version must be valid SemVer.`, + ); + } else { + const rawVersion = + parsedVersion.build.length === 0 + ? parsedVersion.version + : `${parsedVersion.version}+${parsedVersion.build[0]}`; + release = await balena.models.release.get( + { application: fleet, rawVersion }, + { $select: ['id'] }, + ); + } + } else { + release = await balena.models.release.get(params.commitOrId, { + $select: ['id'], + }); + } + + try { + const releaseBundle = await create({ + sdk: balena, + releaseId: release.id, + }); + await fs.writeFile(options.output, releaseBundle); + console.log( + `Release ${params.commitOrId} has been exported to ${options.output}.`, + ); + } catch (error) { + throw new ExpectedError( + `Release ${params.commitOrId} could not be exported: ${error.message}`, + ); + } + } +} diff --git a/src/commands/release/import.ts b/src/commands/release/import.ts new file mode 100644 index 0000000000..f30582770b --- /dev/null +++ b/src/commands/release/import.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2016-2024 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, Args } from '@oclif/core'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk, stripIndent } from '../../utils/lazy'; +import { apply } from '@balena/release-bundle'; +import { createReadStream } from 'fs'; +import { ExpectedError } from '../../errors'; + +export default class ReleaseImportCmd extends Command { + public static description = stripIndent` + Imports a release from a file to an application or fleet. + + To save a release into a file, use 'balena release export'. + + Use the --override-version option to specify the version + of the imported release, overriding the one saved in the file. +`; + public static examples = [ + '$ balena release import ../path/to/release.tar -f 1234567', + '$ balena release import ../path/to/release.tar -f myFleet', + '$ balena release import ../path/to/release.tar -f myOrg/myFleet', + '$ balena release import ../path/to/release.tar -f myOrg/myFleet -V 1.2.3', + ]; + + public static usage = 'release import '; + + public static flags = { + fleet: { ...cf.fleet, exclusive: ['device'] }, + 'override-version': Flags.string({ + description: + 'Imports this release with the specified version overriding the version in the file.', + char: 'V', + required: false, + }), + help: cf.help, + }; + + public static args = { + bundle: Args.string({ + required: true, + description: 'path to a file, e.g. "release.tar"', + }), + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = await this.parse(ReleaseImportCmd); + + const balena = getBalenaSdk(); + + const bundle = createReadStream(params.bundle); + + try { + if ( + typeof options.fleet !== 'number' && + typeof options.fleet !== 'string' + ) { + throw new ExpectedError('Fleet must be a number or slug.'); + } + + // TODO: validate if the path to the release bundle exists + + const application = await balena.models.application.get(options.fleet, { + $select: ['id'], + }); + await apply({ + sdk: balena, + application: application.id, + stream: bundle, + version: options['override-version'], + }); + console.log( + `Release bundle ${params.bundle} has been imported to ${options.fleet}.`, + ); + } catch (error) { + throw new ExpectedError( + `Could not import release bundle ${params.bundle} to fleet ${options.fleet}. ${error.message}`, + ); + } + } +} diff --git a/tests/test-data/pkg/expected-warnings-darwin-arm64.txt b/tests/test-data/pkg/expected-warnings-darwin-arm64.txt index 960b23f8bc..9359a8714a 100644 --- a/tests/test-data/pkg/expected-warnings-darwin-arm64.txt +++ b/tests/test-data/pkg/expected-warnings-darwin-arm64.txt @@ -205,6 +205,12 @@ > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/push/index.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/export.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/import.js > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/release/index.js diff --git a/tests/test-data/pkg/expected-warnings-darwin-x64.txt b/tests/test-data/pkg/expected-warnings-darwin-x64.txt index bab73f1c67..9e1d254f21 100644 --- a/tests/test-data/pkg/expected-warnings-darwin-x64.txt +++ b/tests/test-data/pkg/expected-warnings-darwin-x64.txt @@ -205,6 +205,12 @@ > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/push/index.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/export.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/import.js > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/release/index.js diff --git a/tests/test-data/pkg/expected-warnings-linux-arm64.txt b/tests/test-data/pkg/expected-warnings-linux-arm64.txt index 67d1f96855..c19ec23aa6 100644 --- a/tests/test-data/pkg/expected-warnings-linux-arm64.txt +++ b/tests/test-data/pkg/expected-warnings-linux-arm64.txt @@ -205,6 +205,12 @@ > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/push/index.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/export.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/import.js > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/release/index.js diff --git a/tests/test-data/pkg/expected-warnings-linux-x64.txt b/tests/test-data/pkg/expected-warnings-linux-x64.txt index fe0b5c7626..49b9868471 100644 --- a/tests/test-data/pkg/expected-warnings-linux-x64.txt +++ b/tests/test-data/pkg/expected-warnings-linux-x64.txt @@ -205,6 +205,12 @@ > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/push/index.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/export.js +> Warning Entry 'main' not found in %1 + %1: node_modules/@oclif/core/package.json + %2: build/commands/release/import.js > Warning Entry 'main' not found in %1 %1: node_modules/@oclif/core/package.json %2: build/commands/release/index.js diff --git a/tests/test-data/pkg/expected-warnings-win32-x64.txt b/tests/test-data/pkg/expected-warnings-win32-x64.txt index 6804a05903..9da837a1b4 100644 --- a/tests/test-data/pkg/expected-warnings-win32-x64.txt +++ b/tests/test-data/pkg/expected-warnings-win32-x64.txt @@ -205,6 +205,12 @@ > Warning Entry 'main' not found in %1 %1: node_modules\@oclif\core\package.json %2: build\commands\push\index.js +> Warning Entry 'main' not found in %1 + %1: node_modules\@oclif\core\package.json + %2: build\commands\release\export.js +> Warning Entry 'main' not found in %1 + %1: node_modules\@oclif\core\package.json + %2: build\commands\release\import.js > Warning Entry 'main' not found in %1 %1: node_modules\@oclif\core\package.json %2: build\commands\release\index.js