diff --git a/package-lock.json b/package-lock.json index 4add29a4..f3ca0692 100644 --- a/package-lock.json +++ b/package-lock.json @@ -471,9 +471,9 @@ "dev": true }, "@types/dockerode": { - "version": "2.5.28", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.28.tgz", - "integrity": "sha512-YHs025G2P+h+CDlmub33SY/CvGWCHpHATbgq73wykyvnZZjL0Iq+w+Vv214w3cac5McimFhsVecDJBNgPuMo4Q==", + "version": "2.5.34", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.34.tgz", + "integrity": "sha512-LcbLGcvcBwBAvjH9UrUI+4qotY+A5WCer5r43DR5XHv2ZIEByNXFdPLo1XxR+v/BjkGjlggW8qUiXuVEhqfkpA==", "dev": true, "requires": { "@types/node": "*" diff --git a/package.json b/package.json index 49fcd298..5d50de93 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@types/common-tags": "^1.8.0", "@types/copy-webpack-plugin": "^6.0.0", "@types/dbus": "^1.0.0", - "@types/dockerode": "^2.5.28", + "@types/dockerode": "^2.5.34", "@types/event-stream": "^3.3.34", "@types/express": "^4.17.3", "@types/lockfile": "^1.0.1", diff --git a/src/compose/network.ts b/src/compose/network.ts index cac81d0e..06475673 100644 --- a/src/compose/network.ts +++ b/src/compose/network.ts @@ -1,20 +1,13 @@ import * as Bluebird from 'bluebird'; import * as _ from 'lodash'; +import * as dockerode from 'dockerode'; import { docker } from '../lib/docker-utils'; -import { InvalidAppIdError } from '../lib/errors'; import logTypes = require('../lib/log-types'); -import { checkInt } from '../lib/validation'; import * as logger from '../logger'; import * as ComposeUtils from './utils'; -import { - ComposeNetworkConfig, - DockerIPAMConfig, - DockerNetworkConfig, - NetworkConfig, - NetworkInspect, -} from './types/network'; +import { ComposeNetworkConfig, NetworkConfig } from './types/network'; import { InvalidNetworkConfigurationError, @@ -28,50 +21,42 @@ export class Network { private constructor() {} - public static fromDockerNetwork(network: NetworkInspect): Network { + public static fromDockerNetwork( + network: dockerode.NetworkInspectInfo, + ): Network { const ret = new Network(); const match = network.Name.match(/^([0-9]+)_(.+)$/); if (match == null) { throw new InvalidNetworkNameError(network.Name); } - const appId = checkInt(match[1]) || null; - if (!appId) { - throw new InvalidAppIdError(match[1]); - } + + // If the regex match succeeds `match[1]` should be a number + const appId = parseInt(match[1], 10); ret.appId = appId; ret.name = match[2]; + + const config = network.IPAM?.Config || []; + ret.config = { driver: network.Driver, ipam: { - driver: network.IPAM.Driver, - config: _.map(network.IPAM.Config, (conf) => { - const newConf: NetworkConfig['ipam']['config'][0] = {}; - - if (conf.Subnet != null) { - newConf.subnet = conf.Subnet; - } - if (conf.Gateway != null) { - newConf.gateway = conf.Gateway; - } - if (conf.IPRange != null) { - newConf.ipRange = conf.IPRange; - } - if (conf.AuxAddress != null) { - newConf.auxAddress = conf.AuxAddress; - } - - return newConf; - }), - options: network.IPAM.Options == null ? {} : network.IPAM.Options, + driver: network.IPAM?.Driver ?? 'default', + config: config.map((conf) => ({ + ...(conf.Subnet && { subnet: conf.Subnet }), + ...(conf.Gateway && { gateway: conf.Gateway }), + ...(conf.IPRange && { ipRange: conf.IPRange }), + ...(conf.AuxAddress && { auxAddress: conf.AuxAddress }), + })), + options: network.IPAM?.Options ?? {}, }, enableIPv6: network.EnableIPv6, internal: network.Internal, - labels: _.omit(ComposeUtils.normalizeLabels(network.Labels), [ + labels: _.omit(ComposeUtils.normalizeLabels(network.Labels ?? {}), [ 'io.balena.supervised', ]), - options: network.Options, + options: network.Options ?? {}, }; return ret; @@ -90,19 +75,26 @@ export class Network { Network.validateComposeConfig(network); - const ipam: Partial = network.ipam || {}; - if (ipam.driver == null) { - ipam.driver = 'default'; - } - if (ipam.config == null) { - ipam.config = []; - } - if (ipam.options == null) { - ipam.options = {}; - } + const ipam = network.ipam ?? {}; + const driver = ipam.driver ?? 'default'; + const config = ipam.config ?? []; + const options = ipam.options ?? {}; + net.config = { driver: network.driver || 'bridge', - ipam: ipam as ComposeNetworkConfig['ipam'], + ipam: { + driver, + config: config.map((conf) => ({ + ...(conf.subnet && { subnet: conf.subnet }), + ...(conf.gateway && { gateway: conf.gateway }), + ...(conf.ip_range && { ipRange: conf.ip_range }), + // TODO: compose defines aux_addresses as a dict but dockerode and the + // engine accepts a single AuxAddress. What happens when multiple addresses + // are given? + ...(conf.aux_addresses && { auxAddress: conf.aux_addresses }), + })) as ComposeNetworkConfig['ipam']['config'], + options, + }, enableIPv6: network.enable_ipv6 || false, internal: network.internal || false, labels: network.labels || {}, @@ -130,31 +122,23 @@ export class Network { network: { name: this.name }, }); - return await docker.createNetwork(this.toDockerConfig()); + await docker.createNetwork(this.toDockerConfig()); } - public toDockerConfig(): DockerNetworkConfig { + public toDockerConfig(): dockerode.NetworkCreateOptions { return { Name: Network.generateDockerName(this.appId, this.name), Driver: this.config.driver, CheckDuplicate: true, IPAM: { Driver: this.config.ipam.driver, - Config: _.map(this.config.ipam.config, (conf) => { - const ipamConf: DockerIPAMConfig = {}; - if (conf.subnet != null) { - ipamConf.Subnet = conf.subnet; - } - if (conf.gateway != null) { - ipamConf.Gateway = conf.gateway; - } - if (conf.auxAddress != null) { - ipamConf.AuxAddress = conf.auxAddress; - } - if (conf.ipRange != null) { - ipamConf.IPRange = conf.ipRange; - } - return ipamConf; + Config: this.config.ipam.config.map((conf) => { + return { + ...(conf.subnet && { Subnet: conf.subnet }), + ...(conf.gateway && { Gateway: conf.gateway }), + ...(conf.auxAddress && { AuxAddress: conf.auxAddress }), + ...(conf.ipRange && { IPRange: conf.ipRange }), + }; }), Options: this.config.ipam.options, }, diff --git a/src/compose/types/network.ts b/src/compose/types/network.ts index e3cf3f29..5ffb0155 100644 --- a/src/compose/types/network.ts +++ b/src/compose/types/network.ts @@ -1,39 +1,3 @@ -// It appears the dockerode typings are incomplete, -// extend here for now. -// TODO: Upstream these to definitelytyped -export interface NetworkInspect { - Name: string; - Id: string; - Created: string; - Scope: string; - Driver: string; - EnableIPv6: boolean; - IPAM: { - Driver: string; - Options: null | { [optName: string]: string }; - Config: Array<{ - Subnet: string; - Gateway: string; - IPRange?: string; - AuxAddress?: string; - }>; - }; - Internal: boolean; - Attachable: boolean; - Ingress: boolean; - Containers: { - [containerId: string]: { - Name: string; - EndpointID: string; - MacAddress: string; - IPv4Address: string; - IPv6Address: string; - }; - }; - Options: { [optName: string]: string }; - Labels: { [labelName: string]: string }; -} - export interface ComposeNetworkConfig { driver: string; driver_opts: Dictionary; @@ -70,27 +34,3 @@ export interface NetworkConfig { labels: { [labelName: string]: string }; options: { [optName: string]: string }; } - -export interface DockerIPAMConfig { - Subnet?: string; - IPRange?: string; - Gateway?: string; - AuxAddress?: string; -} - -export interface DockerNetworkConfig { - Name: string; - Driver?: string; - CheckDuplicate: boolean; - IPAM?: { - Driver?: string; - Config?: DockerIPAMConfig[]; - Options?: Dictionary; - }; - Internal?: boolean; - Attachable?: boolean; - Ingress?: boolean; - Options?: Dictionary; - Labels?: Dictionary; - EnableIPv6?: boolean; -} diff --git a/test/17-compose-network.spec.ts b/test/17-compose-network.spec.ts deleted file mode 100644 index 23c912d9..00000000 --- a/test/17-compose-network.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { expect } from './lib/chai-config'; -import { Network } from '../src/compose/network'; - -describe('compose/network', function () { - describe('compose config -> internal config', function () { - it('should convert a compose configuration to an internal representation', function () { - const network = Network.fromComposeObject( - 'test', - 123, - { - driver: 'bridge', - ipam: { - driver: 'default', - config: [ - { - subnet: '172.25.0.0/25', - gateway: '172.25.0.1', - }, - ], - }, - }, - // @ts-ignore ignore passing nulls instead of actual objects - { logger: null, docker: null }, - ); - - expect(network.config).to.deep.equal({ - driver: 'bridge', - ipam: { - driver: 'default', - config: [ - { - subnet: '172.25.0.0/25', - gateway: '172.25.0.1', - }, - ], - options: {}, - }, - enableIPv6: false, - internal: false, - labels: {}, - options: {}, - }); - }); - - it('should handle an incomplete ipam configuration', function () { - const network = Network.fromComposeObject( - 'test', - 123, - { - ipam: { - config: [ - { - subnet: '172.25.0.0/25', - gateway: '172.25.0.1', - }, - ], - }, - }, - // @ts-ignore ignore passing nulls instead of actual objects - { logger: null, docker: null }, - ); - - expect(network.config).to.deep.equal({ - driver: 'bridge', - enableIPv6: false, - internal: false, - labels: {}, - options: {}, - ipam: { - driver: 'default', - options: {}, - config: [ - { - subnet: '172.25.0.0/25', - gateway: '172.25.0.1', - }, - ], - }, - }); - }); - }); - - describe('internal config -> docker config', () => - it('should convert an internal representation to a docker representation', function () { - const network = Network.fromComposeObject( - 'test', - 123, - { - driver: 'bridge', - ipam: { - driver: 'default', - config: [ - { - subnet: '172.25.0.0/25', - gateway: '172.25.0.1', - }, - ], - }, - }, - // @ts-ignore ignore passing nulls instead of actual objects - { logger: null, docker: null }, - ); - - expect(network.toDockerConfig()).to.deep.equal({ - Name: '123_test', - Driver: 'bridge', - CheckDuplicate: true, - IPAM: { - Driver: 'default', - Config: [ - { - Subnet: '172.25.0.0/25', - Gateway: '172.25.0.1', - }, - ], - Options: {}, - }, - EnableIPv6: false, - Internal: false, - Labels: { - 'io.balena.supervised': 'true', - }, - }); - })); -}); diff --git a/test/41-device-api-v1.spec.ts b/test/41-device-api-v1.spec.ts index 5c338920..101e7120 100644 --- a/test/41-device-api-v1.spec.ts +++ b/test/41-device-api-v1.spec.ts @@ -73,6 +73,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { afterEach(() => { // Clear Dockerode actions recorded for each test mockedDockerode.resetHistory(); + appMock.unmockAll(); }); before(async () => { @@ -120,7 +121,6 @@ describe('SupervisorAPI [V1 Endpoints]', () => { healthCheckStubs.forEach((hc) => hc.restore()); // Remove any test data generated await mockedAPI.cleanUp(); - appMock.unmockAll(); targetStateCacheMock.restore(); loggerStub.restore(); }); @@ -213,7 +213,6 @@ describe('SupervisorAPI [V1 Endpoints]', () => { describe('GET /v1/apps/:appId', () => { it('does not return information for an application when there is more than 1 container', async () => { - // Every test case in this suite has a 3 service release mocked so just make the request await request .get('/v1/apps/2') .set('Accept', 'application/json') @@ -236,32 +235,39 @@ describe('SupervisorAPI [V1 Endpoints]', () => { }); appMock.mockManagers([container], [], []); appMock.mockImages([], false, [image]); - // Make request - await request - .get('/v1/apps/2') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(sampleResponses.V1.GET['/apps/2'].statusCode) - .expect('Content-Type', /json/) - .then((response) => { - expect(response.body).to.deep.equal( - sampleResponses.V1.GET['/apps/2'].body, - ); - }); + await mockedDockerode.testWithData( + { containers: [container], images: [image] }, + async () => { + // Make request + await request + .get('/v1/apps/2') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(sampleResponses.V1.GET['/apps/2'].statusCode) + .expect('Content-Type', /json/) + .then((response) => { + expect(response.body).to.deep.equal( + sampleResponses.V1.GET['/apps/2'].body, + ); + }); + }, + ); }); }); describe('POST /v1/apps/:appId/stop', () => { it('does not allow stopping an application when there is more than 1 container', async () => { // Every test case in this suite has a 3 service release mocked so just make the request - await request - .post('/v1/apps/2/stop') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect( - sampleResponses.V1.GET['/apps/2/stop [Multiple containers running]'] - .statusCode, - ); + await mockedDockerode.testWithData({ containers, images }, async () => { + await request + .post('/v1/apps/2/stop') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect( + sampleResponses.V1.GET['/apps/2/stop [Multiple containers running]'] + .statusCode, + ); + }); }); it('stops a SPECIFIC application and returns a containerId', async () => { @@ -298,11 +304,13 @@ describe('SupervisorAPI [V1 Endpoints]', () => { describe('POST /v1/apps/:appId/start', () => { it('does not allow starting an application when there is more than 1 container', async () => { // Every test case in this suite has a 3 service release mocked so just make the request - await request - .post('/v1/apps/2/start') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(400); + await mockedDockerode.testWithData({ containers, images }, async () => { + await request + .post('/v1/apps/2/start') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(400); + }); }); it('starts a SPECIFIC application and returns a containerId', async () => { @@ -387,14 +395,19 @@ describe('SupervisorAPI [V1 Endpoints]', () => { appMock.mockManagers([container], [], []); appMock.mockImages([], false, [image]); - const response = await request - .post('/v1/reboot') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(202); + await mockedDockerode.testWithData( + { containers: [container], images: [image] }, + async () => { + const response = await request + .post('/v1/reboot') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(202); - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(rebootMock).to.have.been.calledOnce; + expect(response.body).to.have.property('Data').that.is.not.empty; + expect(rebootMock).to.have.been.calledOnce; + }, + ); }); it('should return 423 and reject the reboot if no locks are set', async () => { @@ -417,15 +430,20 @@ describe('SupervisorAPI [V1 Endpoints]', () => { appMock.mockManagers([container], [], []); appMock.mockImages([], false, [image]); - const response = await request - .post('/v1/reboot') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(423); + await mockedDockerode.testWithData( + { containers: [container], images: [image] }, + async () => { + const response = await request + .post('/v1/reboot') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(423); - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Error').that.is.not.empty; - expect(rebootMock).to.not.have.been.called; + expect(updateLock.lock).to.be.calledOnce; + expect(response.body).to.have.property('Error').that.is.not.empty; + expect(rebootMock).to.not.have.been.called; + }, + ); (updateLock.lock as SinonStub).restore(); }); @@ -450,16 +468,21 @@ describe('SupervisorAPI [V1 Endpoints]', () => { appMock.mockManagers([container], [], []); appMock.mockImages([], false, [image]); - const response = await request - .post('/v1/reboot') - .send({ force: true }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(202); + await mockedDockerode.testWithData( + { containers: [container], images: [image] }, + async () => { + const response = await request + .post('/v1/reboot') + .send({ force: true }) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(202); - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(rebootMock).to.have.been.calledOnce; + expect(updateLock.lock).to.be.calledOnce; + expect(response.body).to.have.property('Data').that.is.not.empty; + expect(rebootMock).to.have.been.calledOnce; + }, + ); (updateLock.lock as SinonStub).restore(); }); @@ -488,14 +511,19 @@ describe('SupervisorAPI [V1 Endpoints]', () => { appMock.mockManagers([container], [], []); appMock.mockImages([], false, [image]); - const response = await request - .post('/v1/shutdown') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(202); + await mockedDockerode.testWithData( + { containers: [container], images: [image] }, + async () => { + const response = await request + .post('/v1/shutdown') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(202); - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(shutdownMock).to.have.been.calledOnce; + expect(response.body).to.have.property('Data').that.is.not.empty; + expect(shutdownMock).to.have.been.calledOnce; + }, + ); shutdownMock.resetHistory(); }); @@ -520,15 +548,20 @@ describe('SupervisorAPI [V1 Endpoints]', () => { appMock.mockManagers([container], [], []); appMock.mockImages([], false, [image]); - const response = await request - .post('/v1/shutdown') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(423); + await mockedDockerode.testWithData( + { containers: [container], images: [image] }, + async () => { + const response = await request + .post('/v1/shutdown') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(423); - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Error').that.is.not.empty; - expect(shutdownMock).to.not.have.been.called; + expect(updateLock.lock).to.be.calledOnce; + expect(response.body).to.have.property('Error').that.is.not.empty; + expect(shutdownMock).to.not.have.been.called; + }, + ); (updateLock.lock as SinonStub).restore(); }); @@ -553,16 +586,21 @@ describe('SupervisorAPI [V1 Endpoints]', () => { appMock.mockManagers([container], [], []); appMock.mockImages([], false, [image]); - const response = await request - .post('/v1/shutdown') - .send({ force: true }) - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) - .expect(202); + await mockedDockerode.testWithData( + { containers: [container], images: [image] }, + async () => { + const response = await request + .post('/v1/shutdown') + .send({ force: true }) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(202); - expect(updateLock.lock).to.be.calledOnce; - expect(response.body).to.have.property('Data').that.is.not.empty; - expect(shutdownMock).to.have.been.calledOnce; + expect(updateLock.lock).to.be.calledOnce; + expect(response.body).to.have.property('Data').that.is.not.empty; + expect(shutdownMock).to.have.been.calledOnce; + }, + ); (updateLock.lock as SinonStub).restore(); }); diff --git a/test/42-device-api-v2.spec.ts b/test/42-device-api-v2.spec.ts index bca3fbd7..ae06bd49 100644 --- a/test/42-device-api-v2.spec.ts +++ b/test/42-device-api-v2.spec.ts @@ -361,13 +361,15 @@ describe('SupervisorAPI [V2 Endpoints]', () => { }); it('should return 404 for an unknown service', async () => { - await request - .post(`/v2/applications/1658654/start-service?apikey=${appScopedKey}`) - .send({ serviceName: 'unknown' }) - .set('Content-type', 'application/json') - .expect(404); + await mockedDockerode.testWithData({}, async () => { + await request + .post(`/v2/applications/1658654/start-service?apikey=${appScopedKey}`) + .send({ serviceName: 'unknown' }) + .set('Content-type', 'application/json') + .expect(404); - expect(applicationManagerSpy).to.not.have.been.called; + expect(applicationManagerSpy).to.not.have.been.called; + }); }); it('should ignore locks and return 200', async () => { @@ -465,12 +467,16 @@ describe('SupervisorAPI [V2 Endpoints]', () => { }); it('should return 404 for an unknown service', async () => { - await request - .post(`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`) - .send({ serviceName: 'unknown' }) - .set('Content-type', 'application/json') - .expect(404); - expect(applicationManagerSpy).to.not.have.been.called; + await mockedDockerode.testWithData({}, async () => { + await request + .post( + `/v2/applications/1658654/restart-service?apikey=${appScopedKey}`, + ) + .send({ serviceName: 'unknown' }) + .set('Content-type', 'application/json') + .expect(404); + expect(applicationManagerSpy).to.not.have.been.called; + }); }); it('should return 423 for a service with update locks', async () => { diff --git a/test/compose/network.spec.ts b/test/compose/network.spec.ts index 89b97c5a..462f8b58 100644 --- a/test/compose/network.spec.ts +++ b/test/compose/network.spec.ts @@ -2,9 +2,10 @@ import ChaiConfig = require('../lib/chai-config'); const { expect } = ChaiConfig; import { Network } from '../../src/compose/network'; +import { NetworkInspectInfo } from 'dockerode'; -describe('Network', () => { - describe('fromComposeObject', () => { +describe('compose/network', () => { + describe('creating a network from a compose object', () => { it('creates a default network configuration if no config is given', () => { const network = Network.fromComposeObject('default', 12345, {}); @@ -54,6 +55,7 @@ describe('Network', () => { { subnet: '172.20.0.0/16', ip_range: '172.20.10.0/24', + aux_addresses: { host0: '172.20.10.15', host1: '172.20.10.16' }, gateway: '172.20.0.1', }, ], @@ -67,8 +69,9 @@ describe('Network', () => { config: [ { subnet: '172.20.0.0/16', - ip_range: '172.20.10.0/24', + ipRange: '172.20.10.0/24', gateway: '172.20.0.1', + auxAddress: { host0: '172.20.10.15', host1: '172.20.10.16' }, }, ], options: {}, @@ -109,4 +112,221 @@ describe('Network', () => { ); }); }); + + describe('creating a network from docker engine state', () => { + it('rejects networks without the proper name format', () => { + expect(() => + Network.fromDockerNetwork({ + Id: 'deadbeef', + Name: 'abcd', + } as NetworkInspectInfo), + ).to.throw(); + + expect(() => + Network.fromDockerNetwork({ + Id: 'deadbeef', + Name: 'abcd_1234', + } as NetworkInspectInfo), + ).to.throw(); + + expect(() => + Network.fromDockerNetwork({ + Id: 'deadbeef', + Name: 'abcd_abcd', + } as NetworkInspectInfo), + ).to.throw(); + + expect(() => + Network.fromDockerNetwork({ + Id: 'deadbeef', + Name: '1234', + } as NetworkInspectInfo), + ).to.throw(); + }); + + it('creates a network object from a docker network configuration', () => { + const network = Network.fromDockerNetwork({ + Id: 'deadbeef', + Name: '1234_default', + Driver: 'bridge', + EnableIPv6: true, + IPAM: { + Driver: 'default', + Options: {}, + Config: [ + { + Subnet: '172.18.0.0/16', + Gateway: '172.18.0.1', + }, + ], + } as NetworkInspectInfo['IPAM'], + Internal: true, + Containers: {}, + Options: { + 'com.docker.some-option': 'abcd', + } as NetworkInspectInfo['Options'], + Labels: { + 'io.balena.features.something': '123', + } as NetworkInspectInfo['Labels'], + } as NetworkInspectInfo); + + expect(network.appId).to.equal(1234); + expect(network.name).to.equal('default'); + expect(network.config.enableIPv6).to.equal(true); + expect(network.config.ipam.driver).to.equal('default'); + expect(network.config.ipam.options).to.deep.equal({}); + expect(network.config.ipam.config).to.deep.equal([ + { + subnet: '172.18.0.0/16', + gateway: '172.18.0.1', + }, + ]); + expect(network.config.internal).to.equal(true); + expect(network.config.options).to.deep.equal({ + 'com.docker.some-option': 'abcd', + }); + expect(network.config.labels).to.deep.equal({ + 'io.balena.features.something': '123', + }); + }); + + it('normalizes legacy label names and excludes supervised label', () => { + const network = Network.fromDockerNetwork({ + Id: 'deadbeef', + Name: '1234_default', + IPAM: { + Driver: 'default', + Options: {}, + Config: [], + } as NetworkInspectInfo['IPAM'], + Labels: { + 'io.resin.features.something': '123', + 'io.balena.features.dummy': 'abc', + 'io.balena.supervised': 'true', + } as NetworkInspectInfo['Labels'], + } as NetworkInspectInfo); + + expect(network.config.labels).to.deep.equal({ + 'io.balena.features.something': '123', + 'io.balena.features.dummy': 'abc', + }); + }); + }); + + describe('creating a network compose configuration from a network instance', () => { + it('creates a docker compose network object from the internal network config', () => { + const network = Network.fromDockerNetwork({ + Id: 'deadbeef', + Name: '1234_default', + Driver: 'bridge', + EnableIPv6: true, + IPAM: { + Driver: 'default', + Options: {}, + Config: [ + { + Subnet: '172.18.0.0/16', + Gateway: '172.18.0.1', + }, + ], + } as NetworkInspectInfo['IPAM'], + Internal: true, + Containers: {}, + Options: { + 'com.docker.some-option': 'abcd', + } as NetworkInspectInfo['Options'], + Labels: { + 'io.balena.features.something': '123', + } as NetworkInspectInfo['Labels'], + } as NetworkInspectInfo); + + // Convert to compose object + const compose = network.toComposeObject(); + expect(compose.driver).to.equal('bridge'); + expect(compose.driver_opts).to.deep.equal({ + 'com.docker.some-option': 'abcd', + }); + expect(compose.enable_ipv6).to.equal(true); + expect(compose.internal).to.equal(true); + expect(compose.ipam).to.deep.equal({ + driver: 'default', + options: {}, + config: [ + { + subnet: '172.18.0.0/16', + gateway: '172.18.0.1', + }, + ], + }); + expect(compose.labels).to.deep.equal({ + 'io.balena.features.something': '123', + }); + }); + }); + + describe('generateDockerName', () => { + it('creates a proper network name from the user given name and the app id', () => { + expect(Network.generateDockerName(12345, 'default')).to.equal( + '12345_default', + ); + expect(Network.generateDockerName(12345, 'bleh')).to.equal('12345_bleh'); + expect(Network.generateDockerName(1, 'default')).to.equal('1_default'); + }); + }); + + describe('comparing network configurations', () => { + it('ignores IPAM configuration', () => { + const network = Network.fromComposeObject('default', 12345, { + ipam: { + driver: 'default', + config: [ + { + subnet: '172.20.0.0/16', + ip_range: '172.20.10.0/24', + gateway: '172.20.0.1', + }, + ], + options: {}, + }, + }); + expect( + network.isEqualConfig(Network.fromComposeObject('default', 12345, {})), + ).to.be.true; + + // Only ignores ipam.config, not other ipam elements + expect( + network.isEqualConfig( + Network.fromComposeObject('default', 12345, { + ipam: { driver: 'aaa' }, + }), + ), + ).to.be.false; + }); + + it('compares configurations recursively', () => { + expect( + Network.fromComposeObject('default', 12345, {}).isEqualConfig( + Network.fromComposeObject('default', 12345, {}), + ), + ).to.be.true; + expect( + Network.fromComposeObject('default', 12345, { + driver: 'default', + }).isEqualConfig(Network.fromComposeObject('default', 12345, {})), + ).to.be.false; + expect( + Network.fromComposeObject('default', 12345, { + enable_ipv6: true, + }).isEqualConfig(Network.fromComposeObject('default', 12345, {})), + ).to.be.false; + expect( + Network.fromComposeObject('default', 12345, { + enable_ipv6: false, + internal: false, + }).isEqualConfig( + Network.fromComposeObject('default', 12345, { internal: true }), + ), + ).to.be.false; + }); + }); }); diff --git a/test/lib/mocked-dockerode.ts b/test/lib/mocked-dockerode.ts index 9d304304..2431f307 100644 --- a/test/lib/mocked-dockerode.ts +++ b/test/lib/mocked-dockerode.ts @@ -81,6 +81,12 @@ export function registerOverride< overrides[name] = fn; } +export function restoreOverride(name: T) { + if (overrides.hasOwnProperty(name)) { + delete overrides[name]; + } +} + export interface TestData { networks: Dictionary; images: Dictionary; @@ -201,6 +207,24 @@ function createMockedDockerode(data: TestData) { return mockedDockerode; } +type Prototype = Dictionary<(...args: any[]) => any>; +function clonePrototype(prototype: Prototype): Prototype { + const clone: Prototype = {}; + Object.getOwnPropertyNames(prototype).forEach((fn) => { + if (fn !== 'constructor' && _.isFunction(prototype[fn])) { + clone[fn] = prototype[fn]; + } + }); + + return clone; +} + +function assignPrototype(target: Prototype, source: Prototype) { + Object.keys(source).forEach((fn) => { + target[fn] = source[fn]; + }); +} + export async function testWithData( data: Partial, test: () => Promise, @@ -216,7 +240,7 @@ export async function testWithData( }; // grab the original prototype... - const basePrototype = dockerode.prototype; + const basePrototype = clonePrototype(dockerode.prototype); // @ts-expect-error setting a RO property dockerode.prototype = createMockedDockerode(mockedData); @@ -226,7 +250,6 @@ export async function testWithData( await test(); } finally { // reset the original prototype... - // @ts-expect-error setting a RO property - dockerode.prototype = basePrototype; + assignPrototype(dockerode.prototype, basePrototype); } }