mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-05-03 01:22:55 +00:00
Docker always returns ports in ascending order, so if they aren't specified like that in the compose, a restart loop would occur. This patch changes the port maps to be stored in ascending order, based on an alphabetical sort of the internalStart port (not taking into account the protocol). This is the same as how Docker returns them, so they will match, regardless of input form. Change-type: patch Closes: #824 Signed-off-by: Cameron Diver <cameron@balena.io>
937 lines
27 KiB
TypeScript
937 lines
27 KiB
TypeScript
import { detailedDiff as diff } from 'deep-object-diff';
|
|
import * as Dockerode from 'dockerode';
|
|
import Duration = require('duration-js');
|
|
import * as _ from 'lodash';
|
|
import * as path from 'path';
|
|
|
|
import * as conversions from '../lib/conversions';
|
|
import { checkInt } from '../lib/validation';
|
|
import { DockerPortOptions, PortMap } from './ports';
|
|
import {
|
|
ConfigMap,
|
|
DeviceMetadata,
|
|
DockerDevice,
|
|
ServiceComposeConfig,
|
|
ServiceConfig,
|
|
ServiceConfigArrayField,
|
|
} from './types/service';
|
|
import * as ComposeUtils from './utils';
|
|
|
|
import * as updateLock from '../lib/update-lock';
|
|
import { sanitiseComposeConfig } from './sanitise';
|
|
import * as constants from '../lib/constants';
|
|
|
|
export class Service {
|
|
public appId: number | null;
|
|
public imageId: number | null;
|
|
public config: ServiceConfig;
|
|
public serviceName: string | null;
|
|
public releaseId: number | null;
|
|
public serviceId: number | null;
|
|
public imageName: string | null;
|
|
public containerId: string | null;
|
|
|
|
public dependsOn: string | null;
|
|
|
|
public status: string;
|
|
public createdAt: Date | null;
|
|
|
|
private static configArrayFields: ServiceConfigArrayField[] = [
|
|
'volumes',
|
|
'devices',
|
|
'capAdd',
|
|
'capDrop',
|
|
'dns',
|
|
'dnsSearch',
|
|
'dnsOpt',
|
|
'tmpfs',
|
|
'extraHosts',
|
|
'expose',
|
|
'ulimitsArray',
|
|
'groupAdd',
|
|
'securityOpt',
|
|
];
|
|
|
|
// A list of fields to ignore when comparing container configuration
|
|
private static omitFields = [
|
|
'networks',
|
|
'running',
|
|
'containerId',
|
|
// This field is passed at container creation, but is not
|
|
// reported on a container inspect, so we cannot use it
|
|
// to compare containers
|
|
'cpus',
|
|
].concat(Service.configArrayFields);
|
|
|
|
private constructor() {}
|
|
|
|
// The type here is actually ServiceComposeConfig, except that the
|
|
// keys must be camelCase'd first
|
|
public static fromComposeObject(
|
|
appConfig: ConfigMap,
|
|
options: DeviceMetadata,
|
|
): Service {
|
|
const service = new Service();
|
|
|
|
appConfig = ComposeUtils.camelCaseConfig(appConfig);
|
|
|
|
const intOrNull = (
|
|
val: string | number | null | undefined,
|
|
): number | null => {
|
|
return checkInt(val) || null;
|
|
};
|
|
|
|
// Seperate the application information from the docker
|
|
// container configuration
|
|
service.imageId = intOrNull(appConfig.imageId);
|
|
delete appConfig.imageId;
|
|
service.serviceName = appConfig.serviceName;
|
|
delete appConfig.serviceName;
|
|
service.appId = intOrNull(appConfig.appId);
|
|
delete appConfig.appId;
|
|
service.releaseId = intOrNull(appConfig.releaseId);
|
|
delete appConfig.releaseId;
|
|
service.serviceId = intOrNull(appConfig.serviceId);
|
|
delete appConfig.serviceId;
|
|
service.imageName = appConfig.imageName;
|
|
delete appConfig.imageName;
|
|
service.dependsOn = appConfig.dependsOn || null;
|
|
delete appConfig.dependsOn;
|
|
service.createdAt = appConfig.createdAt;
|
|
delete appConfig.createdAt;
|
|
|
|
// We don't need this value
|
|
delete appConfig.commit;
|
|
|
|
// Get rid of any extra values and report them to the user
|
|
const config = sanitiseComposeConfig(appConfig);
|
|
|
|
// Process some values into the correct format, delete them from
|
|
// the original object, and add them to the defaults object below
|
|
// We do it using defaults, as the types may be slightly different.
|
|
// For any types which do not change, we change config[value] directly
|
|
|
|
// First process the networks correctly
|
|
let networks: ServiceConfig['networks'] = {};
|
|
if (_.isArray(config.networks)) {
|
|
_.each(config.networks, name => {
|
|
networks[name] = {};
|
|
});
|
|
} else if (_.isObject(config.networks)) {
|
|
networks = config.networks || {};
|
|
}
|
|
// Prefix the network entries with the app id
|
|
networks = _.mapKeys(networks, (_v, k) => `${service.appId}_${k}`);
|
|
delete config.networks;
|
|
|
|
// Check for unsupported networkMode entries
|
|
if (config.networkMode != null) {
|
|
if (/service:(\s*)?.+/.test(config.networkMode)) {
|
|
console.log(
|
|
'Warning: A network_mode referencing a service is not yet supported. Ignoring.',
|
|
);
|
|
delete config.networkMode;
|
|
} else if (/container:(\s*)?.+/.test(config.networkMode)) {
|
|
console.log(
|
|
'Warning: A network_mode referencing a container is not supported. Ignoring.',
|
|
);
|
|
delete config.networkMode;
|
|
}
|
|
}
|
|
|
|
// memory strings
|
|
const memLimit = ComposeUtils.parseMemoryNumber(config.memLimit, '0');
|
|
const memReservation = ComposeUtils.parseMemoryNumber(
|
|
config.memReservation,
|
|
'0',
|
|
);
|
|
const shmSize = ComposeUtils.parseMemoryNumber(config.shmSize, '64m');
|
|
delete config.memLimit;
|
|
delete config.memReservation;
|
|
delete config.shmSize;
|
|
|
|
// time strings
|
|
let stopGracePeriod = 10;
|
|
if (config.stopGracePeriod != null) {
|
|
stopGracePeriod = new Duration(config.stopGracePeriod).seconds();
|
|
}
|
|
delete config.stopGracePeriod;
|
|
|
|
// ulimits
|
|
const ulimits: ServiceConfig['ulimits'] = {};
|
|
_.each(config.ulimits, (limit, name) => {
|
|
if (_.isNumber(limit)) {
|
|
ulimits[name] = { soft: limit, hard: limit };
|
|
return;
|
|
}
|
|
ulimits[name] = { soft: limit.soft, hard: limit.hard };
|
|
});
|
|
delete config.ulimits;
|
|
|
|
// string or array of strings - normalise to an array
|
|
if (_.isString(config.dns)) {
|
|
config.dns = [config.dns];
|
|
}
|
|
|
|
if (_.isString(config.dnsSearch)) {
|
|
config.dnsSearch = [config.dnsSearch];
|
|
}
|
|
|
|
// Assign network_mode to a default value if necessary
|
|
if (!config.networkMode) {
|
|
if (!_.isEmpty(networks)) {
|
|
config.networkMode = _.keys(networks)[0];
|
|
} else {
|
|
config.networkMode = 'default';
|
|
}
|
|
}
|
|
if (
|
|
config.networkMode !== 'host' &&
|
|
config.networkMode !== 'bridge' &&
|
|
config.networkMode !== 'none'
|
|
) {
|
|
if (networks[config.networkMode] == null) {
|
|
// The network mode has not been set explicitly
|
|
config.networkMode = `${service.appId}_${config.networkMode}`;
|
|
// If we don't have any networks, we need to
|
|
// create the default with some default options
|
|
networks[config.networkMode] = {
|
|
aliases: [service.serviceName || ''],
|
|
};
|
|
}
|
|
}
|
|
|
|
// Add default environment variables and labels
|
|
config.environment = Service.extendEnvVars(
|
|
config.environment || {},
|
|
options,
|
|
service.appId || 0,
|
|
service.serviceName || '',
|
|
);
|
|
config.labels = ComposeUtils.normalizeLabels(
|
|
Service.extendLabels(
|
|
config.labels || {},
|
|
options,
|
|
service.appId || 0,
|
|
service.serviceId || 0,
|
|
service.serviceName || '',
|
|
),
|
|
);
|
|
|
|
// Any other special case handling
|
|
if (config.networkMode === 'host' && !config.hostname) {
|
|
config.hostname = options.hostnameOnHost;
|
|
}
|
|
config.restart = ComposeUtils.createRestartPolicy(config.restart);
|
|
config.command = ComposeUtils.getCommand(config.command, options.imageInfo);
|
|
config.entrypoint = ComposeUtils.getEntryPoint(
|
|
config.entrypoint,
|
|
options.imageInfo,
|
|
);
|
|
config.stopSignal = ComposeUtils.getStopSignal(
|
|
config.stopSignal,
|
|
options.imageInfo,
|
|
);
|
|
config.workingDir = ComposeUtils.getWorkingDir(
|
|
config.workingDir,
|
|
options.imageInfo,
|
|
);
|
|
config.user = ComposeUtils.getUser(config.user, options.imageInfo);
|
|
|
|
const healthcheck = ComposeUtils.getHealthcheck(
|
|
config.healthcheck,
|
|
options.imageInfo,
|
|
);
|
|
delete config.healthcheck;
|
|
|
|
config.volumes = Service.extendAndSanitiseVolumes(
|
|
config.volumes,
|
|
options.imageInfo,
|
|
service.appId || 0,
|
|
service.serviceName || '',
|
|
);
|
|
|
|
let portMaps: PortMap[] = [];
|
|
if (config.ports != null) {
|
|
portMaps = PortMap.normaliseComposePorts(
|
|
_.map(config.ports, p => new PortMap(p)),
|
|
);
|
|
}
|
|
delete config.ports;
|
|
|
|
// get the exposed ports, both from the image and the compose file
|
|
let expose: string[] = [];
|
|
if (config.expose != null) {
|
|
expose = _.map(config.expose, ComposeUtils.sanitiseExposeFromCompose);
|
|
}
|
|
const imageExposedPorts = _.get(
|
|
options.imageInfo,
|
|
'Config.ExposedPorts',
|
|
{},
|
|
);
|
|
expose = expose.concat(_.keys(imageExposedPorts));
|
|
expose = _.uniq(expose);
|
|
// Also add any exposed ports which are implied from the portMaps
|
|
const exposedFromPortMappings = _.flatMap(portMaps, port =>
|
|
port.toExposedPortArray(),
|
|
);
|
|
expose = expose.concat(exposedFromPortMappings);
|
|
delete config.expose;
|
|
|
|
let devices: DockerDevice[] = [];
|
|
if (config.devices != null) {
|
|
devices = _.map(config.devices, ComposeUtils.formatDevice);
|
|
}
|
|
delete config.devices;
|
|
|
|
// Sanity check the incoming boolean values
|
|
config.oomKillDisable = Boolean(config.oomKillDisable);
|
|
config.readOnly = Boolean(config.readOnly);
|
|
if (config.tty != null) {
|
|
config.tty = Boolean(config.tty);
|
|
}
|
|
|
|
if (_.isArray(config.sysctls)) {
|
|
config.sysctls = _.fromPairs(_.map(config.sysctls, v => _.split(v, '=')));
|
|
}
|
|
config.sysctls = _.mapValues(config.sysctls, String);
|
|
|
|
_.each(['cpuShares', 'cpuQuota', 'oomScoreAdj'], key => {
|
|
const numVal = checkInt(config[key]);
|
|
if (numVal) {
|
|
config[key] = numVal;
|
|
} else {
|
|
delete config[key];
|
|
}
|
|
});
|
|
|
|
if (config.cpus != null) {
|
|
config.cpus = Math.round(Number(config.cpus) * 10 ** 9);
|
|
if (_.isNaN(config.cpus)) {
|
|
console.log('Warning: config.cpus value cannot be parsed. Ignoring.');
|
|
console.log(` Value: ${config.cpus}`);
|
|
config.cpus = undefined;
|
|
}
|
|
}
|
|
|
|
let tmpfs: string[] = [];
|
|
if (config.tmpfs != null) {
|
|
if (_.isString(config.tmpfs)) {
|
|
tmpfs = [config.tmpfs];
|
|
} else {
|
|
tmpfs = config.tmpfs;
|
|
}
|
|
}
|
|
delete config.tmpfs;
|
|
|
|
// Normalise the config before passing it to defaults
|
|
ComposeUtils.normalizeNullValues(config);
|
|
|
|
service.config = _.defaults(config, {
|
|
portMaps,
|
|
capAdd: [],
|
|
capDrop: [],
|
|
command: [],
|
|
cgroupParent: '',
|
|
devices,
|
|
dnsOpt: [],
|
|
entrypoint: '',
|
|
extraHosts: [],
|
|
expose,
|
|
networks,
|
|
dns: [],
|
|
dnsSearch: [],
|
|
environment: {},
|
|
labels: {},
|
|
networkMode: '',
|
|
ulimits,
|
|
groupAdd: [],
|
|
healthcheck,
|
|
pid: '',
|
|
pidsLimit: 0,
|
|
securityOpt: [],
|
|
stopGracePeriod,
|
|
stopSignal: 'SIGTERM',
|
|
sysctls: {},
|
|
tmpfs,
|
|
usernsMode: '',
|
|
volumes: [],
|
|
restart: 'always',
|
|
cpuShares: 0,
|
|
cpuQuota: 0,
|
|
cpus: 0,
|
|
cpuset: '',
|
|
domainname: '',
|
|
ipc: 'shareable',
|
|
macAddress: '',
|
|
memLimit,
|
|
memReservation,
|
|
oomKillDisable: false,
|
|
oomScoreAdj: 0,
|
|
privileged: false,
|
|
readOnly: false,
|
|
shmSize,
|
|
hostname: '',
|
|
user: '',
|
|
workingDir: '',
|
|
tty: true,
|
|
});
|
|
|
|
// Mutate service with extra features
|
|
ComposeUtils.addFeaturesFromLabels(service, options);
|
|
|
|
return service;
|
|
}
|
|
|
|
public static fromDockerContainer(
|
|
container: Dockerode.ContainerInspectInfo,
|
|
): Service {
|
|
const svc = new Service();
|
|
|
|
if (container.State.Running) {
|
|
svc.status = 'Running';
|
|
} else if (container.State.Status === 'created') {
|
|
svc.status = 'Installed';
|
|
} else if (container.State.Status === 'dead') {
|
|
svc.status = 'Dead';
|
|
} else {
|
|
svc.status = container.State.Status;
|
|
}
|
|
|
|
svc.createdAt = new Date(container.Created);
|
|
svc.containerId = container.Id;
|
|
|
|
let hostname = container.Config.Hostname;
|
|
if (hostname.length === 12 && _.startsWith(container.Id, hostname)) {
|
|
// A hostname equal to the first part of the container ID actually
|
|
// means no hostname was specified
|
|
hostname = '';
|
|
}
|
|
|
|
let networks: ServiceConfig['networks'] = {};
|
|
if (_.get(container, 'NetworkSettings.Networks', null) != null) {
|
|
networks = ComposeUtils.dockerNetworkToServiceNetwork(
|
|
container.NetworkSettings.Networks,
|
|
);
|
|
}
|
|
|
|
const ulimits: ServiceConfig['ulimits'] = {};
|
|
_.each(container.HostConfig.Ulimits, ({ Name, Soft, Hard }) => {
|
|
ulimits[Name] = { soft: Soft, hard: Hard };
|
|
});
|
|
|
|
const portMaps = PortMap.fromDockerOpts(container.HostConfig.PortBindings);
|
|
let expose = _.flatMap(
|
|
_.flatMap(portMaps, p => p.toDockerOpts().exposedPorts),
|
|
_.keys,
|
|
);
|
|
if (container.Config.ExposedPorts != null) {
|
|
expose = expose.concat(
|
|
_.map(container.Config.ExposedPorts, (_v, k) => k.toString()),
|
|
);
|
|
}
|
|
expose = _.uniq(expose);
|
|
|
|
const tmpfs: string[] = [];
|
|
_.each((container.HostConfig as any).Tmpfs, (_v, key) => {
|
|
tmpfs.push(key);
|
|
});
|
|
|
|
// We cannot use || for this value, as the empty string is a
|
|
// valid restart policy but will equate to null in an OR
|
|
let restart = (container.HostConfig.RestartPolicy || {}).Name;
|
|
if (restart == null) {
|
|
restart = 'always';
|
|
}
|
|
|
|
// Define the service config with the same defaults that are used
|
|
// when creating from a compose object, so comparisons will work
|
|
// correctly
|
|
// TODO: We have extended HostConfig interface to keep up with the
|
|
// missing typings, but we cannot do the same the Config sub-object
|
|
// as it is not defined as it's own type. We need to either recreate
|
|
// the entire ContainerInspectInfo object, or upstream the extra
|
|
// fields to DefinitelyTyped
|
|
svc.config = {
|
|
networkMode: container.HostConfig.NetworkMode,
|
|
|
|
portMaps,
|
|
expose,
|
|
hostname,
|
|
command: container.Config.Cmd || '',
|
|
entrypoint: container.Config.Entrypoint || '',
|
|
volumes: _.concat(
|
|
container.HostConfig.Binds || [],
|
|
_.keys(container.Config.Volumes || {}),
|
|
),
|
|
image: container.Config.Image,
|
|
environment: _.omit(
|
|
conversions.envArrayToObject(container.Config.Env || []),
|
|
['RESIN_DEVICE_NAME_AT_INIT', 'BALENA_DEVICE_NAME_AT_INIT'],
|
|
),
|
|
privileged: container.HostConfig.Privileged || false,
|
|
labels: ComposeUtils.normalizeLabels(container.Config.Labels || {}),
|
|
running: container.State.Running,
|
|
restart,
|
|
capAdd: container.HostConfig.CapAdd || [],
|
|
capDrop: container.HostConfig.CapDrop || [],
|
|
devices: container.HostConfig.Devices || [],
|
|
networks,
|
|
memLimit: container.HostConfig.Memory || 0,
|
|
memReservation: container.HostConfig.MemoryReservation || 0,
|
|
shmSize: container.HostConfig.ShmSize || 0,
|
|
cpuShares: container.HostConfig.CpuShares || 0,
|
|
cpuQuota: container.HostConfig.CpuQuota || 0,
|
|
// Not present on a container inspect
|
|
cpus: 0,
|
|
cpuset: container.HostConfig.CpusetCpus || '',
|
|
domainname: container.Config.Domainname || '',
|
|
oomKillDisable: container.HostConfig.OomKillDisable || false,
|
|
oomScoreAdj: container.HostConfig.OomScoreAdj || 0,
|
|
dns: container.HostConfig.Dns || [],
|
|
dnsSearch: container.HostConfig.DnsSearch || [],
|
|
dnsOpt: container.HostConfig.DnsOptions || [],
|
|
tmpfs,
|
|
extraHosts: container.HostConfig.ExtraHosts || [],
|
|
ulimits,
|
|
stopSignal: (container.Config as any).StopSignal || 'SIGTERM',
|
|
stopGracePeriod: (container.Config as any).StopTimeout || 10,
|
|
healthcheck: ComposeUtils.dockerHealthcheckToServiceHealthcheck(
|
|
(container.Config as any).Healthcheck || {},
|
|
),
|
|
readOnly: container.HostConfig.ReadonlyRootfs || false,
|
|
sysctls: container.HostConfig.Sysctls || {},
|
|
cgroupParent: container.HostConfig.CgroupParent || '',
|
|
groupAdd: container.HostConfig.GroupAdd || [],
|
|
pid: container.HostConfig.PidMode || '',
|
|
pidsLimit: container.HostConfig.PidsLimit || 0,
|
|
securityOpt: container.HostConfig.SecurityOpt || [],
|
|
usernsMode: container.HostConfig.UsernsMode || '',
|
|
ipc: container.HostConfig.IpcMode || '',
|
|
macAddress: (container.Config as any).MacAddress || '',
|
|
user: container.Config.User || '',
|
|
workingDir: container.Config.WorkingDir || '',
|
|
tty: container.Config.Tty || false,
|
|
};
|
|
|
|
svc.appId = checkInt(svc.config.labels['io.balena.app-id']) || null;
|
|
svc.serviceId = checkInt(svc.config.labels['io.balena.service-id']) || null;
|
|
svc.serviceName = svc.config.labels['io.balena.service-name'];
|
|
const nameMatch = container.Name.match(/.*_(\d+)_(\d+)$/);
|
|
|
|
svc.imageId = nameMatch != null ? checkInt(nameMatch[1]) || null : null;
|
|
svc.releaseId = nameMatch != null ? checkInt(nameMatch[2]) || null : null;
|
|
svc.containerId = container.Id;
|
|
|
|
return svc;
|
|
}
|
|
|
|
public toDockerContainer(opts: {
|
|
deviceName: string;
|
|
}): Dockerode.ContainerCreateOptions {
|
|
const { binds, volumes } = this.getBindsAndVolumes();
|
|
const { exposedPorts, portBindings } = this.generateExposeAndPorts();
|
|
|
|
const tmpFs: Dictionary<''> = {};
|
|
_.each(this.config.tmpfs, tmp => {
|
|
tmpFs[tmp] = '';
|
|
});
|
|
|
|
const mainNetwork = _.pickBy(
|
|
this.config.networks,
|
|
(_v, k) => k === this.config.networkMode,
|
|
) as ServiceConfig['networks'];
|
|
|
|
return {
|
|
name: `${this.serviceName}_${this.imageId}_${this.releaseId}`,
|
|
Tty: this.config.tty,
|
|
Cmd: this.config.command,
|
|
Volumes: volumes,
|
|
// Typings are wrong here, the docker daemon accepts a string or string[],
|
|
Entrypoint: this.config.entrypoint as string,
|
|
Env: conversions.envObjectToArray(
|
|
_.assign(
|
|
{
|
|
RESIN_DEVICE_NAME_AT_INIT: opts.deviceName,
|
|
BALENA_DEVICE_NAME_AT_INIT: opts.deviceName,
|
|
},
|
|
this.config.environment,
|
|
),
|
|
),
|
|
ExposedPorts: exposedPorts,
|
|
Image: this.config.image,
|
|
Labels: this.config.labels,
|
|
NetworkingConfig: ComposeUtils.serviceNetworksToDockerNetworks(
|
|
mainNetwork,
|
|
),
|
|
StopSignal: this.config.stopSignal,
|
|
Domainname: this.config.domainname,
|
|
Hostname: this.config.hostname,
|
|
// Typings are wrong here, it says MacAddress is a bool (wtf?) but it is
|
|
// in fact a string
|
|
MacAddress: this.config.macAddress as any,
|
|
User: this.config.user,
|
|
WorkingDir: this.config.workingDir,
|
|
HostConfig: {
|
|
CapAdd: this.config.capAdd,
|
|
CapDrop: this.config.capDrop,
|
|
Binds: binds,
|
|
CgroupParent: this.config.cgroupParent,
|
|
Devices: this.config.devices,
|
|
Dns: this.config.dns,
|
|
DnsOptions: this.config.dnsOpt,
|
|
DnsSearch: this.config.dnsSearch,
|
|
PortBindings: portBindings,
|
|
ExtraHosts: this.config.extraHosts,
|
|
GroupAdd: this.config.groupAdd,
|
|
NetworkMode: this.config.networkMode,
|
|
PidMode: this.config.pid,
|
|
PidsLimit: this.config.pidsLimit,
|
|
SecurityOpt: this.config.securityOpt,
|
|
Sysctls: this.config.sysctls,
|
|
Ulimits: ComposeUtils.serviceUlimitsToDockerUlimits(
|
|
this.config.ulimits,
|
|
),
|
|
RestartPolicy: ComposeUtils.serviceRestartToDockerRestartPolicy(
|
|
this.config.restart,
|
|
),
|
|
CpuShares: this.config.cpuShares,
|
|
CpuQuota: this.config.cpuQuota,
|
|
// Type missing, and HostConfig isn't defined as a seperate object
|
|
// so we cannot extend it easily
|
|
CpusetCpus: this.config.cpuset,
|
|
Memory: this.config.memLimit,
|
|
MemoryReservation: this.config.memReservation,
|
|
OomKillDisable: this.config.oomKillDisable,
|
|
OomScoreAdj: this.config.oomScoreAdj,
|
|
Privileged: this.config.privileged,
|
|
ReadonlyRootfs: this.config.readOnly,
|
|
ShmSize: this.config.shmSize,
|
|
Tmpfs: tmpFs,
|
|
UsernsMode: this.config.usernsMode,
|
|
NanoCpus: this.config.cpus,
|
|
IpcMode: this.config.ipc,
|
|
} as Dockerode.ContainerCreateOptions['HostConfig'],
|
|
Healthcheck: ComposeUtils.serviceHealthcheckToDockerHealthcheck(
|
|
this.config.healthcheck,
|
|
),
|
|
StopTimeout: this.config.stopGracePeriod,
|
|
};
|
|
}
|
|
|
|
public isEqualConfig(service: Service): boolean {
|
|
// Check all of the networks for any changes
|
|
let sameNetworks = true;
|
|
_.each(service.config.networks, (network, name) => {
|
|
if (this.config.networks[name] == null) {
|
|
sameNetworks = false;
|
|
return;
|
|
}
|
|
sameNetworks =
|
|
sameNetworks && this.isSameNetwork(this.config.networks[name], network);
|
|
});
|
|
|
|
// Check the configuration for any changes
|
|
const thisOmitted = _.omit(this.config, Service.omitFields);
|
|
const otherOmitted = _.omit(service.config, Service.omitFields);
|
|
let sameConfig = _.isEqual(thisOmitted, otherOmitted);
|
|
const nonArrayEquals = sameConfig;
|
|
|
|
// Check for array fields which don't match
|
|
const differentArrayFields: string[] = [];
|
|
sameConfig =
|
|
sameConfig &&
|
|
_.every(Service.configArrayFields, (field: ServiceConfigArrayField) => {
|
|
return _.isEmpty(
|
|
_.xorWith(
|
|
// TODO: The typings here aren't accepted, even though we
|
|
// know it's fine
|
|
(this.config as any)[field],
|
|
(service.config as any)[field],
|
|
(a, b) => {
|
|
const eq = _.isEqual(a, b);
|
|
if (!eq) {
|
|
differentArrayFields.push(field);
|
|
}
|
|
return eq;
|
|
},
|
|
),
|
|
);
|
|
});
|
|
|
|
if (!(sameConfig && sameNetworks)) {
|
|
// Add some console output for why a service is not matching
|
|
// so that if we end up in a restart loop, we know exactly why
|
|
console.log(
|
|
`Replacing container for service ${
|
|
this.serviceName
|
|
} because of config changes:`,
|
|
);
|
|
if (!nonArrayEquals) {
|
|
// Try not to leak any sensitive information
|
|
const diffObj = diff(thisOmitted, otherOmitted) as ServiceConfig;
|
|
if (diffObj.environment != null) {
|
|
diffObj.environment = _.mapValues(
|
|
diffObj.environment,
|
|
() => 'hidden',
|
|
);
|
|
}
|
|
console.log(' Non-array fields: ', JSON.stringify(diffObj));
|
|
}
|
|
if (differentArrayFields.length > 0) {
|
|
console.log(' Array Fields: ', differentArrayFields.join(','));
|
|
}
|
|
|
|
if (!sameNetworks) {
|
|
console.log(' Network changes detected');
|
|
}
|
|
}
|
|
return sameNetworks && sameConfig;
|
|
}
|
|
|
|
public extraNetworksToJoin(): ServiceConfig['networks'] {
|
|
return _.omit(this.config.networks, this.config.networkMode);
|
|
}
|
|
|
|
public isEqualExceptForRunningState(service: Service): boolean {
|
|
return (
|
|
this.isEqualConfig(service) &&
|
|
this.releaseId === service.releaseId &&
|
|
this.imageId === service.imageId
|
|
);
|
|
}
|
|
|
|
public isEqual(service: Service): boolean {
|
|
return (
|
|
this.isEqualExceptForRunningState(service) &&
|
|
this.config.running === service.config.running
|
|
);
|
|
}
|
|
|
|
public getNamedVolumes() {
|
|
const defaults = Service.defaultBinds(
|
|
this.appId || 0,
|
|
this.serviceName || '',
|
|
);
|
|
const validVolumes = _.map(this.config.volumes, volume => {
|
|
if (_.includes(defaults, volume) || !_.includes(volume, ':')) {
|
|
return null;
|
|
}
|
|
const bindSource = volume.split(':')[0];
|
|
if (!path.isAbsolute(bindSource)) {
|
|
const match = bindSource.match(/[0-9]+_(.+)/);
|
|
if (match == null) {
|
|
console.log(
|
|
'Error: There was an error parsing a volume bind source, ignoring.',
|
|
);
|
|
console.log(' bind source: ', bindSource);
|
|
return null;
|
|
}
|
|
return match[1];
|
|
}
|
|
return null;
|
|
});
|
|
|
|
return _.reject(validVolumes, _.isNil);
|
|
}
|
|
|
|
public handoverCompleteFullPathsOnHost(): string[] {
|
|
return [
|
|
path.join(this.handoverCompletePathOnHost(), 'handover-complete'),
|
|
path.join(this.handoverCompletePathOnHost(), 'resin-kill-me'),
|
|
];
|
|
}
|
|
|
|
private handoverCompletePathOnHost(): string {
|
|
return path.join(
|
|
constants.rootMountPoint,
|
|
updateLock.lockPath(this.appId || 0, this.serviceName || ''),
|
|
);
|
|
}
|
|
|
|
private getBindsAndVolumes(): {
|
|
binds: string[];
|
|
volumes: { [volName: string]: {} };
|
|
} {
|
|
const binds: string[] = [];
|
|
const volumes: { [volName: string]: {} } = {};
|
|
_.each(this.config.volumes, volume => {
|
|
if (_.includes(volume, ':')) {
|
|
binds.push(volume);
|
|
} else {
|
|
volumes[volume] = {};
|
|
}
|
|
});
|
|
|
|
return { binds, volumes };
|
|
}
|
|
|
|
private generateExposeAndPorts(): DockerPortOptions {
|
|
const exposed: DockerPortOptions['exposedPorts'] = {};
|
|
const ports: DockerPortOptions['portBindings'] = {};
|
|
|
|
_.each(this.config.portMaps, pmap => {
|
|
const { exposedPorts, portBindings } = pmap.toDockerOpts();
|
|
_.merge(exposed, exposedPorts);
|
|
_.merge(ports, portBindings);
|
|
});
|
|
|
|
// We also want to merge the compose and image exposedPorts
|
|
// into the list of exposedPorts
|
|
const composeExposed: DockerPortOptions['exposedPorts'] = {};
|
|
_.each(this.config.expose, port => {
|
|
composeExposed[port] = {};
|
|
});
|
|
_.merge(exposed, composeExposed);
|
|
|
|
return { exposedPorts: exposed, portBindings: ports };
|
|
}
|
|
|
|
private static extendEnvVars(
|
|
environment: { [envVarName: string]: string } | null | undefined,
|
|
options: DeviceMetadata,
|
|
appId: number,
|
|
serviceName: string,
|
|
): { [envVarName: string]: string } {
|
|
let defaultEnv: { [envVarName: string]: string } = {};
|
|
for (let namespace of ['BALENA', 'RESIN']) {
|
|
_.assign(
|
|
defaultEnv,
|
|
_.mapKeys(
|
|
{
|
|
APP_ID: appId.toString(),
|
|
APP_NAME: options.appName,
|
|
SERVICE_NAME: serviceName,
|
|
DEVICE_UUID: options.uuid,
|
|
DEVICE_TYPE: options.deviceType,
|
|
HOST_OS_VERSION: options.osVersion,
|
|
SUPERVISOR_VERSION: options.version,
|
|
APP_LOCK_PATH: '/tmp/balena/updates.lock',
|
|
},
|
|
(_val, key) => `${namespace}_${key}`,
|
|
),
|
|
);
|
|
defaultEnv[namespace] = '1';
|
|
}
|
|
defaultEnv['RESIN_SERVICE_KILL_ME_PATH'] = '/tmp/balena/handover-complete';
|
|
defaultEnv['BALENA_SERVICE_HANDOVER_COMPLETE_PATH'] =
|
|
'/tmp/balena/handover-complete';
|
|
defaultEnv['USER'] = 'root';
|
|
|
|
let env = _.defaults(environment, defaultEnv);
|
|
const imageInfoEnv = _.get(options.imageInfo, 'Config.Env', []);
|
|
env = _.defaults(env, conversions.envArrayToObject(imageInfoEnv));
|
|
return env;
|
|
}
|
|
|
|
private isSameNetwork(
|
|
current: ServiceConfig['networks'][0],
|
|
target: ServiceConfig['networks'][0],
|
|
): boolean {
|
|
let sameNetwork = true;
|
|
// Compare only the values which are defined in the target, as the current
|
|
// values get set to defaults by docker
|
|
if (target.aliases != null) {
|
|
if (current.aliases == null) {
|
|
sameNetwork = false;
|
|
} else {
|
|
// Remove the auto-added docker container id
|
|
const currentAliases = _.filter(current.aliases, (alias: string) => {
|
|
return !_.startsWith(this.containerId!, alias);
|
|
});
|
|
const targetAliases = _.filter(current.aliases, (alias: string) => {
|
|
return !_.startsWith(this.containerId!, alias);
|
|
});
|
|
|
|
// Docker adds container ids to the alias list, directly after
|
|
// the service name, to detect this, check for both target having
|
|
// exactly half of the amount of entries as the current, and check
|
|
// that every second entry (starting from 0) is equal
|
|
if (currentAliases.length === targetAliases.length * 2) {
|
|
sameNetwork = _(currentAliases)
|
|
.filter((_v, k) => k % 2 === 0)
|
|
.isEqual(targetAliases);
|
|
} else {
|
|
// Otherwise compare them literally
|
|
sameNetwork = _.isEmpty(
|
|
_.xorWith(currentAliases, targetAliases, _.isEqual),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (target.ipv4Address != null) {
|
|
sameNetwork =
|
|
sameNetwork && _.isEqual(current.ipv4Address, target.ipv4Address);
|
|
}
|
|
if (target.ipv6Address != null) {
|
|
sameNetwork =
|
|
sameNetwork && _.isEqual(current.ipv6Address, target.ipv6Address);
|
|
}
|
|
if (target.linkLocalIps != null) {
|
|
sameNetwork =
|
|
sameNetwork && _.isEqual(current.linkLocalIps, target.linkLocalIps);
|
|
}
|
|
return sameNetwork;
|
|
}
|
|
|
|
private static extendLabels(
|
|
labels: { [labelName: string]: string } | null | undefined,
|
|
{ imageInfo }: DeviceMetadata,
|
|
appId: number,
|
|
serviceId: number,
|
|
serviceName: string,
|
|
): { [labelName: string]: string } {
|
|
let newLabels = _.defaults(labels, {
|
|
'io.balena.supervised': 'true',
|
|
'io.balena.app-id': appId.toString(),
|
|
'io.balena.service-id': serviceId.toString(),
|
|
'io.balena.service-name': serviceName,
|
|
});
|
|
|
|
const imageLabels = _.get(imageInfo, 'Config.Labels', {});
|
|
newLabels = _.defaults(newLabels, imageLabels);
|
|
return newLabels;
|
|
}
|
|
|
|
private static extendAndSanitiseVolumes(
|
|
composeVolumes: ServiceComposeConfig['volumes'],
|
|
imageInfo: Dockerode.ImageInspectInfo | undefined,
|
|
appId: number,
|
|
serviceName: string,
|
|
): ServiceConfig['volumes'] {
|
|
let volumes: ServiceConfig['volumes'] = [];
|
|
|
|
_.each(composeVolumes, volume => {
|
|
const isBind = _.includes(volume, ':');
|
|
if (isBind) {
|
|
const [bindSource, bindDest, mode] = volume.split(':');
|
|
if (!path.isAbsolute(bindSource)) {
|
|
// namespace our volumes by appId
|
|
let volumeDef = `${appId}_${bindSource}:${bindDest}`;
|
|
if (mode != null) {
|
|
volumeDef = `${volumeDef}:${mode}`;
|
|
}
|
|
volumes.push(volumeDef);
|
|
} else {
|
|
console.log(`Ignoring invalid bind mount ${volume}`);
|
|
}
|
|
} else {
|
|
volumes.push(volume);
|
|
}
|
|
});
|
|
|
|
// Now add the default and image binds
|
|
volumes = volumes.concat(Service.defaultBinds(appId, serviceName));
|
|
volumes = _.union(_.keys(_.get(imageInfo, 'Config.Volumes')), volumes);
|
|
|
|
return volumes;
|
|
}
|
|
|
|
private static defaultBinds(appId: number, serviceName: string): string[] {
|
|
return [
|
|
`${updateLock.lockPath(appId, serviceName)}:/tmp/resin`,
|
|
`${updateLock.lockPath(appId, serviceName)}:/tmp/balena`,
|
|
];
|
|
}
|
|
}
|