balena-supervisor/test/04-service.spec.ts
Robert Günzler ae646a07ec Add label to expose gpu to container
In the absence of an upstream implementation of the DeviceRequest API introduced
as part of Docker API v1.40 we roll our own using a feature label.

As per my comment in the code, we fall back to the default behavior of
docker cli's `--gpu` and request single device with the `gpu` capabilty.
The only implementation at the moment is the NVIDIA driver; here:
https://github.com/balena-os/balena-engine/blob/master/daemon/nvidia_linux.go

Background on the composefile implementation:
https://github.com/compose-spec/compose-spec/issues/74
https://github.com/docker/compose/issues/6691

Change-type: patch
Connects-to: https://github.com/balena-os/balena-jetson/pull/75
Signed-off-by: Robert Günzler <robertg@balena.io>
2020-06-11 14:06:16 +02:00

731 lines
18 KiB
TypeScript

import * as _ from 'lodash';
import ChaiConfig = require('./lib/chai-config');
const { assert, expect } = ChaiConfig;
import Service from '../src/compose/service';
import {
ServiceComposeConfig,
ServiceConfig,
} from '../src/compose/types/service';
const configs = {
simple: {
compose: require('./data/docker-states/simple/compose.json'),
imageInfo: require('./data/docker-states/simple/imageInfo.json'),
inspect: require('./data/docker-states/simple/inspect.json'),
},
entrypoint: {
compose: require('./data/docker-states/entrypoint/compose.json'),
imageInfo: require('./data/docker-states/entrypoint/imageInfo.json'),
inspect: require('./data/docker-states/entrypoint/inspect.json'),
},
networkModeService: {
compose: require('./data/docker-states/network-mode-service/compose.json'),
imageInfo: require('./data/docker-states/network-mode-service/imageInfo.json'),
inspect: require('./data/docker-states/network-mode-service/inspect.json'),
},
};
describe('compose/service', () => {
it('extends environment variables properly', () => {
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',
releaseId: 2,
serviceId: 3,
imageId: 4,
serviceName: 'serviceName',
environment: {
FOO: 'bar',
A_VARIABLE: 'ITS_VALUE',
},
};
const s = 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_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_SUPERVISOR_VERSION: 'v1.0.0',
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_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_SUPERVISOR_VERSION: 'v1.0.0',
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', () => {
const s = 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', () => {
const s = Service.fromComposeObject(
{
appId: '1234',
serviceName: 'foo',
releaseId: 2,
serviceId: 3,
imageId: 4,
expose: [1000, '243/udp'],
ports: ['2344', '2345:2354', '2346:2367/udp'],
},
{
imageInfo: {
Config: {
ExposedPorts: {
'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',
},
],
});
expect(ports.exposedPorts).to.deep.equal({
'1000/tcp': {},
'243/udp': {},
'2344/tcp': {},
'2354/tcp': {},
'2367/udp': {},
'53/tcp': {},
'53/udp': {},
});
});
it('correctly handles port ranges', () => {
const s = Service.fromComposeObject(
{
appId: '1234',
serviceName: 'foo',
releaseId: 2,
serviceId: 3,
imageId: 4,
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({
'1000/tcp': {},
'2000/tcp': {},
'2001/tcp': {},
'2002/tcp': {},
'2003/tcp': {},
'243/udp': {},
});
});
it('should correctly handle large port ranges', function () {
this.timeout(60000);
const s = Service.fromComposeObject(
{
appId: '1234',
serviceName: 'foo',
releaseId: 2,
serviceId: 3,
imageId: 4,
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 report implied exposed ports from portMappings', () => {
const service = Service.fromComposeObject(
{
appId: 123456,
serviceId: 123456,
serviceName: 'test',
ports: ['80:80', '100:100'],
},
{ appName: 'test' } as any,
);
expect(service.config)
.to.have.property('expose')
.that.deep.equals(['80/tcp', '100/tcp']);
});
it('should correctly handle spaces in volume definitions', () => {
const service = Service.fromComposeObject(
{
appId: 123,
serviceId: 123,
serviceName: 'test',
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([
'123_vol1:vol2',
'123_vol3:/usr/src/app',
'123_vol4:/usr/src/app',
'123_vol5:vol6',
'/tmp/balena-supervisor/services/123/test:/tmp/resin',
'/tmp/balena-supervisor/services/123/test:/tmp/balena',
]);
});
describe('Ordered array parameters', () => {
it('Should correctly compare ordered array parameters', () => {
const svc1 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
dns: ['8.8.8.8', '1.1.1.1'],
},
{ appName: 'test' } as any,
);
let svc2 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
dns: ['8.8.8.8', '1.1.1.1'],
},
{ appName: 'test' } as any,
);
assert(svc1.isEqualConfig(svc2, {}));
svc2 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
dns: ['1.1.1.1', '8.8.8.8'],
},
{ appName: 'test' } as any,
);
assert(!svc1.isEqualConfig(svc2, {}));
});
it('should correctly compare non-ordered array parameters', () => {
const svc1 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
volumes: ['abcdef', 'ghijk'],
},
{ appName: 'test' } as any,
);
let svc2 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
volumes: ['abcdef', 'ghijk'],
},
{ appName: 'test' } as any,
);
assert(svc1.isEqualConfig(svc2, {}));
svc2 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
volumes: ['ghijk', 'abcdef'],
},
{ appName: 'test' } as any,
);
assert(svc1.isEqualConfig(svc2, {}));
});
it('should correctly compare both ordered and non-ordered array parameters', () => {
const svc1 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
volumes: ['abcdef', 'ghijk'],
dns: ['8.8.8.8', '1.1.1.1'],
},
{ appName: 'test' } as any,
);
const svc2 = Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
volumes: ['ghijk', 'abcdef'],
dns: ['8.8.8.8', '1.1.1.1'],
},
{ appName: 'test' } as any,
);
assert(svc1.isEqualConfig(svc2, {}));
});
});
describe('parseMemoryNumber()', () => {
const makeComposeServiceWithLimit = (memLimit?: string | number) =>
Service.fromComposeObject(
{
appId: 123456,
serviceId: 123456,
serviceName: 'foobar',
mem_limit: memLimit,
},
{ appName: 'test' } as any,
);
it('should correctly parse memory number strings without a unit', () =>
expect(makeComposeServiceWithLimit('64').config.memLimit).to.equal(64));
it('should correctly apply the default value', () =>
expect(makeComposeServiceWithLimit(undefined).config.memLimit).to.equal(
0,
));
it('should correctly support parsing numbers as memory limits', () =>
expect(makeComposeServiceWithLimit(64).config.memLimit).to.equal(64));
it('should correctly parse memory number strings that use a byte unit', () => {
expect(makeComposeServiceWithLimit('64b').config.memLimit).to.equal(64);
expect(makeComposeServiceWithLimit('64B').config.memLimit).to.equal(64);
});
it('should correctly parse memory number strings that use a kilobyte unit', () => {
expect(makeComposeServiceWithLimit('64k').config.memLimit).to.equal(
65536,
);
expect(makeComposeServiceWithLimit('64K').config.memLimit).to.equal(
65536,
);
expect(makeComposeServiceWithLimit('64kb').config.memLimit).to.equal(
65536,
);
expect(makeComposeServiceWithLimit('64Kb').config.memLimit).to.equal(
65536,
);
});
it('should correctly parse memory number strings that use a megabyte unit', () => {
expect(makeComposeServiceWithLimit('64m').config.memLimit).to.equal(
67108864,
);
expect(makeComposeServiceWithLimit('64M').config.memLimit).to.equal(
67108864,
);
expect(makeComposeServiceWithLimit('64mb').config.memLimit).to.equal(
67108864,
);
expect(makeComposeServiceWithLimit('64Mb').config.memLimit).to.equal(
67108864,
);
});
it('should correctly parse memory number strings that use a gigabyte unit', () => {
expect(makeComposeServiceWithLimit('64g').config.memLimit).to.equal(
68719476736,
);
expect(makeComposeServiceWithLimit('64G').config.memLimit).to.equal(
68719476736,
);
expect(makeComposeServiceWithLimit('64gb').config.memLimit).to.equal(
68719476736,
);
expect(makeComposeServiceWithLimit('64Gb').config.memLimit).to.equal(
68719476736,
);
});
});
describe('getWorkingDir', () => {
const makeComposeServiceWithWorkdir = (workdir?: string) =>
Service.fromComposeObject(
{
appId: 123456,
serviceId: 123456,
serviceName: 'foobar',
workingDir: workdir,
},
{ appName: 'test' } as any,
);
it('should remove a trailing slash', () => {
expect(
makeComposeServiceWithWorkdir('/usr/src/app/').config.workingDir,
).to.equal('/usr/src/app');
expect(makeComposeServiceWithWorkdir('/').config.workingDir).to.equal(
'/',
);
expect(
makeComposeServiceWithWorkdir('/usr/src/app').config.workingDir,
).to.equal('/usr/src/app');
expect(makeComposeServiceWithWorkdir('').config.workingDir).to.equal('');
});
});
describe('io.balena.features.gpu: Docker <-> Compose config', () => {
const gpuDeviceRequest = {
Count: 1,
Capabilities: [['gpu']],
};
it('should succeed from compose object', () => {
const s = 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 succeed from docker container', () => {
const dockerCfg = _.cloneDeep(
require('./data/docker-states/simple/inspect.json'),
);
dockerCfg.HostConfig.DeviceRequests = [gpuDeviceRequest];
const s = Service.fromDockerContainer(dockerCfg);
expect(s.config)
.to.have.property('deviceRequests')
.that.deep.equals([gpuDeviceRequest]);
});
});
describe('Docker <-> Compose config', () => {
const omitConfigForComparison = (config: ServiceConfig) =>
_.omit(config, ['running', 'networks']);
it('should be identical when converting a simple service', () => {
const composeSvc = 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 correct convert formats with a null entrypoint', () => {
const composeSvc = 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 networks from compose to docker format', () => {
const makeComposeServiceWithNetwork = (
networks: ServiceComposeConfig['networks'],
) =>
Service.fromComposeObject(
{
appId: 123456,
serviceId: 123456,
serviceName: 'test',
networks,
},
{ appName: 'test' } as any,
);
expect(
makeComposeServiceWithNetwork({
balena: {
ipv4Address: '1.2.3.4',
},
}).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
).to.deep.equal({
EndpointsConfig: {
'123456_balena': {
IPAMConfig: {
IPv4Address: '1.2.3.4',
},
Aliases: ['test'],
},
},
});
expect(
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: {
'123456_balena': {
IPAMConfig: {
IPv4Address: '1.2.3.4',
IPv6Address: '5.6.7.8',
LinkLocalIPs: ['123.123.123'],
},
Aliases: ['test', '1123'],
},
},
});
});
it('should correctly convert Docker format to service format', () => {
const dockerCfg = require('./data/docker-states/simple/inspect.json');
const makeServiceFromDockerWithNetwork = (networks: {
[name: string]: any;
}) => {
const newConfig = _.cloneDeep(dockerCfg);
newConfig.NetworkSettings = {
Networks: networks,
};
return Service.fromDockerContainer(newConfig);
};
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'],
},
}).config.networks,
).to.deep.equal({
'123456_balena': {
ipv4Address: '1.2.3.4',
ipv6Address: '5.6.7.8',
linkLocalIps: ['123.123.123'],
aliases: ['test', '1123'],
},
});
});
});
return describe('Network mode=service:', () => {
it('should correctly add a depends_on entry for the service', () => {
let s = Service.fromComposeObject(
{
appId: '1234',
serviceName: 'foo',
releaseId: 2,
serviceId: 3,
imageId: 4,
network_mode: 'service: test',
},
{ appName: 'test' } as any,
);
expect(s.dependsOn).to.deep.equal(['test']);
s = Service.fromComposeObject(
{
appId: '1234',
serviceName: 'foo',
releaseId: 2,
serviceId: 3,
imageId: 4,
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:', () => {
const s = Service.fromComposeObject(
{
appId: '1234',
serviceName: 'foo',
releaseId: 2,
serviceId: 3,
imageId: 4,
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', () => {
const composeSvc = 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', () => {
const composeSvc = 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;
});
});
});
});