Fix issues with devices associated with inaccessible applications.

Change-type: patch
Resolves: #1530
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2019-12-11 13:18:58 +01:00
parent c88b317143
commit 33552724a1
11 changed files with 505 additions and 5 deletions

View File

@ -53,7 +53,8 @@ exports.list =
.tap (devices) ->
devices = _.map devices, (device) ->
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
device.application_name = device.belongs_to__application[0].app_name
device.application_name =
if device.belongs_to__application?[0] then device.belongs_to__application[0].app_name else 'N/a'
device.uuid = device.uuid.slice(0, 7)
return device
@ -93,7 +94,8 @@ exports.info =
balena.models.device.getStatus(device).then (status) ->
device.status = status
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
device.application_name = device.belongs_to__application[0].app_name
device.application_name =
if device.belongs_to__application?[0] then device.belongs_to__application[0].app_name else 'N/a'
device.commit = device.is_on__commit
console.log visuals.table.vertical device, [
@ -345,6 +347,8 @@ exports.move =
patterns = require('../utils/patterns')
balena.models.device.get(params.uuid, expandForAppName).then (device) ->
device.application_name =
if device.belongs_to__application?[0] then device.belongs_to__application[0].app_name else 'N/a'
return options.application if options.application
return Promise.all([
@ -359,7 +363,7 @@ exports.move =
return patterns.selectApplication (application) ->
return _.every [
_.some(compatibleDeviceTypes, (dt) -> dt.slug == application.device_type)
device.belongs_to__application[0].app_name isnt application.app_name
device.application_name isnt application.app_name
]
.tap (application) ->
return balena.models.device.move(params.uuid, application)

View File

@ -213,6 +213,22 @@ export class BalenaAPIMock {
// (Also, nock should automatically throw an error, but also not happening)
// For now, the console.error is sufficient (will fail the test)
}
public debug() {
const scope = this.scope;
let mocks = scope.pendingMocks();
console.error(`pending mocks ${mocks.length}: ${mocks}`);
this.scope.on('request', function(_req, _interceptor, _body) {
console.log(`>> REQUEST:` + _req.path);
mocks = scope.pendingMocks();
console.error(`pending mocks ${mocks.length}: ${mocks}`);
});
this.scope.on('replied', function(_req) {
console.log(`<< REPLIED:` + _req.path);
});
}
}
const appServiceVarsByService: { [key: string]: any } = {

View File

@ -0,0 +1,62 @@
import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
const HELP_RESPONSE = `
Usage: device move <uuid>
Use this command to move a device to another application you own.
If you omit the application, you'll get asked for it interactively.
Examples:
\t$ balena device move 7cf02a6
\t$ balena device move 7cf02a6 --application MyNewApp
Options:
--application, -a, --app <application> application name
`;
describe('balena device move', function() {
let api: BalenaAPIMock;
beforeEach(() => {
api = new BalenaAPIMock();
});
afterEach(() => {
// Check all expected api calls have been made and clean up.
api.done();
});
it('should print help text with the -h flag', async () => {
api.expectWhoAmI();
api.expectMixpanel();
const { out, err } = await runCommand('device move -h');
expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE]));
expect(err).to.eql([]);
});
it.skip('should error if uuid not provided', async () => {
// TODO: Figure out how to test for expected errors with current setup
// including exit codes if possible.
api.expectWhoAmI();
api.expectMixpanel();
const { out, err } = await runCommand('device move');
const errLines = cleanOutput(err);
expect(errLines[0]).to.equal('Missing uuid');
expect(out).to.eql([]);
});
// TODO: Test to add once nock matching issues resolved:
// - 'should perform device move if application name provided'
// - 'should start interactive selection of application name if none provided'
// - 'correctly handles devices with missing application'
});

View File

