mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-04-07 19:34:17 +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:
parent
fcd28591c6
commit
c6cf6a0136
@ -9,12 +9,22 @@ import { App } from '../compose/app';
|
||||
import * as applicationManager from '../compose/application-manager';
|
||||
import * as serviceManager from '../compose/service-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 blink = require('../lib/blink');
|
||||
import { lock } from '../lib/update-lock';
|
||||
import { InternalInconsistencyError } from '../lib/errors';
|
||||
import {
|
||||
InternalInconsistencyError,
|
||||
NotFoundError,
|
||||
BadRequestError,
|
||||
} from '../lib/errors';
|
||||
|
||||
import type { InstancedDeviceState } from '../types';
|
||||
import * as messages from './messages';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
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';
|
||||
|
||||
export const v2ServiceEndpointInputErrorMessage =
|
||||
'This endpoint requires one of imageId or serviceName';
|
||||
export const targetServiceNotFound = 'Service does not exist in target release';
|
||||
|
||||
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 { checkInt, checkTruthy } from '../lib/validation';
|
||||
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 applicationManager from '../compose/application-manager';
|
||||
import { generateStep } from '../compose/composition-steps';
|
||||
import { CompositionStepAction } from '../compose/composition-steps';
|
||||
import * as commitStore from '../compose/commit';
|
||||
import { getApp } from '../device-state/db-format';
|
||||
import * as TargetState from '../device-state/target-state';
|
||||
|
||||
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
|
||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
status: 'failed',
|
||||
message: 'Application is not available',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return actions
|
||||
@ -46,84 +48,44 @@ router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
const v1StopOrStart = (
|
||||
req: AuthorizedRequest,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
action: 'start' | 'stop',
|
||||
) => {
|
||||
const appId = checkInt(req.params.appId);
|
||||
const force = checkTruthy(req.body.force);
|
||||
if (appId == null) {
|
||||
return res.status(400).send('Missing app id');
|
||||
}
|
||||
const handleLegacyServiceAction = (action: CompositionStepAction) => {
|
||||
return async (
|
||||
req: AuthorizedRequest,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
const appId = checkInt(req.params.appId);
|
||||
const force = checkTruthy(req.body.force);
|
||||
|
||||
return Promise.all([applicationManager.getCurrentApps(), getApp(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');
|
||||
}
|
||||
if (appId == null) {
|
||||
return res.status(400).send('Invalid app id');
|
||||
}
|
||||
|
||||
// check that the request is scoped to cover this application
|
||||
if (!req.auth.isScoped({ apps: [app.appId] })) {
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
// Get the service from the target state (as we do in v2)
|
||||
// TODO: what if we want to start a service belonging to the current app?
|
||||
const targetService = _.find(targetApp.services, {
|
||||
serviceName: service.serviceName,
|
||||
try {
|
||||
await actions.executeServiceAction({
|
||||
action,
|
||||
appId,
|
||||
force,
|
||||
isLegacy: true,
|
||||
});
|
||||
|
||||
applicationManager.setTargetVolatileForService(service.imageId, {
|
||||
running: action !== 'stop',
|
||||
});
|
||||
|
||||
const stopOpts = { wait: true };
|
||||
const step = generateStep(action, {
|
||||
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 service = await actions.getLegacyService(appId);
|
||||
return res.status(200).send({ containerId: service.containerId });
|
||||
} catch (e: unknown) {
|
||||
if (isNotFoundError(e) || isBadRequestError(e)) {
|
||||
return res.status(e.statusCode).send(e.statusMessage);
|
||||
} else {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const createV1StopOrStartHandler = (action: 'start' | 'stop') =>
|
||||
_.partial(v1StopOrStart, _, _, _, action);
|
||||
|
||||
router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop'));
|
||||
router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start'));
|
||||
router.post('/v1/apps/:appId/stop', handleLegacyServiceAction('stop'));
|
||||
router.post('/v1/apps/:appId/start', handleLegacyServiceAction('start'));
|
||||
|
||||
const rebootOrShutdown = async (
|
||||
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
|
||||
if (!req.auth.isScoped({ apps: [app.appId] })) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
status: 'failed',
|
||||
message: 'Application is not available',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
status: 'failed',
|
||||
message: 'Application is not available',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return actions
|
||||
|
@ -6,11 +6,7 @@ import * as _ from 'lodash';
|
||||
import * as deviceState from '../device-state';
|
||||
import * as apiBinder from '../api-binder';
|
||||
import * as applicationManager from '../compose/application-manager';
|
||||
import {
|
||||
CompositionStepAction,
|
||||
generateStep,
|
||||
} from '../compose/composition-steps';
|
||||
import { getApp } from '../device-state/db-format';
|
||||
import { CompositionStepAction } from '../compose/composition-steps';
|
||||
import { Service } from '../compose/service';
|
||||
import Volume from '../compose/volume';
|
||||
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 serviceManager from '../compose/service-manager';
|
||||
import { spawnJournalctl } from '../lib/journald';
|
||||
import {
|
||||
appNotFoundMessage,
|
||||
serviceNotFoundMessage,
|
||||
v2ServiceEndpointInputErrorMessage,
|
||||
} from './messages';
|
||||
import log from '../lib/supervisor-console';
|
||||
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 * as actions from './actions';
|
||||
import { AuthorizedRequest } from './api-keys';
|
||||
import { fromV2TargetState } from '../lib/legacy';
|
||||
import * as actions from './actions';
|
||||
import { v2ServiceEndpointError } from './messages';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
const handleServiceAction = (
|
||||
req: AuthorizedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
action: CompositionStepAction,
|
||||
): Resolvable<void> => {
|
||||
const { imageId, serviceName, force } = req.body;
|
||||
const appId = checkInt(req.params.appId);
|
||||
if (!appId) {
|
||||
res.status(400).json({
|
||||
status: 'failed',
|
||||
message: 'Missing app id',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handleServiceAction = (action: CompositionStepAction) => {
|
||||
return async (req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
||||
const [appId, imageId, serviceName, force] = [
|
||||
checkInt(req.params.appId),
|
||||
checkInt(req.body.imageId),
|
||||
checkString(req.body.serviceName),
|
||||
checkTruthy(req.body.force),
|
||||
];
|
||||
|
||||
// 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 Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
||||
.then(([apps, targetApp]) => {
|
||||
const app = apps[appId];
|
||||
|
||||
if (app == null) {
|
||||
res.status(404).send(appNotFoundMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Work if we have a service name or an image id
|
||||
if (imageId == null && serviceName == null) {
|
||||
throw new Error(v2ServiceEndpointInputErrorMessage);
|
||||
}
|
||||
|
||||
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',
|
||||
if (!appId) {
|
||||
return res.status(400).json({
|
||||
status: 'failed',
|
||||
message: 'Invalid app id',
|
||||
});
|
||||
return applicationManager
|
||||
.executeStep(
|
||||
generateStep(action, {
|
||||
current: service,
|
||||
target: targetService,
|
||||
wait: true,
|
||||
}),
|
||||
{
|
||||
force,
|
||||
},
|
||||
)
|
||||
.then(() => {
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
}
|
||||
|
||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||
return res.status(401).json({
|
||||
status: 'failed',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!serviceName && !imageId) {
|
||||
throw new BadRequestError(v2ServiceEndpointError);
|
||||
}
|
||||
|
||||
await actions.executeServiceAction({
|
||||
action,
|
||||
appId,
|
||||
imageId,
|
||||
serviceName,
|
||||
force,
|
||||
});
|
||||
return res.status(200).send('OK');
|
||||
} catch (e: unknown) {
|
||||
if (isNotFoundError(e) || isBadRequestError(e)) {
|
||||
return res.status(e.statusCode).send(e.statusMessage);
|
||||
} else {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const createServiceActionHandler = (action: string) =>
|
||||
_.partial(handleServiceAction, _, _, _, action);
|
||||
router.post(
|
||||
'/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(
|
||||
'/v2/applications/:appId/purge',
|
||||
@ -130,7 +111,7 @@ router.post(
|
||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||
return res.status(401).json({
|
||||
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(
|
||||
'/v2/applications/:appId/restart',
|
||||
(req: AuthorizedRequest, res: Response, next: NextFunction) => {
|
||||
@ -172,11 +138,10 @@ router.post(
|
||||
|
||||
// handle the case where the appId is out of scope
|
||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
status: 'failed',
|
||||
message: 'Application is not available',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return actions
|
||||
@ -306,11 +271,10 @@ router.get(
|
||||
|
||||
// handle the case where the appId is out of scope
|
||||
if (!req.auth.isScoped({ apps: [appId] })) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
status: 'failed',
|
||||
message: 'Application is not available',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter applications we do not want
|
||||
|
@ -22,17 +22,24 @@ export class StatusError extends Error {
|
||||
export const isStatusError = (x: unknown): x is StatusError =>
|
||||
x != null && x instanceof Error && !isNaN((x as any).statusCode);
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
public statusCode: number;
|
||||
constructor() {
|
||||
super();
|
||||
this.statusCode = 404;
|
||||
export class NotFoundError extends StatusError {
|
||||
constructor(statusMessage?: string) {
|
||||
super(404, statusMessage ?? 'Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
export const isNotFoundError = (e: unknown): e is NotFoundError =>
|
||||
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 {
|
||||
code?: string;
|
||||
}
|
||||
|
@ -42,7 +42,7 @@ export function checkInt(
|
||||
*
|
||||
* 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)) {
|
||||
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', () => {
|
||||
const BASE_IMAGE = 'alpine:latest';
|
||||
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
|
||||
const waitForSetup = async (
|
||||
targetState: Dictionary<any>,
|
||||
isWaitComplete: (ctns: Docker.ContainerInspectInfo[]) => boolean = (ctns) =>
|
||||
ctns.every((ctn) => ctn.State.Running),
|
||||
isWaitComplete: (
|
||||
ctns: Docker.ContainerInspectInfo[],
|
||||
) => boolean = isAllRunning,
|
||||
) => {
|
||||
// Get expected number of containers from target state
|
||||
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
|
||||
// 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`
|
||||
@ -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
|
||||
// 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`
|
||||
|
@ -5,10 +5,15 @@ import * as request from 'supertest';
|
||||
|
||||
import * as config from '~/src/config';
|
||||
import * as db from '~/src/db';
|
||||
import Service from '~/src/compose/service';
|
||||
import * as deviceApi from '~/src/device-api';
|
||||
import * as actions from '~/src/device-api/actions';
|
||||
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
|
||||
// the api-key module relying on the database.
|
||||
@ -263,4 +268,232 @@ describe('device-api/v1', () => {
|
||||
.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 actions from '~/src/device-api/actions';
|
||||
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
|
||||
// the api-key module relying on the database.
|
||||
@ -183,4 +187,454 @@ describe('device-api/v2', () => {
|
||||
.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', () => {
|
||||
it('returns MAC address', async () => {
|
||||
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 images from '~/src/compose/images';
|
||||
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 applicationManager from '~/src/compose/application-manager';
|
||||
import * as logger from '~/src/logger';
|
||||
|
||||
import { UpdatesLockedError } from '~/lib/errors';
|
||||
|
||||
describe('SupervisorAPI [V2 Endpoints]', () => {
|
||||
let serviceManagerMock: 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
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user