Add support for releases

Signed-off-by: Paul Jonathan <pj@balena.io>
Change-type: minor
This commit is contained in:
Paul Jonathan Zoulin 2021-09-18 23:07:58 +00:00 committed by toochevere
parent e098cdca17
commit a18e182ae4
9 changed files with 496 additions and 52 deletions

View File

@ -8,7 +8,7 @@ _balena() {
local context state line curcontext="$curcontext"
# Valid top-level completions
main_commands=( apps build deploy envs fleets join keys leave login logout logs note orgs preload push scan settings ssh support tags tunnel version whoami api-key app app config device device devices env fleet fleet internal key key local os tag util )
main_commands=( apps build deploy envs fleets join keys leave login logout logs note orgs preload push releases scan settings ssh support tags tunnel version whoami api-key app app config device device devices env fleet fleet internal key key local os release release tag util )
# Sub-completions
api_key_cmds=( generate )
app_cmds=( create purge rename restart rm )
@ -21,6 +21,7 @@ _balena() {
key_cmds=( add rm )
local_cmds=( configure flash )
os_cmds=( build-config configure download initialize versions )
release_cmds=( finalize )
tag_cmds=( rm set )
@ -73,6 +74,9 @@ _balena_sec_cmds() {
"os")
_describe -t os_cmds 'os_cmd' os_cmds "$@" && ret=0
;;
"release")
_describe -t release_cmds 'release_cmd' release_cmds "$@" && ret=0
;;
"tag")
_describe -t tag_cmds 'tag_cmd' tag_cmds "$@" && ret=0
;;

View File

@ -7,7 +7,7 @@ _balena_complete()
local cur prev
# Valid top-level completions
main_commands="apps build deploy envs fleets join keys leave login logout logs note orgs preload push scan settings ssh support tags tunnel version whoami api-key app app config device device devices env fleet fleet internal key key local os tag util"
main_commands="apps build deploy envs fleets join keys leave login logout logs note orgs preload push releases scan settings ssh support tags tunnel version whoami api-key app app config device device devices env fleet fleet internal key key local os release release tag util"
# Sub-completions
api_key_cmds="generate"
app_cmds="create purge rename restart rm"
@ -20,6 +20,7 @@ _balena_complete()
key_cmds="add rm"
local_cmds="configure flash"
os_cmds="build-config configure download initialize versions"
release_cmds="finalize"
tag_cmds="rm set"
@ -67,6 +68,9 @@ _balena_complete()
os)
COMPREPLY=( $(compgen -W "$os_cmds" -- $cur) )
;;
release)
COMPREPLY=( $(compgen -W "$release_cmds" -- $cur) )
;;
tag)
COMPREPLY=( $(compgen -W "$tag_cmds" -- $cur) )
;;

View File