@ -0,0 +1,64 @@
{
"d": [
{
"belongs_to__application": [
{
"app_name": "test app",
"__metadata": {}
}
],
"id": 1747415,
"belongs_to__user": {
"__deferred": {
"uri": "/resin/user(46272)"
},
"__id": 46272
},
"is_managed_by__device": null,
"actor": 4180757,
"should_be_running__release": null,
"device_name": "sparkling-wood",
"device_type": "raspberrypi4-64",
"uuid": "fda508c8583011b8466c26abdd5159f2",
"is_on__commit": "18756d3386c25a044db66b89e0409804",
"note": null,
"local_id": null,
"status": "Idle",
"is_online": false,
"last_connectivity_event": "2019-11-23T00:26:35.074Z",
"is_connected_to_vpn": false,
"last_vpn_event": "2019-11-23T00:26:35.074Z",
"ip_address": "192.168.0.112",
"vpn_address": null,
"public_address": "89.186.29.129",
"os_version": "balenaOS 2.44.0+rev3",
"os_variant": "dev",
"supervisor_version": "10.3.7",
"should_be_managed_by__supervisor_release": null,
"is_managed_by__service_instance": {
"__deferred": {
"uri": "/resin/service_instance(124111)"
},
"__id": 124111
},
"provisioning_progress": null,
"provisioning_state": "",
"download_progress": null,
"is_web_accessible": false,
"longitude": "22.5853",
"latitude": "51.2712",
"location": "Lublin, Lublin, Poland",
"custom_longitude": "",
"custom_latitude": "",
"logs_channel": null,
"is_locked_until__date": null,
"is_accessible_by_support_until__date": null,
"created_at": "2019-11-18T12:27:37.423Z",
"is_active": true,
"api_heartbeat_state": "offline",
"__metadata": {
"uri": "/resin/device(@id)?@id=1747415"
}
}
]
}

View File

@ -0,0 +1,60 @@
{
"d": [
{
"belongs_to__application": [
],
"id": 1747415,
"belongs_to__user": {
"__deferred": {
"uri": "/resin/user(46272)"
},
"__id": 46272
},
"is_managed_by__device": null,
"actor": 4180757,
"should_be_running__release": null,
"device_name": "sparkling-wood",
"device_type": "raspberrypi4-64",
"uuid": "fda508c8583011b8466c26abdd5159f2",
"is_on__commit": "18756d3386c25a044db66b89e0409804",
"note": null,
"local_id": null,
"status": "Idle",
"is_online": false,
"last_connectivity_event": "2019-11-23T00:26:35.074Z",
"is_connected_to_vpn": false,
"last_vpn_event": "2019-11-23T00:26:35.074Z",
"ip_address": "192.168.0.112",
"vpn_address": null,
"public_address": "89.186.29.129",
"os_version": "balenaOS 2.44.0+rev3",
"os_variant": "dev",
"supervisor_version": "10.3.7",
"should_be_managed_by__supervisor_release": null,
"is_managed_by__service_instance": {
"__deferred": {
"uri": "/resin/service_instance(124111)"
},
"__id": 124111
},
"provisioning_progress": null,
"provisioning_state": "",
"download_progress": null,
"is_web_accessible": false,
"longitude": "22.5853",
"latitude": "51.2712",
"location": "Lublin, Lublin, Poland",
"custom_longitude": "",
"custom_latitude": "",
"logs_channel": null,
"is_locked_until__date": null,
"is_accessible_by_support_until__date": null,
"created_at": "2019-11-18T12:27:37.423Z",
"is_active": true,
"api_heartbeat_state": "offline",
"__metadata": {
"uri": "/resin/device(@id)?@id=1747415"
}
}
]
}

View File

