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; }); }); }); });