api-binder: Convert to a singleton

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
Rich Bayliss 2020-07-21 10:45:37 +01:00
parent 13241bf6e7
commit d50f7791e1
No known key found for this signature in database
GPG Key ID: E53C4B4D18499E1A
13 changed files with 996 additions and 726 deletions

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,7 @@ import * as updateLock from './lib/update-lock';
import * as validation from './lib/validation';
import * as network from './network';
import APIBinder from './api-binder';
import * as APIBinder from './api-binder';
import { ApplicationManager } from './application-manager';
import * as deviceConfig from './device-config';
import { ConfigStep } from './device-config';
@ -178,7 +178,7 @@ function createDeviceStateRouter(deviceState: DeviceState) {
}
interface DeviceStateConstructOpts {
apiBinder: APIBinder;
apiBinder: typeof APIBinder;
}
interface DeviceStateEvents {

213
src/lib/api-helper.ts Normal file
View File

@ -0,0 +1,213 @@
import { PinejsClientRequest } from 'pinejs-client-request';
import * as Bluebird from 'bluebird';
import * as config from '../config';
import * as eventTracker from '../event-tracker';
import * as request from './request';
import * as deviceRegister from './register-device';
import {
DeviceNotFoundError,
ExchangeKeyError,
FailedToProvisionDeviceError,
InternalInconsistencyError,
isHttpConflictError,
} from './errors';
import log from './supervisor-console';
export type KeyExchangeOpts = config.ConfigType<'provisioningOptions'>;
export interface Device {
id: number;
[key: string]: unknown;
}
export const fetchDevice = async (
balenaApi: PinejsClientRequest,
uuid: string,
apiKey: string,
timeout: number,
) => {
if (balenaApi == null) {
throw new InternalInconsistencyError(
'fetchDevice called without an initialized API client',
);
}
const reqOpts = {
resource: 'device',
options: {
$filter: {
uuid,
},
},
passthrough: {
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
};
try {
const [device] = (await Bluebird.resolve(balenaApi.get(reqOpts)).timeout(
timeout,
)) as Device[];
if (device == null) {
throw new DeviceNotFoundError();
}
return device;
} catch (e) {
throw new DeviceNotFoundError();
}
};
export const exchangeKeyAndGetDeviceOrRegenerate = async (
balenaApi: PinejsClientRequest,
opts: KeyExchangeOpts,
): Promise<Device> => {
try {
const device = await exchangeKeyAndGetDevice(balenaApi, opts);
log.debug('Key exchange succeeded');
return device;
} catch (e) {
if (e instanceof ExchangeKeyError) {
log.error('Exchanging key failed, re-registering...');
await config.regenerateRegistrationFields();
}
throw e;
}
};
export const exchangeKeyAndGetDevice = async (
balenaApi: PinejsClientRequest,
opts: Partial<KeyExchangeOpts>,
): Promise<Device> => {
const uuid = opts.uuid;
const apiTimeout = opts.apiTimeout;
if (!(uuid && apiTimeout)) {
throw new InternalInconsistencyError(
'UUID and apiTimeout should be defined in exchangeKeyAndGetDevice',
);
}
// If we have an existing device key we first check if it's
// valid, because if it is then we can just use that
if (opts.deviceApiKey != null) {
try {
return await fetchDevice(balenaApi, uuid, opts.deviceApiKey, apiTimeout);
} catch (e) {
if (e instanceof DeviceNotFoundError) {
// do nothing...
} else {
throw e;
}
}
}
// If it's not valid or doesn't exist then we try to use the
// user/provisioning api key for the exchange
if (!opts.provisioningApiKey) {
throw new InternalInconsistencyError(
'Required a provisioning key in exchangeKeyAndGetDevice',
);
}
let device: Device;
try {
device = await fetchDevice(
balenaApi,
uuid,
opts.provisioningApiKey,
apiTimeout,
);
} catch (err) {
throw new ExchangeKeyError(`Couldn't fetch device with provisioning key`);
}
// We found the device so we can try to register a working device key for it
const [res] = await (await request.getRequestInstance())
.postAsync(`${opts.apiEndpoint}/api-key/device/${device.id}/device-key`, {
json: true,
body: {
apiKey: opts.deviceApiKey,
},
headers: {
Authorization: `Bearer ${opts.provisioningApiKey}`,
},
})
.timeout(apiTimeout);
if (res.statusCode !== 200) {
throw new ExchangeKeyError(
`Couldn't register device key with provisioning key`,
);
}
return device;
};
export const provision = async (
balenaApi: PinejsClientRequest,
opts: KeyExchangeOpts,
) => {
await config.initialized;
await eventTracker.initialized;
let device: Device | null = null;
if (
opts.registered_at == null ||
opts.deviceId == null ||
opts.provisioningApiKey != null
) {
if (opts.registered_at != null && opts.deviceId == null) {
log.debug(
'Device is registered but no device id available, attempting key exchange',
);
device = await exchangeKeyAndGetDeviceOrRegenerate(balenaApi, opts);
} else if (opts.registered_at == null) {
log.info('New device detected. Provisioning...');
try {
device = await deviceRegister.register(opts).timeout(opts.apiTimeout);
} catch (err) {
if (
err instanceof deviceRegister.ApiError &&
isHttpConflictError(err.response)
) {
log.debug('UUID already registered, trying a key exchange');
device = await exchangeKeyAndGetDeviceOrRegenerate(balenaApi, opts);
} else {
throw err;
}
}
opts.registered_at = Date.now();
} else if (opts.provisioningApiKey != null) {
log.debug(
'Device is registered but we still have an apiKey, attempting key exchange',
);
device = await exchangeKeyAndGetDevice(balenaApi, opts);
}
if (!device) {
throw new FailedToProvisionDeviceError();
}
const { id } = device;
balenaApi.passthrough.headers.Authorization = `Bearer ${opts.deviceApiKey}`;
const configToUpdate = {
registered_at: opts.registered_at,
deviceId: id,
apiKey: null,
};
await config.set(configToUpdate);
eventTracker.track('Device bootstrap success');
}
return device;
};

View File

@ -14,6 +14,8 @@ interface CodedSysError extends Error {
code?: string;
}
export class DeviceNotFoundError extends TypedError {}
export function NotFoundError(err: StatusCodeError): boolean {
return checkInt(err.statusCode) === 404;
}
@ -50,6 +52,12 @@ export function isHttpConflictError(err: StatusCodeError | Response): boolean {
return checkInt(err.statusCode) === 409;
}
export class FailedToProvisionDeviceError extends TypedError {
public constructor() {
super('Failed to provision device');
}
}
export class ExchangeKeyError extends TypedError {}
export class InternalInconsistencyError extends TypedError {}

View File

@ -20,6 +20,7 @@ import * as db from './db';
import * as config from './config';
import * as dockerUtils from './lib/docker-utils';
import * as logger from './logger';
import * as apiHelper from './lib/api-helper';
const mkdirpAsync = Promise.promisify(mkdirp);
@ -406,8 +407,8 @@ export class Proxyvisor {
}
// If the device is not in the DB it means it was provisioned externally
// so we need to fetch it.
return this.apiBinder
.fetchDevice(uuid, currentApiKey, apiTimeout)
return apiHelper
.fetchDevice(this.apiBinder.balenaApi, uuid, currentApiKey, apiTimeout)
.then((dev) => {
const deviceForDB = {
uuid,

View File

@ -1,4 +1,4 @@
import APIBinder from './api-binder';
import * as apiBinder from './api-binder';
import * as db from './db';
import * as config from './config';
import DeviceState from './device-state';
@ -32,25 +32,24 @@ const startupConfigFields: config.ConfigKey[] = [
export class Supervisor {
private deviceState: DeviceState;
private apiBinder: APIBinder;
private api: SupervisorAPI;
public constructor() {
this.apiBinder = new APIBinder();
this.deviceState = new DeviceState({
apiBinder: this.apiBinder,
apiBinder,
});
// workaround the circular dependency
this.apiBinder.setDeviceState(this.deviceState);
apiBinder.setDeviceState(this.deviceState);
// FIXME: rearchitect proxyvisor to avoid this circular dependency
// by storing current state and having the APIBinder query and report it / provision devices
this.deviceState.applications.proxyvisor.bindToAPI(this.apiBinder);
this.deviceState.applications.proxyvisor.bindToAPI(apiBinder);
this.api = new SupervisorAPI({
routers: [this.apiBinder.router, this.deviceState.router],
routers: [apiBinder.router, this.deviceState.router],
healthchecks: [
this.apiBinder.healthcheck.bind(this.apiBinder),
apiBinder.healthcheck,
this.deviceState.healthcheck.bind(this.deviceState),
],
});
@ -78,14 +77,14 @@ export class Supervisor {
await firewall.initialised;
log.debug('Starting api binder');
await this.apiBinder.initClient();
await apiBinder.initialized;
logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start');
if (conf.legacyAppsPresent && this.apiBinder.balenaApi != null) {
if (conf.legacyAppsPresent && apiBinder.balenaApi != null) {
log.info('Legacy app detected, running migration');
await normaliseLegacyDatabase(
this.deviceState.applications,
this.apiBinder.balenaApi,
apiBinder.balenaApi,
);
}
@ -95,7 +94,7 @@ export class Supervisor {
this.api.listen(conf.listenPort, conf.apiTimeout);
this.deviceState.on('shutdown', () => this.api.stop());
await this.apiBinder.start();
await apiBinder.start();
}
}

View File

@ -7,6 +7,7 @@ process.env.DATABASE_PATH_3 = './test/data/database3.sqlite';
process.env.LED_FILE = './test/data/led_file';
import './lib/mocked-iptables';
import './lib/mocked-event-tracker';
import * as dbus from 'dbus';
import { DBusError, DBusInterface } from 'dbus';

View File

@ -3,11 +3,9 @@ import { fs } from 'mz';
import { Server } from 'net';
import { SinonSpy, SinonStub, spy, stub } from 'sinon';
import ApiBinder from '../src/api-binder';
import prepare = require('./lib/prepare');
import * as config from '../src/config';
import DeviceState from '../src/device-state';
import * as eventTracker from '../src/event-tracker';
import Log from '../src/lib/supervisor-console';
import chai = require('./lib/chai-config');
import balenaAPI = require('./lib/mocked-balena-api');
@ -16,15 +14,25 @@ import ConfigJsonConfigBackend from '../src/config/configJson';
import * as TargetState from '../src/device-state/target-state';
import { DeviceStatus } from '../src/types/state';
import * as CurrentState from '../src/device-state/current-state';
import * as ApiHelper from '../src/lib/api-helper';
import { TypedError } from 'typed-error';
import { DeviceNotFoundError } from '../src/lib/errors';
import { eventTrackSpy } from './lib/mocked-event-tracker';
const { expect } = chai;
let ApiBinder: typeof import('../src/api-binder');
class ExpectedError extends TypedError {}
const defaultConfigBackend = config.configJsonBackend;
const initModels = async (obj: Dictionary<any>, filename: string) => {
await prepare();
// @ts-expect-error setting read-only property
config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename);
config.generateRequiredFields();
await config.generateRequiredFields();
// @ts-expect-error using private properties
config.configJsonBackend.cache = await config.configJsonBackend.read();
await config.generateRequiredFields();
@ -35,15 +43,15 @@ const initModels = async (obj: Dictionary<any>, filename: string) => {
},
} as any;
obj.apiBinder = new ApiBinder();
ApiBinder = await import('../src/api-binder');
await ApiBinder.initialized;
obj.apiBinder = ApiBinder;
obj.deviceState = new DeviceState({
apiBinder: obj.apiBinder,
});
obj.apiBinder.setDeviceState(obj.deviceState);
await obj.apiBinder.initClient(); // Initializes the clients but doesn't trigger provisioning
};
const mockProvisioningOpts = {
@ -55,17 +63,12 @@ const mockProvisioningOpts = {
};
describe('ApiBinder', () => {
const defaultConfigBackend = config.configJsonBackend;
let server: Server;
beforeEach(() => {
stub(eventTracker, 'track');
});
afterEach(() => {
// @ts-expect-error Restoring a non-stub type function
eventTracker.track.restore();
});
before(async () => {
delete require.cache[require.resolve('../src/api-binder')];
before(() => {
spy(balenaAPI.balenaBackend!, 'registerHandler');
server = balenaAPI.listen(3000);
});
@ -83,31 +86,39 @@ describe('ApiBinder', () => {
// We do not support older OS versions anymore, so we only test this case
describe('on an OS with deviceApiKey support', () => {
const components: Dictionary<any> = {};
before(() => {
return initModels(components, '/config-apibinder.json');
before(async () => {
await initModels(components, '/config-apibinder.json');
});
afterEach(() => {
eventTrackSpy.resetHistory();
});
after(async () => {
eventTrackSpy.restore();
// @ts-expect-error setting read-only property
config.configJsonBackend = defaultConfigBackend;
await config.generateRequiredFields();
});
it('provisions a device', () => {
const promise = components.apiBinder.provisionDevice();
it('provisions a device', async () => {
const opts = await config.get('provisioningOptions');
await ApiHelper.provision(components.apiBinder.balenaApi, opts);
return expect(promise).to.be.fulfilled.then(() => {
expect(balenaAPI.balenaBackend!.registerHandler).to.be.calledOnce;
expect(balenaAPI.balenaBackend!.registerHandler).to.be.calledOnce;
expect(eventTrackSpy).to.be.called;
expect(eventTrackSpy).to.be.calledWith('Device bootstrap success');
// @ts-expect-error function does not exist on type
balenaAPI.balenaBackend!.registerHandler.resetHistory();
expect(eventTracker.track).to.be.calledWith('Device bootstrap success');
});
// @ts-expect-error function does not exist on type
balenaAPI.balenaBackend!.registerHandler.resetHistory();
});
it('exchanges keys if resource conflict when provisioning', async () => {
// Get current config to extend
const currentConfig = await config.get('provisioningOptions');
// Stub config values so we have correct conditions
const configStub = stub(config, 'get').resolves({
...currentConfig,
@ -115,30 +126,32 @@ describe('ApiBinder', () => {
provisioningApiKey: '123', // Previous test case deleted the provisioningApiKey so add one
uuid: 'not-unique', // This UUID is used in mocked-balena-api as an existing registered UUID
});
// If api-binder reaches this function then tests pass
// We throw an error so we don't have to keep stubbing
const functionToReach = stub(
components.apiBinder,
ApiHelper,
'exchangeKeyAndGetDeviceOrRegenerate',
).rejects('expected-rejection'); // We throw an error so we don't have to keep stubbing
).throws(new ExpectedError());
spy(Log, 'debug');
try {
await components.apiBinder.provision();
const opts = await config.get('provisioningOptions');
await ApiHelper.provision(components.apiBinder.balenaApi, opts);
} catch (e) {
// Check that the error thrown is from this test
if (e.name !== 'expected-rejection') {
throw e;
}
expect(e).to.be.instanceOf(ExpectedError);
}
expect(functionToReach).to.be.calledOnce;
expect(functionToReach.called).to.be.true;
expect((Log.debug as SinonSpy).lastCall.lastArg).to.equal(
'UUID already registered, trying a key exchange',
);
// Restore stubs
functionToReach.restore();
configStub.restore();
functionToReach.restore();
(Log.debug as SinonStub).restore();
});
@ -184,7 +197,8 @@ describe('ApiBinder', () => {
api_key: 'verysecure',
};
const device = await components.apiBinder.fetchDevice(
const device = await ApiHelper.fetchDevice(
components.apiBinder.balenaApi,
'abcd',
'someApiKey',
30000,
@ -207,27 +221,31 @@ describe('ApiBinder', () => {
it('returns the device if it can fetch it with the deviceApiKey', async () => {
spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
const fetchDeviceStub = stub(components.apiBinder, 'fetchDevice');
const fetchDeviceStub = stub(ApiHelper, 'fetchDevice');
fetchDeviceStub.onCall(0).resolves({ id: 1 });
const device = await components.apiBinder.exchangeKeyAndGetDevice(
const device = await ApiHelper.exchangeKeyAndGetDevice(
components.apiBinder.balenaApi,
mockProvisioningOpts,
);
expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called;
expect(device).to.deep.equal({ id: 1 });
expect(components.apiBinder.fetchDevice).to.be.calledOnce;
expect(fetchDeviceStub).to.be.calledOnce;
components.apiBinder.fetchDevice.restore();
// @ts-expect-error function does not exist on type
balenaAPI.balenaBackend.deviceKeyHandler.restore();
fetchDeviceStub.restore();
});
it('throws if it cannot get the device with any of the keys', () => {
spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
stub(components.apiBinder, 'fetchDevice').returns(Promise.resolve(null));
const fetchDeviceStub = stub(ApiHelper, 'fetchDevice').throws(
new DeviceNotFoundError(),
);
const promise = components.apiBinder.exchangeKeyAndGetDevice(
const promise = ApiHelper.exchangeKeyAndGetDevice(
components.apiBinder.balenaApi,
mockProvisioningOpts,
);
promise.catch(() => {
@ -236,8 +254,8 @@ describe('ApiBinder', () => {
return expect(promise).to.be.rejected.then(() => {
expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called;
expect(components.apiBinder.fetchDevice).to.be.calledTwice;
components.apiBinder.fetchDevice.restore();
expect(fetchDeviceStub).to.be.calledTwice;
fetchDeviceStub.restore();
// @ts-expect-error function does not exist on type
balenaAPI.balenaBackend.deviceKeyHandler.restore();
});
@ -245,17 +263,18 @@ describe('ApiBinder', () => {
it('exchanges the key and returns the device if the provisioning key is valid', async () => {
spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
const fetchDeviceStub = stub(components.apiBinder, 'fetchDevice');
fetchDeviceStub.onCall(0).returns(Promise.resolve(null));
const fetchDeviceStub = stub(ApiHelper, 'fetchDevice');
fetchDeviceStub.onCall(0).throws(new DeviceNotFoundError());
fetchDeviceStub.onCall(1).returns(Promise.resolve({ id: 1 }));
const device = await components.apiBinder.exchangeKeyAndGetDevice(
const device = await ApiHelper.exchangeKeyAndGetDevice(
components.apiBinder.balenaApi,
mockProvisioningOpts as any,
);
expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.be.calledOnce;
expect(device).to.deep.equal({ id: 1 });
expect(components.apiBinder.fetchDevice).to.be.calledTwice;
components.apiBinder.fetchDevice.restore();
expect(fetchDeviceStub).to.be.calledTwice;
fetchDeviceStub.restore();
// @ts-expect-error function does not exist on type
balenaAPI.balenaBackend.deviceKeyHandler.restore();
});
@ -436,17 +455,22 @@ describe('ApiBinder', () => {
appUpdatePollInterval: 1000,
connectivityCheckEnabled: true,
});
// Set lastFetch to now so it is within appUpdatePollInterval
(TargetState as any).lastFetch = process.hrtime();
// Copy previous values to restore later
const previousStateReportErrors = components.apiBinder.stateReportErrors;
const previousDeviceStateConnected =
components.apiBinder.deviceState.connected;
// Set additional conditions not in configStub to cause a fail
// @ts-expect-error
CurrentState.stateReportErrors = 4;
components.apiBinder.deviceState.connected = true;
expect(await components.apiBinder.healthcheck()).to.equal(false);
expect(Log.info).to.be.calledOnce;
expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
stripIndent`
@ -455,6 +479,7 @@ describe('ApiBinder', () => {
- device state is disconnected ? false
- stateReportErrors less then 3 ? false`,
);
// Restore previous values
// @ts-expect-error
CurrentState.stateReportErrors = previousStateReportErrors;

View File

@ -1,6 +1,6 @@
import { SinonStub, stub } from 'sinon';
import APIBinder from '../src/api-binder';
import * as APIBinder from '../src/api-binder';
import { ApplicationManager } from '../src/application-manager';
import DeviceState from '../src/device-state';
import * as constants from '../src/lib/constants';
@ -10,7 +10,6 @@ import { expect } from './lib/chai-config';
import _ = require('lodash');
describe('Startup', () => {
let initClientStub: SinonStub;
let reportCurrentStateStub: SinonStub;
let startStub: SinonStub;
let vpnStatusPathStub: SinonStub;
@ -19,16 +18,11 @@ describe('Startup', () => {
let dockerStub: SinonStub;
before(() => {
initClientStub = stub(APIBinder.prototype as any, 'initClient').returns(
Promise.resolve(),
);
reportCurrentStateStub = stub(
DeviceState.prototype as any,
'reportCurrentState',
).resolves();
startStub = stub(APIBinder.prototype as any, 'start').returns(
Promise.resolve(),
);
startStub = stub(APIBinder as any, 'start').returns(Promise.resolve());
appManagerStub = stub(ApplicationManager.prototype, 'init').returns(
Promise.resolve(),
);
@ -40,7 +34,6 @@ describe('Startup', () => {
});
after(() => {
initClientStub.restore();
startStub.restore();
appManagerStub.restore();
vpnStatusPathStub.restore();

View File

@ -2,7 +2,7 @@ import { expect } from 'chai';
import { spy, stub, SinonStub } from 'sinon';
import * as supertest from 'supertest';
import APIBinder from '../src/api-binder';
import * as apiBinder from '../src/api-binder';
import DeviceState from '../src/device-state';
import Log from '../src/lib/supervisor-console';
import * as images from '../src/compose/images';
@ -24,9 +24,11 @@ describe('SupervisorAPI', () => {
const originalGetStatus = images.getStatus;
before(async () => {
await apiBinder.initialized;
// Stub health checks so we can modify them whenever needed
healthCheckStubs = [
stub(APIBinder.prototype, 'healthcheck'),
stub(apiBinder, 'healthcheck'),
stub(DeviceState.prototype, 'healthcheck'),
];
// The mockedAPI contains stubs that might create unexpected results
@ -37,7 +39,7 @@ describe('SupervisorAPI', () => {
images.getStatus = () => Promise.resolve([]);
// Start test API
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
});
after(async () => {

View File

@ -52,6 +52,12 @@ api.get(/\/v6\/device\(uuid=%27([0-9a-f]+)%27\)/, (req, res) =>
api.balenaBackend!.getDeviceHandler(req, res, _.noop),
);
api.get(/\/v6\/device/, (req, res) => {
const [, uuid] = /uuid eq '([0-9a-f]+)'/i.exec(req.query['$filter']) ?? [];
req.params[0] = uuid;
return api.balenaBackend!.getDeviceHandler(req, res, _.noop);
});
api.post('/api-key/device/:deviceId/device-key', (req, res) =>
api.balenaBackend!.deviceKeyHandler(req, res, _.noop),
);

View File

@ -10,7 +10,7 @@ import * as config from '../../src/config';
import * as db from '../../src/db';
import { createV1Api } from '../../src/device-api/v1';
import { createV2Api } from '../../src/device-api/v2';
import APIBinder from '../../src/api-binder';
import * as APIBinder from '../../src/api-binder';
import DeviceState from '../../src/device-state';
import SupervisorAPI from '../../src/supervisor-api';
@ -109,7 +109,7 @@ async function createAPIOpts(): Promise<SupervisorAPIOpts> {
const deviceState = new DeviceState({
apiBinder: null as any,
});
const apiBinder = new APIBinder();
const apiBinder = APIBinder;
return {
deviceState,
apiBinder,
@ -170,7 +170,7 @@ function restoreStubs() {
interface SupervisorAPIOpts {
deviceState: DeviceState;
apiBinder: APIBinder;
apiBinder: typeof APIBinder;
}
export = { create, cleanUp, STUBBED_VALUES };

View File

@ -0,0 +1,4 @@
import * as eventTracker from '../../src/event-tracker';
import { spy } from 'sinon';
export const eventTrackSpy = spy(eventTracker, 'track');