@ -0,0 +1,94 @@
import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
const HELP_RESPONSE = `
Usage: device <uuid>
Use this command to show information about a single device.
Examples:
\t$ balena device 7cf02a6
`;
describe('balena device', function() {
let api: BalenaAPIMock;
beforeEach(() => {
api = new BalenaAPIMock();
});
afterEach(() => {
// Check all expected api calls have been made and clean up.
api.done();
});
it('should print help text with the -h flag', async () => {
api.expectWhoAmI();
api.expectMixpanel();
const { out, err } = await runCommand('device -h');
expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE]));
expect(err).to.eql([]);
});
it.skip('should error if uuid not provided', async () => {
// TODO: Figure out how to test for expected errors with current setup
// including exit codes if possible.
api.expectWhoAmI();
api.expectMixpanel();
const { out, err } = await runCommand('device');
const errLines = cleanOutput(err);
expect(errLines[0]).to.equal('Missing uuid');
expect(out).to.eql([]);
});
it('should list device details for provided uuid', async () => {
api.expectWhoAmI();
api.expectMixpanel();
api.scope
.get(/^\/v5\/device/)
.replyWithFile(200, __dirname + '/device.api-response.json', {
'Content-Type': 'application/json',
});
const { out, err } = await runCommand('device 27fda508c');
const lines = cleanOutput(out);
expect(lines).to.have.lengthOf(13);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('test app');
expect(err).to.eql([]);
});
it('correctly handles devices with missing application', async () => {
// Devices with missing applications will have application name set to `N/a`.
// e.g. When user has a device associated with app that user is no longer a collaborator of.
api.expectWhoAmI();
api.expectMixpanel();
api.scope
.get(/^\/v5\/device/)
.replyWithFile(200, __dirname + '/device.api-response.missing-app.json', {
'Content-Type': 'application/json',
});
const { out, err } = await runCommand('device 27fda508c');
const lines = cleanOutput(out);
expect(lines).to.have.lengthOf(13);
expect(lines[0]).to.equal('== SPARKLING WOOD');
expect(lines[6].split(':')[1].trim()).to.equal('N/a');
expect(err).to.eql([]);
});
});

View File

@ -0,0 +1,120 @@
{
"d": [
{
"belongs_to__application": [
{
"app_name": "test app",
"__metadata": {}
}
],
"id": 1747415,
"belongs_to__user": {
"__deferred": {
"uri": "/resin/user(46272)"
},
"__id": 46272
},
"is_managed_by__device": null,
"actor": 4180757,
"should_be_running__release": null,
"device_name": "sparkling-wood",
"device_type": "raspberrypi4-64",
"uuid": "fda508c8583011b8466c26abdd5159f2",
"is_on__commit": "18756d3386c25a044db66b89e0409804",
"note": null,
"local_id": null,
"status": "Idle",
"is_online": false,
"last_connectivity_event": "2019-11-23T00:26:35.074Z",
"is_connected_to_vpn": false,
"last_vpn_event": "2019-11-23T00:26:35.074Z",
"ip_address": "192.168.0.112",
"vpn_address": null,
"public_address": "89.186.29.129",
"os_version": "balenaOS 2.44.0+rev3",
"os_variant": "dev",
"supervisor_version": "10.3.7",
"should_be_managed_by__supervisor_release": null,
"is_managed_by__service_instance": {
"__deferred": {
"uri": "/resin/service_instance(124111)"
},
"__id": 124111
},
"provisioning_progress": null,
"provisioning_state": "",
"download_progress": null,
"is_web_accessible": false,
"longitude": "22.5853",
"latitude": "51.2712",
"location": "Lublin, Lublin, Poland",
"custom_longitude": "",
"custom_latitude": "",
"logs_channel": null,
"is_locked_until__date": null,
"is_accessible_by_support_until__date": null,
"created_at": "2019-11-18T12:27:37.423Z",
"is_active": true,
"api_heartbeat_state": "offline",
"__metadata": {
"uri": "/resin/device(@id)?@id=1747415"
}
},
{
"belongs_to__application": [
],
"id": 1747416,
"belongs_to__user": {
"__deferred": {
"uri": "/resin/user(46272)"
},
"__id": 46272
},
"is_managed_by__device": null,
"actor": 4180757,
"should_be_running__release": null,
"device_name": "dashing-spruce",
"device_type": "raspberrypi4-64",
"uuid": "fda508c8583011b8466c26abdd5159f3",
"is_on__commit": "18756d3386c25a044db66b89e0409804",
"note": null,
"local_id": null,
"status": "Idle",
"is_online": false,
"last_connectivity_event": "2019-11-23T00:26:35.074Z",
"is_connected_to_vpn": false,
"last_vpn_event": "2019-11-23T00:26:35.074Z",
"ip_address": "192.168.0.112",
"vpn_address": null,
"public_address": "89.186.29.129",
"os_version": "balenaOS 2.44.0+rev3",
"os_variant": "dev",
"supervisor_version": "10.3.7",
"should_be_managed_by__supervisor_release": null,
"is_managed_by__service_instance": {
"__deferred": {
"uri": "/resin/service_instance(124111)"
},
"__id": 124111
},
"provisioning_progress": null,
"provisioning_state": "",
"download_progress": null,
"is_web_accessible": false,
"longitude": "22.5853",
"latitude": "51.2712",
"location": "Lublin, Lublin, Poland",
"custom_longitude": "",
"custom_latitude": "",
"logs_channel": null,
"is_locked_until__date": null,
"is_accessible_by_support_until__date": null,
"created_at": "2019-11-18T12:27:37.423Z",
"is_active": true,
"api_heartbeat_state": "offline",
"__metadata": {
"uri": "/resin/device(@id)?@id=1747415"
}
}
]
}

