mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-04-24 04:55:42 +00:00
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:
commit
3142196202
@ -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
523
src/device-api/actions.ts
Normal 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);
|
||||
};
|
@ -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,
|
||||
);
|
||||
}
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -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.';
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -22,17 +22,24 @@ export class StatusError extends Error {
|
||||
export const isStatusError = (x: unknown): x is StatusError =>
|
||||
x != null && x instanceof Error && !isNaN((x as any).statusCode);
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
public statusCode: number;
|
||||
constructor() {
|
||||
super();
|
||||
this.statusCode = 404;
|
||||
export class NotFoundError extends StatusError {
|
||||
constructor(statusMessage?: string) {
|
||||
super(404, statusMessage ?? 'Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
export const isNotFoundError = (e: unknown): e is NotFoundError =>
|
||||
isStatusError(e) && e.statusCode === 404;
|
||||
|
||||
export class BadRequestError extends StatusError {
|
||||
constructor(statusMessage?: string) {
|
||||
super(400, statusMessage ?? 'Bad Request');
|
||||
}
|
||||
}
|
||||
|
||||
export const isBadRequestError = (e: unknown): e is BadRequestError =>
|
||||
isStatusError(e) && e.statusCode === 400;
|
||||
|
||||
interface CodedSysError extends Error {
|
||||
code?: string;
|
||||
}
|
||||
|
@ -42,7 +42,7 @@ export function checkInt(
|
||||
*
|
||||
* Check that a string exists, and is not an empty string, 'null', or 'undefined'
|
||||
*/
|
||||
export function checkString(s: unknown): string | void {
|
||||
export function checkString(s: unknown): string | undefined {
|
||||
if (s == null || !_.isString(s) || _.includes(['null', 'undefined', ''], s)) {
|
||||
return;
|
||||
}
|
||||
|
@ -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": {
|
||||
|
@ -1,2 +0,0 @@
|
||||
152.10.30.4
|
||||
253.1.1.0/16
|
@ -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";
|
||||
}
|
@ -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
|
||||
|
776
test/integration/device-api/actions.spec.ts
Normal file
776
test/integration/device-api/actions.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
863
test/integration/device-api/v1.spec.ts
Normal file
863
test/integration/device-api/v1.spec.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
640
test/integration/device-api/v2.spec.ts
Normal file
640
test/integration/device-api/v2.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
228
test/integration/host-config.spec.ts
Normal file
228
test/integration/host-config.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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
@ -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
|
||||
});
|
||||
|
@ -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();
|
||||
}
|
@ -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
160
test/lib/state-helper.ts
Normal 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,
|
||||
};
|
||||
}
|
70
test/unit/device-api/actions.spec.ts
Normal file
70
test/unit/device-api/actions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user