Merge pull request #1842 from balena-os/api-code-reorganization

Move all Supervisor API related routes & code to device-api directory
This commit is contained in:
bulldozer-balena[bot] 2022-10-18 22:57:02 +00:00 committed by GitHub
commit ccb04d42ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1439 additions and 1411 deletions

View File

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

View File

@ -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[];

View File

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

View File

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

View File

@ -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<ScopeTypeKey>;
type Scope = SerializableScope<ScopeTypeKey>;
type ScopeTypeKey = keyof ScopeTypes;
type SerializableScope<T extends ScopeTypeKey> = {
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<ScopedResources>,
@ -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<string> => {
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<Scope[] | null> {
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<string> {
cloudApiKey = await generateKey(0, null, {
async function generateGlobalKey(force: boolean = false): Promise<string> {
globalApiKey = await generateKey(0, null, {
force,
scopes: [{ type: 'global' }],
});
return cloudApiKey;
return globalApiKey;
}
export async function refreshKey(key: string): Promise<string> {
@ -230,7 +145,7 @@ export async function refreshKey(key: string): Promise<string> {
// 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...

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from './logging';
export * from './auth';
export * from './errors';

View File

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

View File

@ -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<unknown>;
if (state.local?.is_on__commit != null) {
stateToSend.commit = state.local.is_on__commit;
}
const service = _.toPairs(
_.toPairs(state.local?.apps)[0]?.[1]?.services,
)[0]?.[1];
if (service != null) {
stateToSend.status = service.status;
if (stateToSend.status === 'Running') {
stateToSend.status = 'Idle';
}
stateToSend.download_progress = service.download_progress;
}
res.json(stateToSend);
} catch (e: any) {
res.status(500).json({
Data: '',
Error: (e != null ? e.message : undefined) || e || 'Unknown error',
});
}
});

File diff suppressed because it is too large Load Diff

View File

@ -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<unknown>;
if (state.local?.is_on__commit != null) {
stateToSend.commit = state.local.is_on__commit;
}
const service = _.toPairs(
_.toPairs(state.local?.apps)[0]?.[1]?.services,
)[0]?.[1];
if (service != null) {
stateToSend.status = service.status;
if (stateToSend.status === 'Running') {
stateToSend.status = 'Idle';
}
stateToSend.download_progress = service.download_progress;
}
res.json(stateToSend);
} catch (e: any) {
res.status(500).json({
Data: '',
Error: (e != null ? e.message : undefined) || e || 'Unknown error',
});
}
});
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<T extends PossibleStepTargets> =
@ -246,7 +95,7 @@ type DeviceStateStep<T extends PossibleStepTargets> =
| { action: 'shutdown' }
| { action: 'noop' }
| CompositionStepT<T extends CompositionStepAction ? T : never>
| 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<T extends PossibleStepTargets>(
}: { 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)) {

View File

@ -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: <T = DBus.AnyInterfaceMethod>(
serviceName: string,
objectPath: string,
ifaceName: string,
) => Promise<DBus.DBusInterface<T>>;
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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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