Update host-config, route, and action tests for host config endpoints

Change-type: minor
Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
Christina Ying Wang 2022-12-08 11:38:11 -08:00
parent 250684d651
commit e1bacda580
14 changed files with 617 additions and 1136 deletions

View File

@ -7,6 +7,7 @@ import * as eventTracker from '../event-tracker';
import * as deviceState from '../device-state';
import * as logger from '../logger';
import * as config from '../config';
import * as hostConfig from '../host-config';
import { App } from '../compose/app';
import * as applicationManager from '../compose/application-manager';
import * as serviceManager from '../compose/service-manager';
@ -494,3 +495,29 @@ export const getLegacyDeviceState = async () => {
return stateToSend;
};
/**
* Get host config from the host-config module; Returns proxy config and hostname.
* Used by:
* - GET /v1/device/host-config
*/
export const getHostConfig = async () => {
return await hostConfig.get();
};
/**
* Patch host configs such as proxy config and hostname
* Used by:
* - PATCH /v1/device/host-config
*/
export const patchHostConfig = async (
conf: Parameters<typeof hostConfig.patch>[0],
force: boolean,
) => {
// If hostname is an empty string, return first 7 digits of device uuid
if (conf.network?.hostname === '') {
const uuid = await config.get('uuid');
conf.network.hostname = uuid?.slice(0, 7);
}
await hostConfig.patch(conf, force);
};

View File

@ -5,18 +5,16 @@ import type { Response } from 'express';
import * as actions from './actions';
import { AuthorizedRequest } from './api-keys';
import * as eventTracker from '../event-tracker';
import * as config from '../config';
import * as deviceState from '../device-state';
import * as constants from '../lib/constants';
import { checkInt, checkTruthy } from '../lib/validation';
import log from '../lib/supervisor-console';
import {
UpdatesLockedError,
isNotFoundError,
isBadRequestError,
UpdatesLockedError,
} from '../lib/errors';
import * as hostConfig from '../host-config';
import { CompositionStepAction } from '../compose/composition-steps';
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
@ -160,20 +158,19 @@ router.post('/v1/update', async (req, res, next) => {
}
});
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.get('/v1/device/host-config', async (_req, res, next) => {
try {
const conf = await actions.getHostConfig();
return res.json(conf);
} catch (e: unknown) {
next(e);
}
});
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");
@ -213,25 +210,16 @@ router.patch('/v1/device/host-config', async (req, res) => {
}
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);
await actions.patchHostConfig(req.body, checkTruthy(req.body.force));
return res.status(200).send('OK');
} catch (e: unknown) {
// Normally the error middleware handles 423 / 503 errors, however this interface
// throws the errors in a different format (text) compared to the middleware (JSON).
// Therefore we need to keep this here to keep the interface consistent.
if (e instanceof UpdatesLockedError) {
return res.status(423).send(e?.message ?? e);
}
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');
return res.status(503).send((e as Error)?.message ?? e ?? 'Unknown error');
}
});

View File

