mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-31 19:17:03 +00:00
4cdf26f82f
This PR adds the following * Supervisor v1 API application actions now return HTTP status code 423 when locks are preventing the action to be performed. Previously this resulted in a 503 error * Supervisor API v2 service actions now returns HTTP status code 423 when locks are preventing the action to be performed. Previously, this resulted in an exception logged by the supervisor and the API query timing out * Supervisor API `/v2/applications/:appId/start-service` now does not check for a lock. Lock handling in v2 actions is now performed by each step executor * `/v1/apps/:appId/start` now queries the target state and uses that information to execute the start step (as v2 does). Previously start resulted in `cannot get appId from undefined` * Extra tests for API methods Change-type: patch Connects-to: #1523 Signed-off-by: Felipe Lalanne <felipe@balena.io>
176 lines
4.5 KiB
TypeScript
176 lines
4.5 KiB
TypeScript
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';
|
|
|
|
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<boolean>>;
|
|
}
|
|
|
|
interface SupervisorAPIStopOpts {
|
|
errored: boolean;
|
|
}
|
|
|
|
export class SupervisorAPI {
|
|
private routers: express.Router[];
|
|
private healthchecks: Array<() => Promise<boolean>>;
|
|
|
|
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 (_e) {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|