mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-05-28 05:14:19 +00:00
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:
parent
7d11e29f85
commit
c08de8701e
82
package-lock.json
generated
82
package-lock.json
generated
@ -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",
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -97,7 +97,6 @@ export const fnSchema = {
|
||||
'uuid',
|
||||
'listenPort',
|
||||
'name',
|
||||
'apiSecret',
|
||||
'apiEndpoint',
|
||||
'deviceApiKey',
|
||||
'version',
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -85,11 +85,6 @@ export const schema = {
|
||||
removeIfNull: false,
|
||||
},
|
||||
|
||||
apiSecret: {
|
||||
source: 'db',
|
||||
mutable: true,
|
||||
removeIfNull: false,
|
||||
},
|
||||
name: {
|
||||
source: 'db',
|
||||
mutable: true,
|
||||
|
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
@ -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),
|
||||
|
@ -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
359
src/lib/api-keys.ts
Normal 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
32
src/migrations/M00005.js
Normal 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'));
|
||||
}
|
@ -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) {
|
||||
|
@ -19,7 +19,6 @@ const startupConfigFields: config.ConfigKey[] = [
|
||||
'uuid',
|
||||
'listenPort',
|
||||
'apiEndpoint',
|
||||
'apiSecret',
|
||||
'apiTimeout',
|
||||
'unmanaged',
|
||||
'deviceApiKey',
|
||||
|
@ -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');
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user