@ -1,4 +1,3 @@
import * as Bluebird from 'bluebird';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import { promises as fs } from 'fs';
@ -158,13 +157,13 @@ async function setProxy(maybeConf: ProxyConfig | null): Promise<void> {
// restart balena-proxy-config if it is loaded and NOT PartOf redsocks-conf.target
if (
(
await Bluebird.any([
await Promise.any([
dbus.servicePartOf('balena-proxy-config'),
dbus.servicePartOf('resin-proxy-config'),
])
).includes('redsocks-conf.target') === false
) {
await Bluebird.any([
await Promise.any([
dbus.restartService('balena-proxy-config'),
dbus.restartService('resin-proxy-config'),
]);
@ -191,39 +190,38 @@ async function setHostname(val: string) {
// restart balena-hostname if it is loaded and NOT PartOf config-json.target
if (
(
await Bluebird.any([
await Promise.any([
dbus.servicePartOf('balena-hostname'),
dbus.servicePartOf('resin-hostname'),
])
).includes('config-json.target') === false
) {
await Bluebird.any([
await Promise.any([
dbus.restartService('balena-hostname'),
dbus.restartService('resin-hostname'),
]);
}
}
// Don't use async/await here to maintain the bluebird
// promises being returned
export function get(): Bluebird<HostConfig> {
return Bluebird.join(readProxy(), readHostname(), (proxy, hostname) => {
return {
network: {
proxy,
hostname,
},
};
});
export async function get(): Promise<HostConfig> {
return {
network: {
proxy: await readProxy(),
hostname: await readHostname(),
},
};
}
export async function patch(conf: HostConfig, force: boolean): Promise<void> {
export async function patch(
conf: HostConfig,
force: boolean = false,
): Promise<void> {
const apps = await applicationManager.getCurrentApps();
const appIds = Object.keys(apps).map((strId) => parseInt(strId, 10));
// It's possible for appIds to be an empty array, but patch shouldn't fail
// as it's not dependent on there being any running user applications.
return updateLock.lock(appIds, { force }, () => {
return updateLock.lock(appIds, { force }, async () => {
const promises: Array<Promise<void>> = [];
if (conf != null && conf.network != null) {
if (conf.network.proxy != null) {
@ -233,6 +231,6 @@ export async function patch(conf: HostConfig, force: boolean): Promise<void> {
promises.push(setHostname(conf.network.hostname));
}
}
return Bluebird.all(promises).return();
await Promise.all(promises);
});
}

View File

@ -1,123 +1,4 @@
{
"V1": {
"GET": {
"/healthy": {
"statusCode": 200,
"body": {},
"text": "OK"
},
"/healthy [2]": {
"statusCode": 500,
"body": {},
"text": "Unhealthy"
},
"/apps/2": {
"statusCode": 200,
"body": {
"appId": 2,
"containerId": "abc123",
"commit": "4e380136c2cf56cd64197d51a1ab263a",
"env": {},
"releaseId": 77777
}
},
"/apps/2 [Multiple containers running]": {
"statusCode": 400,
"body": {
"appId": 2,
"containerId": "abc123",
"commit": "4e380136c2cf56cd64197d51a1ab263a",
"env": {},
"releaseId": 77777
}
},
"/apps/2/stop": {
"statusCode": 200,
"body": {
"containerId": "abc123"
}
},
"/apps/2/stop [Multiple containers running]": {
"statusCode": 400,
"body": {
"containerId": "abc123"
}
},
"/device/host-config [Hostname only]": {
"statusCode": 200,
"body": { "network": { "hostname": "foobardevice" } }
},
"/device/host-config [Hostname and proxy]": {
"statusCode": 200,
"body": {
"network": {
"hostname": "foobardevice",
"proxy": {
"ip": "example.org",
"noProxy": ["152.10.30.4", "253.1.1.0/16"],
"port": 1080,
"type": "socks5",
"login": "foo",
"password": "bar"
}
}
}
}
},
"POST": {
"/restart": {
"statusCode": 200,
"body": {},
"text": "OK"
},
"/restart [Invalid Body]": {
"statusCode": 400,
"body": {},
"text": "Missing app id"
},
"/update [204 Response]": {
"statusCode": 204,
"body": {},
"text": "OK"
},
"/update [202 Response]": {
"statusCode": 202,
"body": {},
"text": "OK"
},
"/blink": {
"statusCode": 200,
"body": {},
"text": "OK"
},
"/regenerate-api-key": {
"statusCode": 200,
"body": {}
},
"/purge [200]": {
"statusCode": 200,
"body": { "Data": "OK", "Error": "" }
},
"/purge [400 Invalid/missing appId]": {
"statusCode": 400,
"text": "Invalid or missing appId"
},
"/purge [401 Out of scope]": {
"statusCode": 401,
"body": {
"status": "failed",
"message": "Application is not available"
}
}
},
"PATCH": {
"/host/device-config": {
"statusCode": 200,
"body": {},
"text": "OK"
}
}
},
"V2": {
"GET": {
"/device/vpn": {

View File

@ -1,2 +0,0 @@
152.10.30.4
253.1.1.0/16

View File

@ -1,17 +0,0 @@
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
ip = example.org;
port = 1080;
type = socks5;
login = "foo";
password = "bar";
}

View File

@ -1,166 +1,21 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import * as Docker from 'dockerode';
import App from '~/src/compose/app';
import * as applicationManager from '~/src/compose/application-manager';
import * as imageManager from '~/src/compose/images';
import * as serviceManager from '~/src/compose/service-manager';
import { Image } from '~/src/compose/images';
import Network from '~/src/compose/network';
import * as networkManager from '~/src/compose/network-manager';
import Service from '~/src/compose/service';
import { ServiceComposeConfig } from '~/src/compose/types/service';
import Volume from '~/src/compose/volume';
import { InstancedAppState } from '~/src/types/state';
import * as config from '~/src/config';
import { createDockerImage } from '~/test-lib/docker-helper';
const DEFAULT_NETWORK = Network.fromComposeObject('default', 1, 'appuuid', {});
async function createService(
{
appId = 1,
appUuid = 'appuuid',
serviceName = 'main',
commit = 'main-commit',
...conf
} = {} as Partial<ServiceComposeConfig>,
{ state = {} as Partial<Service>, options = {} as any } = {},
) {
const svc = await Service.fromComposeObject(
{
appId,
appUuid,
serviceName,
commit,
// db ids should not be used for target state calculation, but images
// are compared using _.isEqual so leaving this here to have image comparisons
// match
serviceId: 1,
imageId: 1,
releaseId: 1,
...conf,
},
options,
);
// Add additonal configuration
for (const k of Object.keys(state)) {
(svc as any)[k] = (state as any)[k];
}
return svc;
}
function createImage(
{
appId = 1,
appUuid = 'appuuid',
name = 'test-image',
serviceName = 'main',
commit = 'main-commit',
...extra
} = {} as Partial<Image>,
) {
return {
appId,
appUuid,
name,
serviceName,
commit,
// db ids should not be used for target state calculation, but images
// are compared using _.isEqual so leaving this here to have image comparisons
// match
imageId: 1,
releaseId: 1,
serviceId: 1,
dependent: 0,
...extra,
} as Image;
}
function createApps(
{
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
},
target = false,
) {
const servicesByAppId = services.reduce(
(svcs, s) => ({ ...svcs, [s.appId]: [s].concat(svcs[s.appId] || []) }),
{} as Dictionary<Service[]>,
);
const volumesByAppId = volumes.reduce(
(vols, v) => ({ ...vols, [v.appId]: [v].concat(vols[v.appId] || []) }),
{} as Dictionary<Volume[]>,
);
const networksByAppId = networks.reduce(
(nets, n) => ({ ...nets, [n.appId]: [n].concat(nets[n.appId] || []) }),
{} as Dictionary<Network[]>,
);
const allAppIds = [
...new Set([
...Object.keys(servicesByAppId),
...Object.keys(networksByAppId),
...Object.keys(volumesByAppId),
]),
].map((i) => parseInt(i, 10));
const apps: InstancedAppState = {};
for (const appId of allAppIds) {
apps[appId] = new App(
{
appId,
services: servicesByAppId[appId] ?? [],
networks: (networksByAppId[appId] ?? []).reduce(
(nets, n) => ({ ...nets, [n.name]: n }),
{},
),
volumes: (volumesByAppId[appId] ?? []).reduce(
(vols, v) => ({ ...vols, [v.name]: v }),
{},
),
},
target,
);
}
return apps;
}
function createCurrentState({
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
images = services.map((s) => ({
// Infer images from services by default
dockerImageId: s.dockerImageId,
...imageManager.imageFromService(s),
})) as Image[],
downloading = [] as string[],
}) {
const currentApps = createApps({ services, networks, volumes });
const containerIdsByAppId = services.reduce(
(ids, s) => ({
...ids,
[s.appId]: {
...ids[s.appId],
...(s.serviceName &&
s.containerId && { [s.serviceName]: s.containerId }),
},
}),
{} as { [appId: number]: Dictionary<string> },
);
return {
currentApps,
availableImages: images,
downloading,
containerIdsByAppId,
};
}
import {
createService,
createImage,
createApps,
createCurrentState,
DEFAULT_NETWORK,
} from '~/test-lib/state-helper';
// TODO: application manager inferNextSteps still queries some stuff from
// the engine instead of receiving that information as parameter. Refactoring

View File

@ -6,6 +6,7 @@ import { setTimeout } from 'timers/promises';
import * as deviceState from '~/src/device-state';
import * as config from '~/src/config';
import * as hostConfig from '~/src/host-config';
import * as deviceApi from '~/src/device-api';
import * as actions from '~/src/device-api/actions';
import * as TargetState from '~/src/device-state/target-state';
@ -731,3 +732,45 @@ describe('updates target state cache', () => {
expect(updateStub).to.not.have.been.called;
});
});
describe('patches host config', () => {
// Stub external dependencies
let hostConfigPatch: SinonStub;
before(async () => {
await config.initialized();
});
beforeEach(() => {
hostConfigPatch = stub(hostConfig, 'patch');
});
afterEach(() => {
hostConfigPatch.restore();
});
it('patches host config', async () => {
const conf = {
network: {
proxy: {
type: 'socks5',
noProxy: ['172.0.10.1'],
},
hostname: 'deadbeef',
},
};
await actions.patchHostConfig(conf, true);
expect(hostConfigPatch).to.have.been.calledWith(conf, true);
});
it('patches hostname as first 7 digits of uuid if hostname parameter is empty string', async () => {
const conf = {
network: {
hostname: '',
},
};
const uuid = await config.get('uuid');
await actions.patchHostConfig(conf, true);
expect(hostConfigPatch).to.have.been.calledWith(
{ network: { hostname: uuid?.slice(0, 7) } },
true,
);
});
});

View File

@ -5,6 +5,7 @@ import * as request from 'supertest';
import * as config from '~/src/config';
import * as db from '~/src/db';
import * as hostConfig from '~/src/host-config';
import Service from '~/src/compose/service';
import * as deviceApi from '~/src/device-api';
import * as actions from '~/src/device-api/actions';
@ -14,6 +15,8 @@ import {
NotFoundError,
BadRequestError,
} from '~/lib/errors';
import log from '~/lib/supervisor-console';
import * as constants from '~/lib/constants';
// All routes that require Authorization are integration tests due to
// the api-key module relying on the database.
@ -768,4 +771,93 @@ describe('device-api/v1', () => {
.expect(503);
});
});
describe('GET /v1/device/host-config', () => {
// Stub external dependencies
let getHostConfigStub: SinonStub;
beforeEach(() => {
getHostConfigStub = stub(hostConfig, 'get');
});
afterEach(() => {
getHostConfigStub.restore();
});
it('responds with 200 and host config', async () => {
getHostConfigStub.resolves({ network: { hostname: 'deadbeef' } });
await request(api)
.get('/v1/device/host-config')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200, { network: { hostname: 'deadbeef' } });
});
it('responds with 503 for other errors that occur during request', async () => {
getHostConfigStub.throws(new Error());
await request(api)
.get('/v1/device/host-config')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(503);
});
});
describe('PATCH /v1/device/host-config', () => {
before(() => stub(actions, 'patchHostConfig'));
after(() => (actions.patchHostConfig as SinonStub).restore());
const validProxyReqs: { [key: string]: number[] | string[] } = {
ip: ['proxy.example.org', 'proxy.foo.org'],
port: [5128, 1080],
type: constants.validRedsocksProxyTypes,
login: ['user', 'user2'],
password: ['foo', 'bar'],
};
it('warns on the supervisor console when provided disallowed proxy fields', async () => {
const invalidProxyReqs: { [key: string]: string | number } = {
// At this time, don't support changing local_ip or local_port
local_ip: '0.0.0.0',
local_port: 12345,
type: 'invalidType',
noProxy: 'not a list of addresses',
};
for (const key of Object.keys(invalidProxyReqs)) {
await request(api)
.patch('/v1/device/host-config')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.send({ network: { proxy: { [key]: invalidProxyReqs[key] } } })
.expect(200)
.then(() => {
if (key === 'type') {
expect(log.warn as SinonStub).to.have.been.calledWith(
`Invalid redsocks proxy type, must be one of ${validProxyReqs.type.join(
', ',
)}`,
);
} else if (key === 'noProxy') {
expect(log.warn as SinonStub).to.have.been.calledWith(
'noProxy field must be an array of addresses',
);
} else {
expect(log.warn as SinonStub).to.have.been.calledWith(
`Invalid proxy field(s): ${key}`,
);
}
});
(log.warn as SinonStub).reset();
}
});
it('warns on console when sent a malformed patch body', async () => {
await request(api)
.patch('/v1/device/host-config')
.send({})
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200)
.then(() => {
expect(log.warn as SinonStub).to.have.been.calledWith(
"Key 'network' must exist in PATCH body",
);
});
});
});
});

View File

@ -0,0 +1,228 @@
import { expect } from 'chai';
import { testfs, TestFs } from 'mocha-pod';
import * as path from 'path';
import { SinonStub, stub } from 'sinon';
import * as fs from 'fs/promises';
import * as hostConfig from '~/src/host-config';
import * as config from '~/src/config';
import * as applicationManager from '~/src/compose/application-manager';
import { InstancedAppState } from '~/src/types/state';
import * as constants from '~/lib/constants';
import * as updateLock from '~/lib/update-lock';
import { UpdatesLockedError } from '~/lib/errors';
import * as dbus from '~/lib/dbus';
import {
createApps,
createService,
DEFAULT_NETWORK,
} from '~/test-lib/state-helper';
describe('host-config', () => {
let tFs: TestFs.Disabled;
let currentApps: InstancedAppState;
const APP_ID = 1;
const SERVICE_NAME = 'one';
const proxyBase = path.join(
constants.rootMountPoint,
constants.bootMountPoint,
'system-proxy',
);
const redsocksConf = path.join(proxyBase, 'redsocks.conf');
const noProxy = path.join(proxyBase, 'no_proxy');
const hostname = path.join(constants.rootMountPoint, '/etc/hostname');
const appLockDir = path.join(
constants.rootMountPoint,
updateLock.lockPath(APP_ID),
);
before(async () => {
await config.initialized();
// Create current state
currentApps = createApps(
{
services: [
await createService({
running: true,
appId: APP_ID,
serviceName: SERVICE_NAME,
}),
],
networks: [DEFAULT_NETWORK],
},
false,
);
// Set up test fs
tFs = testfs({
[redsocksConf]: testfs.from(
'test/data/mnt/boot/system-proxy/redsocks.conf',
),
[noProxy]: testfs.from('test/data/mnt/boot/system-proxy/no_proxy'),
[hostname]: 'deadbeef',
// Create a lock. This won't prevent host config patch unless
// there are current apps present, in which case an updates locked
// error will be thrown.
[appLockDir]: {
[SERVICE_NAME]: {
'updates.lock': '',
},
},
});
});
beforeEach(async () => {
await tFs.enable();
// Stub external dependencies
stub(dbus, 'servicePartOf').resolves('');
stub(dbus, 'restartService').resolves();
});
afterEach(async () => {
await tFs.restore();
(dbus.servicePartOf as SinonStub).restore();
(dbus.restartService as SinonStub).restore();
});
it('reads proxy configs and hostname', async () => {
const { network } = await hostConfig.get();
expect(network).to.have.property('hostname', 'deadbeef');
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example.org');
expect(network.proxy).to.have.property('port', 1080);
expect(network.proxy).to.have.property('type', 'socks5');
expect(network.proxy).to.have.property('login', 'foo');
expect(network.proxy).to.have.property('password', 'bar');
expect(network.proxy).to.have.deep.property('noProxy', [
'152.10.30.4',
'253.1.1.0/16',
]);
});
it('prevents patch if update locks are present', async () => {
stub(applicationManager, 'getCurrentApps').resolves(currentApps);
try {
await hostConfig.patch({ network: { hostname: 'test' } });
expect.fail('Expected hostConfig.patch to throw UpdatesLockedError');
} catch (e: unknown) {
expect(e).to.be.instanceOf(UpdatesLockedError);
}
(applicationManager.getCurrentApps as SinonStub).restore();
});
it('patches if update locks are present but force is specified', async () => {
stub(applicationManager, 'getCurrentApps').resolves(currentApps);
try {
await hostConfig.patch({ network: { hostname: 'deadreef' } }, true);
expect(await config.get('hostname')).to.equal('deadreef');
} catch (e: unknown) {
expect.fail(`Expected hostConfig.patch to not throw, but got ${e}`);
}
(applicationManager.getCurrentApps as SinonStub).restore();
});
it('patches hostname', async () => {
await hostConfig.patch({ network: { hostname: 'test' } });
// /etc/hostname isn't changed until the balena-hostname service
// is restarted through dbus, so we verify the change from config.
expect(await config.get('hostname')).to.equal('test');
});
it('skips restarting hostname services if they are part of config-json.target', async () => {
(dbus.servicePartOf as SinonStub).resolves('config-json.target');
await hostConfig.patch({ network: { hostname: 'newdevice' } });
expect(dbus.restartService as SinonStub).to.not.have.been.called;
expect(await config.get('hostname')).to.equal('newdevice');
});
it('patches proxy', async () => {
await hostConfig.patch({
network: {
proxy: {
ip: 'example2.org',
port: 1090,
type: 'http-relay',
login: 'bar',
password: 'foo',
noProxy: ['balena.io', '222.22.2.2'],
},
},
});
const { network } = await hostConfig.get();
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example2.org');
expect(network.proxy).to.have.property('port', 1090);
expect(network.proxy).to.have.property('type', 'http-relay');
expect(network.proxy).to.have.property('login', 'bar');
expect(network.proxy).to.have.property('password', 'foo');
expect(network.proxy).to.have.deep.property('noProxy', [
'balena.io',
'222.22.2.2',
]);
});
it('skips restarting proxy services when part of redsocks-conf.target', async () => {
(dbus.servicePartOf as SinonStub).resolves('redsocks-conf.target');
await hostConfig.patch({
network: {
proxy: {
ip: 'example2.org',
port: 1090,
type: 'http-relay',
login: 'bar',
password: 'foo',
noProxy: ['balena.io', '222.22.2.2'],
},
},
});
expect(dbus.restartService as SinonStub).to.not.have.been.called;
const { network } = await hostConfig.get();
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example2.org');
expect(network.proxy).to.have.property('port', 1090);
expect(network.proxy).to.have.property('type', 'http-relay');
expect(network.proxy).to.have.property('login', 'bar');
expect(network.proxy).to.have.property('password', 'foo');
expect(network.proxy).to.have.deep.property('noProxy', [
'balena.io',
'222.22.2.2',
]);
});
it('patches redsocks.conf to be empty if prompted', async () => {
await hostConfig.patch({ network: { proxy: {} } });
const { network } = await hostConfig.get();
expect(network).to.have.property('proxy', undefined);
expect(await fs.readdir(proxyBase)).to.not.have.members([
'redsocks.conf',
'no_proxy',
]);
});
it('patches no_proxy to be empty if prompted', async () => {
await hostConfig.patch({
network: {
proxy: {
noProxy: [],
},
},
});
const { network } = await hostConfig.get();
expect(network).to.have.property('proxy');
expect(network.proxy).to.not.have.property('noProxy');
expect(await fs.readdir(proxyBase)).to.not.have.members(['no_proxy']);
});
it("doesn't update hostname or proxy when both are empty", async () => {
const { network } = await hostConfig.get();
await hostConfig.patch({ network: {} });
const { network: newNetwork } = await hostConfig.get();
expect(network.hostname).to.equal(newNetwork.hostname);
expect(network.proxy).to.deep.equal(newNetwork.proxy);
});
});

View File

@ -1,718 +0,0 @@
import { expect } from 'chai';
import { stub, spy, SinonStub, SinonSpy } from 'sinon';
import * as supertest from 'supertest';
import * as path from 'path';
import { promises as fs } from 'fs';
import { exists, unlinkAll } from '~/lib/fs-utils';
import * as appMock from '~/test-lib/application-state-mock';
import * as mockedDockerode from '~/test-lib/mocked-dockerode';
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/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 dbus from '~/lib/dbus';
import * as updateLock from '~/lib/update-lock';
import * as targetStateCache from '~/src/device-state/target-state-cache';
import constants = require('~/lib/constants');
import { UpdatesLockedError } from '~/lib/errors';
import { SchemaTypeKey } from '~/src/config/schema-type';
import log from '~/lib/supervisor-console';
import * as applicationManager from '~/src/compose/application-manager';
import App from '~/src/compose/app';
describe('SupervisorAPI [V1 Endpoints]', () => {
let api: SupervisorAPI;
let targetStateCacheMock: SinonStub;
const request = supertest(
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
);
const services = [
{ appId: 2, appUuid: 'deadbeef', serviceId: 640681, serviceName: 'one' },
{ appId: 2, appUuid: 'deadbeef', serviceId: 640682, serviceName: 'two' },
{ appId: 2, appUuid: 'deadbeef', serviceId: 640683, serviceName: 'three' },
];
const containers = services.map((service) => mockedAPI.mockService(service));
const images = services.map((service) => mockedAPI.mockImage(service));
let loggerStub: SinonStub;
beforeEach(() => {
// Mock a 3 container release
appMock.mockManagers(containers, [], []);
appMock.mockImages([], false, images);
appMock.mockSupervisorNetwork(true);
targetStateCacheMock.resolves({
appId: 2,
appUuid: 'deadbeef',
commit: 'abcdef2',
name: 'test-app2',
source: 'https://api.balena-cloud.com',
releaseId: 1232,
services: JSON.stringify(services),
networks: '[]',
volumes: '[]',
});
});
afterEach(() => {
// Clear Dockerode actions recorded for each test
mockedDockerode.resetHistory();
appMock.unmockAll();
});
before(async () => {
await apiBinder.initialized();
await deviceState.initialized();
await targetStateCache.initialized();
// Do not apply target state
stub(deviceState, 'applyStep').resolves();
// The mockedAPI contains stubs that might create unexpected results
// See the module to know what has been stubbed
api = await mockedAPI.create([]);
// Start test API
await api.listen(
mockedAPI.mockedOptions.listenPort,
mockedAPI.mockedOptions.timeout,
);
// Mock target state cache
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
// Stub logs for all API methods
loggerStub = stub(logger, 'attach');
loggerStub.resolves();
});
after(async () => {
try {
await api.stop();
} catch (e: any) {
if (e.message !== 'Server is not running.') {
throw e;
}
}
(deviceState.applyStep as SinonStub).restore();
// Remove any test data generated
await mockedAPI.cleanUp();
targetStateCacheMock.restore();
loggerStub.restore();
});
describe('/v1/device/host-config', () => {
// Wrap GET and PATCH /v1/device/host-config tests in the same block to share
// common scoped variables, namely file paths and file content
const hostnamePath: string = path.join(
process.env.ROOT_MOUNTPOINT!,
'/etc/hostname',
);
const proxyBasePath: string = path.join(
process.env.ROOT_MOUNTPOINT!,
process.env.BOOT_MOUNTPOINT!,
'system-proxy',
);
const redsocksPath: string = path.join(proxyBasePath, 'redsocks.conf');
const noProxyPath: string = path.join(proxyBasePath, 'no_proxy');
/**
* Copies contents of hostname, redsocks.conf, and no_proxy test files with `.template`
* endings to test files without `.template` endings to ensure the same data always
* exists for /v1/device/host-config test suites
*/
const restoreConfFileTemplates = async (): Promise<void[]> => {
return Promise.all([
fs.writeFile(
hostnamePath,
await fs.readFile(`${hostnamePath}.template`),
),
fs.writeFile(
redsocksPath,
await fs.readFile(`${redsocksPath}.template`),
),
fs.writeFile(noProxyPath, await fs.readFile(`${noProxyPath}.template`)),
]);
};
// Set hostname & proxy file content to expected defaults
before(async () => await restoreConfFileTemplates());
afterEach(async () => await restoreConfFileTemplates());
// Store GET responses for endpoint in variables so we can be less verbose in tests
const hostnameOnlyRes =
sampleResponses.V1.GET['/device/host-config [Hostname only]'];
const hostnameProxyRes =
sampleResponses.V1.GET['/device/host-config [Hostname and proxy]'];
describe('GET /v1/device/host-config', () => {
it('returns current host config (hostname and proxy)', async () => {
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameProxyRes.body);
});
});
it('returns current host config (hostname only)', async () => {
await unlinkAll(redsocksPath, noProxyPath);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(hostnameOnlyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameOnlyRes.body);
});
});
it('errors if no hostname file exists', async () => {
await unlinkAll(hostnamePath);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(503);
});
});
describe('PATCH /v1/device/host-config', () => {
let configSetStub: SinonStub;
let logWarnStub: SinonStub;
let restartServiceSpy: SinonSpy;
const validProxyReqs: { [key: string]: number[] | string[] } = {
ip: ['proxy.example.org', 'proxy.foo.org'],
port: [5128, 1080],
type: constants.validRedsocksProxyTypes,
login: ['user', 'user2'],
password: ['foo', 'bar'],
};
// Mock to short-circuit config.set, allowing writing hostname directly to test file
const configSetFakeFn = async <T extends SchemaTypeKey>(
keyValues: config.ConfigMap<T>,
): Promise<void> =>
await fs.writeFile(hostnamePath, (keyValues as any).hostname);
const validatePatchResponse = (res: supertest.Response): void => {
expect(res.text).to.equal(
sampleResponses.V1.PATCH['/host/device-config'].text,
);
expect(res.body).to.deep.equal(
sampleResponses.V1.PATCH['/host/device-config'].body,
);
};
before(() => {
configSetStub = stub(config, 'set').callsFake(configSetFakeFn);
logWarnStub = stub(log, 'warn');
stub(applicationManager, 'getCurrentApps').resolves({
'1234567': new App(
{
appId: 1234567,
services: [],
volumes: {},
networks: {},
},
false,
),
});
});
after(() => {
configSetStub.restore();
logWarnStub.restore();
(applicationManager.getCurrentApps as SinonStub).restore();
});
beforeEach(() => {
restartServiceSpy = spy(dbus, 'restartService');
});
afterEach(() => {
restartServiceSpy.restore();
});
it('prevents patch if update locks are present', async () => {
stub(updateLock, 'lock').callsFake(async (__, opts, fn) => {
if (opts.force) {
return fn();
}
throw new UpdatesLockedError('Updates locked');
});
await request
.patch('/v1/device/host-config')
.send({ network: { hostname: 'foobaz' } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(423);
expect(updateLock.lock).to.be.calledOnce;
(updateLock.lock as SinonStub).restore();
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.then((response) => {
expect(response.body.network.hostname).to.deep.equal(
'foobardevice',
);
});
});
it('allows patch while update locks are present if force is in req.body', async () => {
stub(updateLock, 'lock').callsFake(async (__, opts, fn) => {
if (opts.force) {
return fn();
}
throw new UpdatesLockedError('Updates locked');
});
await request
.patch('/v1/device/host-config')
.send({ network: { hostname: 'foobaz' }, force: true })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
expect(updateLock.lock).to.be.calledOnce;
(updateLock.lock as SinonStub).restore();
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.then((response) => {
expect(response.body.network.hostname).to.deep.equal('foobaz');
});
});
it('updates the hostname with provided string if string is not empty', async () => {
// stub servicePartOf to throw exceptions for the new service names
stub(dbus, 'servicePartOf').callsFake(
async (serviceName: string): Promise<string> => {
if (serviceName === 'balena-hostname') {
throw new Error('Unit not loaded.');
}
return '';
},
);
await unlinkAll(redsocksPath, noProxyPath);
const patchBody = { network: { hostname: 'newdevice' } };
await request
.patch('/v1/device/host-config')
.send(patchBody)
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// should restart services
expect(restartServiceSpy.callCount).to.equal(2);
expect(restartServiceSpy.args).to.deep.equal([
['balena-hostname'],
['resin-hostname'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.then((response) => {
expect(response.body).to.deep.equal(patchBody);
});
(dbus.servicePartOf as SinonStub).restore();
});
it('skips restarting hostname services if they are part of config-json.target', async () => {
// stub servicePartOf to return the config-json.target we are looking for
stub(dbus, 'servicePartOf').callsFake(async (): Promise<string> => {
return 'config-json.target';
});
await unlinkAll(redsocksPath, noProxyPath);
const patchBody = { network: { hostname: 'newdevice' } };
await request
.patch('/v1/device/host-config')
.send(patchBody)
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// skips restarting hostname services if they are part of config-json.target
expect(restartServiceSpy.callCount).to.equal(0);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.then((response) => {
expect(response.body).to.deep.equal(patchBody);
});
(dbus.servicePartOf as SinonStub).restore();
});
it('updates hostname to first 7 digits of device uuid when sent invalid hostname', async () => {
await unlinkAll(redsocksPath, noProxyPath);
await request
.patch('/v1/device/host-config')
.send({ network: { hostname: '' } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// should restart services
expect(restartServiceSpy.callCount).to.equal(2);
expect(restartServiceSpy.args).to.deep.equal([
['balena-hostname'],
['resin-hostname'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.then(async (response) => {
const uuidHostname = await config
.get('uuid')
.then((uuid) => uuid?.slice(0, 7));
expect(response.body).to.deep.equal({
network: { hostname: uuidHostname },
});
});
});
it('removes proxy when sent empty proxy object', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: {} } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then(async (response) => {
validatePatchResponse(response);
expect(await exists(redsocksPath)).to.be.false;
expect(await exists(noProxyPath)).to.be.false;
});
// should restart services
expect(restartServiceSpy.callCount).to.equal(3);
expect(restartServiceSpy.args).to.deep.equal([
['balena-proxy-config'],
['resin-proxy-config'],
['redsocks'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(hostnameOnlyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameOnlyRes.body);
});
});
it('updates proxy type when provided valid values', async () => {
// stub servicePartOf to throw exceptions for the new service names
stub(dbus, 'servicePartOf').callsFake(
async (serviceName: string): Promise<string> => {
if (serviceName === 'balena-proxy-config') {
throw new Error('Unit not loaded.');
}
return '';
},
);
// Test each proxy patch sequentially to prevent conflicts when writing to fs
let restartCallCount = 0;
for (const key of Object.keys(validProxyReqs)) {
const patchBodyValuesforKey: string[] | number[] =
validProxyReqs[key];
for (const value of patchBodyValuesforKey) {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { [key]: value } } })
.set('Accept', 'application/json')
.set(
'Authorization',
`Bearer ${await deviceApi.getGlobalApiKey()}`,
)
.expect(
sampleResponses.V1.PATCH['/host/device-config'].statusCode,
)
.then((response) => {
validatePatchResponse(response);
});
// should restart services
expect(restartServiceSpy.callCount).to.equal(
++restartCallCount * 3,
);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set(
'Authorization',
`Bearer ${await deviceApi.getGlobalApiKey()}`,
)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal({
network: {
hostname: hostnameProxyRes.body.network.hostname,
// All other proxy configs should be unchanged except for any values sent in patch
proxy: {
...hostnameProxyRes.body.network.proxy,
[key]: value,
},
},
});
});
} // end for (const value of patchBodyValuesforKey)
await restoreConfFileTemplates();
} // end for (const key in validProxyReqs)
(dbus.servicePartOf as SinonStub).restore();
});
it('skips restarting proxy services when part of redsocks-conf.target', async () => {
// stub servicePartOf to return the redsocks-conf.target we are looking for
stub(dbus, 'servicePartOf').callsFake(async (): Promise<string> => {
return 'redsocks-conf.target';
});
// Test each proxy patch sequentially to prevent conflicts when writing to fs
for (const key of Object.keys(validProxyReqs)) {
const patchBodyValuesforKey: string[] | number[] =
validProxyReqs[key];
for (const value of patchBodyValuesforKey) {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { [key]: value } } })
.set('Accept', 'application/json')
.set(
'Authorization',
`Bearer ${await deviceApi.getGlobalApiKey()}`,
)
.expect(
sampleResponses.V1.PATCH['/host/device-config'].statusCode,
)
.then((response) => {
validatePatchResponse(response);
});
// skips restarting proxy services when part of redsocks-conf.target
expect(restartServiceSpy.callCount).to.equal(0);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set(
'Authorization',
`Bearer ${await deviceApi.getGlobalApiKey()}`,
)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal({
network: {
hostname: hostnameProxyRes.body.network.hostname,
// All other proxy configs should be unchanged except for any values sent in patch
proxy: {
...hostnameProxyRes.body.network.proxy,
[key]: value,
},
},
});
});
} // end for (const value of patchBodyValuesforKey)
await restoreConfFileTemplates();
} // end for (const key in validProxyReqs)
(dbus.servicePartOf as SinonStub).restore();
});
it('warns on the supervisor console when provided disallowed proxy fields', async () => {
const invalidProxyReqs: { [key: string]: string | number } = {
// At this time, don't support changing local_ip or local_port
local_ip: '0.0.0.0',
local_port: 12345,
type: 'invalidType',
noProxy: 'not a list of addresses',
};
for (const key of Object.keys(invalidProxyReqs)) {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { [key]: invalidProxyReqs[key] } } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200)
.then(() => {
if (key === 'type') {
expect(logWarnStub).to.have.been.calledWith(
`Invalid redsocks proxy type, must be one of ${validProxyReqs.type.join(
', ',
)}`,
);
} else if (key === 'noProxy') {
expect(logWarnStub).to.have.been.calledWith(
'noProxy field must be an array of addresses',
);
} else {
expect(logWarnStub).to.have.been.calledWith(
`Invalid proxy field(s): ${key}`,
);
}
});
}
});
it('replaces no_proxy file with noProxy array from PATCH body', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { noProxy: ['1.2.3.4/5'] } } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// should restart services
expect(restartServiceSpy.callCount).to.equal(3);
expect(restartServiceSpy.args).to.deep.equal([
['balena-proxy-config'],
['resin-proxy-config'],
['redsocks'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal({
network: {
hostname: hostnameProxyRes.body.network.hostname,
// New noProxy should be only value in no_proxy file
proxy: {
...hostnameProxyRes.body.network.proxy,
noProxy: ['1.2.3.4/5'],
},
},
});
});
});
it('removes no_proxy file when sent an empty array', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { noProxy: [] } } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// should restart services
expect(restartServiceSpy.callCount).to.equal(3);
expect(restartServiceSpy.args).to.deep.equal([
['balena-proxy-config'],
['resin-proxy-config'],
['redsocks'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal({
network: {
hostname: hostnameProxyRes.body.network.hostname,
// Reference all properties in proxy object EXCEPT noProxy
proxy: {
ip: hostnameProxyRes.body.network.proxy.ip,
login: hostnameProxyRes.body.network.proxy.login,
password: hostnameProxyRes.body.network.proxy.password,
port: hostnameProxyRes.body.network.proxy.port,
type: hostnameProxyRes.body.network.proxy.type,
},
},
});
});
});
it('does not update hostname or proxy when hostname or proxy are undefined', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: {} })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// As no host configs were patched, no services should be restarted
expect(restartServiceSpy.callCount).to.equal(0);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameProxyRes.body);
});
});
it('warns on console when sent a malformed patch body', async () => {
await request
.patch('/v1/device/host-config')
.send({})
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200)
.then(() => {
expect(logWarnStub).to.have.been.calledWith(
"Key 'network' must exist in PATCH body",
);
});
expect(restartServiceSpy.callCount).to.equal(0);
});
});
});
});

