mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-21 14:37:49 +00:00
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:
parent
250684d651
commit
e1bacda580
@ -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);
|
||||
};
|
||||
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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": {
|
||||
|
@ -1,2 +0,0 @@
|
||||
152.10.30.4
|
||||
253.1.1.0/16
|
@ -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";
|
||||
}
|
@ -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
|
||||
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
228
test/integration/host-config.spec.ts
Normal file
228
test/integration/host-config.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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
160
test/lib/state-helper.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user