mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-05-31 14:50:47 +00:00
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:
commit
ccb04d42ce
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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[];
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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...
|
@ -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,
|
||||
);
|
||||
}
|
@ -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;
|
84
src/device-api/middleware/auth.ts
Normal file
84
src/device-api/middleware/auth.ts
Normal 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];
|
||||
}
|
42
src/device-api/middleware/errors.ts
Normal file
42
src/device-api/middleware/errors.ts
Normal 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),
|
||||
});
|
||||
};
|
3
src/device-api/middleware/index.ts
Normal file
3
src/device-api/middleware/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './logging';
|
||||
export * from './auth';
|
||||
export * from './errors';
|
19
src/device-api/middleware/logging.ts
Normal file
19
src/device-api/middleware/logging.ts
Normal 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()) },
|
||||
},
|
||||
);
|
@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
1007
src/device-api/v2.ts
1007
src/device-api/v2.ts
File diff suppressed because it is too large
Load Diff
@ -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)) {
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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);
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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');
|
||||
|
@ -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';
|
||||
|
70
test/lib/legacy-mocha-hooks.ts
Normal file
70
test/lib/legacy-mocha-hooks.ts
Normal 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();
|
||||
},
|
||||
};
|
@ -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,
|
||||
|
@ -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);
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user