mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-05-28 21:34:19 +00:00
This reduces circular dependencies from 250 to 80 by ensuring that modules that only require types do not import the full module with all its dependencies. Change-type: patch
1158 lines
35 KiB
TypeScript
1158 lines
35 KiB
TypeScript
import { detailedDiff as diff } from 'deep-object-diff';
|
|
import type Dockerode from 'dockerode';
|
|
import Duration = require('duration-js');
|
|
import _ from 'lodash';
|
|
import path from 'path';
|
|
|
|
import type { DockerPortOptions } from './ports';
|
|
import { PortMap } from './ports';
|
|
import * as ComposeUtils from './utils';
|
|
import * as updateLock from '../lib/update-lock';
|
|
import { sanitiseComposeConfig } from './sanitise';
|
|
import { pathOnRoot } from '../lib/host-utils';
|
|
import log from '../lib/supervisor-console';
|
|
import * as conversions from '../lib/conversions';
|
|
import { checkInt } from '../lib/validation';
|
|
import { InternalInconsistencyError } from '../lib/errors';
|
|
|
|
import type { EnvVarObject } from '../types';
|
|
import type {
|
|
ServiceConfig,
|
|
ServiceConfigArrayField,
|
|
ServiceComposeConfig,
|
|
ConfigMap,
|
|
DeviceMetadata,
|
|
DockerDevice,
|
|
ServiceStatus,
|
|
Service as ServiceIface,
|
|
} from './types';
|
|
import {
|
|
ShortMount,
|
|
ShortBind,
|
|
ShortAnonymousVolume,
|
|
ShortNamedVolume,
|
|
LongDefinition,
|
|
LongTmpfs,
|
|
LongBind,
|
|
LongAnonymousVolume,
|
|
LongNamedVolume,
|
|
} from './types';
|
|
|
|
const SERVICE_NETWORK_MODE_REGEX = /service:\s*(.+)/;
|
|
const CONTAINER_NETWORK_MODE_REGEX = /container:\s*(.+)/;
|
|
|
|
const unsupportedSecurityOpt = (opt: string) => /label=.*/.test(opt);
|
|
|
|
export type Service = ServiceIface;
|
|
|
|
class ServiceImpl implements Service {
|
|
public appId: number;
|
|
public appUuid?: string;
|
|
public imageId: number;
|
|
public config: ServiceConfig;
|
|
public serviceName: string;
|
|
public commit: string;
|
|
public releaseId: number;
|
|
public serviceId: number;
|
|
public imageName: string | null;
|
|
public containerId: string | null;
|
|
public exitErrorMessage: string | null;
|
|
public dependsOn: string[] | null;
|
|
public dockerImageId: string | null;
|
|
public status: ServiceStatus;
|
|
public createdAt: Date | null;
|
|
|
|
private static configArrayFields: ServiceConfigArrayField[] = [
|
|
'volumes',
|
|
'devices',
|
|
'capAdd',
|
|
'capDrop',
|
|
'dnsOpt',
|
|
'tmpfs',
|
|
'extraHosts',
|
|
'ulimitsArray',
|
|
'groupAdd',
|
|
'securityOpt',
|
|
];
|
|
public static orderedConfigArrayFields: ServiceConfigArrayField[] = [
|
|
'dns',
|
|
'dnsSearch',
|
|
];
|
|
public static allConfigArrayFields: ServiceConfigArrayField[] =
|
|
ServiceImpl.configArrayFields.concat(ServiceImpl.orderedConfigArrayFields);
|
|
|
|
// 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',
|
|
// These fields are special case, due to network_mode:service:<service>
|
|
'networkMode',
|
|
'hostname',
|
|
].concat(ServiceImpl.allConfigArrayFields);
|
|
|
|
private constructor() {
|
|
/* do not allow instancing a service object with `new` */
|
|
}
|
|
|
|
// The type here is actually ServiceComposeConfig, except that the
|
|
// keys must be camelCase'd first
|
|
public static async fromComposeObject(
|
|
appConfig: ConfigMap,
|
|
options: DeviceMetadata,
|
|
): Promise<Service> {
|
|
const service = new Service();
|
|
|
|
appConfig = {
|
|
...appConfig,
|
|
composition: ComposeUtils.camelCaseConfig(appConfig.composition || {}),
|
|
};
|
|
|
|
if (!appConfig.appId) {
|
|
throw new InternalInconsistencyError('No app id for service');
|
|
}
|
|
const appId = checkInt(appConfig.appId);
|
|
if (appId == null) {
|
|
throw new InternalInconsistencyError('Malformed app id for service');
|
|
}
|
|
|
|
// Separate the application information from the docker
|
|
// container configuration
|
|
service.imageId = parseInt(appConfig.imageId, 10);
|
|
service.serviceName = appConfig.serviceName;
|
|
service.appId = appId;
|
|
service.releaseId = parseInt(appConfig.releaseId, 10);
|
|
service.serviceId = parseInt(appConfig.serviceId, 10);
|
|
service.imageName = appConfig.image;
|
|
service.createdAt = appConfig.createdAt;
|
|
service.commit = appConfig.commit;
|
|
service.appUuid = appConfig.appUuid;
|
|
|
|
// dependsOn is used by other parts of the step
|
|
// calculation so we delete it from the composition
|
|
service.dependsOn = appConfig.composition?.dependsOn || null;
|
|
delete appConfig.composition?.dependsOn;
|
|
|
|
// Get remaining fields from appConfig
|
|
const { image, running, labels, environment, composition } = appConfig;
|
|
|
|
// Get rid of any extra values and report them to the user
|
|
const config = sanitiseComposeConfig({
|
|
image,
|
|
running,
|
|
...composition,
|
|
|
|
// Ensure the top level label and environment definition is used
|
|
labels: { ...(composition?.labels ?? {}), ...labels },
|
|
environment: { ...(composition?.environment ?? {}), ...environment },
|
|
});
|
|
|
|
// 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 (Array.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.appUuid}_${k}`);
|
|
// Ensure that we add an alias of the service name
|
|
networks = _.mapValues(networks, (v) => {
|
|
if (v.aliases == null) {
|
|
v.aliases = [];
|
|
}
|
|
const serviceName: string = service.serviceName || '';
|
|
if (!_.includes(v.aliases, serviceName)) {
|
|
v.aliases.push(serviceName);
|
|
}
|
|
return v;
|
|
});
|
|
delete config.networks;
|
|
|
|
// 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 (typeof limit === 'number') {
|
|
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 (typeof config.dns === 'string') {
|
|
config.dns = [config.dns];
|
|
}
|
|
|
|
if (typeof config.dnsSearch === 'string') {
|
|
config.dnsSearch = [config.dnsSearch];
|
|
}
|
|
|
|
// Special case network modes
|
|
let serviceNetworkMode = false;
|
|
if (config.networkMode != null) {
|
|
const match = config.networkMode.match(SERVICE_NETWORK_MODE_REGEX);
|
|
if (match != null) {
|
|
// We need to add a depends on here to ensure that
|
|
// the needed container has started up by the time
|
|
// we try to start this service
|
|
if (service.dependsOn == null) {
|
|
service.dependsOn = [];
|
|
}
|
|
service.dependsOn.push(match[1]);
|
|
serviceNetworkMode = true;
|
|
} else if (CONTAINER_NETWORK_MODE_REGEX.test(config.networkMode)) {
|
|
log.warn(
|
|
'A network_mode referencing a container is not supported. Ignoring.',
|
|
);
|
|
delete config.networkMode;
|
|
}
|
|
} else {
|
|
// Assign network_mode to a default value if necessary
|
|
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 && !serviceNetworkMode) {
|
|
// The network mode has not been set explicitly
|
|
config.networkMode = `${service.appUuid}_${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
|
|
// We also omit any device name variables which may have
|
|
// been input from the image (for example, if you docker
|
|
// commit a container which has been run on a balena device)
|
|
config.environment = Service.omitDeviceNameVars(
|
|
Service.extendEnvVars(
|
|
config.environment || {},
|
|
options,
|
|
service.appId || 0,
|
|
service.appUuid!,
|
|
service.serviceName || '',
|
|
),
|
|
);
|
|
config.labels = ComposeUtils.normalizeLabels(
|
|
Service.extendLabels(
|
|
config.labels || {},
|
|
options,
|
|
service.appId || 0,
|
|
service.serviceId || 0,
|
|
service.serviceName || '',
|
|
service.appUuid!, // appUuid will always exist on the target state
|
|
),
|
|
);
|
|
|
|
// Any other special case handling
|
|
if (config.networkMode === 'host' && !config.hostname) {
|
|
config.hostname = options.hostname;
|
|
}
|
|
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,
|
|
service.serviceName || '',
|
|
);
|
|
|
|
let portMaps: PortMap[] = [];
|
|
if (config.ports != null) {
|
|
portMaps = PortMap.fromComposePorts(config.ports);
|
|
}
|
|
delete config.ports;
|
|
|
|
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 (Array.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 (Number.isNaN(config.cpus)) {
|
|
log.warn(
|
|
`config.cpus value cannot be parsed. Ignoring.\n Value:${config.cpus}`,
|
|
);
|
|
config.cpus = undefined;
|
|
}
|
|
}
|
|
|
|
let tmpfs: string[] = [];
|
|
if (config.tmpfs != null) {
|
|
if (typeof config.tmpfs === 'string') {
|
|
tmpfs = [config.tmpfs];
|
|
} else {
|
|
tmpfs = config.tmpfs;
|
|
}
|
|
}
|
|
delete config.tmpfs;
|
|
|
|
if (config.securityOpt != null) {
|
|
const unsupported = (config.securityOpt || []).filter(
|
|
unsupportedSecurityOpt,
|
|
);
|
|
if (unsupported.length > 0) {
|
|
log.warn(`Ignoring unsupported security options: ${unsupported}`);
|
|
config.securityOpt = (config.securityOpt || []).filter(
|
|
(opt) => !unsupportedSecurityOpt(opt),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Normalise the config before passing it to defaults
|
|
ComposeUtils.normalizeNullValues(config);
|
|
|
|
service.config = _.defaults(config, {
|
|
portMaps,
|
|
capAdd: [],
|
|
capDrop: [],
|
|
command: [],
|
|
cgroupParent: '',
|
|
devices,
|
|
deviceRequests: [],
|
|
dnsOpt: [],
|
|
entrypoint: '',
|
|
extraHosts: [],
|
|
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,
|
|
running: true,
|
|
});
|
|
|
|
// If we have the docker image ID, we replace the image
|
|
// with that
|
|
if (options.imageInfo?.Id != null) {
|
|
config.image = options.imageInfo.Id;
|
|
service.dockerImageId = options.imageInfo.Id;
|
|
}
|
|
|
|
// Mutate service with extra features
|
|
await 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 {
|
|
// We know this cast as fine as we represent all of the status available
|
|
// by docker in the ServiceStatus type
|
|
svc.status = container.State.Status as ServiceStatus;
|
|
}
|
|
|
|
svc.createdAt = new Date(container.Created);
|
|
svc.containerId = container.Id;
|
|
svc.exitErrorMessage = container.State.Error;
|
|
|
|
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,
|
|
svc.containerId,
|
|
);
|
|
}
|
|
|
|
const ulimits: ServiceConfig['ulimits'] = {};
|
|
_.each(container.HostConfig.Ulimits, ({ Name, Soft, Hard }) => {
|
|
// The Ulimit type in @types/dockerode allows any element to be
|
|
// null which is probably wrong
|
|
if (Name != null && Soft != null && Hard != null) {
|
|
ulimits[Name] = {
|
|
soft: Soft,
|
|
hard: Hard,
|
|
};
|
|
}
|
|
});
|
|
|
|
const portMaps = PortMap.fromDockerOpts(container.HostConfig.PortBindings);
|
|
const tmpfs: string[] = Object.keys(container.HostConfig?.Tmpfs || {});
|
|
|
|
const binds: string[] = _.uniq(
|
|
([] as string[]).concat(
|
|
container.HostConfig.Binds || [],
|
|
Object.keys(container.Config?.Volumes || {}),
|
|
),
|
|
);
|
|
|
|
const mounts: LongDefinition[] = (container.HostConfig?.Mounts || []).map(
|
|
ComposeUtils.dockerMountToServiceMount,
|
|
);
|
|
|
|
const volumes: ServiceConfig['volumes'] = [...binds, ...mounts];
|
|
|
|
// 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 = _.get(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 = {
|
|
// The typings say that this is optional, but it's
|
|
// always set by docker
|
|
networkMode: container.HostConfig.NetworkMode!,
|
|
|
|
portMaps,
|
|
hostname,
|
|
command: container.Config.Cmd || '',
|
|
entrypoint: container.Config.Entrypoint || '',
|
|
volumes,
|
|
image: container.Config.Image,
|
|
environment: Service.omitDeviceNameVars(
|
|
conversions.envArrayToObject(container.Config.Env || []),
|
|
),
|
|
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 || [],
|
|
deviceRequests: container.HostConfig.DeviceRequests || [],
|
|
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 || []).filter(
|
|
// The docker engine v20+ adds selinux security options depending
|
|
// on the container configuration. Ignore those in the target state
|
|
// comparison as selinux is not supported by balenaOS so those options
|
|
// will not have any effect.
|
|
// https://github.com/moby/moby/blob/master/daemon/create.go#L214
|
|
(opt: string) => !unsupportedSecurityOpt(opt),
|
|
),
|
|
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,
|
|
};
|
|
|
|
const appId = checkInt(svc.config.labels['io.balena.app-id']);
|
|
if (appId == null) {
|
|
throw new InternalInconsistencyError(
|
|
`Found a service with no appId! ${svc}`,
|
|
);
|
|
}
|
|
svc.appId = appId;
|
|
svc.appUuid = svc.config.labels['io.balena.app-uuid'];
|
|
svc.serviceName = svc.config.labels['io.balena.service-name'];
|
|
svc.serviceId = parseInt(svc.config.labels['io.balena.service-id'], 10);
|
|
if (Number.isNaN(svc.serviceId)) {
|
|
throw new InternalInconsistencyError(
|
|
'Attempt to build Service class from container with malformed labels',
|
|
);
|
|
}
|
|
const nameMatch = container.Name.match(/.*_(\d+)_(\d+)(?:_(.*?))?$/);
|
|
if (nameMatch == null) {
|
|
throw new InternalInconsistencyError(
|
|
`Expected supervised container to have name '<serviceName>_<imageId>_<releaseId>_<commit>', got: ${container.Name}`,
|
|
);
|
|
}
|
|
|
|
svc.imageId = parseInt(nameMatch[1], 10);
|
|
svc.releaseId = parseInt(nameMatch[2], 10);
|
|
svc.commit = nameMatch[3];
|
|
svc.containerId = container.Id;
|
|
svc.dockerImageId = container.Config.Image;
|
|
|
|
return svc;
|
|
}
|
|
|
|
/**
|
|
* Here we try to reverse the fromComposeObject to the best of our ability, as
|
|
* this is used for the supervisor reporting it's own target state. Some of
|
|
* these values won't match in a 1-1 comparison, such as `devices`, as we lose
|
|
* some data about.
|
|
*
|
|
* @returns ServiceConfig
|
|
* @memberof Service
|
|
*/
|
|
public toComposeObject() {
|
|
return this.config;
|
|
}
|
|
|
|
public toDockerContainer(opts: {
|
|
deviceName: string;
|
|
containerIds: Dictionary<string>;
|
|
}): Dockerode.ContainerCreateOptions {
|
|
const { binds, mounts, volumes } = this.getBindsMountsAndVolumes();
|
|
const { exposedPorts, portBindings } = this.generateExposeAndPorts();
|
|
|
|
const tmpFs: Dictionary<''> = this.config.tmpfs.reduce(
|
|
(dict, tmp) => ({ ...dict, [tmp]: '' }),
|
|
{},
|
|
);
|
|
|
|
const mainNetwork = _.pickBy(
|
|
this.config.networks,
|
|
(_v, k) => k === this.config.networkMode,
|
|
) as ServiceConfig['networks'];
|
|
|
|
const match = this.config.networkMode.match(SERVICE_NETWORK_MODE_REGEX);
|
|
if (match != null) {
|
|
const containerId = opts.containerIds[match[1]];
|
|
if (!containerId) {
|
|
throw new Error(
|
|
`No container for network_mode: 'service: ${match[1]}'`,
|
|
);
|
|
}
|
|
this.config.networkMode = `container:${containerId}`;
|
|
}
|
|
return {
|
|
name: `${this.serviceName}_${this.imageId}_${this.releaseId}_${this.commit}`,
|
|
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(
|
|
Object.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,
|
|
Mounts: mounts,
|
|
CgroupParent: this.config.cgroupParent,
|
|
Devices: this.config.devices,
|
|
DeviceRequests: this.config.deviceRequests,
|
|
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,
|
|
currentContainerIds: Dictionary<string>,
|
|
): 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);
|
|
if (!sameNetworks) {
|
|
const currentNetwork = this.config.networks[name];
|
|
const newNetwork = network;
|
|
log.debug(
|
|
`Networks do not match!\nCurrent network: \n${JSON.stringify(
|
|
currentNetwork,
|
|
)}\nNew network: \n${JSON.stringify(newNetwork)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
|
|
// Because the service config does not have an index
|
|
// field, we must first convert to unknown. We know that
|
|
// this conversion is fine as the
|
|
// Service.configArrayFields and
|
|
// Service.orderedConfigArrayFields are defined as
|
|
// fields inside of Service.config
|
|
const arrayEq = ComposeUtils.compareArrayFields(
|
|
this.config as unknown as Dictionary<unknown>,
|
|
service.config as unknown as Dictionary<unknown>,
|
|
Service.configArrayFields,
|
|
Service.orderedConfigArrayFields,
|
|
);
|
|
sameConfig = sameConfig && arrayEq.equal;
|
|
let differentArrayFields: string[] = [];
|
|
if (!arrayEq.equal) {
|
|
differentArrayFields = arrayEq.difference;
|
|
}
|
|
|
|
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
|
|
log.debug(
|
|
`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',
|
|
);
|
|
}
|
|
log.debug(' Non-array fields: ', JSON.stringify(diffObj));
|
|
}
|
|
if (differentArrayFields.length > 0) {
|
|
log.debug(' Array Fields: ', differentArrayFields.join(','));
|
|
}
|
|
|
|
if (!sameNetworks) {
|
|
log.debug(' Network changes detected');
|
|
}
|
|
}
|
|
|
|
// Check the network mode separetely, as if it is a
|
|
// service: network mode, the container id needs to be
|
|
// checked against the running containers
|
|
|
|
// When this function is called, it's with the current
|
|
// state as a parameter and the target as the instance.
|
|
// We shouldn't rely on that because it's not enforced
|
|
// anywhere. For that reason we need to consider both
|
|
// network_modes in the correct way
|
|
let sameNetworkMode = false;
|
|
for (const [a, b] of [
|
|
[this.config.networkMode, service.config.networkMode],
|
|
[service.config.networkMode, this.config.networkMode],
|
|
]) {
|
|
const aMatch = a.match(SERVICE_NETWORK_MODE_REGEX);
|
|
const bMatch = b.match(SERVICE_NETWORK_MODE_REGEX);
|
|
|
|
if (aMatch !== null) {
|
|
if (bMatch === null) {
|
|
const containerMatch = b.match(CONTAINER_NETWORK_MODE_REGEX);
|
|
if (
|
|
containerMatch !== null &&
|
|
currentContainerIds[aMatch[1]] === containerMatch[1]
|
|
) {
|
|
sameNetworkMode = true;
|
|
break;
|
|
}
|
|
} else {
|
|
// They're both service entries, we shouldn't get here
|
|
// but it's technically an equal configuration
|
|
if (a === b) {
|
|
sameNetworkMode = true;
|
|
break;
|
|
}
|
|
}
|
|
} else if (a === b && this.config.hostname === service.config.hostname) {
|
|
// We consider the hostname when it's not a service: entry
|
|
sameNetworkMode = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return sameNetworks && sameConfig && sameNetworkMode;
|
|
}
|
|
|
|
public extraNetworksToJoin(): ServiceConfig['networks'] {
|
|
return _.omit(this.config.networks, this.config.networkMode);
|
|
}
|
|
|
|
public isEqualExceptForRunningState(
|
|
service: Service,
|
|
currentContainerIds: Dictionary<string>,
|
|
): boolean {
|
|
return (
|
|
this.isEqualConfig(service, currentContainerIds) &&
|
|
this.commit === service.commit
|
|
);
|
|
}
|
|
|
|
public isEqual(
|
|
service: Service,
|
|
currentContainerIds: Dictionary<string>,
|
|
): boolean {
|
|
return (
|
|
this.isEqualExceptForRunningState(service, currentContainerIds) &&
|
|
this.config.running === service.config.running
|
|
);
|
|
}
|
|
|
|
public handoverCompleteFullPathsOnHost(): string[] {
|
|
const lockPath = updateLock.lockPath(
|
|
this.appId || 0,
|
|
this.serviceName || '',
|
|
);
|
|
return pathOnRoot(
|
|
...['handover-complete', 'resin-kill-me'].map((tail) =>
|
|
path.join(lockPath, tail),
|
|
),
|
|
);
|
|
}
|
|
|
|
private getBindsMountsAndVolumes(): {
|
|
binds: string[];
|
|
mounts: Dockerode.MountSettings[];
|
|
volumes: { [volName: string]: EmptyObject };
|
|
} {
|
|
const binds: string[] = [];
|
|
const mounts: Dockerode.MountSettings[] = [];
|
|
const volumes: { [volName: string]: EmptyObject } = {};
|
|
|
|
for (const volume of this.config.volumes) {
|
|
if (LongDefinition.is(volume)) {
|
|
// Volumes with the long syntax are translated into Docker-accepted configs
|
|
mounts.push(ComposeUtils.serviceMountToDockerMount(volume));
|
|
} else {
|
|
// Volumes with the string short syntax are acceptable as Docker configs as-is
|
|
if (ShortMount.is(volume)) {
|
|
binds.push(volume);
|
|
} else {
|
|
volumes[volume] = {};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { binds, mounts, 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);
|
|
_.mergeWith(ports, portBindings, (destVal, srcVal) => {
|
|
if (destVal == null) {
|
|
return srcVal;
|
|
}
|
|
return destVal.concat(srcVal);
|
|
});
|
|
});
|
|
|
|
return { exposedPorts: exposed, portBindings: ports };
|
|
}
|
|
|
|
private static extendEnvVars(
|
|
environment: { [envVarName: string]: string } | null | undefined,
|
|
options: DeviceMetadata,
|
|
appId: number,
|
|
appUuid: string,
|
|
serviceName: string,
|
|
): { [envVarName: string]: string } {
|
|
const defaultEnv: { [envVarName: string]: string } = {};
|
|
for (const namespace of ['BALENA', 'RESIN']) {
|
|
Object.assign(
|
|
defaultEnv,
|
|
_.mapKeys(
|
|
{
|
|
APP_ID: appId.toString(),
|
|
APP_UUID: appUuid,
|
|
APP_NAME: options.appName,
|
|
SERVICE_NAME: serviceName,
|
|
DEVICE_UUID: options.uuid,
|
|
DEVICE_TYPE: options.deviceType,
|
|
DEVICE_ARCH: options.deviceArch,
|
|
HOST_OS_VERSION: options.osVersion,
|
|
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;
|
|
}
|
|
|
|
public hasNetwork(networkName: string) {
|
|
// TODO; we could probably export network naming methods to another
|
|
// module to avoid duplicate code
|
|
// We don't know if this service is current or target state so we need
|
|
// to check both appId and appUuid since the current service may still
|
|
// have appId
|
|
return (
|
|
`${this.appUuid}_${networkName}` in this.config.networks ||
|
|
`${this.appId}_${networkName}` in this.config.networks
|
|
);
|
|
}
|
|
|
|
public hasNetworkMode(networkName: string) {
|
|
// We don't know if this service is current or target state so we need
|
|
// to check both appId and appUuid since the current service may still
|
|
// have appId
|
|
return (
|
|
`${this.appUuid}_${networkName}` === this.config.networkMode ||
|
|
`${this.appId}_${networkName}` === this.config.networkMode
|
|
);
|
|
}
|
|
|
|
public hasVolume(volumeName: string) {
|
|
return this.config.volumes.some((volumeDefinition) => {
|
|
let source: string;
|
|
|
|
if (LongNamedVolume.is(volumeDefinition)) {
|
|
source = volumeDefinition.source;
|
|
} else if (ShortNamedVolume.is(volumeDefinition)) {
|
|
[source] = volumeDefinition.split(':');
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
return `${this.appId}_${volumeName}` === source;
|
|
});
|
|
}
|
|
|
|
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 {
|
|
const [currentAliases, targetAliases] = [
|
|
current.aliases,
|
|
target.aliases,
|
|
];
|
|
|
|
// Docker may add keep old container ids as aliases for a specific service after
|
|
// restarts, this means that the target aliases really needs to be a subset of the
|
|
// current aliases to prevent service restarts when re-applying the same target state
|
|
sameNetwork =
|
|
_.intersection(currentAliases, targetAliases).length ===
|
|
targetAliases.length;
|
|
}
|
|
}
|
|
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 omitDeviceNameVars(env: EnvVarObject) {
|
|
return _.omit(env, [
|
|
'RESIN_DEVICE_NAME_AT_INIT',
|
|
'BALENA_DEVICE_NAME_AT_INIT',
|
|
]);
|
|
}
|
|
|
|
private static extendLabels(
|
|
labels: { [labelName: string]: string } | null | undefined,
|
|
{ imageInfo }: DeviceMetadata,
|
|
appId: number,
|
|
serviceId: number,
|
|
serviceName: string,
|
|
appUuid: string,
|
|
): { [labelName: string]: string } {
|
|
let newLabels = {
|
|
...labels,
|
|
...{
|
|
'io.balena.supervised': 'true',
|
|
'io.balena.app-id': appId.toString(),
|
|
'io.balena.service-id': serviceId.toString(),
|
|
'io.balena.service-name': serviceName,
|
|
'io.balena.app-uuid': appUuid,
|
|
},
|
|
};
|
|
|
|
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'] = [];
|
|
|
|
// namespace our volumes by appId
|
|
const namespaceVolume = (volumeSource: string) =>
|
|
`${appId}_${volumeSource.trim()}`;
|
|
|
|
for (const volume of composeVolumes || []) {
|
|
const isString = typeof volume === 'string';
|
|
// Bind mounts are not allowed
|
|
if (LongBind.is(volume) || ShortBind.is(volume)) {
|
|
log.warn(
|
|
`Ignoring invalid bind mount ${
|
|
isString ? volume : JSON.stringify(volume)
|
|
}`,
|
|
);
|
|
} else if (
|
|
LongTmpfs.is(volume) ||
|
|
LongAnonymousVolume.is(volume) ||
|
|
ShortAnonymousVolume.is(volume)
|
|
) {
|
|
volumes.push(volume);
|
|
} else if (LongNamedVolume.is(volume)) {
|
|
volume.source = namespaceVolume(volume.source);
|
|
volumes.push(volume);
|
|
} else if (ShortNamedVolume.is(volume)) {
|
|
const [source, target, mode] = (volume as string).split(':');
|
|
let volumeDef = `${namespaceVolume(source)}:${target.trim()}`;
|
|
if (mode != null) {
|
|
volumeDef = `${volumeDef}:${mode.trim()}`;
|
|
}
|
|
volumes.push(volumeDef);
|
|
} else {
|
|
log.warn(
|
|
`Ignoring invalid compose volume definition ${
|
|
isString ? volume : JSON.stringify(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`,
|
|
];
|
|
}
|
|
}
|
|
|
|
export const Service = ServiceImpl;
|