diff --git a/completion/_balena b/completion/_balena index ff9e597b..603c5d17 100644 --- a/completion/_balena +++ b/completion/_balena @@ -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 ;; diff --git a/completion/balena-completion.bash b/completion/balena-completion.bash index c8228b56..f5b815d2 100644 --- a/completion/balena-completion.bash +++ b/completion/balena-completion.bash @@ -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) ) ;; diff --git a/lib/commands/release/finalize.ts b/lib/commands/release/finalize.ts new file mode 100644 index 00000000..53c09846 --- /dev/null +++ b/lib/commands/release/finalize.ts @@ -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 '; + + public static flags: flags.Input = { + 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(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`); + } +} diff --git a/lib/commands/release/index.ts b/lib/commands/release/index.ts new file mode 100644 index 00000000..ae01d0db --- /dev/null +++ b/lib/commands/release/index.ts @@ -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 '; + + public static flags: flags.Input = { + 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( + 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 = [ + '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; + values['tags'] = tagStr; + + console.log(getVisuals().table.vertical(values, [...fields, 'tags'])); + } +} diff --git a/lib/commands/releases.ts b/lib/commands/releases.ts new file mode 100644 index 00000000..a3294061 --- /dev/null +++ b/lib/commands/releases.ts @@ -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 '; + + public static flags: flags.Input = { + 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(ReleasesCmd); + + const fields: Array = [ + '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, + ), + ); + } +} diff --git a/lib/utils/messages.ts b/lib/utils/messages.ts index 36821262..3d00fef7 100644 --- a/lib/utils/messages.ts +++ b/lib/utils/messages.ts @@ -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 diff --git a/tests/commands/release.spec.ts b/tests/commands/release.spec.ts new file mode 100644 index 00000000..8371def4 --- /dev/null +++ b/tests/commands/release.spec.ts @@ -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'); + }); +}); diff --git a/tests/nock/balena-api-mock.ts b/tests/nock/balena-api-mock.ts index 33565eee..901ef8ef 100644 --- a/tests/nock/balena-api-mock.ts +++ b/tests/nock/balena-api-mock.ts @@ -101,12 +101,27 @@ export class BalenaAPIMock extends NockMock { }); } - public expectGetRelease(opts: ScopeOpts = {}) { - this.optGet(/^\/v6\/release($|[(?])/, opts).replyWithFile( - 200, - path.join(apiResponsePath, 'release-GET-v6.json'), - jHeader, - ); + 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, + ); + } } /** diff --git a/tests/test-data/api-response/release-GET-v6.json b/tests/test-data/api-response/release-GET-v6.json index 5ecfce53..efae8a26 100644 --- a/tests/test-data/api-response/release-GET-v6.json +++ b/tests/test-data/api-response/release-GET-v6.json @@ -1,52 +1,95 @@ { "d": [ - { - "contains__image": [ - { - "image": [ - { - "id": 1820810, - "created_at": "2020-01-04T01:13:08.805Z", - "start_timestamp": "2020-01-04T01:13:08.583Z", - "end_timestamp": "2020-01-04T01:13:11.920Z", - "dockerfile": "# FROM busybox\n# FROM arm32v7/busybox\n# FROM arm32v7/alpine\n# FROM eu.gcr.io/buoyant-idea-226013/arm32v7/busybox\n# FROM eu.gcr.io/buoyant-idea-226013/amd64/busybox\n# FROM balenalib/raspberrypi3-debian:jessie-build\nFROM balenalib/raspberrypi3:stretch\nENV UDEV=1\n\n# FROM sander85/rpi-busybox # armv6\n# FROM balenalib/raspberrypi3-alpine\n\n# COPY start.sh /\n# COPY /src/start.sh /src/start.sh\n# COPY /src/hello.txt /\n# COPY src/hi.txt /\n\n# RUN cat /hello.txt\n# RUN cat /hi.txt\n# RUN cat /run/secrets/my-secret.txt\n# EXPOSE 80\nRUN uname -a\n\n# FROM alpine\n# RUN apk update && apk add bash\n# SHELL [\"/bin/bash\", \"-c\"]\n# CMD for ((i=1; i > 0; i++)); do echo \"(Plain Dockerfile 34-$i) $(uname -a)\"; sleep ${INTERVAL=5}; done\n\n# CMD i=1; while :; do echo \"Plain Dockerfile 36 ($i) $(uname -a)\"; sleep 10; i=$((i+1)); done\n# ENTRYPOINT [\"/usr/bin/entry.sh\"]\nCMD [\"/bin/bash\"]\n", - "is_a_build_of__service": { - "__deferred": { - "uri": "/resin/service(233455)" - }, - "__id": 233455 - }, - "image_size": 134320410, - "is_stored_at__image_location": "registry2.balena-cloud.com/v2/9c00c9413942cd15cfc9189c5dac359d", - "project_type": "Standard Dockerfile", - "error_message": null, - "build_log": "Step 1/4 : FROM balenalib/raspberrypi3:stretch\n ---> 8a75ea61d9c0\nStep 2/4 : ENV UDEV=1\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> 159206067c8a\nStep 3/4 : RUN uname -a\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> dd1b3d9c334b\nStep 4/4 : CMD [\"/bin/bash\"]\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> 5211b6f4bb72\nSuccessfully built 5211b6f4bb72\n", - "push_timestamp": "2020-01-04T01:13:14.415Z", - "status": "success", - "content_hash": "sha256:6b5471aae43ae81e8f69e10d1a516cb412569a6d5020a57eae311f8fa16d688a", - "contract": null, - "__metadata": { - "uri": "/resin/image(@id)?@id=1820810" - } - } - ], - "id": 1738663, - "created_at": "2020-01-04T01:13:14.646Z", - "is_part_of__release": { - "__deferred": { - "uri": "/resin/release(1203844)" - }, - "__id": 1203844 - }, - "__metadata": { - "uri": "/resin/image__is_part_of__release(@id)?@id=1738663" - } + { + "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": [ + { + "id": 1820810, + "created_at": "2020-01-04T01:13:08.805Z", + "start_timestamp": "2020-01-04T01:13:08.583Z", + "end_timestamp": "2020-01-04T01:13:11.920Z", + "dockerfile": "# FROM busybox\n# FROM arm32v7/busybox\n# FROM arm32v7/alpine\n# FROM eu.gcr.io/buoyant-idea-226013/arm32v7/busybox\n# FROM eu.gcr.io/buoyant-idea-226013/amd64/busybox\n# FROM balenalib/raspberrypi3-debian:jessie-build\nFROM balenalib/raspberrypi3:stretch\nENV UDEV=1\n\n# FROM sander85/rpi-busybox # armv6\n# FROM balenalib/raspberrypi3-alpine\n\n# COPY start.sh /\n# COPY /src/start.sh /src/start.sh\n# COPY /src/hello.txt /\n# COPY src/hi.txt /\n\n# RUN cat /hello.txt\n# RUN cat /hi.txt\n# RUN cat /run/secrets/my-secret.txt\n# EXPOSE 80\nRUN uname -a\n\n# FROM alpine\n# RUN apk update && apk add bash\n# SHELL [\"/bin/bash\", \"-c\"]\n# CMD for ((i=1; i > 0; i++)); do echo \"(Plain Dockerfile 34-$i) $(uname -a)\"; sleep ${INTERVAL=5}; done\n\n# CMD i=1; while :; do echo \"Plain Dockerfile 36 ($i) $(uname -a)\"; sleep 10; i=$((i+1)); done\n# ENTRYPOINT [\"/usr/bin/entry.sh\"]\nCMD [\"/bin/bash\"]\n", + "is_a_build_of__service": { + "__deferred": { + "uri": "/resin/service(233455)" + }, + "__id": 233455 + }, + "image_size": 134320410, + "is_stored_at__image_location": "registry2.balena-cloud.com/v2/9c00c9413942cd15cfc9189c5dac359d", + "project_type": "Standard Dockerfile", + "error_message": null, + "build_log": "Step 1/4 : FROM balenalib/raspberrypi3:stretch\n ---> 8a75ea61d9c0\nStep 2/4 : ENV UDEV=1\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> 159206067c8a\nStep 3/4 : RUN uname -a\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> dd1b3d9c334b\nStep 4/4 : CMD [\"/bin/bash\"]\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> 5211b6f4bb72\nSuccessfully built 5211b6f4bb72\n", + "push_timestamp": "2020-01-04T01:13:14.415Z", + "status": "success", + "content_hash": "sha256:6b5471aae43ae81e8f69e10d1a516cb412569a6d5020a57eae311f8fa16d688a", + "contract": null, + "__metadata": { + "uri": "/resin/image(@id)?@id=1820810" } + } ], - "id": 1203844, + "id": 1738663, + "created_at": "2020-01-04T01:13:14.646Z", + "is_part_of__release": { + "__deferred": { + "uri": "/resin/release(1203844)" + }, + "__id": 1203844 + }, "__metadata": { - "uri": "/resin/release(@id)?@id=1203844" + "uri": "/resin/image__is_part_of__release(@id)?@id=1738663" } + } + ], + "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" + } + } + } } + } ] -} + }