Compare commits

...

6 Commits

Author SHA1 Message Date
7cc0d8b155 Release export/import code enhancements/fixes.
Change-type: patch
Signed-off-by: Carlo Miguel F. Cruz <carloc@balena.io>
2024-09-19 15:31:47 +08:00
8a588706bb Allow fleet name aside from fleet slug for importing releases.
Change-type: patch
Signed-off-by: Carlo Miguel F. Cruz <carloc@balena.io>
2024-09-19 15:31:47 +08:00
e67e500bf4 Allow fleet name aside from fleet slug for exporting releases.
Change-type: patch
Signed-off-by: Carlo Miguel F. Cruz <carloc@balena.io>
2024-09-19 15:31:47 +08:00
9c71f0f96c Fix release export argument and description.
Change-type: patch
Signed-off-by: Carlo Miguel F. Cruz <carloc@balena.io>
2024-09-19 15:31:46 +08:00
dea07bc720 Add tests for release export & import.
Change-type: patch
Signed-off-by: Carlo Miguel F. Cruz <carloc@balena.io>
2024-09-19 15:31:46 +08:00
6c756d5e80 Add commands for exporting and importing app/fleet releases.
Change-type: minor
Signed-off-by: Carlo Miguel F. Cruz <carloc@balena.io>
2024-09-19 15:31:45 +08:00
15 changed files with 595 additions and 2 deletions

View File

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

View File

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

View File

