api: Implement scoped Supervisor API keys

Each service, when requesting access to the Supervisor API, will
now get an individual key which can be scoped to specific resources.
In this iteration the default scope will be to the application that
the service belongs to.

We also have a `global` scope which is used by the cloud API when in
managed mode.

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
This commit is contained in:
Rich Bayliss 2020-09-16 14:19:23 +00:00
parent 7d11e29f85
commit c08de8701e
22 changed files with 1059 additions and 430 deletions

82
package-lock.json generated
View File

@ -2910,12 +2910,13 @@
"dev": true "dev": true
}, },
"d": { "d": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "http://registry.npmjs.org/d/-/d-1.0.0.tgz", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
"integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
"dev": true, "dev": true,
"requires": { "requires": {
"es5-ext": "^0.10.9" "es5-ext": "^0.10.50",
"type": "^1.0.1"
} }
}, },
"dashdash": { "dashdash": {
@ -3613,14 +3614,22 @@
} }
}, },
"es5-ext": { "es5-ext": {
"version": "0.10.46", "version": "0.10.53",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.46.tgz", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
"integrity": "sha512-24XxRvJXNFwEMpJb3nOkiRJKRoupmjYmOPVlI65Qy2SrtxwOTB+g6ODjBKOtwEHbYrhWRty9xxOWLNdClT2djw==", "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"es6-iterator": "~2.0.3", "es6-iterator": "~2.0.3",
"es6-symbol": "~3.1.1", "es6-symbol": "~3.1.3",
"next-tick": "1" "next-tick": "~1.0.0"
},
"dependencies": {
"next-tick": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=",
"dev": true
}
} }
}, },
"es6-iterator": { "es6-iterator": {
@ -3650,24 +3659,24 @@
} }
}, },
"es6-symbol": { "es6-symbol": {
"version": "3.1.1", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
"integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
"dev": true, "dev": true,
"requires": { "requires": {
"d": "1", "d": "^1.0.1",
"es5-ext": "~0.10.14" "ext": "^1.1.2"
} }
}, },
"es6-weak-map": { "es6-weak-map": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz",
"integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==",
"dev": true, "dev": true,
"requires": { "requires": {
"d": "1", "d": "1",
"es5-ext": "^0.10.14", "es5-ext": "^0.10.46",
"es6-iterator": "^2.0.1", "es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.1" "es6-symbol": "^3.1.1"
} }
}, },
@ -3911,6 +3920,23 @@
} }
} }
}, },
"ext": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
"integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
"dev": true,
"requires": {
"type": "^2.0.0"
},
"dependencies": {
"type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type/-/type-2.1.0.tgz",
"integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==",
"dev": true
}
}
},
"extend": { "extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -5363,9 +5389,9 @@
} }
}, },
"is-promise": { "is-promise": {
"version": "2.1.0", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"dev": true "dev": true
}, },
"is-regexp": { "is-regexp": {
@ -6750,9 +6776,9 @@
} }
}, },
"next-tick": { "next-tick": {
"version": "1.0.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"dev": true "dev": true
}, },
"nice-try": { "nice-try": {
@ -9463,6 +9489,12 @@
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
}, },
"type": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==",
"dev": true
},
"type-detect": { "type-detect": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz",

View File

@ -775,8 +775,9 @@ export class App {
imageInfo, imageInfo,
serviceName: svc.serviceName, serviceName: svc.serviceName,
}; };
// FIXME: Typings for DeviceMetadata // FIXME: Typings for DeviceMetadata
return Service.fromComposeObject( return await Service.fromComposeObject(
svc, svc,
(thisSvcOpts as unknown) as DeviceMetadata, (thisSvcOpts as unknown) as DeviceMetadata,
); );

View File

@ -100,10 +100,10 @@ export class Service {
// The type here is actually ServiceComposeConfig, except that the // The type here is actually ServiceComposeConfig, except that the
// keys must be camelCase'd first // keys must be camelCase'd first
public static fromComposeObject( public static async fromComposeObject(
appConfig: ConfigMap, appConfig: ConfigMap,
options: DeviceMetadata, options: DeviceMetadata,
): Service { ): Promise<Service> {
const service = new Service(); const service = new Service();
appConfig = ComposeUtils.camelCaseConfig(appConfig); appConfig = ComposeUtils.camelCaseConfig(appConfig);
@ -443,7 +443,7 @@ export class Service {
} }
// Mutate service with extra features // Mutate service with extra features
ComposeUtils.addFeaturesFromLabels(service, options); await ComposeUtils.addFeaturesFromLabels(service, options);
return service; return service;
} }

View File

