diff --git a/docker-compose.test.yml b/docker-compose.test.yml index ea1fa261..88356754 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -10,6 +10,7 @@ services: args: ARCH: ${ARCH:-amd64} command: [ '/wait-for-it.sh', '--', '/usr/src/app/entry.sh' ] + stop_grace_period: 3s # Use bridge networking for the tests network_mode: 'bridge' networks: @@ -34,6 +35,7 @@ services: dbus: image: balenablocks/dbus + stop_grace_period: 3s environment: DBUS_CONFIG: session.conf DBUS_ADDRESS: unix:path=/run/dbus/system_bus_socket @@ -44,6 +46,7 @@ services: # requests dbus-services: build: ./test/lib/dbus + stop_grace_period: 3s depends_on: - dbus volumes: @@ -53,6 +56,7 @@ services: docker: image: docker:dind + stop_grace_period: 3s privileged: true environment: DOCKER_TLS_CERTDIR: '' @@ -81,6 +85,7 @@ services: - docker - dbus - dbus-services + stop_grace_period: 3s volumes: - dbus:/run/dbus # Set required supervisor configuration variables here diff --git a/src/device-api/actions.ts b/src/device-api/actions.ts new file mode 100644 index 00000000..388b9bb8 --- /dev/null +++ b/src/device-api/actions.ts @@ -0,0 +1,523 @@ +import * as Bluebird from 'bluebird'; +import * as _ from 'lodash'; + +import { getGlobalApiKey, refreshKey } from '.'; +import * as messages from './messages'; +import * as eventTracker from '../event-tracker'; +import * as deviceState from '../device-state'; +import * as logger from '../logger'; +import * as config from '../config'; +import * as hostConfig from '../host-config'; +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 * as commitStore from '../compose/commit'; +import { getApp } from '../device-state/db-format'; +import * as TargetState from '../device-state/target-state'; +import log from '../lib/supervisor-console'; +import blink = require('../lib/blink'); +import { lock } from '../lib/update-lock'; +import * as constants from '../lib/constants'; +import { + InternalInconsistencyError, + NotFoundError, + BadRequestError, +} from '../lib/errors'; + +import { InstancedDeviceState } from '../types'; + +/** + * Run an array of healthchecks, outputting whether all passed or not + * Used by: + * - GET /v1/healthy + */ +export const runHealthchecks = async ( + healthchecks: Array<() => Promise>, +) => { + const HEALTHCHECK_FAILURE = 'Healthcheck failed'; + + try { + const checks = await Promise.all(healthchecks.map((fn) => fn())); + if (checks.some((check) => !check)) { + throw new Error(HEALTHCHECK_FAILURE); + } + } catch { + log.error(HEALTHCHECK_FAILURE); + return false; + } + + return true; +}; + +/** + * Identify a device by blinking or some other method, if supported + * Used by: + * - POST /v1/blink + */ +const DEFAULT_IDENTIFY_DURATION = 15000; +export const identify = (ms: number = DEFAULT_IDENTIFY_DURATION) => { + eventTracker.track('Device blink'); + blink.pattern.start(); + setTimeout(blink.pattern.stop, ms); +}; + +/** + * Expires the supervisor's API key and generates a new one. + * Also communicates the new key to the balena API, if it's a key + * with global scope. The backend uses the global key to communicate + * with the Supervisor. + * Used by: + * - POST /v1/regenerate-api-key + */ +export const regenerateKey = async (oldKey: string) => { + const shouldReportUpdatedKey = oldKey === (await getGlobalApiKey()); + const newKey = await refreshKey(oldKey); + + if (shouldReportUpdatedKey) { + deviceState.reportCurrentState({ + api_secret: newKey, + }); + } + + return newKey; +}; + +/** + * Restarts an application by recreating containers. + * Used by: + * - POST /v1/restart + * - POST /v2/applications/:appId/restart + */ +export const doRestart = async (appId: number, force: boolean = false) => { + await deviceState.initialized(); + + return await lock(appId, { force }, async () => { + const currentState = await deviceState.getCurrentState(); + if (currentState.local.apps?.[appId] == null) { + throw new InternalInconsistencyError( + `Application with ID ${appId} is not in the current state`, + ); + } + const { services } = currentState.local.apps?.[appId]; + applicationManager.clearTargetVolatileForServices( + services.map((svc) => svc.imageId), + ); + + return deviceState.pausingApply(async () => { + for (const service of services) { + await serviceManager.kill(service, { wait: true }); + await serviceManager.start(service); + } + }); + }); +}; + +/** + * This doesn't truly return an InstancedDeviceState, but it's close enough to mostly work where it's used + */ +export function safeStateClone( + targetState: InstancedDeviceState, +): InstancedDeviceState { + // We avoid using cloneDeep here, as the class + // instances can cause a maximum call stack exceeded + // error + + // TODO: This should really return the config as it + // is returned from the api, but currently that's not + // the easiest thing due to the way they are stored and + // retrieved from the db - when all of the application + // manager is strongly typed, revisit this. The best + // thing to do would be to represent the input with + // io-ts and make sure the below conforms to it + + const cloned: DeepPartial = { + local: { + config: {}, + }, + dependent: { + config: {}, + }, + }; + + if (targetState.local != null) { + cloned.local = { + name: targetState.local.name, + config: _.cloneDeep(targetState.local.config), + apps: _.mapValues(targetState.local.apps, safeAppClone), + }; + } + if (targetState.dependent != null) { + cloned.dependent = _.cloneDeep(targetState.dependent); + } + + return cloned as InstancedDeviceState; +} + +export function safeAppClone(app: App): App { + const containerIdForService = _.fromPairs( + _.map(app.services, (svc) => [ + svc.serviceName, + svc.containerId != null ? svc.containerId.substring(0, 12) : '', + ]), + ); + return new App( + { + appId: app.appId, + appUuid: app.appUuid, + appName: app.appName, + commit: app.commit, + source: app.source, + services: app.services.map((svc) => { + // This is a bit of a hack, but when applying the target state as if it's + // the current state, this will include the previous containerId as a + // network alias. The container ID will be there as Docker adds it + // implicitly when creating a container. Here, we remove any previous + // container IDs before passing it back as target state. We have to do this + // here as when passing it back as target state, the service class cannot + // know that the alias being given is not in fact a user given one. + // TODO: Make the process of moving from a current state to a target state + // well-defined (and implemented in a separate module) + const svcCopy = _.cloneDeep(svc); + + _.each(svcCopy.config.networks, (net) => { + if (Array.isArray(net.aliases)) { + net.aliases = net.aliases.filter( + (alias) => alias !== containerIdForService[svcCopy.serviceName], + ); + } + }); + return svcCopy; + }), + volumes: _.cloneDeep(app.volumes), + networks: _.cloneDeep(app.networks), + isHost: app.isHost, + }, + true, + ); +} + +/** + * Purges volumes for an application. + * Used by: + * - POST /v1/purge + * - POST /v2/applications/:appId/purge + */ +export const doPurge = async (appId: number, force: boolean = false) => { + await deviceState.initialized(); + + logger.logSystemMessage( + `Purging data for app ${appId}`, + { appId }, + 'Purge data', + ); + + return await lock(appId, { force }, async () => { + const currentState = await deviceState.getCurrentState(); + if (currentState.local.apps?.[appId] == null) { + throw new InternalInconsistencyError( + `Application with ID ${appId} is not in the current state`, + ); + } + + const app = currentState.local.apps?.[appId]; + /** + * With multi-container, Docker adds an invalid network alias equal to the current containerId + * to that service's network configs when starting a service. Thus when reapplying intermediateState + * after purging, use a cloned state instance which automatically filters out invalid network aliases. + * This will prevent error logs like the following: + * https://gist.github.com/cywang117/84f9cd4e6a9641dbed530c94e1172f1d#file-logs-sh-L58 + * + * When networks do not match because of their aliases, services are killed and recreated + * an additional time which is unnecessary. Filtering prevents this additional restart BUT + * it is a stopgap measure until we can keep containerId network aliases from being stored + * in state's service config objects (TODO) + */ + const clonedState = safeStateClone(currentState); + // Set services & volumes as empty to be applied as intermediate state + app.services = []; + app.volumes = {}; + + applicationManager.setIsApplyingIntermediate(true); + + return deviceState + .pausingApply(() => + deviceState + .applyIntermediateTarget(currentState, { skipLock: true }) + .then(() => { + // Explicitly remove volumes because application-manager won't + // remove any volumes that are part of an active application. + return Bluebird.each(volumeManager.getAllByAppId(appId), (vol) => + vol.remove(), + ); + }) + .then(() => { + return deviceState.applyIntermediateTarget(clonedState, { + skipLock: true, + }); + }), + ) + .finally(() => { + applicationManager.setIsApplyingIntermediate(false); + deviceState.triggerApplyTarget(); + }); + }) + .then(() => + logger.logSystemMessage('Purged data', { appId }, 'Purge data success'), + ) + .catch((err) => { + applicationManager.setIsApplyingIntermediate(false); + + logger.logSystemMessage( + `Error purging data: ${err}`, + { appId, error: err }, + 'Purge data error', + ); + 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 device state action such as reboot, shutdown, or noop + * Used by: + * - POST /v1/reboot + * - POST /v1/shutdown + * - actions.executeServiceAction + */ +export const executeDeviceAction = async ( + step: Parameters[0], + force: boolean = false, +) => { + return await deviceState.executeStepAction(step, { + force, + }); +}; + +/** + * 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 executeDeviceAction( + generateStep(action, { + current: currentService, + target: targetService, + wait: true, + }), + force, + ); +}; + +/** + * Updates the target state cache of the Supervisor, which triggers an apply if applicable. + * Used by: + * - POST /v1/update + */ +export const updateTarget = async (force: boolean = false) => { + eventTracker.track('Update notification'); + + if (force || (await config.get('instantUpdates'))) { + TargetState.update(force, true).catch(_.noop); + return true; + } + + log.debug( + 'Ignoring update notification because instant updates are disabled or force not specified', + ); + return false; +}; + +/** + * Get application information for a single-container app, throwing if multicontainer + * Used by: + * - GET /v1/apps/:appId + */ +export const getSingleContainerApp = async (appId: number) => { + eventTracker.track('GET app (v1)', { appId }); + const apps = await applicationManager.getCurrentApps(); + const app = apps[appId]; + const service = app?.services?.[0]; + if (service == null) { + // This should return a 404 Not Found, but we can't change the interface now so keep it as a 400 + throw new BadRequestError('App not found'); + } + if (app.services.length > 1) { + throw new BadRequestError( + 'Some v1 endpoints are only allowed on single-container apps', + ); + } + + // Because we only have a single app, we can fetch the commit for that + // app, and maintain backwards compatability + const commit = await commitStore.getCommitForApp(appId); + + return { + appId, + commit, + containerId: service.containerId, + env: _.omit(service.config.environment, constants.privateAppEnvVars), + imageId: service.config.image, + releaseId: service.releaseId, + }; +}; + +/** + * Returns legacy device info, update status, and service status for a single-container application. + * Used by: + * - GET /v1/device + */ +export const getLegacyDeviceState = async () => { + const state = await deviceState.getLegacyState(); + const stateToSend = _.pick(state.local, [ + 'api_port', + 'ip_address', + 'os_version', + 'mac_address', + 'supervisor_version', + 'update_pending', + 'update_failed', + 'update_downloaded', + ]) as Dictionary; + + if (state.local?.is_on__commit != null) { + stateToSend.commit = state.local.is_on__commit; + } + + // NOTE: This only returns the status of the first service, + // even in a multi-container app. We should deprecate this endpoint + // in favor of a multi-container friendly device endpoint (which doesn't + // exist yet), and use that for cloud dashboard diagnostic queries. + const service = _.toPairs( + _.toPairs(state.local?.apps)[0]?.[1]?.services, + )[0]?.[1]; + + if (service != null) { + stateToSend.status = service.status; + if (stateToSend.status === 'Running') { + stateToSend.status = 'Idle'; + } + stateToSend.download_progress = service.download_progress; + } + + return stateToSend; +}; + +/** + * Get host config from the host-config module; Returns proxy config and hostname. + * Used by: + * - GET /v1/device/host-config + */ +export const getHostConfig = async () => { + return await hostConfig.get(); +}; + +/** + * Patch host configs such as proxy config and hostname + * Used by: + * - PATCH /v1/device/host-config + */ +export const patchHostConfig = async ( + conf: Parameters[0], + force: boolean, +) => { + // If hostname is an empty string, return first 7 digits of device uuid + if (conf.network?.hostname === '') { + const uuid = await config.get('uuid'); + conf.network.hostname = uuid?.slice(0, 7); + } + await hostConfig.patch(conf, force); +}; diff --git a/src/device-api/common.ts b/src/device-api/common.ts deleted file mode 100644 index 3a9af268..00000000 --- a/src/device-api/common.ts +++ /dev/null @@ -1,206 +0,0 @@ -import * as Bluebird from 'bluebird'; -import * as _ from 'lodash'; - -import * as logger from '../logger'; -import * as deviceState from '../device-state'; -import * as applicationManager from '../compose/application-manager'; -import * as serviceManager from '../compose/service-manager'; -import * as volumeManager from '../compose/volume-manager'; -import { App } from '../compose/app'; -import { InternalInconsistencyError } from '../lib/errors'; -import { lock } from '../lib/update-lock'; -import { appNotFoundMessage } from './messages'; - -import type { InstancedDeviceState } from '../types'; - -export async function doRestart(appId: number, force: boolean) { - await deviceState.initialized(); - await applicationManager.initialized(); - - return lock(appId, { force }, () => - deviceState.getCurrentState().then(function (currentState) { - if (currentState.local.apps?.[appId] == null) { - throw new InternalInconsistencyError( - `Application with ID ${appId} is not in the current state`, - ); - } - const allApps = currentState.local.apps; - - const app = allApps[appId]; - const imageIds = _.map(app.services, 'imageId'); - applicationManager.clearTargetVolatileForServices(imageIds); - - return deviceState.pausingApply(async () => { - return Bluebird.each(app.services, async (service) => { - await serviceManager.kill(service, { wait: true }); - await serviceManager.start(service); - }); - }); - }), - ); -} - -export async function doPurge(appId: number, force: boolean) { - await deviceState.initialized(); - await applicationManager.initialized(); - - logger.logSystemMessage( - `Purging data for app ${appId}`, - { appId }, - 'Purge data', - ); - return lock(appId, { force }, () => - deviceState.getCurrentState().then(function (currentState) { - const allApps = currentState.local.apps; - - if (allApps?.[appId] == null) { - throw new Error(appNotFoundMessage); - } - - const clonedState = safeStateClone(currentState); - /** - * With multi-container, Docker adds an invalid network alias equal to the current containerId - * to that service's network configs when starting a service. Thus when reapplying intermediateState - * after purging, use a cloned state instance which automatically filters out invalid network aliases. - * - * This will prevent error logs like the following: - * https://gist.github.com/cywang117/84f9cd4e6a9641dbed530c94e1172f1d#file-logs-sh-L58 - * - * When networks do not match because of their aliases, services are killed and recreated - * an additional time which is unnecessary. Filtering prevents this additional restart BUT - * it is a stopgap measure until we can keep containerId network aliases from being stored - * in state's service config objects (TODO) - * - * See https://github.com/balena-os/balena-supervisor/blob/master/src/device-api/common.js#L160-L180 - * for a more in-depth explanation of why aliases need to be filtered out. - */ - - // After cloning, set services & volumes as empty to be applied as intermediateTargetState - allApps[appId].services = []; - allApps[appId].volumes = {}; - - applicationManager.setIsApplyingIntermediate(true); - - return deviceState - .pausingApply(() => - deviceState - .applyIntermediateTarget(currentState, { skipLock: true }) - .then(() => { - // Now that we're not running anything, explicitly - // remove the volumes, we must do this here, as the - // application-manager will not remove any volumes - // which are part of an active application - return Bluebird.each(volumeManager.getAllByAppId(appId), (vol) => - vol.remove(), - ); - }) - .then(() => { - return deviceState.applyIntermediateTarget(clonedState, { - skipLock: true, - }); - }), - ) - .finally(() => { - applicationManager.setIsApplyingIntermediate(false); - deviceState.triggerApplyTarget(); - }); - }), - ) - .then(() => - logger.logSystemMessage('Purged data', { appId }, 'Purge data success'), - ) - .catch((err) => { - applicationManager.setIsApplyingIntermediate(false); - - logger.logSystemMessage( - `Error purging data: ${err}`, - { appId, error: err }, - 'Purge data error', - ); - throw err; - }); -} - -/** - * This doesn't truly return an InstancedDeviceState, but it's close enough to mostly work where it's used - */ -export function safeStateClone( - targetState: InstancedDeviceState, -): InstancedDeviceState { - // We avoid using cloneDeep here, as the class - // instances can cause a maximum call stack exceeded - // error - - // TODO: This should really return the config as it - // is returned from the api, but currently that's not - // the easiest thing due to the way they are stored and - // retrieved from the db - when all of the application - // manager is strongly typed, revisit this. The best - // thing to do would be to represent the input with - // io-ts and make sure the below conforms to it - - const cloned: DeepPartial = { - local: { - config: {}, - }, - dependent: { - config: {}, - }, - }; - - if (targetState.local != null) { - cloned.local = { - name: targetState.local.name, - config: _.cloneDeep(targetState.local.config), - apps: _.mapValues(targetState.local.apps, safeAppClone), - }; - } - if (targetState.dependent != null) { - cloned.dependent = _.cloneDeep(targetState.dependent); - } - - return cloned as InstancedDeviceState; -} - -export function safeAppClone(app: App): App { - const containerIdForService = _.fromPairs( - _.map(app.services, (svc) => [ - svc.serviceName, - svc.containerId != null ? svc.containerId.substring(0, 12) : '', - ]), - ); - return new App( - { - appId: app.appId, - appUuid: app.appUuid, - appName: app.appName, - commit: app.commit, - source: app.source, - services: _.map(app.services, (svc) => { - // This is a bit of a hack, but when applying the target state as if it's - // the current state, this will include the previous containerId as a - // network alias. The container ID will be there as Docker adds it - // implicitly when creating a container. Here, we remove any previous - // container IDs before passing it back as target state. We have to do this - // here as when passing it back as target state, the service class cannot - // know that the alias being given is not in fact a user given one. - // TODO: Make the process of moving from a current state to a target state - // well-defined (and implemented in a separate module) - const svcCopy = _.cloneDeep(svc); - - _.each(svcCopy.config.networks, (net) => { - if (Array.isArray(net.aliases)) { - net.aliases = net.aliases.filter( - (alias) => alias !== containerIdForService[svcCopy.serviceName], - ); - } - }); - return svcCopy; - }), - volumes: _.cloneDeep(app.volumes), - networks: _.cloneDeep(app.networks), - isHost: app.isHost, - }, - true, - ); -} diff --git a/src/device-api/index.ts b/src/device-api/index.ts index 67f196d0..03327c80 100644 --- a/src/device-api/index.ts +++ b/src/device-api/index.ts @@ -1,12 +1,9 @@ import * as express from 'express'; -import * as _ from 'lodash'; import * as middleware from './middleware'; import * as apiKeys from './api-keys'; -import * as eventTracker from '../event-tracker'; -import { reportCurrentState } from '../device-state'; +import * as actions from './actions'; import proxyvisor from '../proxyvisor'; -import blink = require('../lib/blink'); import log from '../lib/supervisor-console'; import type { Server } from 'http'; @@ -43,15 +40,10 @@ export class SupervisorAPI { this.api.use(middleware.logging); this.api.get('/v1/healthy', async (_req, res) => { - try { - const healths = await Promise.all(this.healthchecks.map((fn) => fn())); - if (!_.every(healths)) { - log.error('Healthcheck failed'); - return res.status(500).send('Unhealthy'); - } + const isHealthy = await actions.runHealthchecks(this.healthchecks); + if (isHealthy) { return res.sendStatus(200); - } catch { - log.error('Healthcheck failed'); + } else { return res.status(500).send('Unhealthy'); } }); @@ -61,36 +53,20 @@ export class SupervisorAPI { this.api.use(middleware.auth); this.api.post('/v1/blink', (_req, res) => { - eventTracker.track('Device blink'); - blink.pattern.start(); - setTimeout(blink.pattern.stop, 15000); + actions.identify(); return res.sendStatus(200); }); - // Expires the supervisor's API key and generates a new one. - // It also communicates the new key to the balena API. this.api.post( '/v1/regenerate-api-key', - async (req: apiKeys.AuthorizedRequest, res) => { - await apiKeys.initialized(); - - // check if we're updating the cloud API key - const shouldUpdateCloudKey = - req.auth.apiKey === (await getGlobalApiKey()); - - // regenerate the key... - const newKey = await apiKeys.refreshKey(req.auth.apiKey); - - // if we need to update the cloud API with our new key - if (shouldUpdateCloudKey) { - // report the new key to the cloud API - reportCurrentState({ - api_secret: newKey, - }); + async (req: apiKeys.AuthorizedRequest, res, next) => { + const { apiKey: oldKey } = req.auth; + try { + const newKey = await actions.regenerateKey(oldKey); + return res.status(200).send(newKey); + } catch (e: unknown) { + next(e); } - - // return the value of the new key to the caller - res.status(200).send(newKey); }, ); 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 9de0974c..138414d0 100644 --- a/src/device-api/v1.ts +++ b/src/device-api/v1.ts @@ -1,23 +1,21 @@ import * as express from 'express'; import * as _ from 'lodash'; +import type { Response } from 'express'; -import { doRestart, doPurge } from './common'; +import * as actions from './actions'; import { AuthorizedRequest } from './api-keys'; import * as eventTracker from '../event-tracker'; -import { isReadyForUpdates } from '../api-binder'; -import * as config from '../config'; 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 * as hostConfig from '../host-config'; -import * as applicationManager from '../compose/application-manager'; -import { generateStep } 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'; +import { + isNotFoundError, + isBadRequestError, + UpdatesLockedError, +} from '../lib/errors'; +import { CompositionStepAction } from '../compose/composition-steps'; const disallowedHostConfigPatchFields = ['local_ip', 'local_port']; @@ -33,167 +31,97 @@ 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 doRestart(appId, force) + return actions + .doRestart(appId, force) .then(() => res.status(200).send('OK')) .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', handleLegacyServiceAction('stop')); +router.post('/v1/apps/:appId/start', handleLegacyServiceAction('start')); -router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop')); -router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start')); - -const rebootOrShutdown = async ( - req: express.Request, - res: express.Response, - action: deviceState.DeviceStateStepTarget, -) => { - const override = await config.get('lockOverride'); - const force = checkTruthy(req.body.force) || override; - try { - const response = await deviceState.executeStepAction({ action }, { force }); - res.status(202).json(response); - } catch (e: any) { - const status = e instanceof UpdatesLockedError ? 423 : 500; - res.status(status).json({ - Data: '', - Error: (e != null ? e.message : undefined) || e || 'Unknown error', - }); - } +const handleDeviceAction = (action: deviceState.DeviceStateStepTarget) => { + return async (req: AuthorizedRequest, res: Response) => { + const force = checkTruthy(req.body.force); + try { + await actions.executeDeviceAction({ action }, force); + return res.status(202).send({ Data: 'OK', Error: null }); + } catch (e: unknown) { + const status = e instanceof UpdatesLockedError ? 423 : 500; + return res.status(status).json({ + Data: '', + Error: (e as Error)?.message ?? e ?? 'Unknown error', + }); + } + }; }; -router.post('/v1/reboot', (req, res) => rebootOrShutdown(req, res, 'reboot')); -router.post('/v1/shutdown', (req, res) => - rebootOrShutdown(req, res, 'shutdown'), -); +router.post('/v1/reboot', handleDeviceAction('reboot')); +router.post('/v1/shutdown', handleDeviceAction('shutdown')); router.get('/v1/apps/:appId', async (req: AuthorizedRequest, res, next) => { const appId = checkInt(req.params.appId); - eventTracker.track('GET app (v1)', { appId }); if (appId == null) { return res.status(400).send('Missing app id'); } + // handle the case where the appId is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + return res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + } + try { - const apps = await applicationManager.getCurrentApps(); - const app = apps[appId]; - const service = app?.services?.[0]; - if (service == null) { - return res.status(400).send('App not found'); + const app = await actions.getSingleContainerApp(appId); + return res.json(app); + } catch (e: unknown) { + if (isBadRequestError(e) || isNotFoundError(e)) { + return res.status(e.statusCode).send(e.statusMessage); } - - // handle the case where the appId is out of scope - if (!req.auth.isScoped({ apps: [app.appId] })) { - res.status(401).json({ - status: 'failed', - message: 'Application is not available', - }); - return; - } - - if (app.services.length > 1) { - return res - .status(400) - .send('Some v1 endpoints are only allowed on single-container apps'); - } - - // Because we only have a single app, we can fetch the commit for that - // app, and maintain backwards compatability - const commit = await commitStore.getCommitForApp(appId); - - // Don't return data that will be of no use to the user - const appToSend = { - appId, - commit, - containerId: service.containerId, - env: _.omit(service.config.environment, constants.privateAppEnvVars), - imageId: service.config.image, - releaseId: service.releaseId, - }; - - return res.json(appToSend); - } catch (e) { next(e); } }); @@ -208,54 +136,41 @@ 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 doPurge(appId, force) + return actions + .doPurge(appId, force) .then(() => res.status(200).json({ Data: 'OK', Error: '' })) .catch(next); }); -router.post('/v1/update', (req, res, next) => { - eventTracker.track('Update notification'); - if (isReadyForUpdates()) { - config - .get('instantUpdates') - .then((instantUpdates) => { - if (instantUpdates) { - TargetState.update(req.body.force, true).catch(_.noop); - res.sendStatus(204); - } else { - log.debug( - 'Ignoring update notification because instant updates are disabled', - ); - res.sendStatus(202); - } - }) - .catch(next); - } else { - res.sendStatus(202); +router.post('/v1/update', async (req, res, next) => { + const force = checkTruthy(req.body.force); + try { + const result = await actions.updateTarget(force); + return res.sendStatus(result ? 204 : 202); + } catch (e: unknown) { + next(e); } }); -router.get('/v1/device/host-config', (_req, res) => - hostConfig - .get() - .then((conf) => res.json(conf)) - .catch((err) => - res.status(503).send(err?.message ?? err ?? 'Unknown error'), - ), -); +router.get('/v1/device/host-config', async (_req, res, next) => { + try { + const conf = await actions.getHostConfig(); + return res.json(conf); + } catch (e: unknown) { + next(e); + } +}); router.patch('/v1/device/host-config', async (req, res) => { // Because v1 endpoints are legacy, and this endpoint might already be used // by multiple users, adding too many throws might have unintended side effects. // Thus we're simply logging invalid fields and allowing the request to continue. - try { if (!req.body.network) { log.warn("Key 'network' must exist in PATCH body"); @@ -295,60 +210,24 @@ router.patch('/v1/device/host-config', async (req, res) => { } try { - // If hostname is an empty string, return first 7 digits of device uuid - if (req.body.network?.hostname === '') { - const uuid = await config.get('uuid'); - req.body.network.hostname = uuid?.slice(0, 7); + await actions.patchHostConfig(req.body, checkTruthy(req.body.force)); + return res.status(200).send('OK'); + } catch (e: unknown) { + // Normally the error middleware handles 423 / 503 errors, however this interface + // throws the errors in a different format (text) compared to the middleware (JSON). + // Therefore we need to keep this here to keep the interface consistent. + if (e instanceof UpdatesLockedError) { + return res.status(423).send(e?.message ?? e); } - const lockOverride = await config.get('lockOverride'); - await hostConfig.patch( - req.body, - checkTruthy(req.body.force) || lockOverride, - ); - res.status(200).send('OK'); - } catch (err: any) { - // TODO: We should be able to throw err if it's UpdatesLockedError - // and the error middleware will handle it, but this doesn't work in - // the test environment. Fix this when fixing API tests. - if (err instanceof UpdatesLockedError) { - return res.status(423).send(err?.message ?? err); - } - res.status(503).send(err?.message ?? err ?? 'Unknown error'); + return res.status(503).send((e as Error)?.message ?? e ?? 'Unknown error'); } }); -router.get('/v1/device', async (_req, res) => { +router.get('/v1/device', async (_req, res, next) => { try { - const state = await deviceState.getLegacyState(); - const stateToSend = _.pick(state.local, [ - 'api_port', - 'ip_address', - 'os_version', - 'mac_address', - 'supervisor_version', - 'update_pending', - 'update_failed', - 'update_downloaded', - ]) as Dictionary; - if (state.local?.is_on__commit != null) { - stateToSend.commit = state.local.is_on__commit; - } - const service = _.toPairs( - _.toPairs(state.local?.apps)[0]?.[1]?.services, - )[0]?.[1]; - - if (service != null) { - stateToSend.status = service.status; - if (stateToSend.status === 'Running') { - stateToSend.status = 'Idle'; - } - stateToSend.download_progress = service.download_progress; - } - res.json(stateToSend); - } catch (e: any) { - res.status(500).json({ - Data: '', - Error: (e != null ? e.message : undefined) || e || 'Unknown error', - }); + const state = await actions.getLegacyDeviceState(); + return res.json(state); + } catch (e: unknown) { + next(e); } }); diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index aeaf99ac..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,103 +18,88 @@ 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 { doPurge, doRestart, safeStateClone } from './common'; 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', (req: AuthorizedRequest, res: Response, next: NextFunction) => { - const { force } = req.body; const appId = checkInt(req.params.appId); + const force = checkTruthy(req.body.force); if (!appId) { return res.status(400).json({ status: 'failed', @@ -130,11 +111,12 @@ router.post( if (!req.auth.isScoped({ apps: [appId] })) { return res.status(401).json({ status: 'failed', - message: 'Application is not available', + message: 'Unauthorized', }); } - return doPurge(appId, force) + return actions + .doPurge(appId, force) .then(() => { res.status(200).send('OK'); }) @@ -142,26 +124,11 @@ 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) => { - const { force } = req.body; const appId = checkInt(req.params.appId); + const force = checkTruthy(req.body.force); if (!appId) { return res.status(400).json({ status: 'failed', @@ -171,14 +138,14 @@ 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 doRestart(appId, force) + return actions + .doRestart(appId, force) .then(() => { res.status(200).send('OK'); }) @@ -304,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 @@ -327,7 +293,7 @@ router.get( router.get('/v2/local/target-state', async (_req, res) => { const targetState = await deviceState.getTarget(); - const target = safeStateClone(targetState); + const target = actions.safeStateClone(targetState); res.status(200).json({ status: 'success', diff --git a/src/device-state.ts b/src/device-state.ts index e62616c7..1b7b64d9 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -89,11 +89,7 @@ export type DeviceStateStepTarget = 'reboot' | 'shutdown' | 'noop'; type PossibleStepTargets = CompositionStepAction | DeviceStateStepTarget; type DeviceStateStep = - | { - action: 'reboot'; - } - | { action: 'shutdown' } - | { action: 'noop' } + | { action: DeviceStateStepTarget } | CompositionStepT | deviceConfig.ConfigStep; @@ -564,8 +560,8 @@ export async function shutdown({ }); } -export async function executeStepAction( - step: DeviceStateStep, +export async function executeStepAction( + step: DeviceStateStep, { force, initial, @@ -586,19 +582,12 @@ export async function executeStepAction( case 'reboot': // There isn't really a way that these methods can fail, // and if they do, we wouldn't know about it until after - // the response has been sent back to the API. Just return - // "OK" for this and the below action + // the response has been sent back to the API. await shutdown({ force, reboot: true }); - return { - Data: 'OK', - Error: null, - }; + return; case 'shutdown': await shutdown({ force, reboot: false }); - return { - Data: 'OK', - Error: null, - }; + return; case 'noop': return; default: @@ -607,8 +596,8 @@ export async function executeStepAction( } } -export async function applyStep( - step: DeviceStateStep, +export async function applyStep( + step: DeviceStateStep, { force, initial, @@ -623,12 +612,12 @@ export async function applyStep( return; } try { - const stepResult = await executeStepAction(step, { + await executeStepAction(step, { force, initial, skipLock, }); - emitAsync('step-completed', null, step, stepResult || undefined); + emitAsync('step-completed', null, step); } catch (e: any) { emitAsync('step-error', e, step); throw e; diff --git a/src/host-config.ts b/src/host-config.ts index 6d3fcc69..52caf6d7 100644 --- a/src/host-config.ts +++ b/src/host-config.ts @@ -1,4 +1,3 @@ -import * as Bluebird from 'bluebird'; import { stripIndent } from 'common-tags'; import * as _ from 'lodash'; import { promises as fs } from 'fs'; @@ -158,13 +157,13 @@ async function setProxy(maybeConf: ProxyConfig | null): Promise { // restart balena-proxy-config if it is loaded and NOT PartOf redsocks-conf.target if ( ( - await Bluebird.any([ + await Promise.any([ dbus.servicePartOf('balena-proxy-config'), dbus.servicePartOf('resin-proxy-config'), ]) ).includes('redsocks-conf.target') === false ) { - await Bluebird.any([ + await Promise.any([ dbus.restartService('balena-proxy-config'), dbus.restartService('resin-proxy-config'), ]); @@ -191,39 +190,38 @@ async function setHostname(val: string) { // restart balena-hostname if it is loaded and NOT PartOf config-json.target if ( ( - await Bluebird.any([ + await Promise.any([ dbus.servicePartOf('balena-hostname'), dbus.servicePartOf('resin-hostname'), ]) ).includes('config-json.target') === false ) { - await Bluebird.any([ + await Promise.any([ dbus.restartService('balena-hostname'), dbus.restartService('resin-hostname'), ]); } } -// Don't use async/await here to maintain the bluebird -// promises being returned -export function get(): Bluebird { - return Bluebird.join(readProxy(), readHostname(), (proxy, hostname) => { - return { - network: { - proxy, - hostname, - }, - }; - }); +export async function get(): Promise { + return { + network: { + proxy: await readProxy(), + hostname: await readHostname(), + }, + }; } -export async function patch(conf: HostConfig, force: boolean): Promise { +export async function patch( + conf: HostConfig, + force: boolean = false, +): Promise { const apps = await applicationManager.getCurrentApps(); const appIds = Object.keys(apps).map((strId) => parseInt(strId, 10)); // It's possible for appIds to be an empty array, but patch shouldn't fail // as it's not dependent on there being any running user applications. - return updateLock.lock(appIds, { force }, () => { + return updateLock.lock(appIds, { force }, async () => { const promises: Array> = []; if (conf != null && conf.network != null) { if (conf.network.proxy != null) { @@ -233,6 +231,6 @@ export async function patch(conf: HostConfig, force: boolean): Promise { promises.push(setHostname(conf.network.hostname)); } } - return Bluebird.all(promises).return(); + await Promise.all(promises); }); } 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/data/device-api-responses.json b/test/data/device-api-responses.json index 46d1c133..3347e488 100644 --- a/test/data/device-api-responses.json +++ b/test/data/device-api-responses.json @@ -1,123 +1,4 @@ { - "V1": { - "GET": { - "/healthy": { - "statusCode": 200, - "body": {}, - "text": "OK" - }, - "/healthy [2]": { - "statusCode": 500, - "body": {}, - "text": "Unhealthy" - }, - "/apps/2": { - "statusCode": 200, - "body": { - "appId": 2, - "containerId": "abc123", - "commit": "4e380136c2cf56cd64197d51a1ab263a", - "env": {}, - "releaseId": 77777 - } - }, - "/apps/2 [Multiple containers running]": { - "statusCode": 400, - "body": { - "appId": 2, - "containerId": "abc123", - "commit": "4e380136c2cf56cd64197d51a1ab263a", - "env": {}, - "releaseId": 77777 - } - }, - "/apps/2/stop": { - "statusCode": 200, - "body": { - "containerId": "abc123" - } - }, - "/apps/2/stop [Multiple containers running]": { - "statusCode": 400, - "body": { - "containerId": "abc123" - } - }, - "/device/host-config [Hostname only]": { - "statusCode": 200, - "body": { "network": { "hostname": "foobardevice" } } - }, - "/device/host-config [Hostname and proxy]": { - "statusCode": 200, - "body": { - "network": { - "hostname": "foobardevice", - "proxy": { - "ip": "example.org", - "noProxy": ["152.10.30.4", "253.1.1.0/16"], - "port": 1080, - "type": "socks5", - "login": "foo", - "password": "bar" - } - } - } - } - }, - "POST": { - "/restart": { - "statusCode": 200, - "body": {}, - "text": "OK" - }, - "/restart [Invalid Body]": { - "statusCode": 400, - "body": {}, - "text": "Missing app id" - }, - "/update [204 Response]": { - "statusCode": 204, - "body": {}, - "text": "OK" - }, - "/update [202 Response]": { - "statusCode": 202, - "body": {}, - "text": "OK" - }, - "/blink": { - "statusCode": 200, - "body": {}, - "text": "OK" - }, - "/regenerate-api-key": { - "statusCode": 200, - "body": {} - }, - "/purge [200]": { - "statusCode": 200, - "body": { "Data": "OK", "Error": "" } - }, - "/purge [400 Invalid/missing appId]": { - "statusCode": 400, - "text": "Invalid or missing appId" - }, - "/purge [401 Out of scope]": { - "statusCode": 401, - "body": { - "status": "failed", - "message": "Application is not available" - } - } - }, - "PATCH": { - "/host/device-config": { - "statusCode": 200, - "body": {}, - "text": "OK" - } - } - }, "V2": { "GET": { "/device/vpn": { diff --git a/test/data/mnt/boot/system-proxy/no_proxy.template b/test/data/mnt/boot/system-proxy/no_proxy.template deleted file mode 100644 index 2fc823b7..00000000 --- a/test/data/mnt/boot/system-proxy/no_proxy.template +++ /dev/null @@ -1,2 +0,0 @@ -152.10.30.4 -253.1.1.0/16 diff --git a/test/data/mnt/boot/system-proxy/redsocks.conf.template b/test/data/mnt/boot/system-proxy/redsocks.conf.template deleted file mode 100644 index b5975580..00000000 --- a/test/data/mnt/boot/system-proxy/redsocks.conf.template +++ /dev/null @@ -1,17 +0,0 @@ -base { - log_debug = off; - log_info = on; - log = stderr; - daemon = off; - redirector = iptables; -} - -redsocks { - local_ip = 127.0.0.1; - local_port = 12345; - ip = example.org; - port = 1080; - type = socks5; - login = "foo"; - password = "bar"; -} diff --git a/test/integration/compose/application-manager.spec.ts b/test/integration/compose/application-manager.spec.ts index b107c3db..37721dab 100644 --- a/test/integration/compose/application-manager.spec.ts +++ b/test/integration/compose/application-manager.spec.ts @@ -1,166 +1,21 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import * as Docker from 'dockerode'; -import App from '~/src/compose/app'; import * as applicationManager from '~/src/compose/application-manager'; import * as imageManager from '~/src/compose/images'; import * as serviceManager from '~/src/compose/service-manager'; -import { Image } from '~/src/compose/images'; import Network from '~/src/compose/network'; import * as networkManager from '~/src/compose/network-manager'; -import Service from '~/src/compose/service'; -import { ServiceComposeConfig } from '~/src/compose/types/service'; import Volume from '~/src/compose/volume'; -import { InstancedAppState } from '~/src/types/state'; import * as config from '~/src/config'; import { createDockerImage } from '~/test-lib/docker-helper'; - -const DEFAULT_NETWORK = Network.fromComposeObject('default', 1, 'appuuid', {}); - -async function createService( - { - appId = 1, - appUuid = 'appuuid', - serviceName = 'main', - commit = 'main-commit', - ...conf - } = {} as Partial, - { state = {} as Partial, options = {} as any } = {}, -) { - const svc = await Service.fromComposeObject( - { - appId, - appUuid, - serviceName, - commit, - // db ids should not be used for target state calculation, but images - // are compared using _.isEqual so leaving this here to have image comparisons - // match - serviceId: 1, - imageId: 1, - releaseId: 1, - ...conf, - }, - options, - ); - - // Add additonal configuration - for (const k of Object.keys(state)) { - (svc as any)[k] = (state as any)[k]; - } - return svc; -} - -function createImage( - { - appId = 1, - appUuid = 'appuuid', - name = 'test-image', - serviceName = 'main', - commit = 'main-commit', - ...extra - } = {} as Partial, -) { - return { - appId, - appUuid, - name, - serviceName, - commit, - // db ids should not be used for target state calculation, but images - // are compared using _.isEqual so leaving this here to have image comparisons - // match - imageId: 1, - releaseId: 1, - serviceId: 1, - dependent: 0, - ...extra, - } as Image; -} - -function createApps( - { - services = [] as Service[], - networks = [] as Network[], - volumes = [] as Volume[], - }, - target = false, -) { - const servicesByAppId = services.reduce( - (svcs, s) => ({ ...svcs, [s.appId]: [s].concat(svcs[s.appId] || []) }), - {} as Dictionary, - ); - const volumesByAppId = volumes.reduce( - (vols, v) => ({ ...vols, [v.appId]: [v].concat(vols[v.appId] || []) }), - {} as Dictionary, - ); - const networksByAppId = networks.reduce( - (nets, n) => ({ ...nets, [n.appId]: [n].concat(nets[n.appId] || []) }), - {} as Dictionary, - ); - - const allAppIds = [ - ...new Set([ - ...Object.keys(servicesByAppId), - ...Object.keys(networksByAppId), - ...Object.keys(volumesByAppId), - ]), - ].map((i) => parseInt(i, 10)); - - const apps: InstancedAppState = {}; - for (const appId of allAppIds) { - apps[appId] = new App( - { - appId, - services: servicesByAppId[appId] ?? [], - networks: (networksByAppId[appId] ?? []).reduce( - (nets, n) => ({ ...nets, [n.name]: n }), - {}, - ), - volumes: (volumesByAppId[appId] ?? []).reduce( - (vols, v) => ({ ...vols, [v.name]: v }), - {}, - ), - }, - target, - ); - } - - return apps; -} - -function createCurrentState({ - services = [] as Service[], - networks = [] as Network[], - volumes = [] as Volume[], - images = services.map((s) => ({ - // Infer images from services by default - dockerImageId: s.dockerImageId, - ...imageManager.imageFromService(s), - })) as Image[], - downloading = [] as string[], -}) { - const currentApps = createApps({ services, networks, volumes }); - - const containerIdsByAppId = services.reduce( - (ids, s) => ({ - ...ids, - [s.appId]: { - ...ids[s.appId], - ...(s.serviceName && - s.containerId && { [s.serviceName]: s.containerId }), - }, - }), - {} as { [appId: number]: Dictionary }, - ); - - return { - currentApps, - availableImages: images, - downloading, - containerIdsByAppId, - }; -} +import { + createService, + createImage, + createApps, + createCurrentState, + DEFAULT_NETWORK, +} from '~/test-lib/state-helper'; // TODO: application manager inferNextSteps still queries some stuff from // the engine instead of receiving that information as parameter. Refactoring diff --git a/test/integration/device-api/actions.spec.ts b/test/integration/device-api/actions.spec.ts new file mode 100644 index 00000000..afcac695 --- /dev/null +++ b/test/integration/device-api/actions.spec.ts @@ -0,0 +1,776 @@ +import { expect } from 'chai'; +import { stub, SinonStub, spy, SinonSpy } from 'sinon'; +import * as Docker from 'dockerode'; +import * as request from 'supertest'; +import { setTimeout } from 'timers/promises'; + +import * as deviceState from '~/src/device-state'; +import * as config from '~/src/config'; +import * as hostConfig from '~/src/host-config'; +import * as deviceApi from '~/src/device-api'; +import * as actions from '~/src/device-api/actions'; +import * as TargetState from '~/src/device-state/target-state'; +import * as dbus from '~/lib/dbus'; +import { cleanupDocker } from '~/test-lib/docker-helper'; + +describe('regenerates API keys', () => { + // Stub external dependency - current state report should be tested separately. + // API key related methods are tested in api-keys.spec.ts. + beforeEach(() => stub(deviceState, 'reportCurrentState')); + afterEach(() => (deviceState.reportCurrentState as SinonStub).restore()); + + it("communicates new key to cloud if it's a global key", async () => { + const originalGlobalKey = await deviceApi.getGlobalApiKey(); + const newKey = await actions.regenerateKey(originalGlobalKey); + expect(originalGlobalKey).to.not.equal(newKey); + expect(newKey).to.equal(await deviceApi.getGlobalApiKey()); + expect(deviceState.reportCurrentState as SinonStub).to.have.been.calledOnce; + expect( + (deviceState.reportCurrentState as SinonStub).firstCall.args[0], + ).to.deep.equal({ + api_secret: newKey, + }); + }); + + it("doesn't communicate new key if it's a service key", async () => { + const originalScopedKey = await deviceApi.generateScopedKey(111, 'main'); + const newKey = await actions.regenerateKey(originalScopedKey); + expect(originalScopedKey).to.not.equal(newKey); + expect(newKey).to.not.equal(await deviceApi.getGlobalApiKey()); + expect(deviceState.reportCurrentState as SinonStub).to.not.have.been.called; + }); +}); + +describe('manages application lifecycle', () => { + const BASE_IMAGE = 'alpine:latest'; + const BALENA_SUPERVISOR_ADDRESS = + process.env.BALENA_SUPERVISOR_ADDRESS || 'http://balena-supervisor:48484'; + const APP_ID = 1; + const docker = new Docker(); + + const getSupervisorTarget = async () => + await request(BALENA_SUPERVISOR_ADDRESS) + .get('/v2/local/target-state') + .expect(200) + .then(({ body }) => body.state.local); + + const setSupervisorTarget = async ( + target: Awaited>, + ) => + await request(BALENA_SUPERVISOR_ADDRESS) + .post('/v2/local/target-state') + .set('Content-Type', 'application/json') + .send(JSON.stringify(target)) + .expect(200); + + const generateTargetApps = ({ + serviceCount, + appId, + serviceNames, + }: { + serviceCount: number; + appId: number; + serviceNames: string[]; + }) => { + // Populate app services + const services: Dictionary = {}; + for (let i = 1; i <= serviceCount; i++) { + services[i] = { + environment: {}, + image: BASE_IMAGE, + imageId: `${i}`, + labels: { + 'io.balena.testing': '1', + }, + restart: 'unless-stopped', + running: true, + serviceName: serviceNames[i - 1], + serviceId: `${i}`, + volumes: ['data:/data'], + command: 'sleep infinity', + // Kill container immediately instead of waiting for 10s + stop_signal: 'SIGKILL', + }; + } + + return { + [appId]: { + name: 'localapp', + commit: 'localcommit', + releaseId: '1', + services, + volumes: { + data: {}, + }, + }, + }; + }; + + const generateTarget = async ({ + serviceCount, + appId = APP_ID, + serviceNames = ['server', 'client'], + }: { + serviceCount: number; + appId?: number; + serviceNames?: string[]; + }) => { + const { name, config: svConfig } = await getSupervisorTarget(); + return { + local: { + // We don't want to change name or config as this may result in + // unintended reboots. We just want to test state changes in containers. + name, + config: svConfig, + apps: + serviceCount === 0 + ? {} + : generateTargetApps({ + serviceCount, + appId, + serviceNames, + }), + }, + }; + }; + + 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 = isAllRunning, + ) => { + // Get expected number of containers from target state + const expected = Object.keys( + targetState.local.apps[`${APP_ID}`].services, + ).length; + + // Wait for engine until number of containers are reached. + // This test suite will timeout if anything goes wrong, since + // we don't have any way of knowing whether Docker has finished + // setting up containers or not. + while (true) { + const containers = await docker.listContainers({ all: true }); + const containerInspects = await Promise.all( + containers.map(({ Id }) => docker.getContainer(Id).inspect()), + ); + if (expected === containers.length && isWaitComplete(containerInspects)) { + return containerInspects; + } else { + await setTimeout(500); + } + } + }; + + // Get NEW container inspects. This function should be passed to waitForSetup + // when checking a container has started or been recreated. This is necessary + // because waitForSetup may erroneously return the existing 2 containers + // in its while loop if stopping them takes some time. + const startTimesChanged = (startedAt: string[]) => { + return (ctns: Docker.ContainerInspectInfo[]) => + ctns.every(({ State }) => !startedAt.includes(State.StartedAt)); + }; + + before(async () => { + // Images are ignored in local mode so we need to pull the base image + await docker.pull(BASE_IMAGE); + // Wait for base image to finish pulling + while (true) { + const images = await docker.listImages(); + if (images.length > 0) { + break; + } + await setTimeout(500); + } + }); + + after(async () => { + // Reset Supervisor to state from before lifecycle tests + await setSupervisorTarget(await generateTarget({ serviceCount: 0 })); + + // Remove any leftover engine artifacts + await cleanupDocker(docker); + }); + + describe('manages single container application lifecycle', () => { + const serviceCount = 1; + const serviceNames = ['server']; + let targetState: Awaited>; + let containers: Docker.ContainerInspectInfo[]; + + before(async () => { + targetState = await generateTarget({ + serviceCount, + serviceNames, + }); + }); + + beforeEach(async () => { + // Create a single-container application in local mode + await setSupervisorTarget(targetState); + }); + + // Make sure the app is running and correct before testing more assertions + it('should setup a single container app (sanity check)', async () => { + containers = await waitForSetup(targetState); + // Containers should have correct metadata; + // Testing their names should be sufficient. + containers.forEach((ctn) => { + expect(serviceNames.some((name) => new RegExp(name).test(ctn.Name))).to + .be.true; + }); + }); + + it('should restart an application by recreating containers', async () => { + containers = await waitForSetup(targetState); + const isRestartSuccessful = startTimesChanged( + containers.map((ctn) => ctn.State.StartedAt), + ); + + await actions.doRestart(APP_ID); + + 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 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, + }); + }); + + it('should return information about a single-container app', async () => { + containers = await waitForSetup(targetState); + const containerId = containers[0].Id; + const imageHash = containers[0].Config.Image; + + // Calling actions.getSingleContainerApp doesn't work because + // the action queries the database + const { body } = await request(BALENA_SUPERVISOR_ADDRESS).get( + '/v1/apps/1', + ); + + expect(body).to.have.property('appId', APP_ID); + expect(body).to.have.property('containerId', containerId); + expect(body).to.have.property('imageId', imageHash); + expect(body).to.have.property('releaseId', 1); + // Should return the environment of the single service + expect(body.env).to.have.property('BALENA_APP_ID', String(APP_ID)); + expect(body.env).to.have.property('BALENA_SERVICE_NAME', serviceNames[0]); + }); + + it('should return legacy information about device state', async () => { + containers = await waitForSetup(targetState); + + const { body } = await request(BALENA_SUPERVISOR_ADDRESS).get( + '/v1/device', + ); + + expect(body).to.have.property('api_port', 48484); + // Versions match semver versioning scheme: major.minor.patch(+rev)? + expect(body) + .to.have.property('os_version') + .that.matches(/balenaOS\s[1-2]\.[0-9]{1,3}\.[0-9]{1,3}(?:\+rev[0-9])?/); + expect(body) + .to.have.property('supervisor_version') + .that.matches(/(?:[0-9]+\.?){3}/); + // Matches a space-separated string of IPv4 and/or IPv6 addresses + expect(body) + .to.have.property('ip_address') + .that.matches( + /(?:(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})|(?:(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}))\s?/, + ); + // Matches a space-separated string of MAC addresses + expect(body) + .to.have.property('mac_address') + .that.matches(/(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\s?/); + expect(body).to.have.property('update_pending').that.is.a('boolean'); + expect(body).to.have.property('update_failed').that.is.a('boolean'); + expect(body).to.have.property('update_downloaded').that.is.a('boolean'); + // Container should be running so the overall status is Idle + expect(body).to.have.property('status', 'Idle'); + expect(body).to.have.property('download_progress', null); + }); + + // 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` + // and the `CreatedAt` extracted from the new volume to pass this assertion. + it('should purge an application by removing services then removing volumes', async () => { + containers = await waitForSetup(targetState); + const isRestartSuccessful = startTimesChanged( + containers.map((ctn) => ctn.State.StartedAt), + ); + + // Get volume metadata. As the name stays the same, we just need to check that the volume + // has been deleted & recreated. We can use the CreatedAt timestamp to determine this. + const volume = (await docker.listVolumes()).Volumes.find((vol) => + /data/.test(vol.Name), + ); + if (!volume) { + expect.fail('Expected initial volume with name matching "data"'); + } + // CreatedAt is a valid key but isn't typed properly + const createdAt = (volume as any).CreatedAt; + + // Calling actions.doPurge won't work as intended because purge relies on + // setting and applying intermediate state before applying target state again, + // but target state is set in the balena-supervisor container instead of sut. + // NOTE: if running ONLY this test, it has a chance of failing since the first and + // second volume creation happen in quick succession (sometimes in the same second). + await request(BALENA_SUPERVISOR_ADDRESS) + .post('/v1/purge') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ appId: 1 })); + + 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), + ); + + // Volume should be recreated + const newVolume = (await docker.listVolumes()).Volumes.find((vol) => + /data/.test(vol.Name), + ); + if (!volume) { + expect.fail('Expected recreated volume with name matching "data"'); + } + expect((newVolume as any).CreatedAt).to.not.equal(createdAt); + }); + }); + + describe('manages multi-container application lifecycle', () => { + const serviceCount = 2; + const serviceNames = ['server', 'client']; + let targetState: Awaited>; + let containers: Docker.ContainerInspectInfo[]; + + before(async () => { + targetState = await generateTarget({ + serviceCount, + serviceNames, + }); + }); + + beforeEach(async () => { + // Create a multi-container application in local mode + await setSupervisorTarget(targetState); + }); + + // Make sure the app is running and correct before testing more assertions + it('should setup a multi-container app (sanity check)', async () => { + containers = await waitForSetup(targetState); + // Containers should have correct metadata; + // Testing their names should be sufficient. + containers.forEach((ctn) => { + expect(serviceNames.some((name) => new RegExp(name).test(ctn.Name))).to + .be.true; + }); + }); + + it('should restart an application by recreating containers', async () => { + containers = await waitForSetup(targetState); + const isRestartSuccessful = startTimesChanged( + containers.map((ctn) => ctn.State.StartedAt), + ); + + await actions.doRestart(APP_ID); + + 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 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` + // and the `CreatedAt` extracted from the new volume to pass this assertion. + it('should purge an application by removing services then removing volumes', async () => { + containers = await waitForSetup(targetState); + const isRestartSuccessful = startTimesChanged( + containers.map((ctn) => ctn.State.StartedAt), + ); + + // Get volume metadata. As the name stays the same, we just need to check that the volume + // has been deleted & recreated. We can use the CreatedAt timestamp to determine this. + const volume = (await docker.listVolumes()).Volumes.find((vol) => + /data/.test(vol.Name), + ); + if (!volume) { + expect.fail('Expected initial volume with name matching "data"'); + } + // CreatedAt is a valid key but isn't typed properly + const createdAt = (volume as any).CreatedAt; + + // Calling actions.doPurge won't work as intended because purge relies on + // setting and applying intermediate state before applying target state again, + // but target state is set in the balena-supervisor container instead of sut. + // NOTE: if running ONLY this test, it has a chance of failing since the first and + // second volume creation happen in quick succession (sometimes in the same second). + await request(BALENA_SUPERVISOR_ADDRESS) + .post('/v1/purge') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ appId: 1 })); + + 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), + ); + + // Volume should be recreated + const newVolume = (await docker.listVolumes()).Volumes.find((vol) => + /data/.test(vol.Name), + ); + if (!volume) { + expect.fail('Expected recreated volume with name matching "data"'); + } + expect((newVolume as any).CreatedAt).to.not.equal(createdAt); + }); + }); +}); + +describe('reboots or shuts down device', () => { + before(async () => { + spy(dbus, 'reboot'); + spy(dbus, 'shutdown'); + }); + + after(() => { + (dbus.reboot as SinonSpy).restore(); + (dbus.shutdown as SinonSpy).restore(); + }); + + it('reboots device', async () => { + await actions.executeDeviceAction({ action: 'reboot' }); + expect(dbus.reboot as SinonSpy).to.have.been.called; + }); + + it('shuts down device', async () => { + await actions.executeDeviceAction({ action: 'shutdown' }); + expect(dbus.shutdown as SinonSpy).to.have.been.called; + }); +}); + +describe('updates target state cache', () => { + let updateStub: SinonStub; + // Stub external dependencies. TargetState.update and api-binder methods + // should be tested separately. + before(async () => { + updateStub = stub(TargetState, 'update').resolves(); + // updateTarget reads instantUpdates from the db + await config.initialized(); + }); + + after(() => { + updateStub.restore(); + }); + + afterEach(() => { + updateStub.resetHistory(); + }); + + it('updates target state cache if instant updates are enabled', async () => { + await config.set({ instantUpdates: true }); + await actions.updateTarget(); + expect(updateStub).to.have.been.calledWith(false); + }); + + it('updates target state cache if force is specified', async () => { + await config.set({ instantUpdates: false }); + await actions.updateTarget(true); + expect(updateStub).to.have.been.calledWith(true); + }); + + it("doesn't update target state cache if instantUpdates and force are false", async () => { + await config.set({ instantUpdates: false }); + await actions.updateTarget(false); + expect(updateStub).to.not.have.been.called; + }); +}); + +describe('patches host config', () => { + // Stub external dependencies + let hostConfigPatch: SinonStub; + before(async () => { + await config.initialized(); + }); + beforeEach(() => { + hostConfigPatch = stub(hostConfig, 'patch'); + }); + afterEach(() => { + hostConfigPatch.restore(); + }); + + it('patches host config', async () => { + const conf = { + network: { + proxy: { + type: 'socks5', + noProxy: ['172.0.10.1'], + }, + hostname: 'deadbeef', + }, + }; + await actions.patchHostConfig(conf, true); + expect(hostConfigPatch).to.have.been.calledWith(conf, true); + }); + + it('patches hostname as first 7 digits of uuid if hostname parameter is empty string', async () => { + const conf = { + network: { + hostname: '', + }, + }; + const uuid = await config.get('uuid'); + await actions.patchHostConfig(conf, true); + expect(hostConfigPatch).to.have.been.calledWith( + { network: { hostname: uuid?.slice(0, 7) } }, + true, + ); + }); +}); diff --git a/test/integration/device-api/v1.spec.ts b/test/integration/device-api/v1.spec.ts new file mode 100644 index 00000000..4b1f0077 --- /dev/null +++ b/test/integration/device-api/v1.spec.ts @@ -0,0 +1,863 @@ +import { expect } from 'chai'; +import * as express from 'express'; +import { SinonStub, stub } from 'sinon'; +import * as request from 'supertest'; + +import * as config from '~/src/config'; +import * as db from '~/src/db'; +import * as hostConfig from '~/src/host-config'; +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, + NotFoundError, + BadRequestError, +} from '~/lib/errors'; +import log from '~/lib/supervisor-console'; +import * as constants from '~/lib/constants'; + +// All routes that require Authorization are integration tests due to +// the api-key module relying on the database. +describe('device-api/v1', () => { + let api: express.Application; + + before(async () => { + await config.initialized(); + + // `api` is a private property on SupervisorAPI but + // passing it directly to supertest is easier than + // setting up an API listen port & timeout + api = new deviceApi.SupervisorAPI({ + routers: [v1.router], + healthchecks: [], + // @ts-expect-error + }).api; + }); + + describe('GET /v1/healthy', () => { + after(() => { + api = new deviceApi.SupervisorAPI({ + routers: [v1.router], + healthchecks: [], + // @ts-expect-error + }).api; + }); + + it('responds with 200 because all healthchecks pass', async () => { + api = new deviceApi.SupervisorAPI({ + routers: [v1.router], + healthchecks: [stub().resolves(true), stub().resolves(true)], + // @ts-expect-error + }).api; + await request(api).get('/v1/healthy').expect(200); + }); + + it('responds with 500 because some healthchecks did not pass', async () => { + api = new deviceApi.SupervisorAPI({ + routers: [v1.router], + healthchecks: [stub().resolves(false), stub().resolves(true)], + // @ts-expect-error + }).api; + await request(api).get('/v1/healthy').expect(500); + }); + }); + + describe('POST /v1/blink', () => { + // Actions are tested elsewhere so we can stub the dependency here + before(() => stub(actions, 'identify')); + after(() => (actions.identify as SinonStub).restore()); + + it('responds with 200', async () => { + await request(api) + .post('/v1/blink') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + }); + }); + + describe('POST /v1/regenerate-api-key', () => { + // Actions are tested elsewhere so we can stub the dependency here + beforeEach(() => stub(actions, 'regenerateKey')); + afterEach(() => (actions.regenerateKey as SinonStub).restore()); + + it('responds with 200 and valid new API key', async () => { + const oldKey = await deviceApi.getGlobalApiKey(); + const newKey = 'my_new_key'; + (actions.regenerateKey as SinonStub).resolves(newKey); + + await request(api) + .post('/v1/regenerate-api-key') + .set('Authorization', `Bearer ${oldKey}`) + .expect(200) + .then((response) => { + expect(response.text).to.match(new RegExp(newKey)); + }); + }); + + it('responds with 503 if regenerate was unsuccessful', async () => { + const oldKey = await deviceApi.getGlobalApiKey(); + (actions.regenerateKey as SinonStub).throws(new Error()); + + await request(api) + .post('/v1/regenerate-api-key') + .set('Authorization', `Bearer ${oldKey}`) + .expect(503); + }); + }); + + describe('POST /v1/restart', () => { + // Actions are tested elsewhere so we can stub the dependency here + let doRestartStub: SinonStub; + beforeEach(() => { + doRestartStub = stub(actions, 'doRestart').resolves(); + }); + afterEach(async () => { + doRestartStub.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/restart') + .send({ appId: 1234567, force: false }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doRestartStub).to.have.been.calledWith(1234567, false); + doRestartStub.resetHistory(); + + // Parses force: true + await request(api) + .post('/v1/restart') + .send({ appId: 7654321, force: true }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doRestartStub).to.have.been.calledWith(7654321, true); + doRestartStub.resetHistory(); + + // Defaults to force: false + await request(api) + .post('/v1/restart') + .send({ appId: 7654321 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doRestartStub).to.have.been.calledWith(7654321, false); + }); + + it('responds with 400 if appId is missing', async () => { + await request(api) + .post('/v1/restart') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(400); + }); + + 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/restart') + .send({ appId: 7654321 }) + .set('Authorization', `Bearer ${scopedKey}`) + .expect(401); + }); + + it('responds with 200 if restart succeeded', async () => { + await request(api) + .post('/v1/restart') + .send({ appId: 1234567 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + }); + + it('responds with 423 if there are update locks', async () => { + doRestartStub.throws(new UpdatesLockedError()); + await request(api) + .post('/v1/restart') + .send({ appId: 1234567 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(423); + }); + + it('responds with 503 for other errors that occur during restart', async () => { + doRestartStub.throws(new Error()); + await request(api) + .post('/v1/restart') + .send({ appId: 1234567 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(503); + }); + }); + + describe('POST /v1/purge', () => { + let doPurgeStub: SinonStub; + beforeEach(() => { + doPurgeStub = stub(actions, 'doPurge').resolves(); + }); + afterEach(async () => { + doPurgeStub.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/purge') + .send({ appId: 1234567, force: false }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doPurgeStub).to.have.been.calledWith(1234567, false); + doPurgeStub.resetHistory(); + + // Parses force: true + await request(api) + .post('/v1/purge') + .send({ appId: 7654321, force: true }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doPurgeStub).to.have.been.calledWith(7654321, true); + doPurgeStub.resetHistory(); + + // Defaults to force: false + await request(api) + .post('/v1/purge') + .send({ appId: 7654321 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doPurgeStub).to.have.been.calledWith(7654321, false); + }); + + it('responds with 400 if appId is missing', async () => { + await request(api) + .post('/v1/purge') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(400); + }); + + 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/purge') + .send({ appId: 7654321 }) + .set('Authorization', `Bearer ${scopedKey}`) + .expect(401); + }); + + it('responds with 200 if purge succeeded', async () => { + await request(api) + .post('/v1/purge') + .send({ appId: 1234567 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + }); + + it('responds with 423 if there are update locks', async () => { + doPurgeStub.throws(new UpdatesLockedError()); + await request(api) + .post('/v1/purge') + .send({ appId: 1234567 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(423); + }); + + it('responds with 503 for other errors that occur during purge', async () => { + doPurgeStub.throws(new Error()); + await request(api) + .post('/v1/purge') + .send({ appId: 1234567 }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .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); + }); + }); + + describe('POST /v1/reboot', () => { + let executeDeviceActionStub: SinonStub; + beforeEach(() => { + executeDeviceActionStub = stub(actions, 'executeDeviceAction').resolves(); + }); + afterEach(async () => executeDeviceActionStub.restore()); + + it('validates data from request body', async () => { + // Parses force: false + await request(api) + .post('/v1/reboot') + .send({ force: false }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(executeDeviceActionStub).to.have.been.calledWith( + { + action: 'reboot', + }, + false, + ); + executeDeviceActionStub.resetHistory(); + + // Parses force: true + await request(api) + .post('/v1/reboot') + .send({ force: true }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(executeDeviceActionStub).to.have.been.calledWith( + { + action: 'reboot', + }, + true, + ); + executeDeviceActionStub.resetHistory(); + + // Defaults to force: false + await request(api) + .post('/v1/reboot') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(executeDeviceActionStub).to.have.been.calledWith( + { + action: 'reboot', + }, + false, + ); + }); + + it('responds with 202 if request successful', async () => { + await request(api) + .post('/v1/reboot') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(202); + }); + + it('responds with 423 if there are update locks', async () => { + executeDeviceActionStub.throws(new UpdatesLockedError()); + await request(api) + .post('/v1/reboot') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(423); + }); + + it('responds with 500 for other errors that occur during reboot', async () => { + executeDeviceActionStub.throws(new Error()); + await request(api) + .post('/v1/reboot') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(500); + }); + }); + + describe('POST /v1/shutdown', () => { + let executeDeviceActionStub: SinonStub; + beforeEach(() => { + executeDeviceActionStub = stub(actions, 'executeDeviceAction').resolves(); + }); + afterEach(async () => executeDeviceActionStub.restore()); + + it('validates data from request body', async () => { + // Parses force: false + await request(api) + .post('/v1/shutdown') + .send({ force: false }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(executeDeviceActionStub).to.have.been.calledWith( + { + action: 'shutdown', + }, + false, + ); + executeDeviceActionStub.resetHistory(); + + // Parses force: true + await request(api) + .post('/v1/shutdown') + .send({ force: true }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(executeDeviceActionStub).to.have.been.calledWith( + { + action: 'shutdown', + }, + true, + ); + executeDeviceActionStub.resetHistory(); + + // Defaults to force: false + await request(api) + .post('/v1/shutdown') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(executeDeviceActionStub).to.have.been.calledWith( + { + action: 'shutdown', + }, + false, + ); + }); + + it('responds with 202 if request successful', async () => { + await request(api) + .post('/v1/shutdown') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(202); + }); + + it('responds with 423 if there are update locks', async () => { + executeDeviceActionStub.throws(new UpdatesLockedError()); + await request(api) + .post('/v1/shutdown') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(423); + }); + + it('responds with 500 for other errors that occur during shutdown', async () => { + executeDeviceActionStub.throws(new Error()); + await request(api) + .post('/v1/shutdown') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(500); + }); + }); + + describe('POST /v1/update', () => { + let updateTargetStub: SinonStub; + beforeEach(() => { + updateTargetStub = stub(actions, 'updateTarget'); + }); + afterEach(async () => updateTargetStub.restore()); + + it('validates data from request body', async () => { + // Parses force: false + await request(api) + .post('/v1/update') + .send({ force: false }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(updateTargetStub.lastCall.firstArg).to.be.false; + updateTargetStub.resetHistory(); + + // Parses force: true + await request(api) + .post('/v1/update') + .send({ force: true }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(updateTargetStub.lastCall.firstArg).to.be.true; + updateTargetStub.resetHistory(); + + // Defaults to force: false + await request(api) + .post('/v1/update') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(updateTargetStub.lastCall.firstArg).to.be.false; + }); + + it('responds with 204 if update triggered', async () => { + updateTargetStub.returns(true); + await request(api) + .post('/v1/update') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(204); + }); + + it('responds with 202 if update not triggered', async () => { + updateTargetStub.returns(false); + await request(api) + .post('/v1/update') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(202); + }); + }); + + describe('GET /v1/apps/:appId', () => { + let getSingleContainerAppStub: SinonStub; + beforeEach(() => { + getSingleContainerAppStub = stub( + actions, + 'getSingleContainerApp', + ).resolves({} as any); + }); + afterEach(async () => { + getSingleContainerAppStub.restore(); + // Remove all scoped API keys between tests + await db.models('apiSecret').whereNot({ appId: 0 }).del(); + }); + + it('validates data from request body', async () => { + await request(api) + .get('/v1/apps/1234567') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`); + expect(getSingleContainerAppStub).to.have.been.calledWith(1234567); + }); + + it('responds with 200 if request successful', async () => { + await request(api) + .get('/v1/apps/1234567') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, {}); + }); + + it('responds with 400 if invalid appId parameter', async () => { + await request(api) + .get('/v1/apps/badAppId') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(400); + }); + + it('responds with 400 if action throws BadRequestError', async () => { + getSingleContainerAppStub.throws(new BadRequestError()); + await request(api) + .get('/v1/apps/1234567') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(400); + }); + + it("responds with 401 if caller's API key is not in scope of appId", async () => { + const scopedKey = await deviceApi.generateScopedKey(7654321, 'main'); + await request(api) + .get('/v1/apps/1234567') + .set('Authorization', `Bearer ${scopedKey}`) + .expect(401); + }); + + it('responds with 503 for other errors that occur during request', async () => { + getSingleContainerAppStub.throws(new Error()); + await request(api) + .get('/v1/apps/1234567') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(503); + }); + }); + + describe('GET /v1/device', () => { + let getLegacyDeviceStateStub: SinonStub; + beforeEach(() => { + getLegacyDeviceStateStub = stub(actions, 'getLegacyDeviceState'); + }); + afterEach(() => getLegacyDeviceStateStub.restore()); + + it('responds with 200 and legacy device state', async () => { + getLegacyDeviceStateStub.resolves({ test_state: 'Success' }); + await request(api) + .get('/v1/device') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { test_state: 'Success' }); + }); + + it('responds with 503 for other errors that occur during request', async () => { + getLegacyDeviceStateStub.throws(new Error()); + await request(api) + .get('/v1/device') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(503); + }); + }); + + describe('GET /v1/device/host-config', () => { + // Stub external dependencies + let getHostConfigStub: SinonStub; + beforeEach(() => { + getHostConfigStub = stub(hostConfig, 'get'); + }); + afterEach(() => { + getHostConfigStub.restore(); + }); + + it('responds with 200 and host config', async () => { + getHostConfigStub.resolves({ network: { hostname: 'deadbeef' } }); + await request(api) + .get('/v1/device/host-config') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { network: { hostname: 'deadbeef' } }); + }); + + it('responds with 503 for other errors that occur during request', async () => { + getHostConfigStub.throws(new Error()); + await request(api) + .get('/v1/device/host-config') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(503); + }); + }); + + describe('PATCH /v1/device/host-config', () => { + before(() => stub(actions, 'patchHostConfig')); + after(() => (actions.patchHostConfig as SinonStub).restore()); + + const validProxyReqs: { [key: string]: number[] | string[] } = { + ip: ['proxy.example.org', 'proxy.foo.org'], + port: [5128, 1080], + type: constants.validRedsocksProxyTypes, + login: ['user', 'user2'], + password: ['foo', 'bar'], + }; + + it('warns on the supervisor console when provided disallowed proxy fields', async () => { + const invalidProxyReqs: { [key: string]: string | number } = { + // At this time, don't support changing local_ip or local_port + local_ip: '0.0.0.0', + local_port: 12345, + type: 'invalidType', + noProxy: 'not a list of addresses', + }; + + for (const key of Object.keys(invalidProxyReqs)) { + await request(api) + .patch('/v1/device/host-config') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .send({ network: { proxy: { [key]: invalidProxyReqs[key] } } }) + .expect(200) + .then(() => { + if (key === 'type') { + expect(log.warn as SinonStub).to.have.been.calledWith( + `Invalid redsocks proxy type, must be one of ${validProxyReqs.type.join( + ', ', + )}`, + ); + } else if (key === 'noProxy') { + expect(log.warn as SinonStub).to.have.been.calledWith( + 'noProxy field must be an array of addresses', + ); + } else { + expect(log.warn as SinonStub).to.have.been.calledWith( + `Invalid proxy field(s): ${key}`, + ); + } + }); + (log.warn as SinonStub).reset(); + } + }); + + it('warns on console when sent a malformed patch body', async () => { + await request(api) + .patch('/v1/device/host-config') + .send({}) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200) + .then(() => { + expect(log.warn as SinonStub).to.have.been.calledWith( + "Key 'network' must exist in PATCH body", + ); + }); + }); + }); +}); diff --git a/test/integration/device-api/v2.spec.ts b/test/integration/device-api/v2.spec.ts new file mode 100644 index 00000000..7c189c9c --- /dev/null +++ b/test/integration/device-api/v2.spec.ts @@ -0,0 +1,640 @@ +import { expect } from 'chai'; +import * as express from 'express'; +import { SinonStub, stub } from 'sinon'; +import * as request from 'supertest'; + +import * as config from '~/src/config'; +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, + NotFoundError, + BadRequestError, +} from '~/lib/errors'; + +// All routes that require Authorization are integration tests due to +// the api-key module relying on the database. +describe('device-api/v2', () => { + let api: express.Application; + + before(async () => { + await config.initialized(); + + // `api` is a private property on SupervisorAPI but + // passing it directly to supertest is easier than + // setting up an API listen port & timeout + api = new deviceApi.SupervisorAPI({ + routers: [v2.router], + healthchecks: [], + // @ts-expect-error + }).api; + }); + + describe('POST /v2/applications/:appId/restart', () => { + // Actions are tested elsewhere so we can stub the dependency here + let doRestartStub: SinonStub; + beforeEach(() => { + doRestartStub = stub(actions, 'doRestart').resolves(); + }); + afterEach(async () => { + doRestartStub.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') + .send({ force: false }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doRestartStub).to.have.been.calledWith(1234567, false); + doRestartStub.resetHistory(); + + // Parses force: true + await request(api) + .post('/v2/applications/7654321/restart') + .send({ force: true }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doRestartStub).to.have.been.calledWith(7654321, true); + doRestartStub.resetHistory(); + + // Defaults to force: false + await request(api) + .post('/v2/applications/7654321/restart') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doRestartStub).to.have.been.calledWith(7654321, false); + }); + + it('responds with 400 if appId is missing', async () => { + await request(api) + .post('/v2/applications/badAppId/restart') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(400); + }); + + 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') + .set('Authorization', `Bearer ${scopedKey}`) + .expect(401); + }); + + it('responds with 200 if restart succeeded', async () => { + await request(api) + .post('/v2/applications/1234567/restart') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + }); + + it('responds with 423 if there are update locks', async () => { + doRestartStub.throws(new UpdatesLockedError()); + await request(api) + .post('/v2/applications/1234567/restart') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(423); + }); + + it('responds with 503 for other errors that occur during restart', async () => { + doRestartStub.throws(new Error()); + await request(api) + .post('/v2/applications/7654321/restart') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(503); + }); + }); + + describe('POST /v2/applications/:appId/purge', () => { + // Actions are tested elsewhere so we can stub the dependency here + let doPurgeStub: SinonStub; + beforeEach(() => { + doPurgeStub = stub(actions, 'doPurge').resolves(); + }); + afterEach(async () => { + doPurgeStub.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/purge') + .send({ force: false }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doPurgeStub).to.have.been.calledWith(1234567, false); + doPurgeStub.resetHistory(); + + // Parses force: true + await request(api) + .post('/v2/applications/7654321/purge') + .send({ force: true }) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doPurgeStub).to.have.been.calledWith(7654321, true); + doPurgeStub.resetHistory(); + + // Defaults to force: false + await request(api) + .post('/v2/applications/7654321/purge') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + expect(doPurgeStub).to.have.been.calledWith(7654321, false); + }); + + it('responds with 400 if appId is missing', async () => { + await request(api) + .post('/v2/applications/badAppId/purge') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(400); + }); + + 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/purge') + .set('Authorization', `Bearer ${scopedKey}`) + .expect(401); + }); + + it('responds with 200 if purge succeeded', async () => { + await request(api) + .post('/v2/applications/1234567/purge') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200); + }); + + it('responds with 423 if there are update locks', async () => { + doPurgeStub.throws(new UpdatesLockedError()); + await request(api) + .post('/v2/applications/1234567/purge') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(423); + }); + + it('responds with 503 for other errors that occur during purge', async () => { + doPurgeStub.throws(new Error()); + await request(api) + .post('/v2/applications/7654321/purge') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .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/integration/host-config.spec.ts b/test/integration/host-config.spec.ts new file mode 100644 index 00000000..965f3e69 --- /dev/null +++ b/test/integration/host-config.spec.ts @@ -0,0 +1,228 @@ +import { expect } from 'chai'; +import { testfs, TestFs } from 'mocha-pod'; +import * as path from 'path'; +import { SinonStub, stub } from 'sinon'; +import * as fs from 'fs/promises'; + +import * as hostConfig from '~/src/host-config'; +import * as config from '~/src/config'; +import * as applicationManager from '~/src/compose/application-manager'; +import { InstancedAppState } from '~/src/types/state'; +import * as constants from '~/lib/constants'; +import * as updateLock from '~/lib/update-lock'; +import { UpdatesLockedError } from '~/lib/errors'; +import * as dbus from '~/lib/dbus'; +import { + createApps, + createService, + DEFAULT_NETWORK, +} from '~/test-lib/state-helper'; + +describe('host-config', () => { + let tFs: TestFs.Disabled; + let currentApps: InstancedAppState; + const APP_ID = 1; + const SERVICE_NAME = 'one'; + const proxyBase = path.join( + constants.rootMountPoint, + constants.bootMountPoint, + 'system-proxy', + ); + const redsocksConf = path.join(proxyBase, 'redsocks.conf'); + const noProxy = path.join(proxyBase, 'no_proxy'); + const hostname = path.join(constants.rootMountPoint, '/etc/hostname'); + const appLockDir = path.join( + constants.rootMountPoint, + updateLock.lockPath(APP_ID), + ); + + before(async () => { + await config.initialized(); + + // Create current state + currentApps = createApps( + { + services: [ + await createService({ + running: true, + appId: APP_ID, + serviceName: SERVICE_NAME, + }), + ], + networks: [DEFAULT_NETWORK], + }, + false, + ); + + // Set up test fs + tFs = testfs({ + [redsocksConf]: testfs.from( + 'test/data/mnt/boot/system-proxy/redsocks.conf', + ), + [noProxy]: testfs.from('test/data/mnt/boot/system-proxy/no_proxy'), + [hostname]: 'deadbeef', + // Create a lock. This won't prevent host config patch unless + // there are current apps present, in which case an updates locked + // error will be thrown. + [appLockDir]: { + [SERVICE_NAME]: { + 'updates.lock': '', + }, + }, + }); + }); + + beforeEach(async () => { + await tFs.enable(); + // Stub external dependencies + stub(dbus, 'servicePartOf').resolves(''); + stub(dbus, 'restartService').resolves(); + }); + + afterEach(async () => { + await tFs.restore(); + (dbus.servicePartOf as SinonStub).restore(); + (dbus.restartService as SinonStub).restore(); + }); + + it('reads proxy configs and hostname', async () => { + const { network } = await hostConfig.get(); + expect(network).to.have.property('hostname', 'deadbeef'); + expect(network).to.have.property('proxy'); + expect(network.proxy).to.have.property('ip', 'example.org'); + expect(network.proxy).to.have.property('port', 1080); + expect(network.proxy).to.have.property('type', 'socks5'); + expect(network.proxy).to.have.property('login', 'foo'); + expect(network.proxy).to.have.property('password', 'bar'); + expect(network.proxy).to.have.deep.property('noProxy', [ + '152.10.30.4', + '253.1.1.0/16', + ]); + }); + + it('prevents patch if update locks are present', async () => { + stub(applicationManager, 'getCurrentApps').resolves(currentApps); + + try { + await hostConfig.patch({ network: { hostname: 'test' } }); + expect.fail('Expected hostConfig.patch to throw UpdatesLockedError'); + } catch (e: unknown) { + expect(e).to.be.instanceOf(UpdatesLockedError); + } + + (applicationManager.getCurrentApps as SinonStub).restore(); + }); + + it('patches if update locks are present but force is specified', async () => { + stub(applicationManager, 'getCurrentApps').resolves(currentApps); + + try { + await hostConfig.patch({ network: { hostname: 'deadreef' } }, true); + expect(await config.get('hostname')).to.equal('deadreef'); + } catch (e: unknown) { + expect.fail(`Expected hostConfig.patch to not throw, but got ${e}`); + } + + (applicationManager.getCurrentApps as SinonStub).restore(); + }); + + it('patches hostname', async () => { + await hostConfig.patch({ network: { hostname: 'test' } }); + // /etc/hostname isn't changed until the balena-hostname service + // is restarted through dbus, so we verify the change from config. + expect(await config.get('hostname')).to.equal('test'); + }); + + it('skips restarting hostname services if they are part of config-json.target', async () => { + (dbus.servicePartOf as SinonStub).resolves('config-json.target'); + await hostConfig.patch({ network: { hostname: 'newdevice' } }); + expect(dbus.restartService as SinonStub).to.not.have.been.called; + expect(await config.get('hostname')).to.equal('newdevice'); + }); + + it('patches proxy', async () => { + await hostConfig.patch({ + network: { + proxy: { + ip: 'example2.org', + port: 1090, + type: 'http-relay', + login: 'bar', + password: 'foo', + noProxy: ['balena.io', '222.22.2.2'], + }, + }, + }); + const { network } = await hostConfig.get(); + expect(network).to.have.property('proxy'); + expect(network.proxy).to.have.property('ip', 'example2.org'); + expect(network.proxy).to.have.property('port', 1090); + expect(network.proxy).to.have.property('type', 'http-relay'); + expect(network.proxy).to.have.property('login', 'bar'); + expect(network.proxy).to.have.property('password', 'foo'); + expect(network.proxy).to.have.deep.property('noProxy', [ + 'balena.io', + '222.22.2.2', + ]); + }); + + it('skips restarting proxy services when part of redsocks-conf.target', async () => { + (dbus.servicePartOf as SinonStub).resolves('redsocks-conf.target'); + await hostConfig.patch({ + network: { + proxy: { + ip: 'example2.org', + port: 1090, + type: 'http-relay', + login: 'bar', + password: 'foo', + noProxy: ['balena.io', '222.22.2.2'], + }, + }, + }); + expect(dbus.restartService as SinonStub).to.not.have.been.called; + const { network } = await hostConfig.get(); + expect(network).to.have.property('proxy'); + expect(network.proxy).to.have.property('ip', 'example2.org'); + expect(network.proxy).to.have.property('port', 1090); + expect(network.proxy).to.have.property('type', 'http-relay'); + expect(network.proxy).to.have.property('login', 'bar'); + expect(network.proxy).to.have.property('password', 'foo'); + expect(network.proxy).to.have.deep.property('noProxy', [ + 'balena.io', + '222.22.2.2', + ]); + }); + + it('patches redsocks.conf to be empty if prompted', async () => { + await hostConfig.patch({ network: { proxy: {} } }); + const { network } = await hostConfig.get(); + expect(network).to.have.property('proxy', undefined); + expect(await fs.readdir(proxyBase)).to.not.have.members([ + 'redsocks.conf', + 'no_proxy', + ]); + }); + + it('patches no_proxy to be empty if prompted', async () => { + await hostConfig.patch({ + network: { + proxy: { + noProxy: [], + }, + }, + }); + const { network } = await hostConfig.get(); + expect(network).to.have.property('proxy'); + expect(network.proxy).to.not.have.property('noProxy'); + expect(await fs.readdir(proxyBase)).to.not.have.members(['no_proxy']); + }); + + it("doesn't update hostname or proxy when both are empty", async () => { + const { network } = await hostConfig.get(); + await hostConfig.patch({ network: {} }); + const { network: newNetwork } = await hostConfig.get(); + expect(network.hostname).to.equal(newNetwork.hostname); + expect(network.proxy).to.deep.equal(newNetwork.proxy); + }); +}); diff --git a/test/integration/lib/docker-utils.spec.ts b/test/integration/lib/docker-utils.spec.ts index ab0c1b9c..aaa15305 100644 --- a/test/integration/lib/docker-utils.spec.ts +++ b/test/integration/lib/docker-utils.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { stub } from 'sinon'; import * as dockerUtils from '~/lib/docker-utils'; -import { createDockerImage } from '~/test-lib/docker-helper'; +import { createDockerImage, cleanupDocker } from '~/test-lib/docker-helper'; import * as Docker from 'dockerode'; describe('lib/docker-utils', () => { @@ -10,6 +10,8 @@ describe('lib/docker-utils', () => { describe('getNetworkGateway', async () => { before(async () => { + // Remove network if it already exists + await cleanupDocker(docker); await docker.createNetwork({ Name: 'supervisor0', Options: { @@ -28,14 +30,7 @@ describe('lib/docker-utils', () => { }); after(async () => { - const allNetworks = await docker.listNetworks(); - - // Delete any remaining networks - await Promise.all( - allNetworks - .filter(({ Name }) => !['bridge', 'host', 'none'].includes(Name)) // exclude docker default network from the cleanup - .map(({ Name }) => docker.getNetwork(Name).remove()), - ); + await cleanupDocker(docker); }); // test using existing data... diff --git a/test/legacy/41-device-api-v1.spec.ts b/test/legacy/41-device-api-v1.spec.ts deleted file mode 100644 index 3b0cbf9c..00000000 --- a/test/legacy/41-device-api-v1.spec.ts +++ /dev/null @@ -1,1505 +0,0 @@ -import * as _ from 'lodash'; -import { expect } from 'chai'; -import { - stub, - spy, - useFakeTimers, - SinonStub, - SinonSpy, - SinonFakeTimers, -} from 'sinon'; -import * as supertest from 'supertest'; -import * as path from 'path'; -import { promises as fs } from 'fs'; - -import { exists, unlinkAll } from '~/lib/fs-utils'; -import * as appMock from '~/test-lib/application-state-mock'; -import * as mockedDockerode from '~/test-lib/mocked-dockerode'; -import mockedAPI = require('~/test-lib/mocked-device-api'); -import sampleResponses = require('~/test-data/device-api-responses.json'); -import * as config from '~/src/config'; -import * as logger from '~/src/logger'; -import SupervisorAPI from '~/src/device-api'; -import * as deviceApi from '~/src/device-api'; -import * as apiBinder from '~/src/api-binder'; -import * as deviceState from '~/src/device-state'; -import * as apiKeys from '~/src/device-api/api-keys'; -import * as dbus from '~/lib/dbus'; -import * as updateLock from '~/lib/update-lock'; -import * as TargetState from '~/src/device-state/target-state'; -import * as targetStateCache from '~/src/device-state/target-state-cache'; -import blink = require('~/lib/blink'); -import constants = require('~/lib/constants'); -import * as deviceAPIActions from '~/src/device-api/common'; -import { UpdatesLockedError } from '~/lib/errors'; -import { SchemaTypeKey } from '~/src/config/schema-type'; -import log from '~/lib/supervisor-console'; -import * as applicationManager from '~/src/compose/application-manager'; -import App from '~/src/compose/app'; - -describe('SupervisorAPI [V1 Endpoints]', () => { - let api: SupervisorAPI; - let healthCheckStubs: SinonStub[]; - let targetStateCacheMock: SinonStub; - const request = supertest( - `http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`, - ); - const services = [ - { appId: 2, appUuid: 'deadbeef', serviceId: 640681, serviceName: 'one' }, - { appId: 2, appUuid: 'deadbeef', serviceId: 640682, serviceName: 'two' }, - { appId: 2, appUuid: 'deadbeef', serviceId: 640683, serviceName: 'three' }, - ]; - const containers = services.map((service) => mockedAPI.mockService(service)); - const images = services.map((service) => mockedAPI.mockImage(service)); - - let loggerStub: SinonStub; - - beforeEach(() => { - // Mock a 3 container release - appMock.mockManagers(containers, [], []); - appMock.mockImages([], false, images); - appMock.mockSupervisorNetwork(true); - - targetStateCacheMock.resolves({ - appId: 2, - appUuid: 'deadbeef', - commit: 'abcdef2', - name: 'test-app2', - source: 'https://api.balena-cloud.com', - releaseId: 1232, - services: JSON.stringify(services), - networks: '[]', - volumes: '[]', - }); - }); - - afterEach(() => { - // Clear Dockerode actions recorded for each test - mockedDockerode.resetHistory(); - appMock.unmockAll(); - }); - - before(async () => { - await apiBinder.initialized(); - await deviceState.initialized(); - await targetStateCache.initialized(); - - // Do not apply target state - stub(deviceState, 'applyStep').resolves(); - - // Stub health checks so we can modify them whenever needed - healthCheckStubs = [ - stub(apiBinder, 'healthcheck'), - stub(deviceState, 'healthcheck'), - ]; - - // The mockedAPI contains stubs that might create unexpected results - // See the module to know what has been stubbed - api = await mockedAPI.create(healthCheckStubs); - - // Start test API - await api.listen( - mockedAPI.mockedOptions.listenPort, - mockedAPI.mockedOptions.timeout, - ); - - // Mock target state cache - targetStateCacheMock = stub(targetStateCache, 'getTargetApp'); - - // Stub logs for all API methods - loggerStub = stub(logger, 'attach'); - loggerStub.resolves(); - }); - - after(async () => { - try { - await api.stop(); - } catch (e: any) { - if (e.message !== 'Server is not running.') { - throw e; - } - } - (deviceState.applyStep as SinonStub).restore(); - // Restore healthcheck stubs - healthCheckStubs.forEach((hc) => hc.restore()); - // Remove any test data generated - await mockedAPI.cleanUp(); - targetStateCacheMock.restore(); - loggerStub.restore(); - }); - - describe('POST /v1/restart', () => { - it('restarts all containers in release', async () => { - // Perform the test with our mocked release - await mockedDockerode.testWithData({ containers, images }, async () => { - // Perform test - await request - .post('/v1/restart') - .send({ appId: 2 }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/restart'].statusCode) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.POST['/restart'].body, - ); - expect(response.text).to.deep.equal( - sampleResponses.V1.POST['/restart'].text, - ); - }); - // Check that mockedDockerode contains 3 stop and start actions - const removeSteps = _(mockedDockerode.actions) - .pickBy({ name: 'stop' }) - .map() - .value(); - expect(removeSteps).to.have.lengthOf(3); - const startSteps = _(mockedDockerode.actions) - .pickBy({ name: 'start' }) - .map() - .value(); - expect(startSteps).to.have.lengthOf(3); - }); - }); - - it('validates request body parameters', async () => { - await request - .post('/v1/restart') - .send({ thing: '' }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/restart [Invalid Body]'].statusCode) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.POST['/restart [Invalid Body]'].body, - ); - expect(response.text).to.deep.equal( - sampleResponses.V1.POST['/restart [Invalid Body]'].text, - ); - }); - }); - }); - - describe('GET /v1/healthy', () => { - it('returns OK because all checks pass', async () => { - // Make all healthChecks pass - healthCheckStubs.forEach((hc) => hc.resolves(true)); - await request - .get('/v1/healthy') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.GET['/healthy'].statusCode) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.GET['/healthy'].body, - ); - expect(response.text).to.deep.equal( - sampleResponses.V1.GET['/healthy'].text, - ); - }); - }); - it('Fails because some checks did not pass', async () => { - healthCheckStubs.forEach((hc) => hc.resolves(false)); - await request - .get('/v1/healthy') - .set('Accept', 'application/json') - .expect(sampleResponses.V1.GET['/healthy [2]'].statusCode) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.GET['/healthy [2]'].body, - ); - expect(response.text).to.deep.equal( - sampleResponses.V1.GET['/healthy [2]'].text, - ); - }); - }); - }); - - describe('GET /v1/apps/:appId', () => { - it('does not return information for an application when there is more than 1 container', async () => { - await request - .get('/v1/apps/2') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect( - sampleResponses.V1.GET['/apps/2 [Multiple containers running]'] - .statusCode, - ); - }); - - it('returns information about a specific application', async () => { - // Setup single container application - const container = mockedAPI.mockService({ - containerId: 'abc123', - appId: 2, - releaseId: 77777, - }); - const image = mockedAPI.mockImage({ - appId: 2, - }); - appMock.mockManagers([container], [], []); - appMock.mockImages([], false, [image]); - await mockedDockerode.testWithData( - { containers: [container], images: [image] }, - async () => { - // Make request - await request - .get('/v1/apps/2') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.GET['/apps/2'].statusCode) - .expect('Content-Type', /json/) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.GET['/apps/2'].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/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 - .get('/v1/device') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(200); - - expect(response.body).to.have.property('mac_address').that.is.not.empty; - }); - }); - - describe('POST /v1/reboot', () => { - let rebootMock: SinonStub; - before(() => { - rebootMock = stub(dbus, 'reboot').resolves((() => void 0) as any); - }); - - after(() => { - rebootMock.restore(); - }); - - afterEach(() => { - rebootMock.resetHistory(); - }); - - it('should return 202 and reboot if no locks are set', async () => { - // Setup single container application - const container = mockedAPI.mockService({ - containerId: 'abc123', - appId: 2, - releaseId: 77777, - }); - const image = mockedAPI.mockImage({ - appId: 2, - }); - appMock.mockManagers([container], [], []); - appMock.mockImages([], false, [image]); - - await mockedDockerode.testWithData( - { containers: [container], images: [image] }, - async () => { - const response = await request - .post('/v1/reboot') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(202); - - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(rebootMock).to.have.been.calledOnce; - }, - ); - }); - - it('should return 423 and reject the reboot if no locks are set', async () => { - stub(updateLock, 'lock').callsFake(async (__, opts, fn) => { - if (opts.force) { - return fn(); - } - throw new UpdatesLockedError('Updates locked'); - }); - - // Setup single container application - const container = mockedAPI.mockService({ - containerId: 'abc123', - appId: 2, - releaseId: 77777, - }); - const image = mockedAPI.mockImage({ - appId: 2, - }); - appMock.mockManagers([container], [], []); - appMock.mockImages([], false, [image]); - - await mockedDockerode.testWithData( - { containers: [container], images: [image] }, - async () => { - const response = await request - .post('/v1/reboot') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(423); - - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Error').that.is.not.empty; - expect(rebootMock).to.not.have.been.called; - }, - ); - - (updateLock.lock as SinonStub).restore(); - }); - - it('should return 202 and reboot if force is set to true', async () => { - stub(updateLock, 'lock').callsFake(async (__, opts, fn) => { - if (opts.force) { - return fn(); - } - throw new UpdatesLockedError('Updates locked'); - }); - - // Setup single container application - const container = mockedAPI.mockService({ - containerId: 'abc123', - appId: 2, - releaseId: 77777, - }); - const image = mockedAPI.mockImage({ - appId: 2, - }); - appMock.mockManagers([container], [], []); - appMock.mockImages([], false, [image]); - - await mockedDockerode.testWithData( - { containers: [container], images: [image] }, - async () => { - const response = await request - .post('/v1/reboot') - .send({ force: true }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(202); - - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(rebootMock).to.have.been.calledOnce; - }, - ); - - (updateLock.lock as SinonStub).restore(); - }); - }); - - describe('POST /v1/shutdown', () => { - let shutdownMock: SinonStub; - before(() => { - shutdownMock = stub(dbus, 'shutdown').resolves((() => void 0) as any); - }); - - after(async () => { - shutdownMock.restore(); - }); - - it('should return 202 and shutdown if no locks are set', async () => { - // Setup single container application - const container = mockedAPI.mockService({ - containerId: 'abc123', - appId: 2, - releaseId: 77777, - }); - const image = mockedAPI.mockImage({ - appId: 2, - }); - appMock.mockManagers([container], [], []); - appMock.mockImages([], false, [image]); - - await mockedDockerode.testWithData( - { containers: [container], images: [image] }, - async () => { - const response = await request - .post('/v1/shutdown') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(202); - - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(shutdownMock).to.have.been.calledOnce; - }, - ); - - shutdownMock.resetHistory(); - }); - - it('should lock all applications before trying to shutdown', async () => { - // Setup 2 applications running - const twoContainers = [ - mockedAPI.mockService({ - containerId: 'abc123', - appId: 1000, - releaseId: 55555, - }), - mockedAPI.mockService({ - containerId: 'def456', - appId: 2000, - releaseId: 77777, - }), - ]; - const twoImages = [ - mockedAPI.mockImage({ - appId: 1000, - }), - mockedAPI.mockImage({ - appId: 2000, - }), - ]; - appMock.mockManagers(twoContainers, [], []); - appMock.mockImages([], false, twoImages); - - const lockSpy = spy(updateLock, 'lock'); - await mockedDockerode.testWithData( - { containers: twoContainers, images: twoImages }, - async () => { - const response = await request - .post('/v1/shutdown') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(202); - - expect(lockSpy.callCount).to.equal(1); - // Check that lock was passed both application Ids - expect(lockSpy.lastCall.args[0]).to.deep.equal([1000, 2000]); - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(shutdownMock).to.have.been.calledOnce; - }, - ); - - shutdownMock.resetHistory(); - lockSpy.restore(); - }); - - it('should return 423 and reject the reboot if locks are set', async () => { - stub(updateLock, 'lock').callsFake(async (__, opts, fn) => { - if (opts.force) { - return fn(); - } - throw new UpdatesLockedError('Updates locked'); - }); - - // Setup single container application - const container = mockedAPI.mockService({ - containerId: 'abc123', - appId: 2, - releaseId: 77777, - }); - const image = mockedAPI.mockImage({ - appId: 2, - }); - appMock.mockManagers([container], [], []); - appMock.mockImages([], false, [image]); - - await mockedDockerode.testWithData( - { containers: [container], images: [image] }, - async () => { - const response = await request - .post('/v1/shutdown') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(423); - - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Error').that.is.not.empty; - expect(shutdownMock).to.not.have.been.called; - }, - ); - - (updateLock.lock as SinonStub).restore(); - }); - - it('should return 202 and shutdown if force is set to true', async () => { - stub(updateLock, 'lock').callsFake(async (__, opts, fn) => { - if (opts.force) { - return fn(); - } - throw new UpdatesLockedError('Updates locked'); - }); - - // Setup single container application - const container = mockedAPI.mockService({ - containerId: 'abc123', - appId: 2, - releaseId: 77777, - }); - const image = mockedAPI.mockImage({ - appId: 2, - }); - appMock.mockManagers([container], [], []); - appMock.mockImages([], false, [image]); - - await mockedDockerode.testWithData( - { containers: [container], images: [image] }, - async () => { - const response = await request - .post('/v1/shutdown') - .send({ force: true }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(202); - - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(shutdownMock).to.have.been.calledOnce; - }, - ); - - (updateLock.lock as SinonStub).restore(); - }); - }); - - describe('POST /v1/update', () => { - let configStub: SinonStub; - let targetUpdateSpy: SinonSpy; - let readyForUpdatesStub: SinonStub; - - before(() => { - configStub = stub(config, 'get'); - targetUpdateSpy = spy(TargetState, 'update'); - readyForUpdatesStub = stub(apiBinder, 'isReadyForUpdates').returns(true); - }); - - afterEach(() => { - targetUpdateSpy.resetHistory(); - }); - - after(() => { - configStub.restore(); - targetUpdateSpy.restore(); - readyForUpdatesStub.restore(); - }); - - it('returns 204 with no parameters', async () => { - // Stub response for getting instantUpdates - configStub.resolves(true); - // Make request - await request - .post('/v1/update') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/update [204 Response]'].statusCode); - // Check that TargetState.update was called - expect(targetUpdateSpy).to.be.called; - expect(targetUpdateSpy).to.be.calledWith(undefined, true); - }); - - it('returns 204 with force: true in body', async () => { - // Stub response for getting instantUpdates - configStub.resolves(true); - // Make request with force: true in the body - await request - .post('/v1/update') - .send({ force: true }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/update [204 Response]'].statusCode); - // Check that TargetState.update was called - expect(targetUpdateSpy).to.be.called; - expect(targetUpdateSpy).to.be.calledWith(true, true); - }); - - it('returns 202 when instantUpdates are disabled', async () => { - // Stub response for getting instantUpdates - configStub.resolves(false); - // Make request - await request - .post('/v1/update') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/update [202 Response]'].statusCode); - // Check that TargetState.update was not called - expect(targetUpdateSpy).to.not.be.called; - }); - }); - - describe('POST /v1/blink', () => { - // Further blink function-specific testing located in 07-blink.spec.ts - it('responds with code 200 and empty body', async () => { - await request - .post('/v1/blink') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/blink'].statusCode) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.POST['/blink'].body, - ); - expect(response.text).to.deep.equal( - sampleResponses.V1.POST['/blink'].text, - ); - }); - }); - - it('directs device to blink for 15000ms (hardcoded length)', async () => { - const blinkStartSpy: SinonSpy = spy(blink.pattern, 'start'); - const blinkStopSpy: SinonSpy = spy(blink.pattern, 'stop'); - const clock: SinonFakeTimers = useFakeTimers(); - - await request - .post('/v1/blink') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .then(() => { - expect(blinkStartSpy.callCount).to.equal(1); - clock.tick(15000); - expect(blinkStopSpy.callCount).to.equal(1); - }); - - blinkStartSpy.restore(); - blinkStopSpy.restore(); - clock.restore(); - }); - }); - - describe('POST /v1/regenerate-api-key', () => { - it('returns a valid new API key', async () => { - const refreshKeySpy: SinonSpy = spy(apiKeys, 'refreshKey'); - - let newKey: string = ''; - - await request - .post('/v1/regenerate-api-key') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/regenerate-api-key'].statusCode) - .then((response) => - Promise.all([response, deviceApi.getGlobalApiKey()]), - ) - .then(([response, globalApiKey]) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.POST['/regenerate-api-key'].body, - ); - expect(response.text).to.equal(globalApiKey); - newKey = response.text; - expect(refreshKeySpy.callCount).to.equal(1); - }); - - // Ensure persistence with future calls - await request - .post('/v1/blink') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${newKey}`) - .expect(sampleResponses.V1.POST['/blink'].statusCode); - - refreshKeySpy.restore(); - }); - - it('expires old API key after generating new key', async () => { - const oldKey: string = await deviceApi.getGlobalApiKey(); - - await request - .post('/v1/regenerate-api-key') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${oldKey}`) - .expect(sampleResponses.V1.POST['/regenerate-api-key'].statusCode); - - await request - .post('/v1/restart') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${oldKey}`) - .expect(401); - }); - - it('communicates the new API key to balena API', async () => { - const reportStateSpy: SinonSpy = spy(deviceState, 'reportCurrentState'); - - await request - .post('/v1/regenerate-api-key') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .then(() => { - expect(reportStateSpy.callCount).to.equal(1); - // Further reportCurrentState tests should be in 05-device-state.spec.ts, - // but its test case seems to currently be skipped until interface redesign - }); - - reportStateSpy.restore(); - }); - }); - - describe('/v1/device/host-config', () => { - // Wrap GET and PATCH /v1/device/host-config tests in the same block to share - // common scoped variables, namely file paths and file content - const hostnamePath: string = path.join( - process.env.ROOT_MOUNTPOINT!, - '/etc/hostname', - ); - const proxyBasePath: string = path.join( - process.env.ROOT_MOUNTPOINT!, - process.env.BOOT_MOUNTPOINT!, - 'system-proxy', - ); - const redsocksPath: string = path.join(proxyBasePath, 'redsocks.conf'); - const noProxyPath: string = path.join(proxyBasePath, 'no_proxy'); - - /** - * Copies contents of hostname, redsocks.conf, and no_proxy test files with `.template` - * endings to test files without `.template` endings to ensure the same data always - * exists for /v1/device/host-config test suites - */ - const restoreConfFileTemplates = async (): Promise => { - return Promise.all([ - fs.writeFile( - hostnamePath, - await fs.readFile(`${hostnamePath}.template`), - ), - fs.writeFile( - redsocksPath, - await fs.readFile(`${redsocksPath}.template`), - ), - fs.writeFile(noProxyPath, await fs.readFile(`${noProxyPath}.template`)), - ]); - }; - - // Set hostname & proxy file content to expected defaults - before(async () => await restoreConfFileTemplates()); - afterEach(async () => await restoreConfFileTemplates()); - - // Store GET responses for endpoint in variables so we can be less verbose in tests - const hostnameOnlyRes = - sampleResponses.V1.GET['/device/host-config [Hostname only]']; - const hostnameProxyRes = - sampleResponses.V1.GET['/device/host-config [Hostname and proxy]']; - - describe('GET /v1/device/host-config', () => { - it('returns current host config (hostname and proxy)', async () => { - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(hostnameProxyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal(hostnameProxyRes.body); - }); - }); - - it('returns current host config (hostname only)', async () => { - await unlinkAll(redsocksPath, noProxyPath); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(hostnameOnlyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal(hostnameOnlyRes.body); - }); - }); - - it('errors if no hostname file exists', async () => { - await unlinkAll(hostnamePath); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(503); - }); - }); - - describe('PATCH /v1/device/host-config', () => { - let configSetStub: SinonStub; - let logWarnStub: SinonStub; - let restartServiceSpy: SinonSpy; - - const validProxyReqs: { [key: string]: number[] | string[] } = { - ip: ['proxy.example.org', 'proxy.foo.org'], - port: [5128, 1080], - type: constants.validRedsocksProxyTypes, - login: ['user', 'user2'], - password: ['foo', 'bar'], - }; - - // Mock to short-circuit config.set, allowing writing hostname directly to test file - const configSetFakeFn = async ( - keyValues: config.ConfigMap, - ): Promise => - await fs.writeFile(hostnamePath, (keyValues as any).hostname); - - const validatePatchResponse = (res: supertest.Response): void => { - expect(res.text).to.equal( - sampleResponses.V1.PATCH['/host/device-config'].text, - ); - expect(res.body).to.deep.equal( - sampleResponses.V1.PATCH['/host/device-config'].body, - ); - }; - - before(() => { - configSetStub = stub(config, 'set').callsFake(configSetFakeFn); - logWarnStub = stub(log, 'warn'); - stub(applicationManager, 'getCurrentApps').resolves({ - '1234567': new App( - { - appId: 1234567, - services: [], - volumes: {}, - networks: {}, - }, - false, - ), - }); - }); - - after(() => { - configSetStub.restore(); - logWarnStub.restore(); - (applicationManager.getCurrentApps as SinonStub).restore(); - }); - - beforeEach(() => { - restartServiceSpy = spy(dbus, 'restartService'); - }); - - afterEach(() => { - restartServiceSpy.restore(); - }); - - it('prevents patch if update locks are present', async () => { - stub(updateLock, 'lock').callsFake(async (__, opts, fn) => { - if (opts.force) { - return fn(); - } - throw new UpdatesLockedError('Updates locked'); - }); - - await request - .patch('/v1/device/host-config') - .send({ network: { hostname: 'foobaz' } }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(423); - - expect(updateLock.lock).to.be.calledOnce; - (updateLock.lock as SinonStub).restore(); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .then((response) => { - expect(response.body.network.hostname).to.deep.equal( - 'foobardevice', - ); - }); - }); - - it('allows patch while update locks are present if force is in req.body', async () => { - stub(updateLock, 'lock').callsFake(async (__, opts, fn) => { - if (opts.force) { - return fn(); - } - throw new UpdatesLockedError('Updates locked'); - }); - - await request - .patch('/v1/device/host-config') - .send({ network: { hostname: 'foobaz' }, force: true }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(200); - - expect(updateLock.lock).to.be.calledOnce; - (updateLock.lock as SinonStub).restore(); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .then((response) => { - expect(response.body.network.hostname).to.deep.equal('foobaz'); - }); - }); - - it('updates the hostname with provided string if string is not empty', async () => { - // stub servicePartOf to throw exceptions for the new service names - stub(dbus, 'servicePartOf').callsFake( - async (serviceName: string): Promise => { - if (serviceName === 'balena-hostname') { - throw new Error('Unit not loaded.'); - } - return ''; - }, - ); - await unlinkAll(redsocksPath, noProxyPath); - - const patchBody = { network: { hostname: 'newdevice' } }; - - await request - .patch('/v1/device/host-config') - .send(patchBody) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) - .then((response) => { - validatePatchResponse(response); - }); - - // should restart services - expect(restartServiceSpy.callCount).to.equal(2); - expect(restartServiceSpy.args).to.deep.equal([ - ['balena-hostname'], - ['resin-hostname'], - ]); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .then((response) => { - expect(response.body).to.deep.equal(patchBody); - }); - - (dbus.servicePartOf as SinonStub).restore(); - }); - - it('skips restarting hostname services if they are part of config-json.target', async () => { - // stub servicePartOf to return the config-json.target we are looking for - stub(dbus, 'servicePartOf').callsFake(async (): Promise => { - return 'config-json.target'; - }); - - await unlinkAll(redsocksPath, noProxyPath); - - const patchBody = { network: { hostname: 'newdevice' } }; - - await request - .patch('/v1/device/host-config') - .send(patchBody) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) - .then((response) => { - validatePatchResponse(response); - }); - - // skips restarting hostname services if they are part of config-json.target - expect(restartServiceSpy.callCount).to.equal(0); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .then((response) => { - expect(response.body).to.deep.equal(patchBody); - }); - - (dbus.servicePartOf as SinonStub).restore(); - }); - - it('updates hostname to first 7 digits of device uuid when sent invalid hostname', async () => { - await unlinkAll(redsocksPath, noProxyPath); - await request - .patch('/v1/device/host-config') - .send({ network: { hostname: '' } }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) - .then((response) => { - validatePatchResponse(response); - }); - - // should restart services - expect(restartServiceSpy.callCount).to.equal(2); - expect(restartServiceSpy.args).to.deep.equal([ - ['balena-hostname'], - ['resin-hostname'], - ]); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .then(async (response) => { - const uuidHostname = await config - .get('uuid') - .then((uuid) => uuid?.slice(0, 7)); - - expect(response.body).to.deep.equal({ - network: { hostname: uuidHostname }, - }); - }); - }); - - it('removes proxy when sent empty proxy object', async () => { - await request - .patch('/v1/device/host-config') - .send({ network: { proxy: {} } }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) - .then(async (response) => { - validatePatchResponse(response); - - expect(await exists(redsocksPath)).to.be.false; - expect(await exists(noProxyPath)).to.be.false; - }); - - // should restart services - expect(restartServiceSpy.callCount).to.equal(3); - expect(restartServiceSpy.args).to.deep.equal([ - ['balena-proxy-config'], - ['resin-proxy-config'], - ['redsocks'], - ]); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(hostnameOnlyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal(hostnameOnlyRes.body); - }); - }); - - it('updates proxy type when provided valid values', async () => { - // stub servicePartOf to throw exceptions for the new service names - stub(dbus, 'servicePartOf').callsFake( - async (serviceName: string): Promise => { - if (serviceName === 'balena-proxy-config') { - throw new Error('Unit not loaded.'); - } - return ''; - }, - ); - // Test each proxy patch sequentially to prevent conflicts when writing to fs - let restartCallCount = 0; - for (const key of Object.keys(validProxyReqs)) { - const patchBodyValuesforKey: string[] | number[] = - validProxyReqs[key]; - for (const value of patchBodyValuesforKey) { - await request - .patch('/v1/device/host-config') - .send({ network: { proxy: { [key]: value } } }) - .set('Accept', 'application/json') - .set( - 'Authorization', - `Bearer ${await deviceApi.getGlobalApiKey()}`, - ) - .expect( - sampleResponses.V1.PATCH['/host/device-config'].statusCode, - ) - .then((response) => { - validatePatchResponse(response); - }); - - // should restart services - expect(restartServiceSpy.callCount).to.equal( - ++restartCallCount * 3, - ); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set( - 'Authorization', - `Bearer ${await deviceApi.getGlobalApiKey()}`, - ) - .expect(hostnameProxyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal({ - network: { - hostname: hostnameProxyRes.body.network.hostname, - // All other proxy configs should be unchanged except for any values sent in patch - proxy: { - ...hostnameProxyRes.body.network.proxy, - [key]: value, - }, - }, - }); - }); - } // end for (const value of patchBodyValuesforKey) - await restoreConfFileTemplates(); - } // end for (const key in validProxyReqs) - (dbus.servicePartOf as SinonStub).restore(); - }); - - it('skips restarting proxy services when part of redsocks-conf.target', async () => { - // stub servicePartOf to return the redsocks-conf.target we are looking for - stub(dbus, 'servicePartOf').callsFake(async (): Promise => { - return 'redsocks-conf.target'; - }); - // Test each proxy patch sequentially to prevent conflicts when writing to fs - for (const key of Object.keys(validProxyReqs)) { - const patchBodyValuesforKey: string[] | number[] = - validProxyReqs[key]; - for (const value of patchBodyValuesforKey) { - await request - .patch('/v1/device/host-config') - .send({ network: { proxy: { [key]: value } } }) - .set('Accept', 'application/json') - .set( - 'Authorization', - `Bearer ${await deviceApi.getGlobalApiKey()}`, - ) - .expect( - sampleResponses.V1.PATCH['/host/device-config'].statusCode, - ) - .then((response) => { - validatePatchResponse(response); - }); - - // skips restarting proxy services when part of redsocks-conf.target - expect(restartServiceSpy.callCount).to.equal(0); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set( - 'Authorization', - `Bearer ${await deviceApi.getGlobalApiKey()}`, - ) - .expect(hostnameProxyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal({ - network: { - hostname: hostnameProxyRes.body.network.hostname, - // All other proxy configs should be unchanged except for any values sent in patch - proxy: { - ...hostnameProxyRes.body.network.proxy, - [key]: value, - }, - }, - }); - }); - } // end for (const value of patchBodyValuesforKey) - await restoreConfFileTemplates(); - } // end for (const key in validProxyReqs) - (dbus.servicePartOf as SinonStub).restore(); - }); - - it('warns on the supervisor console when provided disallowed proxy fields', async () => { - const invalidProxyReqs: { [key: string]: string | number } = { - // At this time, don't support changing local_ip or local_port - local_ip: '0.0.0.0', - local_port: 12345, - type: 'invalidType', - noProxy: 'not a list of addresses', - }; - - for (const key of Object.keys(invalidProxyReqs)) { - await request - .patch('/v1/device/host-config') - .send({ network: { proxy: { [key]: invalidProxyReqs[key] } } }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(200) - .then(() => { - if (key === 'type') { - expect(logWarnStub).to.have.been.calledWith( - `Invalid redsocks proxy type, must be one of ${validProxyReqs.type.join( - ', ', - )}`, - ); - } else if (key === 'noProxy') { - expect(logWarnStub).to.have.been.calledWith( - 'noProxy field must be an array of addresses', - ); - } else { - expect(logWarnStub).to.have.been.calledWith( - `Invalid proxy field(s): ${key}`, - ); - } - }); - } - }); - - it('replaces no_proxy file with noProxy array from PATCH body', async () => { - await request - .patch('/v1/device/host-config') - .send({ network: { proxy: { noProxy: ['1.2.3.4/5'] } } }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) - .then((response) => { - validatePatchResponse(response); - }); - - // should restart services - expect(restartServiceSpy.callCount).to.equal(3); - expect(restartServiceSpy.args).to.deep.equal([ - ['balena-proxy-config'], - ['resin-proxy-config'], - ['redsocks'], - ]); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(hostnameProxyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal({ - network: { - hostname: hostnameProxyRes.body.network.hostname, - // New noProxy should be only value in no_proxy file - proxy: { - ...hostnameProxyRes.body.network.proxy, - noProxy: ['1.2.3.4/5'], - }, - }, - }); - }); - }); - - it('removes no_proxy file when sent an empty array', async () => { - await request - .patch('/v1/device/host-config') - .send({ network: { proxy: { noProxy: [] } } }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) - .then((response) => { - validatePatchResponse(response); - }); - - // should restart services - expect(restartServiceSpy.callCount).to.equal(3); - expect(restartServiceSpy.args).to.deep.equal([ - ['balena-proxy-config'], - ['resin-proxy-config'], - ['redsocks'], - ]); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(hostnameProxyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal({ - network: { - hostname: hostnameProxyRes.body.network.hostname, - // Reference all properties in proxy object EXCEPT noProxy - proxy: { - ip: hostnameProxyRes.body.network.proxy.ip, - login: hostnameProxyRes.body.network.proxy.login, - password: hostnameProxyRes.body.network.proxy.password, - port: hostnameProxyRes.body.network.proxy.port, - type: hostnameProxyRes.body.network.proxy.type, - }, - }, - }); - }); - }); - - it('does not update hostname or proxy when hostname or proxy are undefined', async () => { - await request - .patch('/v1/device/host-config') - .send({ network: {} }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) - .then((response) => { - validatePatchResponse(response); - }); - - // As no host configs were patched, no services should be restarted - expect(restartServiceSpy.callCount).to.equal(0); - - await request - .get('/v1/device/host-config') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(hostnameProxyRes.statusCode) - .then((response) => { - expect(response.body).to.deep.equal(hostnameProxyRes.body); - }); - }); - - it('warns on console when sent a malformed patch body', async () => { - await request - .patch('/v1/device/host-config') - .send({}) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(200) - .then(() => { - expect(logWarnStub).to.have.been.calledWith( - "Key 'network' must exist in PATCH body", - ); - }); - - expect(restartServiceSpy.callCount).to.equal(0); - }); - }); - }); - - describe('POST /v1/purge', () => { - it('errors if no appId found in request body', async () => { - await request - .post('/v1/purge') - .send({}) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect( - sampleResponses.V1.POST['/purge [400 Invalid/missing appId]'] - .statusCode, - ) - .then((response) => { - expect(response.text).to.equal( - sampleResponses.V1.POST['/purge [400 Invalid/missing appId]'].text, - ); - }); - }); - - it('purges the /data directory with valid appId', async () => { - const doPurgeStub: SinonStub = stub( - deviceAPIActions, - 'doPurge', - ).resolves(); - - await mockedDockerode.testWithData({ containers, images }, async () => { - await request - .post('/v1/purge') - .send({ appId: 2 }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) - .expect(sampleResponses.V1.POST['/purge [200]'].statusCode) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.POST['/purge [200]'].body, - ); - }); - }); - - expect(doPurgeStub.callCount).to.equal(1); - doPurgeStub.restore(); - }); - - it('errors if appId is out of scope (application not available)', async () => { - // Generate a new scoped key to call the endpoint, as mocked - // appId = 2 services are all in the global scope and thus - // resolve to true for any isScoped check - const scopedKey = await deviceApi.generateScopedKey( - 2, - containers[0].serviceName, - ); - - await request - .post('/v1/purge') - .send({ appId: 3 }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${scopedKey}`) - .expect(sampleResponses.V1.POST['/purge [401 Out of scope]'].statusCode) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.POST['/purge [401 Out of scope]'].body, - ); - }); - }); - }); -}); 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 }); diff --git a/test/lib/application-state-mock.ts b/test/lib/application-state-mock.ts deleted file mode 100644 index 704a1a9c..00000000 --- a/test/lib/application-state-mock.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as networkManager from '~/src/compose/network-manager'; -import * as volumeManager from '~/src/compose/volume-manager'; -import * as serviceManager from '~/src/compose/service-manager'; -import * as imageManager from '~/src/compose/images'; - -import Service from '~/src/compose/service'; -import Network from '~/src/compose/network'; -import Volume from '~/src/compose/volume'; - -const originalVolGetAll = volumeManager.getAll; -const originalSvcGetAll = serviceManager.getAll; -const originalNetGetAll = networkManager.getAll; -const originalNeedsClean = imageManager.isCleanupNeeded; -const originalImageAvailable = imageManager.getAvailable; -const originalNetworkReady = networkManager.supervisorNetworkReady; - -export function mockManagers(svcs: Service[], vols: Volume[], nets: Network[]) { - // @ts-expect-error Assigning to a RO property - volumeManager.getAll = async () => vols; - // @ts-expect-error Assigning to a RO property - networkManager.getAll = async () => nets; - // @ts-expect-error Assigning to a RO property - serviceManager.getAll = async () => { - // Filter services that are being removed - svcs = svcs.filter((s) => s.status !== 'removing'); - // Update Installing containers to Running - svcs = svcs.map((s) => { - if (s.status === 'Installing') { - s.status = 'Running'; - } - return s; - }); - return svcs; - }; -} - -function unmockManagers() { - // @ts-expect-error Assigning to a RO property - volumeManager.getAll = originalVolGetAll; - // @ts-expect-error Assigning to a RO property - networkManager.getAll = originalNetGetAll; - // @ts-expect-error Assigning to a RO property - serviceManager.getall = originalSvcGetAll; -} - -export function mockImages( - _downloading: number[], - cleanup: boolean, - available: imageManager.Image[], -) { - // @ts-expect-error Assigning to a RO property - imageManager.isCleanupNeeded = async () => cleanup; - // @ts-expect-error Assigning to a RO property - imageManager.getAvailable = async () => available; -} - -function unmockImages() { - // @ts-expect-error Assigning to a RO property - imageManager.isCleanupNeeded = originalNeedsClean; - // @ts-expect-error Assigning to a RO property - imageManager.getAvailable = originalImageAvailable; -} - -export function mockSupervisorNetwork(exists: boolean) { - // @ts-expect-error Assigning to a RO property - networkManager.supervisorNetworkReady = async () => exists; -} - -function unmockSupervisorNetwork() { - // @ts-expect-error Assigning to a RO property - networkManager.supervisorNetworkReady = originalNetworkReady; -} - -export function unmockAll() { - unmockManagers(); - unmockImages(); - unmockSupervisorNetwork(); -} diff --git a/test/lib/docker-helper.ts b/test/lib/docker-helper.ts index 1aca43f7..13f4a22f 100644 --- a/test/lib/docker-helper.ts +++ b/test/lib/docker-helper.ts @@ -1,7 +1,9 @@ import * as Docker from 'dockerode'; import * as tar from 'tar-stream'; - import { strict as assert } from 'assert'; +import { setTimeout } from 'timers/promises'; + +import { isStatusError } from '~/lib/errors'; // Creates an image from scratch with just some labels export async function createDockerImage( @@ -41,3 +43,47 @@ export async function createDockerImage( }); }); } + +// Clean up all Docker relics from tests +export const cleanupDocker = async (docker = new Docker()) => { + // Remove all containers + // Some containers may still be running so a prune won't suffice + try { + const containers = await docker.listContainers({ all: true }); + await Promise.all( + containers.map(({ Id }) => + docker.getContainer(Id).remove({ force: true }), + ), + ); + } catch (e: unknown) { + // Sometimes a container is already in the process of being removed + // This is safe to ignore since we're removing them anyway. + if (isStatusError(e) && e.statusCode !== 409) { + throw e; + } + } + + // Wait until containers are all removed + while (true) { + if ((await docker.listContainers({ all: true })).length > 0) { + await setTimeout(100); + } else { + break; + } + } + + // Remove all networks except defaults + const networks = await docker.listNetworks(); + await Promise.all( + networks + .filter(({ Name }) => !['bridge', 'host', 'none'].includes(Name)) // exclude docker default network from the cleanup + .map(({ Name }) => docker.getNetwork(Name).remove()), + ); + + // Remove all volumes + const { Volumes } = await docker.listVolumes(); + await Promise.all(Volumes.map(({ Name }) => docker.getVolume(Name).remove())); + + // Remove all images + await docker.pruneImages({ filters: { dangling: { false: true } } }); +}; diff --git a/test/lib/state-helper.ts b/test/lib/state-helper.ts new file mode 100644 index 00000000..676de0d5 --- /dev/null +++ b/test/lib/state-helper.ts @@ -0,0 +1,160 @@ +import App from '~/src/compose/app'; +import * as imageManager from '~/src/compose/images'; +import { Image } from '~/src/compose/images'; +import Network from '~/src/compose/network'; +import Service from '~/src/compose/service'; +import { ServiceComposeConfig } from '~/src/compose/types/service'; +import Volume from '~/src/compose/volume'; +import { InstancedAppState } from '~/src/types/state'; + +export const DEFAULT_NETWORK = Network.fromComposeObject( + 'default', + 1, + 'appuuid', + {}, +); + +export async function createService( + { + appId = 1, + appUuid = 'appuuid', + serviceName = 'main', + commit = 'main-commit', + ...conf + } = {} as Partial, + { state = {} as Partial, options = {} as any } = {}, +) { + const svc = await Service.fromComposeObject( + { + appId, + appUuid, + serviceName, + commit, + // db ids should not be used for target state calculation, but images + // are compared using _.isEqual so leaving this here to have image comparisons + // match + serviceId: 1, + imageId: 1, + releaseId: 1, + ...conf, + }, + options, + ); + + // Add additonal configuration + for (const k of Object.keys(state)) { + (svc as any)[k] = (state as any)[k]; + } + return svc; +} + +export function createImage( + { + appId = 1, + appUuid = 'appuuid', + name = 'test-image', + serviceName = 'main', + commit = 'main-commit', + ...extra + } = {} as Partial, +) { + return { + appId, + appUuid, + name, + serviceName, + commit, + // db ids should not be used for target state calculation, but images + // are compared using _.isEqual so leaving this here to have image comparisons + // match + imageId: 1, + releaseId: 1, + serviceId: 1, + dependent: 0, + ...extra, + } as Image; +} + +export function createApps( + { + services = [] as Service[], + networks = [] as Network[], + volumes = [] as Volume[], + }, + target = false, +) { + const servicesByAppId = services.reduce( + (svcs, s) => ({ ...svcs, [s.appId]: [s].concat(svcs[s.appId] || []) }), + {} as Dictionary, + ); + const volumesByAppId = volumes.reduce( + (vols, v) => ({ ...vols, [v.appId]: [v].concat(vols[v.appId] || []) }), + {} as Dictionary, + ); + const networksByAppId = networks.reduce( + (nets, n) => ({ ...nets, [n.appId]: [n].concat(nets[n.appId] || []) }), + {} as Dictionary, + ); + + const allAppIds = [ + ...new Set([ + ...Object.keys(servicesByAppId), + ...Object.keys(networksByAppId), + ...Object.keys(volumesByAppId), + ]), + ].map((i) => parseInt(i, 10)); + + const apps: InstancedAppState = {}; + for (const appId of allAppIds) { + apps[appId] = new App( + { + appId, + services: servicesByAppId[appId] ?? [], + networks: (networksByAppId[appId] ?? []).reduce( + (nets, n) => ({ ...nets, [n.name]: n }), + {}, + ), + volumes: (volumesByAppId[appId] ?? []).reduce( + (vols, v) => ({ ...vols, [v.name]: v }), + {}, + ), + }, + target, + ); + } + + return apps; +} + +export function createCurrentState({ + services = [] as Service[], + networks = [] as Network[], + volumes = [] as Volume[], + images = services.map((s) => ({ + // Infer images from services by default + dockerImageId: s.dockerImageId, + ...imageManager.imageFromService(s), + })) as Image[], + downloading = [] as string[], +}) { + const currentApps = createApps({ services, networks, volumes }); + + const containerIdsByAppId = services.reduce( + (ids, s) => ({ + ...ids, + [s.appId]: { + ...ids[s.appId], + ...(s.serviceName && + s.containerId && { [s.serviceName]: s.containerId }), + }, + }), + {} as { [appId: number]: Dictionary }, + ); + + return { + currentApps, + availableImages: images, + downloading, + containerIdsByAppId, + }; +} diff --git a/test/unit/device-api/actions.spec.ts b/test/unit/device-api/actions.spec.ts new file mode 100644 index 00000000..8a846fae --- /dev/null +++ b/test/unit/device-api/actions.spec.ts @@ -0,0 +1,70 @@ +import { expect } from 'chai'; +import { spy, useFakeTimers, stub, SinonStub } from 'sinon'; + +import * as hostConfig from '~/src/host-config'; +import * as actions from '~/src/device-api/actions'; +import blink = require('~/lib/blink'); + +describe('device-api/actions', () => { + describe('runs healthchecks', () => { + const taskTrue = () => Promise.resolve(true); + const taskFalse = () => Promise.resolve(false); + const taskError = () => { + throw new Error(); + }; + + it('resolves true if all healthchecks pass', async () => { + expect(await actions.runHealthchecks([taskTrue, taskTrue])).to.be.true; + }); + + it('resolves false if any healthchecks throw an error or fail', async () => { + expect(await actions.runHealthchecks([taskTrue, taskFalse])).to.be.false; + expect(await actions.runHealthchecks([taskTrue, taskError])).to.be.false; + expect(await actions.runHealthchecks([taskFalse, taskError])).to.be.false; + expect(await actions.runHealthchecks([taskFalse, taskFalse])).to.be.false; + expect(await actions.runHealthchecks([taskError, taskError])).to.be.false; + }); + }); + + describe('identifies device', () => { + // This suite doesn't test that the blink submodule writes to the correct + // led file location on host. That test should be part of the blink module. + it('directs device to blink for set duration', async () => { + const blinkStartSpy = spy(blink.pattern, 'start'); + const blinkStopSpy = spy(blink.pattern, 'stop'); + const clock = useFakeTimers(); + + actions.identify(); + expect(blinkStartSpy.callCount).to.equal(1); + clock.tick(15000); + expect(blinkStopSpy.callCount).to.equal(1); + + blinkStartSpy.restore(); + blinkStopSpy.restore(); + clock.restore(); + }); + }); + + describe('gets host config', () => { + // Stub external dependencies + // TODO: host-config module integration tests + let hostConfigGet: SinonStub; + before(() => { + hostConfigGet = stub(hostConfig, 'get'); + }); + after(() => { + hostConfigGet.restore(); + }); + + it('gets host config', async () => { + const conf = { + network: { + proxy: {}, + hostname: 'deadbeef', + }, + }; + hostConfigGet.resolves(conf); + expect(await actions.getHostConfig()).to.deep.equal(conf); + }); + }); +});