@ -0,0 +1,86 @@
/**
* @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 * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
help: void;
}
interface ArgsDef {
commitOrId: string | number;
}
export default class ReleaseFinalizeCmd extends Command {
public static description = stripIndent`
Finalize a release.
Finalize a release. Releases can be "draft" or "final", and this command
changes a draft release into a final release. Draft releases can be created
with the \`--draft\` option of the \`balena build\` or \`balena deploy\`
commands.
Draft releases are not automatically deployed to devices tracking the latest application
release. For a draft release to be deployed to a device, the device should be
explicity pinned to that release. Conversely, final releases may trigger immediate
deployment to unpinned devices (subject to a device's polling period) and, for
this reason, final releases cannot be changed back to draft status.
`;
public static examples = [
'$ balena release finalize a777f7345fe3d655c1c981aa642e5555',
'$ balena release finalize 1234567',
];
public static usage = 'finalize <commitOrId>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static args = [
{
name: 'commitOrId',
description: 'the commit or ID of the release to finalize',
required: true,
parse: (commitOrId: string) => tryAsInteger(commitOrId),
},
];
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(ReleaseFinalizeCmd);
const balena = getBalenaSdk();
const release = await balena.models.release.get(params.commitOrId, {
$select: ['id', 'is_final'],
});
if (release.is_final) {
console.log(`Release ${params.commitOrId} is already finalized!`);
return;
}
await balena.models.release.finalize(release.id);
console.log(`Release ${params.commitOrId} finalized`);
}
}

View File

@ -0,0 +1,128 @@
/**
* @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 * as cf from '../../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import type * as BalenaSdk from 'balena-sdk';
import jsyaml = require('js-yaml');
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
help: void;
composition?: boolean;
}
interface ArgsDef {
commitOrId: string | number;
}
export default class ReleaseCmd extends Command {
public static description = stripIndent`
Get info for a release.
`;
public static examples = [
'$ balena release a777f7345fe3d655c1c981aa642e5555',
'$ balena release 1234567',
];
public static usage = 'release <commitOrId>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
composition: flags.boolean({
default: false,
char: 'c',
description: 'Return the release composition',
}),
};
public static args = [
{
name: 'commitOrId',
description: 'the commit or ID of the release to get information',
required: true,
parse: (commitOrId: string) => tryAsInteger(commitOrId),
},
];
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ReleaseCmd,
);
const balena = getBalenaSdk();
if (options.composition) {
await this.showComposition(params.commitOrId, balena);
} else {
await this.showReleaseInfo(params.commitOrId, balena);
}
}
async showComposition(
commitOrId: string | number,
balena: BalenaSdk.BalenaSDK,
) {
const release = await balena.models.release.get(commitOrId, {
$select: 'composition',
});
console.log(jsyaml.dump(release.composition));
}
async showReleaseInfo(
commitOrId: string | number,
balena: BalenaSdk.BalenaSDK,
) {
const fields: Array<keyof BalenaSdk.Release> = [
'id',
'commit',
'created_at',
'status',
'semver',
'is_final',
'build_log',
'start_timestamp',
'end_timestamp',
];
const release = await balena.models.release.get(commitOrId, {
$select: fields,
$expand: {
release_tag: {
$select: ['tag_key', 'value'],
},
},
});
const tagStr = release
.release_tag!.map((t) => `${t.tag_key}=${t.value}`)
.join('\n');
const _ = await import('lodash');
const values = _.mapValues(
release,
(val) => val ?? 'N/a',
) as Dictionary<string>;
values['tags'] = tagStr;
console.log(getVisuals().table.vertical(values, [...fields, 'tags']));
}
}

86
lib/commands/releases.ts Normal file
View File

@ -0,0 +1,86 @@
/**
* @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 * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { applicationNameNote } from '../utils/messages';
import type * as BalenaSdk from 'balena-sdk';
interface FlagsDef {
help: void;
}
interface ArgsDef {
fleet: string;
}
export default class ReleasesCmd extends Command {
public static description = stripIndent`
List all releases of a fleet.
List all releases of the given fleet.
${applicationNameNote.split('\n').join('\n\t\t')}
`;
public static examples = ['$ balena releases myorg/myfleet'];
public static usage = 'releases <fleet>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static args = [
{
name: 'fleet',
description: 'fleet name or slug',
required: true,
},
];
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(ReleasesCmd);
const fields: Array<keyof BalenaSdk.Release> = [
'id',
'commit',
'created_at',
'status',
'semver',
'is_final',
];
const balena = getBalenaSdk();
const releases = await balena.models.release.getAllByApplication(
params.fleet,
{ $select: fields },
);
const _ = await import('lodash');
console.log(
getVisuals().table.horizontal(
releases.map((rel) => _.mapValues(rel, (val) => val ?? 'N/a')),
fields,
),
);
}
}

View File

@ -208,6 +208,16 @@ environments). Numeric fleet IDs are deprecated because they consist of an
implementation detail of the balena backend. We intend to remove support for
numeric IDs at some point in the future.`;
export const applicationNameNote = `\
Fleets may be specified by fleet name or slug. Slugs are recommended because
they are unique and unambiguous. Slugs can be listed with the \`balena fleets\`
command. Note that slugs may change if the fleet is renamed. Fleet names are
not unique and may result in "Fleet is ambiguous" errors at any time (even if
"it used to work in the past"), for example if the name clashes with a newly
created public/open fleet, or with fleets from other balena accounts that you
may be invited to join under any role. For this reason, fleet names are
especially discouraged in scripts (e.g. CI environments).`;
export const jsonInfo = `\
The --json option is recommended when scripting the output of this command,
because field names are less likely to change in JSON format and because it

View File

@ -0,0 +1,68 @@
/**
* @license
* Copyright 2019-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 { expect } from 'chai';
import { BalenaAPIMock } from '../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../helpers';
describe('balena release', function () {
let api: BalenaAPIMock;
beforeEach(() => {
api = new BalenaAPIMock();
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetMixpanel({ optional: true });
});
afterEach(() => {
// Check all expected api calls have been made and clean up.
api.done();
});
it('should show release details', async () => {
api.expectGetRelease();
const { out } = await runCommand('release 27fda508c');
const lines = cleanOutput(out);
expect(lines[0]).to.contain('ID: ');
expect(lines[0]).to.contain(' 142334');
expect(lines[1]).to.contain('COMMIT: ');
expect(lines[1]).to.contain(' 90247b54de4fa7a0a3cbc85e73c68039');
});
it('should return release composition', async () => {
api.expectGetRelease();
const { out } = await runCommand('release 27fda508c --composition');
const lines = cleanOutput(out);
expect(lines[0]).to.be.equal("version: '2.1'");
expect(lines[1]).to.be.equal('networks: {}');
expect(lines[2]).to.be.equal('volumes:');
expect(lines[3]).to.be.equal('resin-data: {}');
expect(lines[4]).to.be.equal('services:');
expect(lines[5]).to.be.equal('main:');
});
it('should list releases', async () => {
api.expectGetRelease();
api.expectGetApplication();
const { out } = await runCommand('releases someapp');
const lines = cleanOutput(out);
expect(lines.length).to.be.equal(2);
expect(lines[1]).to.contain('142334');
expect(lines[1]).to.contain('90247b54de4fa7a0a3cbc85e73c68039');
});
});

View File

@ -101,13 +101,28 @@ export class BalenaAPIMock extends NockMock {
});
}
public expectGetRelease(opts: ScopeOpts = {}) {
this.optGet(/^\/v6\/release($|[(?])/, opts).replyWithFile(
public expectGetRelease({
notFound = false,
optional = false,
persist = false,
} = {}) {
const interceptor = this.optGet(/^\/v6\/release($|[(?])/, {
persist,
optional,
});
if (notFound) {
interceptor.reply(200, { d: [] });
} else {
this.optGet(/^\/v6\/release($|[(?])/, {
persist,
optional,
}).replyWithFile(
200,
path.join(apiResponsePath, 'release-GET-v6.json'),
jHeader,
);
}
}
/**
* Mocks balena-release call

View File

@ -1,6 +1,18 @@
{
"d": [
{
"id": 142334,
"commit": "90247b54de4fa7a0a3cbc85e73c68039",
"created_at": "2021-08-25T22:18:34.014Z",
"status": "success",
"semver": "0.0.0",
"is_final": false,
"build_log": null,
"start_timestamp": "2021-08-25T22:18:33.624Z",
"end_timestamp": "2021-08-25T22:18:48.820Z",
"__metadata": {
"uri": "/resin/release(@id)?@id=142334"
},
"contains__image": [
{
"image": [
@ -43,9 +55,40 @@
}
}
],
"id": 1203844,
"__metadata": {
"uri": "/resin/release(@id)?@id=1203844"
"release_tag": [
{
"tag_key": "testtag1",
"value": "val1",
"__metadata": {}
}
],
"composition": {
"version": "2.1",
"networks": {},
"volumes": {
"resin-data": {}
},
"services": {
"main": {
"build": {
"context": "."
},
"privileged": true,
"tty": true,
"restart": "always",
"network_mode": "host",
"volumes": [
"resin-data:/data"
],
"labels": {
"io.resin.features.kernel-modules": "1",
"io.resin.features.firmware": "1",
"io.resin.features.dbus": "1",
"io.resin.features.supervisor-api": "1",
"io.resin.features.resin-api": "1"
}
}
}
}
}
]