View File

@ -1,78 +0,0 @@
import * as networkManager from '~/src/compose/network-manager';
import * as volumeManager from '~/src/compose/volume-manager';
import * as serviceManager from '~/src/compose/service-manager';
import * as imageManager from '~/src/compose/images';
import Service from '~/src/compose/service';
import Network from '~/src/compose/network';
import Volume from '~/src/compose/volume';
const originalVolGetAll = volumeManager.getAll;
const originalSvcGetAll = serviceManager.getAll;
const originalNetGetAll = networkManager.getAll;
const originalNeedsClean = imageManager.isCleanupNeeded;
const originalImageAvailable = imageManager.getAvailable;
const originalNetworkReady = networkManager.supervisorNetworkReady;
export function mockManagers(svcs: Service[], vols: Volume[], nets: Network[]) {
// @ts-expect-error Assigning to a RO property
volumeManager.getAll = async () => vols;
// @ts-expect-error Assigning to a RO property
networkManager.getAll = async () => nets;
// @ts-expect-error Assigning to a RO property
serviceManager.getAll = async () => {
// Filter services that are being removed
svcs = svcs.filter((s) => s.status !== 'removing');
// Update Installing containers to Running
svcs = svcs.map((s) => {
if (s.status === 'Installing') {
s.status = 'Running';
}
return s;
});
return svcs;
};
}
function unmockManagers() {
// @ts-expect-error Assigning to a RO property
volumeManager.getAll = originalVolGetAll;
// @ts-expect-error Assigning to a RO property
networkManager.getAll = originalNetGetAll;
// @ts-expect-error Assigning to a RO property
serviceManager.getall = originalSvcGetAll;
}
export function mockImages(
_downloading: number[],
cleanup: boolean,
available: imageManager.Image[],
) {
// @ts-expect-error Assigning to a RO property
imageManager.isCleanupNeeded = async () => cleanup;
// @ts-expect-error Assigning to a RO property
imageManager.getAvailable = async () => available;
}
function unmockImages() {
// @ts-expect-error Assigning to a RO property
imageManager.isCleanupNeeded = originalNeedsClean;
// @ts-expect-error Assigning to a RO property
imageManager.getAvailable = originalImageAvailable;
}
export function mockSupervisorNetwork(exists: boolean) {
// @ts-expect-error Assigning to a RO property
networkManager.supervisorNetworkReady = async () => exists;
}
function unmockSupervisorNetwork() {
// @ts-expect-error Assigning to a RO property
networkManager.supervisorNetworkReady = originalNetworkReady;
}
export function unmockAll() {
unmockManagers();
unmockImages();
unmockSupervisorNetwork();
}

