mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-21 02:01:35 +00:00
Merge pull request #1461 from balena-io/add-scoped-api-keys
Implement scoped Supervisor API keys
This commit is contained in:
commit
c563ee0d31
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