Convert target state in local endpoints

Convert target state from to v3 in `/v2/local/target-state`. Add tests
for target state conversion
This commit is contained in:
Felipe Lalanne 2021-08-23 20:42:05 +00:00
parent 1edd060143
commit 063bd400a4
5 changed files with 346 additions and 10 deletions

41
package-lock.json generated
View File

@ -6627,6 +6627,12 @@
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
},
"lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=",
"dev": true
},
"log-symbols": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz",
@ -7635,6 +7641,35 @@
}
}
},
"nock": {
"version": "13.1.2",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.1.2.tgz",
"integrity": "sha512-BDjokoeGZnBghmvwCcDJ1yM5TDRMRAJfGi1xIzX5rKTlifbyx1oRpAVl3aNhEA3kGbUSEPD7gBLmwVdnQibrIA==",
"dev": true,
"requires": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
"lodash.set": "^4.3.2",
"propagate": "^2.0.0"
},
"dependencies": {
"debug": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
"dev": true,
"requires": {
"ms": "2.1.2"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
}
}
},
"node-libs-browser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
@ -8415,6 +8450,12 @@
"integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=",
"dev": true
},
"propagate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
"integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
"dev": true
},
"proxy-addr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",

View File

@ -108,6 +108,7 @@
"mock-fs": "^4.14.0",
"morgan": "^1.10.0",
"network-checker": "^0.1.1",
"nock": "^13.1.2",
"nodemon": "^2.0.4",
"pinejs-client-request": "^7.2.1",
"pretty-ms": "^7.0.1",

View File

