diff --git a/src/api-binder/index.ts b/src/api-binder/index.ts index 352082a2..c2545884 100644 --- a/src/api-binder/index.ts +++ b/src/api-binder/index.ts @@ -1,6 +1,5 @@ import * as Bluebird from 'bluebird'; import { stripIndent } from 'common-tags'; -import * as express from 'express'; import { isLeft } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import * as _ from 'lodash'; @@ -45,6 +44,10 @@ interface DeviceTag { let readyForUpdates = false; +export function isReadyForUpdates() { + return readyForUpdates; +} + export async function healthcheck() { const { appUpdatePollInterval, unmanaged, connectivityCheckEnabled } = await config.getMany([ @@ -570,29 +573,3 @@ export const initialized = _.once(async () => { log.info(`API Binder bound to: ${baseUrl}`); }); - -export const router = express.Router(); -router.use(express.urlencoded({ limit: '10mb', extended: true })); -router.use(express.json({ limit: '10mb' })); - -router.post('/v1/update', (req, res, next) => { - eventTracker.track('Update notification'); - if (readyForUpdates) { - 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); - } -}); diff --git a/src/compose/app.ts b/src/compose/app.ts index ed6ddd1a..24c9eb45 100644 --- a/src/compose/app.ts +++ b/src/compose/app.ts @@ -59,7 +59,6 @@ export class App { public commit?: string; public source?: string; public isHost?: boolean; - // Services are stored as an array, as at any one time we could have more than one // service for a single service ID running (for example handover) public services: Service[]; diff --git a/src/compose/application-manager.ts b/src/compose/application-manager.ts index 5746bda9..f8d5a153 100644 --- a/src/compose/application-manager.ts +++ b/src/compose/application-manager.ts @@ -1,48 +1,45 @@ -import * as express from 'express'; import * as _ from 'lodash'; +import { EventEmitter } from 'events'; import StrictEventEmitter from 'strict-event-emitter-types'; import * as config from '../config'; import { transaction, Transaction } from '../db'; +import * as logger from '../logger'; +import LocalModeManager from '../local-mode'; +import proxyvisor from '../proxyvisor'; + import * as dbFormat from '../device-state/db-format'; import { validateTargetContracts } from '../lib/contracts'; import constants = require('../lib/constants'); import { docker } from '../lib/docker-utils'; -import * as logger from '../logger'; import log from '../lib/supervisor-console'; -import LocalModeManager from '../local-mode'; import { ContractViolationError, InternalInconsistencyError, } from '../lib/errors'; import { lock } from '../lib/update-lock'; +import { checkTruthy } from '../lib/validation'; import App from './app'; import * as volumeManager from './volume-manager'; import * as networkManager from './network-manager'; import * as serviceManager from './service-manager'; import * as imageManager from './images'; -import type { Image } from './images'; -import { getExecutors, CompositionStepT } from './composition-steps'; import * as commitStore from './commit'; - import Service from './service'; import Network from './network'; import Volume from './volume'; +import { generateStep, getExecutors } from './composition-steps'; -import { createV1Api } from '../device-api/v1'; -import { createV2Api } from '../device-api/v2'; -import { CompositionStep, generateStep } from './composition-steps'; -import { +import type { InstancedAppState, TargetApps, DeviceLegacyReport, AppState, ServiceState, } from '../types/state'; -import { checkTruthy } from '../lib/validation'; -import { Proxyvisor } from '../proxyvisor'; -import { EventEmitter } from 'events'; +import type { Image } from './images'; +import type { CompositionStep, CompositionStepT } from './composition-steps'; type ApplicationManagerEventEmitter = StrictEventEmitter< EventEmitter, @@ -56,22 +53,8 @@ export const removeListener: typeof events['removeListener'] = export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind(events); -const proxyvisor = new Proxyvisor(); const localModeManager = new LocalModeManager(); -export const router = (() => { - const $router = express.Router(); - $router.use(express.urlencoded({ extended: true, limit: '10mb' })); - $router.use(express.json({ limit: '10mb' })); - - createV1Api($router); - createV2Api($router); - - $router.use(proxyvisor.router); - - return $router; -})(); - export let fetchesInProgress = 0; export let timeSpentFetching = 0; diff --git a/src/compose/utils.ts b/src/compose/utils.ts index f8e968c2..1b3fa38f 100644 --- a/src/compose/utils.ts +++ b/src/compose/utils.ts @@ -20,7 +20,7 @@ import { import log from '../lib/supervisor-console'; -import * as apiKeys from '../lib/api-keys'; +import * as deviceApi from '../device-api'; export function camelCaseConfig( literalConfig: ConfigMap, @@ -377,7 +377,7 @@ export async function addFeaturesFromLabels( }, 'io.balena.features.supervisor-api': async () => { // create a app/service specific API secret - const apiSecret = await apiKeys.generateScopedKey( + const apiSecret = await deviceApi.generateScopedKey( service.appId, service.serviceName, ); diff --git a/src/lib/api-keys.ts b/src/device-api/api-keys.ts similarity index 65% rename from src/lib/api-keys.ts rename to src/device-api/api-keys.ts index cc309ef7..6c48d12d 100644 --- a/src/lib/api-keys.ts +++ b/src/device-api/api-keys.ts @@ -1,13 +1,13 @@ import * as _ from 'lodash'; import * as express from 'express'; import * as memoizee from 'memoizee'; +import { TypedError } from 'typed-error'; -import * as config from '../config'; import * as db from '../db'; -import { generateUniqueKey } from './register-device'; +import { generateUniqueKey } from '../lib/register-device'; -export class KeyNotFoundError extends Error {} +class KeyNotFoundError extends TypedError {} /** * The schema for the `apiSecret` table in the database @@ -20,7 +20,7 @@ interface DbApiSecret { key: string; } -export type Scope = SerializableScope; +type Scope = SerializableScope; type ScopeTypeKey = keyof ScopeTypes; type SerializableScope = { type: T; @@ -59,13 +59,8 @@ const scopeChecks: ScopeCheckCollection = { resources.apps != null && resources.apps.includes(appId), }; -export function serialiseScopes(scopes: Scope[]): string { - return JSON.stringify(scopes); -} - -export function deserialiseScopes(json: string): Scope[] { - return JSON.parse(json); -} +const serialiseScopes = (scopes: Scope[]): string => JSON.stringify(scopes); +const deserialiseScopes = (json: string): Scope[] => JSON.parse(json); export const isScoped = ( resources: Partial, @@ -88,106 +83,28 @@ export type AuthorizedRequestHandler = ( next: express.NextFunction, ) => void; -// empty until populated in `initialized` -export let cloudApiKey: string = ''; - // should be called before trying to use this singleton export const initialized = _.once(async () => { await db.initialized(); // make sure we have an API key which the cloud will use to call us - await generateCloudKey(); + await generateGlobalKey(); }); -/** - * This middleware will extract an API key used to make a call, and then expand it out to provide - * access to the scopes it has. The `req` will be updated to include this `auth` data. - * - * E.g. `req.auth.scopes: []` - * - * @param req - * @param res - * @param next - */ -export const authMiddleware: AuthorizedRequestHandler = async ( - req, - res, - next, -) => { - // grab the API key used for the request - const apiKey = getApiKeyFromRequest(req) ?? ''; +// empty until populated in `initialized` +let globalApiKey: string = ''; - // store the key in the request, and an empty scopes array to populate after resolving the key scopes - req.auth = { - apiKey, - scopes: [], - isScoped: (resources) => isScoped(resources, req.auth.scopes), - }; - - try { - const conf = await config.getMany(['localMode', 'unmanaged']); - - // we only need to check the API key if managed and not in local mode - const needsAuth = !conf.unmanaged && !conf.localMode; - - // no need to authenticate, shortcut - if (!needsAuth) { - // Allow requests that do not need auth to be scoped for all applications - req.auth.isScoped = () => true; - return next(); - } - - // if we have a key, find the scopes and add them to the request - if (apiKey && apiKey !== '') { - await initialized(); - const scopes = await getScopesForKey(apiKey); - - if (scopes != null) { - // keep the scopes for later incase they're desired - req.auth.scopes.push(...scopes); - return next(); - } - } - - // we do not have a valid key... - return res.sendStatus(401); - } catch (err) { - console.error(err); - res.status(503).send(`Unexpected error: ${err}`); +export const getGlobalApiKey = async (): Promise => { + if (globalApiKey === '') { + await initialized(); } + + return globalApiKey; }; -function isEqualScope(a: Scope, b: Scope): boolean { - return _.isEqual(a, b); -} +const isEqualScope = (a: Scope, b: Scope): boolean => _.isEqual(a, b); -function getApiKeyFromRequest(req: express.Request): string | undefined { - // Check query for key - if (req.query.apikey) { - if (typeof req.query.apikey !== 'string') { - // We were passed something as an api key but it wasn't a string - // so ignore it - return; - } - return req.query.apikey; - } - - // Get Authorization header to search for key - const authHeader = req.get('Authorization'); - - // Check header for key - if (!authHeader) { - return; - } - - // Check authHeader with various schemes - const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i); - - // Return key from match or undefined - return match?.[1]; -} - -export type GenerateKeyOptions = { force: boolean; scopes: Scope[] }; +type GenerateKeyOptions = { force: boolean; scopes: Scope[] }; export async function getScopesForKey(key: string): Promise { const apiKey = await getApiKeyByKey(key); @@ -209,14 +126,12 @@ export async function generateScopedKey( return await generateKey(appId, serviceName, options); } -export async function generateCloudKey( - force: boolean = false, -): Promise { - cloudApiKey = await generateKey(0, null, { +async function generateGlobalKey(force: boolean = false): Promise { + globalApiKey = await generateKey(0, null, { force, scopes: [{ type: 'global' }], }); - return cloudApiKey; + return globalApiKey; } export async function refreshKey(key: string): Promise { @@ -230,7 +145,7 @@ export async function refreshKey(key: string): Promise { // if this is a cloud key that is being refreshed if (appId === 0 && serviceName === null) { - return await generateCloudKey(true); + return await generateGlobalKey(true); } // generate a new key, expiring the old one... diff --git a/src/device-api/common.js b/src/device-api/common.ts similarity index 73% rename from src/device-api/common.js rename to src/device-api/common.ts index c8f9383d..3a9af268 100644 --- a/src/device-api/common.js +++ b/src/device-api/common.ts @@ -6,11 +6,14 @@ 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 '../lib/messages'; +import { appNotFoundMessage } from './messages'; -export async function doRestart(appId, force) { +import type { InstancedDeviceState } from '../types'; + +export async function doRestart(appId: number, force: boolean) { await deviceState.initialized(); await applicationManager.initialized(); @@ -37,7 +40,7 @@ export async function doRestart(appId, force) { ); } -export async function doPurge(appId, force) { +export async function doPurge(appId: number, force: boolean) { await deviceState.initialized(); await applicationManager.initialized(); @@ -120,10 +123,10 @@ export async function doPurge(appId, force) { /** * This doesn't truly return an InstancedDeviceState, but it's close enough to mostly work where it's used - * - * @returns { import('../types/state').InstancedDeviceState } */ -export function safeStateClone(targetState) { +export function safeStateClone( + targetState: InstancedDeviceState, +): InstancedDeviceState { // We avoid using cloneDeep here, as the class // instances can cause a maximum call stack exceeded // error @@ -136,8 +139,7 @@ export function safeStateClone(targetState) { // thing to do would be to represent the input with // io-ts and make sure the below conforms to it - /** @type { any } */ - const cloned = { + const cloned: DeepPartial = { local: { config: {}, }, @@ -157,43 +159,48 @@ export function safeStateClone(targetState) { cloned.dependent = _.cloneDeep(targetState.dependent); } - return cloned; + return cloned as InstancedDeviceState; } -export function safeAppClone(app) { +export function safeAppClone(app: App): App { const containerIdForService = _.fromPairs( _.map(app.services, (svc) => [ svc.serviceName, - svc.containerId != null ? svc.containerId.substr(0, 12) : '', + svc.containerId != null ? svc.containerId.substring(0, 12) : '', ]), ); - return { - appId: app.appId, - name: app.name, - commit: app.commit, - releaseId: app.releaseId, - 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); + 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), - }; + _.each(svcCopy.config.networks, (net) => { + if (Array.isArray(net.aliases)) { + net.aliases = net.aliases.filter( + (alias) => alias !== containerIdForService[svcCopy.serviceName], + ); + } + }); + return svcCopy; + }), + volumes: _.cloneDeep(app.volumes), + networks: _.cloneDeep(app.networks), + isHost: app.isHost, + }, + true, + ); } diff --git a/src/device-api/index.ts b/src/device-api/index.ts index e69de29b..67f196d0 100644 --- a/src/device-api/index.ts +++ b/src/device-api/index.ts @@ -0,0 +1,140 @@ +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 proxyvisor from '../proxyvisor'; +import blink = require('../lib/blink'); +import log from '../lib/supervisor-console'; + +import type { Server } from 'http'; + +interface SupervisorAPIConstructOpts { + routers: express.Router[]; + healthchecks: Array<() => Promise>; +} + +interface SupervisorAPIStopOpts { + errored: boolean; +} + +// API key methods +// For better black boxing, device-api should serve as the interface +// to the rest of the Supervisor code for accessing API key related methods. +export const getGlobalApiKey = apiKeys.getGlobalApiKey; +export const refreshKey = apiKeys.refreshKey; +export const generateScopedKey = apiKeys.generateScopedKey; +export const getScopesForKey = apiKeys.getScopesForKey; + +export class SupervisorAPI { + private routers: express.Router[]; + private healthchecks: Array<() => Promise>; + + private api = express(); + private server: Server | null = null; + + public constructor({ routers, healthchecks }: SupervisorAPIConstructOpts) { + this.routers = routers; + this.healthchecks = healthchecks; + + this.api.disable('x-powered-by'); + 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'); + } + return res.sendStatus(200); + } catch { + log.error('Healthcheck failed'); + return res.status(500).send('Unhealthy'); + } + }); + + this.api.get('/ping', (_req, res) => res.send('OK')); + + this.api.use(middleware.auth); + + this.api.post('/v1/blink', (_req, res) => { + eventTracker.track('Device blink'); + blink.pattern.start(); + setTimeout(blink.pattern.stop, 15000); + 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, + }); + } + + // return the value of the new key to the caller + res.status(200).send(newKey); + }, + ); + + this.api.use(express.urlencoded({ limit: '10mb', extended: true })); + this.api.use(express.json({ limit: '10mb' })); + + // And assign all external routers + for (const router of this.routers) { + this.api.use(router); + } + + this.api.use(proxyvisor.router); + + this.api.use(middleware.errors); + } + + public async listen(port: number, apiTimeout: number): Promise { + return new Promise((resolve) => { + this.server = this.api.listen(port, () => { + log.info(`Supervisor API successfully started on port ${port}`); + if (this.server) { + this.server.timeout = apiTimeout; + } + return resolve(); + }); + }); + } + + public async stop(options?: SupervisorAPIStopOpts): Promise { + if (this.server != null) { + return new Promise((resolve, reject) => { + this.server?.close((err: Error) => { + if (err) { + log.error('Failed to stop Supervisor API'); + return reject(err); + } + options?.errored + ? log.error('Stopped Supervisor API') + : log.info('Stopped Supervisor API'); + return resolve(); + }); + }); + } + } +} + +export default SupervisorAPI; diff --git a/src/lib/messages.ts b/src/device-api/messages.ts similarity index 100% rename from src/lib/messages.ts rename to src/device-api/messages.ts diff --git a/src/device-api/middleware/auth.ts b/src/device-api/middleware/auth.ts new file mode 100644 index 00000000..2d660eff --- /dev/null +++ b/src/device-api/middleware/auth.ts @@ -0,0 +1,84 @@ +import * as apiKeys from '../api-keys'; +import * as config from '../../config'; + +import type { Request } from 'express'; + +/** + * This middleware will extract an API key used to make a call, and then expand it out to provide + * access to the scopes it has. The `req` will be updated to include this `auth` data. + * + * E.g. `req.auth.scopes: []` + * + * @param req + * @param res + * @param next + */ +export const auth: apiKeys.AuthorizedRequestHandler = async ( + req, + res, + next, +) => { + // grab the API key used for the request + const apiKey = getApiKeyFromRequest(req) ?? ''; + + // store the key in the request, and an empty scopes array to populate after resolving the key scopes + req.auth = { + apiKey, + scopes: [], + isScoped: (resources) => apiKeys.isScoped(resources, req.auth.scopes), + }; + + try { + const conf = await config.getMany(['localMode', 'unmanaged']); + + // we only need to check the API key if managed and not in local mode + const needsAuth = !conf.unmanaged && !conf.localMode; + + // no need to authenticate, shortcut + if (!needsAuth) { + // Allow requests that do not need auth to be scoped for all applications + req.auth.isScoped = () => true; + return next(); + } + + // if we have a key, find the scopes and add them to the request + if (apiKey && apiKey !== '') { + await apiKeys.initialized(); + const scopes = await apiKeys.getScopesForKey(apiKey); + + if (scopes != null) { + // keep the scopes for later incase they're desired + req.auth.scopes.push(...scopes); + return next(); + } + } + + // we do not have a valid key... + return res.sendStatus(401); + } catch (err) { + console.error(err); + res.status(503).send(`Unexpected error: ${err}`); + } +}; + +function getApiKeyFromRequest(req: Request): string | undefined { + const { apikey } = req.query; + // Check query for key + if (apikey && typeof apikey === 'string') { + return apikey; + } + + // Get Authorization header to search for key + const authHeader = req.get('Authorization'); + + // Check header for key + if (!authHeader) { + return undefined; + } + + // Check authHeader with various schemes + const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i); + + // Return key from match or undefined + return match?.[1]; +} diff --git a/src/device-api/middleware/errors.ts b/src/device-api/middleware/errors.ts new file mode 100644 index 00000000..db68349f --- /dev/null +++ b/src/device-api/middleware/errors.ts @@ -0,0 +1,42 @@ +import * as _ from 'lodash'; + +import { UpdatesLockedError } from '../../lib/errors'; +import log from '../../lib/supervisor-console'; + +import type { Request, Response, NextFunction } from 'express'; + +const messageFromError = (err?: Error | string | null): string => { + let message = 'Unknown error'; + if (err != null) { + if (_.isError(err) && err.message != null) { + message = err.message; + } else { + message = err as string; + } + } + return message; +}; + +export const errors = ( + err: Error, + req: Request, + res: Response, + next: NextFunction, +) => { + if (res.headersSent) { + // Error happens while we are writing the response - default handler closes the connection. + next(err); + return; + } + + // Return 423 Locked when locks as set + const code = err instanceof UpdatesLockedError ? 423 : 503; + if (code !== 423) { + log.error(`Error on ${req.method} ${req.path}: `, err); + } + + res.status(code).send({ + status: 'failed', + message: messageFromError(err), + }); +}; diff --git a/src/device-api/middleware/index.ts b/src/device-api/middleware/index.ts new file mode 100644 index 00000000..4bdf997a --- /dev/null +++ b/src/device-api/middleware/index.ts @@ -0,0 +1,3 @@ +export * from './logging'; +export * from './auth'; +export * from './errors'; diff --git a/src/device-api/middleware/logging.ts b/src/device-api/middleware/logging.ts new file mode 100644 index 00000000..7b0e9861 --- /dev/null +++ b/src/device-api/middleware/logging.ts @@ -0,0 +1,19 @@ +import * as morgan from 'morgan'; +import { Request } from 'express'; + +import log from '../../lib/supervisor-console'; + +export const logging = morgan( + (tokens, req: Request, res) => + [ + tokens.method(req, res), + req.path, + tokens.status(req, res), + '-', + tokens['response-time'](req, res), + 'ms', + ].join(' '), + { + stream: { write: (d) => log.api(d.toString().trimEnd()) }, + }, +); diff --git a/src/device-api/v1.ts b/src/device-api/v1.ts index c8f0255a..9de0974c 100644 --- a/src/device-api/v1.ts +++ b/src/device-api/v1.ts @@ -1,181 +1,170 @@ import * as express from 'express'; import * as _ from 'lodash'; +import { doRestart, doPurge } from './common'; +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 { doRestart, doPurge } from './common'; - +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 { AuthorizedRequest } from '../lib/api-keys'; import { getApp } from '../device-state/db-format'; +import * as TargetState from '../device-state/target-state'; -export function createV1Api(router: express.Router) { - router.post('/v1/restart', (req: AuthorizedRequest, res, next) => { - const appId = checkInt(req.body.appId); - const force = checkTruthy(req.body.force); - eventTracker.track('Restart container (v1)', { appId }); - if (appId == null) { - return res.status(400).send('Missing app id'); - } +const disallowedHostConfigPatchFields = ['local_ip', 'local_port']; - // 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; - } +export const router = express.Router(); - return doRestart(appId, force) - .then(() => res.status(200).send('OK')) - .catch(next); - }); +router.post('/v1/restart', (req: AuthorizedRequest, res, next) => { + const appId = checkInt(req.body.appId); + const force = checkTruthy(req.body.force); + eventTracker.track('Restart container (v1)', { appId }); + if (appId == null) { + return res.status(400).send('Missing app id'); + } - 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'); - } + // 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]) => { - 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', - ); - } + return doRestart(appId, force) + .then(() => res.status(200).send('OK')) + .catch(next); +}); - // check that the request is scoped to cover this application - if (!req.auth.isScoped({ apps: [app.appId] })) { - return res.status(401).send('Unauthorized'); - } +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'); + } - // 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, - }); - - 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 createV1StopOrStartHandler = (action: 'start' | 'stop') => - _.partial(v1StopOrStart, _, _, _, action); - - router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop')); - router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start')); - - 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'); - } - - try { - const apps = await applicationManager.getCurrentApps(); - const app = apps[appId]; - const service = app?.services?.[0]; - if (service == null) { + return Promise.all([applicationManager.getCurrentApps(), getApp(appId)]) + .then(([apps, targetApp]) => { + if (apps[appId] == null) { return res.status(400).send('App not found'); } - - // 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; + 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'); } - // 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); + // check that the request is scoped to cover this application + if (!req.auth.isScoped({ apps: [app.appId] })) { + return res.status(401).send('Unauthorized'); + } - // 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, - }; + // 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, + }); - return res.json(appToSend); - } catch (e) { - next(e); - } - }); + applicationManager.setTargetVolatileForService(service.imageId, { + running: action !== 'stop', + }); - router.post('/v1/purge', (req: AuthorizedRequest, res, next) => { - const appId = checkInt(req.body.appId); - const force = checkTruthy(req.body.force); - if (appId == null) { - const errMsg = 'Invalid or missing appId'; - return res.status(400).send(errMsg); + 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 createV1StopOrStartHandler = (action: 'start' | 'stop') => + _.partial(v1StopOrStart, _, _, _, action); + +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', + }); + } +}; + +router.post('/v1/reboot', (req, res) => rebootOrShutdown(req, res, 'reboot')); +router.post('/v1/shutdown', (req, res) => + rebootOrShutdown(req, res, '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'); + } + + 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'); } // handle the case where the appId is out of scope - if (!req.auth.isScoped({ apps: [appId] })) { + if (!req.auth.isScoped({ apps: [app.appId] })) { res.status(401).json({ status: 'failed', message: 'Application is not available', @@ -183,8 +172,183 @@ export function createV1Api(router: express.Router) { return; } - return doPurge(appId, force) - .then(() => res.status(200).json({ Data: 'OK', Error: '' })) + 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); + } +}); + +router.post('/v1/purge', (req: AuthorizedRequest, res, next) => { + const appId = checkInt(req.body.appId); + const force = checkTruthy(req.body.force); + if (appId == null) { + const errMsg = 'Invalid or missing appId'; + return res.status(400).send(errMsg); + } + + // 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 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.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.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"); + // If network does not exist, skip all field validation checks below + throw new Error(); + } + + const { proxy } = req.body.network; + + // Validate proxy fields, if they exist + if (proxy && Object.keys(proxy).length) { + const blacklistedFields = Object.keys(proxy).filter((key) => + disallowedHostConfigPatchFields.includes(key), + ); + + if (blacklistedFields.length > 0) { + log.warn(`Invalid proxy field(s): ${blacklistedFields.join(', ')}`); + } + + if ( + proxy.type && + !constants.validRedsocksProxyTypes.includes(proxy.type) + ) { + log.warn( + `Invalid redsocks proxy type, must be one of ${constants.validRedsocksProxyTypes.join( + ', ', + )}`, + ); + } + + if (proxy.noProxy && !Array.isArray(proxy.noProxy)) { + log.warn('noProxy field must be an array of addresses'); + } + } + } catch (e) { + /* noop */ + } + + 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); + } + 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'); + } +}); + +router.get('/v1/device', async (_req, res) => { + try { + const state = await deviceState.getLegacyState(); + const stateToSend = _.pick(state.local, [ + 'api_port', + 'ip_address', + 'os_version', + 'mac_address', + 'supervisor_version', + 'update_pending', + 'update_failed', + 'update_downloaded', + ]) as Dictionary; + if (state.local?.is_on__commit != null) { + stateToSend.commit = state.local.is_on__commit; + } + const service = _.toPairs( + _.toPairs(state.local?.apps)[0]?.[1]?.services, + )[0]?.[1]; + + if (service != null) { + stateToSend.status = service.status; + if (stateToSend.status === 'Running') { + stateToSend.status = 'Idle'; + } + stateToSend.download_progress = service.download_progress; + } + res.json(stateToSend); + } catch (e: any) { + res.status(500).json({ + Data: '', + Error: (e != null ? e.message : undefined) || e || 'Unknown error', + }); + } +}); diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index fe30541e..aeaf99ac 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -1,5 +1,6 @@ import * as Bluebird from 'bluebird'; -import { NextFunction, Response, Router } from 'express'; +import * as express from 'express'; +import type { Response, NextFunction } from 'express'; import * as _ from 'lodash'; import * as deviceState from '../device-state'; @@ -25,30 +26,147 @@ import { appNotFoundMessage, serviceNotFoundMessage, v2ServiceEndpointInputErrorMessage, -} from '../lib/messages'; +} from './messages'; import log from '../lib/supervisor-console'; import supervisorVersion = require('../lib/supervisor-version'); import { checkInt, checkTruthy } from '../lib/validation'; import { isVPNActive } from '../network'; import { doPurge, doRestart, safeStateClone } from './common'; -import { AuthorizedRequest } from '../lib/api-keys'; +import { AuthorizedRequest } from './api-keys'; import { fromV2TargetState } from '../lib/legacy'; -export function createV2Api(router: Router) { - const handleServiceAction = ( - req: AuthorizedRequest, - res: Response, - next: NextFunction, - action: CompositionStepAction, - ): Resolvable => { - const { imageId, serviceName, force } = req.body; +export const router = express.Router(); + +const handleServiceAction = ( + req: AuthorizedRequest, + res: Response, + next: NextFunction, + action: CompositionStepAction, +): Resolvable => { + const { imageId, serviceName, force } = req.body; + const appId = checkInt(req.params.appId); + if (!appId) { + res.status(400).json({ + status: 'failed', + message: 'Missing app id', + }); + return; + } + + // 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', + }); + return applicationManager + .executeStep( + generateStep(action, { + current: service, + target: targetService, + wait: true, + }), + { + force, + }, + ) + .then(() => { + res.status(200).send('OK'); + }); + }) + .catch(next); +}; + +const createServiceActionHandler = (action: string) => + _.partial(handleServiceAction, _, _, _, action); + +router.post( + '/v2/applications/:appId/purge', + (req: AuthorizedRequest, res: Response, next: NextFunction) => { + const { force } = req.body; const appId = checkInt(req.params.appId); if (!appId) { - res.status(400).json({ + return res.status(400).json({ + status: 'failed', + message: 'Missing app id', + }); + } + + // handle the case where the application is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + return res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + } + + return doPurge(appId, force) + .then(() => { + res.status(200).send('OK'); + }) + .catch(next); + }, +); + +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); + if (!appId) { + return res.status(400).json({ status: 'failed', message: 'Missing app id', }); - return; } // handle the case where the appId is out of scope @@ -60,549 +178,428 @@ export function createV2Api(router: Router) { 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', - }); - return applicationManager - .executeStep( - generateStep(action, { - current: service, - target: targetService, - wait: true, - }), - { - force, - }, - ) - .then(() => { - res.status(200).send('OK'); - }); + return doRestart(appId, force) + .then(() => { + res.status(200).send('OK'); }) .catch(next); - }; + }, +); - const createServiceActionHandler = (action: string) => - _.partial(handleServiceAction, _, _, _, action); - - router.post( - '/v2/applications/:appId/purge', - (req: AuthorizedRequest, res: Response, next: NextFunction) => { - const { force } = req.body; - const appId = checkInt(req.params.appId); - if (!appId) { - return res.status(400).json({ - status: 'failed', - message: 'Missing app id', - }); - } - - // handle the case where the application is out of scope - if (!req.auth.isScoped({ apps: [appId] })) { - res.status(401).json({ - status: 'failed', - message: 'Application is not available', - }); - return; - } - - return doPurge(appId, force) - .then(() => { - res.status(200).send('OK'); - }) - .catch(next); - }, - ); - - 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); - if (!appId) { - return res.status(400).json({ - status: 'failed', - message: 'Missing app id', - }); - } - - // 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 doRestart(appId, force) - .then(() => { - res.status(200).send('OK'); - }) - .catch(next); - }, - ); - - // TODO: Support dependent applications when this feature is complete - router.get( - '/v2/applications/state', - async (req: AuthorizedRequest, res: Response, next: NextFunction) => { - // It's kinda hacky to access the services and db via the application manager - // maybe refactor this code - Bluebird.join( - serviceManager.getState(), - images.getState(), - db.models('app').select(['appId', 'commit', 'name']), - ( - services, - imgs, - apps: Array<{ appId: string; commit: string; name: string }>, - ) => { - // Create an object which is keyed my application name - const response: { - [appName: string]: { - appId: number; - commit: string; - services: { - [serviceName: string]: { - status?: string; - releaseId: number; - downloadProgress: number | null; - }; +// TODO: Support dependent applications when this feature is complete +router.get( + '/v2/applications/state', + async (req: AuthorizedRequest, res: Response, next: NextFunction) => { + // It's kinda hacky to access the services and db via the application manager + // maybe refactor this code + Bluebird.join( + serviceManager.getState(), + images.getState(), + db.models('app').select(['appId', 'commit', 'name']), + ( + services, + imgs, + apps: Array<{ appId: string; commit: string; name: string }>, + ) => { + // Create an object which is keyed my application name + const response: { + [appName: string]: { + appId: number; + commit: string; + services: { + [serviceName: string]: { + status?: string; + releaseId: number; + downloadProgress: number | null; }; }; - } = {}; + }; + } = {}; - const appNameById: { [id: number]: string } = {}; + const appNameById: { [id: number]: string } = {}; - // only access scoped apps - apps - .filter((app) => - req.auth.isScoped({ apps: [parseInt(app.appId, 10)] }), - ) - .forEach((app) => { - const appId = parseInt(app.appId, 10); - response[app.name] = { - appId, - commit: app.commit, - services: {}, - }; + // only access scoped apps + apps + .filter((app) => + req.auth.isScoped({ apps: [parseInt(app.appId, 10)] }), + ) + .forEach((app) => { + const appId = parseInt(app.appId, 10); + response[app.name] = { + appId, + commit: app.commit, + services: {}, + }; - appNameById[appId] = app.name; + appNameById[appId] = app.name; + }); + + // only access scoped images + imgs + .filter((img) => req.auth.isScoped({ apps: [img.appId] })) + .forEach((img) => { + const appName = appNameById[img.appId]; + if (appName == null) { + log.warn( + `Image found for unknown application!\nImage: ${JSON.stringify( + img, + )}`, + ); + return; + } + + const svc = _.find(services, (service: Service) => { + return service.imageId === img.imageId; }); - // only access scoped images - imgs - .filter((img) => req.auth.isScoped({ apps: [img.appId] })) - .forEach((img) => { - const appName = appNameById[img.appId]; - if (appName == null) { - log.warn( - `Image found for unknown application!\nImage: ${JSON.stringify( - img, - )}`, - ); - return; - } + let status: string | undefined; + if (svc == null) { + status = img.status; + } else { + status = svc.status || img.status; + } + response[appName].services[img.serviceName] = { + status, + releaseId: img.releaseId, + downloadProgress: img.downloadProgress || null, + }; + }); - const svc = _.find(services, (service: Service) => { - return service.imageId === img.imageId; - }); + res.status(200).json(response); + }, + ).catch(next); + }, +); - let status: string | undefined; - if (svc == null) { - status = img.status; - } else { - status = svc.status || img.status; - } - response[appName].services[img.serviceName] = { - status, - releaseId: img.releaseId, - downloadProgress: img.downloadProgress || null, - }; - }); - - res.status(200).json(response); - }, - ).catch(next); - }, - ); - - router.get( - '/v2/applications/:appId/state', - async (req: AuthorizedRequest, res: Response) => { - // Check application ID provided is valid - const appId = checkInt(req.params.appId); - if (!appId) { - return res.status(400).json({ - status: 'failed', - message: `Invalid application ID: ${req.params.appId}`, - }); - } - - // Query device for all applications - let apps: any; - try { - apps = await applicationManager.getLegacyState(); - } catch (e: any) { - log.error(e.message); - return res.status(500).json({ - status: 'failed', - message: `Unable to retrieve state for application ID: ${appId}`, - }); - } - // Check if the application exists - if (!(appId in apps.local) || !req.auth.isScoped({ apps: [appId] })) { - return res.status(409).json({ - status: 'failed', - message: `Application ID does not exist: ${appId}`, - }); - } - - // 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; - } - - // Filter applications we do not want - for (const app in apps.local) { - if (app !== appId.toString()) { - delete apps.local[app]; - } - } - - const commit = await commitStore.getCommitForApp(appId); - - // Return filtered applications - return res.status(200).json({ commit, ...apps }); - }, - ); - - router.get('/v2/local/target-state', async (_req, res) => { - const targetState = await deviceState.getTarget(); - const target = safeStateClone(targetState); - - res.status(200).json({ - status: 'success', - state: target, - }); - }); - - router.post('/v2/local/target-state', async (req, res) => { - // let's first ensure that we're in local mode, otherwise - // this function should not do anything - const localMode = await config.get('localMode'); - if (!localMode) { +router.get( + '/v2/applications/:appId/state', + async (req: AuthorizedRequest, res: Response) => { + // Check application ID provided is valid + const appId = checkInt(req.params.appId); + if (!appId) { return res.status(400).json({ status: 'failed', - message: 'Target state can only set when device is in local mode', + message: `Invalid application ID: ${req.params.appId}`, }); } - // Now attempt to set the state - const force = req.body.force; - - // Migrate target state from v2 to v3 to maintain API compatibility - const targetState = await fromV2TargetState(req.body, true); - + // Query device for all applications + let apps: any; try { - await deviceState.setTarget(targetState, true); - await deviceState.triggerApplyTarget({ force }); - res.status(200).json({ - status: 'success', - message: 'OK', - }); + apps = await applicationManager.getLegacyState(); } catch (e: any) { - res.status(400).json({ + log.error(e.message); + return res.status(500).json({ status: 'failed', - message: e.message, + message: `Unable to retrieve state for application ID: ${appId}`, }); } - }); - - router.get('/v2/local/device-info', async (_req, res) => { - try { - const { deviceType, deviceArch } = await config.getMany([ - 'deviceType', - 'deviceArch', - ]); - - return res.status(200).json({ - status: 'success', - info: { - arch: deviceArch, - deviceType, - }, - }); - } catch (e: any) { - res.status(500).json({ + // Check if the application exists + if (!(appId in apps.local) || !req.auth.isScoped({ apps: [appId] })) { + return res.status(409).json({ status: 'failed', - message: e.message, + message: `Application ID does not exist: ${appId}`, }); } - }); - router.get('/v2/local/logs', async (_req, res) => { - const serviceNameCache: { [sId: number]: string } = {}; - const backend = logger.getLocalBackend(); - // Cache the service names to IDs per call to the endpoint - backend.assignServiceNameResolver(async (id: number) => { - if (id in serviceNameCache) { - return serviceNameCache[id]; - } else { - const name = await applicationManager.serviceNameFromId(id); - serviceNameCache[id] = name; - return name; + // 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; + } + + // Filter applications we do not want + for (const app in apps.local) { + if (app !== appId.toString()) { + delete apps.local[app]; } - }); + } - // Get the stream, and stream it into res - const listenStream = backend.attachListener(); + const commit = await commitStore.getCommitForApp(appId); - // The http connection doesn't correctly intialise until some data is sent, - // which means any callers waiting on the data being returned will hang - // until the first logs comes through. To avoid this we send an initial - // message - res.write( - `${JSON.stringify({ message: 'Streaming logs', isSystem: true })}\n`, - ); - listenStream.pipe(res); + // Return filtered applications + return res.status(200).json({ commit, ...apps }); + }, +); + +router.get('/v2/local/target-state', async (_req, res) => { + const targetState = await deviceState.getTarget(); + const target = safeStateClone(targetState); + + res.status(200).json({ + status: 'success', + state: target, }); +}); - router.get('/v2/version', (_req, res) => { +router.post('/v2/local/target-state', async (req, res) => { + // let's first ensure that we're in local mode, otherwise + // this function should not do anything + const localMode = await config.get('localMode'); + if (!localMode) { + return res.status(400).json({ + status: 'failed', + message: 'Target state can only set when device is in local mode', + }); + } + + // Now attempt to set the state + const force = req.body.force; + + // Migrate target state from v2 to v3 to maintain API compatibility + const targetState = await fromV2TargetState(req.body, true); + + try { + await deviceState.setTarget(targetState, true); + await deviceState.triggerApplyTarget({ force }); res.status(200).json({ status: 'success', - version: supervisorVersion, + message: 'OK', }); + } catch (e: any) { + res.status(400).json({ + status: 'failed', + message: e.message, + }); + } +}); + +router.get('/v2/local/device-info', async (_req, res) => { + try { + const { deviceType, deviceArch } = await config.getMany([ + 'deviceType', + 'deviceArch', + ]); + + return res.status(200).json({ + status: 'success', + info: { + arch: deviceArch, + deviceType, + }, + }); + } catch (e: any) { + res.status(500).json({ + status: 'failed', + message: e.message, + }); + } +}); + +router.get('/v2/local/logs', async (_req, res) => { + const serviceNameCache: { [sId: number]: string } = {}; + const backend = logger.getLocalBackend(); + // Cache the service names to IDs per call to the endpoint + backend.assignServiceNameResolver(async (id: number) => { + if (id in serviceNameCache) { + return serviceNameCache[id]; + } else { + const name = await applicationManager.serviceNameFromId(id); + serviceNameCache[id] = name; + return name; + } }); - router.get('/v2/containerId', async (req: AuthorizedRequest, res) => { - const services = (await serviceManager.getAll()).filter((service) => - req.auth.isScoped({ apps: [service.appId] }), - ); + // Get the stream, and stream it into res + const listenStream = backend.attachListener(); - if (req.query.serviceName != null || req.query.service != null) { - const serviceName = req.query.serviceName || req.query.service; - const service = _.find( - services, - (svc) => svc.serviceName === serviceName, - ); - if (service != null) { - res.status(200).json({ - status: 'success', - containerId: service.containerId, - }); - } else { - res.status(503).json({ - status: 'failed', - message: 'Could not find service with that name', - }); - } - } else { + // The http connection doesn't correctly intialise until some data is sent, + // which means any callers waiting on the data being returned will hang + // until the first logs comes through. To avoid this we send an initial + // message + res.write( + `${JSON.stringify({ message: 'Streaming logs', isSystem: true })}\n`, + ); + listenStream.pipe(res); +}); + +router.get('/v2/version', (_req, res) => { + res.status(200).json({ + status: 'success', + version: supervisorVersion, + }); +}); + +router.get('/v2/containerId', async (req: AuthorizedRequest, res) => { + const services = (await serviceManager.getAll()).filter((service) => + req.auth.isScoped({ apps: [service.appId] }), + ); + + if (req.query.serviceName != null || req.query.service != null) { + const serviceName = req.query.serviceName || req.query.service; + const service = _.find(services, (svc) => svc.serviceName === serviceName); + if (service != null) { res.status(200).json({ status: 'success', - services: _(services) - .keyBy('serviceName') - .mapValues('containerId') - .value(), + containerId: service.containerId, }); - } - }); - - router.get('/v2/state/status', async (req: AuthorizedRequest, res) => { - const appIds: number[] = []; - const pending = deviceState.isApplyInProgress(); - const containerStates = (await serviceManager.getAll()) - .filter((service) => req.auth.isScoped({ apps: [service.appId] })) - .map((svc) => { - appIds.push(svc.appId); - return _.pick( - svc, - 'status', - 'serviceName', - 'appId', - 'imageId', - 'serviceId', - 'containerId', - 'createdAt', - ); - }); - - let downloadProgressTotal = 0; - let downloads = 0; - const imagesStates = (await images.getState()) - .filter((img) => req.auth.isScoped({ apps: [img.appId] })) - .map((img) => { - appIds.push(img.appId); - if (img.downloadProgress != null) { - downloadProgressTotal += img.downloadProgress; - downloads += 1; - } - return _.pick( - img, - 'name', - 'appId', - 'serviceName', - 'imageId', - 'dockerImageId', - 'status', - 'downloadProgress', - ); - }); - - let overallDownloadProgress: number | null = null; - if (downloads > 0) { - overallDownloadProgress = downloadProgressTotal / downloads; - } - - // This endpoint does not support multi-app but the device might be running multiple apps - // We must return information for only 1 application so use the first one in the list - const appId = appIds[0]; - // Get the commit for this application - const commit = await commitStore.getCommitForApp(appId); - // Filter containers by this application - const appContainers = containerStates.filter((c) => c.appId === appId); - // Filter images by this application - const appImages = imagesStates.filter((i) => i.appId === appId); - - return res.status(200).send({ - status: 'success', - appState: pending ? 'applying' : 'applied', - overallDownloadProgress, - containers: appContainers, - images: appImages, - release: commit, - }); - }); - - router.get('/v2/device/name', async (_req, res) => { - const deviceName = await config.get('name'); - res.json({ - status: 'success', - deviceName, - }); - }); - - router.get('/v2/device/tags', async (_req, res) => { - try { - const tags = await apiBinder.fetchDeviceTags(); - return res.json({ - status: 'success', - tags, - }); - } catch (e: any) { - log.error(e); - res.status(500).json({ + } else { + res.status(503).json({ status: 'failed', - message: e.message, + message: 'Could not find service with that name', }); } - }); + } else { + res.status(200).json({ + status: 'success', + services: _(services) + .keyBy('serviceName') + .mapValues('containerId') + .value(), + }); + } +}); - router.get('/v2/device/vpn', async (_req, res) => { - const conf = await deviceConfig.getCurrent(); - // Build VPNInfo - const info = { - enabled: conf.SUPERVISOR_VPN_CONTROL === 'true', - connected: await isVPNActive(), - }; - // Return payload +router.get('/v2/state/status', async (req: AuthorizedRequest, res) => { + const appIds: number[] = []; + const pending = deviceState.isApplyInProgress(); + const containerStates = (await serviceManager.getAll()) + .filter((service) => req.auth.isScoped({ apps: [service.appId] })) + .map((svc) => { + appIds.push(svc.appId); + return _.pick( + svc, + 'status', + 'serviceName', + 'appId', + 'imageId', + 'serviceId', + 'containerId', + 'createdAt', + ); + }); + + let downloadProgressTotal = 0; + let downloads = 0; + const imagesStates = (await images.getState()) + .filter((img) => req.auth.isScoped({ apps: [img.appId] })) + .map((img) => { + appIds.push(img.appId); + if (img.downloadProgress != null) { + downloadProgressTotal += img.downloadProgress; + downloads += 1; + } + return _.pick( + img, + 'name', + 'appId', + 'serviceName', + 'imageId', + 'dockerImageId', + 'status', + 'downloadProgress', + ); + }); + + let overallDownloadProgress: number | null = null; + if (downloads > 0) { + overallDownloadProgress = downloadProgressTotal / downloads; + } + + // This endpoint does not support multi-app but the device might be running multiple apps + // We must return information for only 1 application so use the first one in the list + const appId = appIds[0]; + // Get the commit for this application + const commit = await commitStore.getCommitForApp(appId); + // Filter containers by this application + const appContainers = containerStates.filter((c) => c.appId === appId); + // Filter images by this application + const appImages = imagesStates.filter((i) => i.appId === appId); + + return res.status(200).send({ + status: 'success', + appState: pending ? 'applying' : 'applied', + overallDownloadProgress, + containers: appContainers, + images: appImages, + release: commit, + }); +}); + +router.get('/v2/device/name', async (_req, res) => { + const deviceName = await config.get('name'); + res.json({ + status: 'success', + deviceName, + }); +}); + +router.get('/v2/device/tags', async (_req, res) => { + try { + const tags = await apiBinder.fetchDeviceTags(); return res.json({ status: 'success', - vpn: info, + tags, }); + } catch (e: any) { + log.error(e); + res.status(500).json({ + status: 'failed', + message: e.message, + }); + } +}); + +router.get('/v2/device/vpn', async (_req, res) => { + const conf = await deviceConfig.getCurrent(); + // Build VPNInfo + const info = { + enabled: conf.SUPERVISOR_VPN_CONTROL === 'true', + connected: await isVPNActive(), + }; + // Return payload + return res.json({ + status: 'success', + vpn: info, }); +}); - router.get('/v2/cleanup-volumes', async (req: AuthorizedRequest, res) => { - const targetState = await applicationManager.getTargetApps(); - const referencedVolumes = Object.values(targetState) - // if this app isn't in scope of the request, do not cleanup it's volumes - .filter((app) => req.auth.isScoped({ apps: [app.id] })) - .flatMap((app) => { - const [release] = Object.values(app.releases); - // Return a list of the volume names - return Object.keys(release?.volumes ?? {}).map((volumeName) => - Volume.generateDockerName(app.id, volumeName), - ); - }); - - await volumeManager.removeOrphanedVolumes(referencedVolumes); - res.json({ - status: 'success', +router.get('/v2/cleanup-volumes', async (req: AuthorizedRequest, res) => { + const targetState = await applicationManager.getTargetApps(); + const referencedVolumes = Object.values(targetState) + // if this app isn't in scope of the request, do not cleanup it's volumes + .filter((app) => req.auth.isScoped({ apps: [app.id] })) + .flatMap((app) => { + const [release] = Object.values(app.releases); + // Return a list of the volume names + return Object.keys(release?.volumes ?? {}).map((volumeName) => + Volume.generateDockerName(app.id, volumeName), + ); }); + + await volumeManager.removeOrphanedVolumes(referencedVolumes); + res.json({ + status: 'success', }); +}); - router.post('/v2/journal-logs', (req, res) => { - const all = checkTruthy(req.body.all); - const follow = checkTruthy(req.body.follow); - const count = checkInt(req.body.count, { positive: true }) || undefined; - const unit = req.body.unit; - const format = req.body.format || 'short'; - const containerId = req.body.containerId; +router.post('/v2/journal-logs', (req, res) => { + const all = checkTruthy(req.body.all); + const follow = checkTruthy(req.body.follow); + const count = checkInt(req.body.count, { positive: true }) || undefined; + const unit = req.body.unit; + const format = req.body.format || 'short'; + const containerId = req.body.containerId; - const journald = spawnJournalctl({ - all, - follow, - count, - unit, - format, - containerId, - }); - res.status(200); - // We know stdout will be present - journald.stdout!.pipe(res); - res.on('close', () => { - journald.kill('SIGKILL'); - }); - journald.on('exit', () => { - journald.stdout!.unpipe(); - res.end(); - }); + const journald = spawnJournalctl({ + all, + follow, + count, + unit, + format, + containerId, }); -} + res.status(200); + // We know stdout will be present + journald.stdout!.pipe(res); + res.on('close', () => { + journald.kill('SIGKILL'); + }); + journald.on('exit', () => { + journald.stdout!.unpipe(); + res.end(); + }); +}); diff --git a/src/device-state.ts b/src/device-state.ts index 1ef3292b..2a094153 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -1,25 +1,20 @@ import * as Bluebird from 'bluebird'; import { stripIndent } from 'common-tags'; import { EventEmitter } from 'events'; -import * as express from 'express'; import * as _ from 'lodash'; import StrictEventEmitter from 'strict-event-emitter-types'; import { isRight } from 'fp-ts/lib/Either'; import Reporter from 'io-ts-reporters'; - import prettyMs = require('pretty-ms'); import * as config from './config'; import * as db from './db'; import * as logger from './logger'; -import { - CompositionStepT, - CompositionStepAction, -} from './compose/composition-steps'; -import { loadTargetFromFile } from './device-state/preload'; import * as globalEventBus from './event-bus'; -import * as hostConfig from './host-config'; +import * as network from './network'; +import * as deviceConfig from './device-config'; + import constants = require('./lib/constants'); import * as dbus from './lib/dbus'; import { @@ -28,14 +23,14 @@ import { UpdatesLockedError, } from './lib/errors'; import * as updateLock from './lib/update-lock'; -import * as validation from './lib/validation'; -import * as network from './network'; - +import * as dbFormat from './device-state/db-format'; +import { getGlobalApiKey } from './device-api'; +import * as sysInfo from './lib/system-info'; +import { log } from './lib/supervisor-console'; +import { loadTargetFromFile } from './device-state/preload'; import * as applicationManager from './compose/application-manager'; import * as commitStore from './compose/commit'; -import * as deviceConfig from './device-config'; -import { ConfigStep } from './device-config'; -import { log } from './lib/supervisor-console'; + import { DeviceLegacyState, InstancedDeviceState, @@ -44,11 +39,10 @@ import { DeviceReport, AppState, } from './types'; -import * as dbFormat from './device-state/db-format'; -import * as apiKeys from './lib/api-keys'; -import * as sysInfo from './lib/system-info'; - -const disallowedHostConfigPatchFields = ['local_ip', 'local_port']; +import type { + CompositionStepT, + CompositionStepAction, +} from './compose/composition-steps'; function parseTargetState(state: unknown): TargetState { const res = TargetState.decode(state); @@ -61,151 +55,6 @@ function parseTargetState(state: unknown): TargetState { throw new TargetStateError(errors.join('\n')); } -// TODO (refactor): This shouldn't be here, and instead should be part of the other -// device api stuff in ./device-api -function createDeviceStateRouter() { - router = express.Router(); - router.use(express.urlencoded({ limit: '10mb', extended: true })); - router.use(express.json({ limit: '10mb' })); - - const rebootOrShutdown = async ( - req: express.Request, - res: express.Response, - action: DeviceStateStepTarget, - ) => { - const override = await config.get('lockOverride'); - const force = validation.checkTruthy(req.body.force) || override; - try { - const response = await 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', - }); - } - }; - router.post('/v1/reboot', (req, res) => rebootOrShutdown(req, res, 'reboot')); - router.post('/v1/shutdown', (req, res) => - rebootOrShutdown(req, res, 'shutdown'), - ); - - 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.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"); - // If network does not exist, skip all field validation checks below - throw new Error(); - } - - const { proxy } = req.body.network; - - // Validate proxy fields, if they exist - if (proxy && Object.keys(proxy).length) { - const blacklistedFields = Object.keys(proxy).filter((key) => - disallowedHostConfigPatchFields.includes(key), - ); - - if (blacklistedFields.length > 0) { - log.warn(`Invalid proxy field(s): ${blacklistedFields.join(', ')}`); - } - - if ( - proxy.type && - !constants.validRedsocksProxyTypes.includes(proxy.type) - ) { - log.warn( - `Invalid redsocks proxy type, must be one of ${constants.validRedsocksProxyTypes.join( - ', ', - )}`, - ); - } - - if (proxy.noProxy && !Array.isArray(proxy.noProxy)) { - log.warn('noProxy field must be an array of addresses'); - } - } - } catch (e) { - /* noop */ - } - - 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); - } - const lockOverride = await config.get('lockOverride'); - await hostConfig.patch( - req.body, - validation.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'); - } - }); - - router.get('/v1/device', async (_req, res) => { - try { - const state = await getLegacyState(); - const stateToSend = _.pick(state.local, [ - 'api_port', - 'ip_address', - 'os_version', - 'mac_address', - 'supervisor_version', - 'update_pending', - 'update_failed', - 'update_downloaded', - ]) as Dictionary; - if (state.local?.is_on__commit != null) { - stateToSend.commit = state.local.is_on__commit; - } - const service = _.toPairs( - _.toPairs(state.local?.apps)[0]?.[1]?.services, - )[0]?.[1]; - - if (service != null) { - stateToSend.status = service.status; - if (stateToSend.status === 'Running') { - stateToSend.status = 'Idle'; - } - stateToSend.download_progress = service.download_progress; - } - res.json(stateToSend); - } catch (e: any) { - res.status(500).json({ - Data: '', - Error: (e != null ? e.message : undefined) || e || 'Unknown error', - }); - } - }); - - router.use(applicationManager.router); - return router; -} - interface DeviceStateEvents { error: Error; change: void; @@ -236,7 +85,7 @@ export const removeListener: typeof events['removeListener'] = export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind(events); -type DeviceStateStepTarget = 'reboot' | 'shutdown' | 'noop'; +export type DeviceStateStepTarget = 'reboot' | 'shutdown' | 'noop'; type PossibleStepTargets = CompositionStepAction | DeviceStateStepTarget; type DeviceStateStep = @@ -246,7 +95,7 @@ type DeviceStateStep = | { action: 'shutdown' } | { action: 'noop' } | CompositionStepT - | ConfigStep; + | deviceConfig.ConfigStep; let currentVolatile: DeviceReport = {}; const writeLock = updateLock.writeLock; @@ -266,8 +115,6 @@ let applyInProgress = false; export let connected: boolean; export let lastSuccessfulUpdate: number | null = null; -export let router: express.Router; - events.on('error', (err) => log.error('deviceState error: ', err)); events.on('apply-target-state-end', function (err) { if (err != null) { @@ -288,7 +135,6 @@ export const initialized = _.once(async () => { await applicationManager.initialized(); applicationManager.on('change', (d) => reportCurrentState(d)); - createDeviceStateRouter(); config.on('change', (changedConfig) => { if (changedConfig.loggingEnabled != null) { @@ -373,7 +219,6 @@ async function saveInitialConfig() { export async function loadInitialState() { await applicationManager.initialized(); - await apiKeys.initialized(); const conf = await config.getMany([ 'initialConfigSaved', @@ -399,9 +244,10 @@ export async function loadInitialState() { } log.info('Reporting initial state, supervisor version and API info'); + const globalApiKey = await getGlobalApiKey(); reportCurrentState({ api_port: conf.listenPort, - api_secret: apiKeys.cloudApiKey, + api_secret: globalApiKey, os_version: conf.osVersion, os_variant: conf.osVariant, mac_address: conf.macAddress, @@ -715,7 +561,7 @@ export async function executeStepAction( }: { force?: boolean; initial?: boolean; skipLock?: boolean }, ) { if (deviceConfig.isValidAction(step.action)) { - await deviceConfig.executeStepAction(step as ConfigStep, { + await deviceConfig.executeStepAction(step as deviceConfig.ConfigStep, { initial, }); } else if (_.includes(applicationManager.validActions, step.action)) { diff --git a/src/lib/dbus.ts b/src/lib/dbus.ts index aa1e887f..9fe1dee5 100644 --- a/src/lib/dbus.ts +++ b/src/lib/dbus.ts @@ -1,15 +1,27 @@ import { getBus, Error as DBusError } from 'dbus'; import { promisify } from 'util'; import { TypedError } from 'typed-error'; +import * as _ from 'lodash'; import log from './supervisor-console'; +import DBus = require('dbus'); export class DbusError extends TypedError {} -const bus = getBus('system'); -const getInterfaceAsync = promisify(bus.getInterface.bind(bus)); +let bus: DBus.DBusConnection; +let getInterfaceAsync: ( + serviceName: string, + objectPath: string, + ifaceName: string, +) => Promise>; + +export const initialized = _.once(async () => { + bus = getBus('system'); + getInterfaceAsync = promisify(bus.getInterface.bind(bus)); +}); async function getSystemdInterface() { + await initialized(); try { return await getInterfaceAsync( 'org.freedesktop.systemd1', @@ -21,7 +33,8 @@ async function getSystemdInterface() { } } -export async function getLoginManagerInterface() { +async function getLoginManagerInterface() { + await initialized(); try { return await getInterfaceAsync( 'org.freedesktop.login1', diff --git a/src/proxyvisor.js b/src/proxyvisor.js index e3ec168f..c57dcb32 100644 --- a/src/proxyvisor.js +++ b/src/proxyvisor.js @@ -87,10 +87,8 @@ const formatCurrentAsState = (device) => ({ config: device.config, }); -const createProxyvisorRouter = function (proxyvisor) { +const createProxyvisorRouter = function (pv) { const router = express.Router(); - router.use(express.urlencoded({ limit: '10mb', extended: true })); - router.use(express.json({ limit: '10mb' })); router.get('/v1/devices', async (_req, res) => { try { const fields = await db.models('dependentDevice').select(); @@ -315,7 +313,7 @@ const createProxyvisorRouter = function (proxyvisor) { await fs.lstat(dest); } catch { await Promise.using( - proxyvisor.docker.imageRootDirMounted(app.image), + pv.docker.imageRootDirMounted(app.image), (rootDir) => getTarArchive(rootDir + '/assets', dest), ); } @@ -346,7 +344,7 @@ const createProxyvisorRouter = function (proxyvisor) { return router; }; -export class Proxyvisor { +class Proxyvisor { constructor() { this.executeStepAction = this.executeStepAction.bind(this); this.getCurrentStates = this.getCurrentStates.bind(this); @@ -1003,3 +1001,6 @@ export class Proxyvisor { ); } } + +const proxyvisor = new Proxyvisor(); +export default proxyvisor; diff --git a/src/supervisor-api.ts b/src/supervisor-api.ts deleted file mode 100644 index ea0b549a..00000000 --- a/src/supervisor-api.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import * as express from 'express'; -import { Server } from 'http'; -import * as _ from 'lodash'; -import * as morgan from 'morgan'; - -import * as eventTracker from './event-tracker'; -import blink = require('./lib/blink'); - -import log from './lib/supervisor-console'; -import * as apiKeys from './lib/api-keys'; -import * as deviceState from './device-state'; -import { UpdatesLockedError } from './lib/errors'; - -interface SupervisorAPIConstructOpts { - routers: express.Router[]; - healthchecks: Array<() => Promise>; -} - -interface SupervisorAPIStopOpts { - errored: boolean; -} - -export class SupervisorAPI { - private routers: express.Router[]; - private healthchecks: Array<() => Promise>; - - private api = express(); - private server: Server | null = null; - - public constructor({ routers, healthchecks }: SupervisorAPIConstructOpts) { - this.routers = routers; - this.healthchecks = healthchecks; - - this.api.disable('x-powered-by'); - this.api.use( - morgan( - (tokens, req, res) => - [ - tokens.method(req, res), - req.path, - tokens.status(req, res), - '-', - tokens['response-time'](req, res), - 'ms', - ].join(' '), - { - stream: { write: (d) => log.api(d.toString().trimRight()) }, - }, - ), - ); - - 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'); - } - return res.sendStatus(200); - } catch { - log.error('Healthcheck failed'); - return res.status(500).send('Unhealthy'); - } - }); - - this.api.get('/ping', (_req, res) => res.send('OK')); - - this.api.use(apiKeys.authMiddleware); - - this.api.post('/v1/blink', (_req, res) => { - eventTracker.track('Device blink'); - blink.pattern.start(); - setTimeout(blink.pattern.stop, 15000); - 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 deviceState.initialized(); - await apiKeys.initialized(); - - // check if we're updating the cloud API key - const updateCloudKey = req.auth.apiKey === apiKeys.cloudApiKey; - - // regenerate the key... - const newKey = await apiKeys.refreshKey(req.auth.apiKey); - - // if we need to update the cloud API with our new key - if (updateCloudKey) { - // report the new key to the cloud API - deviceState.reportCurrentState({ - api_secret: apiKeys.cloudApiKey, - }); - } - - // return the value of the new key to the caller - res.status(200).send(newKey); - }, - ); - - // And assign all external routers - for (const router of this.routers) { - this.api.use(router); - } - - // Error handling. - const messageFromError = (err?: Error | string | null): string => { - let message = 'Unknown error'; - if (err != null) { - if (_.isError(err) && err.message != null) { - message = err.message; - } else { - message = err as string; - } - } - return message; - }; - - this.api.use( - (err: Error, req: Request, res: Response, next: NextFunction) => { - if (res.headersSent) { - // Error happens while we are writing the response - default handler closes the connection. - next(err); - return; - } - - // Return 423 Locked when locks as set - const code = err instanceof UpdatesLockedError ? 423 : 503; - if (code !== 423) { - log.error(`Error on ${req.method} ${req.path}: `, err); - } - - res.status(code).send({ - status: 'failed', - message: messageFromError(err), - }); - }, - ); - } - - public async listen(port: number, apiTimeout: number): Promise { - return new Promise((resolve) => { - this.server = this.api.listen(port, () => { - log.info(`Supervisor API successfully started on port ${port}`); - if (this.server) { - this.server.timeout = apiTimeout; - } - return resolve(); - }); - }); - } - - public async stop(options?: SupervisorAPIStopOpts): Promise { - if (this.server != null) { - return new Promise((resolve, reject) => { - this.server?.close((err: Error) => { - if (err) { - log.error('Failed to stop Supervisor API'); - return reject(err); - } - options?.errored - ? log.error('Stopped Supervisor API') - : log.info('Stopped Supervisor API'); - return resolve(); - }); - }); - } - } -} - -export default SupervisorAPI; diff --git a/src/supervisor.ts b/src/supervisor.ts index b98020ef..3d741d41 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -2,16 +2,19 @@ import * as apiBinder from './api-binder'; import * as db from './db'; import * as config from './config'; import * as deviceState from './device-state'; +import * as logger from './logger'; +import SupervisorAPI from './device-api'; +import * as v1 from './device-api/v1'; +import * as v2 from './device-api/v2'; +import logMonitor from './logging/monitor'; + import { intialiseContractRequirements } from './lib/contracts'; import { normaliseLegacyDatabase } from './lib/legacy'; import * as osRelease from './lib/os-release'; -import * as logger from './logger'; -import SupervisorAPI from './supervisor-api'; import log from './lib/supervisor-console'; import version = require('./lib/supervisor-version'); import * as avahi from './lib/avahi'; import * as firewall from './lib/firewall'; -import logMonitor from './logging/monitor'; const startupConfigFields: config.ConfigKey[] = [ 'uuid', @@ -59,14 +62,13 @@ export class Supervisor { await normaliseLegacyDatabase(); } - // Start the state engine, the device API and API binder - // in parallel + // Start the state engine, the device API and API binder in parallel await Promise.all([ deviceState.loadInitialState(), (() => { log.info('Starting API server'); this.api = new SupervisorAPI({ - routers: [apiBinder.router, deviceState.router], + routers: [v1.router, v2.router], healthchecks: [apiBinder.healthcheck, deviceState.healthcheck], }); this.api.listen(conf.listenPort, conf.apiTimeout); diff --git a/test/integration/compose/service.spec.ts b/test/integration/compose/service.spec.ts index eb5ee1be..4519ac04 100644 --- a/test/integration/compose/service.spec.ts +++ b/test/integration/compose/service.spec.ts @@ -3,7 +3,7 @@ import * as _ from 'lodash'; import { expect } from 'chai'; import Service from '~/src/compose/service'; -import * as apiKeys from '~/lib/api-keys'; +import * as deviceApi from '~/src/device-api'; describe('compose/service: integration tests', () => { describe('Feature labels', () => { @@ -43,7 +43,7 @@ describe('compose/service: integration tests', () => { }); it('sets BALENA_API_KEY env var to the scoped API key value', async () => { - const mykey = await apiKeys.generateScopedKey(123456, 'foobar'); + const mykey = await deviceApi.generateScopedKey(123456, 'foobar'); const service = await Service.fromComposeObject( { diff --git a/test/legacy/.mocharc.js b/test/legacy/.mocharc.js index 0d987580..6a4ee1c4 100644 --- a/test/legacy/.mocharc.js +++ b/test/legacy/.mocharc.js @@ -8,6 +8,7 @@ module.exports = { 'tsconfig-paths/register', 'test/lib/chai.ts', 'test/legacy/fixtures.ts', + 'test/lib/legacy-mocha-hooks.ts' ], spec: ['test/legacy/**/*.spec.ts'], timeout: '30000', diff --git a/test/legacy/21-supervisor-api.spec.ts b/test/legacy/21-supervisor-api.spec.ts index 340af82a..7b58a124 100644 --- a/test/legacy/21-supervisor-api.spec.ts +++ b/test/legacy/21-supervisor-api.spec.ts @@ -6,10 +6,9 @@ import mockedAPI = require('~/test-lib/mocked-device-api'); import * as apiBinder from '~/src/api-binder'; import * as deviceState from '~/src/device-state'; import Log from '~/lib/supervisor-console'; -import SupervisorAPI from '~/src/supervisor-api'; -import * as apiKeys from '~/lib/api-keys'; import * as db from '~/src/db'; -import { cloudApiKey } from '~/lib/api-keys'; +import SupervisorAPI from '~/src/device-api'; +import * as deviceApi from '~/src/device-api'; const mockedOptions = { listenPort: 54321, @@ -30,10 +29,6 @@ describe('SupervisorAPI', () => { // Start test API await api.listen(mockedOptions.listenPort, mockedOptions.timeout); - - // Create a scoped key - await apiKeys.initialized(); - await apiKeys.generateCloudKey(); }); after(async () => { @@ -56,7 +51,7 @@ describe('SupervisorAPI', () => { await request .get('/ping') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200); }); }); @@ -64,7 +59,7 @@ describe('SupervisorAPI', () => { describe('API Key Scope', () => { it('should generate a key which is scoped for a single application', async () => { // single app scoped key... - const appScopedKey = await apiKeys.generateScopedKey(1, 'main'); + const appScopedKey = await deviceApi.generateScopedKey(1, 'main'); await request .get('/v2/applications/1/state') @@ -74,7 +69,7 @@ describe('SupervisorAPI', () => { }); it('should generate a key which is scoped for multiple applications', async () => { // multi-app scoped key... - const multiAppScopedKey = await apiKeys.generateScopedKey(1, 'other', { + const multiAppScopedKey = await deviceApi.generateScopedKey(1, 'other', { scopes: [1, 2].map((appId) => { return { type: 'app', appId }; }), @@ -96,17 +91,19 @@ describe('SupervisorAPI', () => { await request .get('/v2/applications/1/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200); await request .get('/v2/applications/2/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200); }); it('should have a cached lookup of the key scopes to save DB loading', async () => { - const scopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey); + const scopes = await deviceApi.getScopesForKey( + await deviceApi.getGlobalApiKey(), + ); const key = 'not-a-normal-key'; await db.initialized(); @@ -116,26 +113,30 @@ describe('SupervisorAPI', () => { key, }) .where({ - key: apiKeys.cloudApiKey, + key: await deviceApi.getGlobalApiKey(), }); // the key we had is now gone, but the cache should return values - const cachedScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey); + const cachedScopes = await deviceApi.getScopesForKey( + await deviceApi.getGlobalApiKey(), + ); expect(cachedScopes).to.deep.equal(scopes); // this should bust the cache... - await apiKeys.generateCloudKey(true); + await deviceApi.refreshKey(await deviceApi.getGlobalApiKey()); // the key we changed should be gone now, and the new key should have the cloud scopes - const missingScopes = await apiKeys.getScopesForKey(key); - const freshScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey); + const missingScopes = await deviceApi.getScopesForKey(key); + const freshScopes = await deviceApi.getScopesForKey( + await deviceApi.getGlobalApiKey(), + ); expect(missingScopes).to.be.null; expect(freshScopes).to.deep.equal(scopes); }); it('should regenerate a key and invalidate the old one', async () => { // single app scoped key... - const appScopedKey = await apiKeys.generateScopedKey(1, 'main'); + const appScopedKey = await deviceApi.generateScopedKey(1, 'main'); await request .get('/v2/applications/1/state') @@ -143,7 +144,7 @@ describe('SupervisorAPI', () => { .set('Authorization', `Bearer ${appScopedKey}`) .expect(200); - const newScopedKey = await apiKeys.refreshKey(appScopedKey); + const newScopedKey = await deviceApi.refreshKey(appScopedKey); await request .get('/v2/applications/1/state') @@ -225,20 +226,22 @@ describe('SupervisorAPI', () => { }); it('finds apiKey from query', async () => { - return request.post(`/v1/blink?apikey=${cloudApiKey}`).expect(200); + return request + .post(`/v1/blink?apikey=${await deviceApi.getGlobalApiKey()}`) + .expect(200); }); it('finds apiKey from Authorization header (ApiKey scheme)', async () => { return request .post('/v1/blink') - .set('Authorization', `ApiKey ${cloudApiKey}`) + .set('Authorization', `ApiKey ${await deviceApi.getGlobalApiKey()}`) .expect(200); }); it('finds apiKey from Authorization header (Bearer scheme)', async () => { return request .post('/v1/blink') - .set('Authorization', `Bearer ${cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200); }); @@ -256,7 +259,10 @@ describe('SupervisorAPI', () => { for (const scheme of randomCases) { return request .post('/v1/blink') - .set('Authorization', `${scheme} ${cloudApiKey}`) + .set( + 'Authorization', + `${scheme} ${await deviceApi.getGlobalApiKey()}`, + ) .expect(200); } }); diff --git a/test/legacy/41-device-api-v1.spec.ts b/test/legacy/41-device-api-v1.spec.ts index 4e789389..dd9aa6e3 100644 --- a/test/legacy/41-device-api-v1.spec.ts +++ b/test/legacy/41-device-api-v1.spec.ts @@ -20,17 +20,18 @@ import mockedAPI = require('~/test-lib/mocked-device-api'); import sampleResponses = require('~/test-data/device-api-responses.json'); import * as config from '~/src/config'; import * as logger from '~/src/logger'; -import SupervisorAPI from '~/src/supervisor-api'; +import SupervisorAPI from '~/src/device-api'; +import * as deviceApi from '~/src/device-api'; import * as apiBinder from '~/src/api-binder'; import * as deviceState from '~/src/device-state'; -import * as apiKeys from '~/lib/api-keys'; +import * as apiKeys from '~/src/device-api/api-keys'; import * as dbus from '~/lib/dbus'; import * as updateLock from '~/lib/update-lock'; import * as TargetState from '~/src/device-state/target-state'; import * as targetStateCache from '~/src/device-state/target-state-cache'; import blink = require('~/lib/blink'); import constants = require('~/lib/constants'); -import * as deviceAPI from '~/src/device-api/common'; +import * as deviceAPIActions from '~/src/device-api/common'; import { UpdatesLockedError } from '~/lib/errors'; import { SchemaTypeKey } from '~/src/config/schema-type'; import log from '~/lib/supervisor-console'; @@ -106,10 +107,6 @@ describe('SupervisorAPI [V1 Endpoints]', () => { // Mock target state cache targetStateCacheMock = stub(targetStateCache, 'getTargetApp'); - // Create a scoped key - await apiKeys.initialized(); - await apiKeys.generateCloudKey(); - // Stub logs for all API methods loggerStub = stub(logger, 'attach'); loggerStub.resolves(); @@ -141,7 +138,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .post('/v1/restart') .send({ appId: 2 }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/restart'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -170,7 +167,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .post('/v1/restart') .send({ thing: '' }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/restart [Invalid Body]'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -190,7 +187,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/healthy') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.GET['/healthy'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -223,7 +220,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/apps/2') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect( sampleResponses.V1.GET['/apps/2 [Multiple containers running]'] .statusCode, @@ -249,7 +246,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/apps/2') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.GET['/apps/2'].statusCode) .expect('Content-Type', /json/) .then((response) => { @@ -271,7 +268,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/apps/2/stop') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect( sampleResponses.V1.GET['/apps/2/stop [Multiple containers running]'] .statusCode, @@ -297,7 +294,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/apps/2/stop') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode) .expect('Content-Type', /json/) .then((response) => { @@ -319,7 +316,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/apps/2/start') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(400); }); }); @@ -356,7 +353,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/apps/2/start') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200) .expect('Content-Type', /json/) .then((response) => { @@ -372,7 +369,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { const response = await request .get('/v1/device') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200); expect(response.body).to.have.property('mac_address').that.is.not.empty; @@ -412,7 +409,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { const response = await request .post('/v1/reboot') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(202); expect(response.body).to.have.property('Data').that.is.not.empty; @@ -447,7 +444,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { const response = await request .post('/v1/reboot') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(423); expect(updateLock.lock).to.be.calledOnce; @@ -486,7 +483,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .post('/v1/reboot') .send({ force: true }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(202); expect(updateLock.lock).to.be.calledOnce; @@ -528,7 +525,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { const response = await request .post('/v1/shutdown') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(202); expect(response.body).to.have.property('Data').that.is.not.empty; @@ -571,7 +568,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { const response = await request .post('/v1/shutdown') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(202); expect(lockSpy.callCount).to.equal(1); @@ -612,7 +609,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { const response = await request .post('/v1/shutdown') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(423); expect(updateLock.lock).to.be.calledOnce; @@ -651,7 +648,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .post('/v1/shutdown') .send({ force: true }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(202); expect(updateLock.lock).to.be.calledOnce; @@ -667,10 +664,12 @@ describe('SupervisorAPI [V1 Endpoints]', () => { describe('POST /v1/update', () => { let configStub: SinonStub; let targetUpdateSpy: SinonSpy; + let readyForUpdatesStub: SinonStub; before(() => { configStub = stub(config, 'get'); targetUpdateSpy = spy(TargetState, 'update'); + readyForUpdatesStub = stub(apiBinder, 'isReadyForUpdates').returns(true); }); afterEach(() => { @@ -680,6 +679,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { after(() => { configStub.restore(); targetUpdateSpy.restore(); + readyForUpdatesStub.restore(); }); it('returns 204 with no parameters', async () => { @@ -689,7 +689,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/update') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/update [204 Response]'].statusCode); // Check that TargetState.update was called expect(targetUpdateSpy).to.be.called; @@ -704,7 +704,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .post('/v1/update') .send({ force: true }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/update [204 Response]'].statusCode); // Check that TargetState.update was called expect(targetUpdateSpy).to.be.called; @@ -718,7 +718,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/update') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/update [202 Response]'].statusCode); // Check that TargetState.update was not called expect(targetUpdateSpy).to.not.be.called; @@ -731,7 +731,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/blink') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/blink'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -751,7 +751,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/blink') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .then(() => { expect(blinkStartSpy.callCount).to.equal(1); clock.tick(15000); @@ -773,13 +773,16 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/regenerate-api-key') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/regenerate-api-key'].statusCode) - .then((response) => { + .then((response) => + Promise.all([response, deviceApi.getGlobalApiKey()]), + ) + .then(([response, globalApiKey]) => { expect(response.body).to.deep.equal( sampleResponses.V1.POST['/regenerate-api-key'].body, ); - expect(response.text).to.equal(apiKeys.cloudApiKey); + expect(response.text).to.equal(globalApiKey); newKey = response.text; expect(refreshKeySpy.callCount).to.equal(1); }); @@ -795,7 +798,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { }); it('expires old API key after generating new key', async () => { - const oldKey: string = apiKeys.cloudApiKey; + const oldKey: string = await deviceApi.getGlobalApiKey(); await request .post('/v1/regenerate-api-key') @@ -816,7 +819,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .post('/v1/regenerate-api-key') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .then(() => { expect(reportStateSpy.callCount).to.equal(1); // Further reportCurrentState tests should be in 05-device-state.spec.ts, @@ -876,7 +879,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(hostnameProxyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal(hostnameProxyRes.body); @@ -889,7 +892,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(hostnameOnlyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal(hostnameOnlyRes.body); @@ -902,7 +905,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(503); }); }); @@ -977,7 +980,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { hostname: 'foobaz' } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(423); expect(updateLock.lock).to.be.calledOnce; @@ -986,7 +989,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .then((response) => { expect(response.body.network.hostname).to.deep.equal( 'foobardevice', @@ -1006,7 +1009,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { hostname: 'foobaz' }, force: true }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200); expect(updateLock.lock).to.be.calledOnce; @@ -1015,7 +1018,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .then((response) => { expect(response.body.network.hostname).to.deep.equal('foobaz'); }); @@ -1039,7 +1042,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send(patchBody) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) .then((response) => { validatePatchResponse(response); @@ -1055,7 +1058,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .then((response) => { expect(response.body).to.deep.equal(patchBody); }); @@ -1077,7 +1080,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send(patchBody) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) .then((response) => { validatePatchResponse(response); @@ -1089,7 +1092,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .then((response) => { expect(response.body).to.deep.equal(patchBody); }); @@ -1103,7 +1106,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { hostname: '' } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) .then((response) => { validatePatchResponse(response); @@ -1119,7 +1122,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .then(async (response) => { const uuidHostname = await config .get('uuid') @@ -1136,7 +1139,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { proxy: {} } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) .then(async (response) => { validatePatchResponse(response); @@ -1156,7 +1159,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(hostnameOnlyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal(hostnameOnlyRes.body); @@ -1183,7 +1186,10 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { proxy: { [key]: value } } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set( + 'Authorization', + `Bearer ${await deviceApi.getGlobalApiKey()}`, + ) .expect( sampleResponses.V1.PATCH['/host/device-config'].statusCode, ) @@ -1199,7 +1205,10 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set( + 'Authorization', + `Bearer ${await deviceApi.getGlobalApiKey()}`, + ) .expect(hostnameProxyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal({ @@ -1233,7 +1242,10 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { proxy: { [key]: value } } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set( + 'Authorization', + `Bearer ${await deviceApi.getGlobalApiKey()}`, + ) .expect( sampleResponses.V1.PATCH['/host/device-config'].statusCode, ) @@ -1247,7 +1259,10 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set( + 'Authorization', + `Bearer ${await deviceApi.getGlobalApiKey()}`, + ) .expect(hostnameProxyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal({ @@ -1281,7 +1296,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { proxy: { [key]: invalidProxyReqs[key] } } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200) .then(() => { if (key === 'type') { @@ -1308,7 +1323,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { proxy: { noProxy: ['1.2.3.4/5'] } } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) .then((response) => { validatePatchResponse(response); @@ -1325,7 +1340,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(hostnameProxyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal({ @@ -1346,7 +1361,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: { proxy: { noProxy: [] } } }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) .then((response) => { validatePatchResponse(response); @@ -1363,7 +1378,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(hostnameProxyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal({ @@ -1387,7 +1402,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({ network: {} }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode) .then((response) => { validatePatchResponse(response); @@ -1399,7 +1414,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await request .get('/v1/device/host-config') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(hostnameProxyRes.statusCode) .then((response) => { expect(response.body).to.deep.equal(hostnameProxyRes.body); @@ -1411,7 +1426,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .patch('/v1/device/host-config') .send({}) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(200) .then(() => { expect(logWarnStub).to.have.been.calledWith( @@ -1430,7 +1445,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { .post('/v1/purge') .send({}) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect( sampleResponses.V1.POST['/purge [400 Invalid/missing appId]'] .statusCode, @@ -1443,14 +1458,17 @@ describe('SupervisorAPI [V1 Endpoints]', () => { }); it('purges the /data directory with valid appId', async () => { - const doPurgeStub: SinonStub = stub(deviceAPI, 'doPurge').resolves(); + const doPurgeStub: SinonStub = stub( + deviceAPIActions, + 'doPurge', + ).resolves(); await mockedDockerode.testWithData({ containers, images }, async () => { await request .post('/v1/purge') .send({ appId: 2 }) .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V1.POST['/purge [200]'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -1467,7 +1485,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { // Generate a new scoped key to call the endpoint, as mocked // appId = 2 services are all in the global scope and thus // resolve to true for any isScoped check - const scopedKey = await apiKeys.generateScopedKey( + const scopedKey = await deviceApi.generateScopedKey( 2, containers[0].serviceName, ); diff --git a/test/legacy/42-device-api-v2.spec.ts b/test/legacy/42-device-api-v2.spec.ts index df8fd318..0cf29999 100644 --- a/test/legacy/42-device-api-v2.spec.ts +++ b/test/legacy/42-device-api-v2.spec.ts @@ -7,10 +7,10 @@ import sampleResponses = require('~/test-data/device-api-responses.json'); import mockedAPI = require('~/test-lib/mocked-device-api'); import * as apiBinder from '~/src/api-binder'; import * as deviceState from '~/src/device-state'; -import SupervisorAPI from '~/src/supervisor-api'; +import SupervisorAPI from '~/src/device-api'; +import * as deviceApi from '~/src/device-api'; import * as serviceManager from '~/src/compose/service-manager'; import * as images from '~/src/compose/images'; -import * as apiKeys from '~/lib/api-keys'; import * as config from '~/src/config'; import * as updateLock from '~/lib/update-lock'; import * as targetStateCache from '~/src/device-state/target-state-cache'; @@ -45,9 +45,6 @@ describe('SupervisorAPI [V2 Endpoints]', () => { mockedAPI.mockedOptions.timeout, ); - // Create a scoped key - await apiKeys.initialized(); - await apiKeys.generateCloudKey(); serviceManagerMock = stub(serviceManager, 'getAll').resolves([]); imagesMock = stub(images, 'getState').resolves([]); @@ -85,7 +82,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { await request .get('/v2/device/vpn') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect('Content-Type', /json/) .expect(sampleResponses.V2.GET['/device/vpn'].statusCode) .then((response) => { @@ -101,7 +98,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { await request .get('/v2/applications/1/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V2.GET['/applications/1/state'].statusCode) .expect('Content-Type', /json/) .then((response) => { @@ -115,7 +112,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { await request .get('/v2/applications/123invalid/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect('Content-Type', /json/) .expect( sampleResponses.V2.GET['/applications/123invalid/state'].statusCode, @@ -131,7 +128,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { await request .get('/v2/applications/9000/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) .expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -142,7 +139,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { describe('Scoped API Keys', () => { it('returns 409 because app is out of scope of the key', async () => { - const apiKey = await apiKeys.generateScopedKey(3, 'main'); + const apiKey = await deviceApi.generateScopedKey(3, 'main'); await request .get('/v2/applications/2/state') .set('Accept', 'application/json') @@ -164,7 +161,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { it('should return scoped application', async () => { // Create scoped key for application - const appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); + const appScopedKey = await deviceApi.generateScopedKey(1658654, 'main'); // Setup device conditions serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]); imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]); @@ -188,7 +185,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { it('should return no application info due to lack of scope', async () => { // Create scoped key for wrong application - const appScopedKey = await apiKeys.generateScopedKey(1, 'main'); + const appScopedKey = await deviceApi.generateScopedKey(1, 'main'); // Setup device conditions serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]); imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]); @@ -211,7 +208,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { it('should return success when device has no applications', async () => { // Create scoped key for any application - const appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); + const appScopedKey = await deviceApi.generateScopedKey(1658654, 'main'); // Setup device conditions serviceManagerMock.resolves([]); imagesMock.resolves([]); @@ -234,7 +231,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { it('should only return 1 application when N > 1 applications on device', async () => { // Create scoped key for application - const appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); + const appScopedKey = await deviceApi.generateScopedKey(1658654, 'main'); // Setup device conditions serviceManagerMock.resolves([ mockedAPI.mockService({ appId: 1658654 }), @@ -330,7 +327,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { before(async () => { // Create scoped key for application - appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); + appScopedKey = await deviceApi.generateScopedKey(1658654, 'main'); // Mock target state cache targetStateCacheMock = stub(targetStateCache, 'getTargetApp'); @@ -439,7 +436,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { before(async () => { // Create scoped key for application - appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); + appScopedKey = await deviceApi.generateScopedKey(1658654, 'main'); // Mock target state cache targetStateCacheMock = stub(targetStateCache, 'getTargetApp'); diff --git a/test/legacy/fixtures.ts b/test/legacy/fixtures.ts index e1389438..09bbbed0 100644 --- a/test/legacy/fixtures.ts +++ b/test/legacy/fixtures.ts @@ -31,6 +31,5 @@ fs.writeFileSync( fs.readFileSync('./test/data/testconfig.json'), ); -import '~/test-lib/mocked-dbus'; import '~/test-lib/mocked-dockerode'; import '~/test-lib/mocked-iptables'; diff --git a/test/lib/legacy-mocha-hooks.ts b/test/lib/legacy-mocha-hooks.ts new file mode 100644 index 00000000..21fbda83 --- /dev/null +++ b/test/lib/legacy-mocha-hooks.ts @@ -0,0 +1,70 @@ +// TODO: Remove this file when all legacy tests have migrated to unit/integration. + +import { stub, SinonStub } from 'sinon'; +import * as dbus from 'dbus'; +import { Error as DBusError, DBusInterface } from 'dbus'; +import { initialized } from '~/src/lib/dbus'; + +let getBusStub: SinonStub; + +export const mochaHooks = { + async beforeAll() { + getBusStub = stub(dbus, 'getBus').returns({ + getInterface: ( + serviceName: string, + _objectPath: string, + _interfaceName: string, + interfaceCb: (err: null | DBusError, iface: DBusInterface) => void, + ) => { + if (/systemd/.test(serviceName)) { + interfaceCb(null, { + StartUnit: () => { + // noop + }, + RestartUnit: () => { + // noop + }, + StopUnit: () => { + // noop + }, + EnableUnitFiles: () => { + // noop + }, + DisableUnitFiles: () => { + // noop + }, + GetUnit: ( + _unitName: string, + getUnitCb: (err: null | Error, unitPath: string) => void, + ) => { + getUnitCb(null, 'this is the unit path'); + }, + Get: ( + _unitName: string, + _property: string, + getCb: (err: null | Error, value: unknown) => void, + ) => { + getCb(null, 'this is the value'); + }, + } as any); + } else { + interfaceCb(null, { + Reboot: () => { + // noop + }, + PowerOff: () => { + // noop + }, + } as any); + } + }, + } as dbus.DBusConnection); + + // Initialize dbus module before any tests are run so any further tests + // that interface with lib/dbus use the stubbed busses above. + await initialized(); + }, + afterAll() { + getBusStub.restore(); + }, +}; diff --git a/test/lib/mocked-balena-api.ts b/test/lib/mocked-balena-api.ts index c8286e26..b92ee95d 100644 --- a/test/lib/mocked-balena-api.ts +++ b/test/lib/mocked-balena-api.ts @@ -12,7 +12,7 @@ const api: express.Express & { } = express(); // tslint:disable-next-line -api.use(require('body-parser').json()); +api.use(express.json()); api.balenaBackend = { currentId: 1, diff --git a/test/lib/mocked-dbus.ts b/test/lib/mocked-dbus.ts deleted file mode 100644 index ae9496f8..00000000 --- a/test/lib/mocked-dbus.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as dbus from 'dbus'; -import { Error as DBusError, DBusInterface } from 'dbus'; -import { stub } from 'sinon'; - -/** - * Because lib/dbus invokes dbus.getBus on module import, - * getBus needs to be stubbed at the root level due how JS - * `require` works. lib/dbus interfaces with the systemd and - * logind interfaces, which expose the unit methods below. - * - * There should be no need to un-stub dbus.getBus at any point - * during testing, since we never want to interact with the actual - * dbus system socket in the test environment. - * - * To test interaction with lib/dbus, import lib/dbus into the test suite - * and stub the necessary methods, as you would with any other module. - */ -stub(dbus, 'getBus').returns({ - getInterface: ( - serviceName: string, - _objectPath: string, - _interfaceName: string, - interfaceCb: (err: null | DBusError, iface: DBusInterface) => void, - ) => { - if (/systemd/.test(serviceName)) { - interfaceCb(null, { - StartUnit: () => { - // noop - }, - RestartUnit: () => { - // noop - }, - StopUnit: () => { - // noop - }, - EnableUnitFiles: () => { - // noop - }, - DisableUnitFiles: () => { - // noop - }, - GetUnit: ( - _unitName: string, - getUnitCb: (err: null | Error, unitPath: string) => void, - ) => { - getUnitCb(null, 'this is the unit path'); - }, - Get: ( - _unitName: string, - _property: string, - getCb: (err: null | Error, value: unknown) => void, - ) => { - getCb(null, 'this is the value'); - }, - } as any); - } else { - interfaceCb(null, { - Reboot: () => { - // noop - }, - PowerOff: () => { - // noop - }, - } as any); - } - }, -} as dbus.DBusConnection); diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index 4b5dba08..47fdf7d3 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -1,18 +1,16 @@ import * as _ from 'lodash'; -import { Router } from 'express'; import rewire = require('rewire'); import { unlinkAll } from '~/lib/fs-utils'; -import * as applicationManager from '~/src/compose/application-manager'; import * as serviceManager from '~/src/compose/service-manager'; import * as volumeManager from '~/src/compose/volume-manager'; import * as commitStore from '~/src/compose/commit'; import * as config from '~/src/config'; import * as db from '~/src/db'; -import { createV1Api } from '~/src/device-api/v1'; -import { createV2Api } from '~/src/device-api/v2'; +import * as v1 from '~/src/device-api/v1'; +import * as v2 from '~/src/device-api/v2'; import * as deviceState from '~/src/device-state'; -import SupervisorAPI from '~/src/supervisor-api'; +import SupervisorAPI from '~/src/device-api'; import { Service } from '~/src/compose/service'; import { Image } from '~/src/compose/images'; @@ -135,7 +133,7 @@ async function create( // Create SupervisorAPI const api = new SupervisorAPI({ - routers: [deviceState.router, buildRoutes()], + routers: [v1.router, v2.router], healthchecks, }); @@ -173,21 +171,10 @@ async function initConfig(): Promise { } } -function buildRoutes(): Router { - // Add to existing apiBinder router (it contains additional middleware and endpoints) - const router = apiBinder.router; - // Add V1 routes - createV1Api(applicationManager.router); - // Add V2 routes - createV2Api(applicationManager.router); - // Return modified Router - return router; -} - // TO-DO: Create a cleaner way to restore previous values. const originalVolGetAll = volumeManager.getAllByAppId; const originalSvcGetStatus = serviceManager.getState; -const originalReadyForUpdates = apiBinder.__get__('readyForUpdates'); +const originalReadyForUpdates = apiBinder.isReadyForUpdates(); function setupStubs() { apiBinder.__set__('readyForUpdates', true);