Expose ports from port mappings on services

PR #2217 removed the expose configuration but also caused a regresion
where ports set via the `ports` configuration would no longer get
exposed to the host, despite portmappings being set. This fixes that
issue by exposing only those ports comming from port mappings.

Change-type: patch
This commit is contained in:
Felipe Lalanne 2023-10-24 14:58:16 -03:00
parent 6d2470a686
commit 9bd216327f
5 changed files with 50 additions and 24 deletions

View File

@ -15,6 +15,7 @@ export interface PortBindings {
} }
export interface DockerPortOptions { export interface DockerPortOptions {
exposedPorts: Dictionary<{}>;
portBindings: PortBindings; portBindings: PortBindings;
} }
@ -48,15 +49,19 @@ export class PortMap {
this.ports.externalEnd, this.ports.externalEnd,
); );
const exposedPorts: { [key: string]: {} } = {};
const portBindings: PortBindings = {}; const portBindings: PortBindings = {};
_.zipWith(internalRange, externalRange, (internal, external) => { _.zipWith(internalRange, externalRange, (internal, external) => {
exposedPorts[`${internal}/${this.ports.protocol}`] = {};
portBindings[`${internal}/${this.ports.protocol}`] = [ portBindings[`${internal}/${this.ports.protocol}`] = [
{ HostIp: this.ports.host, HostPort: external.toString() }, { HostIp: this.ports.host, HostPort: external.toString() },
]; ];
}); });
return { return {
exposedPorts,
portBindings, portBindings,
}; };
} }

View File

@ -651,7 +651,7 @@ export class Service {
containerIds: Dictionary<string>; containerIds: Dictionary<string>;
}): Dockerode.ContainerCreateOptions { }): Dockerode.ContainerCreateOptions {
const { binds, mounts, volumes } = this.getBindsMountsAndVolumes(); const { binds, mounts, volumes } = this.getBindsMountsAndVolumes();
const { portBindings } = this.generatePortBindings(); const { exposedPorts, portBindings } = this.generateExposeAndPorts();
const tmpFs: Dictionary<''> = this.config.tmpfs.reduce( const tmpFs: Dictionary<''> = this.config.tmpfs.reduce(
(dict, tmp) => ({ ...dict, [tmp]: '' }), (dict, tmp) => ({ ...dict, [tmp]: '' }),
@ -689,6 +689,7 @@ export class Service {
this.config.environment, this.config.environment,
), ),
), ),
ExposedPorts: exposedPorts,
Image: this.config.image, Image: this.config.image,
Labels: this.config.labels, Labels: this.config.labels,
NetworkingConfig: NetworkingConfig:
@ -927,11 +928,13 @@ export class Service {
return { binds, mounts, volumes }; return { binds, mounts, volumes };
} }
private generatePortBindings(): DockerPortOptions { private generateExposeAndPorts(): DockerPortOptions {
const exposed: DockerPortOptions['exposedPorts'] = {};
const ports: DockerPortOptions['portBindings'] = {}; const ports: DockerPortOptions['portBindings'] = {};
_.each(this.config.portMaps, (pmap) => { _.each(this.config.portMaps, (pmap) => {
const { portBindings } = pmap.toDockerOpts(); const { exposedPorts, portBindings } = pmap.toDockerOpts();
_.merge(exposed, exposedPorts);
_.mergeWith(ports, portBindings, (destVal, srcVal) => { _.mergeWith(ports, portBindings, (destVal, srcVal) => {
if (destVal == null) { if (destVal == null) {
return srcVal; return srcVal;
@ -940,7 +943,7 @@ export class Service {
}); });
}); });
return { portBindings: ports }; return { exposedPorts: exposed, portBindings: ports };
} }
private static extendEnvVars( private static extendEnvVars(

View File

@ -3,6 +3,7 @@ import * as Docker from 'dockerode';
import { TargetStateV2 } from '~/lib/legacy'; import { TargetStateV2 } from '~/lib/legacy';
import * as request from 'supertest'; import * as request from 'supertest';
import { setTimeout as delay } from 'timers/promises'; import { setTimeout as delay } from 'timers/promises';
import { exec } from '~/lib/fs-utils';
const BALENA_SUPERVISOR_ADDRESS = const BALENA_SUPERVISOR_ADDRESS =
process.env.BALENA_SUPERVISOR_ADDRESS || 'http://balena-supervisor:48484'; process.env.BALENA_SUPERVISOR_ADDRESS || 'http://balena-supervisor:48484';
@ -97,9 +98,11 @@ describe('state engine', () => {
serviceName: 'one', serviceName: 'one',
restart: 'unless-stopped', restart: 'unless-stopped',
running: true, running: true,
command: 'sleep infinity', command:
'sh -c "while true; do echo -n \'Hello World!!\' | nc -lv -p 8080; done"',
stop_signal: 'SIGKILL', stop_signal: 'SIGKILL',
networks: ['default'], networks: ['default'],
ports: ['8080'],
labels: {}, labels: {},
environment: {}, environment: {},
}, },
@ -134,6 +137,10 @@ describe('state engine', () => {
{ Name: '/one_11_1_deadbeef', State: 'running' }, { Name: '/one_11_1_deadbeef', State: 'running' },
{ Name: '/two_12_1_deadbeef', State: 'running' }, { Name: '/two_12_1_deadbeef', State: 'running' },
]); ]);
// Test that the service is running and accesssible via port 8080
// this will throw if the server does not respond
await exec('nc -v docker 8080 -z');
}); });
// This test recovery from issue #1576, where a device running a service from the target release // This test recovery from issue #1576, where a device running a service from the target release