@ -18,6 +18,8 @@ import {
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
import * as apiKeys from '../lib/api-keys';
export function camelCaseConfig( export function camelCaseConfig(
literalConfig: ConfigMap, literalConfig: ConfigMap,
): ServiceComposeConfig { ): ServiceComposeConfig {
@ -316,10 +318,10 @@ export function dockerDeviceToStr(device: DockerDevice): string {
// TODO: Export these strings to a constant lib, to // TODO: Export these strings to a constant lib, to
// enable changing them easily // enable changing them easily
// Mutates service // Mutates service
export function addFeaturesFromLabels( export async function addFeaturesFromLabels(
service: Service, service: Service,
options: DeviceMetadata, options: DeviceMetadata,
): void { ): Promise<void> {
const setEnvVariables = function (key: string, val: string) { const setEnvVariables = function (key: string, val: string) {
service.config.environment[`RESIN_${key}`] = val; service.config.environment[`RESIN_${key}`] = val;
service.config.environment[`BALENA_${key}`] = val; service.config.environment[`BALENA_${key}`] = val;
@ -356,18 +358,24 @@ export function addFeaturesFromLabels(
setEnvVariables('API_KEY', options.deviceApiKey); setEnvVariables('API_KEY', options.deviceApiKey);
setEnvVariables('API_URL', options.apiEndpoint); setEnvVariables('API_URL', options.apiEndpoint);
}, },
'io.balena.features.supervisor-api': () => { 'io.balena.features.supervisor-api': async () => {
// create a app/service specific API secret
const apiSecret = await apiKeys.generateScopedKey(
service.appId,
service.serviceId,
);
const host = (() => {
if (service.config.networkMode === 'host') {
return '127.0.0.1';
} else {
service.config.networks[constants.supervisorNetworkInterface] = {};
return options.supervisorApiHost;
}
})();
setEnvVariables('SUPERVISOR_API_KEY', apiSecret);
setEnvVariables('SUPERVISOR_PORT', options.listenPort.toString()); setEnvVariables('SUPERVISOR_PORT', options.listenPort.toString());
setEnvVariables('SUPERVISOR_API_KEY', options.apiSecret);
let host: string;
if (service.config.networkMode === 'host') {
host = '127.0.0.1';
} else {
host = options.supervisorApiHost;
service.config.networks[constants.supervisorNetworkInterface] = {};
}
setEnvVariables('SUPERVISOR_HOST', host); setEnvVariables('SUPERVISOR_HOST', host);
setEnvVariables( setEnvVariables(
'SUPERVISOR_ADDRESS', 'SUPERVISOR_ADDRESS',
@ -388,11 +396,12 @@ export function addFeaturesFromLabels(
} as Dockerode.DeviceRequest), } as Dockerode.DeviceRequest),
}; };
_.each(features, (fn, label) => { for (const feature of Object.keys(features) as [keyof typeof features]) {
if (checkTruthy(service.config.labels[label])) { const fn = features[feature];
fn(); if (checkTruthy(service.config.labels[feature])) {
await fn();
} }
}); }
// This is a special case, and folding it into the // This is a special case, and folding it into the
// structure above would unnecessarily complicate things. // structure above would unnecessarily complicate things.

View File

@ -97,7 +97,6 @@ export const fnSchema = {
'uuid', 'uuid',
'listenPort', 'listenPort',
'name', 'name',
'apiSecret',
'apiEndpoint', 'apiEndpoint',
'deviceApiKey', 'deviceApiKey',
'version', 'version',

View File

@ -281,14 +281,13 @@ function validateConfigMap<T extends SchemaTypeKey>(
} }
export async function generateRequiredFields() { export async function generateRequiredFields() {
return getMany(['uuid', 'deviceApiKey', 'apiSecret', 'unmanaged']).then( return getMany(['uuid', 'deviceApiKey', 'unmanaged']).then(
({ uuid, deviceApiKey, apiSecret, unmanaged }) => { ({ uuid, deviceApiKey, unmanaged }) => {
// These fields need to be set regardless // These fields need to be set regardless
if (uuid == null || apiSecret == null) { if (uuid == null) {
uuid = uuid || newUniqueKey(); uuid = uuid || newUniqueKey();
apiSecret = apiSecret || newUniqueKey();
} }
return set({ uuid, apiSecret }).then(() => { return set({ uuid }).then(() => {
if (unmanaged) { if (unmanaged) {
return; return;
} }

View File

@ -88,10 +88,6 @@ export const schemaTypes = {
}, },
// Database types // Database types
apiSecret: {
type: t.string,
default: NullOrUndefined,
},
name: { name: {
type: t.string, type: t.string,
default: 'local', default: 'local',
@ -231,7 +227,6 @@ export const schemaTypes = {
uuid: t.union([t.string, NullOrUndefined]), uuid: t.union([t.string, NullOrUndefined]),
listenPort: PermissiveNumber, listenPort: PermissiveNumber,
name: t.string, name: t.string,
apiSecret: t.union([t.string, NullOrUndefined]),
deviceApiKey: t.string, deviceApiKey: t.string,
apiEndpoint: t.string, apiEndpoint: t.string,
version: t.string, version: t.string,

View File

@ -85,11 +85,6 @@ export const schema = {
removeIfNull: false, removeIfNull: false,
}, },
apiSecret: {
source: 'db',
mutable: true,
removeIfNull: false,
},
name: { name: {
source: 'db', source: 'db',
mutable: true, mutable: true,

View File

@ -1,4 +1,5 @@
import * as Promise from 'bluebird'; import * as Promise from 'bluebird';
import * as express from 'express';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as eventTracker from '../event-tracker'; import * as eventTracker from '../event-tracker';
@ -8,21 +9,37 @@ import { doRestart, doPurge } from './common';
import * as applicationManager from '../compose/application-manager'; import * as applicationManager from '../compose/application-manager';
import { generateStep } from '../compose/composition-steps'; import { generateStep } from '../compose/composition-steps';
import { AuthorizedRequest } from '../lib/api-keys';
export const createV1Api = function (router) { export function createV1Api(router: express.Router) {
router.post('/v1/restart', function (req, res, next) { router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
const appId = checkInt(req.body.appId); const appId = checkInt(req.body.appId);
const force = checkTruthy(req.body.force) ?? false; const force = checkTruthy(req.body.force) ?? false;
eventTracker.track('Restart container (v1)', { appId }); eventTracker.track('Restart container (v1)', { appId });
if (appId == null) { if (appId == null) {
return res.status(400).send('Missing app id'); return res.status(400).send('Missing app id');
} }
// handle the case where the appId is out of scope
if (!req.auth.isScoped({ apps: [appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
return doRestart(appId, force) return doRestart(appId, force)
.then(() => res.status(200).send('OK')) .then(() => res.status(200).send('OK'))
.catch(next); .catch(next);
}); });
const v1StopOrStart = function (req, res, next, action) { const v1StopOrStart = (
req: AuthorizedRequest,
res: express.Response,
next: express.NextFunction,
action: 'start' | 'stop',
) => {
const appId = checkInt(req.params.appId); const appId = checkInt(req.params.appId);
const force = checkTruthy(req.body.force) ?? false; const force = checkTruthy(req.body.force) ?? false;
if (appId == null) { if (appId == null) {
@ -47,13 +64,21 @@ export const createV1Api = function (router) {
'Some v1 endpoints are only allowed on single-container apps', 'Some v1 endpoints are only allowed on single-container apps',
); );
} }
// check that the request is scoped to cover this application
if (!req.auth.isScoped({ apps: [app.appId] })) {
return res.status(401).send('Unauthorized');
}
applicationManager.setTargetVolatileForService(service.imageId, { applicationManager.setTargetVolatileForService(service.imageId, {
running: action !== 'stop', running: action !== 'stop',
}); });
const stopOpts = { wait: true };
const step = generateStep(action, { current: service, ...stopOpts });
return applicationManager return applicationManager
.executeStep(generateStep(action, { current: service, wait: true }), { .executeStep(step, { force })
force,
})
.then(function () { .then(function () {
if (action === 'stop') { if (action === 'stop') {
return service; return service;
@ -75,13 +100,13 @@ export const createV1Api = function (router) {
.catch(next); .catch(next);
}; };
const createV1StopOrStartHandler = (action) => const createV1StopOrStartHandler = (action: 'start' | 'stop') =>
_.partial(v1StopOrStart, _, _, _, action); _.partial(v1StopOrStart, _, _, _, action);
router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop')); router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop'));
router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start')); router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start'));
router.get('/v1/apps/:appId', function (req, res, next) { router.get('/v1/apps/:appId', (req: AuthorizedRequest, res, next) => {
const appId = checkInt(req.params.appId); const appId = checkInt(req.params.appId);
eventTracker.track('GET app (v1)', { appId }); eventTracker.track('GET app (v1)', { appId });
if (appId == null) { if (appId == null) {
@ -96,6 +121,7 @@ export const createV1Api = function (router) {
if (service == null) { if (service == null) {
return res.status(400).send('App not found'); return res.status(400).send('App not found');
} }
if (app.services.length > 1) { if (app.services.length > 1) {
return res return res
.status(400) .status(400)
@ -103,31 +129,50 @@ export const createV1Api = function (router) {
'Some v1 endpoints are only allowed on single-container apps', 'Some v1 endpoints are only allowed on single-container apps',
); );
} }
// handle the case where the appId is out of scope
if (!req.auth.isScoped({ apps: [app.appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
// Don't return data that will be of no use to the user // Don't return data that will be of no use to the user
const appToSend = { const appToSend = {
appId, appId,
commit: status.commit!,
containerId: service.containerId, containerId: service.containerId,
env: _.omit(service.config.environment, constants.privateAppEnvVars), env: _.omit(service.config.environment, constants.privateAppEnvVars),
releaseId: service.releaseId,
imageId: service.config.image, imageId: service.config.image,
releaseId: service.releaseId,
}; };
if (status.commit != null) {
appToSend.commit = status.commit;
}
return res.json(appToSend); return res.json(appToSend);
}, },
).catch(next); ).catch(next);
}); });
router.post('/v1/purge', function (req, res, next) { router.post('/v1/purge', (req: AuthorizedRequest, res, next) => {
const appId = checkInt(req.body.appId); const appId = checkInt(req.body.appId);
const force = checkTruthy(req.body.force) ?? false; const force = checkTruthy(req.body.force) ?? false;
if (appId == null) { if (appId == null) {
const errMsg = 'Invalid or missing appId'; const errMsg = 'Invalid or missing appId';
return res.status(400).send(errMsg); return res.status(400).send(errMsg);
} }
// handle the case where the appId is out of scope
if (!req.auth.isScoped({ apps: [appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
return doPurge(appId, force) return doPurge(appId, force)
.then(() => res.status(200).json({ Data: 'OK', Error: '' })) .then(() => res.status(200).json({ Data: 'OK', Error: '' }))
.catch(next); .catch(next);
}); });
}; }

View File

@ -1,5 +1,5 @@
import * as Bluebird from 'bluebird'; import * as Bluebird from 'bluebird';
import { NextFunction, Request, Response, Router } from 'express'; import { NextFunction, Response, Router } from 'express';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as deviceState from '../device-state'; import * as deviceState from '../device-state';
@ -30,10 +30,11 @@ import supervisorVersion = require('../lib/supervisor-version');
import { checkInt, checkTruthy } from '../lib/validation'; import { checkInt, checkTruthy } from '../lib/validation';
import { isVPNActive } from '../network'; import { isVPNActive } from '../network';
import { doPurge, doRestart, safeStateClone } from './common'; import { doPurge, doRestart, safeStateClone } from './common';
import { AuthorizedRequest } from '../lib/api-keys';
export function createV2Api(router: Router) { export function createV2Api(router: Router) {
const handleServiceAction = ( const handleServiceAction = (
req: Request, req: AuthorizedRequest,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
action: CompositionStepAction, action: CompositionStepAction,
@ -48,6 +49,15 @@ export function createV2Api(router: Router) {
return; return;
} }
// handle the case where the appId is out of scope
if (!req.auth.isScoped({ apps: [appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
return applicationManager.lockingIfNecessary(appId, { force }, () => { return applicationManager.lockingIfNecessary(appId, { force }, () => {
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)]) return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
.then(([apps, targetApp]) => { .then(([apps, targetApp]) => {
@ -115,7 +125,7 @@ export function createV2Api(router: Router) {
router.post( router.post(
'/v2/applications/:appId/purge', '/v2/applications/:appId/purge',
(req: Request, res: Response, next: NextFunction) => { (req: AuthorizedRequest, res: Response, next: NextFunction) => {
const { force } = req.body; const { force } = req.body;
const appId = checkInt(req.params.appId); const appId = checkInt(req.params.appId);
if (!appId) { if (!appId) {
@ -125,6 +135,15 @@ export function createV2Api(router: Router) {
}); });
} }
// handle the case where the application is out of scope
if (!req.auth.isScoped({ apps: [appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
return doPurge(appId, force) return doPurge(appId, force)
.then(() => { .then(() => {
res.status(200).send('OK'); res.status(200).send('OK');
@ -150,7 +169,7 @@ export function createV2Api(router: Router) {
router.post( router.post(
'/v2/applications/:appId/restart', '/v2/applications/:appId/restart',
(req: Request, res: Response, next: NextFunction) => { (req: AuthorizedRequest, res: Response, next: NextFunction) => {
const { force } = req.body; const { force } = req.body;
const appId = checkInt(req.params.appId); const appId = checkInt(req.params.appId);
if (!appId) { if (!appId) {
@ -160,6 +179,15 @@ export function createV2Api(router: Router) {
}); });
} }
// handle the case where the appId is out of scope
if (!req.auth.isScoped({ apps: [appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
return doRestart(appId, force) return doRestart(appId, force)
.then(() => { .then(() => {
res.status(200).send('OK'); res.status(200).send('OK');
@ -171,7 +199,7 @@ export function createV2Api(router: Router) {
// TODO: Support dependent applications when this feature is complete // TODO: Support dependent applications when this feature is complete
router.get( router.get(
'/v2/applications/state', '/v2/applications/state',
async (_req: Request, res: Response, next: NextFunction) => { async (req: AuthorizedRequest, res: Response, next: NextFunction) => {
// It's kinda hacky to access the services and db via the application manager // It's kinda hacky to access the services and db via the application manager
// maybe refactor this code // maybe refactor this code
Bluebird.join( Bluebird.join(
@ -200,44 +228,52 @@ export function createV2Api(router: Router) {
const appNameById: { [id: number]: string } = {}; const appNameById: { [id: number]: string } = {};
apps.forEach((app) => { // only access scoped apps
const appId = parseInt(app.appId, 10); apps
response[app.name] = { .filter((app) =>
appId, req.auth.isScoped({ apps: [parseInt(app.appId, 10)] }),
commit: app.commit, )
services: {}, .forEach((app) => {
}; const appId = parseInt(app.appId, 10);
response[app.name] = {
appId,
commit: app.commit,
services: {},
};
appNameById[appId] = app.name; appNameById[appId] = app.name;
});
imgs.forEach((img) => {
const appName = appNameById[img.appId];
if (appName == null) {
log.warn(
`Image found for unknown application!\nImage: ${JSON.stringify(
img,
)}`,
);
return;
}
const svc = _.find(services, (service: Service) => {
return service.imageId === img.imageId;
}); });
let status: string | undefined; // only access scoped images
if (svc == null) { imgs
status = img.status; .filter((img) => req.auth.isScoped({ apps: [img.appId] }))
} else { .forEach((img) => {
status = svc.status || img.status; const appName = appNameById[img.appId];
} if (appName == null) {
response[appName].services[img.serviceName] = { log.warn(
status, `Image found for unknown application!\nImage: ${JSON.stringify(
releaseId: img.releaseId, img,
downloadProgress: img.downloadProgress || null, )}`,
}; );
}); return;
}
const svc = _.find(services, (service: Service) => {
return service.imageId === img.imageId;
});
let status: string | undefined;
if (svc == null) {
status = img.status;
} else {
status = svc.status || img.status;
}
response[appName].services[img.serviceName] = {
status,
releaseId: img.releaseId,
downloadProgress: img.downloadProgress || null,
};
});
res.status(200).json(response); res.status(200).json(response);
}, },
@ -247,7 +283,7 @@ export function createV2Api(router: Router) {
router.get( router.get(
'/v2/applications/:appId/state', '/v2/applications/:appId/state',
async (req: Request, res: Response) => { async (req: AuthorizedRequest, res: Response) => {
// Check application ID provided is valid // Check application ID provided is valid
const appId = checkInt(req.params.appId); const appId = checkInt(req.params.appId);
if (!appId) { if (!appId) {
@ -256,6 +292,7 @@ export function createV2Api(router: Router) {
message: `Invalid application ID: ${req.params.appId}`, message: `Invalid application ID: ${req.params.appId}`,
}); });
} }
// Query device for all applications // Query device for all applications
let apps: any; let apps: any;
try { try {
@ -268,12 +305,22 @@ export function createV2Api(router: Router) {
}); });
} }
// Check if the application exists // Check if the application exists
if (!(appId in apps.local)) { if (!(appId in apps.local) || !req.auth.isScoped({ apps: [appId] })) {
return res.status(409).json({ return res.status(409).json({
status: 'failed', status: 'failed',
message: `Application ID does not exist: ${appId}`, message: `Application ID does not exist: ${appId}`,
}); });
} }
// handle the case where the appId is out of scope
if (!req.auth.isScoped({ apps: [appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
// Filter applications we do not want // Filter applications we do not want
for (const app in apps.local) { for (const app in apps.local) {
if (app !== appId.toString()) { if (app !== appId.toString()) {
@ -380,8 +427,10 @@ export function createV2Api(router: Router) {
}); });
}); });
router.get('/v2/containerId', async (req, res) => { router.get('/v2/containerId', async (req: AuthorizedRequest, res) => {
const services = await serviceManager.getAll(); const services = (await serviceManager.getAll()).filter((service) =>
req.auth.isScoped({ apps: [service.appId] }),
);
if (req.query.serviceName != null || req.query.service != null) { if (req.query.serviceName != null || req.query.service != null) {
const serviceName = req.query.serviceName || req.query.service; const serviceName = req.query.serviceName || req.query.service;
@ -411,41 +460,45 @@ export function createV2Api(router: Router) {
} }
}); });
router.get('/v2/state/status', async (_req, res) => { router.get('/v2/state/status', async (req: AuthorizedRequest, res) => {
const currentRelease = await config.get('currentCommit'); const currentRelease = await config.get('currentCommit');
const pending = deviceState.isApplyInProgress(); const pending = deviceState.isApplyInProgress();
const containerStates = (await serviceManager.getAll()).map((svc) => const containerStates = (await serviceManager.getAll())
_.pick( .filter((service) => req.auth.isScoped({ apps: [service.appId] }))
svc, .map((svc) =>
'status', _.pick(
'serviceName', svc,
'appId', 'status',
'imageId', 'serviceName',
'serviceId', 'appId',
'containerId', 'imageId',
'createdAt', 'serviceId',
), 'containerId',
); 'createdAt',
),
);
let downloadProgressTotal = 0; let downloadProgressTotal = 0;
let downloads = 0; let downloads = 0;
const imagesStates = (await images.getStatus()).map((img) => { const imagesStates = (await images.getStatus())
if (img.downloadProgress != null) { .filter((img) => req.auth.isScoped({ apps: [img.appId] }))
downloadProgressTotal += img.downloadProgress; .map((img) => {
downloads += 1; if (img.downloadProgress != null) {
} downloadProgressTotal += img.downloadProgress;
return _.pick( downloads += 1;
img, }
'name', return _.pick(
'appId', img,
'serviceName', 'name',
'imageId', 'appId',
'dockerImageId', 'serviceName',
'status', 'imageId',
'downloadProgress', 'dockerImageId',
); 'status',
}); 'downloadProgress',
);
});
let overallDownloadProgress: number | null = null; let overallDownloadProgress: number | null = null;
if (downloads > 0) { if (downloads > 0) {
@ -500,10 +553,15 @@ export function createV2Api(router: Router) {
}); });
}); });
router.get('/v2/cleanup-volumes', async (_req, res) => { router.get('/v2/cleanup-volumes', async (req: AuthorizedRequest, res) => {
const targetState = await applicationManager.getTargetApps(); const targetState = await applicationManager.getTargetApps();
const referencedVolumes: string[] = []; const referencedVolumes: string[] = [];
_.each(targetState, (app, appId) => { _.each(targetState, (app, appId) => {
// if this app isn't in scope of the request, do not cleanup it's volumes
if (!req.auth.isScoped({ apps: [parseInt(appId, 10)] })) {
return;
}
_.each(app.volumes, (_volume, volumeName) => { _.each(app.volumes, (_volume, volumeName) => {
referencedVolumes.push( referencedVolumes.push(
Volume.generateDockerName(parseInt(appId, 10), volumeName), Volume.generateDockerName(parseInt(appId, 10), volumeName),

View File

@ -38,6 +38,7 @@ import {
InstancedAppState, InstancedAppState,
} from './types/state'; } from './types/state';
import * as dbFormat from './device-state/db-format'; import * as dbFormat from './device-state/db-format';
import * as apiKeys from './lib/api-keys';
function validateLocalState(state: any): asserts state is TargetState['local'] { function validateLocalState(state: any): asserts state is TargetState['local'] {
if (state.name != null) { if (state.name != null) {
@ -265,9 +266,7 @@ export const initialized = (async () => {
if (changedConfig.loggingEnabled != null) { if (changedConfig.loggingEnabled != null) {
logger.enable(changedConfig.loggingEnabled); logger.enable(changedConfig.loggingEnabled);
} }
if (changedConfig.apiSecret != null) {
reportCurrentState({ api_secret: changedConfig.apiSecret });
}
if (changedConfig.appUpdatePollInterval != null) { if (changedConfig.appUpdatePollInterval != null) {
maxPollTime = changedConfig.appUpdatePollInterval; maxPollTime = changedConfig.appUpdatePollInterval;
} }
@ -346,11 +345,11 @@ async function saveInitialConfig() {
export async function loadInitialState() { export async function loadInitialState() {
await applicationManager.initialized; await applicationManager.initialized;
await apiKeys.initialized;
const conf = await config.getMany([ const conf = await config.getMany([
'initialConfigSaved', 'initialConfigSaved',
'listenPort', 'listenPort',
'apiSecret',
'osVersion', 'osVersion',
'osVariant', 'osVariant',
'macAddress', 'macAddress',
@ -374,7 +373,7 @@ export async function loadInitialState() {
log.info('Reporting initial state, supervisor version and API info'); log.info('Reporting initial state, supervisor version and API info');
reportCurrentState({ reportCurrentState({
api_port: conf.listenPort, api_port: conf.listenPort,
api_secret: conf.apiSecret, api_secret: apiKeys.cloudApiKey,
os_version: conf.osVersion, os_version: conf.osVersion,
os_variant: conf.osVariant, os_variant: conf.osVariant,
mac_address: conf.macAddress, mac_address: conf.macAddress,

359
src/lib/api-keys.ts Normal file
View File

@ -0,0 +1,359 @@
import * as _ from 'lodash';
import * as express from 'express';
import * as memoizee from 'memoizee';
import * as config from '../config';
import * as db from '../db';
import { generateUniqueKey } from './register-device';
export class KeyNotFoundError extends Error {}
/**
* The schema for the `apiSecret` table in the database
*/
interface DbApiSecret {
id: number;
appId: number;
serviceId: number;
scopes: string;
key: string;
}
export type Scope = SerializableScope<ScopeTypeKey>;
type ScopeTypeKey = keyof ScopeTypes;
type SerializableScope<T extends ScopeTypeKey> = {
type: T;
} & ScopeTypes[T];
type ScopeCheck<T extends ScopeTypeKey> = (
resources: Partial<ScopedResources>,
scope: ScopeTypes[T],
) => Resolvable<boolean>;
type ScopeCheckCollection = {
[K in ScopeTypeKey]: ScopeCheck<K>;
};
/**
* The scopes which a key can cover.
*/
type ScopeTypes = {
global: {};
app: {
appId: number;
};
};
/**
* The resources which can be protected with scopes.
*/
interface ScopedResources {
apps: number[];
}
/**
* The checks when determining if a key is scoped for a resource.
*/
const scopeChecks: ScopeCheckCollection = {
global: () => true,
app: (resources, { appId }) =>
resources.apps != null && resources.apps.includes(appId),
};
export function serialiseScopes(scopes: Scope[]): string {
return JSON.stringify(scopes);
}
export function deserialiseScopes(json: string): Scope[] {
return JSON.parse(json);
}
export const isScoped = (
resources: Partial<ScopedResources>,
scopes: Scope[],
) =>
scopes.some((scope) =>
scopeChecks[scope.type](resources, (scope as unknown) as any),
);
export type AuthorizedRequest = express.Request & {
auth: {
isScoped: (resources: Partial<ScopedResources>) => boolean;
apiKey: string;
scopes: Scope[];
};
};
export type AuthorizedRequestHandler = (
req: AuthorizedRequest,
res: express.Response,
next: express.NextFunction,
) => void;
// empty until populated in `initialized`
export let cloudApiKey: string = '';
// should be called before trying to use this singleton
export const initialized = (async () => {
await db.initialized;
// make sure we have an API key which the cloud will use to call us
await generateCloudKey();
})();
/**
* This middleware will extract an API key used to make a call, and then expand it out to provide
* access to the scopes it has. The `req` will be updated to include this `auth` data.
*
* E.g. `req.auth.scopes: []`
*
* @param req
* @param res
* @param next
*/
export const authMiddleware: AuthorizedRequestHandler = async (
req,
res,
next,
) => {
// grab the API key used for the request
const apiKey = getApiKeyFromRequest(req) ?? '';
// store the key in the request, and an empty scopes array to populate after resolving the key scopes
req.auth = {
apiKey,
scopes: [],
isScoped: () => false,
};
try {
const conf = await config.getMany(['localMode', 'unmanaged', 'osVariant']);
// we only need to check the API key if a) unmanaged and on a production image, or b) managed and not in local mode
const needsAuth = conf.unmanaged
? conf.osVariant === 'prod'
: !conf.localMode;
// no need to authenticate, shortcut
if (!needsAuth) {
return next();
}
// if we have a key, find the scopes and add them to the request
if (apiKey && apiKey !== '') {
await initialized;
const scopes = await getScopesForKey(apiKey);
if (scopes != null) {
// keep the scopes for later incase they're desired
req.auth.scopes.push(...scopes);
// which resources are scoped...
req.auth.isScoped = (resources) => isScoped(resources, req.auth.scopes);
return next();
}
}
// we do not have a valid key...
return res.sendStatus(401);
} catch (err) {
console.error(err);
res.status(503).send(`Unexpected error: ${err}`);
}
};
function isEqualScope(a: Scope, b: Scope): boolean {
return _.isEqual(a, b);
}
function getApiKeyFromRequest(req: express.Request): string | undefined {
// Check query for key
if (req.query.apikey) {
return req.query.apikey;
}
// Get Authorization header to search for key
const authHeader = req.get('Authorization');
// Check header for key
if (!authHeader) {
return undefined;
}
// Check authHeader with various schemes
const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i);
// Return key from match or undefined
return match?.[1];
}
export type GenerateKeyOptions = { force: boolean; scopes: Scope[] };
export async function getScopesForKey(key: string): Promise<Scope[] | null> {
const apiKey = await getApiKeyByKey(key);
// null means the key wasn't known...
if (apiKey == null) {
return null;
}
return deserialiseScopes(apiKey.scopes);
}
export async function generateScopedKey(
appId: number,
serviceId: number,
options?: Partial<GenerateKeyOptions>,
): Promise<string> {
await initialized;
return await generateKey(appId, serviceId, options);
}
export async function generateCloudKey(
force: boolean = false,
): Promise<string> {
cloudApiKey = await generateKey(0, 0, {
force,
scopes: [{ type: 'global' }],
});
return cloudApiKey;
}
export async function refreshKey(key: string): Promise<string> {
const apiKey = await getApiKeyByKey(key);
if (apiKey == null) {
throw new KeyNotFoundError();
}
const { appId, serviceId, scopes } = apiKey;
// if this is a cloud key that is being refreshed
if (appId === 0 && serviceId === 0) {
return await generateCloudKey(true);
}
// generate a new key, expiring the old one...
const newKey = await generateScopedKey(appId, serviceId, {
force: true,
scopes: deserialiseScopes(scopes),
});
// return the regenerated key
return newKey;
}
/**
* A cached lookup of the database key
*/
const getApiKeyForService = memoizee(
async (appId: number, serviceId: number): Promise<DbApiSecret[]> => {
await db.initialized;
return await db.models('apiSecret').where({ appId, serviceId }).select();
},
{
promise: true,
maxAge: 60000, // 1 minute
normalizer: ([appId, serviceId]) => `${appId}-${serviceId}`,
},
);
/**
* A cached lookup of the database key for a given application/service pair
*/
const getApiKeyByKey = memoizee(
async (key: string): Promise<DbApiSecret> => {
await db.initialized;
const [apiKey] = await db.models('apiSecret').where({ key }).select();
return apiKey;
},
{
promise: true,
maxAge: 60000, // 1 minute
},
);
/**
* All key generate logic should come though this method. It handles cache clearing.
*
* @param appId
* @param serviceId
* @param options
*/
async function generateKey(
appId: number,
serviceId: number,
options?: Partial<GenerateKeyOptions>,
): Promise<string> {
// set default options
const { force, scopes }: GenerateKeyOptions = {
force: false,
scopes: [{ type: 'app', appId }],
...options,
};
// grab the existing API key info
const secrets = await getApiKeyForService(appId, serviceId);
// if we need a new key
if (secrets.length === 0 || force) {
// are forcing a new key?
if (force) {
await db.models('apiSecret').where({ appId, serviceId }).del();
}
// remove the cached lookup for the key
const [apiKey] = secrets;
if (apiKey != null) {
getApiKeyByKey.clear(apiKey.key);
}
// remove the cached value for this lookup
getApiKeyForService.clear(appId, serviceId);
// return a new API key
return await createNewKey(appId, serviceId, scopes);
}
// grab the current secret and scopes
const [currentSecret] = secrets;
const currentScopes: Scope[] = JSON.parse(currentSecret.scopes);
const scopesWeAlreadyHave = scopes.filter((desiredScope) =>
currentScopes.some((currentScope) =>
isEqualScope(desiredScope, currentScope),
),
);
// if we have the correct scopes, then return our existing key...
if (
scopes.length === currentScopes.length &&
scopesWeAlreadyHave.length === currentScopes.length
) {
return currentSecret.key;
}
// forcibly get a new key...
return await generateKey(appId, serviceId, { ...options, force: true });
}
/**
* Generates a new key value and inserts it into the DB.
*
* @param appId
* @param serviceId
* @param scopes
*/
async function createNewKey(appId: number, serviceId: number, scopes: Scope[]) {
const key = generateUniqueKey();
await db.models('apiSecret').insert({
appId,
serviceId,
key,
scopes: serialiseScopes(scopes),
});
// return the new key
return key;
}

32
src/migrations/M00005.js Normal file
View File

@ -0,0 +1,32 @@
import { generateScopedKey } from '../lib/api-keys';
export async function up(knex) {
// Create a new table to hold the api keys
await knex.schema.createTable('apiSecret', (table) => {
table.increments('id').primary();
table.integer('appId');
table.integer('serviceId');
table.string('key');
table.string('scopes');
table.unique(['appId', 'serviceId']);
});
// Delete any existing API secrets
await knex('config').where({ key: 'apiSecret' }).del();
// Add an api secret per service in the db
const apps = await knex('app');
for (const app of apps) {
const appId = app.appId;
const services = JSON.parse(app.services);
for (const service of services) {
const serviceId = service.id;
await generateScopedKey(appId, serviceId);
}
}
}
export function down() {
return Promise.reject(new Error('Not Implemented'));
}

View File

@ -4,59 +4,12 @@ import { Server } from 'http';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import * as config from './config';
import * as eventTracker from './event-tracker'; import * as eventTracker from './event-tracker';
import blink = require('./lib/blink'); import blink = require('./lib/blink');
import log from './lib/supervisor-console'; import log from './lib/supervisor-console';
import * as apiKeys from './lib/api-keys';
function getKeyFromReq(req: express.Request): string | undefined { import * as deviceState from './device-state';
// Check query for key
if (req.query.apikey) {
return req.query.apikey;
}
// Get Authorization header to search for key
const authHeader = req.get('Authorization');
// Check header for key
if (!authHeader) {
return undefined;
}
// Check authHeader with various schemes
const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i);
// Return key from match or undefined
return match?.[1];
}
function authenticate(): express.RequestHandler {
return async (req, res, next) => {
try {
const conf = await config.getMany([
'apiSecret',
'localMode',
'unmanaged',
'osVariant',
]);
const needsAuth = conf.unmanaged
? conf.osVariant === 'prod'
: !conf.localMode;
if (needsAuth) {
// Only get the key if we need it
const key = getKeyFromReq(req);
if (key && conf.apiSecret && key === conf.apiSecret) {
return next();
} else {
return res.sendStatus(401);
}
} else {
return next();
}
} catch (err) {
res.status(503).send(`Unexpected error: ${err}`);
}
};
}
const expressLogger = morgan( const expressLogger = morgan(
(tokens, req, res) => (tokens, req, res) =>
@ -112,7 +65,7 @@ export class SupervisorAPI {
this.api.get('/ping', (_req, res) => res.send('OK')); this.api.get('/ping', (_req, res) => res.send('OK'));
this.api.use(authenticate()); this.api.use(apiKeys.authMiddleware);
this.api.post('/v1/blink', (_req, res) => { this.api.post('/v1/blink', (_req, res) => {
eventTracker.track('Device blink'); eventTracker.track('Device blink');
@ -123,11 +76,30 @@ export class SupervisorAPI {
// Expires the supervisor's API key and generates a new one. // Expires the supervisor's API key and generates a new one.
// It also communicates the new key to the balena API. // It also communicates the new key to the balena API.
this.api.post('/v1/regenerate-api-key', async (_req, res) => { this.api.post(
const secret = config.newUniqueKey(); '/v1/regenerate-api-key',
await config.set({ apiSecret: secret }); async (req: apiKeys.AuthorizedRequest, res) => {
res.status(200).send(secret); await deviceState.initialized;
}); await apiKeys.initialized;
// check if we're updating the cloud API key
const updateCloudKey = req.auth.apiKey === apiKeys.cloudApiKey;
// regenerate the key...
const newKey = await apiKeys.refreshKey(req.auth.apiKey);
// if we need to update the cloud API with our new key
if (updateCloudKey) {
// report the new key to the cloud API
deviceState.reportCurrentState({
api_secret: apiKeys.cloudApiKey,
});
}
// return the value of the new key to the caller
res.status(200).send(newKey);
},
);
// And assign all external routers // And assign all external routers
for (const router of this.routers) { for (const router of this.routers) {
@ -135,7 +107,6 @@ export class SupervisorAPI {
} }
// Error handling. // Error handling.
const messageFromError = (err?: Error | string | null): string => { const messageFromError = (err?: Error | string | null): string => {
let message = 'Unknown error'; let message = 'Unknown error';
if (err != null) { if (err != null) {

View File

@ -19,7 +19,6 @@ const startupConfigFields: config.ConfigKey[] = [
'uuid', 'uuid',
'listenPort', 'listenPort',
'apiEndpoint', 'apiEndpoint',
'apiSecret',
'apiTimeout', 'apiTimeout',
'unmanaged', 'unmanaged',
'deviceApiKey', 'deviceApiKey',

View File

@ -64,12 +64,6 @@ describe('Config', () => {
}); });
}); });
it('allows removing a db key', async () => {
await conf.remove('apiSecret');
const secret = await conf.get('apiSecret');
return expect(secret).to.be.undefined;
});
it('allows deleting a config.json key and returns a default value if none is set', async () => { it('allows deleting a config.json key and returns a default value if none is set', async () => {
await conf.remove('appUpdatePollInterval'); await conf.remove('appUpdatePollInterval');
const poll = await conf.get('appUpdatePollInterval'); const poll = await conf.get('appUpdatePollInterval');

View File

@ -28,7 +28,7 @@ const configs = {
}; };
describe('compose/service', () => { describe('compose/service', () => {
it('extends environment variables properly', () => { it('extends environment variables properly', async () => {
const extendEnvVarsOpts = { const extendEnvVarsOpts = {
uuid: '1234', uuid: '1234',
appName: 'awesomeApp', appName: 'awesomeApp',
@ -50,7 +50,10 @@ describe('compose/service', () => {
A_VARIABLE: 'ITS_VALUE', A_VARIABLE: 'ITS_VALUE',
}, },
}; };
const s = Service.fromComposeObject(service, extendEnvVarsOpts as any); const s = await Service.fromComposeObject(
service,
extendEnvVarsOpts as any,
);
expect(s.config.environment).to.deep.equal({ expect(s.config.environment).to.deep.equal({
FOO: 'bar', FOO: 'bar',
@ -81,8 +84,8 @@ describe('compose/service', () => {
}); });
}); });
it('returns the correct default bind mounts', () => { it('returns the correct default bind mounts', async () => {
const s = Service.fromComposeObject( const s = await Service.fromComposeObject(
{ {
appId: '1234', appId: '1234',
serviceName: 'foo', serviceName: 'foo',
@ -99,8 +102,8 @@ describe('compose/service', () => {
]); ]);
}); });
it('produces the correct port bindings and exposed ports', () => { it('produces the correct port bindings and exposed ports', async () => {
const s = Service.fromComposeObject( const s = await Service.fromComposeObject(
{ {
appId: '1234', appId: '1234',
serviceName: 'foo', serviceName: 'foo',
@ -155,8 +158,8 @@ describe('compose/service', () => {
}); });
}); });
it('correctly handles port ranges', () => { it('correctly handles port ranges', async () => {
const s = Service.fromComposeObject( const s = await Service.fromComposeObject(
{ {
appId: '1234', appId: '1234',
serviceName: 'foo', serviceName: 'foo',
@ -207,9 +210,9 @@ describe('compose/service', () => {
}); });
}); });
it('should correctly handle large port ranges', function () { it('should correctly handle large port ranges', async function () {
this.timeout(60000); this.timeout(60000);
const s = Service.fromComposeObject( const s = await Service.fromComposeObject(
{ {
appId: '1234', appId: '1234',
serviceName: 'foo', serviceName: 'foo',
@ -224,8 +227,8 @@ describe('compose/service', () => {
expect((s as any).generateExposeAndPorts()).to.not.throw; expect((s as any).generateExposeAndPorts()).to.not.throw;
}); });
it('should correctly report implied exposed ports from portMappings', () => { it('should correctly report implied exposed ports from portMappings', async () => {
const service = Service.fromComposeObject( const service = await Service.fromComposeObject(
{ {
appId: 123456, appId: 123456,
serviceId: 123456, serviceId: 123456,
@ -240,8 +243,8 @@ describe('compose/service', () => {
.that.deep.equals(['80/tcp', '100/tcp']); .that.deep.equals(['80/tcp', '100/tcp']);
}); });
it('should correctly handle spaces in volume definitions', () => { it('should correctly handle spaces in volume definitions', async () => {
const service = Service.fromComposeObject( const service = await Service.fromComposeObject(
{ {
appId: 123, appId: 123,
serviceId: 123, serviceId: 123,
@ -270,8 +273,8 @@ describe('compose/service', () => {
}); });
describe('Ordered array parameters', () => { describe('Ordered array parameters', () => {
it('Should correctly compare ordered array parameters', () => { it('Should correctly compare ordered array parameters', async () => {
const svc1 = Service.fromComposeObject( const svc1 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -280,7 +283,7 @@ describe('compose/service', () => {
}, },
{ appName: 'test' } as any, { appName: 'test' } as any,
); );
let svc2 = Service.fromComposeObject( let svc2 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -291,7 +294,7 @@ describe('compose/service', () => {
); );
assert(svc1.isEqualConfig(svc2, {})); assert(svc1.isEqualConfig(svc2, {}));
svc2 = Service.fromComposeObject( svc2 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -303,8 +306,8 @@ describe('compose/service', () => {
assert(!svc1.isEqualConfig(svc2, {})); assert(!svc1.isEqualConfig(svc2, {}));
}); });
it('should correctly compare non-ordered array parameters', () => { it('should correctly compare non-ordered array parameters', async () => {
const svc1 = Service.fromComposeObject( const svc1 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -313,7 +316,7 @@ describe('compose/service', () => {
}, },
{ appName: 'test' } as any, { appName: 'test' } as any,
); );
let svc2 = Service.fromComposeObject( let svc2 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -324,7 +327,7 @@ describe('compose/service', () => {
); );
assert(svc1.isEqualConfig(svc2, {})); assert(svc1.isEqualConfig(svc2, {}));
svc2 = Service.fromComposeObject( svc2 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -336,8 +339,8 @@ describe('compose/service', () => {
assert(svc1.isEqualConfig(svc2, {})); assert(svc1.isEqualConfig(svc2, {}));
}); });
it('should correctly compare both ordered and non-ordered array parameters', () => { it('should correctly compare both ordered and non-ordered array parameters', async () => {
const svc1 = Service.fromComposeObject( const svc1 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -347,7 +350,7 @@ describe('compose/service', () => {
}, },
{ appName: 'test' } as any, { appName: 'test' } as any,
); );
const svc2 = Service.fromComposeObject( const svc2 = await Service.fromComposeObject(
{ {
appId: 1, appId: 1,
serviceId: 1, serviceId: 1,
@ -362,8 +365,8 @@ describe('compose/service', () => {
}); });
describe('parseMemoryNumber()', () => { describe('parseMemoryNumber()', () => {
const makeComposeServiceWithLimit = (memLimit?: string | number) => const makeComposeServiceWithLimit = async (memLimit?: string | number) =>
Service.fromComposeObject( await Service.fromComposeObject(
{ {
appId: 123456, appId: 123456,
serviceId: 123456, serviceId: 123456,
@ -373,74 +376,82 @@ describe('compose/service', () => {
{ appName: 'test' } as any, { appName: 'test' } as any,
); );
it('should correctly parse memory number strings without a unit', () => it('should correctly parse memory number strings without a unit', async () =>
expect(makeComposeServiceWithLimit('64').config.memLimit).to.equal(64)); expect(
(await makeComposeServiceWithLimit('64')).config.memLimit,
).to.equal(64));
it('should correctly apply the default value', () => it('should correctly apply the default value', async () =>
expect(makeComposeServiceWithLimit(undefined).config.memLimit).to.equal( expect(
0, (await makeComposeServiceWithLimit(undefined)).config.memLimit,
).to.equal(0));
it('should correctly support parsing numbers as memory limits', async () =>
expect((await makeComposeServiceWithLimit(64)).config.memLimit).to.equal(
64,
)); ));
it('should correctly support parsing numbers as memory limits', () => it('should correctly parse memory number strings that use a byte unit', async () => {
expect(makeComposeServiceWithLimit(64).config.memLimit).to.equal(64)); expect(
(await makeComposeServiceWithLimit('64b')).config.memLimit,
it('should correctly parse memory number strings that use a byte unit', () => { ).to.equal(64);
expect(makeComposeServiceWithLimit('64b').config.memLimit).to.equal(64); expect(
expect(makeComposeServiceWithLimit('64B').config.memLimit).to.equal(64); (await makeComposeServiceWithLimit('64B')).config.memLimit,
).to.equal(64);
}); });
it('should correctly parse memory number strings that use a kilobyte unit', () => { it('should correctly parse memory number strings that use a kilobyte unit', async () => {
expect(makeComposeServiceWithLimit('64k').config.memLimit).to.equal( expect(
65536, (await makeComposeServiceWithLimit('64k')).config.memLimit,
); ).to.equal(65536);
expect(makeComposeServiceWithLimit('64K').config.memLimit).to.equal( expect(
65536, (await makeComposeServiceWithLimit('64K')).config.memLimit,
); ).to.equal(65536);
expect(makeComposeServiceWithLimit('64kb').config.memLimit).to.equal( expect(
65536, (await makeComposeServiceWithLimit('64kb')).config.memLimit,
); ).to.equal(65536);
expect(makeComposeServiceWithLimit('64Kb').config.memLimit).to.equal( expect(
65536, (await makeComposeServiceWithLimit('64Kb')).config.memLimit,
); ).to.equal(65536);
}); });
it('should correctly parse memory number strings that use a megabyte unit', () => { it('should correctly parse memory number strings that use a megabyte unit', async () => {
expect(makeComposeServiceWithLimit('64m').config.memLimit).to.equal( expect(
67108864, (await makeComposeServiceWithLimit('64m')).config.memLimit,
); ).to.equal(67108864);
expect(makeComposeServiceWithLimit('64M').config.memLimit).to.equal( expect(
67108864, (await makeComposeServiceWithLimit('64M')).config.memLimit,
); ).to.equal(67108864);
expect(makeComposeServiceWithLimit('64mb').config.memLimit).to.equal( expect(
67108864, (await makeComposeServiceWithLimit('64mb')).config.memLimit,
); ).to.equal(67108864);
expect(makeComposeServiceWithLimit('64Mb').config.memLimit).to.equal( expect(
67108864, (await makeComposeServiceWithLimit('64Mb')).config.memLimit,
); ).to.equal(67108864);
}); });
it('should correctly parse memory number strings that use a gigabyte unit', () => { it('should correctly parse memory number strings that use a gigabyte unit', async () => {
expect(makeComposeServiceWithLimit('64g').config.memLimit).to.equal( expect(
68719476736, (await makeComposeServiceWithLimit('64g')).config.memLimit,
); ).to.equal(68719476736);
expect(makeComposeServiceWithLimit('64G').config.memLimit).to.equal( expect(
68719476736, (await makeComposeServiceWithLimit('64G')).config.memLimit,
); ).to.equal(68719476736);
expect(makeComposeServiceWithLimit('64gb').config.memLimit).to.equal( expect(
68719476736, (await makeComposeServiceWithLimit('64gb')).config.memLimit,
); ).to.equal(68719476736);
expect(makeComposeServiceWithLimit('64Gb').config.memLimit).to.equal( expect(
68719476736, (await makeComposeServiceWithLimit('64Gb')).config.memLimit,
); ).to.equal(68719476736);
}); });
}); });
describe('getWorkingDir', () => { describe('getWorkingDir', () => {
const makeComposeServiceWithWorkdir = (workdir?: string) => const makeComposeServiceWithWorkdir = async (workdir?: string) =>
Service.fromComposeObject( await Service.fromComposeObject(
{ {
appId: 123456, appId: 123456,
serviceId: 123456, serviceId: 123456,
@ -450,17 +461,20 @@ describe('compose/service', () => {
{ appName: 'test' } as any, { appName: 'test' } as any,
); );
it('should remove a trailing slash', () => { it('should remove a trailing slash', async () => {
expect( expect(
makeComposeServiceWithWorkdir('/usr/src/app/').config.workingDir, (await makeComposeServiceWithWorkdir('/usr/src/app/')).config
.workingDir,
).to.equal('/usr/src/app'); ).to.equal('/usr/src/app');
expect(makeComposeServiceWithWorkdir('/').config.workingDir).to.equal(
'/',
);
expect( expect(
makeComposeServiceWithWorkdir('/usr/src/app').config.workingDir, (await makeComposeServiceWithWorkdir('/')).config.workingDir,
).to.equal('/');
expect(
(await makeComposeServiceWithWorkdir('/usr/src/app')).config.workingDir,
).to.equal('/usr/src/app'); ).to.equal('/usr/src/app');
expect(makeComposeServiceWithWorkdir('').config.workingDir).to.equal(''); expect(
(await makeComposeServiceWithWorkdir('')).config.workingDir,
).to.equal('');
}); });
}); });
@ -469,8 +483,8 @@ describe('compose/service', () => {
Count: 1, Count: 1,
Capabilities: [['gpu']], Capabilities: [['gpu']],
}; };
it('should succeed from compose object', () => { it('should succeed from compose object', async () => {
const s = Service.fromComposeObject( const s = await Service.fromComposeObject(
{ {
appId: 123, appId: 123,
serviceId: 123, serviceId: 123,
@ -504,8 +518,8 @@ describe('compose/service', () => {
const omitConfigForComparison = (config: ServiceConfig) => const omitConfigForComparison = (config: ServiceConfig) =>
_.omit(config, ['running', 'networks']); _.omit(config, ['running', 'networks']);
it('should be identical when converting a simple service', () => { it('should be identical when converting a simple service', async () => {
const composeSvc = Service.fromComposeObject( const composeSvc = await Service.fromComposeObject(
configs.simple.compose, configs.simple.compose,
configs.simple.imageInfo, configs.simple.imageInfo,
); );
@ -518,8 +532,8 @@ describe('compose/service', () => {
expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true; expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true;
}); });
it('should correctly convert formats with a null entrypoint', () => { it('should correctly convert formats with a null entrypoint', async () => {
const composeSvc = Service.fromComposeObject( const composeSvc = await Service.fromComposeObject(
configs.entrypoint.compose, configs.entrypoint.compose,
configs.entrypoint.imageInfo, configs.entrypoint.imageInfo,
); );
@ -533,11 +547,11 @@ describe('compose/service', () => {
}); });
describe('Networks', () => { describe('Networks', () => {
it('should correctly convert networks from compose to docker format', () => { it('should correctly convert networks from compose to docker format', async () => {
const makeComposeServiceWithNetwork = ( const makeComposeServiceWithNetwork = async (
networks: ServiceComposeConfig['networks'], networks: ServiceComposeConfig['networks'],
) => ) =>
Service.fromComposeObject( await Service.fromComposeObject(
{ {
appId: 123456, appId: 123456,
serviceId: 123456, serviceId: 123456,
@ -548,11 +562,13 @@ describe('compose/service', () => {
); );
expect( expect(
makeComposeServiceWithNetwork({ (
balena: { await makeComposeServiceWithNetwork({
ipv4Address: '1.2.3.4', balena: {
}, ipv4Address: '1.2.3.4',
}).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig, },
})
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
).to.deep.equal({ ).to.deep.equal({
EndpointsConfig: { EndpointsConfig: {
'123456_balena': { '123456_balena': {
@ -565,14 +581,16 @@ describe('compose/service', () => {
}); });
expect( expect(
makeComposeServiceWithNetwork({ (
balena: { await makeComposeServiceWithNetwork({
aliases: ['test', '1123'], balena: {
ipv4Address: '1.2.3.4', aliases: ['test', '1123'],
ipv6Address: '5.6.7.8', ipv4Address: '1.2.3.4',
linkLocalIps: ['123.123.123'], ipv6Address: '5.6.7.8',
}, linkLocalIps: ['123.123.123'],
}).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig, },
})
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
).to.deep.equal({ ).to.deep.equal({
EndpointsConfig: { EndpointsConfig: {
'123456_balena': { '123456_balena': {
@ -638,8 +656,8 @@ describe('compose/service', () => {
}); });
return describe('Network mode=service:', () => { return describe('Network mode=service:', () => {
it('should correctly add a depends_on entry for the service', () => { it('should correctly add a depends_on entry for the service', async () => {
let s = Service.fromComposeObject( let s = await Service.fromComposeObject(
{ {
appId: '1234', appId: '1234',
serviceName: 'foo', serviceName: 'foo',
@ -653,7 +671,7 @@ describe('compose/service', () => {
expect(s.dependsOn).to.deep.equal(['test']); expect(s.dependsOn).to.deep.equal(['test']);
s = Service.fromComposeObject( s = await Service.fromComposeObject(
{ {
appId: '1234', appId: '1234',
serviceName: 'foo', serviceName: 'foo',
@ -669,8 +687,8 @@ describe('compose/service', () => {
expect(s.dependsOn).to.deep.equal(['another_service', 'test']); expect(s.dependsOn).to.deep.equal(['another_service', 'test']);
}); });
it('should correctly convert a network_mode service: to a container:', () => { it('should correctly convert a network_mode service: to a container:', async () => {
const s = Service.fromComposeObject( const s = await Service.fromComposeObject(
{ {
appId: '1234', appId: '1234',
serviceName: 'foo', serviceName: 'foo',
@ -692,8 +710,8 @@ describe('compose/service', () => {
.that.equals('container:abcdef'); .that.equals('container:abcdef');
}); });
it('should not cause a container restart if a service: container has not changed', () => { it('should not cause a container restart if a service: container has not changed', async () => {
const composeSvc = Service.fromComposeObject( const composeSvc = await Service.fromComposeObject(
configs.networkModeService.compose, configs.networkModeService.compose,
configs.networkModeService.imageInfo, configs.networkModeService.imageInfo,
); );
@ -709,8 +727,8 @@ describe('compose/service', () => {
.true; .true;
}); });
it('should restart a container when its dependent network mode container changes', () => { it('should restart a container when its dependent network mode container changes', async () => {
const composeSvc = Service.fromComposeObject( const composeSvc = await Service.fromComposeObject(
configs.networkModeService.compose, configs.networkModeService.compose,
configs.networkModeService.imageInfo, configs.networkModeService.imageInfo,
); );

View File

@ -313,7 +313,9 @@ describe('deviceState', () => {
service.image = imageName; service.image = imageName;
(service as any).imageName = imageName; (service as any).imageName = imageName;
services.push( services.push(
Service.fromComposeObject(service, { appName: 'supertest' } as any), await Service.fromComposeObject(service, {
appName: 'supertest',
} as any),
); );
} }

View File

@ -12,13 +12,14 @@ import mockedAPI = require('./lib/mocked-device-api');
import * as applicationManager from '../src/compose/application-manager'; import * as applicationManager from '../src/compose/application-manager';
import { InstancedAppState } from '../src/types/state'; import { InstancedAppState } from '../src/types/state';
import * as apiKeys from '../src/lib/api-keys';
import * as db from '../src/db';
const mockedOptions = { const mockedOptions = {
listenPort: 54321, listenPort: 54321,
timeout: 30000, timeout: 30000,
}; };
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
describe('SupervisorAPI', () => { describe('SupervisorAPI', () => {
let api: SupervisorAPI; let api: SupervisorAPI;
let healthCheckStubs: SinonStub[]; let healthCheckStubs: SinonStub[];
@ -40,6 +41,10 @@ describe('SupervisorAPI', () => {
// Start test API // Start test API
await api.listen(mockedOptions.listenPort, mockedOptions.timeout); await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Create a scoped key
await apiKeys.initialized;
await apiKeys.generateCloudKey();
}); });
after(async () => { after(async () => {
@ -56,6 +61,104 @@ describe('SupervisorAPI', () => {
await mockedAPI.cleanUp(); await mockedAPI.cleanUp();
}); });
describe('API Key Scope', () => {
it('should generate a key which is scoped for a single application', async () => {
// single app scoped key...
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(200);
});
it('should generate a key which is scoped for multiple applications', async () => {
// multi-app scoped key...
const multiAppScopedKey = await apiKeys.generateScopedKey(1, 2, {
scopes: [1, 2].map((appId) => {
return { type: 'app', appId };
}),
});
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${multiAppScopedKey}`)
.expect(200);
await request
.get('/v2/applications/2/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${multiAppScopedKey}`)
.expect(200);
});
it('should generate a key which is scoped for all applications', async () => {
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200);
await request
.get('/v2/applications/2/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200);
});
it('should have a cached lookup of the key scopes to save DB loading', async () => {
const scopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
const key = 'not-a-normal-key';
await db.initialized;
await db
.models('apiSecret')
.update({
key,
})
.where({
key: apiKeys.cloudApiKey,
});
// the key we had is now gone, but the cache should return values
const cachedScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
expect(cachedScopes).to.deep.equal(scopes);
// this should bust the cache...
await apiKeys.generateCloudKey(true);
// the key we changed should be gone now, and the new key should have the cloud scopes
const missingScopes = await apiKeys.getScopesForKey(key);
const freshScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
expect(missingScopes).to.be.null;
expect(freshScopes).to.deep.equal(scopes);
});
it('should regenerate a key and invalidate the old one', async () => {
// single app scoped key...
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(200);
const newScopedKey = await apiKeys.refreshKey(appScopedKey);
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(401);
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${newScopedKey}`)
.expect(200);
});
});
describe('/ping', () => { describe('/ping', () => {
it('responds with OK (without auth)', async () => { it('responds with OK (without auth)', async () => {
await request.get('/ping').set('Accept', 'application/json').expect(200); await request.get('/ping').set('Accept', 'application/json').expect(200);
@ -64,7 +167,7 @@ describe('SupervisorAPI', () => {
await request await request
.get('/ping') .get('/ping')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200); .expect(200);
}); });
}); });
@ -77,7 +180,7 @@ describe('SupervisorAPI', () => {
await request await request
.get('/v1/healthy') .get('/v1/healthy')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.GET['/healthy'].statusCode) .expect(sampleResponses.V1.GET['/healthy'].statusCode)
.then((response) => { .then((response) => {
expect(response.body).to.deep.equal( expect(response.body).to.deep.equal(
@ -138,7 +241,7 @@ describe('SupervisorAPI', () => {
await request await request
.get('/v1/apps/2') .get('/v1/apps/2')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.GET['/apps/2'].statusCode) .expect(sampleResponses.V1.GET['/apps/2'].statusCode)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.then((response) => { .then((response) => {
@ -154,7 +257,7 @@ describe('SupervisorAPI', () => {
await request await request
.post('/v1/apps/2/stop') .post('/v1/apps/2/stop')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode) .expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.then((response) => { .then((response) => {
@ -170,7 +273,7 @@ describe('SupervisorAPI', () => {
const response = await request const response = await request
.get('/v1/device') .get('/v1/device')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200); .expect(200);
expect(response.body).to.have.property('mac_address').that.is.not.empty; expect(response.body).to.have.property('mac_address').that.is.not.empty;
@ -184,7 +287,7 @@ describe('SupervisorAPI', () => {
await request await request
.get('/v2/device/vpn') .get('/v2/device/vpn')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(sampleResponses.V2.GET['/device/vpn'].statusCode) .expect(sampleResponses.V2.GET['/device/vpn'].statusCode)
.then((response) => { .then((response) => {
@ -200,9 +303,9 @@ describe('SupervisorAPI', () => {
await request await request
.get('/v2/applications/1/state') .get('/v2/applications/1/state')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/)
.expect(sampleResponses.V2.GET['/applications/1/state'].statusCode) .expect(sampleResponses.V2.GET['/applications/1/state'].statusCode)
.expect('Content-Type', /json/)
.then((response) => { .then((response) => {
expect(response.body).to.deep.equal( expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/applications/1/state'].body, sampleResponses.V2.GET['/applications/1/state'].body,
@ -214,7 +317,7 @@ describe('SupervisorAPI', () => {
await request await request
.get('/v2/applications/123invalid/state') .get('/v2/applications/123invalid/state')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect( .expect(
sampleResponses.V2.GET['/applications/123invalid/state'].statusCode, sampleResponses.V2.GET['/applications/123invalid/state'].statusCode,
@ -230,8 +333,7 @@ describe('SupervisorAPI', () => {
await request await request
.get('/v2/applications/9000/state') .get('/v2/applications/9000/state')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/)
.expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode) .expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode)
.then((response) => { .then((response) => {
expect(response.body).to.deep.equal( expect(response.body).to.deep.equal(
@ -239,6 +341,17 @@ describe('SupervisorAPI', () => {
); );
}); });
}); });
describe('Scoped API Keys', () => {
it('returns 409 because app is out of scope of the key', async () => {
const apiKey = await apiKeys.generateScopedKey(3, 1);
await request
.get('/v2/applications/2/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKey}`)
.expect(409);
});
});
}); });
// TODO: add tests for rest of V2 endpoints // TODO: add tests for rest of V2 endpoints

View File

@ -2,13 +2,13 @@ import * as supertest from 'supertest';
import SupervisorAPI from '../src/supervisor-api'; import SupervisorAPI from '../src/supervisor-api';
import mockedAPI = require('./lib/mocked-device-api'); import mockedAPI = require('./lib/mocked-device-api');
import { cloudApiKey } from '../src/lib/api-keys';
const mockedOptions = { const mockedOptions = {
listenPort: 12345, listenPort: 12345,
timeout: 30000, timeout: 30000,
}; };
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
const INVALID_SECRET = 'bad_api_secret'; const INVALID_SECRET = 'bad_api_secret';
describe('SupervisorAPI authentication', () => { describe('SupervisorAPI authentication', () => {
@ -39,20 +39,20 @@ describe('SupervisorAPI authentication', () => {
}); });
it('finds apiKey from query', async () => { it('finds apiKey from query', async () => {
return request.post(`/v1/blink?apikey=${VALID_SECRET}`).expect(200); return request.post(`/v1/blink?apikey=${cloudApiKey}`).expect(200);
}); });
it('finds apiKey from Authorization header (ApiKey scheme)', async () => { it('finds apiKey from Authorization header (ApiKey scheme)', async () => {
return request return request
.post('/v1/blink') .post('/v1/blink')
.set('Authorization', `ApiKey ${VALID_SECRET}`) .set('Authorization', `ApiKey ${cloudApiKey}`)
.expect(200); .expect(200);
}); });
it('finds apiKey from Authorization header (Bearer scheme)', async () => { it('finds apiKey from Authorization header (Bearer scheme)', async () => {
return request return request
.post('/v1/blink') .post('/v1/blink')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${cloudApiKey}`)
.expect(200); .expect(200);
}); });
@ -70,7 +70,7 @@ describe('SupervisorAPI authentication', () => {
for (const scheme of randomCases) { for (const scheme of randomCases) {
return request return request
.post('/v1/blink') .post('/v1/blink')
.set('Authorization', `${scheme} ${VALID_SECRET}`) .set('Authorization', `${scheme} ${cloudApiKey}`)
.expect(200); .expect(200);
} }
}); });

View File

@ -45,7 +45,7 @@ function createApp(
); );
} }
function createService( async function createService(
conf: Partial<ServiceComposeConfig>, conf: Partial<ServiceComposeConfig>,
appId = 1, appId = 1,
serviceName = 'test', serviceName = 'test',
@ -54,7 +54,7 @@ function createService(
imageId = 4, imageId = 4,
extraState?: Partial<Service>, extraState?: Partial<Service>,
) { ) {
const svc = Service.fromComposeObject( const svc = await Service.fromComposeObject(
{ {
appId, appId,
serviceName, serviceName,
@ -265,15 +265,15 @@ describe('compose/app', () => {
.that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' }); .that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' });
}); });
it('should kill dependencies of a volume before changing config', () => { it('should kill dependencies of a volume before changing config', async () => {
const current = createApp( const current = createApp(
[createService({ volumes: ['test-volume'] })], [await createService({ volumes: ['test-volume'] })],
[], [],
[Volume.fromComposeObject('test-volume', 1, {})], [Volume.fromComposeObject('test-volume', 1, {})],
false, false,
); );
const target = createApp( const target = createApp(
[createService({ volumes: ['test-volume'] })], [await createService({ volumes: ['test-volume'] })],
[], [],
[ [
Volume.fromComposeObject('test-volume', 1, { Volume.fromComposeObject('test-volume', 1, {
@ -381,14 +381,14 @@ describe('compose/app', () => {
.that.equals('test-network'); .that.equals('test-network');
}); });
it('should kill dependencies of networks before removing', () => { it('should kill dependencies of networks before removing', async () => {
const current = createApp( const current = createApp(
[createService({ networks: { 'test-network': {} } })], [await createService({ networks: { 'test-network': {} } })],
[Network.fromComposeObject('test-network', 1, {})], [Network.fromComposeObject('test-network', 1, {})],
[], [],
false, false,
); );
const target = createApp([createService({})], [], [], true); const target = createApp([await createService({})], [], [], true);
const steps = current.nextStepsForAppUpdate(defaultContext, target); const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('kill', steps); const idx = expectStep('kill', steps);
@ -398,15 +398,15 @@ describe('compose/app', () => {
.that.equals('test'); .that.equals('test');
}); });
it('should kill dependencies of networks before changing config', () => { it('should kill dependencies of networks before changing config', async () => {
const current = createApp( const current = createApp(
[createService({ networks: { 'test-network': {} } })], [await createService({ networks: { 'test-network': {} } })],
[Network.fromComposeObject('test-network', 1, {})], [Network.fromComposeObject('test-network', 1, {})],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({ networks: { 'test-network': {} } })], [await createService({ networks: { 'test-network': {} } })],
[ [
Network.fromComposeObject('test-network', 1, { Network.fromComposeObject('test-network', 1, {
labels: { test: 'test' }, labels: { test: 'test' },
@ -426,8 +426,8 @@ describe('compose/app', () => {
expect(() => expectStep('removeNetwork', steps)).to.throw(); expect(() => expectStep('removeNetwork', steps)).to.throw();
}); });
it('should not output a kill step for a service which is already stopping when changing a volume', () => { it('should not output a kill step for a service which is already stopping when changing a volume', async () => {
const service = createService({ volumes: ['test-volume'] }); const service = await createService({ volumes: ['test-volume'] });
service.status = 'Stopping'; service.status = 'Stopping';
const current = createApp( const current = createApp(
[service], [service],
@ -464,13 +464,16 @@ describe('compose/app', () => {
it('should create a kill step for service which is no longer referenced', async () => { it('should create a kill step for service which is no longer referenced', async () => {
const current = createApp( const current = createApp(
[createService({}, 1, 'main', 1, 1), createService({}, 1, 'aux', 1, 2)], [
await createService({}, 1, 'main', 1, 1),
await createService({}, 1, 'aux', 1, 2),
],
[Network.fromComposeObject('test-network', 1, {})], [Network.fromComposeObject('test-network', 1, {})],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({}, 1, 'main', 2, 1)], [await createService({}, 1, 'main', 2, 1)],
[Network.fromComposeObject('test-network', 1, {})], [Network.fromComposeObject('test-network', 1, {})],
[], [],
true, true,
@ -486,7 +489,7 @@ describe('compose/app', () => {
it('should emit a noop when a service which is no longer referenced is already stopping', async () => { it('should emit a noop when a service which is no longer referenced is already stopping', async () => {
const current = createApp( const current = createApp(
[createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })], [await createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })],
[], [],
[], [],
false, false,
@ -497,15 +500,15 @@ describe('compose/app', () => {
expectStep('noop', steps); expectStep('noop', steps);
}); });
it('should remove a dead container that is still referenced in the target state', () => { it('should remove a dead container that is still referenced in the target state', async () => {
const current = createApp( const current = createApp(
[createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], [await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[], [],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({}, 1, 'main', 1, 1, 1)], [await createService({}, 1, 'main', 1, 1, 1)],
[], [],
[], [],
true, true,
@ -515,9 +518,9 @@ describe('compose/app', () => {
expectStep('remove', steps); expectStep('remove', steps);
}); });
it('should remove a dead container that is not referenced in the target state', () => { it('should remove a dead container that is not referenced in the target state', async () => {
const current = createApp( const current = createApp(
[createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], [await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[], [],
[], [],
false, false,
@ -528,10 +531,10 @@ describe('compose/app', () => {
expectStep('remove', steps); expectStep('remove', steps);
}); });
it('should emit a noop when a service has an image downloading', () => { it('should emit a noop when a service has an image downloading', async () => {
const current = createApp([], [], [], false); const current = createApp([], [], [], false);
const target = createApp( const target = createApp(
[createService({}, 1, 'main', 1, 1, 1)], [await createService({}, 1, 'main', 1, 1, 1)],
[], [],
[], [],
true, true,
@ -544,15 +547,15 @@ describe('compose/app', () => {
expectStep('noop', steps); expectStep('noop', steps);
}); });
it('should emit an updateMetadata step when a service has not changed but the release has', () => { it('should emit an updateMetadata step when a service has not changed but the release has', async () => {
const current = createApp( const current = createApp(
[createService({}, 1, 'main', 1, 1, 1)], [await createService({}, 1, 'main', 1, 1, 1)],
[], [],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({}, 1, 'main', 2, 1, 1)], [await createService({}, 1, 'main', 2, 1, 1)],
[], [],
[], [],
true, true,
@ -562,15 +565,15 @@ describe('compose/app', () => {
expectStep('updateMetadata', steps); expectStep('updateMetadata', steps);
}); });
it('should stop a container which has stoppped as its target', () => { it('should stop a container which has stoppped as its target', async () => {
const current = createApp( const current = createApp(
[createService({}, 1, 'main', 1, 1, 1)], [await createService({}, 1, 'main', 1, 1, 1)],
[], [],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({ running: false }, 1, 'main', 1, 1, 1)], [await createService({ running: false }, 1, 'main', 1, 1, 1)],
[], [],
[], [],
true, true,
@ -580,7 +583,7 @@ describe('compose/app', () => {
expectStep('stop', steps); expectStep('stop', steps);
}); });
it('should recreate a container if the target configuration changes', () => { it('should recreate a container if the target configuration changes', async () => {
const contextWithImages = { const contextWithImages = {
...defaultContext, ...defaultContext,
...{ ...{
@ -598,13 +601,13 @@ describe('compose/app', () => {
}, },
}; };
let current = createApp( let current = createApp(
[createService({}, 1, 'main', 1, 1, 1, {})], [await createService({}, 1, 'main', 1, 1, 1, {})],
[defaultNetwork], [defaultNetwork],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({ privileged: true }, 1, 'main', 1, 1, 1, {})], [await createService({ privileged: true }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork], [defaultNetwork],
[], [],
true, true,
@ -624,7 +627,7 @@ describe('compose/app', () => {
.forTarget((t) => t.serviceName === 'main').to.exist; .forTarget((t) => t.serviceName === 'main').to.exist;
}); });
it('should not start a container when it depends on a service which is being installed', () => { it('should not start a container when it depends on a service which is being installed', async () => {
const mainImage: Image = { const mainImage: Image = {
appId: 1, appId: 1,
dependent: 0, dependent: 0,
@ -651,7 +654,7 @@ describe('compose/app', () => {
try { try {
let current = createApp( let current = createApp(
[ [
createService({ running: false }, 1, 'dep', 1, 2, 2, { await createService({ running: false }, 1, 'dep', 1, 2, 2, {
status: 'Installing', status: 'Installing',
containerId: 'id', containerId: 'id',
}), }),
@ -662,8 +665,8 @@ describe('compose/app', () => {
); );
const target = createApp( const target = createApp(
[ [
createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }), await createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }),
createService({}, 1, 'dep', 1, 2, 2), await createService({}, 1, 'dep', 1, 2, 2),
], ],
[defaultNetwork], [defaultNetwork],
[], [],
@ -681,7 +684,7 @@ describe('compose/app', () => {
// we now make our current state have the 'dep' service as started... // we now make our current state have the 'dep' service as started...
current = createApp( current = createApp(
[createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })], [await createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })],
[defaultNetwork], [defaultNetwork],
[], [],
false, false,
@ -704,10 +707,10 @@ describe('compose/app', () => {
} }
}); });
it('should emit a fetch step when an image has not been downloaded for a service', () => { it('should emit a fetch step when an image has not been downloaded for a service', async () => {
const current = createApp([], [], [], false); const current = createApp([], [], [], false);
const target = createApp( const target = createApp(
[createService({}, 1, 'main', 1, 1, 1)], [await createService({}, 1, 'main', 1, 1, 1)],
[], [],
[], [],
true, true,
@ -717,15 +720,15 @@ describe('compose/app', () => {
withSteps(steps).expectStep('fetch').to.exist; withSteps(steps).expectStep('fetch').to.exist;
}); });
it('should stop a container which has stoppped as its target', () => { it('should stop a container which has stoppped as its target', async () => {
const current = createApp( const current = createApp(
[createService({}, 1, 'main', 1, 1, 1)], [await createService({}, 1, 'main', 1, 1, 1)],
[], [],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({ running: false }, 1, 'main', 1, 1, 1)], [await createService({ running: false }, 1, 'main', 1, 1, 1)],
[], [],
[], [],
true, true,
@ -735,7 +738,7 @@ describe('compose/app', () => {
withSteps(steps).expectStep('stop'); withSteps(steps).expectStep('stop');
}); });
it('should create a start step when all that changes is a running state', () => { it('should create a start step when all that changes is a running state', async () => {
const contextWithImages = { const contextWithImages = {
...defaultContext, ...defaultContext,
...{ ...{
@ -753,13 +756,13 @@ describe('compose/app', () => {
}, },
}; };
const current = createApp( const current = createApp(
[createService({ running: false }, 1, 'main', 1, 1, 1, {})], [await createService({ running: false }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork], [defaultNetwork],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({}, 1, 'main', 1, 1, 1, {})], [await createService({}, 1, 'main', 1, 1, 1, {})],
[defaultNetwork], [defaultNetwork],
[], [],
true, true,
@ -772,7 +775,7 @@ describe('compose/app', () => {
.forTarget((t) => t.serviceName === 'main').to.exist; .forTarget((t) => t.serviceName === 'main').to.exist;
}); });
it('should not infer a fetch step when the download is already in progress', () => { it('should not infer a fetch step when the download is already in progress', async () => {
const contextWithDownloading = { const contextWithDownloading = {
...defaultContext, ...defaultContext,
...{ ...{
@ -781,7 +784,7 @@ describe('compose/app', () => {
}; };
const current = createApp([], [], [], false); const current = createApp([], [], [], false);
const target = createApp( const target = createApp(
[createService({}, 1, 'main', 1, 1, 1)], [await createService({}, 1, 'main', 1, 1, 1)],
[], [],
[], [],
true, true,
@ -791,7 +794,7 @@ describe('compose/app', () => {
withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist; withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist;
}); });
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => { it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
const contextWithImages = { const contextWithImages = {
...defaultContext, ...defaultContext,
...{ ...{
@ -814,14 +817,24 @@ describe('compose/app', () => {
}; };
const current = createApp( const current = createApp(
[createService({ labels, image: 'main-image' }, 1, 'main', 1, 1, 1, {})], [
await createService(
{ labels, image: 'main-image' },
1,
'main',
1,
1,
1,
{},
),
],
[defaultNetwork], [defaultNetwork],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[ [
createService( await createService(
{ labels, image: 'main-image-2' }, { labels, image: 'main-image-2' },
1, 1,
'main', 'main',
@ -854,7 +867,7 @@ describe('compose/app', () => {
.that.equals('main-image-2'); .that.equals('main-image-2');
}); });
it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => { it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
const contextWithImages = { const contextWithImages = {
...defaultContext, ...defaultContext,
...{ ...{
@ -893,10 +906,10 @@ describe('compose/app', () => {
const current = createApp( const current = createApp(
[ [
createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, { await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {
dependsOn: ['dep'], dependsOn: ['dep'],
}), }),
createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}), await createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}),
], ],
[defaultNetwork], [defaultNetwork],
[], [],
@ -904,10 +917,10 @@ describe('compose/app', () => {
); );
const target = createApp( const target = createApp(
[ [
createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, { await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, {
dependsOn: ['dep'], dependsOn: ['dep'],
}), }),
createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}), await createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}),
], ],
[defaultNetwork], [defaultNetwork],
[], [],
@ -918,7 +931,7 @@ describe('compose/app', () => {
withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist; withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist;
}); });
it('should create several kill steps as long as there is no unmet dependencies', () => { it('should create several kill steps as long as there is no unmet dependencies', async () => {
const contextWithImages = { const contextWithImages = {
...defaultContext, ...defaultContext,
...{ ...{
@ -946,13 +959,13 @@ describe('compose/app', () => {
}; };
const current = createApp( const current = createApp(
[createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})], [await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork], [defaultNetwork],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)], [await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)],
[defaultNetwork], [defaultNetwork],
[], [],
true, true,
@ -966,14 +979,14 @@ describe('compose/app', () => {
withSteps(steps).expectStep('kill').forCurrent('main').to.exist; withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
}); });
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => { it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
const labels = { const labels = {
'io.balena.update.strategy': 'kill-then-download', 'io.balena.update.strategy': 'kill-then-download',
}; };
const current = createApp([createService({ labels })], [], [], false); const current = createApp([await createService({ labels })], [], [], false);
const target = createApp( const target = createApp(
[createService({ privileged: true })], [await createService({ privileged: true })],
[], [],
[], [],
true, true,
@ -983,15 +996,15 @@ describe('compose/app', () => {
withSteps(steps).expectStep('kill').forCurrent('main'); withSteps(steps).expectStep('kill').forCurrent('main');
}); });
it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => { it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
const current = createApp( const current = createApp(
[createService({ image: 'image1' })], [await createService({ image: 'image1' })],
[], [],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({ image: 'image2' })], [await createService({ image: 'image2' })],
[], [],
[], [],
true, true,
@ -1002,19 +1015,19 @@ describe('compose/app', () => {
withSteps(steps).rejectStep('kill'); withSteps(steps).rejectStep('kill');
}); });
it('should create several kill steps as long as there is no unmet dependencies', () => { it('should create several kill steps as long as there is no unmet dependencies', async () => {
const current = createApp( const current = createApp(
[ [
createService({}, 1, 'one', 1, 2), await createService({}, 1, 'one', 1, 2),
createService({}, 1, 'two', 1, 3), await createService({}, 1, 'two', 1, 3),
createService({}, 1, 'three', 1, 4), await createService({}, 1, 'three', 1, 4),
], ],
[], [],
[], [],
false, false,
); );
const target = createApp( const target = createApp(
[createService({}, 1, 'three', 1, 4)], [await createService({}, 1, 'three', 1, 4)],
[], [],
[], [],
true, true,
@ -1023,10 +1036,10 @@ describe('compose/app', () => {
const steps = current.nextStepsForAppUpdate(defaultContext, target); const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).expectStep('kill').to.have.length(2); withSteps(steps).expectStep('kill').to.have.length(2);
}); });
it('should not create a service when a network it depends on is not ready', () => { it('should not create a service when a network it depends on is not ready', async () => {
const current = createApp([], [defaultNetwork], [], false); const current = createApp([], [defaultNetwork], [], false);
const target = createApp( const target = createApp(
[createService({ networks: ['test'] }, 1)], [await createService({ networks: ['test'] }, 1)],
[defaultNetwork, Network.fromComposeObject('test', 1, {})], [defaultNetwork, Network.fromComposeObject('test', 1, {})],
[], [],
true, true,

View File

@ -18,7 +18,6 @@ const DB_PATH = './test/data/supervisor-api.sqlite';
// Holds all values used for stubbing // Holds all values used for stubbing
const STUBBED_VALUES = { const STUBBED_VALUES = {
config: { config: {
apiSecret: 'secure_api_secret',
currentCommit: '7fc9c5bea8e361acd49886fe6cc1e1cd', currentCommit: '7fc9c5bea8e361acd49886fe6cc1e1cd',
}, },
services: [ services: [
@ -109,10 +108,7 @@ async function createAPIOpts(): Promise<void> {
async function initConfig(): Promise<void> { async function initConfig(): Promise<void> {
// Initialize this config // Initialize this config
await config.initialized; await config.initialized;
// Set testing secret
await config.set({
apiSecret: STUBBED_VALUES.config.apiSecret,
});
// Set a currentCommit // Set a currentCommit
await config.set({ await config.set({
currentCommit: STUBBED_VALUES.config.currentCommit, currentCommit: STUBBED_VALUES.config.currentCommit,