Add fromDockerOpts and normalization to PortMap class, and use in fromContainer

This function takes the docker output representing ports, and generates
the port map values from them. This means that services can accurately
be compared and next steps can be inferred.

The normalization function ensures that regardless of source, PortMaps
that represent the same port setup will be represented correctly
internally.

Change-type: patch
Closes: #644
Signed-off-by: Cameron Diver <cameron@resin.io>
This commit is contained in:
Cameron Diver
2018-05-22 16:30:23 +01:00
parent 7b77e45f69
commit 5b8068794e
2 changed files with 98 additions and 12 deletions

View File

@ -5,6 +5,9 @@ import TypedError = require('typed-error');
const PORTS_REGEX =
/^(?:(?:([a-fA-F\d.:]+):)?([\d]*)(?:-([\d]+))?:)?([\d]+)(?:-([\d]+))?(?:\/(udp|tcp))?$/;
// A regex to extract the protocol and internal port of the incoming Docker options
const DOCKER_OPTS_PORTS_REGEX = /(\d+)(?:\/?([a-z]+))?/i;
export class InvalidPortDefinition extends TypedError { }
export interface PortBindings {
@ -16,6 +19,15 @@ export interface DockerPortOptions {
portBindings: PortBindings;
}
interface PortRange {
internalStart: number;
internalEnd: number;
externalStart: number;
externalEnd: number;
protocol: string;
host: string;
}
export class PortMap {
private internalStart: number;
@ -25,8 +37,17 @@ export class PortMap {
private protocol: string;
private host: string;
public constructor(portStr: string) {
this.parsePortString(portStr);
public constructor(portStrOrObj: string | PortRange) {
if (_.isString(portStrOrObj)) {
this.parsePortString(portStrOrObj);
} else {
this.internalStart = portStrOrObj.internalStart;
this.internalEnd = portStrOrObj.internalEnd;
this.externalStart = portStrOrObj.externalStart;
this.externalEnd = portStrOrObj.externalEnd;
this.protocol = portStrOrObj.protocol;
this.host = portStrOrObj.host;
}
}
public toDockerOpts(): DockerPortOptions {
@ -50,6 +71,75 @@ export class PortMap {
};
}
/**
* fromDockerOpts
*
* We need to be able to compare target and running containers, but
* running containers store their ports in a separate format (see the
* output of toDockerOpts).
*
* This function takes the output of toDockerOpts (or more accurately,
* the output of the docker daemon when probed about a given container)
* and produces a list of PortMap objects, which can then be compared.
*
*/
public static fromDockerOpts(
portBindings: PortBindings,
): PortMap[] {
// Create a list of portBindings, rather than the map (which we can't
// order)
const portMaps = _.map(portBindings, (hostObj, internalStr) => {
const match = internalStr.match(DOCKER_OPTS_PORTS_REGEX);
if (match == null) {
throw new Error(`Could not parse docker port output: ${internalStr}`);
}
const internal = parseInt(match[1], 10);
const external = parseInt(hostObj[0].HostPort, 10);
const host = hostObj[0].HostIp;
const proto = match[2] || 'tcp';
return new PortMap({
internalStart: internal,
internalEnd: internal,
externalStart: external,
externalEnd: external,
protocol: proto,
host,
});
});
return PortMap.normalisePortMaps(portMaps);
}
public static normalisePortMaps(portMaps: PortMap[]): PortMap[] {
// Fold any ranges into each other if possible
return _(portMaps)
.sortBy((p) => p.protocol)
.sortBy((p) => p.host)
.sortBy((p) => p.internalStart)
.reduce((res: PortMap[], p: PortMap) => {
const last = _.last(res);
if (last == null) {
res.push(p);
} else {
if (last.internalEnd + 1 === p.internalStart &&
last.externalEnd + 1 === p.externalStart &&
last.protocol === p.protocol &&
last.host === p.host
) {
last.internalEnd += 1;
last.externalEnd += 1;
} else {
res.push(p);
}
}
return res;
}, []);
}
private parsePortString(portStr: string): void {
const match = portStr.match(PORTS_REGEX);
if (match == null) {

View File

@ -142,8 +142,7 @@ module.exports = class Service
@capDrop
@status
@devices
@exposedPorts
@portBindings
@portMappings
@networks
@memLimit
@memReservation
@ -194,8 +193,6 @@ module.exports = class Service
@capAdd ?= []
@capDrop ?= []
@devices ?= []
@exposedPorts ?= {}
@portBindings ?= {}
@memLimit = parseMemoryNumber(@memLimit, '0')
@memReservation = parseMemoryNumber(@memReservation, '0')
@ -240,6 +237,8 @@ module.exports = class Service
if _.isEmpty(@ipc)
@ipc = 'shareable'
@portMappings ?= @getPortsAndPortBindings()
# If the service has no containerId, it is a target service and has to be normalised and extended
if !@containerId?
if !@networkMode?
@ -271,7 +270,6 @@ module.exports = class Service
@extendLabels(opts.imageInfo)
@extendAndSanitiseVolumes(opts.imageInfo)
@extendAndSanitiseExposedPorts(opts.imageInfo)
@portMappings = @getPortsAndPortBindings()
@devices = formatDevices(@devices)
@addFeaturesFromLabels(opts)
if @dns?
@ -449,6 +447,8 @@ module.exports = class Service
if containerPort? and !_.includes(boundContainerPorts, containerPort)
expose.push(containerPort)
portMappings = PortMap.fromDockerOpts(container.HostConfig.PortBindings)
appId = checkInt(container.Config.Labels['io.resin.app-id'])
serviceId = checkInt(container.Config.Labels['io.resin.service-id'])
serviceName = container.Config.Labels['io.resin.service-name']
@ -483,8 +483,7 @@ module.exports = class Service
running: container.State.Running
createdAt: new Date(container.Created)
restartPolicy: container.HostConfig.RestartPolicy
ports: ports
expose: expose
portMappings: portMappings
containerId: container.Id
capAdd: container.HostConfig.CapAdd
capDrop: container.HostConfig.CapDrop
@ -664,9 +663,6 @@ module.exports = class Service
_.isEmpty(_.xor(_.keys(@networks), _.keys(otherService.networks)))
isSameContainer: (otherService) =>
# We need computed fields to be present to compare two services
@portMappings ?= @getPortsAndPortBindings()
otherService.portMappings ?= otherService.getPortsAndPortBindings()
propertiesToCompare = [
'image'