View File

@ -98,6 +98,9 @@ describe('compose/ports', function () {
describe('toDockerOpts', function () { describe('toDockerOpts', function () {
it('should correctly generate docker options', () => it('should correctly generate docker options', () =>
expect(new PortMapPublic('80').toDockerOpts()).to.deep.equal({ expect(new PortMapPublic('80').toDockerOpts()).to.deep.equal({
exposedPorts: {
'80/tcp': {},
},
portBindings: { portBindings: {
'80/tcp': [{ HostIp: '', HostPort: '80' }], '80/tcp': [{ HostIp: '', HostPort: '80' }],
}, },
@ -105,6 +108,14 @@ describe('compose/ports', function () {
it('should correctly generate docker options for a port range', () => it('should correctly generate docker options for a port range', () =>
expect(new PortMapPublic('80-85').toDockerOpts()).to.deep.equal({ expect(new PortMapPublic('80-85').toDockerOpts()).to.deep.equal({
exposedPorts: {
'80/tcp': {},
'81/tcp': {},
'82/tcp': {},
'83/tcp': {},
'84/tcp': {},
'85/tcp': {},
},
portBindings: { portBindings: {
'80/tcp': [{ HostIp: '', HostPort: '80' }], '80/tcp': [{ HostIp: '', HostPort: '80' }],
'81/tcp': [{ HostIp: '', HostPort: '81' }], '81/tcp': [{ HostIp: '', HostPort: '81' }],

View File

@ -112,6 +112,7 @@ describe('compose/service: unit tests', () => {
serviceId: 3, serviceId: 3,
imageId: 4, imageId: 4,
composition: { composition: {
// This will be ignored
expose: [1000, '243/udp'], expose: [1000, '243/udp'],
ports: ['2344', '2345:2354', '2346:2367/udp'], ports: ['2344', '2345:2354', '2346:2367/udp'],
}, },
@ -120,6 +121,7 @@ describe('compose/service: unit tests', () => {
imageInfo: { imageInfo: {
Config: { Config: {
ExposedPorts: { ExposedPorts: {
// This will be ignored by generateExposeAndPorts
'53/tcp': {}, '53/tcp': {},
'53/udp': {}, '53/udp': {},
'2354/tcp': {}, '2354/tcp': {},
@ -129,7 +131,7 @@ describe('compose/service: unit tests', () => {
} as any, } as any,
); );
const ports = (s as any).generatePortBindings(); const ports = (s as any).generateExposeAndPorts();
expect(ports.portBindings).to.deep.equal({ expect(ports.portBindings).to.deep.equal({
'2344/tcp': [ '2344/tcp': [
{ {
@ -150,6 +152,13 @@ describe('compose/service: unit tests', () => {
}, },
], ],
}); });
// 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 () => { it('correctly handles port ranges', async () => {
@ -168,7 +177,7 @@ describe('compose/service: unit tests', () => {
{ appName: 'test' } as any, { appName: 'test' } as any,
); );
const ports = (s as any).generatePortBindings(); const ports = (s as any).generateExposeAndPorts();
expect(ports.portBindings).to.deep.equal({ expect(ports.portBindings).to.deep.equal({
'2000/tcp': [ '2000/tcp': [
{ {
@ -195,6 +204,13 @@ describe('compose/service: unit tests', () => {
}, },
], ],
}); });
expect(ports.exposedPorts).to.deep.equal({
'2000/tcp': {},
'2001/tcp': {},
'2002/tcp': {},
'2003/tcp': {},
});
}); });
it('should correctly handle large port ranges', async function () { it('should correctly handle large port ranges', async function () {
@ -213,23 +229,7 @@ describe('compose/service: unit tests', () => {
{ appName: 'test' } as any, { appName: 'test' } as any,
); );
expect((s as any).generatePortBindings).to.not.throw; expect((s as any).generateExposeAndPorts()).to.not.throw;
});
it('should not report implied exposed ports from portMappings', async () => {
const service = await Service.fromComposeObject(
{
appId: 123456,
serviceId: 123456,
serviceName: 'test',
composition: {
ports: ['80:80', '100:100'],
},
},
{ appName: 'test' } as any,
);
expect(service.config).to.not.have.property('expose');
}); });
it('should correctly handle spaces in volume definitions', async () => { it('should correctly handle spaces in volume definitions', async () => {