mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-22 18:22:41 +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
|
"dev": true
|
||||||
},
|
},
|
||||||
"d": {
|
"d": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "http://registry.npmjs.org/d/-/d-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
|
||||||
"integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=",
|
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"es5-ext": "^0.10.9"
|
"es5-ext": "^0.10.50",
|
||||||
|
"type": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashdash": {
|
"dashdash": {
|
||||||
@ -3613,14 +3614,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es5-ext": {
|
"es5-ext": {
|
||||||
"version": "0.10.46",
|
"version": "0.10.53",
|
||||||
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.46.tgz",
|
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
|
||||||
"integrity": "sha512-24XxRvJXNFwEMpJb3nOkiRJKRoupmjYmOPVlI65Qy2SrtxwOTB+g6ODjBKOtwEHbYrhWRty9xxOWLNdClT2djw==",
|
"integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"es6-iterator": "~2.0.3",
|
"es6-iterator": "~2.0.3",
|
||||||
"es6-symbol": "~3.1.1",
|
"es6-symbol": "~3.1.3",
|
||||||
"next-tick": "1"
|
"next-tick": "~1.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next-tick": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
|
||||||
|
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es6-iterator": {
|
"es6-iterator": {
|
||||||
@ -3650,24 +3659,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es6-symbol": {
|
"es6-symbol": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
|
||||||
"integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=",
|
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"d": "1",
|
"d": "^1.0.1",
|
||||||
"es5-ext": "~0.10.14"
|
"ext": "^1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es6-weak-map": {
|
"es6-weak-map": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz",
|
||||||
"integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=",
|
"integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"d": "1",
|
"d": "1",
|
||||||
"es5-ext": "^0.10.14",
|
"es5-ext": "^0.10.46",
|
||||||
"es6-iterator": "^2.0.1",
|
"es6-iterator": "^2.0.3",
|
||||||
"es6-symbol": "^3.1.1"
|
"es6-symbol": "^3.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -3911,6 +3920,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ext": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"type": "^2.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"type": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type/-/type-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"extend": {
|
"extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@ -5363,9 +5389,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"is-promise": {
|
"is-promise": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||||
"integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=",
|
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"is-regexp": {
|
"is-regexp": {
|
||||||
@ -6750,9 +6776,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"next-tick": {
|
"next-tick": {
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||||
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=",
|
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"nice-try": {
|
"nice-try": {
|
||||||
@ -9463,6 +9489,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
|
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
|
||||||
},
|
},
|
||||||
|
"type": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"type-detect": {
|
"type-detect": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz",
|
||||||
|
@ -775,8 +775,9 @@ export class App {
|
|||||||
imageInfo,
|
imageInfo,
|
||||||
serviceName: svc.serviceName,
|
serviceName: svc.serviceName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// FIXME: Typings for DeviceMetadata
|
// FIXME: Typings for DeviceMetadata
|
||||||
return Service.fromComposeObject(
|
return await Service.fromComposeObject(
|
||||||
svc,
|
svc,
|
||||||
(thisSvcOpts as unknown) as DeviceMetadata,
|
(thisSvcOpts as unknown) as DeviceMetadata,
|
||||||
);
|
);
|
||||||
|
@ -100,10 +100,10 @@ export class Service {
|
|||||||
|
|
||||||
// The type here is actually ServiceComposeConfig, except that the
|
// The type here is actually ServiceComposeConfig, except that the
|
||||||
// keys must be camelCase'd first
|
// keys must be camelCase'd first
|
||||||
public static fromComposeObject(
|
public static async fromComposeObject(
|
||||||
appConfig: ConfigMap,
|
appConfig: ConfigMap,
|
||||||
options: DeviceMetadata,
|
options: DeviceMetadata,
|
||||||
): Service {
|
): Promise<Service> {
|
||||||
const service = new Service();
|
const service = new Service();
|
||||||
|
|
||||||
appConfig = ComposeUtils.camelCaseConfig(appConfig);
|
appConfig = ComposeUtils.camelCaseConfig(appConfig);
|
||||||
@ -443,7 +443,7 @@ export class Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mutate service with extra features
|
// Mutate service with extra features
|
||||||
ComposeUtils.addFeaturesFromLabels(service, options);
|
await ComposeUtils.addFeaturesFromLabels(service, options);
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,8 @@ import {
|
|||||||
|
|
||||||
import log from '../lib/supervisor-console';
|
import log from '../lib/supervisor-console';
|
||||||
|
|
||||||
|
import * as apiKeys from '../lib/api-keys';
|
||||||
|
|
||||||
export function camelCaseConfig(
|
export function camelCaseConfig(
|
||||||
literalConfig: ConfigMap,
|
literalConfig: ConfigMap,
|
||||||
): ServiceComposeConfig {
|
): ServiceComposeConfig {
|
||||||
@ -316,10 +318,10 @@ export function dockerDeviceToStr(device: DockerDevice): string {
|
|||||||
// TODO: Export these strings to a constant lib, to
|
// TODO: Export these strings to a constant lib, to
|
||||||
// enable changing them easily
|
// enable changing them easily
|
||||||
// Mutates service
|
// Mutates service
|
||||||
export function addFeaturesFromLabels(
|
export async function addFeaturesFromLabels(
|
||||||
service: Service,
|
service: Service,
|
||||||
options: DeviceMetadata,
|
options: DeviceMetadata,
|
||||||
): void {
|
): Promise<void> {
|
||||||
const setEnvVariables = function (key: string, val: string) {
|
const setEnvVariables = function (key: string, val: string) {
|
||||||
service.config.environment[`RESIN_${key}`] = val;
|
service.config.environment[`RESIN_${key}`] = val;
|
||||||
service.config.environment[`BALENA_${key}`] = val;
|
service.config.environment[`BALENA_${key}`] = val;
|
||||||
@ -356,18 +358,24 @@ export function addFeaturesFromLabels(
|
|||||||
setEnvVariables('API_KEY', options.deviceApiKey);
|
setEnvVariables('API_KEY', options.deviceApiKey);
|
||||||
setEnvVariables('API_URL', options.apiEndpoint);
|
setEnvVariables('API_URL', options.apiEndpoint);
|
||||||
},
|
},
|
||||||
'io.balena.features.supervisor-api': () => {
|
'io.balena.features.supervisor-api': async () => {
|
||||||
|
// create a app/service specific API secret
|
||||||
|
const apiSecret = await apiKeys.generateScopedKey(
|
||||||
|
service.appId,
|
||||||
|
service.serviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const host = (() => {
|
||||||
|
if (service.config.networkMode === 'host') {
|
||||||
|
return '127.0.0.1';
|
||||||
|
} else {
|
||||||
|
service.config.networks[constants.supervisorNetworkInterface] = {};
|
||||||
|
return options.supervisorApiHost;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
setEnvVariables('SUPERVISOR_API_KEY', apiSecret);
|
||||||
setEnvVariables('SUPERVISOR_PORT', options.listenPort.toString());
|
setEnvVariables('SUPERVISOR_PORT', options.listenPort.toString());
|
||||||
setEnvVariables('SUPERVISOR_API_KEY', options.apiSecret);
|
|
||||||
|
|
||||||
let host: string;
|
|
||||||
|
|
||||||
if (service.config.networkMode === 'host') {
|
|
||||||
host = '127.0.0.1';
|
|
||||||
} else {
|
|
||||||
host = options.supervisorApiHost;
|
|
||||||
service.config.networks[constants.supervisorNetworkInterface] = {};
|
|
||||||
}
|
|
||||||
setEnvVariables('SUPERVISOR_HOST', host);
|
setEnvVariables('SUPERVISOR_HOST', host);
|
||||||
setEnvVariables(
|
setEnvVariables(
|
||||||
'SUPERVISOR_ADDRESS',
|
'SUPERVISOR_ADDRESS',
|
||||||
@ -388,11 +396,12 @@ export function addFeaturesFromLabels(
|
|||||||
} as Dockerode.DeviceRequest),
|
} as Dockerode.DeviceRequest),
|
||||||
};
|
};
|
||||||
|
|
||||||
_.each(features, (fn, label) => {
|
for (const feature of Object.keys(features) as [keyof typeof features]) {
|
||||||
if (checkTruthy(service.config.labels[label])) {
|
const fn = features[feature];
|
||||||
fn();
|
if (checkTruthy(service.config.labels[feature])) {
|
||||||
|
await fn();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// This is a special case, and folding it into the
|
// This is a special case, and folding it into the
|
||||||
// structure above would unnecessarily complicate things.
|
// structure above would unnecessarily complicate things.
|
||||||
|
@ -97,7 +97,6 @@ export const fnSchema = {
|
|||||||
'uuid',
|
'uuid',
|
||||||
'listenPort',
|
'listenPort',
|
||||||
'name',
|
'name',
|
||||||
'apiSecret',
|
|
||||||
'apiEndpoint',
|
'apiEndpoint',
|
||||||
'deviceApiKey',
|
'deviceApiKey',
|
||||||
'version',
|
'version',
|
||||||
|
@ -281,14 +281,13 @@ function validateConfigMap<T extends SchemaTypeKey>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateRequiredFields() {
|
export async function generateRequiredFields() {
|
||||||
return getMany(['uuid', 'deviceApiKey', 'apiSecret', 'unmanaged']).then(
|
return getMany(['uuid', 'deviceApiKey', 'unmanaged']).then(
|
||||||
({ uuid, deviceApiKey, apiSecret, unmanaged }) => {
|
({ uuid, deviceApiKey, unmanaged }) => {
|
||||||
// These fields need to be set regardless
|
// These fields need to be set regardless
|
||||||
if (uuid == null || apiSecret == null) {
|
if (uuid == null) {
|
||||||
uuid = uuid || newUniqueKey();
|
uuid = uuid || newUniqueKey();
|
||||||
apiSecret = apiSecret || newUniqueKey();
|
|
||||||
}
|
}
|
||||||
return set({ uuid, apiSecret }).then(() => {
|
return set({ uuid }).then(() => {
|
||||||
if (unmanaged) {
|
if (unmanaged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -88,10 +88,6 @@ export const schemaTypes = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Database types
|
// Database types
|
||||||
apiSecret: {
|
|
||||||
type: t.string,
|
|
||||||
default: NullOrUndefined,
|
|
||||||
},
|
|
||||||
name: {
|
name: {
|
||||||
type: t.string,
|
type: t.string,
|
||||||
default: 'local',
|
default: 'local',
|
||||||
@ -231,7 +227,6 @@ export const schemaTypes = {
|
|||||||
uuid: t.union([t.string, NullOrUndefined]),
|
uuid: t.union([t.string, NullOrUndefined]),
|
||||||
listenPort: PermissiveNumber,
|
listenPort: PermissiveNumber,
|
||||||
name: t.string,
|
name: t.string,
|
||||||
apiSecret: t.union([t.string, NullOrUndefined]),
|
|
||||||
deviceApiKey: t.string,
|
deviceApiKey: t.string,
|
||||||
apiEndpoint: t.string,
|
apiEndpoint: t.string,
|
||||||
version: t.string,
|
version: t.string,
|
||||||
|
@ -85,11 +85,6 @@ export const schema = {
|
|||||||
removeIfNull: false,
|
removeIfNull: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
apiSecret: {
|
|
||||||
source: 'db',
|
|
||||||
mutable: true,
|
|
||||||
removeIfNull: false,
|
|
||||||
},
|
|
||||||
name: {
|
name: {
|
||||||
source: 'db',
|
source: 'db',
|
||||||
mutable: true,
|
mutable: true,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import * as Promise from 'bluebird';
|
import * as Promise from 'bluebird';
|
||||||
|
import * as express from 'express';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import * as eventTracker from '../event-tracker';
|
import * as eventTracker from '../event-tracker';
|
||||||
@ -8,21 +9,37 @@ import { doRestart, doPurge } from './common';
|
|||||||
|
|
||||||
import * as applicationManager from '../compose/application-manager';
|
import * as applicationManager from '../compose/application-manager';
|
||||||
import { generateStep } from '../compose/composition-steps';
|
import { generateStep } from '../compose/composition-steps';
|
||||||
|
import { AuthorizedRequest } from '../lib/api-keys';
|
||||||
|
|
||||||
export const createV1Api = function (router) {
|
export function createV1Api(router: express.Router) {
|
||||||
router.post('/v1/restart', function (req, res, next) {
|
router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
|
||||||
const appId = checkInt(req.body.appId);
|
const appId = checkInt(req.body.appId);
|
||||||
const force = checkTruthy(req.body.force) ?? false;
|
const force = checkTruthy(req.body.force) ?? false;
|
||||||
eventTracker.track('Restart container (v1)', { appId });
|
eventTracker.track('Restart container (v1)', { appId });
|
||||||
if (appId == null) {
|
if (appId == null) {
|
||||||
return res.status(400).send('Missing app id');
|
return res.status(400).send('Missing app id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the case where the appId is out of scope
|
||||||
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
|
res.status(401).json({
|
||||||
|
status: 'failed',
|
||||||
|
message: 'Application is not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return doRestart(appId, force)
|
return doRestart(appId, force)
|
||||||
.then(() => res.status(200).send('OK'))
|
.then(() => res.status(200).send('OK'))
|
||||||
.catch(next);
|
.catch(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
const v1StopOrStart = function (req, res, next, action) {
|
const v1StopOrStart = (
|
||||||
|
req: AuthorizedRequest,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
action: 'start' | 'stop',
|
||||||
|
) => {
|
||||||
const appId = checkInt(req.params.appId);
|
const appId = checkInt(req.params.appId);
|
||||||
const force = checkTruthy(req.body.force) ?? false;
|
const force = checkTruthy(req.body.force) ?? false;
|
||||||
if (appId == null) {
|
if (appId == null) {
|
||||||
@ -47,13 +64,21 @@ export const createV1Api = function (router) {
|
|||||||
'Some v1 endpoints are only allowed on single-container apps',
|
'Some v1 endpoints are only allowed on single-container apps',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check that the request is scoped to cover this application
|
||||||
|
if (!req.auth.isScoped({ apps: [app.appId] })) {
|
||||||
|
return res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
applicationManager.setTargetVolatileForService(service.imageId, {
|
applicationManager.setTargetVolatileForService(service.imageId, {
|
||||||
running: action !== 'stop',
|
running: action !== 'stop',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stopOpts = { wait: true };
|
||||||
|
const step = generateStep(action, { current: service, ...stopOpts });
|
||||||
|
|
||||||
return applicationManager
|
return applicationManager
|
||||||
.executeStep(generateStep(action, { current: service, wait: true }), {
|
.executeStep(step, { force })
|
||||||
force,
|
|
||||||
})
|
|
||||||
.then(function () {
|
.then(function () {
|
||||||
if (action === 'stop') {
|
if (action === 'stop') {
|
||||||
return service;
|
return service;
|
||||||
@ -75,13 +100,13 @@ export const createV1Api = function (router) {
|
|||||||
.catch(next);
|
.catch(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createV1StopOrStartHandler = (action) =>
|
const createV1StopOrStartHandler = (action: 'start' | 'stop') =>
|
||||||
_.partial(v1StopOrStart, _, _, _, action);
|
_.partial(v1StopOrStart, _, _, _, action);
|
||||||
|
|
||||||
router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop'));
|
router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop'));
|
||||||
router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start'));
|
router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start'));
|
||||||
|
|
||||||
router.get('/v1/apps/:appId', function (req, res, next) {
|
router.get('/v1/apps/:appId', (req: AuthorizedRequest, res, next) => {
|
||||||
const appId = checkInt(req.params.appId);
|
const appId = checkInt(req.params.appId);
|
||||||
eventTracker.track('GET app (v1)', { appId });
|
eventTracker.track('GET app (v1)', { appId });
|
||||||
if (appId == null) {
|
if (appId == null) {
|
||||||
@ -96,6 +121,7 @@ export const createV1Api = function (router) {
|
|||||||
if (service == null) {
|
if (service == null) {
|
||||||
return res.status(400).send('App not found');
|
return res.status(400).send('App not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.services.length > 1) {
|
if (app.services.length > 1) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
@ -103,31 +129,50 @@ export const createV1Api = function (router) {
|
|||||||
'Some v1 endpoints are only allowed on single-container apps',
|
'Some v1 endpoints are only allowed on single-container apps',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the case where the appId is out of scope
|
||||||
|
if (!req.auth.isScoped({ apps: [app.appId] })) {
|
||||||
|
res.status(401).json({
|
||||||
|
status: 'failed',
|
||||||
|
message: 'Application is not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't return data that will be of no use to the user
|
// Don't return data that will be of no use to the user
|
||||||
const appToSend = {
|
const appToSend = {
|
||||||
appId,
|
appId,
|
||||||
|
commit: status.commit!,
|
||||||
containerId: service.containerId,
|
containerId: service.containerId,
|
||||||
env: _.omit(service.config.environment, constants.privateAppEnvVars),
|
env: _.omit(service.config.environment, constants.privateAppEnvVars),
|
||||||
releaseId: service.releaseId,
|
|
||||||
imageId: service.config.image,
|
imageId: service.config.image,
|
||||||
|
releaseId: service.releaseId,
|
||||||
};
|
};
|
||||||
if (status.commit != null) {
|
|
||||||
appToSend.commit = status.commit;
|
|
||||||
}
|
|
||||||
return res.json(appToSend);
|
return res.json(appToSend);
|
||||||
},
|
},
|
||||||
).catch(next);
|
).catch(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/v1/purge', function (req, res, next) {
|
router.post('/v1/purge', (req: AuthorizedRequest, res, next) => {
|
||||||
const appId = checkInt(req.body.appId);
|
const appId = checkInt(req.body.appId);
|
||||||
const force = checkTruthy(req.body.force) ?? false;
|
const force = checkTruthy(req.body.force) ?? false;
|
||||||
if (appId == null) {
|
if (appId == null) {
|
||||||
const errMsg = 'Invalid or missing appId';
|
const errMsg = 'Invalid or missing appId';
|
||||||
return res.status(400).send(errMsg);
|
return res.status(400).send(errMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the case where the appId is out of scope
|
||||||
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
|
res.status(401).json({
|
||||||
|
status: 'failed',
|
||||||
|
message: 'Application is not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return doPurge(appId, force)
|
return doPurge(appId, force)
|
||||||
.then(() => res.status(200).json({ Data: 'OK', Error: '' }))
|
.then(() => res.status(200).json({ Data: 'OK', Error: '' }))
|
||||||
.catch(next);
|
.catch(next);
|
||||||
});
|
});
|
||||||
};
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import * as Bluebird from 'bluebird';
|
import * as Bluebird from 'bluebird';
|
||||||
import { NextFunction, Request, Response, Router } from 'express';
|
import { NextFunction, Response, Router } from 'express';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import * as deviceState from '../device-state';
|
import * as deviceState from '../device-state';
|
||||||
@ -30,10 +30,11 @@ import supervisorVersion = require('../lib/supervisor-version');
|
|||||||
import { checkInt, checkTruthy } from '../lib/validation';
|
import { checkInt, checkTruthy } from '../lib/validation';
|
||||||
import { isVPNActive } from '../network';
|
import { isVPNActive } from '../network';
|
||||||
import { doPurge, doRestart, safeStateClone } from './common';
|
import { doPurge, doRestart, safeStateClone } from './common';
|
||||||
|
import { AuthorizedRequest } from '../lib/api-keys';
|
||||||
|
|
||||||
export function createV2Api(router: Router) {
|
export function createV2Api(router: Router) {
|
||||||
const handleServiceAction = (
|
const handleServiceAction = (
|
||||||
req: Request,
|
req: AuthorizedRequest,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
action: CompositionStepAction,
|
action: CompositionStepAction,
|
||||||
@ -48,6 +49,15 @@ export function createV2Api(router: Router) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the case where the appId is out of scope
|
||||||
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
|
res.status(401).json({
|
||||||
|
status: 'failed',
|
||||||
|
message: 'Application is not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return applicationManager.lockingIfNecessary(appId, { force }, () => {
|
return applicationManager.lockingIfNecessary(appId, { force }, () => {
|
||||||
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
||||||
.then(([apps, targetApp]) => {
|
.then(([apps, targetApp]) => {
|
||||||
@ -115,7 +125,7 @@ export function createV2Api(router: Router) {
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/v2/applications/:appId/purge',
|
'/v2/applications/:appId/purge',
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
||||||
const { force } = req.body;
|
const { force } = req.body;
|
||||||
const appId = checkInt(req.params.appId);
|
const appId = checkInt(req.params.appId);
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
@ -125,6 +135,15 @@ export function createV2Api(router: Router) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the case where the application is out of scope
|
||||||
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
|
res.status(401).json({
|
||||||
|
status: 'failed',
|
||||||
|
message: 'Application is not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return doPurge(appId, force)
|
return doPurge(appId, force)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
res.status(200).send('OK');
|
res.status(200).send('OK');
|
||||||
@ -150,7 +169,7 @@ export function createV2Api(router: Router) {
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/v2/applications/:appId/restart',
|
'/v2/applications/:appId/restart',
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
||||||
const { force } = req.body;
|
const { force } = req.body;
|
||||||
const appId = checkInt(req.params.appId);
|
const appId = checkInt(req.params.appId);
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
@ -160,6 +179,15 @@ export function createV2Api(router: Router) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the case where the appId is out of scope
|
||||||
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
|
res.status(401).json({
|
||||||
|
status: 'failed',
|
||||||
|
message: 'Application is not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return doRestart(appId, force)
|
return doRestart(appId, force)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
res.status(200).send('OK');
|
res.status(200).send('OK');
|
||||||
@ -171,7 +199,7 @@ export function createV2Api(router: Router) {
|
|||||||
// TODO: Support dependent applications when this feature is complete
|
// TODO: Support dependent applications when this feature is complete
|
||||||
router.get(
|
router.get(
|
||||||
'/v2/applications/state',
|
'/v2/applications/state',
|
||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
||||||
// It's kinda hacky to access the services and db via the application manager
|
// It's kinda hacky to access the services and db via the application manager
|
||||||
// maybe refactor this code
|
// maybe refactor this code
|
||||||
Bluebird.join(
|
Bluebird.join(
|
||||||
@ -200,44 +228,52 @@ export function createV2Api(router: Router) {
|
|||||||
|
|
||||||
const appNameById: { [id: number]: string } = {};
|
const appNameById: { [id: number]: string } = {};
|
||||||
|
|
||||||
apps.forEach((app) => {
|
// only access scoped apps
|
||||||
const appId = parseInt(app.appId, 10);
|
apps
|
||||||
response[app.name] = {
|
.filter((app) =>
|
||||||
appId,
|
req.auth.isScoped({ apps: [parseInt(app.appId, 10)] }),
|
||||||
commit: app.commit,
|
)
|
||||||
services: {},
|
.forEach((app) => {
|
||||||
};
|
const appId = parseInt(app.appId, 10);
|
||||||
|
response[app.name] = {
|
||||||
|
appId,
|
||||||
|
commit: app.commit,
|
||||||
|
services: {},
|
||||||
|
};
|
||||||
|
|
||||||
appNameById[appId] = app.name;
|
appNameById[appId] = app.name;
|
||||||
});
|
|
||||||
|
|
||||||
imgs.forEach((img) => {
|
|
||||||
const appName = appNameById[img.appId];
|
|
||||||
if (appName == null) {
|
|
||||||
log.warn(
|
|
||||||
`Image found for unknown application!\nImage: ${JSON.stringify(
|
|
||||||
img,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svc = _.find(services, (service: Service) => {
|
|
||||||
return service.imageId === img.imageId;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let status: string | undefined;
|
// only access scoped images
|
||||||
if (svc == null) {
|
imgs
|
||||||
status = img.status;
|
.filter((img) => req.auth.isScoped({ apps: [img.appId] }))
|
||||||
} else {
|
.forEach((img) => {
|
||||||
status = svc.status || img.status;
|
const appName = appNameById[img.appId];
|
||||||
}
|
if (appName == null) {
|
||||||
response[appName].services[img.serviceName] = {
|
log.warn(
|
||||||
status,
|
`Image found for unknown application!\nImage: ${JSON.stringify(
|
||||||
releaseId: img.releaseId,
|
img,
|
||||||
downloadProgress: img.downloadProgress || null,
|
)}`,
|
||||||
};
|
);
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svc = _.find(services, (service: Service) => {
|
||||||
|
return service.imageId === img.imageId;
|
||||||
|
});
|
||||||
|
|
||||||
|
let status: string | undefined;
|
||||||
|
if (svc == null) {
|
||||||
|
status = img.status;
|
||||||
|
} else {
|
||||||
|
status = svc.status || img.status;
|
||||||
|
}
|
||||||
|
response[appName].services[img.serviceName] = {
|
||||||
|
status,
|
||||||
|
releaseId: img.releaseId,
|
||||||
|
downloadProgress: img.downloadProgress || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
},
|
},
|
||||||
@ -247,7 +283,7 @@ export function createV2Api(router: Router) {
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/v2/applications/:appId/state',
|
'/v2/applications/:appId/state',
|
||||||
async (req: Request, res: Response) => {
|
async (req: AuthorizedRequest, res: Response) => {
|
||||||
// Check application ID provided is valid
|
// Check application ID provided is valid
|
||||||
const appId = checkInt(req.params.appId);
|
const appId = checkInt(req.params.appId);
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
@ -256,6 +292,7 @@ export function createV2Api(router: Router) {
|
|||||||
message: `Invalid application ID: ${req.params.appId}`,
|
message: `Invalid application ID: ${req.params.appId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query device for all applications
|
// Query device for all applications
|
||||||
let apps: any;
|
let apps: any;
|
||||||
try {
|
try {
|
||||||
@ -268,12 +305,22 @@ export function createV2Api(router: Router) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Check if the application exists
|
// Check if the application exists
|
||||||
if (!(appId in apps.local)) {
|
if (!(appId in apps.local) || !req.auth.isScoped({ apps: [appId] })) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: `Application ID does not exist: ${appId}`,
|
message: `Application ID does not exist: ${appId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the case where the appId is out of scope
|
||||||
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
|
res.status(401).json({
|
||||||
|
status: 'failed',
|
||||||
|
message: 'Application is not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Filter applications we do not want
|
// Filter applications we do not want
|
||||||
for (const app in apps.local) {
|
for (const app in apps.local) {
|
||||||
if (app !== appId.toString()) {
|
if (app !== appId.toString()) {
|
||||||
@ -380,8 +427,10 @@ export function createV2Api(router: Router) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/v2/containerId', async (req, res) => {
|
router.get('/v2/containerId', async (req: AuthorizedRequest, res) => {
|
||||||
const services = await serviceManager.getAll();
|
const services = (await serviceManager.getAll()).filter((service) =>
|
||||||
|
req.auth.isScoped({ apps: [service.appId] }),
|
||||||
|
);
|
||||||
|
|
||||||
if (req.query.serviceName != null || req.query.service != null) {
|
if (req.query.serviceName != null || req.query.service != null) {
|
||||||
const serviceName = req.query.serviceName || req.query.service;
|
const serviceName = req.query.serviceName || req.query.service;
|
||||||
@ -411,41 +460,45 @@ export function createV2Api(router: Router) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/v2/state/status', async (_req, res) => {
|
router.get('/v2/state/status', async (req: AuthorizedRequest, res) => {
|
||||||
const currentRelease = await config.get('currentCommit');
|
const currentRelease = await config.get('currentCommit');
|
||||||
|
|
||||||
const pending = deviceState.isApplyInProgress();
|
const pending = deviceState.isApplyInProgress();
|
||||||
const containerStates = (await serviceManager.getAll()).map((svc) =>
|
const containerStates = (await serviceManager.getAll())
|
||||||
_.pick(
|
.filter((service) => req.auth.isScoped({ apps: [service.appId] }))
|
||||||
svc,
|
.map((svc) =>
|
||||||
'status',
|
_.pick(
|
||||||
'serviceName',
|
svc,
|
||||||
'appId',
|
'status',
|
||||||
'imageId',
|
'serviceName',
|
||||||
'serviceId',
|
'appId',
|
||||||
'containerId',
|
'imageId',
|
||||||
'createdAt',
|
'serviceId',
|
||||||
),
|
'containerId',
|
||||||
);
|
'createdAt',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let downloadProgressTotal = 0;
|
let downloadProgressTotal = 0;
|
||||||
let downloads = 0;
|
let downloads = 0;
|
||||||
const imagesStates = (await images.getStatus()).map((img) => {
|
const imagesStates = (await images.getStatus())
|
||||||
if (img.downloadProgress != null) {
|
.filter((img) => req.auth.isScoped({ apps: [img.appId] }))
|
||||||
downloadProgressTotal += img.downloadProgress;
|
.map((img) => {
|
||||||
downloads += 1;
|
if (img.downloadProgress != null) {
|
||||||
}
|
downloadProgressTotal += img.downloadProgress;
|
||||||
return _.pick(
|
downloads += 1;
|
||||||
img,
|
}
|
||||||
'name',
|
return _.pick(
|
||||||
'appId',
|
img,
|
||||||
'serviceName',
|
'name',
|
||||||
'imageId',
|
'appId',
|
||||||
'dockerImageId',
|
'serviceName',
|
||||||
'status',
|
'imageId',
|
||||||
'downloadProgress',
|
'dockerImageId',
|
||||||
);
|
'status',
|
||||||
});
|
'downloadProgress',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
let overallDownloadProgress: number | null = null;
|
let overallDownloadProgress: number | null = null;
|
||||||
if (downloads > 0) {
|
if (downloads > 0) {
|
||||||
@ -500,10 +553,15 @@ export function createV2Api(router: Router) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/v2/cleanup-volumes', async (_req, res) => {
|
router.get('/v2/cleanup-volumes', async (req: AuthorizedRequest, res) => {
|
||||||
const targetState = await applicationManager.getTargetApps();
|
const targetState = await applicationManager.getTargetApps();
|
||||||
const referencedVolumes: string[] = [];
|
const referencedVolumes: string[] = [];
|
||||||
_.each(targetState, (app, appId) => {
|
_.each(targetState, (app, appId) => {
|
||||||
|
// if this app isn't in scope of the request, do not cleanup it's volumes
|
||||||
|
if (!req.auth.isScoped({ apps: [parseInt(appId, 10)] })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_.each(app.volumes, (_volume, volumeName) => {
|
_.each(app.volumes, (_volume, volumeName) => {
|
||||||
referencedVolumes.push(
|
referencedVolumes.push(
|
||||||
Volume.generateDockerName(parseInt(appId, 10), volumeName),
|
Volume.generateDockerName(parseInt(appId, 10), volumeName),
|
||||||
|
@ -38,6 +38,7 @@ import {
|
|||||||
InstancedAppState,
|
InstancedAppState,
|
||||||
} from './types/state';
|
} from './types/state';
|
||||||
import * as dbFormat from './device-state/db-format';
|
import * as dbFormat from './device-state/db-format';
|
||||||
|
import * as apiKeys from './lib/api-keys';
|
||||||
|
|
||||||
function validateLocalState(state: any): asserts state is TargetState['local'] {
|
function validateLocalState(state: any): asserts state is TargetState['local'] {
|
||||||
if (state.name != null) {
|
if (state.name != null) {
|
||||||
@ -265,9 +266,7 @@ export const initialized = (async () => {
|
|||||||
if (changedConfig.loggingEnabled != null) {
|
if (changedConfig.loggingEnabled != null) {
|
||||||
logger.enable(changedConfig.loggingEnabled);
|
logger.enable(changedConfig.loggingEnabled);
|
||||||
}
|
}
|
||||||
if (changedConfig.apiSecret != null) {
|
|
||||||
reportCurrentState({ api_secret: changedConfig.apiSecret });
|
|
||||||
}
|
|
||||||
if (changedConfig.appUpdatePollInterval != null) {
|
if (changedConfig.appUpdatePollInterval != null) {
|
||||||
maxPollTime = changedConfig.appUpdatePollInterval;
|
maxPollTime = changedConfig.appUpdatePollInterval;
|
||||||
}
|
}
|
||||||
@ -346,11 +345,11 @@ async function saveInitialConfig() {
|
|||||||
|
|
||||||
export async function loadInitialState() {
|
export async function loadInitialState() {
|
||||||
await applicationManager.initialized;
|
await applicationManager.initialized;
|
||||||
|
await apiKeys.initialized;
|
||||||
|
|
||||||
const conf = await config.getMany([
|
const conf = await config.getMany([
|
||||||
'initialConfigSaved',
|
'initialConfigSaved',
|
||||||
'listenPort',
|
'listenPort',
|
||||||
'apiSecret',
|
|
||||||
'osVersion',
|
'osVersion',
|
||||||
'osVariant',
|
'osVariant',
|
||||||
'macAddress',
|
'macAddress',
|
||||||
@ -374,7 +373,7 @@ export async function loadInitialState() {
|
|||||||
log.info('Reporting initial state, supervisor version and API info');
|
log.info('Reporting initial state, supervisor version and API info');
|
||||||
reportCurrentState({
|
reportCurrentState({
|
||||||
api_port: conf.listenPort,
|
api_port: conf.listenPort,
|
||||||
api_secret: conf.apiSecret,
|
api_secret: apiKeys.cloudApiKey,
|
||||||
os_version: conf.osVersion,
|
os_version: conf.osVersion,
|
||||||
os_variant: conf.osVariant,
|
os_variant: conf.osVariant,
|
||||||
mac_address: conf.macAddress,
|
mac_address: conf.macAddress,
|
||||||
|
359
src/lib/api-keys.ts
Normal file
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 _ from 'lodash';
|
||||||
import * as morgan from 'morgan';
|
import * as morgan from 'morgan';
|
||||||
|
|
||||||
import * as config from './config';
|
|
||||||
import * as eventTracker from './event-tracker';
|
import * as eventTracker from './event-tracker';
|
||||||
import blink = require('./lib/blink');
|
import blink = require('./lib/blink');
|
||||||
|
|
||||||
import log from './lib/supervisor-console';
|
import log from './lib/supervisor-console';
|
||||||
|
import * as apiKeys from './lib/api-keys';
|
||||||
function getKeyFromReq(req: express.Request): string | undefined {
|
import * as deviceState from './device-state';
|
||||||
// Check query for key
|
|
||||||
if (req.query.apikey) {
|
|
||||||
return req.query.apikey;
|
|
||||||
}
|
|
||||||
// Get Authorization header to search for key
|
|
||||||
const authHeader = req.get('Authorization');
|
|
||||||
// Check header for key
|
|
||||||
if (!authHeader) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Check authHeader with various schemes
|
|
||||||
const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i);
|
|
||||||
// Return key from match or undefined
|
|
||||||
return match?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function authenticate(): express.RequestHandler {
|
|
||||||
return async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const conf = await config.getMany([
|
|
||||||
'apiSecret',
|
|
||||||
'localMode',
|
|
||||||
'unmanaged',
|
|
||||||
'osVariant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const needsAuth = conf.unmanaged
|
|
||||||
? conf.osVariant === 'prod'
|
|
||||||
: !conf.localMode;
|
|
||||||
|
|
||||||
if (needsAuth) {
|
|
||||||
// Only get the key if we need it
|
|
||||||
const key = getKeyFromReq(req);
|
|
||||||
if (key && conf.apiSecret && key === conf.apiSecret) {
|
|
||||||
return next();
|
|
||||||
} else {
|
|
||||||
return res.sendStatus(401);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(503).send(`Unexpected error: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const expressLogger = morgan(
|
const expressLogger = morgan(
|
||||||
(tokens, req, res) =>
|
(tokens, req, res) =>
|
||||||
@ -112,7 +65,7 @@ export class SupervisorAPI {
|
|||||||
|
|
||||||
this.api.get('/ping', (_req, res) => res.send('OK'));
|
this.api.get('/ping', (_req, res) => res.send('OK'));
|
||||||
|
|
||||||
this.api.use(authenticate());
|
this.api.use(apiKeys.authMiddleware);
|
||||||
|
|
||||||
this.api.post('/v1/blink', (_req, res) => {
|
this.api.post('/v1/blink', (_req, res) => {
|
||||||
eventTracker.track('Device blink');
|
eventTracker.track('Device blink');
|
||||||
@ -123,11 +76,30 @@ export class SupervisorAPI {
|
|||||||
|
|
||||||
// Expires the supervisor's API key and generates a new one.
|
// Expires the supervisor's API key and generates a new one.
|
||||||
// It also communicates the new key to the balena API.
|
// It also communicates the new key to the balena API.
|
||||||
this.api.post('/v1/regenerate-api-key', async (_req, res) => {
|
this.api.post(
|
||||||
const secret = config.newUniqueKey();
|
'/v1/regenerate-api-key',
|
||||||
await config.set({ apiSecret: secret });
|
async (req: apiKeys.AuthorizedRequest, res) => {
|
||||||
res.status(200).send(secret);
|
await deviceState.initialized;
|
||||||
});
|
await apiKeys.initialized;
|
||||||
|
|
||||||
|
// check if we're updating the cloud API key
|
||||||
|
const updateCloudKey = req.auth.apiKey === apiKeys.cloudApiKey;
|
||||||
|
|
||||||
|
// regenerate the key...
|
||||||
|
const newKey = await apiKeys.refreshKey(req.auth.apiKey);
|
||||||
|
|
||||||
|
// if we need to update the cloud API with our new key
|
||||||
|
if (updateCloudKey) {
|
||||||
|
// report the new key to the cloud API
|
||||||
|
deviceState.reportCurrentState({
|
||||||
|
api_secret: apiKeys.cloudApiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// return the value of the new key to the caller
|
||||||
|
res.status(200).send(newKey);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// And assign all external routers
|
// And assign all external routers
|
||||||
for (const router of this.routers) {
|
for (const router of this.routers) {
|
||||||
@ -135,7 +107,6 @@ export class SupervisorAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Error handling.
|
// Error handling.
|
||||||
|
|
||||||
const messageFromError = (err?: Error | string | null): string => {
|
const messageFromError = (err?: Error | string | null): string => {
|
||||||
let message = 'Unknown error';
|
let message = 'Unknown error';
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
|
@ -19,7 +19,6 @@ const startupConfigFields: config.ConfigKey[] = [
|
|||||||
'uuid',
|
'uuid',
|
||||||
'listenPort',
|
'listenPort',
|
||||||
'apiEndpoint',
|
'apiEndpoint',
|
||||||
'apiSecret',
|
|
||||||
'apiTimeout',
|
'apiTimeout',
|
||||||
'unmanaged',
|
'unmanaged',
|
||||||
'deviceApiKey',
|
'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 () => {
|
it('allows deleting a config.json key and returns a default value if none is set', async () => {
|
||||||
await conf.remove('appUpdatePollInterval');
|
await conf.remove('appUpdatePollInterval');
|
||||||
const poll = await conf.get('appUpdatePollInterval');
|
const poll = await conf.get('appUpdatePollInterval');
|
||||||
|
@ -28,7 +28,7 @@ const configs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('compose/service', () => {
|
describe('compose/service', () => {
|
||||||
it('extends environment variables properly', () => {
|
it('extends environment variables properly', async () => {
|
||||||
const extendEnvVarsOpts = {
|
const extendEnvVarsOpts = {
|
||||||
uuid: '1234',
|
uuid: '1234',
|
||||||
appName: 'awesomeApp',
|
appName: 'awesomeApp',
|
||||||
@ -50,7 +50,10 @@ describe('compose/service', () => {
|
|||||||
A_VARIABLE: 'ITS_VALUE',
|
A_VARIABLE: 'ITS_VALUE',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const s = Service.fromComposeObject(service, extendEnvVarsOpts as any);
|
const s = await Service.fromComposeObject(
|
||||||
|
service,
|
||||||
|
extendEnvVarsOpts as any,
|
||||||
|
);
|
||||||
|
|
||||||
expect(s.config.environment).to.deep.equal({
|
expect(s.config.environment).to.deep.equal({
|
||||||
FOO: 'bar',
|
FOO: 'bar',
|
||||||
@ -81,8 +84,8 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the correct default bind mounts', () => {
|
it('returns the correct default bind mounts', async () => {
|
||||||
const s = Service.fromComposeObject(
|
const s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: '1234',
|
appId: '1234',
|
||||||
serviceName: 'foo',
|
serviceName: 'foo',
|
||||||
@ -99,8 +102,8 @@ describe('compose/service', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('produces the correct port bindings and exposed ports', () => {
|
it('produces the correct port bindings and exposed ports', async () => {
|
||||||
const s = Service.fromComposeObject(
|
const s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: '1234',
|
appId: '1234',
|
||||||
serviceName: 'foo',
|
serviceName: 'foo',
|
||||||
@ -155,8 +158,8 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('correctly handles port ranges', () => {
|
it('correctly handles port ranges', async () => {
|
||||||
const s = Service.fromComposeObject(
|
const s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: '1234',
|
appId: '1234',
|
||||||
serviceName: 'foo',
|
serviceName: 'foo',
|
||||||
@ -207,9 +210,9 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly handle large port ranges', function () {
|
it('should correctly handle large port ranges', async function () {
|
||||||
this.timeout(60000);
|
this.timeout(60000);
|
||||||
const s = Service.fromComposeObject(
|
const s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: '1234',
|
appId: '1234',
|
||||||
serviceName: 'foo',
|
serviceName: 'foo',
|
||||||
@ -224,8 +227,8 @@ describe('compose/service', () => {
|
|||||||
expect((s as any).generateExposeAndPorts()).to.not.throw;
|
expect((s as any).generateExposeAndPorts()).to.not.throw;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly report implied exposed ports from portMappings', () => {
|
it('should correctly report implied exposed ports from portMappings', async () => {
|
||||||
const service = Service.fromComposeObject(
|
const service = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 123456,
|
appId: 123456,
|
||||||
serviceId: 123456,
|
serviceId: 123456,
|
||||||
@ -240,8 +243,8 @@ describe('compose/service', () => {
|
|||||||
.that.deep.equals(['80/tcp', '100/tcp']);
|
.that.deep.equals(['80/tcp', '100/tcp']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly handle spaces in volume definitions', () => {
|
it('should correctly handle spaces in volume definitions', async () => {
|
||||||
const service = Service.fromComposeObject(
|
const service = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 123,
|
appId: 123,
|
||||||
serviceId: 123,
|
serviceId: 123,
|
||||||
@ -270,8 +273,8 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Ordered array parameters', () => {
|
describe('Ordered array parameters', () => {
|
||||||
it('Should correctly compare ordered array parameters', () => {
|
it('Should correctly compare ordered array parameters', async () => {
|
||||||
const svc1 = Service.fromComposeObject(
|
const svc1 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -280,7 +283,7 @@ describe('compose/service', () => {
|
|||||||
},
|
},
|
||||||
{ appName: 'test' } as any,
|
{ appName: 'test' } as any,
|
||||||
);
|
);
|
||||||
let svc2 = Service.fromComposeObject(
|
let svc2 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -291,7 +294,7 @@ describe('compose/service', () => {
|
|||||||
);
|
);
|
||||||
assert(svc1.isEqualConfig(svc2, {}));
|
assert(svc1.isEqualConfig(svc2, {}));
|
||||||
|
|
||||||
svc2 = Service.fromComposeObject(
|
svc2 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -303,8 +306,8 @@ describe('compose/service', () => {
|
|||||||
assert(!svc1.isEqualConfig(svc2, {}));
|
assert(!svc1.isEqualConfig(svc2, {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly compare non-ordered array parameters', () => {
|
it('should correctly compare non-ordered array parameters', async () => {
|
||||||
const svc1 = Service.fromComposeObject(
|
const svc1 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -313,7 +316,7 @@ describe('compose/service', () => {
|
|||||||
},
|
},
|
||||||
{ appName: 'test' } as any,
|
{ appName: 'test' } as any,
|
||||||
);
|
);
|
||||||
let svc2 = Service.fromComposeObject(
|
let svc2 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -324,7 +327,7 @@ describe('compose/service', () => {
|
|||||||
);
|
);
|
||||||
assert(svc1.isEqualConfig(svc2, {}));
|
assert(svc1.isEqualConfig(svc2, {}));
|
||||||
|
|
||||||
svc2 = Service.fromComposeObject(
|
svc2 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -336,8 +339,8 @@ describe('compose/service', () => {
|
|||||||
assert(svc1.isEqualConfig(svc2, {}));
|
assert(svc1.isEqualConfig(svc2, {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly compare both ordered and non-ordered array parameters', () => {
|
it('should correctly compare both ordered and non-ordered array parameters', async () => {
|
||||||
const svc1 = Service.fromComposeObject(
|
const svc1 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -347,7 +350,7 @@ describe('compose/service', () => {
|
|||||||
},
|
},
|
||||||
{ appName: 'test' } as any,
|
{ appName: 'test' } as any,
|
||||||
);
|
);
|
||||||
const svc2 = Service.fromComposeObject(
|
const svc2 = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 1,
|
appId: 1,
|
||||||
serviceId: 1,
|
serviceId: 1,
|
||||||
@ -362,8 +365,8 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('parseMemoryNumber()', () => {
|
describe('parseMemoryNumber()', () => {
|
||||||
const makeComposeServiceWithLimit = (memLimit?: string | number) =>
|
const makeComposeServiceWithLimit = async (memLimit?: string | number) =>
|
||||||
Service.fromComposeObject(
|
await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 123456,
|
appId: 123456,
|
||||||
serviceId: 123456,
|
serviceId: 123456,
|
||||||
@ -373,74 +376,82 @@ describe('compose/service', () => {
|
|||||||
{ appName: 'test' } as any,
|
{ appName: 'test' } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
it('should correctly parse memory number strings without a unit', () =>
|
it('should correctly parse memory number strings without a unit', async () =>
|
||||||
expect(makeComposeServiceWithLimit('64').config.memLimit).to.equal(64));
|
expect(
|
||||||
|
(await makeComposeServiceWithLimit('64')).config.memLimit,
|
||||||
|
).to.equal(64));
|
||||||
|
|
||||||
it('should correctly apply the default value', () =>
|
it('should correctly apply the default value', async () =>
|
||||||
expect(makeComposeServiceWithLimit(undefined).config.memLimit).to.equal(
|
expect(
|
||||||
0,
|
(await makeComposeServiceWithLimit(undefined)).config.memLimit,
|
||||||
|
).to.equal(0));
|
||||||
|
|
||||||
|
it('should correctly support parsing numbers as memory limits', async () =>
|
||||||
|
expect((await makeComposeServiceWithLimit(64)).config.memLimit).to.equal(
|
||||||
|
64,
|
||||||
));
|
));
|
||||||
|
|
||||||
it('should correctly support parsing numbers as memory limits', () =>
|
it('should correctly parse memory number strings that use a byte unit', async () => {
|
||||||
expect(makeComposeServiceWithLimit(64).config.memLimit).to.equal(64));
|
expect(
|
||||||
|
(await makeComposeServiceWithLimit('64b')).config.memLimit,
|
||||||
it('should correctly parse memory number strings that use a byte unit', () => {
|
).to.equal(64);
|
||||||
expect(makeComposeServiceWithLimit('64b').config.memLimit).to.equal(64);
|
expect(
|
||||||
expect(makeComposeServiceWithLimit('64B').config.memLimit).to.equal(64);
|
(await makeComposeServiceWithLimit('64B')).config.memLimit,
|
||||||
|
).to.equal(64);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly parse memory number strings that use a kilobyte unit', () => {
|
it('should correctly parse memory number strings that use a kilobyte unit', async () => {
|
||||||
expect(makeComposeServiceWithLimit('64k').config.memLimit).to.equal(
|
expect(
|
||||||
65536,
|
(await makeComposeServiceWithLimit('64k')).config.memLimit,
|
||||||
);
|
).to.equal(65536);
|
||||||
expect(makeComposeServiceWithLimit('64K').config.memLimit).to.equal(
|
expect(
|
||||||
65536,
|
(await makeComposeServiceWithLimit('64K')).config.memLimit,
|
||||||
);
|
).to.equal(65536);
|
||||||
|
|
||||||
expect(makeComposeServiceWithLimit('64kb').config.memLimit).to.equal(
|
expect(
|
||||||
65536,
|
(await makeComposeServiceWithLimit('64kb')).config.memLimit,
|
||||||
);
|
).to.equal(65536);
|
||||||
expect(makeComposeServiceWithLimit('64Kb').config.memLimit).to.equal(
|
expect(
|
||||||
65536,
|
(await makeComposeServiceWithLimit('64Kb')).config.memLimit,
|
||||||
);
|
).to.equal(65536);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly parse memory number strings that use a megabyte unit', () => {
|
it('should correctly parse memory number strings that use a megabyte unit', async () => {
|
||||||
expect(makeComposeServiceWithLimit('64m').config.memLimit).to.equal(
|
expect(
|
||||||
67108864,
|
(await makeComposeServiceWithLimit('64m')).config.memLimit,
|
||||||
);
|
).to.equal(67108864);
|
||||||
expect(makeComposeServiceWithLimit('64M').config.memLimit).to.equal(
|
expect(
|
||||||
67108864,
|
(await makeComposeServiceWithLimit('64M')).config.memLimit,
|
||||||
);
|
).to.equal(67108864);
|
||||||
|
|
||||||
expect(makeComposeServiceWithLimit('64mb').config.memLimit).to.equal(
|
expect(
|
||||||
67108864,
|
(await makeComposeServiceWithLimit('64mb')).config.memLimit,
|
||||||
);
|
).to.equal(67108864);
|
||||||
expect(makeComposeServiceWithLimit('64Mb').config.memLimit).to.equal(
|
expect(
|
||||||
67108864,
|
(await makeComposeServiceWithLimit('64Mb')).config.memLimit,
|
||||||
);
|
).to.equal(67108864);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly parse memory number strings that use a gigabyte unit', () => {
|
it('should correctly parse memory number strings that use a gigabyte unit', async () => {
|
||||||
expect(makeComposeServiceWithLimit('64g').config.memLimit).to.equal(
|
expect(
|
||||||
68719476736,
|
(await makeComposeServiceWithLimit('64g')).config.memLimit,
|
||||||
);
|
).to.equal(68719476736);
|
||||||
expect(makeComposeServiceWithLimit('64G').config.memLimit).to.equal(
|
expect(
|
||||||
68719476736,
|
(await makeComposeServiceWithLimit('64G')).config.memLimit,
|
||||||
);
|
).to.equal(68719476736);
|
||||||
|
|
||||||
expect(makeComposeServiceWithLimit('64gb').config.memLimit).to.equal(
|
expect(
|
||||||
68719476736,
|
(await makeComposeServiceWithLimit('64gb')).config.memLimit,
|
||||||
);
|
).to.equal(68719476736);
|
||||||
expect(makeComposeServiceWithLimit('64Gb').config.memLimit).to.equal(
|
expect(
|
||||||
68719476736,
|
(await makeComposeServiceWithLimit('64Gb')).config.memLimit,
|
||||||
);
|
).to.equal(68719476736);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getWorkingDir', () => {
|
describe('getWorkingDir', () => {
|
||||||
const makeComposeServiceWithWorkdir = (workdir?: string) =>
|
const makeComposeServiceWithWorkdir = async (workdir?: string) =>
|
||||||
Service.fromComposeObject(
|
await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 123456,
|
appId: 123456,
|
||||||
serviceId: 123456,
|
serviceId: 123456,
|
||||||
@ -450,17 +461,20 @@ describe('compose/service', () => {
|
|||||||
{ appName: 'test' } as any,
|
{ appName: 'test' } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
it('should remove a trailing slash', () => {
|
it('should remove a trailing slash', async () => {
|
||||||
expect(
|
expect(
|
||||||
makeComposeServiceWithWorkdir('/usr/src/app/').config.workingDir,
|
(await makeComposeServiceWithWorkdir('/usr/src/app/')).config
|
||||||
|
.workingDir,
|
||||||
).to.equal('/usr/src/app');
|
).to.equal('/usr/src/app');
|
||||||
expect(makeComposeServiceWithWorkdir('/').config.workingDir).to.equal(
|
|
||||||
'/',
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
makeComposeServiceWithWorkdir('/usr/src/app').config.workingDir,
|
(await makeComposeServiceWithWorkdir('/')).config.workingDir,
|
||||||
|
).to.equal('/');
|
||||||
|
expect(
|
||||||
|
(await makeComposeServiceWithWorkdir('/usr/src/app')).config.workingDir,
|
||||||
).to.equal('/usr/src/app');
|
).to.equal('/usr/src/app');
|
||||||
expect(makeComposeServiceWithWorkdir('').config.workingDir).to.equal('');
|
expect(
|
||||||
|
(await makeComposeServiceWithWorkdir('')).config.workingDir,
|
||||||
|
).to.equal('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -469,8 +483,8 @@ describe('compose/service', () => {
|
|||||||
Count: 1,
|
Count: 1,
|
||||||
Capabilities: [['gpu']],
|
Capabilities: [['gpu']],
|
||||||
};
|
};
|
||||||
it('should succeed from compose object', () => {
|
it('should succeed from compose object', async () => {
|
||||||
const s = Service.fromComposeObject(
|
const s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 123,
|
appId: 123,
|
||||||
serviceId: 123,
|
serviceId: 123,
|
||||||
@ -504,8 +518,8 @@ describe('compose/service', () => {
|
|||||||
const omitConfigForComparison = (config: ServiceConfig) =>
|
const omitConfigForComparison = (config: ServiceConfig) =>
|
||||||
_.omit(config, ['running', 'networks']);
|
_.omit(config, ['running', 'networks']);
|
||||||
|
|
||||||
it('should be identical when converting a simple service', () => {
|
it('should be identical when converting a simple service', async () => {
|
||||||
const composeSvc = Service.fromComposeObject(
|
const composeSvc = await Service.fromComposeObject(
|
||||||
configs.simple.compose,
|
configs.simple.compose,
|
||||||
configs.simple.imageInfo,
|
configs.simple.imageInfo,
|
||||||
);
|
);
|
||||||
@ -518,8 +532,8 @@ describe('compose/service', () => {
|
|||||||
expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true;
|
expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly convert formats with a null entrypoint', () => {
|
it('should correctly convert formats with a null entrypoint', async () => {
|
||||||
const composeSvc = Service.fromComposeObject(
|
const composeSvc = await Service.fromComposeObject(
|
||||||
configs.entrypoint.compose,
|
configs.entrypoint.compose,
|
||||||
configs.entrypoint.imageInfo,
|
configs.entrypoint.imageInfo,
|
||||||
);
|
);
|
||||||
@ -533,11 +547,11 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Networks', () => {
|
describe('Networks', () => {
|
||||||
it('should correctly convert networks from compose to docker format', () => {
|
it('should correctly convert networks from compose to docker format', async () => {
|
||||||
const makeComposeServiceWithNetwork = (
|
const makeComposeServiceWithNetwork = async (
|
||||||
networks: ServiceComposeConfig['networks'],
|
networks: ServiceComposeConfig['networks'],
|
||||||
) =>
|
) =>
|
||||||
Service.fromComposeObject(
|
await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: 123456,
|
appId: 123456,
|
||||||
serviceId: 123456,
|
serviceId: 123456,
|
||||||
@ -548,11 +562,13 @@ describe('compose/service', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
makeComposeServiceWithNetwork({
|
(
|
||||||
balena: {
|
await makeComposeServiceWithNetwork({
|
||||||
ipv4Address: '1.2.3.4',
|
balena: {
|
||||||
},
|
ipv4Address: '1.2.3.4',
|
||||||
}).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
},
|
||||||
|
})
|
||||||
|
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
||||||
).to.deep.equal({
|
).to.deep.equal({
|
||||||
EndpointsConfig: {
|
EndpointsConfig: {
|
||||||
'123456_balena': {
|
'123456_balena': {
|
||||||
@ -565,14 +581,16 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
makeComposeServiceWithNetwork({
|
(
|
||||||
balena: {
|
await makeComposeServiceWithNetwork({
|
||||||
aliases: ['test', '1123'],
|
balena: {
|
||||||
ipv4Address: '1.2.3.4',
|
aliases: ['test', '1123'],
|
||||||
ipv6Address: '5.6.7.8',
|
ipv4Address: '1.2.3.4',
|
||||||
linkLocalIps: ['123.123.123'],
|
ipv6Address: '5.6.7.8',
|
||||||
},
|
linkLocalIps: ['123.123.123'],
|
||||||
}).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
},
|
||||||
|
})
|
||||||
|
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
||||||
).to.deep.equal({
|
).to.deep.equal({
|
||||||
EndpointsConfig: {
|
EndpointsConfig: {
|
||||||
'123456_balena': {
|
'123456_balena': {
|
||||||
@ -638,8 +656,8 @@ describe('compose/service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return describe('Network mode=service:', () => {
|
return describe('Network mode=service:', () => {
|
||||||
it('should correctly add a depends_on entry for the service', () => {
|
it('should correctly add a depends_on entry for the service', async () => {
|
||||||
let s = Service.fromComposeObject(
|
let s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: '1234',
|
appId: '1234',
|
||||||
serviceName: 'foo',
|
serviceName: 'foo',
|
||||||
@ -653,7 +671,7 @@ describe('compose/service', () => {
|
|||||||
|
|
||||||
expect(s.dependsOn).to.deep.equal(['test']);
|
expect(s.dependsOn).to.deep.equal(['test']);
|
||||||
|
|
||||||
s = Service.fromComposeObject(
|
s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: '1234',
|
appId: '1234',
|
||||||
serviceName: 'foo',
|
serviceName: 'foo',
|
||||||
@ -669,8 +687,8 @@ describe('compose/service', () => {
|
|||||||
expect(s.dependsOn).to.deep.equal(['another_service', 'test']);
|
expect(s.dependsOn).to.deep.equal(['another_service', 'test']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly convert a network_mode service: to a container:', () => {
|
it('should correctly convert a network_mode service: to a container:', async () => {
|
||||||
const s = Service.fromComposeObject(
|
const s = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId: '1234',
|
appId: '1234',
|
||||||
serviceName: 'foo',
|
serviceName: 'foo',
|
||||||
@ -692,8 +710,8 @@ describe('compose/service', () => {
|
|||||||
.that.equals('container:abcdef');
|
.that.equals('container:abcdef');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not cause a container restart if a service: container has not changed', () => {
|
it('should not cause a container restart if a service: container has not changed', async () => {
|
||||||
const composeSvc = Service.fromComposeObject(
|
const composeSvc = await Service.fromComposeObject(
|
||||||
configs.networkModeService.compose,
|
configs.networkModeService.compose,
|
||||||
configs.networkModeService.imageInfo,
|
configs.networkModeService.imageInfo,
|
||||||
);
|
);
|
||||||
@ -709,8 +727,8 @@ describe('compose/service', () => {
|
|||||||
.true;
|
.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should restart a container when its dependent network mode container changes', () => {
|
it('should restart a container when its dependent network mode container changes', async () => {
|
||||||
const composeSvc = Service.fromComposeObject(
|
const composeSvc = await Service.fromComposeObject(
|
||||||
configs.networkModeService.compose,
|
configs.networkModeService.compose,
|
||||||
configs.networkModeService.imageInfo,
|
configs.networkModeService.imageInfo,
|
||||||
);
|
);
|
||||||
|
@ -313,7 +313,9 @@ describe('deviceState', () => {
|
|||||||
service.image = imageName;
|
service.image = imageName;
|
||||||
(service as any).imageName = imageName;
|
(service as any).imageName = imageName;
|
||||||
services.push(
|
services.push(
|
||||||
Service.fromComposeObject(service, { appName: 'supertest' } as any),
|
await Service.fromComposeObject(service, {
|
||||||
|
appName: 'supertest',
|
||||||
|
} as any),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,13 +12,14 @@ import mockedAPI = require('./lib/mocked-device-api');
|
|||||||
import * as applicationManager from '../src/compose/application-manager';
|
import * as applicationManager from '../src/compose/application-manager';
|
||||||
import { InstancedAppState } from '../src/types/state';
|
import { InstancedAppState } from '../src/types/state';
|
||||||
|
|
||||||
|
import * as apiKeys from '../src/lib/api-keys';
|
||||||
|
import * as db from '../src/db';
|
||||||
|
|
||||||
const mockedOptions = {
|
const mockedOptions = {
|
||||||
listenPort: 54321,
|
listenPort: 54321,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
|
|
||||||
|
|
||||||
describe('SupervisorAPI', () => {
|
describe('SupervisorAPI', () => {
|
||||||
let api: SupervisorAPI;
|
let api: SupervisorAPI;
|
||||||
let healthCheckStubs: SinonStub[];
|
let healthCheckStubs: SinonStub[];
|
||||||
@ -40,6 +41,10 @@ describe('SupervisorAPI', () => {
|
|||||||
|
|
||||||
// Start test API
|
// Start test API
|
||||||
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||||
|
|
||||||
|
// Create a scoped key
|
||||||
|
await apiKeys.initialized;
|
||||||
|
await apiKeys.generateCloudKey();
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@ -56,6 +61,104 @@ describe('SupervisorAPI', () => {
|
|||||||
await mockedAPI.cleanUp();
|
await mockedAPI.cleanUp();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('API Key Scope', () => {
|
||||||
|
it('should generate a key which is scoped for a single application', async () => {
|
||||||
|
// single app scoped key...
|
||||||
|
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/1/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
it('should generate a key which is scoped for multiple applications', async () => {
|
||||||
|
// multi-app scoped key...
|
||||||
|
const multiAppScopedKey = await apiKeys.generateScopedKey(1, 2, {
|
||||||
|
scopes: [1, 2].map((appId) => {
|
||||||
|
return { type: 'app', appId };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/1/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${multiAppScopedKey}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/2/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${multiAppScopedKey}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
it('should generate a key which is scoped for all applications', async () => {
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/1/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/2/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
it('should have a cached lookup of the key scopes to save DB loading', async () => {
|
||||||
|
const scopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
|
||||||
|
|
||||||
|
const key = 'not-a-normal-key';
|
||||||
|
await db.initialized;
|
||||||
|
await db
|
||||||
|
.models('apiSecret')
|
||||||
|
.update({
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
.where({
|
||||||
|
key: apiKeys.cloudApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// the key we had is now gone, but the cache should return values
|
||||||
|
const cachedScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
|
||||||
|
expect(cachedScopes).to.deep.equal(scopes);
|
||||||
|
|
||||||
|
// this should bust the cache...
|
||||||
|
await apiKeys.generateCloudKey(true);
|
||||||
|
|
||||||
|
// the key we changed should be gone now, and the new key should have the cloud scopes
|
||||||
|
const missingScopes = await apiKeys.getScopesForKey(key);
|
||||||
|
const freshScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
|
||||||
|
|
||||||
|
expect(missingScopes).to.be.null;
|
||||||
|
expect(freshScopes).to.deep.equal(scopes);
|
||||||
|
});
|
||||||
|
it('should regenerate a key and invalidate the old one', async () => {
|
||||||
|
// single app scoped key...
|
||||||
|
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/1/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const newScopedKey = await apiKeys.refreshKey(appScopedKey);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/1/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/1/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${newScopedKey}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('/ping', () => {
|
describe('/ping', () => {
|
||||||
it('responds with OK (without auth)', async () => {
|
it('responds with OK (without auth)', async () => {
|
||||||
await request.get('/ping').set('Accept', 'application/json').expect(200);
|
await request.get('/ping').set('Accept', 'application/json').expect(200);
|
||||||
@ -64,7 +167,7 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.get('/ping')
|
.get('/ping')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -77,7 +180,7 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.get('/v1/healthy')
|
.get('/v1/healthy')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect(sampleResponses.V1.GET['/healthy'].statusCode)
|
.expect(sampleResponses.V1.GET['/healthy'].statusCode)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
expect(response.body).to.deep.equal(
|
expect(response.body).to.deep.equal(
|
||||||
@ -138,7 +241,7 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.get('/v1/apps/2')
|
.get('/v1/apps/2')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect(sampleResponses.V1.GET['/apps/2'].statusCode)
|
.expect(sampleResponses.V1.GET['/apps/2'].statusCode)
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@ -154,7 +257,7 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.post('/v1/apps/2/stop')
|
.post('/v1/apps/2/stop')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode)
|
.expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode)
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@ -170,7 +273,7 @@ describe('SupervisorAPI', () => {
|
|||||||
const response = await request
|
const response = await request
|
||||||
.get('/v1/device')
|
.get('/v1/device')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).to.have.property('mac_address').that.is.not.empty;
|
expect(response.body).to.have.property('mac_address').that.is.not.empty;
|
||||||
@ -184,7 +287,7 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.get('/v2/device/vpn')
|
.get('/v2/device/vpn')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(sampleResponses.V2.GET['/device/vpn'].statusCode)
|
.expect(sampleResponses.V2.GET['/device/vpn'].statusCode)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@ -200,9 +303,9 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.get('/v2/applications/1/state')
|
.get('/v2/applications/1/state')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect(sampleResponses.V2.GET['/applications/1/state'].statusCode)
|
.expect(sampleResponses.V2.GET['/applications/1/state'].statusCode)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
expect(response.body).to.deep.equal(
|
expect(response.body).to.deep.equal(
|
||||||
sampleResponses.V2.GET['/applications/1/state'].body,
|
sampleResponses.V2.GET['/applications/1/state'].body,
|
||||||
@ -214,7 +317,7 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.get('/v2/applications/123invalid/state')
|
.get('/v2/applications/123invalid/state')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(
|
.expect(
|
||||||
sampleResponses.V2.GET['/applications/123invalid/state'].statusCode,
|
sampleResponses.V2.GET['/applications/123invalid/state'].statusCode,
|
||||||
@ -230,8 +333,7 @@ describe('SupervisorAPI', () => {
|
|||||||
await request
|
await request
|
||||||
.get('/v2/applications/9000/state')
|
.get('/v2/applications/9000/state')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode)
|
.expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
expect(response.body).to.deep.equal(
|
expect(response.body).to.deep.equal(
|
||||||
@ -239,6 +341,17 @@ describe('SupervisorAPI', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Scoped API Keys', () => {
|
||||||
|
it('returns 409 because app is out of scope of the key', async () => {
|
||||||
|
const apiKey = await apiKeys.generateScopedKey(3, 1);
|
||||||
|
await request
|
||||||
|
.get('/v2/applications/2/state')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKey}`)
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: add tests for rest of V2 endpoints
|
// TODO: add tests for rest of V2 endpoints
|
||||||
|
@ -2,13 +2,13 @@ import * as supertest from 'supertest';
|
|||||||
|
|
||||||
import SupervisorAPI from '../src/supervisor-api';
|
import SupervisorAPI from '../src/supervisor-api';
|
||||||
import mockedAPI = require('./lib/mocked-device-api');
|
import mockedAPI = require('./lib/mocked-device-api');
|
||||||
|
import { cloudApiKey } from '../src/lib/api-keys';
|
||||||
|
|
||||||
const mockedOptions = {
|
const mockedOptions = {
|
||||||
listenPort: 12345,
|
listenPort: 12345,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
|
|
||||||
const INVALID_SECRET = 'bad_api_secret';
|
const INVALID_SECRET = 'bad_api_secret';
|
||||||
|
|
||||||
describe('SupervisorAPI authentication', () => {
|
describe('SupervisorAPI authentication', () => {
|
||||||
@ -39,20 +39,20 @@ describe('SupervisorAPI authentication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('finds apiKey from query', async () => {
|
it('finds apiKey from query', async () => {
|
||||||
return request.post(`/v1/blink?apikey=${VALID_SECRET}`).expect(200);
|
return request.post(`/v1/blink?apikey=${cloudApiKey}`).expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finds apiKey from Authorization header (ApiKey scheme)', async () => {
|
it('finds apiKey from Authorization header (ApiKey scheme)', async () => {
|
||||||
return request
|
return request
|
||||||
.post('/v1/blink')
|
.post('/v1/blink')
|
||||||
.set('Authorization', `ApiKey ${VALID_SECRET}`)
|
.set('Authorization', `ApiKey ${cloudApiKey}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finds apiKey from Authorization header (Bearer scheme)', async () => {
|
it('finds apiKey from Authorization header (Bearer scheme)', async () => {
|
||||||
return request
|
return request
|
||||||
.post('/v1/blink')
|
.post('/v1/blink')
|
||||||
.set('Authorization', `Bearer ${VALID_SECRET}`)
|
.set('Authorization', `Bearer ${cloudApiKey}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ describe('SupervisorAPI authentication', () => {
|
|||||||
for (const scheme of randomCases) {
|
for (const scheme of randomCases) {
|
||||||
return request
|
return request
|
||||||
.post('/v1/blink')
|
.post('/v1/blink')
|
||||||
.set('Authorization', `${scheme} ${VALID_SECRET}`)
|
.set('Authorization', `${scheme} ${cloudApiKey}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -45,7 +45,7 @@ function createApp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createService(
|
async function createService(
|
||||||
conf: Partial<ServiceComposeConfig>,
|
conf: Partial<ServiceComposeConfig>,
|
||||||
appId = 1,
|
appId = 1,
|
||||||
serviceName = 'test',
|
serviceName = 'test',
|
||||||
@ -54,7 +54,7 @@ function createService(
|
|||||||
imageId = 4,
|
imageId = 4,
|
||||||
extraState?: Partial<Service>,
|
extraState?: Partial<Service>,
|
||||||
) {
|
) {
|
||||||
const svc = Service.fromComposeObject(
|
const svc = await Service.fromComposeObject(
|
||||||
{
|
{
|
||||||
appId,
|
appId,
|
||||||
serviceName,
|
serviceName,
|
||||||
@ -265,15 +265,15 @@ describe('compose/app', () => {
|
|||||||
.that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' });
|
.that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should kill dependencies of a volume before changing config', () => {
|
it('should kill dependencies of a volume before changing config', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({ volumes: ['test-volume'] })],
|
[await createService({ volumes: ['test-volume'] })],
|
||||||
[],
|
[],
|
||||||
[Volume.fromComposeObject('test-volume', 1, {})],
|
[Volume.fromComposeObject('test-volume', 1, {})],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ volumes: ['test-volume'] })],
|
[await createService({ volumes: ['test-volume'] })],
|
||||||
[],
|
[],
|
||||||
[
|
[
|
||||||
Volume.fromComposeObject('test-volume', 1, {
|
Volume.fromComposeObject('test-volume', 1, {
|
||||||
@ -381,14 +381,14 @@ describe('compose/app', () => {
|
|||||||
.that.equals('test-network');
|
.that.equals('test-network');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should kill dependencies of networks before removing', () => {
|
it('should kill dependencies of networks before removing', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({ networks: { 'test-network': {} } })],
|
[await createService({ networks: { 'test-network': {} } })],
|
||||||
[Network.fromComposeObject('test-network', 1, {})],
|
[Network.fromComposeObject('test-network', 1, {})],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp([createService({})], [], [], true);
|
const target = createApp([await createService({})], [], [], true);
|
||||||
|
|
||||||
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
||||||
const idx = expectStep('kill', steps);
|
const idx = expectStep('kill', steps);
|
||||||
@ -398,15 +398,15 @@ describe('compose/app', () => {
|
|||||||
.that.equals('test');
|
.that.equals('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should kill dependencies of networks before changing config', () => {
|
it('should kill dependencies of networks before changing config', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({ networks: { 'test-network': {} } })],
|
[await createService({ networks: { 'test-network': {} } })],
|
||||||
[Network.fromComposeObject('test-network', 1, {})],
|
[Network.fromComposeObject('test-network', 1, {})],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ networks: { 'test-network': {} } })],
|
[await createService({ networks: { 'test-network': {} } })],
|
||||||
[
|
[
|
||||||
Network.fromComposeObject('test-network', 1, {
|
Network.fromComposeObject('test-network', 1, {
|
||||||
labels: { test: 'test' },
|
labels: { test: 'test' },
|
||||||
@ -426,8 +426,8 @@ describe('compose/app', () => {
|
|||||||
expect(() => expectStep('removeNetwork', steps)).to.throw();
|
expect(() => expectStep('removeNetwork', steps)).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not output a kill step for a service which is already stopping when changing a volume', () => {
|
it('should not output a kill step for a service which is already stopping when changing a volume', async () => {
|
||||||
const service = createService({ volumes: ['test-volume'] });
|
const service = await createService({ volumes: ['test-volume'] });
|
||||||
service.status = 'Stopping';
|
service.status = 'Stopping';
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[service],
|
[service],
|
||||||
@ -464,13 +464,16 @@ describe('compose/app', () => {
|
|||||||
|
|
||||||
it('should create a kill step for service which is no longer referenced', async () => {
|
it('should create a kill step for service which is no longer referenced', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1), createService({}, 1, 'aux', 1, 2)],
|
[
|
||||||
|
await createService({}, 1, 'main', 1, 1),
|
||||||
|
await createService({}, 1, 'aux', 1, 2),
|
||||||
|
],
|
||||||
[Network.fromComposeObject('test-network', 1, {})],
|
[Network.fromComposeObject('test-network', 1, {})],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'main', 2, 1)],
|
[await createService({}, 1, 'main', 2, 1)],
|
||||||
[Network.fromComposeObject('test-network', 1, {})],
|
[Network.fromComposeObject('test-network', 1, {})],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -486,7 +489,7 @@ describe('compose/app', () => {
|
|||||||
|
|
||||||
it('should emit a noop when a service which is no longer referenced is already stopping', async () => {
|
it('should emit a noop when a service which is no longer referenced is already stopping', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })],
|
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
@ -497,15 +500,15 @@ describe('compose/app', () => {
|
|||||||
expectStep('noop', steps);
|
expectStep('noop', steps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove a dead container that is still referenced in the target state', () => {
|
it('should remove a dead container that is still referenced in the target state', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
|
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1)],
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -515,9 +518,9 @@ describe('compose/app', () => {
|
|||||||
expectStep('remove', steps);
|
expectStep('remove', steps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove a dead container that is not referenced in the target state', () => {
|
it('should remove a dead container that is not referenced in the target state', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
|
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
@ -528,10 +531,10 @@ describe('compose/app', () => {
|
|||||||
expectStep('remove', steps);
|
expectStep('remove', steps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit a noop when a service has an image downloading', () => {
|
it('should emit a noop when a service has an image downloading', async () => {
|
||||||
const current = createApp([], [], [], false);
|
const current = createApp([], [], [], false);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1)],
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -544,15 +547,15 @@ describe('compose/app', () => {
|
|||||||
expectStep('noop', steps);
|
expectStep('noop', steps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit an updateMetadata step when a service has not changed but the release has', () => {
|
it('should emit an updateMetadata step when a service has not changed but the release has', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1)],
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'main', 2, 1, 1)],
|
[await createService({}, 1, 'main', 2, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -562,15 +565,15 @@ describe('compose/app', () => {
|
|||||||
expectStep('updateMetadata', steps);
|
expectStep('updateMetadata', steps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop a container which has stoppped as its target', () => {
|
it('should stop a container which has stoppped as its target', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1)],
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ running: false }, 1, 'main', 1, 1, 1)],
|
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -580,7 +583,7 @@ describe('compose/app', () => {
|
|||||||
expectStep('stop', steps);
|
expectStep('stop', steps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should recreate a container if the target configuration changes', () => {
|
it('should recreate a container if the target configuration changes', async () => {
|
||||||
const contextWithImages = {
|
const contextWithImages = {
|
||||||
...defaultContext,
|
...defaultContext,
|
||||||
...{
|
...{
|
||||||
@ -598,13 +601,13 @@ describe('compose/app', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let current = createApp(
|
let current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1, {})],
|
[await createService({}, 1, 'main', 1, 1, 1, {})],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ privileged: true }, 1, 'main', 1, 1, 1, {})],
|
[await createService({ privileged: true }, 1, 'main', 1, 1, 1, {})],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -624,7 +627,7 @@ describe('compose/app', () => {
|
|||||||
.forTarget((t) => t.serviceName === 'main').to.exist;
|
.forTarget((t) => t.serviceName === 'main').to.exist;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not start a container when it depends on a service which is being installed', () => {
|
it('should not start a container when it depends on a service which is being installed', async () => {
|
||||||
const mainImage: Image = {
|
const mainImage: Image = {
|
||||||
appId: 1,
|
appId: 1,
|
||||||
dependent: 0,
|
dependent: 0,
|
||||||
@ -651,7 +654,7 @@ describe('compose/app', () => {
|
|||||||
try {
|
try {
|
||||||
let current = createApp(
|
let current = createApp(
|
||||||
[
|
[
|
||||||
createService({ running: false }, 1, 'dep', 1, 2, 2, {
|
await createService({ running: false }, 1, 'dep', 1, 2, 2, {
|
||||||
status: 'Installing',
|
status: 'Installing',
|
||||||
containerId: 'id',
|
containerId: 'id',
|
||||||
}),
|
}),
|
||||||
@ -662,8 +665,8 @@ describe('compose/app', () => {
|
|||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[
|
[
|
||||||
createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }),
|
await createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }),
|
||||||
createService({}, 1, 'dep', 1, 2, 2),
|
await createService({}, 1, 'dep', 1, 2, 2),
|
||||||
],
|
],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
@ -681,7 +684,7 @@ describe('compose/app', () => {
|
|||||||
|
|
||||||
// we now make our current state have the 'dep' service as started...
|
// we now make our current state have the 'dep' service as started...
|
||||||
current = createApp(
|
current = createApp(
|
||||||
[createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })],
|
[await createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
@ -704,10 +707,10 @@ describe('compose/app', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit a fetch step when an image has not been downloaded for a service', () => {
|
it('should emit a fetch step when an image has not been downloaded for a service', async () => {
|
||||||
const current = createApp([], [], [], false);
|
const current = createApp([], [], [], false);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1)],
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -717,15 +720,15 @@ describe('compose/app', () => {
|
|||||||
withSteps(steps).expectStep('fetch').to.exist;
|
withSteps(steps).expectStep('fetch').to.exist;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop a container which has stoppped as its target', () => {
|
it('should stop a container which has stoppped as its target', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1)],
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ running: false }, 1, 'main', 1, 1, 1)],
|
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -735,7 +738,7 @@ describe('compose/app', () => {
|
|||||||
withSteps(steps).expectStep('stop');
|
withSteps(steps).expectStep('stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a start step when all that changes is a running state', () => {
|
it('should create a start step when all that changes is a running state', async () => {
|
||||||
const contextWithImages = {
|
const contextWithImages = {
|
||||||
...defaultContext,
|
...defaultContext,
|
||||||
...{
|
...{
|
||||||
@ -753,13 +756,13 @@ describe('compose/app', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({ running: false }, 1, 'main', 1, 1, 1, {})],
|
[await createService({ running: false }, 1, 'main', 1, 1, 1, {})],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1, {})],
|
[await createService({}, 1, 'main', 1, 1, 1, {})],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -772,7 +775,7 @@ describe('compose/app', () => {
|
|||||||
.forTarget((t) => t.serviceName === 'main').to.exist;
|
.forTarget((t) => t.serviceName === 'main').to.exist;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not infer a fetch step when the download is already in progress', () => {
|
it('should not infer a fetch step when the download is already in progress', async () => {
|
||||||
const contextWithDownloading = {
|
const contextWithDownloading = {
|
||||||
...defaultContext,
|
...defaultContext,
|
||||||
...{
|
...{
|
||||||
@ -781,7 +784,7 @@ describe('compose/app', () => {
|
|||||||
};
|
};
|
||||||
const current = createApp([], [], [], false);
|
const current = createApp([], [], [], false);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'main', 1, 1, 1)],
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -791,7 +794,7 @@ describe('compose/app', () => {
|
|||||||
withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist;
|
withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => {
|
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
|
||||||
const contextWithImages = {
|
const contextWithImages = {
|
||||||
...defaultContext,
|
...defaultContext,
|
||||||
...{
|
...{
|
||||||
@ -814,14 +817,24 @@ describe('compose/app', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({ labels, image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
|
[
|
||||||
|
await createService(
|
||||||
|
{ labels, image: 'main-image' },
|
||||||
|
1,
|
||||||
|
'main',
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[
|
[
|
||||||
createService(
|
await createService(
|
||||||
{ labels, image: 'main-image-2' },
|
{ labels, image: 'main-image-2' },
|
||||||
1,
|
1,
|
||||||
'main',
|
'main',
|
||||||
@ -854,7 +867,7 @@ describe('compose/app', () => {
|
|||||||
.that.equals('main-image-2');
|
.that.equals('main-image-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => {
|
it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
|
||||||
const contextWithImages = {
|
const contextWithImages = {
|
||||||
...defaultContext,
|
...defaultContext,
|
||||||
...{
|
...{
|
||||||
@ -893,10 +906,10 @@ describe('compose/app', () => {
|
|||||||
|
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[
|
[
|
||||||
createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {
|
await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {
|
||||||
dependsOn: ['dep'],
|
dependsOn: ['dep'],
|
||||||
}),
|
}),
|
||||||
createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}),
|
await createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}),
|
||||||
],
|
],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
@ -904,10 +917,10 @@ describe('compose/app', () => {
|
|||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[
|
[
|
||||||
createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, {
|
await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, {
|
||||||
dependsOn: ['dep'],
|
dependsOn: ['dep'],
|
||||||
}),
|
}),
|
||||||
createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}),
|
await createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}),
|
||||||
],
|
],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
@ -918,7 +931,7 @@ describe('compose/app', () => {
|
|||||||
withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist;
|
withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create several kill steps as long as there is no unmet dependencies', () => {
|
it('should create several kill steps as long as there is no unmet dependencies', async () => {
|
||||||
const contextWithImages = {
|
const contextWithImages = {
|
||||||
...defaultContext,
|
...defaultContext,
|
||||||
...{
|
...{
|
||||||
@ -946,13 +959,13 @@ describe('compose/app', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
|
[await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)],
|
[await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)],
|
||||||
[defaultNetwork],
|
[defaultNetwork],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -966,14 +979,14 @@ describe('compose/app', () => {
|
|||||||
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
|
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => {
|
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
|
||||||
const labels = {
|
const labels = {
|
||||||
'io.balena.update.strategy': 'kill-then-download',
|
'io.balena.update.strategy': 'kill-then-download',
|
||||||
};
|
};
|
||||||
|
|
||||||
const current = createApp([createService({ labels })], [], [], false);
|
const current = createApp([await createService({ labels })], [], [], false);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ privileged: true })],
|
[await createService({ privileged: true })],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -983,15 +996,15 @@ describe('compose/app', () => {
|
|||||||
withSteps(steps).expectStep('kill').forCurrent('main');
|
withSteps(steps).expectStep('kill').forCurrent('main');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => {
|
it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[createService({ image: 'image1' })],
|
[await createService({ image: 'image1' })],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ image: 'image2' })],
|
[await createService({ image: 'image2' })],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -1002,19 +1015,19 @@ describe('compose/app', () => {
|
|||||||
withSteps(steps).rejectStep('kill');
|
withSteps(steps).rejectStep('kill');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create several kill steps as long as there is no unmet dependencies', () => {
|
it('should create several kill steps as long as there is no unmet dependencies', async () => {
|
||||||
const current = createApp(
|
const current = createApp(
|
||||||
[
|
[
|
||||||
createService({}, 1, 'one', 1, 2),
|
await createService({}, 1, 'one', 1, 2),
|
||||||
createService({}, 1, 'two', 1, 3),
|
await createService({}, 1, 'two', 1, 3),
|
||||||
createService({}, 1, 'three', 1, 4),
|
await createService({}, 1, 'three', 1, 4),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({}, 1, 'three', 1, 4)],
|
[await createService({}, 1, 'three', 1, 4)],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
@ -1023,10 +1036,10 @@ describe('compose/app', () => {
|
|||||||
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
||||||
withSteps(steps).expectStep('kill').to.have.length(2);
|
withSteps(steps).expectStep('kill').to.have.length(2);
|
||||||
});
|
});
|
||||||
it('should not create a service when a network it depends on is not ready', () => {
|
it('should not create a service when a network it depends on is not ready', async () => {
|
||||||
const current = createApp([], [defaultNetwork], [], false);
|
const current = createApp([], [defaultNetwork], [], false);
|
||||||
const target = createApp(
|
const target = createApp(
|
||||||
[createService({ networks: ['test'] }, 1)],
|
[await createService({ networks: ['test'] }, 1)],
|
||||||
[defaultNetwork, Network.fromComposeObject('test', 1, {})],
|
[defaultNetwork, Network.fromComposeObject('test', 1, {})],
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
|
@ -18,7 +18,6 @@ const DB_PATH = './test/data/supervisor-api.sqlite';
|
|||||||
// Holds all values used for stubbing
|
// Holds all values used for stubbing
|
||||||
const STUBBED_VALUES = {
|
const STUBBED_VALUES = {
|
||||||
config: {
|
config: {
|
||||||
apiSecret: 'secure_api_secret',
|
|
||||||
currentCommit: '7fc9c5bea8e361acd49886fe6cc1e1cd',
|
currentCommit: '7fc9c5bea8e361acd49886fe6cc1e1cd',
|
||||||
},
|
},
|
||||||
services: [
|
services: [
|
||||||
@ -109,10 +108,7 @@ async function createAPIOpts(): Promise<void> {
|
|||||||
async function initConfig(): Promise<void> {
|
async function initConfig(): Promise<void> {
|
||||||
// Initialize this config
|
// Initialize this config
|
||||||
await config.initialized;
|
await config.initialized;
|
||||||
// Set testing secret
|
|
||||||
await config.set({
|
|
||||||
apiSecret: STUBBED_VALUES.config.apiSecret,
|
|
||||||
});
|
|
||||||
// Set a currentCommit
|
// Set a currentCommit
|
||||||
await config.set({
|
await config.set({
|
||||||
currentCommit: STUBBED_VALUES.config.currentCommit,
|
currentCommit: STUBBED_VALUES.config.currentCommit,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user