Merge pull request #2048 from balena-os/separate-route-and-action-tests

Separate route and action tests for Supervisor v1 API
This commit is contained in:
Christina Wang 2023-01-17 11:27:20 -08:00 committed by GitHub
commit 3142196202
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 3564 additions and 2774 deletions

View File

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

523
src/device-api/actions.ts Normal file
View File

@ -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<boolean>>,
) => {
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<InstancedDeviceState> = {
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<typeof deviceState.executeStepAction>[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<void> => {
// Get current and target apps
const [currentApp, targetApp] = await Promise.all([
getCurrentApp(appId, isLegacy ? BadRequestError : NotFoundError),
getApp(appId),
]);
const isSingleContainer = currentApp.services.length === 1;
if (!isSingleContainer && !serviceName && !imageId) {
// App is multicontainer but no service parameters were provided
throw new BadRequestError(messages.v2ServiceEndpointError);
}
// Find service in current and target apps
const currentService = isSingleContainer
? currentApp.services[0]
: currentApp.services.find(
(s) => s.imageId === imageId || s.serviceName === serviceName,
);
if (currentService == null) {
// Legacy (v1) throws 400 while v2 throws 404, and we have to keep the interface consistent.
throw new (isLegacy ? BadRequestError : NotFoundError)(
messages.serviceNotFound,
);
}
const targetService = targetApp.services.find(
(s) =>
s.imageId === currentService.imageId ||
s.serviceName === currentService.serviceName,
);
if (targetService == null) {
throw new NotFoundError(messages.targetServiceNotFound);
}
// Set volatile target state
applicationManager.setTargetVolatileForService(currentService.imageId, {
running: action !== 'stop',
});
// Execute action on service
return await 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<any>;
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<typeof hostConfig.patch>[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);
};

View File

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

View File

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

View File

@ -1,9 +1,11 @@
export const appNotFoundMessage = `App not found: an app needs to be installed for this endpoint to work.
export const appNotFound = `App not found: an app needs to be installed for this endpoint to work.
If you've recently moved this device from another app,
please push an app and wait for it to be installed first.`;
export const serviceNotFoundMessage =
export const serviceNotFound =
'Service not found, a container must exist for this endpoint to work';
export const v2ServiceEndpointInputErrorMessage =
'This endpoint requires one of imageId or serviceName';
export const targetServiceNotFound = 'Service does not exist in target release';
export const v2ServiceEndpointError =
'serviceName or imageId parameters were not provided or the app is multi-container. Use the v2 service action endpoints.';

View File

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

View File

@ -6,11 +6,7 @@ import * as _ from 'lodash';
import * as deviceState from '../device-state';
import * as apiBinder from '../api-binder';
import * as applicationManager from '../compose/application-manager';
import {
CompositionStepAction,
generateStep,
} from '../compose/composition-steps';
import { getApp } from '../device-state/db-format';
import { CompositionStepAction } from '../compose/composition-steps';
import { Service } from '../compose/service';
import Volume from '../compose/volume';
import * as commitStore from '../compose/commit';
@ -22,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<void> => {
const { imageId, serviceName, force } = req.body;
const appId = checkInt(req.params.appId);
if (!appId) {
res.status(400).json({
status: 'failed',
message: 'Missing app id',
});
return;
}
const handleServiceAction = (action: CompositionStepAction) => {
return async (req: AuthorizedRequest, res: Response, next: NextFunction) => {
const [appId, imageId, serviceName, force] = [
checkInt(req.params.appId),
checkInt(req.body.imageId),
checkString(req.body.serviceName),
checkTruthy(req.body.force),
];
// handle the case where the appId is out of scope
if (!req.auth.isScoped({ apps: [appId] })) {
res.status(401).json({
status: 'failed',
message: 'Application is not available',
});
return;
}
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
.then(([apps, targetApp]) => {
const app = apps[appId];
if (app == null) {
res.status(404).send(appNotFoundMessage);
return;
}
// Work if we have a service name or an image id
if (imageId == null && serviceName == null) {
throw new Error(v2ServiceEndpointInputErrorMessage);
}
let service: Service | undefined;
let targetService: Service | undefined;
if (imageId != null) {
service = _.find(app.services, { imageId });
targetService = _.find(targetApp.services, { imageId });
} else {
service = _.find(app.services, { serviceName });
targetService = _.find(targetApp.services, { serviceName });
}
if (service == null) {
res.status(404).send(serviceNotFoundMessage);
return;
}
applicationManager.setTargetVolatileForService(service.imageId!, {
running: action !== 'stop',
if (!appId) {
return res.status(400).json({
status: 'failed',
message: 'Invalid app id',
});
return applicationManager
.executeStep(
generateStep(action, {
current: service,
target: targetService,
wait: true,
}),
{
force,
},
)
.then(() => {
res.status(200).send('OK');
});
})
.catch(next);
}
if (!req.auth.isScoped({ apps: [appId] })) {
return res.status(401).json({
status: 'failed',
message: 'Unauthorized',
});
}
try {
if (!serviceName && !imageId) {
throw new BadRequestError(v2ServiceEndpointError);
}
await actions.executeServiceAction({
action,
appId,
imageId,
serviceName,
force,
});
return res.status(200).send('OK');
} catch (e: unknown) {
if (isNotFoundError(e) || isBadRequestError(e)) {
return res.status(e.statusCode).send(e.statusMessage);
} else {
next(e);
}
}
};
};
const createServiceActionHandler = (action: string) =>
_.partial(handleServiceAction, _, _, _, action);
router.post(
'/v2/applications/:appId/restart-service',
handleServiceAction('restart'),
);
router.post(
'/v2/applications/:appId/stop-service',
handleServiceAction('stop'),
);
router.post(
'/v2/applications/:appId/start-service',
handleServiceAction('start'),
);
router.post(
'/v2/applications/:appId/purge',
(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',

View File

@ -89,11 +89,7 @@ export type DeviceStateStepTarget = 'reboot' | 'shutdown' | 'noop';
type PossibleStepTargets = CompositionStepAction | DeviceStateStepTarget;
type DeviceStateStep<T extends PossibleStepTargets> =
| {
action: 'reboot';
}
| { action: 'shutdown' }
| { action: 'noop' }
| { action: DeviceStateStepTarget }
| CompositionStepT<T extends CompositionStepAction ? T : never>
| deviceConfig.ConfigStep;
@ -564,8 +560,8 @@ export async function shutdown({
});
}
export async function executeStepAction<T extends PossibleStepTargets>(
step: DeviceStateStep<T>,
export async function executeStepAction(
step: DeviceStateStep<PossibleStepTargets>,
{
force,
initial,
@ -586,19 +582,12 @@ export async function executeStepAction<T extends PossibleStepTargets>(
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<T extends PossibleStepTargets>(
}
}
export async function applyStep<T extends PossibleStepTargets>(
step: DeviceStateStep<T>,
export async function applyStep(
step: DeviceStateStep<PossibleStepTargets>,
{
force,
initial,
@ -623,12 +612,12 @@ export async function applyStep<T extends PossibleStepTargets>(
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;

View File

@ -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<void> {
// 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<HostConfig> {
return Bluebird.join(readProxy(), readHostname(), (proxy, hostname) => {
return {
network: {
proxy,
hostname,
},
};
});
export async function get(): Promise<HostConfig> {
return {
network: {
proxy: await readProxy(),
hostname: await readHostname(),
},
};
}
export async function patch(conf: HostConfig, force: boolean): Promise<void> {
export async function patch(
conf: HostConfig,
force: boolean = false,
): Promise<void> {
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<Promise<void>> = [];
if (conf != null && conf.network != null) {
if (conf.network.proxy != null) {
@ -233,6 +231,6 @@ export async function patch(conf: HostConfig, force: boolean): Promise<void> {
promises.push(setHostname(conf.network.hostname));
}
}
return Bluebird.all(promises).return();
await Promise.all(promises);
});
}

View File

@ -22,17 +22,24 @@ export class StatusError extends Error {
export const isStatusError = (x: unknown): x is StatusError =>
x != null && x instanceof Error && !isNaN((x as any).statusCode);
export class NotFoundError extends Error {
public statusCode: number;
constructor() {
super();
this.statusCode = 404;
export class NotFoundError extends StatusError {
constructor(statusMessage?: string) {
super(404, statusMessage ?? 'Not Found');
}
}
export const isNotFoundError = (e: unknown): e is NotFoundError =>
isStatusError(e) && e.statusCode === 404;
export class BadRequestError extends StatusError {
constructor(statusMessage?: string) {
super(400, statusMessage ?? 'Bad Request');
}
}
export const isBadRequestError = (e: unknown): e is BadRequestError =>
isStatusError(e) && e.statusCode === 400;
interface CodedSysError extends Error {
code?: string;
}

View File

@ -42,7 +42,7 @@ export function checkInt(
*
* Check that a string exists, and is not an empty string, 'null', or 'undefined'
*/
export function checkString(s: unknown): string | void {
export function checkString(s: unknown): string | undefined {
if (s == null || !_.isString(s) || _.includes(['null', 'undefined', ''], s)) {
return;
}

View File

@ -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": {

View File

@ -1,2 +0,0 @@
152.10.30.4
253.1.1.0/16

View File

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

View File

@ -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<ServiceComposeConfig>,
{ state = {} as Partial<Service>, 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<Image>,
) {
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<Service[]>,
);
const volumesByAppId = volumes.reduce(
(vols, v) => ({ ...vols, [v.appId]: [v].concat(vols[v.appId] || []) }),
{} as Dictionary<Volume[]>,
);
const networksByAppId = networks.reduce(
(nets, n) => ({ ...nets, [n.appId]: [n].concat(nets[n.appId] || []) }),
{} as Dictionary<Network[]>,
);
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<string> },
);
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

View File

@ -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<ReturnType<typeof generateTarget>>,
) =>
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<any> = {};
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<any>,
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<ReturnType<typeof generateTarget>>;
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<ReturnType<typeof generateTarget>>;
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,
);
});
});

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,10 @@ import * as deviceApi from '~/src/device-api';
import * as serviceManager from '~/src/compose/service-manager';
import * as images from '~/src/compose/images';
import * as config from '~/src/config';
import * as updateLock from '~/lib/update-lock';
import * as targetStateCache from '~/src/device-state/target-state-cache';
import * as mockedDockerode from '~/test-lib/mocked-dockerode';
import * as applicationManager from '~/src/compose/application-manager';
import * as logger from '~/src/logger';
import { UpdatesLockedError } from '~/lib/errors';
describe('SupervisorAPI [V2 Endpoints]', () => {
let serviceManagerMock: SinonStub;
let imagesMock: SinonStub;
@ -288,262 +284,5 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
});
});
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
// manager methods. A refactor is needed
describe.skip('POST /v2/applications/:appId/start-service', function () {
let appScopedKey: string;
let targetStateCacheMock: SinonStub;
let lockMock: SinonStub;
const service = {
serviceName: 'main',
containerId: 'abc123',
appId: 1658654,
serviceId: 640681,
};
const mockContainers = [mockedAPI.mockService(service)];
const mockImages = [mockedAPI.mockImage(service)];
beforeEach(() => {
// Setup device conditions
serviceManagerMock.resolves(mockContainers);
imagesMock.resolves(mockImages);
targetStateCacheMock.resolves({
appId: 2,
commit: 'abcdef2',
name: 'test-app2',
source: 'https://api.balena-cloud.com',
releaseId: 1232,
services: JSON.stringify([service]),
networks: '[]',
volumes: '[]',
});
lockMock.reset();
});
before(async () => {
// Create scoped key for application
appScopedKey = await deviceApi.generateScopedKey(1658654, 'main');
// Mock target state cache
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
lockMock = stub(updateLock, 'lock');
});
after(async () => {
targetStateCacheMock.restore();
lockMock.restore();
});
it('should return 200 for an existing service', async () => {
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(200);
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 404 for an unknown service', async () => {
await mockedDockerode.testWithData({}, async () => {
await request
.post(`/v2/applications/1658654/start-service?apikey=${appScopedKey}`)
.send({ serviceName: 'unknown' })
.set('Content-type', 'application/json')
.expect(404);
expect(applicationManagerSpy).to.not.have.been.called;
});
});
it('should ignore locks and return 200', async () => {
// Turn lock on
lockMock.throws(new UpdatesLockedError('Updates locked'));
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(200);
expect(lockMock).to.not.have.been.called;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
});
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
// manager methods. A refactor is needed
describe.skip('POST /v2/applications/:appId/restart-service', () => {
let appScopedKey: string;
let targetStateCacheMock: SinonStub;
let lockMock: SinonStub;
const service = {
serviceName: 'main',
containerId: 'abc123',
appId: 1658654,
serviceId: 640681,
};
const mockContainers = [mockedAPI.mockService(service)];
const mockImages = [mockedAPI.mockImage(service)];
const lockFake = async (
_: any,
opts: { force: boolean },
fn: () => any,
) => {
if (opts.force) {
return fn();
}
throw new UpdatesLockedError('Updates locked');
};
beforeEach(() => {
// Setup device conditions
serviceManagerMock.resolves(mockContainers);
imagesMock.resolves(mockImages);
targetStateCacheMock.resolves({
appId: 2,
commit: 'abcdef2',
name: 'test-app2',
source: 'https://api.balena-cloud.com',
releaseId: 1232,
services: JSON.stringify(mockContainers),
networks: '[]',
volumes: '[]',
});
lockMock.reset();
});
before(async () => {
// Create scoped key for application
appScopedKey = await deviceApi.generateScopedKey(1658654, 'main');
// Mock target state cache
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
lockMock = stub(updateLock, 'lock');
});
after(async () => {
targetStateCacheMock.restore();
lockMock.restore();
});
it('should return 200 for an existing service', async () => {
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(200);
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 404 for an unknown service', async () => {
await mockedDockerode.testWithData({}, async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'unknown' })
.set('Content-type', 'application/json')
.expect(404);
expect(applicationManagerSpy).to.not.have.been.called;
});
});
it('should return 423 for a service with update locks', async () => {
// Turn lock on
lockMock.throws(new UpdatesLockedError('Updates locked'));
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(423);
expect(lockMock).to.be.calledOnce;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 200 for a service with update locks and force true', async () => {
// Turn lock on
lockMock.callsFake(lockFake);
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main', force: true })
.set('Content-type', 'application/json')
.expect(200);
expect(lockMock).to.be.calledOnce;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 423 if force is explicitely set to false', async () => {
// Turn lock on
lockMock.callsFake(lockFake);
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main', force: false })
.set('Content-type', 'application/json')
.expect(423);
expect(lockMock).to.be.calledOnce;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
});
// TODO: add tests for rest of V2 endpoints
});

View File

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

View File

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

160
test/lib/state-helper.ts Normal file
View File

@ -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<ServiceComposeConfig>,
{ state = {} as Partial<Service>, 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<Image>,
) {
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<Service[]>,
);
const volumesByAppId = volumes.reduce(
(vols, v) => ({ ...vols, [v.appId]: [v].concat(vols[v.appId] || []) }),
{} as Dictionary<Volume[]>,
);
const networksByAppId = networks.reduce(
(nets, n) => ({ ...nets, [n.appId]: [n].concat(nets[n.appId] || []) }),
{} as Dictionary<Network[]>,
);
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<string> },
);
return {
currentApps,
availableImages: images,
downloading,
containerIdsByAppId,
};
}

View File

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