160
test/lib/state-helper.ts Normal file
View File

@ -0,0 +1,160 @@
import App from '~/src/compose/app';
import * as imageManager from '~/src/compose/images';
import { Image } from '~/src/compose/images';
import Network from '~/src/compose/network';
import Service from '~/src/compose/service';
import { ServiceComposeConfig } from '~/src/compose/types/service';
import Volume from '~/src/compose/volume';
import { InstancedAppState } from '~/src/types/state';
export const DEFAULT_NETWORK = Network.fromComposeObject(
'default',
1,
'appuuid',
{},
);
export async function createService(
{
appId = 1,
appUuid = 'appuuid',
serviceName = 'main',
commit = 'main-commit',
...conf
} = {} as Partial<ServiceComposeConfig>,
{ state = {} as Partial<Service>, options = {} as any } = {},
) {
const svc = await Service.fromComposeObject(
{
appId,
appUuid,
serviceName,
commit,
// db ids should not be used for target state calculation, but images
// are compared using _.isEqual so leaving this here to have image comparisons
// match
serviceId: 1,
imageId: 1,
releaseId: 1,
...conf,
},
options,
);
// Add additonal configuration
for (const k of Object.keys(state)) {
(svc as any)[k] = (state as any)[k];
}
return svc;
}
export function createImage(
{
appId = 1,
appUuid = 'appuuid',
name = 'test-image',
serviceName = 'main',
commit = 'main-commit',
...extra
} = {} as Partial<Image>,
) {
return {
appId,
appUuid,
name,
serviceName,
commit,
// db ids should not be used for target state calculation, but images
// are compared using _.isEqual so leaving this here to have image comparisons
// match
imageId: 1,
releaseId: 1,
serviceId: 1,
dependent: 0,
...extra,
} as Image;
}
export function createApps(
{
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
},
target = false,
) {
const servicesByAppId = services.reduce(
(svcs, s) => ({ ...svcs, [s.appId]: [s].concat(svcs[s.appId] || []) }),
{} as Dictionary<Service[]>,
);
const volumesByAppId = volumes.reduce(
(vols, v) => ({ ...vols, [v.appId]: [v].concat(vols[v.appId] || []) }),
{} as Dictionary<Volume[]>,
);
const networksByAppId = networks.reduce(
(nets, n) => ({ ...nets, [n.appId]: [n].concat(nets[n.appId] || []) }),
{} as Dictionary<Network[]>,
);
const allAppIds = [
...new Set([
...Object.keys(servicesByAppId),
...Object.keys(networksByAppId),
...Object.keys(volumesByAppId),
]),
].map((i) => parseInt(i, 10));
const apps: InstancedAppState = {};
for (const appId of allAppIds) {
apps[appId] = new App(
{
appId,
services: servicesByAppId[appId] ?? [],
networks: (networksByAppId[appId] ?? []).reduce(
(nets, n) => ({ ...nets, [n.name]: n }),
{},
),
volumes: (volumesByAppId[appId] ?? []).reduce(
(vols, v) => ({ ...vols, [v.name]: v }),
{},
),
},
target,
);
}
return apps;
}
export function createCurrentState({
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
images = services.map((s) => ({
// Infer images from services by default
dockerImageId: s.dockerImageId,
...imageManager.imageFromService(s),
})) as Image[],
downloading = [] as string[],
}) {
const currentApps = createApps({ services, networks, volumes });
const containerIdsByAppId = services.reduce(
(ids, s) => ({
...ids,
[s.appId]: {
...ids[s.appId],
...(s.serviceName &&
s.containerId && { [s.serviceName]: s.containerId }),
},
}),
{} as { [appId: number]: Dictionary<string> },
);
return {
currentApps,
availableImages: images,
downloading,
containerIdsByAppId,
};
}

View File

@ -1,6 +1,7 @@
import { expect } from 'chai';
import { spy, useFakeTimers } from 'sinon';
import { spy, useFakeTimers, stub, SinonStub } from 'sinon';
import * as hostConfig from '~/src/host-config';
import * as actions from '~/src/device-api/actions';
import blink = require('~/lib/blink');
@ -43,4 +44,27 @@ describe('device-api/actions', () => {
clock.restore();
});
});
describe('gets host config', () => {
// Stub external dependencies
// TODO: host-config module integration tests
let hostConfigGet: SinonStub;
before(() => {
hostConfigGet = stub(hostConfig, 'get');
});
after(() => {
hostConfigGet.restore();
});
it('gets host config', async () => {
const conf = {
network: {
proxy: {},
hostname: 'deadbeef',
},
};
hostConfigGet.resolves(conf);
expect(await actions.getHostConfig()).to.deep.equal(conf);
});
});
});