mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-06-18 07:18:14 +00:00
Use executeServiceAction for v1/v2 service action endpoints
This includes: - /v1/apps/:appId/(stop|start) - /v2/applications/:appId/(restart|stop|start)-service Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
@ -9,12 +9,22 @@ import { App } from '../compose/app';
|
|||||||
import * as applicationManager from '../compose/application-manager';
|
import * as applicationManager from '../compose/application-manager';
|
||||||
import * as serviceManager from '../compose/service-manager';
|
import * as serviceManager from '../compose/service-manager';
|
||||||
import * as volumeManager from '../compose/volume-manager';
|
import * as volumeManager from '../compose/volume-manager';
|
||||||
|
import {
|
||||||
|
CompositionStepAction,
|
||||||
|
generateStep,
|
||||||
|
} from '../compose/composition-steps';
|
||||||
|
import { getApp } from '../device-state/db-format';
|
||||||
import log from '../lib/supervisor-console';
|
import log from '../lib/supervisor-console';
|
||||||
import blink = require('../lib/blink');
|
import blink = require('../lib/blink');
|
||||||
import { lock } from '../lib/update-lock';
|
import { lock } from '../lib/update-lock';
|
||||||
import { InternalInconsistencyError } from '../lib/errors';
|
import {
|
||||||
|
InternalInconsistencyError,
|
||||||
|
NotFoundError,
|
||||||
|
BadRequestError,
|
||||||
|
} from '../lib/errors';
|
||||||
|
|
||||||
import type { InstancedDeviceState } from '../types';
|
import type { InstancedDeviceState } from '../types';
|
||||||
|
import * as messages from './messages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run an array of healthchecks, outputting whether all passed or not
|
* Run an array of healthchecks, outputting whether all passed or not
|
||||||
@ -265,3 +275,108 @@ export const doPurge = async (appId: number, force: boolean = false) => {
|
|||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClientError = BadRequestError | NotFoundError;
|
||||||
|
/**
|
||||||
|
* Get the current app by its appId from application manager, handling the
|
||||||
|
* case of app not being found or app not having services. ClientError should be
|
||||||
|
* BadRequestError if querying from a legacy endpoint (v1), otherwise NotFoundError.
|
||||||
|
*/
|
||||||
|
const getCurrentApp = async (
|
||||||
|
appId: number,
|
||||||
|
clientError: new (message: string) => ClientError,
|
||||||
|
) => {
|
||||||
|
const currentApps = await applicationManager.getCurrentApps();
|
||||||
|
const currentApp = currentApps[appId];
|
||||||
|
if (currentApp == null || currentApp.services.length === 0) {
|
||||||
|
// App with given appId doesn't exist, or app doesn't have any services.
|
||||||
|
throw new clientError(messages.appNotFound);
|
||||||
|
}
|
||||||
|
return currentApp;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get service details from a legacy (single-container) app.
|
||||||
|
* Will only return the first service for multi-container apps, so shouldn't
|
||||||
|
* be used for multi-container. The routes that use this, use it to return
|
||||||
|
* the containerId of the service after an action was executed on that service,
|
||||||
|
* in keeping with the existing legacy interface.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - POST /v1/apps/:appId/stop
|
||||||
|
* - POST /v1/apps/:appId/start
|
||||||
|
*/
|
||||||
|
export const getLegacyService = async (appId: number) => {
|
||||||
|
return (await getCurrentApp(appId, BadRequestError)).services[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a composition step action on a service.
|
||||||
|
* isLegacy indicates that the action is being called from a legacy (v1) endpoint,
|
||||||
|
* as a different error code is returned on certain failures to maintain the old interface.
|
||||||
|
* Used by:
|
||||||
|
* - POST /v1/apps/:appId/(stop|start)
|
||||||
|
* - POST /v2/applications/:appId/(restart|stop|start)-service
|
||||||
|
*/
|
||||||
|
export const executeServiceAction = async ({
|
||||||
|
action,
|
||||||
|
appId,
|
||||||
|
serviceName,
|
||||||
|
imageId,
|
||||||
|
force = false,
|
||||||
|
isLegacy = false,
|
||||||
|
}: {
|
||||||
|
action: CompositionStepAction;
|
||||||
|
appId: number;
|
||||||
|
serviceName?: string;
|
||||||
|
imageId?: number;
|
||||||
|
force?: boolean;
|
||||||
|
isLegacy?: boolean;
|
||||||
|
}): Promise<void> => {
|
||||||
|
// Get current and target apps
|
||||||
|
const [currentApp, targetApp] = await Promise.all([
|
||||||
|
getCurrentApp(appId, isLegacy ? BadRequestError : NotFoundError),
|
||||||
|
getApp(appId),
|
||||||
|
]);
|
||||||
|
const isSingleContainer = currentApp.services.length === 1;
|
||||||
|
if (!isSingleContainer && !serviceName && !imageId) {
|
||||||
|
// App is multicontainer but no service parameters were provided
|
||||||
|
throw new BadRequestError(messages.v2ServiceEndpointError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find service in current and target apps
|
||||||
|
const currentService = isSingleContainer
|
||||||
|
? currentApp.services[0]
|
||||||
|
: currentApp.services.find(
|
||||||
|
(s) => s.imageId === imageId || s.serviceName === serviceName,
|
||||||
|
);
|
||||||
|
if (currentService == null) {
|
||||||
|
// Legacy (v1) throws 400 while v2 throws 404, and we have to keep the interface consistent.
|
||||||
|
throw new (isLegacy ? BadRequestError : NotFoundError)(
|
||||||
|
messages.serviceNotFound,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const targetService = targetApp.services.find(
|
||||||
|
(s) =>
|
||||||
|
s.imageId === currentService.imageId ||
|
||||||
|
s.serviceName === currentService.serviceName,
|
||||||
|
);
|
||||||
|
if (targetService == null) {
|
||||||
|
throw new NotFoundError(messages.targetServiceNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set volatile target state
|
||||||
|
applicationManager.setTargetVolatileForService(currentService.imageId, {
|
||||||
|
running: action !== 'stop',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute action on service
|
||||||
|
return await applicationManager.executeStep(
|
||||||
|
generateStep(action, {
|
||||||
|
current: currentService,
|
||||||
|
target: targetService,
|
||||||
|
wait: true,
|
||||||
|
}),
|
||||||
|
{ force },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
export const appNotFoundMessage = `App not found: an app needs to be installed for this endpoint to work.
|
export const appNotFound = `App not found: an app needs to be installed for this endpoint to work.
|
||||||
If you've recently moved this device from another app,
|
If you've recently moved this device from another app,
|
||||||
please push an app and wait for it to be installed first.`;
|
please push an app and wait for it to be installed first.`;
|
||||||
|
|
||||||
export const serviceNotFoundMessage =
|
export const serviceNotFound =
|
||||||
'Service not found, a container must exist for this endpoint to work';
|
'Service not found, a container must exist for this endpoint to work';
|
||||||
|
|
||||||
export const v2ServiceEndpointInputErrorMessage =
|
export const targetServiceNotFound = 'Service does not exist in target release';
|
||||||
'This endpoint requires one of imageId or serviceName';
|
|
||||||
|
export const v2ServiceEndpointError =
|
||||||
|
'serviceName or imageId parameters were not provided or the app is multi-container. Use the v2 service action endpoints.';
|
||||||
|
@ -11,12 +11,15 @@ import * as deviceState from '../device-state';
|
|||||||
import * as constants from '../lib/constants';
|
import * as constants from '../lib/constants';
|
||||||
import { checkInt, checkTruthy } from '../lib/validation';
|
import { checkInt, checkTruthy } from '../lib/validation';
|
||||||
import log from '../lib/supervisor-console';
|
import log from '../lib/supervisor-console';
|
||||||
import { UpdatesLockedError } from '../lib/errors';
|
import {
|
||||||
|
UpdatesLockedError,
|
||||||
|
isNotFoundError,
|
||||||
|
isBadRequestError,
|
||||||
|
} from '../lib/errors';
|
||||||
import * as hostConfig from '../host-config';
|
import * as hostConfig from '../host-config';
|
||||||
import * as applicationManager from '../compose/application-manager';
|
import * as applicationManager from '../compose/application-manager';
|
||||||
import { generateStep } from '../compose/composition-steps';
|
import { CompositionStepAction } from '../compose/composition-steps';
|
||||||
import * as commitStore from '../compose/commit';
|
import * as commitStore from '../compose/commit';
|
||||||
import { getApp } from '../device-state/db-format';
|
|
||||||
import * as TargetState from '../device-state/target-state';
|
import * as TargetState from '../device-state/target-state';
|
||||||
|
|
||||||
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
|
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
|
||||||
@ -33,11 +36,10 @@ router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
|
|||||||
|
|
||||||
// handle the case where the appId is out of scope
|
// handle the case where the appId is out of scope
|
||||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Application is not available',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
@ -46,84 +48,44 @@ router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
|
|||||||
.catch(next);
|
.catch(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
const v1StopOrStart = (
|
const handleLegacyServiceAction = (action: CompositionStepAction) => {
|
||||||
|
return async (
|
||||||
req: AuthorizedRequest,
|
req: AuthorizedRequest,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
next: express.NextFunction,
|
next: express.NextFunction,
|
||||||
action: 'start' | 'stop',
|
|
||||||
) => {
|
) => {
|
||||||
const appId = checkInt(req.params.appId);
|
const appId = checkInt(req.params.appId);
|
||||||
const force = checkTruthy(req.body.force);
|
const force = checkTruthy(req.body.force);
|
||||||
|
|
||||||
if (appId == null) {
|
if (appId == null) {
|
||||||
return res.status(400).send('Missing app id');
|
return res.status(400).send('Invalid app id');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
.then(([apps, targetApp]) => {
|
|
||||||
if (apps[appId] == null) {
|
|
||||||
return res.status(400).send('App not found');
|
|
||||||
}
|
|
||||||
const app = apps[appId];
|
|
||||||
let service = app.services[0];
|
|
||||||
if (service == null) {
|
|
||||||
return res.status(400).send('No services on app');
|
|
||||||
}
|
|
||||||
if (app.services.length > 1) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.send('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');
|
return res.status(401).send('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the service from the target state (as we do in v2)
|
try {
|
||||||
// TODO: what if we want to start a service belonging to the current app?
|
await actions.executeServiceAction({
|
||||||
const targetService = _.find(targetApp.services, {
|
action,
|
||||||
serviceName: service.serviceName,
|
appId,
|
||||||
|
force,
|
||||||
|
isLegacy: true,
|
||||||
});
|
});
|
||||||
|
const service = await actions.getLegacyService(appId);
|
||||||
applicationManager.setTargetVolatileForService(service.imageId, {
|
return res.status(200).send({ containerId: service.containerId });
|
||||||
running: action !== 'stop',
|
} catch (e: unknown) {
|
||||||
});
|
if (isNotFoundError(e) || isBadRequestError(e)) {
|
||||||
|
return res.status(e.statusCode).send(e.statusMessage);
|
||||||
const stopOpts = { wait: true };
|
} else {
|
||||||
const step = generateStep(action, {
|
next(e);
|
||||||
current: service,
|
|
||||||
target: targetService,
|
|
||||||
...stopOpts,
|
|
||||||
});
|
|
||||||
|
|
||||||
return applicationManager
|
|
||||||
.executeStep(step, { force })
|
|
||||||
.then(function () {
|
|
||||||
if (action === 'stop') {
|
|
||||||
return service;
|
|
||||||
}
|
}
|
||||||
// We refresh the container id in case we were starting an app with no container yet
|
|
||||||
return applicationManager.getCurrentApps().then(function (apps2) {
|
|
||||||
const app2 = apps2[appId];
|
|
||||||
service = app2.services[0];
|
|
||||||
if (service == null) {
|
|
||||||
throw new Error('App not found after running action');
|
|
||||||
}
|
}
|
||||||
return service;
|
};
|
||||||
});
|
|
||||||
})
|
|
||||||
.then((service2) =>
|
|
||||||
res.status(200).json({ containerId: service2.containerId }),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(next);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createV1StopOrStartHandler = (action: 'start' | 'stop') =>
|
router.post('/v1/apps/:appId/stop', handleLegacyServiceAction('stop'));
|
||||||
_.partial(v1StopOrStart, _, _, _, action);
|
router.post('/v1/apps/:appId/start', handleLegacyServiceAction('start'));
|
||||||
|
|
||||||
router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop'));
|
|
||||||
router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start'));
|
|
||||||
|
|
||||||
const rebootOrShutdown = async (
|
const rebootOrShutdown = async (
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
@ -166,11 +128,10 @@ router.get('/v1/apps/:appId', async (req: AuthorizedRequest, res, next) => {
|
|||||||
|
|
||||||
// handle the case where the appId is out of scope
|
// handle the case where the appId is out of scope
|
||||||
if (!req.auth.isScoped({ apps: [app.appId] })) {
|
if (!req.auth.isScoped({ apps: [app.appId] })) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Application is not available',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.services.length > 1) {
|
if (app.services.length > 1) {
|
||||||
@ -209,11 +170,10 @@ router.post('/v1/purge', (req: AuthorizedRequest, res, next) => {
|
|||||||
|
|
||||||
// handle the case where the appId is out of scope
|
// handle the case where the appId is out of scope
|
||||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Application is not available',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
@ -6,11 +6,7 @@ import * as _ from 'lodash';
|
|||||||
import * as deviceState from '../device-state';
|
import * as deviceState from '../device-state';
|
||||||
import * as apiBinder from '../api-binder';
|
import * as apiBinder from '../api-binder';
|
||||||
import * as applicationManager from '../compose/application-manager';
|
import * as applicationManager from '../compose/application-manager';
|
||||||
import {
|
import { CompositionStepAction } from '../compose/composition-steps';
|
||||||
CompositionStepAction,
|
|
||||||
generateStep,
|
|
||||||
} from '../compose/composition-steps';
|
|
||||||
import { getApp } from '../device-state/db-format';
|
|
||||||
import { Service } from '../compose/service';
|
import { Service } from '../compose/service';
|
||||||
import Volume from '../compose/volume';
|
import Volume from '../compose/volume';
|
||||||
import * as commitStore from '../compose/commit';
|
import * as commitStore from '../compose/commit';
|
||||||
@ -22,97 +18,82 @@ import * as images from '../compose/images';
|
|||||||
import * as volumeManager from '../compose/volume-manager';
|
import * as volumeManager from '../compose/volume-manager';
|
||||||
import * as serviceManager from '../compose/service-manager';
|
import * as serviceManager from '../compose/service-manager';
|
||||||
import { spawnJournalctl } from '../lib/journald';
|
import { spawnJournalctl } from '../lib/journald';
|
||||||
import {
|
|
||||||
appNotFoundMessage,
|
|
||||||
serviceNotFoundMessage,
|
|
||||||
v2ServiceEndpointInputErrorMessage,
|
|
||||||
} from './messages';
|
|
||||||
import log from '../lib/supervisor-console';
|
import log from '../lib/supervisor-console';
|
||||||
import supervisorVersion = require('../lib/supervisor-version');
|
import supervisorVersion = require('../lib/supervisor-version');
|
||||||
import { checkInt, checkTruthy } from '../lib/validation';
|
import { checkInt, checkString, checkTruthy } from '../lib/validation';
|
||||||
|
import {
|
||||||
|
isNotFoundError,
|
||||||
|
isBadRequestError,
|
||||||
|
BadRequestError,
|
||||||
|
} from '../lib/errors';
|
||||||
import { isVPNActive } from '../network';
|
import { isVPNActive } from '../network';
|
||||||
import * as actions from './actions';
|
|
||||||
import { AuthorizedRequest } from './api-keys';
|
import { AuthorizedRequest } from './api-keys';
|
||||||
import { fromV2TargetState } from '../lib/legacy';
|
import { fromV2TargetState } from '../lib/legacy';
|
||||||
|
import * as actions from './actions';
|
||||||
|
import { v2ServiceEndpointError } from './messages';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
const handleServiceAction = (
|
const handleServiceAction = (action: CompositionStepAction) => {
|
||||||
req: AuthorizedRequest,
|
return async (req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
||||||
res: Response,
|
const [appId, imageId, serviceName, force] = [
|
||||||
next: NextFunction,
|
checkInt(req.params.appId),
|
||||||
action: CompositionStepAction,
|
checkInt(req.body.imageId),
|
||||||
): Resolvable<void> => {
|
checkString(req.body.serviceName),
|
||||||
const { imageId, serviceName, force } = req.body;
|
checkTruthy(req.body.force),
|
||||||
const appId = checkInt(req.params.appId);
|
];
|
||||||
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Missing app id',
|
message: 'Invalid app id',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle the case where the appId is out of scope
|
|
||||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Application is not available',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
try {
|
||||||
.then(([apps, targetApp]) => {
|
if (!serviceName && !imageId) {
|
||||||
const app = apps[appId];
|
throw new BadRequestError(v2ServiceEndpointError);
|
||||||
|
|
||||||
if (app == null) {
|
|
||||||
res.status(404).send(appNotFoundMessage);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work if we have a service name or an image id
|
await actions.executeServiceAction({
|
||||||
if (imageId == null && serviceName == null) {
|
action,
|
||||||
throw new Error(v2ServiceEndpointInputErrorMessage);
|
appId,
|
||||||
}
|
imageId,
|
||||||
|
serviceName,
|
||||||
let service: Service | undefined;
|
|
||||||
let targetService: Service | undefined;
|
|
||||||
if (imageId != null) {
|
|
||||||
service = _.find(app.services, { imageId });
|
|
||||||
targetService = _.find(targetApp.services, { imageId });
|
|
||||||
} else {
|
|
||||||
service = _.find(app.services, { serviceName });
|
|
||||||
targetService = _.find(targetApp.services, { serviceName });
|
|
||||||
}
|
|
||||||
if (service == null) {
|
|
||||||
res.status(404).send(serviceNotFoundMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationManager.setTargetVolatileForService(service.imageId!, {
|
|
||||||
running: action !== 'stop',
|
|
||||||
});
|
|
||||||
return applicationManager
|
|
||||||
.executeStep(
|
|
||||||
generateStep(action, {
|
|
||||||
current: service,
|
|
||||||
target: targetService,
|
|
||||||
wait: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
force,
|
force,
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
res.status(200).send('OK');
|
|
||||||
});
|
});
|
||||||
})
|
return res.status(200).send('OK');
|
||||||
.catch(next);
|
} catch (e: unknown) {
|
||||||
|
if (isNotFoundError(e) || isBadRequestError(e)) {
|
||||||
|
return res.status(e.statusCode).send(e.statusMessage);
|
||||||
|
} else {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createServiceActionHandler = (action: string) =>
|
router.post(
|
||||||
_.partial(handleServiceAction, _, _, _, action);
|
'/v2/applications/:appId/restart-service',
|
||||||
|
handleServiceAction('restart'),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/v2/applications/:appId/stop-service',
|
||||||
|
handleServiceAction('stop'),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/v2/applications/:appId/start-service',
|
||||||
|
handleServiceAction('start'),
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/v2/applications/:appId/purge',
|
'/v2/applications/:appId/purge',
|
||||||
@ -130,7 +111,7 @@ router.post(
|
|||||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Application is not available',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,21 +124,6 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/v2/applications/:appId/restart-service',
|
|
||||||
createServiceActionHandler('restart'),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/v2/applications/:appId/stop-service',
|
|
||||||
createServiceActionHandler('stop'),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/v2/applications/:appId/start-service',
|
|
||||||
createServiceActionHandler('start'),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/v2/applications/:appId/restart',
|
'/v2/applications/:appId/restart',
|
||||||
(req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
(req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
||||||
@ -172,11 +138,10 @@ router.post(
|
|||||||
|
|
||||||
// handle the case where the appId is out of scope
|
// handle the case where the appId is out of scope
|
||||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Application is not available',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
@ -306,11 +271,10 @@ router.get(
|
|||||||
|
|
||||||
// handle the case where the appId is out of scope
|
// handle the case where the appId is out of scope
|
||||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Application is not available',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter applications we do not want
|
// Filter applications we do not want
|
||||||
|
@ -22,17 +22,24 @@ export class StatusError extends Error {
|
|||||||
export const isStatusError = (x: unknown): x is StatusError =>
|
export const isStatusError = (x: unknown): x is StatusError =>
|
||||||
x != null && x instanceof Error && !isNaN((x as any).statusCode);
|
x != null && x instanceof Error && !isNaN((x as any).statusCode);
|
||||||
|
|
||||||
export class NotFoundError extends Error {
|
export class NotFoundError extends StatusError {
|
||||||
public statusCode: number;
|
constructor(statusMessage?: string) {
|
||||||
constructor() {
|
super(404, statusMessage ?? 'Not Found');
|
||||||
super();
|
|
||||||
this.statusCode = 404;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isNotFoundError = (e: unknown): e is NotFoundError =>
|
export const isNotFoundError = (e: unknown): e is NotFoundError =>
|
||||||
isStatusError(e) && e.statusCode === 404;
|
isStatusError(e) && e.statusCode === 404;
|
||||||
|
|
||||||
|
export class BadRequestError extends StatusError {
|
||||||
|
constructor(statusMessage?: string) {
|
||||||
|
super(400, statusMessage ?? 'Bad Request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isBadRequestError = (e: unknown): e is BadRequestError =>
|
||||||
|
isStatusError(e) && e.statusCode === 400;
|
||||||
|
|
||||||
interface CodedSysError extends Error {
|
interface CodedSysError extends Error {
|
||||||
code?: string;
|
code?: string;
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ export function checkInt(
|
|||||||
*
|
*
|
||||||
* Check that a string exists, and is not an empty string, 'null', or 'undefined'
|
* Check that a string exists, and is not an empty string, 'null', or 'undefined'
|
||||||
*/
|
*/
|
||||||
export function checkString(s: unknown): string | void {
|
export function checkString(s: unknown): string | undefined {
|
||||||
if (s == null || !_.isString(s) || _.includes(['null', 'undefined', ''], s)) {
|
if (s == null || !_.isString(s) || _.includes(['null', 'undefined', ''], s)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -37,8 +37,6 @@ describe('regenerates API keys', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: test all the container stop / start / recreate / purge related actions
|
|
||||||
// together here to avoid repeated setup of containers and images.
|
|
||||||
describe('manages application lifecycle', () => {
|
describe('manages application lifecycle', () => {
|
||||||
const BASE_IMAGE = 'alpine:latest';
|
const BASE_IMAGE = 'alpine:latest';
|
||||||
const BALENA_SUPERVISOR_ADDRESS =
|
const BALENA_SUPERVISOR_ADDRESS =
|
||||||
@ -132,11 +130,21 @@ describe('manages application lifecycle', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAllRunning = (ctns: Docker.ContainerInspectInfo[]) =>
|
||||||
|
ctns.every((ctn) => ctn.State.Running);
|
||||||
|
|
||||||
|
const isAllStopped = (ctns: Docker.ContainerInspectInfo[]) =>
|
||||||
|
ctns.every((ctn) => !ctn.State.Running);
|
||||||
|
|
||||||
|
const isSomeStopped = (ctns: Docker.ContainerInspectInfo[]) =>
|
||||||
|
ctns.some((ctn) => !ctn.State.Running);
|
||||||
|
|
||||||
// Wait until containers are in a ready state prior to testing assertions
|
// Wait until containers are in a ready state prior to testing assertions
|
||||||
const waitForSetup = async (
|
const waitForSetup = async (
|
||||||
targetState: Dictionary<any>,
|
targetState: Dictionary<any>,
|
||||||
isWaitComplete: (ctns: Docker.ContainerInspectInfo[]) => boolean = (ctns) =>
|
isWaitComplete: (
|
||||||
ctns.every((ctn) => ctn.State.Running),
|
ctns: Docker.ContainerInspectInfo[],
|
||||||
|
) => boolean = isAllRunning,
|
||||||
) => {
|
) => {
|
||||||
// Get expected number of containers from target state
|
// Get expected number of containers from target state
|
||||||
const expected = Object.keys(
|
const expected = Object.keys(
|
||||||
@ -242,6 +250,111 @@ describe('manages application lifecycle', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should restart service by removing and recreating corresponding container', async () => {
|
||||||
|
containers = await waitForSetup(targetState);
|
||||||
|
const isRestartSuccessful = startTimesChanged(
|
||||||
|
containers.map((ctn) => ctn.State.StartedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calling actions.executeServiceAction directly doesn't work
|
||||||
|
// because it relies on querying target state of the balena-supervisor
|
||||||
|
// container, which isn't accessible directly from sut.
|
||||||
|
await request(BALENA_SUPERVISOR_ADDRESS)
|
||||||
|
.post('/v2/applications/1/restart-service')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send(JSON.stringify({ serviceName: serviceNames[0] }));
|
||||||
|
|
||||||
|
const restartedContainers = await waitForSetup(
|
||||||
|
targetState,
|
||||||
|
isRestartSuccessful,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Technically the wait function above should already verify that the two
|
||||||
|
// containers have been restarted, but verify explcitly with an assertion
|
||||||
|
expect(isRestartSuccessful(restartedContainers)).to.be.true;
|
||||||
|
|
||||||
|
// Containers should have different Ids since they're recreated
|
||||||
|
expect(restartedContainers.map(({ Id }) => Id)).to.not.have.members(
|
||||||
|
containers.map((ctn) => ctn.Id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop a running service', async () => {
|
||||||
|
containers = await waitForSetup(targetState);
|
||||||
|
|
||||||
|
// Calling actions.executeServiceAction directly doesn't work
|
||||||
|
// because it relies on querying target state of the balena-supervisor
|
||||||
|
// container, which isn't accessible directly from sut.
|
||||||
|
const response = await request(BALENA_SUPERVISOR_ADDRESS)
|
||||||
|
.post('/v1/apps/1/stop')
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
const stoppedContainers = await waitForSetup(targetState, isAllStopped);
|
||||||
|
|
||||||
|
// Technically the wait function above should already verify that the two
|
||||||
|
// containers have been restarted, but verify explcitly with an assertion
|
||||||
|
expect(isAllStopped(stoppedContainers)).to.be.true;
|
||||||
|
|
||||||
|
// Containers should have the same Ids since none should be removed
|
||||||
|
expect(stoppedContainers.map(({ Id }) => Id)).to.have.members(
|
||||||
|
containers.map((ctn) => ctn.Id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Endpoint should return the containerId of the stopped service
|
||||||
|
expect(response.body).to.deep.equal({
|
||||||
|
containerId: stoppedContainers[0].Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the container
|
||||||
|
const containerToStart = containers.find(({ Name }) =>
|
||||||
|
new RegExp(serviceNames[0]).test(Name),
|
||||||
|
);
|
||||||
|
if (!containerToStart) {
|
||||||
|
expect.fail(
|
||||||
|
`Expected a container matching "${serviceNames[0]}" to be present`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await docker.getContainer(containerToStart.Id).start();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start a stopped service', async () => {
|
||||||
|
containers = await waitForSetup(targetState);
|
||||||
|
|
||||||
|
// First, stop the container so we can test the start step
|
||||||
|
const containerToStop = containers.find((ctn) =>
|
||||||
|
new RegExp(serviceNames[0]).test(ctn.Name),
|
||||||
|
);
|
||||||
|
if (!containerToStop) {
|
||||||
|
expect.fail(
|
||||||
|
`Expected a container matching "${serviceNames[0]}" to be present`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await docker.getContainer(containerToStop.Id).stop();
|
||||||
|
|
||||||
|
// Calling actions.executeServiceAction directly doesn't work
|
||||||
|
// because it relies on querying target state of the balena-supervisor
|
||||||
|
// container, which isn't accessible directly from sut.
|
||||||
|
const response = await request(BALENA_SUPERVISOR_ADDRESS)
|
||||||
|
.post('/v1/apps/1/start')
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
const runningContainers = await waitForSetup(targetState, isAllRunning);
|
||||||
|
|
||||||
|
// Technically the wait function above should already verify that the two
|
||||||
|
// containers have been restarted, but verify explcitly with an assertion
|
||||||
|
expect(isAllRunning(runningContainers)).to.be.true;
|
||||||
|
|
||||||
|
// Containers should have the same Ids since none should be removed
|
||||||
|
expect(runningContainers.map(({ Id }) => Id)).to.have.members(
|
||||||
|
containers.map((ctn) => ctn.Id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Endpoint should return the containerId of the started service
|
||||||
|
expect(response.body).to.deep.equal({
|
||||||
|
containerId: runningContainers[0].Id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// This test should be ordered last in this `describe` block, because the test compares
|
// This test should be ordered last in this `describe` block, because the test compares
|
||||||
// the `CreatedAt` timestamps of volumes to determine whether purge was successful. Thus,
|
// the `CreatedAt` timestamps of volumes to determine whether purge was successful. Thus,
|
||||||
// ordering the assertion last will ensure some time has passed between the first `CreatedAt`
|
// ordering the assertion last will ensure some time has passed between the first `CreatedAt`
|
||||||
@ -350,6 +463,103 @@ describe('manages application lifecycle', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should restart service by removing and recreating corresponding container', async () => {
|
||||||
|
containers = await waitForSetup(targetState);
|
||||||
|
const isRestartSuccessful = startTimesChanged(
|
||||||
|
containers.map((ctn) => ctn.State.StartedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calling actions.executeServiceAction directly doesn't work
|
||||||
|
// because it relies on querying target state of the balena-supervisor
|
||||||
|
// container, which isn't accessible directly from sut.
|
||||||
|
await request(BALENA_SUPERVISOR_ADDRESS)
|
||||||
|
.post('/v2/applications/1/restart-service')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send(JSON.stringify({ serviceName: serviceNames[0] }));
|
||||||
|
|
||||||
|
const restartedContainers = await waitForSetup(
|
||||||
|
targetState,
|
||||||
|
isRestartSuccessful,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Technically the wait function above should already verify that the two
|
||||||
|
// containers have been restarted, but verify explcitly with an assertion
|
||||||
|
expect(isRestartSuccessful(restartedContainers)).to.be.true;
|
||||||
|
|
||||||
|
// Containers should have different Ids since they're recreated
|
||||||
|
expect(restartedContainers.map(({ Id }) => Id)).to.not.have.members(
|
||||||
|
containers.map((ctn) => ctn.Id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop a running service', async () => {
|
||||||
|
containers = await waitForSetup(targetState);
|
||||||
|
|
||||||
|
// Calling actions.executeServiceAction directly doesn't work
|
||||||
|
// because it relies on querying target state of the balena-supervisor
|
||||||
|
// container, which isn't accessible directly from sut.
|
||||||
|
await request(BALENA_SUPERVISOR_ADDRESS)
|
||||||
|
.post('/v2/applications/1/stop-service')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send(JSON.stringify({ serviceName: serviceNames[0] }));
|
||||||
|
|
||||||
|
const stoppedContainers = await waitForSetup(targetState, isSomeStopped);
|
||||||
|
|
||||||
|
// Technically the wait function above should already verify that the two
|
||||||
|
// containers have been restarted, but verify explcitly with an assertion
|
||||||
|
expect(isSomeStopped(stoppedContainers)).to.be.true;
|
||||||
|
|
||||||
|
// Containers should have the same Ids since none should be removed
|
||||||
|
expect(stoppedContainers.map(({ Id }) => Id)).to.have.members(
|
||||||
|
containers.map((ctn) => ctn.Id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start the container
|
||||||
|
const containerToStart = containers.find(({ Name }) =>
|
||||||
|
new RegExp(serviceNames[0]).test(Name),
|
||||||
|
);
|
||||||
|
if (!containerToStart) {
|
||||||
|
expect.fail(
|
||||||
|
`Expected a container matching "${serviceNames[0]}" to be present`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await docker.getContainer(containerToStart.Id).start();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start a stopped service', async () => {
|
||||||
|
containers = await waitForSetup(targetState);
|
||||||
|
|
||||||
|
// First, stop the container so we can test the start step
|
||||||
|
const containerToStop = containers.find((ctn) =>
|
||||||
|
new RegExp(serviceNames[0]).test(ctn.Name),
|
||||||
|
);
|
||||||
|
if (!containerToStop) {
|
||||||
|
expect.fail(
|
||||||
|
`Expected a container matching "${serviceNames[0]}" to be present`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await docker.getContainer(containerToStop.Id).stop();
|
||||||
|
|
||||||
|
// Calling actions.executeServiceAction directly doesn't work
|
||||||
|
// because it relies on querying target state of the balena-supervisor
|
||||||
|
// container, which isn't accessible directly from sut.
|
||||||
|
await request(BALENA_SUPERVISOR_ADDRESS)
|
||||||
|
.post('/v2/applications/1/start-service')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send(JSON.stringify({ serviceName: serviceNames[0] }));
|
||||||
|
|
||||||
|
const runningContainers = await waitForSetup(targetState, isAllRunning);
|
||||||
|
|
||||||
|
// Technically the wait function above should already verify that the two
|
||||||
|
// containers have been restarted, but verify explcitly with an assertion
|
||||||
|
expect(isAllRunning(runningContainers)).to.be.true;
|
||||||
|
|
||||||
|
// Containers should have the same Ids since none should be removed
|
||||||
|
expect(runningContainers.map(({ Id }) => Id)).to.have.members(
|
||||||
|
containers.map((ctn) => ctn.Id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// This test should be ordered last in this `describe` block, because the test compares
|
// This test should be ordered last in this `describe` block, because the test compares
|
||||||
// the `CreatedAt` timestamps of volumes to determine whether purge was successful. Thus,
|
// the `CreatedAt` timestamps of volumes to determine whether purge was successful. Thus,
|
||||||
// ordering the assertion last will ensure some time has passed between the first `CreatedAt`
|
// ordering the assertion last will ensure some time has passed between the first `CreatedAt`
|
||||||
|
@ -5,10 +5,15 @@ import * as request from 'supertest';
|
|||||||
|
|
||||||
import * as config from '~/src/config';
|
import * as config from '~/src/config';
|
||||||
import * as db from '~/src/db';
|
import * as db from '~/src/db';
|
||||||
|
import Service from '~/src/compose/service';
|
||||||
import * as deviceApi from '~/src/device-api';
|
import * as deviceApi from '~/src/device-api';
|
||||||
import * as actions from '~/src/device-api/actions';
|
import * as actions from '~/src/device-api/actions';
|
||||||
import * as v1 from '~/src/device-api/v1';
|
import * as v1 from '~/src/device-api/v1';
|
||||||
import { UpdatesLockedError } from '~/lib/errors';
|
import {
|
||||||
|
UpdatesLockedError,
|
||||||
|
NotFoundError,
|
||||||
|
BadRequestError,
|
||||||
|
} from '~/lib/errors';
|
||||||
|
|
||||||
// All routes that require Authorization are integration tests due to
|
// All routes that require Authorization are integration tests due to
|
||||||
// the api-key module relying on the database.
|
// the api-key module relying on the database.
|
||||||
@ -263,4 +268,232 @@ describe('device-api/v1', () => {
|
|||||||
.expect(503);
|
.expect(503);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/apps/:appId/stop', () => {
|
||||||
|
let executeServiceActionStub: SinonStub;
|
||||||
|
let getLegacyServiceStub: SinonStub;
|
||||||
|
beforeEach(() => {
|
||||||
|
executeServiceActionStub = stub(
|
||||||
|
actions,
|
||||||
|
'executeServiceAction',
|
||||||
|
).resolves();
|
||||||
|
getLegacyServiceStub = stub(actions, 'getLegacyService').resolves({
|
||||||
|
containerId: 'abcdef',
|
||||||
|
} as Service);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
executeServiceActionStub.restore();
|
||||||
|
getLegacyServiceStub.restore();
|
||||||
|
// Remove all scoped API keys between tests
|
||||||
|
await db.models('apiSecret').whereNot({ appId: 0 }).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates data from request body', async () => {
|
||||||
|
// Parses force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/stop')
|
||||||
|
.send({ force: false })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 1234567,
|
||||||
|
force: false,
|
||||||
|
isLegacy: true,
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses force: true
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/7654321/stop')
|
||||||
|
.send({ force: true })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 7654321,
|
||||||
|
force: true,
|
||||||
|
isLegacy: true,
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Defaults to force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/7654321/stop')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
isLegacy: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("responds with 401 if caller's API key is not in scope of appId", async () => {
|
||||||
|
const scopedKey = await deviceApi.generateScopedKey(1234567, 'main');
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/7654321/stop')
|
||||||
|
.set('Authorization', `Bearer ${scopedKey}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 200 and containerId if service stop succeeded if service stop succeeded', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/stop')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200, { containerId: 'abcdef' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 404 if app or service not found', async () => {
|
||||||
|
executeServiceActionStub.throws(new NotFoundError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/stop')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 400 if invalid appId or appId corresponds to a multicontainer release', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/badAppId/stop')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
executeServiceActionStub.throws(new BadRequestError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/stop')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 423 if there are update locks', async () => {
|
||||||
|
executeServiceActionStub.throws(new UpdatesLockedError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/stop')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(423);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 503 for other errors that occur during service stop', async () => {
|
||||||
|
executeServiceActionStub.throws(new Error());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/stop')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/apps/:appId/start', () => {
|
||||||
|
let executeServiceActionStub: SinonStub;
|
||||||
|
let getLegacyServiceStub: SinonStub;
|
||||||
|
beforeEach(() => {
|
||||||
|
executeServiceActionStub = stub(
|
||||||
|
actions,
|
||||||
|
'executeServiceAction',
|
||||||
|
).resolves();
|
||||||
|
getLegacyServiceStub = stub(actions, 'getLegacyService').resolves({
|
||||||
|
containerId: 'abcdef',
|
||||||
|
} as Service);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
executeServiceActionStub.restore();
|
||||||
|
getLegacyServiceStub.restore();
|
||||||
|
// Remove all scoped API keys between tests
|
||||||
|
await db.models('apiSecret').whereNot({ appId: 0 }).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates data from request body', async () => {
|
||||||
|
// Parses force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/start')
|
||||||
|
.send({ force: false })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 1234567,
|
||||||
|
force: false,
|
||||||
|
isLegacy: true,
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses force: true
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/7654321/start')
|
||||||
|
.send({ force: true })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 7654321,
|
||||||
|
force: true,
|
||||||
|
isLegacy: true,
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Defaults to force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/7654321/start')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
isLegacy: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("responds with 401 if caller's API key is not in scope of appId", async () => {
|
||||||
|
const scopedKey = await deviceApi.generateScopedKey(1234567, 'main');
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/7654321/start')
|
||||||
|
.set('Authorization', `Bearer ${scopedKey}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 200 and containerId if service start succeeded', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/start')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200, { containerId: 'abcdef' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 404 if app or service not found', async () => {
|
||||||
|
executeServiceActionStub.throws(new NotFoundError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/start')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 400 if invalid appId or appId corresponds to a multicontainer release', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/badAppId/start')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
executeServiceActionStub.throws(new BadRequestError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/start')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 423 if there are update locks', async () => {
|
||||||
|
executeServiceActionStub.throws(new UpdatesLockedError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/start')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(423);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 503 for other errors that occur during service start', async () => {
|
||||||
|
executeServiceActionStub.throws(new Error());
|
||||||
|
await request(api)
|
||||||
|
.post('/v1/apps/1234567/start')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,7 +8,11 @@ import * as db from '~/src/db';
|
|||||||
import * as deviceApi from '~/src/device-api';
|
import * as deviceApi from '~/src/device-api';
|
||||||
import * as actions from '~/src/device-api/actions';
|
import * as actions from '~/src/device-api/actions';
|
||||||
import * as v2 from '~/src/device-api/v2';
|
import * as v2 from '~/src/device-api/v2';
|
||||||
import { UpdatesLockedError } from '~/lib/errors';
|
import {
|
||||||
|
UpdatesLockedError,
|
||||||
|
NotFoundError,
|
||||||
|
BadRequestError,
|
||||||
|
} from '~/lib/errors';
|
||||||
|
|
||||||
// All routes that require Authorization are integration tests due to
|
// All routes that require Authorization are integration tests due to
|
||||||
// the api-key module relying on the database.
|
// the api-key module relying on the database.
|
||||||
@ -183,4 +187,454 @@ describe('device-api/v2', () => {
|
|||||||
.expect(503);
|
.expect(503);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /v2/applications/:appId/stop-service', () => {
|
||||||
|
// Actions are tested elsewhere so we can stub the dependency here
|
||||||
|
let executeServiceActionStub: SinonStub;
|
||||||
|
beforeEach(() => {
|
||||||
|
executeServiceActionStub = stub(
|
||||||
|
actions,
|
||||||
|
'executeServiceAction',
|
||||||
|
).resolves();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
executeServiceActionStub.restore();
|
||||||
|
// Remove all scoped API keys between tests
|
||||||
|
await db.models('apiSecret').whereNot({ appId: 0 }).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates data from request body', async () => {
|
||||||
|
// Parses force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/stop-service')
|
||||||
|
.send({ force: false, serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 1234567,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses force: true
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/stop-service')
|
||||||
|
.send({ force: true, serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 7654321,
|
||||||
|
force: true,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Defaults to force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses imageId
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/stop-service')
|
||||||
|
.send({ imageId: 111 })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: 111,
|
||||||
|
serviceName: undefined,
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses serviceName
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'stop',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("responds with 401 if caller's API key is not in scope of appId", async () => {
|
||||||
|
const scopedKey = await deviceApi.generateScopedKey(1234567, 'main');
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${scopedKey}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 200 if service stop succeeded', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 404 if app or service not found', async () => {
|
||||||
|
executeServiceActionStub.throws(new NotFoundError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 400 if invalid appId or missing serviceName/imageId from request body', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/badAppId/stop-service')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
executeServiceActionStub.throws(new BadRequestError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 423 if there are update locks', async () => {
|
||||||
|
executeServiceActionStub.throws(new UpdatesLockedError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(423);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 503 for other errors that occur during service stop', async () => {
|
||||||
|
executeServiceActionStub.throws(new Error());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/stop-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v2/applications/:appId/start-service', () => {
|
||||||
|
// Actions are tested elsewhere so we can stub the dependency here
|
||||||
|
let executeServiceActionStub: SinonStub;
|
||||||
|
beforeEach(() => {
|
||||||
|
executeServiceActionStub = stub(
|
||||||
|
actions,
|
||||||
|
'executeServiceAction',
|
||||||
|
).resolves();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
executeServiceActionStub.restore();
|
||||||
|
// Remove all scoped API keys between tests
|
||||||
|
await db.models('apiSecret').whereNot({ appId: 0 }).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates data from request body', async () => {
|
||||||
|
// Parses force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/start-service')
|
||||||
|
.send({ force: false, serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 1234567,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses force: true
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/start-service')
|
||||||
|
.send({ force: true, serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 7654321,
|
||||||
|
force: true,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Defaults to force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses imageId
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/start-service')
|
||||||
|
.send({ imageId: 111 })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: 111,
|
||||||
|
serviceName: undefined,
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses serviceName
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'start',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("responds with 401 if caller's API key is not in scope of appId", async () => {
|
||||||
|
const scopedKey = await deviceApi.generateScopedKey(1234567, 'main');
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${scopedKey}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 200 if service start succeeded', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 404 if app or service not found', async () => {
|
||||||
|
executeServiceActionStub.throws(new NotFoundError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 400 if invalid appId or missing serviceName/imageId from request body', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/badAppId/start-service')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
executeServiceActionStub.throws(new BadRequestError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 423 if there are update locks', async () => {
|
||||||
|
executeServiceActionStub.throws(new UpdatesLockedError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(423);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 503 for other errors that occur during service start', async () => {
|
||||||
|
executeServiceActionStub.throws(new Error());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/start-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v2/applications/:appId/restart-service', () => {
|
||||||
|
// Actions are tested elsewhere so we can stub the dependency here
|
||||||
|
let executeServiceActionStub: SinonStub;
|
||||||
|
beforeEach(() => {
|
||||||
|
executeServiceActionStub = stub(
|
||||||
|
actions,
|
||||||
|
'executeServiceAction',
|
||||||
|
).resolves();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
executeServiceActionStub.restore();
|
||||||
|
// Remove all scoped API keys between tests
|
||||||
|
await db.models('apiSecret').whereNot({ appId: 0 }).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates data from request body', async () => {
|
||||||
|
// Parses force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/restart-service')
|
||||||
|
.send({ force: false, serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'restart',
|
||||||
|
appId: 1234567,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses force: true
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/restart-service')
|
||||||
|
.send({ force: true, serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'restart',
|
||||||
|
appId: 7654321,
|
||||||
|
force: true,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Defaults to force: false
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'restart',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses imageId
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/restart-service')
|
||||||
|
.send({ imageId: 111 })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'restart',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: 111,
|
||||||
|
serviceName: undefined,
|
||||||
|
});
|
||||||
|
executeServiceActionStub.resetHistory();
|
||||||
|
|
||||||
|
// Parses serviceName
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(executeServiceActionStub).to.have.been.calledWith({
|
||||||
|
action: 'restart',
|
||||||
|
appId: 7654321,
|
||||||
|
force: false,
|
||||||
|
imageId: undefined,
|
||||||
|
serviceName: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("responds with 401 if caller's API key is not in scope of appId", async () => {
|
||||||
|
const scopedKey = await deviceApi.generateScopedKey(1234567, 'main');
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${scopedKey}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 200 if service restart succeeded', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 404 if app or service not found', async () => {
|
||||||
|
executeServiceActionStub.throws(new NotFoundError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 400 if invalid appId or missing serviceName/imageId from request body', async () => {
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/badAppId/restart-service')
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
executeServiceActionStub.throws(new BadRequestError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 423 if there are update locks', async () => {
|
||||||
|
executeServiceActionStub.throws(new UpdatesLockedError());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/1234567/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(423);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds with 503 for other errors that occur during service restart', async () => {
|
||||||
|
executeServiceActionStub.throws(new Error());
|
||||||
|
await request(api)
|
||||||
|
.post('/v2/applications/7654321/restart-service')
|
||||||
|
.send({ serviceName: 'test' })
|
||||||
|
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||||
|
.expect(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -152,111 +152,6 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
||||||
// manager methods. A refactor is needed
|
|
||||||
describe.skip('POST /v1/apps/:appId/stop', () => {
|
|
||||||
it('does not allow stopping an application when there is more than 1 container', async () => {
|
|
||||||
// Every test case in this suite has a 3 service release mocked so just make the request
|
|
||||||
await mockedDockerode.testWithData({ containers, images }, async () => {
|
|
||||||
await request
|
|
||||||
.post('/v1/apps/2/stop')
|
|
||||||
.set('Accept', 'application/json')
|
|
||||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
|
||||||
.expect(
|
|
||||||
sampleResponses.V1.GET['/apps/2/stop [Multiple containers running]']
|
|
||||||
.statusCode,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops a SPECIFIC application and returns a containerId', async () => {
|
|
||||||
// Setup single container application
|
|
||||||
const container = mockedAPI.mockService({
|
|
||||||
containerId: 'abc123',
|
|
||||||
appId: 2,
|
|
||||||
});
|
|
||||||
const image = mockedAPI.mockImage({
|
|
||||||
appId: 2,
|
|
||||||
});
|
|
||||||
appMock.mockManagers([container], [], []);
|
|
||||||
appMock.mockImages([], false, [image]);
|
|
||||||
// Perform the test with our mocked release
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: [container], images: [image] },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post('/v1/apps/2/stop')
|
|
||||||
.set('Accept', 'application/json')
|
|
||||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
|
||||||
.expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode)
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.then((response) => {
|
|
||||||
expect(response.body).to.deep.equal(
|
|
||||||
sampleResponses.V1.GET['/apps/2/stop'].body,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
||||||
// manager methods. A refactor is needed
|
|
||||||
describe.skip('POST /v1/apps/:appId/start', () => {
|
|
||||||
it('does not allow starting an application when there is more than 1 container', async () => {
|
|
||||||
// Every test case in this suite has a 3 service release mocked so just make the request
|
|
||||||
await mockedDockerode.testWithData({ containers, images }, async () => {
|
|
||||||
await request
|
|
||||||
.post('/v1/apps/2/start')
|
|
||||||
.set('Accept', 'application/json')
|
|
||||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
|
||||||
.expect(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts a SPECIFIC application and returns a containerId', async () => {
|
|
||||||
const service = {
|
|
||||||
serviceName: 'main',
|
|
||||||
containerId: 'abc123',
|
|
||||||
appId: 2,
|
|
||||||
serviceId: 640681,
|
|
||||||
};
|
|
||||||
// Setup single container application
|
|
||||||
const container = mockedAPI.mockService(service);
|
|
||||||
const image = mockedAPI.mockImage(service);
|
|
||||||
appMock.mockManagers([container], [], []);
|
|
||||||
appMock.mockImages([], false, [image]);
|
|
||||||
|
|
||||||
// Target state returns single service
|
|
||||||
targetStateCacheMock.resolves({
|
|
||||||
appId: 2,
|
|
||||||
commit: 'abcdef2',
|
|
||||||
name: 'test-app2',
|
|
||||||
source: 'https://api.balena-cloud.com',
|
|
||||||
releaseId: 1232,
|
|
||||||
services: JSON.stringify([service]),
|
|
||||||
volumes: '[]',
|
|
||||||
networks: '[]',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Perform the test with our mocked release
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: [container], images: [image] },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post('/v1/apps/2/start')
|
|
||||||
.set('Accept', 'application/json')
|
|
||||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
|
||||||
.expect(200)
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.then((response) => {
|
|
||||||
expect(response.body).to.deep.equal({ containerId: 'abc123' });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /v1/device', () => {
|
describe('GET /v1/device', () => {
|
||||||
it('returns MAC address', async () => {
|
it('returns MAC address', async () => {
|
||||||
const response = await request
|
const response = await request
|
||||||
|
@ -11,14 +11,10 @@ import * as deviceApi from '~/src/device-api';
|
|||||||
import * as serviceManager from '~/src/compose/service-manager';
|
import * as serviceManager from '~/src/compose/service-manager';
|
||||||
import * as images from '~/src/compose/images';
|
import * as images from '~/src/compose/images';
|
||||||
import * as config from '~/src/config';
|
import * as config from '~/src/config';
|
||||||
import * as updateLock from '~/lib/update-lock';
|
|
||||||
import * as targetStateCache from '~/src/device-state/target-state-cache';
|
|
||||||
import * as mockedDockerode from '~/test-lib/mocked-dockerode';
|
import * as mockedDockerode from '~/test-lib/mocked-dockerode';
|
||||||
import * as applicationManager from '~/src/compose/application-manager';
|
import * as applicationManager from '~/src/compose/application-manager';
|
||||||
import * as logger from '~/src/logger';
|
import * as logger from '~/src/logger';
|
||||||
|
|
||||||
import { UpdatesLockedError } from '~/lib/errors';
|
|
||||||
|
|
||||||
describe('SupervisorAPI [V2 Endpoints]', () => {
|
describe('SupervisorAPI [V2 Endpoints]', () => {
|
||||||
let serviceManagerMock: SinonStub;
|
let serviceManagerMock: SinonStub;
|
||||||
let imagesMock: SinonStub;
|
let imagesMock: SinonStub;
|
||||||
@ -288,262 +284,5 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
||||||
// manager methods. A refactor is needed
|
|
||||||
describe.skip('POST /v2/applications/:appId/start-service', function () {
|
|
||||||
let appScopedKey: string;
|
|
||||||
let targetStateCacheMock: SinonStub;
|
|
||||||
let lockMock: SinonStub;
|
|
||||||
|
|
||||||
const service = {
|
|
||||||
serviceName: 'main',
|
|
||||||
containerId: 'abc123',
|
|
||||||
appId: 1658654,
|
|
||||||
serviceId: 640681,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockContainers = [mockedAPI.mockService(service)];
|
|
||||||
const mockImages = [mockedAPI.mockImage(service)];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Setup device conditions
|
|
||||||
serviceManagerMock.resolves(mockContainers);
|
|
||||||
imagesMock.resolves(mockImages);
|
|
||||||
|
|
||||||
targetStateCacheMock.resolves({
|
|
||||||
appId: 2,
|
|
||||||
commit: 'abcdef2',
|
|
||||||
name: 'test-app2',
|
|
||||||
source: 'https://api.balena-cloud.com',
|
|
||||||
releaseId: 1232,
|
|
||||||
services: JSON.stringify([service]),
|
|
||||||
networks: '[]',
|
|
||||||
volumes: '[]',
|
|
||||||
});
|
|
||||||
|
|
||||||
lockMock.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
// Create scoped key for application
|
|
||||||
appScopedKey = await deviceApi.generateScopedKey(1658654, 'main');
|
|
||||||
|
|
||||||
// Mock target state cache
|
|
||||||
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
|
|
||||||
|
|
||||||
lockMock = stub(updateLock, 'lock');
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
targetStateCacheMock.restore();
|
|
||||||
lockMock.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 200 for an existing service', async () => {
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: mockContainers, images: mockImages },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post(
|
|
||||||
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
|
|
||||||
)
|
|
||||||
.send({ serviceName: 'main' })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(applicationManagerSpy).to.have.been.calledOnce;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for an unknown service', async () => {
|
|
||||||
await mockedDockerode.testWithData({}, async () => {
|
|
||||||
await request
|
|
||||||
.post(`/v2/applications/1658654/start-service?apikey=${appScopedKey}`)
|
|
||||||
.send({ serviceName: 'unknown' })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(404);
|
|
||||||
|
|
||||||
expect(applicationManagerSpy).to.not.have.been.called;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ignore locks and return 200', async () => {
|
|
||||||
// Turn lock on
|
|
||||||
lockMock.throws(new UpdatesLockedError('Updates locked'));
|
|
||||||
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: mockContainers, images: mockImages },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post(
|
|
||||||
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
|
|
||||||
)
|
|
||||||
.send({ serviceName: 'main' })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(lockMock).to.not.have.been.called;
|
|
||||||
expect(applicationManagerSpy).to.have.been.calledOnce;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
||||||
// manager methods. A refactor is needed
|
|
||||||
describe.skip('POST /v2/applications/:appId/restart-service', () => {
|
|
||||||
let appScopedKey: string;
|
|
||||||
let targetStateCacheMock: SinonStub;
|
|
||||||
let lockMock: SinonStub;
|
|
||||||
|
|
||||||
const service = {
|
|
||||||
serviceName: 'main',
|
|
||||||
containerId: 'abc123',
|
|
||||||
appId: 1658654,
|
|
||||||
serviceId: 640681,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockContainers = [mockedAPI.mockService(service)];
|
|
||||||
const mockImages = [mockedAPI.mockImage(service)];
|
|
||||||
const lockFake = async (
|
|
||||||
_: any,
|
|
||||||
opts: { force: boolean },
|
|
||||||
fn: () => any,
|
|
||||||
) => {
|
|
||||||
if (opts.force) {
|
|
||||||
return fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new UpdatesLockedError('Updates locked');
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Setup device conditions
|
|
||||||
serviceManagerMock.resolves(mockContainers);
|
|
||||||
imagesMock.resolves(mockImages);
|
|
||||||
|
|
||||||
targetStateCacheMock.resolves({
|
|
||||||
appId: 2,
|
|
||||||
commit: 'abcdef2',
|
|
||||||
name: 'test-app2',
|
|
||||||
source: 'https://api.balena-cloud.com',
|
|
||||||
releaseId: 1232,
|
|
||||||
services: JSON.stringify(mockContainers),
|
|
||||||
networks: '[]',
|
|
||||||
volumes: '[]',
|
|
||||||
});
|
|
||||||
|
|
||||||
lockMock.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
// Create scoped key for application
|
|
||||||
appScopedKey = await deviceApi.generateScopedKey(1658654, 'main');
|
|
||||||
|
|
||||||
// Mock target state cache
|
|
||||||
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
|
|
||||||
lockMock = stub(updateLock, 'lock');
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
targetStateCacheMock.restore();
|
|
||||||
lockMock.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 200 for an existing service', async () => {
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: mockContainers, images: mockImages },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post(
|
|
||||||
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
|
||||||
)
|
|
||||||
.send({ serviceName: 'main' })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(applicationManagerSpy).to.have.been.calledOnce;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for an unknown service', async () => {
|
|
||||||
await mockedDockerode.testWithData({}, async () => {
|
|
||||||
await request
|
|
||||||
.post(
|
|
||||||
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
|
||||||
)
|
|
||||||
.send({ serviceName: 'unknown' })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(404);
|
|
||||||
expect(applicationManagerSpy).to.not.have.been.called;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 423 for a service with update locks', async () => {
|
|
||||||
// Turn lock on
|
|
||||||
lockMock.throws(new UpdatesLockedError('Updates locked'));
|
|
||||||
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: mockContainers, images: mockImages },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post(
|
|
||||||
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
|
||||||
)
|
|
||||||
.send({ serviceName: 'main' })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(423);
|
|
||||||
|
|
||||||
expect(lockMock).to.be.calledOnce;
|
|
||||||
expect(applicationManagerSpy).to.have.been.calledOnce;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 200 for a service with update locks and force true', async () => {
|
|
||||||
// Turn lock on
|
|
||||||
lockMock.callsFake(lockFake);
|
|
||||||
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: mockContainers, images: mockImages },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post(
|
|
||||||
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
|
||||||
)
|
|
||||||
.send({ serviceName: 'main', force: true })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(lockMock).to.be.calledOnce;
|
|
||||||
expect(applicationManagerSpy).to.have.been.calledOnce;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 423 if force is explicitely set to false', async () => {
|
|
||||||
// Turn lock on
|
|
||||||
lockMock.callsFake(lockFake);
|
|
||||||
|
|
||||||
await mockedDockerode.testWithData(
|
|
||||||
{ containers: mockContainers, images: mockImages },
|
|
||||||
async () => {
|
|
||||||
await request
|
|
||||||
.post(
|
|
||||||
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
|
||||||
)
|
|
||||||
.send({ serviceName: 'main', force: false })
|
|
||||||
.set('Content-type', 'application/json')
|
|
||||||
.expect(423);
|
|
||||||
|
|
||||||
expect(lockMock).to.be.calledOnce;
|
|
||||||
expect(applicationManagerSpy).to.have.been.calledOnce;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: add tests for rest of V2 endpoints
|
// TODO: add tests for rest of V2 endpoints
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user