From c6cf6a013610a2ec8eba8149ca17f0375324bc33 Mon Sep 17 00:00:00 2001 From: Christina Ying Wang Date: Wed, 30 Nov 2022 21:09:10 -0800 Subject: [PATCH] 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 --- src/device-api/actions.ts | 117 ++++- src/device-api/messages.ts | 10 +- src/device-api/v1.ts | 128 ++---- src/device-api/v2.ts | 176 +++----- src/lib/errors.ts | 17 +- src/lib/validation.ts | 2 +- test/integration/device-api/actions.spec.ts | 218 +++++++++- test/integration/device-api/v1.spec.ts | 235 +++++++++- test/integration/device-api/v2.spec.ts | 456 +++++++++++++++++++- test/legacy/41-device-api-v1.spec.ts | 105 ----- test/legacy/42-device-api-v2.spec.ts | 261 ----------- 11 files changed, 1152 insertions(+), 573 deletions(-) diff --git a/src/device-api/actions.ts b/src/device-api/actions.ts index 2866d957..b1c38bd6 100644 --- a/src/device-api/actions.ts +++ b/src/device-api/actions.ts @@ -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 => { + // 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 }, + ); +}; diff --git a/src/device-api/messages.ts b/src/device-api/messages.ts index 95606c8e..31fdbf54 100644 --- a/src/device-api/messages.ts +++ b/src/device-api/messages.ts @@ -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.'; diff --git a/src/device-api/v1.ts b/src/device-api/v1.ts index 69ff8630..450f3bdb 100644 --- a/src/device-api/v1.ts +++ b/src/device-api/v1.ts @@ -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 diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index 597306b8..80e41634 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -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 => { - 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 diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 20b592fa..86903757 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -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; } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 7c7e130a..aba84838 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -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; } diff --git a/test/integration/device-api/actions.spec.ts b/test/integration/device-api/actions.spec.ts index 845ad375..64d97d73 100644 --- a/test/integration/device-api/actions.spec.ts +++ b/test/integration/device-api/actions.spec.ts @@ -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, - 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` diff --git a/test/integration/device-api/v1.spec.ts b/test/integration/device-api/v1.spec.ts index 1a9292d6..a323b15d 100644 --- a/test/integration/device-api/v1.spec.ts +++ b/test/integration/device-api/v1.spec.ts @@ -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); + }); + }); }); diff --git a/test/integration/device-api/v2.spec.ts b/test/integration/device-api/v2.spec.ts index 1801946b..7c189c9c 100644 --- a/test/integration/device-api/v2.spec.ts +++ b/test/integration/device-api/v2.spec.ts @@ -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); + }); + }); }); diff --git a/test/legacy/41-device-api-v1.spec.ts b/test/legacy/41-device-api-v1.spec.ts index b437a1c4..eb622bd3 100644 --- a/test/legacy/41-device-api-v1.spec.ts +++ b/test/legacy/41-device-api-v1.spec.ts @@ -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 diff --git a/test/legacy/42-device-api-v2.spec.ts b/test/legacy/42-device-api-v2.spec.ts index 6dcc2f23..6d9dcd1a 100644 --- a/test/legacy/42-device-api-v2.spec.ts +++ b/test/legacy/42-device-api-v2.spec.ts @@ -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 });