@ -32,6 +32,7 @@ import { checkInt, checkTruthy } from '../lib/validation';
import { isVPNActive } from '../network';
import { doPurge, doRestart, safeStateClone } from './common';
import { AuthorizedRequest } from '../lib/api-keys';
import { fromV2TargetState } from '../lib/legacy';
export function createV2Api(router: Router) {
const handleServiceAction = (
@ -346,7 +347,10 @@ export function createV2Api(router: Router) {
// Now attempt to set the state
const force = req.body.force;
const targetState = req.body;
// Migrate target state from v2 to v3 to maintain API compatibility
const targetState = await fromV2TargetState(req.body, true);
try {
await deviceState.setTarget(targetState, true);
await deviceState.triggerApplyTarget({ force });

View File

@ -9,6 +9,7 @@ import * as serviceManager from '../compose/service-manager';
import * as deviceState from '../device-state';
import * as applicationManager from '../compose/application-manager';
import {
StatusError,
DatabaseParseError,
NotFoundError,
InternalInconsistencyError,
@ -23,7 +24,7 @@ import type {
DatabaseService,
} from '../device-state/target-state-cache';
import { TargetApp, TargetApps } from '../types';
import { TargetApp, TargetApps, TargetState } from '../types';
const defaultLegacyVolume = () => 'resin-data';
@ -226,14 +227,40 @@ export async function normaliseLegacyDatabase() {
export type TargetAppsV2 = {
[id: string]: {
name: string;
commit: string;
releaseId: number;
commit?: string;
releaseId?: number;
services: { [id: string]: any };
volumes: { [name: string]: any };
networks: { [name: string]: any };
};
};
type TargetStateV2 = {
local: {
name: string;
config: { [name: string]: string };
apps: TargetAppsV2;
};
};
export async function fromV2TargetState(
target: TargetStateV2,
local = false,
): Promise<TargetState> {
const { uuid, name } = await config.getMany(['uuid', 'name']);
if (!uuid) {
throw new InternalInconsistencyError('No UUID for device');
}
return {
[uuid]: {
name: target?.local?.name ?? name,
config: target?.local?.config ?? {},
apps: await fromV2TargetApps(target?.local?.apps ?? {}, local),
},
};
}
/**
* Convert v2 to v3 target apps. If local is false
* it will query the API to get the app uuid
@ -262,9 +289,7 @@ export async function fromV2TargetApps(
});
if (!appDetails || !appDetails.uuid) {
throw new InternalInconsistencyError(
`No app with id ${appId} found on the API.`,
);
throw new StatusError(404, `No app with id ${appId} found on the API.`);
}
return appDetails.uuid;
@ -285,7 +310,7 @@ export async function fromV2TargetApps(
? {
[app.commit]: {
id: app.releaseId,
services: Object.keys(app.services)
services: Object.keys(app.services ?? {})
.map((serviceId) => {
const {
imageId,
@ -320,8 +345,8 @@ export async function fromV2TargetApps(
}),
{},
),
volumes: app.volumes,
networks: app.networks,
volumes: app.volumes ?? {},
networks: app.networks ?? {},
},
}
: {};

265
test/src/lib/legacy.spec.ts Normal file
View File

@ -0,0 +1,265 @@
import { expect } from 'chai';
import { isRight } from 'fp-ts/lib/Either';
import * as sinon from 'sinon';
import * as nock from 'nock';
import { TargetState } from '../../../src/types';
import * as config from '../../../src/config';
import * as legacy from '../../../src/lib/legacy';
import log from '../../../src/lib/supervisor-console';
describe('lib/legacy', () => {
before(async () => {
// disable log output during testing
sinon.stub(log, 'debug');
sinon.stub(log, 'warn');
sinon.stub(log, 'info');
sinon.stub(log, 'event');
sinon.stub(log, 'success');
await config.initialized;
// Set the device uuid and name
await config.set({ uuid: 'local' });
await config.set({ name: 'my-device' });
});
after(() => {
sinon.restore();
});
describe('Converting target state v2 to v3', () => {
it('accepts a local target state with empty configuration', async () => {
const target = await legacy.fromV2TargetState({} as any, true);
const decoded = TargetState.decode(target);
if (!isRight(decoded)) {
console.log(decoded.left);
// We do it this way let the type guard be triggered
expect.fail('Resulting target state is a valid v3 target state');
}
const decodedTarget = decoded.right;
expect(decodedTarget)
.to.have.property('local')
.that.has.property('name')
.that.equals('my-device');
expect(decodedTarget)
.to.have.property('local')
.that.has.property('apps')
.that.deep.equals({});
expect(decodedTarget)
.to.have.property('local')
.that.has.property('config')
.that.deep.equals({});
});
it('accepts a local target state for an app without releases', async () => {
const target = await legacy.fromV2TargetState(
{
local: {
name: 'my-new-name',
config: {
BALENA_SUPERVISOR_PORT: '11111',
},
apps: {
'1': {
name: 'hello-world',
},
},
},
} as any,
true,
);
const decoded = TargetState.decode(target);
if (!isRight(decoded)) {
console.log(decoded.left);
// We do it this way let the type guard be triggered
expect.fail('Resulting target state is a valid v3 target state');
}
const decodedTarget = decoded.right;
expect(decodedTarget)
.to.have.property('local')
.that.has.property('config')
.that.has.property('BALENA_SUPERVISOR_PORT')
.that.equals('11111');
expect(decodedTarget)
.to.have.property('local')
.that.has.property('name')
.that.equals('my-new-name');
expect(decodedTarget).to.have.property('local').that.has.property('apps');
const apps = decodedTarget.local.apps;
expect(apps)
.to.have.property('1')
.that.has.property('name')
.that.equals('hello-world');
expect(apps)
.to.have.property('1')
.that.has.property('releases')
.that.deep.equals({});
});
it('accepts a local target state with valid config and apps', async () => {
const target = await legacy.fromV2TargetState(
{
local: {
name: 'my-new-name',
config: {
BALENA_SUPERVISOR_PORT: '11111',
},
apps: {
'1': {
releaseId: 1,
commit: 'localrelease',
name: 'hello-world',
services: {
'1': {
imageId: 1,
serviceName: 'hello',
image: 'ubuntu:latest',
running: true,
environment: {},
labels: { 'io.balena.features.api': 'true' },
privileged: true,
ports: ['3001:3001'],
},
},
networks: {
my_net: {
labels: {
'io.balena.some.label': 'foo',
},
},
},
volumes: {
my_volume: {
labels: {
'io.balena.some.label': 'foo',
},
},
},
},
},
},
},
true,
);
const decoded = TargetState.decode(target);
if (!isRight(decoded)) {
console.log(decoded.left);
// We do it this way let the type guard be triggered
expect.fail('Resulting target state is a valid v3 target state');
}
const decodedTarget = decoded.right;
expect(decodedTarget)
.to.have.property('local')
.that.has.property('config')
.that.has.property('BALENA_SUPERVISOR_PORT')
.that.equals('11111');
expect(decodedTarget)
.to.have.property('local')
.that.has.property('name')
.that.equals('my-new-name');
expect(decodedTarget).to.have.property('local').that.has.property('apps');
const apps = decodedTarget.local.apps;
expect(apps)
.to.have.property('1')
.that.has.property('releases')
.that.has.property('localrelease');
expect(apps)
.to.have.property('1')
.that.has.property('name')
.that.equals('hello-world');
const release = apps['1'].releases.localrelease;
expect(release).to.have.property('id').that.equals(1);
expect(release).to.have.property('services').that.has.property('hello');
const service = release.services.hello;
expect(service).to.have.property('image').that.equals('ubuntu:latest');
expect(service)
.to.have.property('composition')
.that.deep.equals({ privileged: true, ports: ['3001:3001'] });
expect(release)
.to.have.property('networks')
.that.has.property('my_net')
.that.has.property('labels')
.that.deep.equals({ 'io.balena.some.label': 'foo' });
expect(release)
.to.have.property('volumes')
.that.has.property('my_volume')
.that.has.property('labels')
.that.deep.equals({ 'io.balena.some.label': 'foo' });
});
it('accepts a cloud target state and requests app uuid from API', async () => {
const apiEndpoint = await config.get('apiEndpoint');
nock(apiEndpoint)
.get('/v6/application(1)?$select=uuid')
.reply(200, { d: [{ uuid: 'some-uuid' }] });
const target = await legacy.fromV2TargetState(
{
local: {
name: 'my-new-name',
config: {
BALENA_SUPERVISOR_PORT: '11111',
},
apps: {
'1': {
name: 'hello-world',
},
},
},
} as any,
false, // local = false
);
const decoded = TargetState.decode(target);
if (!isRight(decoded)) {
console.log(decoded.left);
// We do it this way let the type guard be triggered
expect.fail('Resulting target state is a valid v3 target state');
}
const decodedTarget = decoded.right;
expect(decodedTarget)
.to.have.property('local')
.that.has.property('config')
.that.has.property('BALENA_SUPERVISOR_PORT')
.that.equals('11111');
expect(decodedTarget)
.to.have.property('local')
.that.has.property('name')
.that.equals('my-new-name');
expect(decodedTarget).to.have.property('local').that.has.property('apps');
const apps = decodedTarget.local.apps;
expect(apps)
.to.have.property('some-uuid')
.that.has.property('name')
.that.equals('hello-world');
expect(apps)
.to.have.property('some-uuid')
.that.has.property('id')
.that.equals(1);
expect(apps)
.to.have.property('some-uuid')
.that.has.property('releases')
.that.deep.equals({});
});
});
});