diff --git a/src/device-api/index.ts b/src/device-api/index.ts index e69de29b..4aaa138d 100644 --- a/src/device-api/index.ts +++ b/src/device-api/index.ts @@ -0,0 +1,175 @@ +import * as express from 'express'; +import * as _ from 'lodash'; +import * as morgan from 'morgan'; + +import * as eventTracker from '../event-tracker'; +import * as deviceState from '../device-state'; +import blink = require('../lib/blink'); +import log from '../lib/supervisor-console'; +import * as apiKeys from '../lib/api-keys'; +import { UpdatesLockedError } from '../lib/errors'; + +import type { Server } from 'http'; +import type { NextFunction, Request, Response } from 'express'; + +const expressLogger = 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()) }, + }, +); + +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(expressLogger); + + 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-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..03d4c0ea 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -6,7 +6,7 @@ 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 SupervisorAPI from './device-api'; import log from './lib/supervisor-console'; import version = require('./lib/supervisor-version'); import * as avahi from './lib/avahi'; diff --git a/test/legacy/21-supervisor-api.spec.ts b/test/legacy/21-supervisor-api.spec.ts index 340af82a..37450db5 100644 --- a/test/legacy/21-supervisor-api.spec.ts +++ b/test/legacy/21-supervisor-api.spec.ts @@ -6,7 +6,7 @@ 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 SupervisorAPI from '~/src/device-api'; import * as apiKeys from '~/lib/api-keys'; import * as db from '~/src/db'; import { cloudApiKey } from '~/lib/api-keys'; diff --git a/test/legacy/41-device-api-v1.spec.ts b/test/legacy/41-device-api-v1.spec.ts index 4e789389..0bc1ce78 100644 --- a/test/legacy/41-device-api-v1.spec.ts +++ b/test/legacy/41-device-api-v1.spec.ts @@ -20,7 +20,7 @@ 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 apiBinder from '~/src/api-binder'; import * as deviceState from '~/src/device-state'; import * as apiKeys from '~/lib/api-keys'; diff --git a/test/legacy/42-device-api-v2.spec.ts b/test/legacy/42-device-api-v2.spec.ts index df8fd318..96905dde 100644 --- a/test/legacy/42-device-api-v2.spec.ts +++ b/test/legacy/42-device-api-v2.spec.ts @@ -7,7 +7,7 @@ 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 serviceManager from '~/src/compose/service-manager'; import * as images from '~/src/compose/images'; import * as apiKeys from '~/lib/api-keys'; diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index 4b5dba08..46e3b7c4 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -12,7 +12,7 @@ import * as db from '~/src/db'; import { createV1Api } from '~/src/device-api/v1'; import { createV2Api } 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';