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:
Christina Ying Wang 2022-11-30 21:09:10 -08:00
parent fcd28591c6
commit c6cf6a0136
11 changed files with 1152 additions and 573 deletions

View File

@ -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 },
);
};

View File

@ -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.';

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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`

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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

View File

@ -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
});