View File

@ -0,0 +1,77 @@
import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
const HELP_RESPONSE = `
Usage: devices
Use this command to list all devices that belong to you.
You can filter the devices by application by using the \`--application\` option.
Examples:
\t$ balena devices
\t$ balena devices --application MyApp
\t$ balena devices --app MyApp
\t$ balena devices -a MyApp
Options:
--application, -a, --app <application> application name
`;
describe('balena devices', function() {
let api: BalenaAPIMock;
beforeEach(() => {
api = new BalenaAPIMock();
});
afterEach(() => {
// Check all expected api calls have been made and clean up.
api.done();
});
it('should print help text with the -h flag', async () => {
api.expectWhoAmI();
api.expectMixpanel();
const { out, err } = await runCommand('devices -h');
expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE]));
expect(err).to.eql([]);
});
it('should list devices from own and collaborator apps', async () => {
api.expectWhoAmI();
api.expectMixpanel();
api.scope
.get(
'/v5/device?$orderby=device_name%20asc&$expand=belongs_to__application($select=app_name)',
)
.replyWithFile(200, __dirname + '/devices.api-response.json', {
'Content-Type': 'application/json',
});
const { out, err } = await runCommand('devices');
const lines = cleanOutput(out);
expect(lines[0].replace(/ +/g, ' ')).to.equal(
'ID UUID DEVICE NAME DEVICE TYPE APPLICATION NAME STATUS ' +
'IS ONLINE SUPERVISOR VERSION OS VERSION DASHBOARD URL',
);
expect(lines).to.have.lengthOf.at.least(2);
expect(lines.some(l => l.includes('test app'))).to.be.true;
// Devices with missing applications will have application name set to `N/a`.
// e.g. When user has a device associated with app that user is no longer a collaborator of.
expect(lines.some(l => l.includes('N/a'))).to.be.true;
expect(err).to.eql([]);
});
});

View File

@ -39,9 +39,10 @@ describe('balena devices supported', function() {
api.expectWhoAmI();
api.expectMixpanel();
// TODO: Using the alias api.expect here causes route /config/vars to be called unexpectedly - why?
api.scope
.get('/device-types/v1')
.replyWithFile(200, __dirname + '/supported.api-response.json', {
.replyWithFile(200, __dirname + '/device-types.api-response.json', {
'Content-Type': 'application/json',
});

View File

@ -24,7 +24,9 @@ import * as balenaCLI from '../build/app';
import { configureBluebird, setMaxListeners } from '../build/app-common';
configureBluebird();
setMaxListeners(25); // it appears that 'nock' adds a bunch of listeners - bug?
setMaxListeners(35); // it appears that 'nock' adds a bunch of listeners - bug?
// SL: Looks like it's not nock causing this, as have seen the problem triggered from help.spec,
// which is not using nock. Perhaps mocha/chai? (unlikely), or something in the CLI?
export const runCommand = async (cmd: string) => {
const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];