Merge pull request #1461 from balena-io/add-scoped-api-keys

Implement scoped Supervisor API keys
This commit is contained in:
bulldozer-balena[bot] 2020-09-18 13:50:08 +00:00 committed by GitHub
commit c563ee0d31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1059 additions and 430 deletions

82
package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -18,6 +18,8 @@ import {
import log from '../lib/supervisor-console';
import * as apiKeys from '../lib/api-keys';
export function camelCaseConfig(
literalConfig: ConfigMap,
): ServiceComposeConfig {
@ -316,10 +318,10 @@ export function dockerDeviceToStr(device: DockerDevice): string {
// TODO: Export these strings to a constant lib, to
// enable changing them easily
// Mutates service
export function addFeaturesFromLabels(
export async function addFeaturesFromLabels(
service: Service,
options: DeviceMetadata,
): void {
): Promise<void> {
const setEnvVariables = function (key: string, val: string) {
service.config.environment[`RESIN_${key}`] = val;
service.config.environment[`BALENA_${key}`] = val;
@ -356,18 +358,24 @@ export function addFeaturesFromLabels(
setEnvVariables('API_KEY', options.deviceApiKey);
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_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_ADDRESS',
@ -388,11 +396,12 @@ export function addFeaturesFromLabels(
} as Dockerode.DeviceRequest),
};
_.each(features, (fn, label) => {
if (checkTruthy(service.config.labels[label])) {
fn();
for (const feature of Object.keys(features) as [keyof typeof features]) {
const fn = features[feature];
if (checkTruthy(service.config.labels[feature])) {
await fn();
}
});
}
// This is a special case, and folding it into the
// structure above would unnecessarily complicate things.

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import * as Promise from 'bluebird';
import * as express from 'express';
import * as _ from 'lodash';
import * as eventTracker from '../event-tracker';
@ -8,21 +9,37 @@ import { doRestart, doPurge } from './common';
import * as applicationManager from '../compose/application-manager';
import { generateStep } from '../compose/composition-steps';
import { AuthorizedRequest } from '../lib/api-keys';
export const createV1Api = function (router) {
router.post('/v1/restart', function (req, res, next) {
export function createV1Api(router: express.Router) {
router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
const appId = checkInt(req.body.appId);
const force = checkTruthy(req.body.force) ?? false;
eventTracker.track('Restart container (v1)', { appId });
if (appId == null) {
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)
.then(() => res.status(200).send('OK'))
.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 force = checkTruthy(req.body.force) ?? false;
if (appId == null) {
@ -47,13 +64,21 @@ export const createV1Api = function (router) {
'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, {
running: action !== 'stop',
});
const stopOpts = { wait: true };
const step = generateStep(action, { current: service, ...stopOpts });
return applicationManager
.executeStep(generateStep(action, { current: service, wait: true }), {
force,
})
.executeStep(step, { force })
.then(function () {
if (action === 'stop') {
return service;
@ -75,13 +100,13 @@ export const createV1Api = function (router) {
.catch(next);
};
const createV1StopOrStartHandler = (action) =>
const createV1StopOrStartHandler = (action: 'start' | 'stop') =>
_.partial(v1StopOrStart, _, _, _, action);
router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop'));
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);
eventTracker.track('GET app (v1)', { appId });
if (appId == null) {
@ -96,6 +121,7 @@ export const createV1Api = function (router) {
if (service == null) {
return res.status(400).send('App not found');
}
if (app.services.length > 1) {
return res
.status(400)
@ -103,31 +129,50 @@ export const createV1Api = function (router) {
'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
const appToSend = {
appId,
commit: status.commit!,
containerId: service.containerId,
env: _.omit(service.config.environment, constants.privateAppEnvVars),
releaseId: service.releaseId,
imageId: service.config.image,
releaseId: service.releaseId,
};
if (status.commit != null) {
appToSend.commit = status.commit;
}
return res.json(appToSend);
},
).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 force = checkTruthy(req.body.force) ?? false;
if (appId == null) {
const errMsg = 'Invalid or missing appId';
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)
.then(() => res.status(200).json({ Data: 'OK', Error: '' }))
.catch(next);
});
};
}

View File

@ -1,5 +1,5 @@
import * as Bluebird from 'bluebird';
import { NextFunction, Request, Response, Router } from 'express';
import { NextFunction, Response, Router } from 'express';
import * as _ from 'lodash';
import * as deviceState from '../device-state';
@ -30,10 +30,11 @@ import supervisorVersion = require('../lib/supervisor-version');
import { checkInt, checkTruthy } from '../lib/validation';
import { isVPNActive } from '../network';
import { doPurge, doRestart, safeStateClone } from './common';
import { AuthorizedRequest } from '../lib/api-keys';
export function createV2Api(router: Router) {
const handleServiceAction = (
req: Request,
req: AuthorizedRequest,
res: Response,
next: NextFunction,
action: CompositionStepAction,
@ -48,6 +49,15 @@ export function createV2Api(router: Router) {
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 Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
.then(([apps, targetApp]) => {
@ -115,7 +125,7 @@ export function createV2Api(router: Router) {
router.post(
'/v2/applications/:appId/purge',
(req: Request, res: Response, next: NextFunction) => {
(req: AuthorizedRequest, res: Response, next: NextFunction) => {
const { force } = req.body;
const appId = checkInt(req.params.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)
.then(() => {
res.status(200).send('OK');
@ -150,7 +169,7 @@ export function createV2Api(router: Router) {
router.post(
'/v2/applications/:appId/restart',
(req: Request, res: Response, next: NextFunction) => {
(req: AuthorizedRequest, res: Response, next: NextFunction) => {
const { force } = req.body;
const appId = checkInt(req.params.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)
.then(() => {
res.status(200).send('OK');
@ -171,7 +199,7 @@ export function createV2Api(router: Router) {
// TODO: Support dependent applications when this feature is complete
router.get(
'/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
// maybe refactor this code
Bluebird.join(
@ -200,44 +228,52 @@ export function createV2Api(router: Router) {
const appNameById: { [id: number]: string } = {};
apps.forEach((app) => {
const appId = parseInt(app.appId, 10);
response[app.name] = {
appId,
commit: app.commit,
services: {},
};
// only access scoped apps
apps
.filter((app) =>
req.auth.isScoped({ apps: [parseInt(app.appId, 10)] }),
)
.forEach((app) => {
const appId = parseInt(app.appId, 10);
response[app.name] = {
appId,
commit: app.commit,
services: {},
};
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;
appNameById[appId] = app.name;
});
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,
};
});
// only access scoped images
imgs
.filter((img) => req.auth.isScoped({ apps: [img.appId] }))
.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;
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);
},
@ -247,7 +283,7 @@ export function createV2Api(router: Router) {
router.get(
'/v2/applications/:appId/state',
async (req: Request, res: Response) => {
async (req: AuthorizedRequest, res: Response) => {
// Check application ID provided is valid
const appId = checkInt(req.params.appId);
if (!appId) {
@ -256,6 +292,7 @@ export function createV2Api(router: Router) {
message: `Invalid application ID: ${req.params.appId}`,
});
}
// Query device for all applications
let apps: any;
try {
@ -268,12 +305,22 @@ export function createV2Api(router: Router) {
});
}
// 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({
status: 'failed',
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
for (const app in apps.local) {
if (app !== appId.toString()) {
@ -380,8 +427,10 @@ export function createV2Api(router: Router) {
});
});
router.get('/v2/containerId', async (req, res) => {
const services = await serviceManager.getAll();
router.get('/v2/containerId', async (req: AuthorizedRequest, res) => {
const services = (await serviceManager.getAll()).filter((service) =>
req.auth.isScoped({ apps: [service.appId] }),
);
if (req.query.serviceName != null || req.query.service != null) {
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 pending = deviceState.isApplyInProgress();
const containerStates = (await serviceManager.getAll()).map((svc) =>
_.pick(
svc,
'status',
'serviceName',
'appId',
'imageId',
'serviceId',
'containerId',
'createdAt',
),
);
const containerStates = (await serviceManager.getAll())
.filter((service) => req.auth.isScoped({ apps: [service.appId] }))
.map((svc) =>
_.pick(
svc,
'status',
'serviceName',
'appId',
'imageId',
'serviceId',
'containerId',
'createdAt',
),
);
let downloadProgressTotal = 0;
let downloads = 0;
const imagesStates = (await images.getStatus()).map((img) => {
if (img.downloadProgress != null) {
downloadProgressTotal += img.downloadProgress;
downloads += 1;
}
return _.pick(
img,
'name',
'appId',
'serviceName',
'imageId',
'dockerImageId',
'status',
'downloadProgress',
);
});
const imagesStates = (await images.getStatus())
.filter((img) => req.auth.isScoped({ apps: [img.appId] }))
.map((img) => {
if (img.downloadProgress != null) {
downloadProgressTotal += img.downloadProgress;
downloads += 1;
}
return _.pick(
img,
'name',
'appId',
'serviceName',
'imageId',
'dockerImageId',
'status',
'downloadProgress',
);
});
let overallDownloadProgress: number | null = null;
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 referencedVolumes: string[] = [];
_.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) => {
referencedVolumes.push(
Volume.generateDockerName(parseInt(appId, 10), volumeName),

View File

@ -38,6 +38,7 @@ import {
InstancedAppState,
} from './types/state';
import * as dbFormat from './device-state/db-format';
import * as apiKeys from './lib/api-keys';
function validateLocalState(state: any): asserts state is TargetState['local'] {
if (state.name != null) {
@ -265,9 +266,7 @@ export const initialized = (async () => {
if (changedConfig.loggingEnabled != null) {
logger.enable(changedConfig.loggingEnabled);
}
if (changedConfig.apiSecret != null) {
reportCurrentState({ api_secret: changedConfig.apiSecret });
}
if (changedConfig.appUpdatePollInterval != null) {
maxPollTime = changedConfig.appUpdatePollInterval;
}
@ -346,11 +345,11 @@ async function saveInitialConfig() {
export async function loadInitialState() {
await applicationManager.initialized;
await apiKeys.initialized;
const conf = await config.getMany([
'initialConfigSaved',
'listenPort',
'apiSecret',
'osVersion',
'osVariant',
'macAddress',
@ -374,7 +373,7 @@ export async function loadInitialState() {
log.info('Reporting initial state, supervisor version and API info');
reportCurrentState({
api_port: conf.listenPort,
api_secret: conf.apiSecret,
api_secret: apiKeys.cloudApiKey,
os_version: conf.osVersion,
os_variant: conf.osVariant,
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 morgan from 'morgan';
import * as config from './config';
import * as eventTracker from './event-tracker';
import blink = require('./lib/blink');
import log from './lib/supervisor-console';
function getKeyFromReq(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];
}
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}`);
}
};
}
import * as apiKeys from './lib/api-keys';
import * as deviceState from './device-state';
const expressLogger = morgan(
(tokens, req, res) =>
@ -112,7 +65,7 @@ export class SupervisorAPI {
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) => {
eventTracker.track('Device blink');
@ -123,11 +76,30 @@ export class SupervisorAPI {
// Expires the supervisor's API key and generates a new one.
// It also communicates the new key to the balena API.
this.api.post('/v1/regenerate-api-key', async (_req, res) => {
const secret = config.newUniqueKey();
await config.set({ apiSecret: secret });
res.status(200).send(secret);
});
this.api.post(
'/v1/regenerate-api-key',
async (req: apiKeys.AuthorizedRequest, res) => {
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
for (const router of this.routers) {
@ -135,7 +107,6 @@ export class SupervisorAPI {
}
// Error handling.
const messageFromError = (err?: Error | string | null): string => {
let message = 'Unknown error';
if (err != null) {

View File

@ -19,7 +19,6 @@ const startupConfigFields: config.ConfigKey[] = [
'uuid',
'listenPort',
'apiEndpoint',
'apiSecret',
'apiTimeout',
'unmanaged',
'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 () => {
await conf.remove('appUpdatePollInterval');
const poll = await conf.get('appUpdatePollInterval');

View File

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

View File

@ -313,7 +313,9 @@ describe('deviceState', () => {
service.image = imageName;
(service as any).imageName = imageName;
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 { InstancedAppState } from '../src/types/state';
import * as apiKeys from '../src/lib/api-keys';
import * as db from '../src/db';
const mockedOptions = {
listenPort: 54321,
timeout: 30000,
};
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
describe('SupervisorAPI', () => {
let api: SupervisorAPI;
let healthCheckStubs: SinonStub[];
@ -40,6 +41,10 @@ describe('SupervisorAPI', () => {
// Start test API
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Create a scoped key
await apiKeys.initialized;
await apiKeys.generateCloudKey();
});
after(async () => {
@ -56,6 +61,104 @@ describe('SupervisorAPI', () => {
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', () => {
it('responds with OK (without auth)', async () => {
await request.get('/ping').set('Accept', 'application/json').expect(200);
@ -64,7 +167,7 @@ describe('SupervisorAPI', () => {
await request
.get('/ping')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200);
});
});
@ -77,7 +180,7 @@ describe('SupervisorAPI', () => {
await request
.get('/v1/healthy')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.GET['/healthy'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
@ -138,7 +241,7 @@ describe('SupervisorAPI', () => {
await request
.get('/v1/apps/2')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.GET['/apps/2'].statusCode)
.expect('Content-Type', /json/)
.then((response) => {
@ -154,7 +257,7 @@ describe('SupervisorAPI', () => {
await request
.post('/v1/apps/2/stop')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode)
.expect('Content-Type', /json/)
.then((response) => {
@ -170,7 +273,7 @@ describe('SupervisorAPI', () => {
const response = await request
.get('/v1/device')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200);
expect(response.body).to.have.property('mac_address').that.is.not.empty;
@ -184,7 +287,7 @@ describe('SupervisorAPI', () => {
await request
.get('/v2/device/vpn')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/)
.expect(sampleResponses.V2.GET['/device/vpn'].statusCode)
.then((response) => {
@ -200,9 +303,9 @@ describe('SupervisorAPI', () => {
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.expect('Content-Type', /json/)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V2.GET['/applications/1/state'].statusCode)
.expect('Content-Type', /json/)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/applications/1/state'].body,
@ -214,7 +317,7 @@ describe('SupervisorAPI', () => {
await request
.get('/v2/applications/123invalid/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/)
.expect(
sampleResponses.V2.GET['/applications/123invalid/state'].statusCode,
@ -230,8 +333,7 @@ describe('SupervisorAPI', () => {
await request
.get('/v2/applications/9000/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.expect('Content-Type', /json/)
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode)
.then((response) => {
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

View File

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

View File

@ -45,7 +45,7 @@ function createApp(
);
}
function createService(
async function createService(
conf: Partial<ServiceComposeConfig>,
appId = 1,
serviceName = 'test',
@ -54,7 +54,7 @@ function createService(
imageId = 4,
extraState?: Partial<Service>,
) {
const svc = Service.fromComposeObject(
const svc = await Service.fromComposeObject(
{
appId,
serviceName,
@ -265,15 +265,15 @@ describe('compose/app', () => {
.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(
[createService({ volumes: ['test-volume'] })],
[await createService({ volumes: ['test-volume'] })],
[],
[Volume.fromComposeObject('test-volume', 1, {})],
false,
);
const target = createApp(
[createService({ volumes: ['test-volume'] })],
[await createService({ volumes: ['test-volume'] })],
[],
[
Volume.fromComposeObject('test-volume', 1, {
@ -381,14 +381,14 @@ describe('compose/app', () => {
.that.equals('test-network');
});
it('should kill dependencies of networks before removing', () => {
it('should kill dependencies of networks before removing', async () => {
const current = createApp(
[createService({ networks: { 'test-network': {} } })],
[await createService({ networks: { 'test-network': {} } })],
[Network.fromComposeObject('test-network', 1, {})],
[],
false,
);
const target = createApp([createService({})], [], [], true);
const target = createApp([await createService({})], [], [], true);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('kill', steps);
@ -398,15 +398,15 @@ describe('compose/app', () => {
.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(
[createService({ networks: { 'test-network': {} } })],
[await createService({ networks: { 'test-network': {} } })],
[Network.fromComposeObject('test-network', 1, {})],
[],
false,
);
const target = createApp(
[createService({ networks: { 'test-network': {} } })],
[await createService({ networks: { 'test-network': {} } })],
[
Network.fromComposeObject('test-network', 1, {
labels: { test: 'test' },
@ -426,8 +426,8 @@ describe('compose/app', () => {
expect(() => expectStep('removeNetwork', steps)).to.throw();
});
it('should not output a kill step for a service which is already stopping when changing a volume', () => {
const service = createService({ volumes: ['test-volume'] });
it('should not output a kill step for a service which is already stopping when changing a volume', async () => {
const service = await createService({ volumes: ['test-volume'] });
service.status = 'Stopping';
const current = createApp(
[service],
@ -464,13 +464,16 @@ describe('compose/app', () => {
it('should create a kill step for service which is no longer referenced', async () => {
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, {})],
[],
false,
);
const target = createApp(
[createService({}, 1, 'main', 2, 1)],
[await createService({}, 1, 'main', 2, 1)],
[Network.fromComposeObject('test-network', 1, {})],
[],
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 () => {
const current = createApp(
[createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })],
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })],
[],
[],
false,
@ -497,15 +500,15 @@ describe('compose/app', () => {
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(
[createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[],
[],
false,
);
const target = createApp(
[createService({}, 1, 'main', 1, 1, 1)],
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
@ -515,9 +518,9 @@ describe('compose/app', () => {
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(
[createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[],
[],
false,
@ -528,10 +531,10 @@ describe('compose/app', () => {
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 target = createApp(
[createService({}, 1, 'main', 1, 1, 1)],
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
@ -544,15 +547,15 @@ describe('compose/app', () => {
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(
[createService({}, 1, 'main', 1, 1, 1)],
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
false,
);
const target = createApp(
[createService({}, 1, 'main', 2, 1, 1)],
[await createService({}, 1, 'main', 2, 1, 1)],
[],
[],
true,
@ -562,15 +565,15 @@ describe('compose/app', () => {
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(
[createService({}, 1, 'main', 1, 1, 1)],
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
false,
);
const target = createApp(
[createService({ running: false }, 1, 'main', 1, 1, 1)],
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
[],
[],
true,
@ -580,7 +583,7 @@ describe('compose/app', () => {
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 = {
...defaultContext,
...{
@ -598,13 +601,13 @@ describe('compose/app', () => {
},
};
let current = createApp(
[createService({}, 1, 'main', 1, 1, 1, {})],
[await createService({}, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
false,
);
const target = createApp(
[createService({ privileged: true }, 1, 'main', 1, 1, 1, {})],
[await createService({ privileged: true }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
true,
@ -624,7 +627,7 @@ describe('compose/app', () => {
.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 = {
appId: 1,
dependent: 0,
@ -651,7 +654,7 @@ describe('compose/app', () => {
try {
let current = createApp(
[
createService({ running: false }, 1, 'dep', 1, 2, 2, {
await createService({ running: false }, 1, 'dep', 1, 2, 2, {
status: 'Installing',
containerId: 'id',
}),
@ -662,8 +665,8 @@ describe('compose/app', () => {
);
const target = createApp(
[
createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }),
createService({}, 1, 'dep', 1, 2, 2),
await createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }),
await createService({}, 1, 'dep', 1, 2, 2),
],
[defaultNetwork],
[],
@ -681,7 +684,7 @@ describe('compose/app', () => {
// we now make our current state have the 'dep' service as started...
current = createApp(
[createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })],
[await createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })],
[defaultNetwork],
[],
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 target = createApp(
[createService({}, 1, 'main', 1, 1, 1)],
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
@ -717,15 +720,15 @@ describe('compose/app', () => {
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(
[createService({}, 1, 'main', 1, 1, 1)],
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
false,
);
const target = createApp(
[createService({ running: false }, 1, 'main', 1, 1, 1)],
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
[],
[],
true,
@ -735,7 +738,7 @@ describe('compose/app', () => {
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 = {
...defaultContext,
...{
@ -753,13 +756,13 @@ describe('compose/app', () => {
},
};
const current = createApp(
[createService({ running: false }, 1, 'main', 1, 1, 1, {})],
[await createService({ running: false }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
false,
);
const target = createApp(
[createService({}, 1, 'main', 1, 1, 1, {})],
[await createService({}, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
true,
@ -772,7 +775,7 @@ describe('compose/app', () => {
.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 = {
...defaultContext,
...{
@ -781,7 +784,7 @@ describe('compose/app', () => {
};
const current = createApp([], [], [], false);
const target = createApp(
[createService({}, 1, 'main', 1, 1, 1)],
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
@ -791,7 +794,7 @@ describe('compose/app', () => {
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 = {
...defaultContext,
...{
@ -814,14 +817,24 @@ describe('compose/app', () => {
};
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],
[],
false,
);
const target = createApp(
[
createService(
await createService(
{ labels, image: 'main-image-2' },
1,
'main',
@ -854,7 +867,7 @@ describe('compose/app', () => {
.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 = {
...defaultContext,
...{
@ -893,10 +906,10 @@ describe('compose/app', () => {
const current = createApp(
[
createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {
await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {
dependsOn: ['dep'],
}),
createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}),
await createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}),
],
[defaultNetwork],
[],
@ -904,10 +917,10 @@ describe('compose/app', () => {
);
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'],
}),
createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}),
await createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}),
],
[defaultNetwork],
[],
@ -918,7 +931,7 @@ describe('compose/app', () => {
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 = {
...defaultContext,
...{
@ -946,13 +959,13 @@ describe('compose/app', () => {
};
const current = createApp(
[createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
[await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
false,
);
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],
[],
true,
@ -966,14 +979,14 @@ describe('compose/app', () => {
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 = {
'io.balena.update.strategy': 'kill-then-download',
};
const current = createApp([createService({ labels })], [], [], false);
const current = createApp([await createService({ labels })], [], [], false);
const target = createApp(
[createService({ privileged: true })],
[await createService({ privileged: true })],
[],
[],
true,
@ -983,15 +996,15 @@ describe('compose/app', () => {
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(
[createService({ image: 'image1' })],
[await createService({ image: 'image1' })],
[],
[],
false,
);
const target = createApp(
[createService({ image: 'image2' })],
[await createService({ image: 'image2' })],
[],
[],
true,
@ -1002,19 +1015,19 @@ describe('compose/app', () => {
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(
[
createService({}, 1, 'one', 1, 2),
createService({}, 1, 'two', 1, 3),
createService({}, 1, 'three', 1, 4),
await createService({}, 1, 'one', 1, 2),
await createService({}, 1, 'two', 1, 3),
await createService({}, 1, 'three', 1, 4),
],
[],
[],
false,
);
const target = createApp(
[createService({}, 1, 'three', 1, 4)],
[await createService({}, 1, 'three', 1, 4)],
[],
[],
true,
@ -1023,10 +1036,10 @@ describe('compose/app', () => {
const steps = current.nextStepsForAppUpdate(defaultContext, target);
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 target = createApp(
[createService({ networks: ['test'] }, 1)],
[await createService({ networks: ['test'] }, 1)],
[defaultNetwork, Network.fromComposeObject('test', 1, {})],
[],
true,

View File

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