@ -282,7 +282,9 @@ are encouraged to regularly update the balena CLI to the latest version.
- Releases
- [release export &#60;commitorapplication&#62;](#release-export-commitorapplication)
- [release finalize &#60;commitorid&#62;](#release-finalize-commitorid)
- [release import &#60;file&#62; &#60;applicapplication&#62;](#release-import-file-applicapplication)
- [release &#60;commitorid&#62;](#release-commitorid)
- [release invalidate &#60;commitorid&#62;](#release-invalidate-commitorid)
- [release validate &#60;commitorid&#62;](#release-validate-commitorid)
@ -3345,6 +3347,40 @@ The notes for this release
# Releases
## release export &#60;commitOrApplication&#62;
Exports a release to a file that you can use to import an exact
copy of the original release into another app, block, or fleet.
If the SemVer of a release is provided using the --version option,
the first argument is assumed to be the app's slug.
Only successful releases can be exported.
To import a release to an app, block, or fleet, use 'balena release import'.
Examples:
$ balena release export a777f7345fe3d655c1c981aa642e5555 -o ../path/to/release.tar
$ balena release export myOrg/myFleet --version 1.2.3 -o ../path/to/release.tar
$ balena release export myApp --version 1.2.3 -o ../path/to/release.tar
### Arguments
#### COMMITORAPPLICATION
release commit or app if used in conjunction with the --version option
### Options
#### -o, --output OUTPUT
output path
#### --version VERSION
version of the release to export from the specified app, block, or fleet
## release finalize &#60;commitOrId&#62;
Finalize a release. Releases can be "draft" or "final", and this command
@ -3371,6 +3407,41 @@ the commit or ID of the release to finalize
### Options
## release import &#60;file&#62; &#60;applicapplication&#62;
Imports a release from a file to an app, block, or fleet. The revision field of the
release is automatically omitted when importing a release. The balena API will
auto-increment the revision field of the imported release if a release exists with
the same semver. A release will not be imported if a successful release with the
same commit already exists.
Use the --override-version option to specify the version
of the imported release, overriding the one saved in the file.
To export a release to a file, use 'balena release export'.
Examples:
$ balena release import ../path/to/release.tar myApp
$ balena release import ../path/to/release.tar myOrg/myFleet
$ balena release import ../path/to/release.tar myOrg/myFleet --override-version 1.2.3
### Arguments
#### BUNDLE
path to a file, e.g. "./release.tar"
#### APPLICATION
app, block, or fleet that the release will be imported to, e.g. "myOrg/myFleet"
### Options
#### --override-version OVERRIDE-VERSION
Imports this release with the specified version overriding the version in the file.
## release &#60;commitOrId&#62;
The --json option is recommended when scripting the output of this command,

40
npm-shrinkwrap.json generated
View File

@ -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",
"@sentry/node": "^6.16.1",
"balena-config-json": "^4.2.0",
@ -1651,6 +1652,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",
@ -5305,6 +5340,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",

View File

@ -195,6 +195,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",
"@sentry/node": "^6.16.1",
"balena-config-json": "^4.2.0",

View File

@ -0,0 +1,118 @@
/**
* @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 { 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`
Exports a release into a file.
Exports a release to a file that you can use to import an exact
copy of the original release into another app, block, or fleet.
If the SemVer of a release is provided using the --version option,
the first argument is assumed to be the app's slug.
Only successful releases can be exported.
To import a release to an app, block, or fleet, use 'balena release import'.
`;
public static examples = [
'$ balena release export a777f7345fe3d655c1c981aa642e5555 -o ../path/to/release.tar',
'$ balena release export myOrg/myFleet --version 1.2.3 -o ../path/to/release.tar',
'$ balena release export myApp --version 1.2.3 -o ../path/to/release.tar',
];
public static usage = 'release export <commitOrApplication>';
public static flags = {
output: Flags.string({
description: 'output path',
char: 'o',
required: true,
}),
version: Flags.string({
description:
'version of the release to export from the specified app, block, or fleet',
}),
help: cf.help,
};
public static args = {
commitOrApplication: Args.string({
description:
'release commit or app if used in conjunction with the --version option',
required: true,
}),
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = await this.parse(ReleaseExportCmd);
let versionInfo = '';
try {
const balena = getBalenaSdk();
let releaseDetails: string | { application: number; rawVersion: string }; // ReleaseRawVersionApplicationPair
if (options.version != null) {
versionInfo = ` version ${options.version}`;
const parsedVersion = semver.parse(options.version);
if (parsedVersion == null) {
throw new ExpectedError(`version must be valid SemVer`);
}
const { getApplication } = await import('../../utils/sdk');
const { id: application } = await getApplication(
balena,
params.commitOrApplication,
);
releaseDetails = { application, rawVersion: parsedVersion.raw };
} else {
releaseDetails = params.commitOrApplication;
}
const { id: releaseId } = await balena.models.release.get(
releaseDetails,
{
$select: ['id'],
},
);
const releaseBundle = await create({
sdk: balena,
releaseId,
});
await fs.writeFile(options.output, releaseBundle);
console.log(
`Release ${params.commitOrApplication}${versionInfo} has been exported to ${options.output}.`,
);
} catch (error) {
throw new ExpectedError(
`Release ${params.commitOrApplication}${versionInfo} could not be exported: ${error.message}`,
);
}
}
}

View File

@ -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 { 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 type { ReadStream } from 'fs';
import { promises as fs } from 'fs';
import { ExpectedError } from '../../errors';
export default class ReleaseImportCmd extends Command {
public static description = stripIndent`
Imports a release from a file to an app, block, or fleet.
Imports a release from a file to an app, block, or fleet. The revision field of the
release is automatically omitted when importing a release. The balena API will
auto-increment the revision field of the imported release if a release exists with
the same semver. A release will not be imported if a successful release with the
same commit already exists.
Use the --override-version option to specify the version
of the imported release, overriding the one saved in the file.
To export a release to a file, use 'balena release export'.
`;
public static examples = [
'$ balena release import ../path/to/release.tar myApp',
'$ balena release import ../path/to/release.tar myOrg/myFleet',
'$ balena release import ../path/to/release.tar myOrg/myFleet --override-version 1.2.3',
];
public static usage = 'release import <file> <applicapplication>';
public static flags = {
'override-version': Flags.string({
description:
'Imports this release with the specified version overriding the version in the file.',
required: false,
}),
help: cf.help,
};
public static args = {
bundle: Args.string({
required: true,
description: 'path to a file, e.g. "./release.tar"',
}),
application: Args.string({
required: true,
description:
'app, block, or fleet that the release will be imported to, e.g. "myOrg/myFleet"',
}),
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = await this.parse(ReleaseImportCmd);
try {
const balena = getBalenaSdk();
let bundle: ReadStream;
try {
const fileHandle = await fs.open(params.bundle);
bundle = fileHandle.createReadStream();
} catch (error) {
throw new Error(`${params.bundle} does not exist or is not accessible`);
}
const { getApplication } = await import('../../utils/sdk');
const { id: application } = await getApplication(
balena,
params.application,
);
if (application == null) {
throw new ExpectedError(`Fleet ${params.application} not found`);
}
await apply({
sdk: balena,
application,
stream: bundle,
version: options['override-version'],
});
console.log(
`Release bundle ${params.bundle} has been imported to ${params.application}.`,
);
} catch (error) {
throw new ExpectedError(
`Could not import release bundle ${params.bundle} to ${params.application}: ${error.message}`,
);
}
}
}

View File

@ -0,0 +1,110 @@
import * as os from 'node:os';
import * as path from 'node:path';
import * as stream from 'node:stream';
import { cleanOutput, runCommand } from '../../helpers';
import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { expect } from 'chai';
import * as mock from 'mock-require';
import * as sinon from 'sinon';
import { promises as fs } from 'node:fs';
// "itSS" means "it() Skip Standalone"
const itSS = process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? it.skip : it;
describe('balena release export', function () {
const appCommit = 'badc0ffe';
const appSlug = 'testOrg/testApp';
const appVersion = '1.2.3+rev1';
const tmpDir = os.tmpdir();
const outputPath = path.resolve(tmpDir, 'release.tar');
let api: BalenaAPIMock;
let releaseFileBuffer: Buffer;
const releaseBundleCreateStub = sinon.stub();
this.beforeEach(async function () {
api = new BalenaAPIMock();
api.expectGetWhoAmI();
releaseFileBuffer = await fs.readFile(
path.join('tests', 'test-data', 'release.tar'),
);
mock('@balena/release-bundle', {
create: releaseBundleCreateStub,
});
});
this.afterEach(() => {
// Check all expected api calls have been made and clean up.
api.done();
mock.stop('@balena/release-bundle');
});
itSS('should export a release to a file', async () => {
api.expectGetRelease();
releaseBundleCreateStub.resolves(stream.Readable.from(releaseFileBuffer));
const { out, err } = await runCommand(
`release export ${appCommit} -o ${outputPath}`,
);
const lines = cleanOutput(out);
expect(lines[0]).to.contain(
`Release ${appCommit} has been exported to ${outputPath}.`,
);
expect(err).to.be.empty;
});
itSS('should fail if the create throws an error', async () => {
api.expectGetRelease();
const expectedError = `BalenaReleaseNotFound: Release not found: ${appCommit}`;
releaseBundleCreateStub.rejects(new Error(expectedError));
const { err } = await runCommand(
`release export ${appCommit} -o ${outputPath}`,
);
expect(cleanOutput(err, true)).to.include(
`Release ${appCommit} could not be exported: ${expectedError}`,
);
});
itSS('should parse with application slug and version', async () => {
api.expectGetRelease();
api.expectGetApplication({ times: 2 });
releaseBundleCreateStub.resolves(stream.Readable.from(releaseFileBuffer));
const { out, err } = await runCommand(
`release export ${appSlug} -o ${outputPath} --version ${appVersion}`,
);
const lines = cleanOutput(out);
expect(lines[0]).to.contain(
`Release ${appSlug} version ${appVersion} has been exported to ${outputPath}.`,
);
expect(err).to.be.empty;
});
it('should fail if the app slug is provided without the release version', async () => {
api.expectGetRelease({ notFound: true });
const expectedError = `Release not found: ${appSlug}`;
const { err } = await runCommand(
`release export ${appSlug} -o ${outputPath}`,
);
expect(cleanOutput(err, true)).to.include(
`Release ${appSlug} could not be exported: ${expectedError}`,
);
});
it('should fail if the semver is invalid', async () => {
const expectedError = 'version must be valid SemVer';
const { err } = await runCommand(
`release export ${appSlug} --version ${appCommit} -o ${outputPath}`,
);
expect(cleanOutput(err, true)).to.include(
`Release ${appSlug} version ${appCommit} could not be exported: ${expectedError}`,
);
});
});

View File

@ -0,0 +1,113 @@
import { cleanOutput, runCommand } from '../../helpers';
import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { expect } from 'chai';
import * as mock from 'mock-require';
import * as sinon from 'sinon';
import * as path from 'node:path';
// "itSS" means "it() Skip Standalone"
const itSS = process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? it.skip : it;
describe('balena release import', function () {
const appCommit = '4c8becf0780ca69d33b638ea8fa163d7';
const appSlug = 'myOrg/myFleet';
const releasePath = path.join('tests', 'test-data', 'release.tar');
let api: BalenaAPIMock;
const releaseBundleApplyStub = sinon.stub();
this.beforeEach(async function () {
api = new BalenaAPIMock();
api.expectGetWhoAmI();
mock('@balena/release-bundle', {
apply: releaseBundleApplyStub,
});
});
this.afterEach(() => {
// Check all expected api calls have been made and clean up.
api.done();
mock.stop('@balena/release-bundle');
});
itSS('should import a release to an app', async () => {
api.expectGetApplication();
releaseBundleApplyStub.resolves(123);
const { out, err } = await runCommand(
`release import ${releasePath} ${appSlug}`,
);
const lines = cleanOutput(out);
expect(lines[0]).to.contain(
`Release bundle ${releasePath} has been imported to ${appSlug}.`,
);
expect(err).to.be.empty;
});
itSS(
'should import a release to an app with a version override',
async () => {
api.expectGetApplication();
releaseBundleApplyStub.resolves(123);
const { out, err } = await runCommand(
`release import ${releasePath} ${appSlug} --override-version 1.2.3`,
);
const lines = cleanOutput(out);
expect(lines[0]).to.contain(
`Release bundle ${releasePath} has been imported to ${appSlug}.`,
);
expect(err).to.be.empty;
},
);
it('should fail if release file does not exist', async () => {
const nonExistentFile = path.join(
'tests',
'test-data',
'non-existent-file.tar',
);
const { out, err } = await runCommand(
`release import ${nonExistentFile} ${appSlug}`,
);
expect(cleanOutput(err, true)).to.contain(
`Could not import release bundle ${nonExistentFile} to ${appSlug}: ${nonExistentFile} does not exist or is not accessible`,
);
expect(out).to.be.empty;
});
itSS('should fail if overriding version is not a valid semver', async () => {
api.expectGetApplication();
const expectedError = `Manifest is malformed: Expected version to be a valid semantic version but found '${appCommit}'`;
releaseBundleApplyStub.rejects(new Error(expectedError));
const { out, err } = await runCommand(
`release import ${releasePath} ${appSlug} --override-version ${appCommit}`,
);
expect(cleanOutput(err, true)).to.contain(
`Could not import release bundle ${releasePath} to ${appSlug}: ${expectedError}`,
);
expect(out).to.be.empty;
});
itSS(
'should fail if a successful release with the same commit already exists',
async () => {
api.expectGetApplication();
const expectedError = `A successful release with commit ${appCommit} (1.2.3) already exists; nothing to do`;
releaseBundleApplyStub.rejects(new Error(expectedError));
const { out, err } = await runCommand(
`release import ${releasePath} ${appSlug}`,
);
expect(cleanOutput(err, true)).to.include(
`Could not import release bundle ${releasePath} to ${appSlug}: ${expectedError}`,
);
expect(out).to.be.empty;
},
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
tests/test-data/release.tar Normal file

Binary file not shown.