diff --git a/package-lock.json b/package-lock.json index b1329002..8c9e92c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index daff4d27..672b701a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index 05cc85df..f6609683 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -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 }); diff --git a/src/lib/legacy.ts b/src/lib/legacy.ts index 1fc8d5e2..895426be 100644 --- a/src/lib/legacy.ts +++ b/src/lib/legacy.ts @@ -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 { + 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 ?? {}, }, } : {}; diff --git a/test/src/lib/legacy.spec.ts b/test/src/lib/legacy.spec.ts new file mode 100644 index 00000000..7b053bc8 --- /dev/null +++ b/test/src/lib/legacy.spec.ts @@ -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({}); + }); + }); +});