mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-21 22:47:49 +00:00
94de4006a0
This splits `App`, `Network`, `Service` and `Volume` which used to be defined as classes into an interface and a class implementation that is not exported. This will allow to work with just the types in some cases and prevent circular dependencies when importing. Change-type: patch
1452 lines
39 KiB
TypeScript
1452 lines
39 KiB
TypeScript
import * as _ from 'lodash';
|
|
|
|
import { expect } from 'chai';
|
|
import { createContainer } from '~/test-lib/mockerode';
|
|
|
|
import { Service } from '~/src/compose/service';
|
|
import { Volume } from '~/src/compose/volume';
|
|
import * as ServiceT from '~/src/compose/types/service';
|
|
import * as constants from '~/lib/constants';
|
|
|
|
const configs = {
|
|
simple: {
|
|
compose: require('~/test-data/docker-states/simple/compose.json'),
|
|
imageInfo: require('~/test-data/docker-states/simple/imageInfo.json'),
|
|
inspect: require('~/test-data/docker-states/simple/inspect.json'),
|
|
},
|
|
entrypoint: {
|
|
compose: require('~/test-data/docker-states/entrypoint/compose.json'),
|
|
imageInfo: require('~/test-data/docker-states/entrypoint/imageInfo.json'),
|
|
inspect: require('~/test-data/docker-states/entrypoint/inspect.json'),
|
|
},
|
|
networkModeService: {
|
|
compose: require('~/test-data/docker-states/network-mode-service/compose.json'),
|
|
imageInfo: require('~/test-data/docker-states/network-mode-service/imageInfo.json'),
|
|
inspect: require('~/test-data/docker-states/network-mode-service/inspect.json'),
|
|
},
|
|
};
|
|
|
|
describe('compose/service: unit tests', () => {
|
|
describe('Creating a service instance from a compose object', () => {
|
|
it('extends environment variables with additional OS info', async () => {
|
|
const extendEnvVarsOpts = {
|
|
uuid: '1234',
|
|
appName: 'awesomeApp',
|
|
commit: 'abcdef',
|
|
name: 'awesomeDevice',
|
|
version: 'v1.0.0',
|
|
deviceArch: 'amd64',
|
|
deviceType: 'raspberry-pi',
|
|
osVersion: 'Resin OS 2.0.2',
|
|
};
|
|
const service = {
|
|
appId: '23',
|
|
appUuid: 'deadbeef',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
serviceName: 'serviceName',
|
|
environment: {
|
|
FOO: 'bar',
|
|
A_VARIABLE: 'ITS_VALUE',
|
|
},
|
|
};
|
|
const s = await Service.fromComposeObject(
|
|
service,
|
|
extendEnvVarsOpts as any,
|
|
);
|
|
|
|
expect(s.config.environment).to.deep.equal({
|
|
FOO: 'bar',
|
|
A_VARIABLE: 'ITS_VALUE',
|
|
RESIN_APP_ID: '23',
|
|
RESIN_APP_UUID: 'deadbeef',
|
|
RESIN_APP_NAME: 'awesomeApp',
|
|
RESIN_DEVICE_UUID: '1234',
|
|
RESIN_DEVICE_ARCH: 'amd64',
|
|
RESIN_DEVICE_TYPE: 'raspberry-pi',
|
|
RESIN_HOST_OS_VERSION: 'Resin OS 2.0.2',
|
|
RESIN_SERVICE_NAME: 'serviceName',
|
|
RESIN_APP_LOCK_PATH: '/tmp/balena/updates.lock',
|
|
RESIN_SERVICE_KILL_ME_PATH: '/tmp/balena/handover-complete',
|
|
RESIN: '1',
|
|
BALENA_APP_ID: '23',
|
|
BALENA_APP_UUID: 'deadbeef',
|
|
BALENA_APP_NAME: 'awesomeApp',
|
|
BALENA_DEVICE_UUID: '1234',
|
|
BALENA_DEVICE_ARCH: 'amd64',
|
|
BALENA_DEVICE_TYPE: 'raspberry-pi',
|
|
BALENA_HOST_OS_VERSION: 'Resin OS 2.0.2',
|
|
BALENA_SERVICE_NAME: 'serviceName',
|
|
BALENA_APP_LOCK_PATH: '/tmp/balena/updates.lock',
|
|
BALENA_SERVICE_HANDOVER_COMPLETE_PATH: '/tmp/balena/handover-complete',
|
|
BALENA: '1',
|
|
USER: 'root',
|
|
});
|
|
});
|
|
|
|
it('returns the correct default bind mounts', async () => {
|
|
const s = await Service.fromComposeObject(
|
|
{
|
|
appId: '1234',
|
|
serviceName: 'foo',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
},
|
|
{ appName: 'foo' } as any,
|
|
);
|
|
const binds = (Service as any).defaultBinds(s.appId, s.serviceName);
|
|
expect(binds).to.deep.equal([
|
|
'/tmp/balena-supervisor/services/1234/foo:/tmp/resin',
|
|
'/tmp/balena-supervisor/services/1234/foo:/tmp/balena',
|
|
]);
|
|
});
|
|
|
|
it('produces the correct port bindings and exposed ports', async () => {
|
|
const s = await Service.fromComposeObject(
|
|
{
|
|
appId: '1234',
|
|
serviceName: 'foo',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
composition: {
|
|
// This will be ignored
|
|
expose: [1000, '243/udp'],
|
|
ports: ['2344', '2345:2354', '2346:2367/udp'],
|
|
},
|
|
},
|
|
{
|
|
imageInfo: {
|
|
Config: {
|
|
ExposedPorts: {
|
|
// This will be ignored by generateExposeAndPorts
|
|
'53/tcp': {},
|
|
'53/udp': {},
|
|
'2354/tcp': {},
|
|
},
|
|
},
|
|
},
|
|
} as any,
|
|
);
|
|
|
|
const ports = (s as any).generateExposeAndPorts();
|
|
expect(ports.portBindings).to.deep.equal({
|
|
'2344/tcp': [
|
|
{
|
|
HostIp: '',
|
|
HostPort: '2344',
|
|
},
|
|
],
|
|
'2354/tcp': [
|
|
{
|
|
HostIp: '',
|
|
HostPort: '2345',
|
|
},
|
|
],
|
|
'2367/udp': [
|
|
{
|
|
HostIp: '',
|
|
HostPort: '2346',
|
|
},
|
|
],
|
|
});
|
|
// Only exposes ports coming from the `ports`
|
|
// property
|
|
expect(ports.exposedPorts).to.deep.equal({
|
|
'2344/tcp': {},
|
|
'2354/tcp': {},
|
|
'2367/udp': {},
|
|
});
|
|
});
|
|
|
|
it('correctly handles port ranges', async () => {
|
|
const s = await Service.fromComposeObject(
|
|
{
|
|
appId: '1234',
|
|
serviceName: 'foo',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
composition: {
|
|
expose: [1000, '243/udp'],
|
|
ports: ['1000-1003:2000-2003'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
const ports = (s as any).generateExposeAndPorts();
|
|
expect(ports.portBindings).to.deep.equal({
|
|
'2000/tcp': [
|
|
{
|
|
HostIp: '',
|
|
HostPort: '1000',
|
|
},
|
|
],
|
|
'2001/tcp': [
|
|
{
|
|
HostIp: '',
|
|
HostPort: '1001',
|
|
},
|
|
],
|
|
'2002/tcp': [
|
|
{
|
|
HostIp: '',
|
|
HostPort: '1002',
|
|
},
|
|
],
|
|
'2003/tcp': [
|
|
{
|
|
HostIp: '',
|
|
HostPort: '1003',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(ports.exposedPorts).to.deep.equal({
|
|
'2000/tcp': {},
|
|
'2001/tcp': {},
|
|
'2002/tcp': {},
|
|
'2003/tcp': {},
|
|
});
|
|
});
|
|
|
|
it('should correctly handle large port ranges', async function () {
|
|
this.timeout(60000);
|
|
const s = await Service.fromComposeObject(
|
|
{
|
|
appId: '1234',
|
|
serviceName: 'foo',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
composition: {
|
|
ports: ['5-65536:5-65536/tcp', '5-65536:5-65536/udp'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect((s as any).generateExposeAndPorts()).to.not.throw;
|
|
});
|
|
|
|
it('should correctly handle spaces in volume definitions', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123,
|
|
serviceId: 123,
|
|
serviceName: 'test',
|
|
composition: {
|
|
volumes: [
|
|
'vol1:vol2',
|
|
'vol3 :/usr/src/app',
|
|
'vol4: /usr/src/app',
|
|
'vol5 : vol6',
|
|
],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(service.config)
|
|
.to.have.property('volumes')
|
|
.that.deep.equals([
|
|
`${Volume.generateDockerName(123, 'vol1')}:vol2`,
|
|
`${Volume.generateDockerName(123, 'vol3')}:/usr/src/app`,
|
|
`${Volume.generateDockerName(123, 'vol4')}:/usr/src/app`,
|
|
`${Volume.generateDockerName(123, 'vol5')}:vol6`,
|
|
|
|
'/tmp/balena-supervisor/services/123/test:/tmp/resin',
|
|
'/tmp/balena-supervisor/services/123/test:/tmp/balena',
|
|
]);
|
|
});
|
|
|
|
describe('Parsing memory strings from compose configuration', () => {
|
|
const makeComposeServiceWithLimit = async (memLimit?: string | number) =>
|
|
await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
composition: {
|
|
mem_limit: memLimit,
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
it('should correctly parse memory number strings without a unit', async () =>
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64')).config.memLimit,
|
|
).to.equal(64));
|
|
|
|
it('should correctly apply the default value', async () =>
|
|
expect(
|
|
(await makeComposeServiceWithLimit(undefined)).config.memLimit,
|
|
).to.equal(0));
|
|
|
|
it('should correctly support parsing numbers as memory limits', async () =>
|
|
expect(
|
|
(await makeComposeServiceWithLimit(64)).config.memLimit,
|
|
).to.equal(64));
|
|
|
|
it('should correctly parse memory number strings that use a byte unit', async () => {
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64b')).config.memLimit,
|
|
).to.equal(64);
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64B')).config.memLimit,
|
|
).to.equal(64);
|
|
});
|
|
|
|
it('should correctly parse memory number strings that use a kilobyte unit', async () => {
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64k')).config.memLimit,
|
|
).to.equal(65536);
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64K')).config.memLimit,
|
|
).to.equal(65536);
|
|
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64kb')).config.memLimit,
|
|
).to.equal(65536);
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64Kb')).config.memLimit,
|
|
).to.equal(65536);
|
|
});
|
|
|
|
it('should correctly parse memory number strings that use a megabyte unit', async () => {
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64m')).config.memLimit,
|
|
).to.equal(67108864);
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64M')).config.memLimit,
|
|
).to.equal(67108864);
|
|
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64mb')).config.memLimit,
|
|
).to.equal(67108864);
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64Mb')).config.memLimit,
|
|
).to.equal(67108864);
|
|
});
|
|
|
|
it('should correctly parse memory number strings that use a gigabyte unit', async () => {
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64g')).config.memLimit,
|
|
).to.equal(68719476736);
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64G')).config.memLimit,
|
|
).to.equal(68719476736);
|
|
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64gb')).config.memLimit,
|
|
).to.equal(68719476736);
|
|
expect(
|
|
(await makeComposeServiceWithLimit('64Gb')).config.memLimit,
|
|
).to.equal(68719476736);
|
|
});
|
|
});
|
|
|
|
describe('Getting work dir from the compose configuration', () => {
|
|
const makeComposeServiceWithWorkdir = async (workdir?: string) =>
|
|
await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
composition: {
|
|
workingDir: workdir,
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
it('should remove a trailing slash', async () => {
|
|
expect(
|
|
(await makeComposeServiceWithWorkdir('/usr/src/app/')).config
|
|
.workingDir,
|
|
).to.equal('/usr/src/app');
|
|
expect(
|
|
(await makeComposeServiceWithWorkdir('/')).config.workingDir,
|
|
).to.equal('/');
|
|
expect(
|
|
(await makeComposeServiceWithWorkdir('/usr/src/app')).config
|
|
.workingDir,
|
|
).to.equal('/usr/src/app');
|
|
expect(
|
|
(await makeComposeServiceWithWorkdir('')).config.workingDir,
|
|
).to.equal('');
|
|
});
|
|
});
|
|
|
|
describe('Configuring service networks', () => {
|
|
it('should correctly convert networks from compose to docker format', async () => {
|
|
const makeComposeServiceWithNetwork = async (
|
|
networks: ServiceT.ServiceComposeConfig['networks'],
|
|
) =>
|
|
await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
appUuid: 'deadbeef',
|
|
serviceId: 123456,
|
|
serviceName: 'test',
|
|
composition: {
|
|
networks,
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(
|
|
(
|
|
await makeComposeServiceWithNetwork({
|
|
balena: {
|
|
ipv4Address: '1.2.3.4',
|
|
},
|
|
})
|
|
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
|
).to.deep.equal({
|
|
EndpointsConfig: {
|
|
deadbeef_balena: {
|
|
IPAMConfig: {
|
|
IPv4Address: '1.2.3.4',
|
|
},
|
|
Aliases: ['test'],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
(
|
|
await makeComposeServiceWithNetwork({
|
|
balena: {
|
|
aliases: ['test', '1123'],
|
|
ipv4Address: '1.2.3.4',
|
|
ipv6Address: '5.6.7.8',
|
|
linkLocalIps: ['123.123.123'],
|
|
},
|
|
})
|
|
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
|
).to.deep.equal({
|
|
EndpointsConfig: {
|
|
deadbeef_balena: {
|
|
IPAMConfig: {
|
|
IPv4Address: '1.2.3.4',
|
|
IPv6Address: '5.6.7.8',
|
|
LinkLocalIPs: ['123.123.123'],
|
|
},
|
|
Aliases: ['test', '1123'],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Comparing services', () => {
|
|
describe('Comparing array parameters', () => {
|
|
it('Should correctly compare ordered array parameters', async () => {
|
|
const svc1 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
dns: ['8.8.8.8', '1.1.1.1'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
let svc2 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
dns: ['8.8.8.8', '1.1.1.1'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
expect(svc1.isEqualConfig(svc2, {})).to.be.true;
|
|
|
|
svc2 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
dns: ['1.1.1.1', '8.8.8.8'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
expect(!svc1.isEqualConfig(svc2, {})).to.be.true;
|
|
});
|
|
|
|
it('should correctly compare non-ordered array parameters', async () => {
|
|
const svc1 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
volumes: ['abcdef', 'ghijk'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
let svc2 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
volumes: ['abcdef', 'ghijk'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
expect(svc1.isEqualConfig(svc2, {})).to.be.true;
|
|
|
|
svc2 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
volumes: ['ghijk', 'abcdef'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
expect(svc1.isEqualConfig(svc2, {})).to.be.true;
|
|
});
|
|
|
|
it('should correctly compare both ordered and non-ordered array parameters', async () => {
|
|
const svc1 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
volumes: ['abcdef', 'ghijk'],
|
|
dns: ['8.8.8.8', '1.1.1.1'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
const svc2 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
volumes: ['ghijk', 'abcdef'],
|
|
dns: ['8.8.8.8', '1.1.1.1'],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
expect(svc1.isEqualConfig(svc2, {})).to.be.true;
|
|
});
|
|
});
|
|
|
|
it('should accept that target network aliases are a subset of current network aliases', async () => {
|
|
const svc1 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
networks: {
|
|
test: {
|
|
aliases: ['hello', 'world'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
const svc2 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
networks: {
|
|
test: {
|
|
aliases: ['hello', 'sweet', 'world'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
// All aliases in target service (svc1) are contained in service 2
|
|
expect(svc2.isEqualConfig(svc1, {})).to.be.true;
|
|
// But the opposite is not true
|
|
expect(svc1.isEqualConfig(svc2, {})).to.be.false;
|
|
});
|
|
|
|
it('should accept equal lists of network aliases', async () => {
|
|
const svc1 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
networks: {
|
|
test: {
|
|
aliases: ['hello', 'world'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
const svc2 = await Service.fromComposeObject(
|
|
{
|
|
appId: 1,
|
|
serviceId: 1,
|
|
serviceName: 'test',
|
|
composition: {
|
|
networks: {
|
|
test: {
|
|
aliases: ['hello', 'world'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(svc1.isEqualConfig(svc2, {})).to.be.true;
|
|
expect(svc2.isEqualConfig(svc1, {})).to.be.true;
|
|
});
|
|
});
|
|
|
|
describe('Feature labels', () => {
|
|
describe('io.balena.features.balena-socket', () => {
|
|
it('should mount the socket in the container and set DOCKER_HOST with the proper location', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.balena-socket': '1',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.deep.include.members([
|
|
{
|
|
type: 'bind',
|
|
source: constants.dockerSocket,
|
|
target: constants.containerDockerSocket,
|
|
},
|
|
{
|
|
type: 'bind',
|
|
source: constants.dockerSocket,
|
|
target: constants.dockerSocket,
|
|
},
|
|
{
|
|
type: 'bind',
|
|
source: constants.dockerSocket,
|
|
target: '/var/run/balena.sock',
|
|
},
|
|
]);
|
|
|
|
expect(service.config.environment['DOCKER_HOST']).to.equal(
|
|
`unix://${constants.containerDockerSocket}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Features for mounting host directories (sys, dbus, proc, etc.)', () => {
|
|
it('should add /run/dbus:/host/run/dbus to bind mounts when io.balena.features.dbus is used', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.dbus': '1',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.include.members([
|
|
'/run/dbus:/host/run/dbus',
|
|
]);
|
|
});
|
|
|
|
it('should add `/sys` to the container bind mounts when io.balena.features.sysfs is used', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.sysfs': '1',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.include.members(['/sys:/sys']);
|
|
});
|
|
|
|
it('should add `/proc` to the container bind mounts when io.balena.features.procfs is used', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.procfs': '1',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.include.members(['/proc:/proc']);
|
|
});
|
|
|
|
it('should add `/lib/modules` to the container bind mounts when io.balena.features.kernel-modules is used (if the host path exists)', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.kernel-modules': '1',
|
|
},
|
|
},
|
|
{
|
|
appName: 'test',
|
|
hostPathExists: {
|
|
modules: true,
|
|
},
|
|
} as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.include.members([
|
|
'/lib/modules:/lib/modules',
|
|
]);
|
|
});
|
|
|
|
it('should NOT add `/lib/modules` to the container bind mounts when io.balena.features.kernel-modules is used (if the host path does NOT exist)', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.kernel-modules': '1',
|
|
},
|
|
},
|
|
{
|
|
appName: 'test',
|
|
hostPathExists: {
|
|
modules: false,
|
|
},
|
|
} as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.not.include.members([
|
|
'/lib/modules:/lib/modules',
|
|
]);
|
|
});
|
|
|
|
it('should add `/lib/firmware` to the container bind mounts when io.balena.features.firmware is used (if the host path exists)', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.firmware': '1',
|
|
},
|
|
},
|
|
{
|
|
appName: 'test',
|
|
hostPathExists: {
|
|
firmware: true,
|
|
},
|
|
} as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.include.members([
|
|
'/lib/firmware:/lib/firmware',
|
|
]);
|
|
});
|
|
|
|
it('should NOT add `/lib/firmware` to the container bind mounts when io.balena.features.firmware is used (if the host path does NOT exist)', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123456,
|
|
serviceId: 123456,
|
|
serviceName: 'foobar',
|
|
labels: {
|
|
'io.balena.features.firmware': '1',
|
|
},
|
|
},
|
|
{
|
|
appName: 'test',
|
|
hostPathExists: {
|
|
firmware: false,
|
|
},
|
|
} as any,
|
|
);
|
|
|
|
expect(service.config.volumes).to.not.include.members([
|
|
'/lib/firmware:/lib/firmware',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('io.balena.features.gpu', () => {
|
|
const gpuDeviceRequest = {
|
|
Driver: '',
|
|
DeviceIDs: [],
|
|
Count: 1,
|
|
Capabilities: [['gpu']],
|
|
Options: {},
|
|
};
|
|
it('should add GPU to compose configuration when the feature is set', async () => {
|
|
const s = await Service.fromComposeObject(
|
|
{
|
|
appId: 123,
|
|
serviceId: 123,
|
|
serviceName: 'test',
|
|
labels: {
|
|
'io.balena.features.gpu': '1',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(s.config)
|
|
.to.have.property('deviceRequests')
|
|
.that.deep.equals([gpuDeviceRequest]);
|
|
});
|
|
|
|
it('should obtain the GPU config from docker configuration', () => {
|
|
const mockContainer = createContainer({
|
|
Id: 'deadbeef',
|
|
Name: 'main_431889_572579',
|
|
HostConfig: {
|
|
DeviceRequests: [gpuDeviceRequest],
|
|
},
|
|
Config: {
|
|
Labels: {
|
|
'io.resin.app-id': '1011165',
|
|
'io.resin.architecture': 'armv7hf',
|
|
'io.resin.service-id': '43697',
|
|
'io.resin.service-name': 'main',
|
|
'io.resin.supervised': 'true',
|
|
},
|
|
},
|
|
});
|
|
const s = Service.fromDockerContainer(mockContainer.inspectInfo);
|
|
|
|
expect(s.config)
|
|
.to.have.property('deviceRequests')
|
|
.that.deep.equals([gpuDeviceRequest]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Creating service instances from docker configuration', () => {
|
|
const omitConfigForComparison = (config: ServiceT.ServiceConfig) =>
|
|
_.omit(config, ['running', 'networks']);
|
|
|
|
it('should be equivalent to loading from compose config for simple services', async () => {
|
|
// TODO: improve the readability of this code
|
|
const composeSvc = await Service.fromComposeObject(
|
|
configs.simple.compose,
|
|
configs.simple.imageInfo,
|
|
);
|
|
const dockerSvc = Service.fromDockerContainer(configs.simple.inspect);
|
|
|
|
const composeConfig = omitConfigForComparison(composeSvc.config);
|
|
const dockerConfig = omitConfigForComparison(dockerSvc.config);
|
|
expect(composeConfig).to.deep.equal(dockerConfig);
|
|
|
|
expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true;
|
|
});
|
|
|
|
it('should correctly handle a null entrypoint', async () => {
|
|
const composeSvc = await Service.fromComposeObject(
|
|
configs.entrypoint.compose,
|
|
configs.entrypoint.imageInfo,
|
|
);
|
|
const dockerSvc = Service.fromDockerContainer(configs.entrypoint.inspect);
|
|
|
|
const composeConfig = omitConfigForComparison(composeSvc.config);
|
|
const dockerConfig = omitConfigForComparison(dockerSvc.config);
|
|
expect(composeConfig).to.deep.equal(dockerConfig);
|
|
|
|
expect(dockerSvc.isEqualConfig(composeSvc, {})).to.equals(true);
|
|
});
|
|
|
|
describe('Networks', () => {
|
|
it('should correctly convert Docker format to service format', () => {
|
|
const { inspectInfo } = createContainer({
|
|
Id: 'deadbeef',
|
|
Name: 'main_431889_572579',
|
|
Config: {
|
|
Labels: {
|
|
'io.resin.app-id': '1011165',
|
|
'io.resin.architecture': 'armv7hf',
|
|
'io.resin.service-id': '43697',
|
|
'io.resin.service-name': 'main',
|
|
'io.resin.supervised': 'true',
|
|
},
|
|
},
|
|
});
|
|
|
|
const makeServiceFromDockerWithNetwork = (networks: {
|
|
[name: string]: any;
|
|
}) => {
|
|
return Service.fromDockerContainer({
|
|
...inspectInfo,
|
|
NetworkSettings: {
|
|
...inspectInfo.NetworkSettings,
|
|
Networks: networks,
|
|
},
|
|
});
|
|
};
|
|
|
|
expect(
|
|
makeServiceFromDockerWithNetwork({
|
|
'123456_balena': {
|
|
IPAMConfig: {
|
|
IPv4Address: '1.2.3.4',
|
|
},
|
|
Aliases: [],
|
|
},
|
|
}).config.networks,
|
|
).to.deep.equal({
|
|
'123456_balena': {
|
|
ipv4Address: '1.2.3.4',
|
|
},
|
|
});
|
|
|
|
expect(
|
|
makeServiceFromDockerWithNetwork({
|
|
'123456_balena': {
|
|
IPAMConfig: {
|
|
IPv4Address: '1.2.3.4',
|
|
IPv6Address: '5.6.7.8',
|
|
LinkLocalIps: ['123.123.123'],
|
|
},
|
|
Aliases: ['test', '1123', 'deadbeef'],
|
|
},
|
|
}).config.networks,
|
|
).to.deep.equal({
|
|
'123456_balena': {
|
|
ipv4Address: '1.2.3.4',
|
|
ipv6Address: '5.6.7.8',
|
|
linkLocalIps: ['123.123.123'],
|
|
// The container id got removed from the alias list
|
|
aliases: ['test', '1123'],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Network mode service:', () => {
|
|
const omitConfigForComparison = (config: ServiceT.ServiceConfig) =>
|
|
_.omit(config, ['running', 'networks']);
|
|
|
|
it('should correctly add a depends_on entry for the service', async () => {
|
|
let s = await Service.fromComposeObject(
|
|
{
|
|
appId: '1234',
|
|
serviceName: 'foo',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
composition: {
|
|
network_mode: 'service: test',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(s.dependsOn).to.deep.equal(['test']);
|
|
|
|
s = await Service.fromComposeObject(
|
|
{
|
|
appId: '1234',
|
|
serviceName: 'foo',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
composition: {
|
|
depends_on: ['another_service'],
|
|
network_mode: 'service: test',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(s.dependsOn).to.deep.equal(['another_service', 'test']);
|
|
});
|
|
|
|
it('should correctly convert a network_mode service: to a container:', async () => {
|
|
const s = await Service.fromComposeObject(
|
|
{
|
|
appId: '1234',
|
|
serviceName: 'foo',
|
|
releaseId: 2,
|
|
serviceId: 3,
|
|
imageId: 4,
|
|
composition: {
|
|
network_mode: 'service: test',
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
return expect(
|
|
s.toDockerContainer({
|
|
deviceName: '',
|
|
containerIds: { test: 'abcdef' },
|
|
}),
|
|
)
|
|
.to.have.property('HostConfig')
|
|
.that.has.property('NetworkMode')
|
|
.that.equals('container:abcdef');
|
|
});
|
|
|
|
it('should not cause a container restart if a service: container has not changed', async () => {
|
|
const composeSvc = await Service.fromComposeObject(
|
|
configs.networkModeService.compose,
|
|
configs.networkModeService.imageInfo,
|
|
);
|
|
const dockerSvc = Service.fromDockerContainer(
|
|
configs.networkModeService.inspect,
|
|
);
|
|
|
|
const composeConfig = omitConfigForComparison(composeSvc.config);
|
|
const dockerConfig = omitConfigForComparison(dockerSvc.config);
|
|
expect(composeConfig).to.not.deep.equal(dockerConfig);
|
|
|
|
expect(dockerSvc.isEqualConfig(composeSvc, { test: 'abcdef' })).to.be
|
|
.true;
|
|
});
|
|
|
|
it('should restart a container when its dependent network mode container changes', async () => {
|
|
const composeSvc = await Service.fromComposeObject(
|
|
configs.networkModeService.compose,
|
|
configs.networkModeService.imageInfo,
|
|
);
|
|
const dockerSvc = Service.fromDockerContainer(
|
|
configs.networkModeService.inspect,
|
|
);
|
|
|
|
const composeConfig = omitConfigForComparison(composeSvc.config);
|
|
const dockerConfig = omitConfigForComparison(dockerSvc.config);
|
|
expect(composeConfig).to.not.deep.equal(dockerConfig);
|
|
|
|
return expect(dockerSvc.isEqualConfig(composeSvc, { test: 'qwerty' })).to
|
|
.be.false;
|
|
});
|
|
});
|
|
|
|
describe('Security options', () => {
|
|
it('ignores selinux security options on the target state', async () => {
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId: 123,
|
|
serviceId: 123,
|
|
serviceName: 'test',
|
|
composition: {
|
|
securityOpt: [
|
|
'label=user:USER',
|
|
'label=user:ROLE',
|
|
'seccomp=unconfined',
|
|
],
|
|
},
|
|
},
|
|
{ appName: 'test' } as any,
|
|
);
|
|
|
|
expect(service.config)
|
|
.to.have.property('securityOpt')
|
|
.that.deep.equals(['seccomp=unconfined']);
|
|
});
|
|
|
|
it('ignores selinux security options on the current state', async () => {
|
|
const mockContainer = createContainer({
|
|
Id: 'deadbeef',
|
|
Name: 'main_431889_572579',
|
|
Config: {
|
|
Labels: {
|
|
'io.resin.app-id': '1011165',
|
|
'io.resin.architecture': 'armv7hf',
|
|
'io.resin.service-id': '43697',
|
|
'io.resin.service-name': 'main',
|
|
'io.resin.supervised': 'true',
|
|
},
|
|
},
|
|
HostConfig: {
|
|
SecurityOpt: ['label=disable', 'seccomp=unconfined'],
|
|
},
|
|
});
|
|
const service = Service.fromDockerContainer(
|
|
await mockContainer.inspect(),
|
|
);
|
|
|
|
expect(service.config)
|
|
.to.have.property('securityOpt')
|
|
.that.deep.equals(['seccomp=unconfined']);
|
|
});
|
|
});
|
|
|
|
describe('Long syntax volume configuration', () => {
|
|
it('should generate a docker container config from a compose object (compose -> Service, Service -> container)', async () => {
|
|
/**
|
|
* compose -> Service (fromComposeObject)
|
|
*/
|
|
const appId = 5;
|
|
const serviceName = 'main';
|
|
const longSyntaxVolumes = [
|
|
// Long named volume
|
|
{
|
|
type: 'volume',
|
|
source: 'another',
|
|
target: '/another',
|
|
readOnly: true,
|
|
},
|
|
// Long anonymous volume
|
|
{
|
|
type: 'volume',
|
|
target: '/yet/another',
|
|
},
|
|
{ type: 'bind', source: '/mnt/data', target: '/data' },
|
|
{ type: 'tmpfs', target: '/home/tmp' },
|
|
];
|
|
const service = await Service.fromComposeObject(
|
|
{
|
|
appId,
|
|
serviceName,
|
|
releaseId: 4,
|
|
serviceId: 3,
|
|
imageId: 2,
|
|
composition: {
|
|
volumes: [
|
|
'myvolume:/myvolume',
|
|
'readonly:/readonly:ro',
|
|
'/home/mybind:/mybind',
|
|
'anonymous_volume',
|
|
...longSyntaxVolumes,
|
|
],
|
|
tmpfs: ['/var/tmp'],
|
|
},
|
|
},
|
|
{ appName: 'bar' } as any,
|
|
);
|
|
|
|
// Only tmpfs from composition should be added to config.tmpfs
|
|
expect(service.config.tmpfs).to.deep.equal(['/var/tmp']);
|
|
// config.volumes should include all long syntax and short syntax mounts, excluding binds
|
|
expect(service.config.volumes).to.deep.include.members([
|
|
`${appId}_myvolume:/myvolume`,
|
|
`${appId}_readonly:/readonly:ro`,
|
|
'anonymous_volume',
|
|
...longSyntaxVolumes.filter(({ type }) => type !== 'bind'),
|
|
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/resin`,
|
|
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/balena`,
|
|
]);
|
|
// bind mounts are not allowed
|
|
expect(service.config.volumes).to.not.deep.include.members([
|
|
'/home/mybind:/mybind',
|
|
...longSyntaxVolumes.filter(({ type }) => type === 'bind'),
|
|
]);
|
|
|
|
/**
|
|
* Service -> container (toDockerContainer)
|
|
*/
|
|
// Inject bind mounts (as feature labels would)
|
|
// Bind mounts added under feature labels use the short syntax, except for the engine label
|
|
service.config.volumes.push('/var/log/journal:/var/log/journal:ro');
|
|
service.config.volumes.push({
|
|
type: 'bind',
|
|
source: constants.dockerSocket,
|
|
target: constants.containerDockerSocket,
|
|
} as ServiceT.LongBind);
|
|
|
|
const ctn = service.toDockerContainer({
|
|
deviceName: 'thicc_nucc',
|
|
} as any);
|
|
// Only long syntax volumes should be listed under HostConfig.Mounts.
|
|
// This includes any tmpfs volumes defined using long syntax, consistent
|
|
// with docker-compose's behavior.
|
|
expect(ctn.HostConfig)
|
|
.to.have.property('Mounts')
|
|
.that.deep.includes.members([
|
|
{
|
|
Type: 'volume',
|
|
Source: `${appId}_another`, // Should be namespaced by appId
|
|
Target: '/another',
|
|
ReadOnly: true,
|
|
},
|
|
{ Type: 'tmpfs', Target: '/home/tmp' },
|
|
{
|
|
Type: 'bind',
|
|
Source: constants.dockerSocket,
|
|
Target: constants.containerDockerSocket,
|
|
},
|
|
]);
|
|
|
|
// bind mounts except for the engine feature label should be filtered out
|
|
expect(ctn.HostConfig)
|
|
.to.have.property('Mounts')
|
|
.that.does.not.deep.include.members([
|
|
{ Type: 'bind', Source: '/mnt/data', Target: '/data' },
|
|
]);
|
|
|
|
// Short syntax volumes should be configured as HostConfig.Binds
|
|
expect(ctn.HostConfig)
|
|
.to.have.property('Binds')
|
|
.that.includes.members([
|
|
`${appId}_myvolume:/myvolume`,
|
|
`${appId}_readonly:/readonly:ro`,
|
|
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/resin`,
|
|
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/balena`,
|
|
'/var/log/journal:/var/log/journal:ro',
|
|
]);
|
|
|
|
// Tmpfs volumes defined through compose's service.tmpfs are under HostConfig.Tmpfs.
|
|
// Otherwise tmpfs volumes defined through compose's service.volumes as type: 'tmpfs' are under HostConfig.Mounts.
|
|
expect(ctn.HostConfig).to.have.property('Tmpfs').that.deep.equals({
|
|
'/var/tmp': '',
|
|
});
|
|
});
|
|
|
|
it('should generate a service instance from a docker container (container -> Service)', async () => {
|
|
const appId = 6;
|
|
const mockContainer = createContainer({
|
|
Id: 'deadbeef',
|
|
Name: 'main_123_456_789',
|
|
HostConfig: {
|
|
Binds: [
|
|
`${appId}_test:/test`,
|
|
`${appId}_test2:/test2:ro`,
|
|
'/proc:/proc',
|
|
'/etc/machine-id:/etc/machine-id:ro',
|
|
],
|
|
Tmpfs: {
|
|
'/var/tmp1': '',
|
|
},
|
|
Mounts: [
|
|
{
|
|
Type: 'volume',
|
|
Source: '6_test3',
|
|
Target: '/test3',
|
|
},
|
|
{
|
|
Type: 'tmpfs',
|
|
Target: '/var/tmp2',
|
|
} as any,
|
|
// Dockerode typings require a Source field but tmpfs doesn't require Source
|
|
],
|
|
},
|
|
Config: {
|
|
Volumes: {
|
|
'/var/lib/volume': {},
|
|
},
|
|
Labels: {
|
|
'io.balena.app-id': `${appId}`,
|
|
'io.balena.architecture': 'amd64',
|
|
'io.balena.service-id': '123',
|
|
'io.balena.service-name': 'main',
|
|
'io.balena.supervised': 'true',
|
|
},
|
|
},
|
|
});
|
|
|
|
const service = Service.fromDockerContainer(mockContainer.inspectInfo);
|
|
// service.volumes should combine:
|
|
// - HostConfig.Binds
|
|
// - 'volume'|'tmpfs' types from HostConfig.Mounts
|
|
// - Config.Volumes
|
|
expect(service.config)
|
|
.to.have.property('volumes')
|
|
.that.deep.includes.members([
|
|
`${appId}_test:/test`,
|
|
`${appId}_test2:/test2:ro`,
|
|
'/proc:/proc',
|
|
'/etc/machine-id:/etc/machine-id:ro',
|
|
'/var/lib/volume',
|
|
{
|
|
type: 'volume',
|
|
source: '6_test3',
|
|
target: '/test3',
|
|
},
|
|
{
|
|
type: 'tmpfs',
|
|
target: '/var/tmp2',
|
|
},
|
|
]);
|
|
|
|
// service.tmpfs should only include HostConfig.Tmpfs,
|
|
// 'tmpfs' types defined with long syntax belong to HostConfig.Mounts
|
|
// and therefore are added to service.config.volumes.
|
|
expect(service.config)
|
|
.to.have.property('tmpfs')
|
|
.that.deep.equals(['/var/tmp1']);
|
|
});
|
|
});
|
|
|
|
describe('Service volume types', () => {
|
|
it('should correctly identify short syntax volumes', () => {
|
|
// Short binds
|
|
['/one:/one', '/two:/two:ro', '/three:/three:rw'].forEach((b) => {
|
|
expect(ServiceT.ShortMount.is(b)).to.be.true;
|
|
expect(ServiceT.ShortBind.is(b)).to.be.true;
|
|
expect(ServiceT.ShortAnonymousVolume.is(b)).to.be.false;
|
|
expect(ServiceT.ShortNamedVolume.is(b)).to.be.false;
|
|
});
|
|
// Short anonymous volumes
|
|
['volume', 'another_volume'].forEach((v) => {
|
|
expect(ServiceT.ShortMount.is(v)).to.be.false;
|
|
expect(ServiceT.ShortBind.is(v)).to.be.false;
|
|
expect(ServiceT.ShortAnonymousVolume.is(v)).to.be.true;
|
|
expect(ServiceT.ShortNamedVolume.is(v)).to.be.false;
|
|
});
|
|
// Short named volumes
|
|
[
|
|
'another_one:/another/one',
|
|
'yet_another:/yet/another:ro',
|
|
'final:/final:rw',
|
|
].forEach((v) => {
|
|
expect(ServiceT.ShortMount.is(v)).to.be.true;
|
|
expect(ServiceT.ShortBind.is(v)).to.be.false;
|
|
expect(ServiceT.ShortAnonymousVolume.is(v)).to.be.false;
|
|
expect(ServiceT.ShortNamedVolume.is(v)).to.be.true;
|
|
});
|
|
});
|
|
|
|
it('should correctly identify long syntax volumes', () => {
|
|
// For all the following examples where optionals are defined, only the option with key equal to the type
|
|
// will be applied to the resulting volume, but the other options shouldn't cause the type check to return false.
|
|
// For example, for a definition with { type: volume }, only { volume: { nocopy: boolean }} option will apply.
|
|
const longAnonymousVols = [
|
|
{ type: 'volume', target: '/one' },
|
|
{ type: 'volume', target: '/two', readOnly: true },
|
|
{ type: 'volume', target: '/three', volume: { nocopy: true } },
|
|
{ type: 'volume', target: '/four', bind: { propagation: 'slave' } },
|
|
{ type: 'volume', target: '/five', tmpfs: { size: 200 } },
|
|
];
|
|
longAnonymousVols.forEach((v) => {
|
|
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.true;
|
|
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongBind.is(v)).to.be.false;
|
|
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
|
});
|
|
|
|
const longNamedVols = [
|
|
{ type: 'volume', source: 'one', target: '/one' },
|
|
{ type: 'volume', source: 'two', target: '/two', readOnly: false },
|
|
{
|
|
type: 'volume',
|
|
source: 'three',
|
|
target: '/three',
|
|
volume: { nocopy: false },
|
|
},
|
|
{
|
|
type: 'volume',
|
|
source: 'four',
|
|
target: '/four',
|
|
bind: { propagation: 'slave' },
|
|
},
|
|
{
|
|
type: 'volume',
|
|
source: 'five',
|
|
target: '/five',
|
|
tmpfs: { size: 200 },
|
|
},
|
|
];
|
|
longNamedVols.forEach((v) => {
|
|
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongNamedVolume.is(v)).to.be.true;
|
|
expect(ServiceT.LongBind.is(v)).to.be.false;
|
|
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
|
});
|
|
|
|
const longBinds = [
|
|
{ type: 'bind', source: '/one', target: '/one' },
|
|
{ type: 'bind', source: '/two', target: '/two', readOnly: true },
|
|
{
|
|
type: 'bind',
|
|
source: '/three',
|
|
target: '/three',
|
|
volume: { nocopy: false },
|
|
},
|
|
{
|
|
type: 'bind',
|
|
source: '/four',
|
|
target: '/four',
|
|
bind: { propagation: 'slave' },
|
|
},
|
|
{
|
|
type: 'bind',
|
|
source: '/five',
|
|
target: '/five',
|
|
tmpfs: { size: 200 },
|
|
},
|
|
];
|
|
longBinds.forEach((v) => {
|
|
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongBind.is(v)).to.be.true;
|
|
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
|
});
|
|
|
|
const longTmpfs = [
|
|
{ type: 'tmpfs', target: '/var/tmp' },
|
|
{ type: 'tmpfs', target: '/var/tmp2', readOnly: false },
|
|
{ type: 'tmpfs', target: '/var/tmp3', volume: { nocopy: false } },
|
|
{ type: 'tmpfs', target: '/var/tmp4', bind: { propagation: 'slave' } },
|
|
{ type: 'tmpfs', target: '/var/tmp4', tmpfs: { size: 200 } },
|
|
];
|
|
longTmpfs.forEach((v) => {
|
|
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongBind.is(v)).to.be.false;
|
|
expect(ServiceT.LongTmpfs.is(v)).to.be.true;
|
|
});
|
|
|
|
// All of the following volume definitions are not allowed by docker-compose
|
|
const invalids = [
|
|
// bind without source
|
|
{ type: 'bind', target: '/test' },
|
|
// bind with source that's not an absolute path
|
|
{ type: 'bind', source: 'not_a_bind', target: '/bind' },
|
|
// tmpfs with source
|
|
{ type: 'tmpfs', source: '/var/tmp', target: '/home/tmp' },
|
|
// Other types besides volume, tmpfs, or bind
|
|
{ type: 'invalid', source: 'test', target: '/test2' },
|
|
];
|
|
invalids.forEach((v) => {
|
|
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
|
expect(ServiceT.LongBind.is(v)).to.be.false;
|
|
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
|
});
|
|
});
|
|
});
|
|
});
|