Rich Bayliss 96c68166a1
application-manager: Convert to a singleton
Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
Signed-off-by: Cameron Diver <cameron@balena.io>
2020-09-14 11:23:36 +01:00

582 lines
15 KiB
TypeScript

import * as Dockerode from 'dockerode';
import Duration = require('duration-js');
import * as _ from 'lodash';
import { parse as parseCommand } from 'shell-quote';
import * as constants from '../lib/constants';
import { checkTruthy } from '../lib/validation';
import { Service } from './service';
import {
ComposeHealthcheck,
ConfigMap,
DeviceMetadata,
DockerDevice,
ServiceComposeConfig,
ServiceConfig,
ServiceHealthcheck,
} from './types/service';
import log from '../lib/supervisor-console';
export function camelCaseConfig(
literalConfig: ConfigMap,
): ServiceComposeConfig {
const config = _.mapKeys(literalConfig, (_v, k) => _.camelCase(k));
// Networks can either be an object or array, but given _.isObject
// returns true for an array, we check the other way
if (!_.isArray(config.networks)) {
const networksTmp = _.cloneDeep(config.networks);
_.each(networksTmp, (v, k) => {
config.networks[k] = _.mapKeys(v, (_v, key) => _.camelCase(key));
});
}
return config as ServiceComposeConfig;
}
export function parseMemoryNumber(
valueAsString: string | null | undefined,
defaultValue?: string,
): number {
if (valueAsString == null) {
if (defaultValue != null) {
return parseMemoryNumber(defaultValue);
}
return 0;
}
const match = valueAsString.toString().match(/^([0-9]+)([bkmg]?)b?$/i);
if (match == null) {
if (defaultValue != null) {
return parseMemoryNumber(defaultValue);
}
return 0;
}
const num = match[1];
const pow: { [key: string]: number } = {
'': 0,
b: 0,
B: 0,
K: 1,
k: 1,
m: 2,
M: 2,
g: 3,
G: 3,
};
return parseInt(num, 10) * 1024 ** pow[match[2]];
}
export const validRestartPolicies = [
'no',
'always',
'on-failure',
'unless-stopped',
];
export function createRestartPolicy(name?: string): string {
if (name == null) {
return 'always';
}
// Ensure that name is a string, otherwise the below could
// throw
if (!_.isString(name)) {
log.warn(`Non-string argument for restart field: ${name} - ignoring.`);
return 'always';
}
name = name.toLowerCase().trim();
if (!_.includes(validRestartPolicies, name)) {
return 'always';
}
if (name === 'no') {
return '';
}
return name;
}
function processCommandString(command: string): string {
return command.replace(/(\$)/g, '\\$1');
}
function processCommandParsedArrayElement(
arg: string | { [key: string]: string },
): string {
if (_.isString(arg)) {
return arg;
}
if (arg.op === 'glob') {
return arg.pattern;
}
return arg.op;
}
function commandAsArray(command: string | string[]): string[] {
if (_.isString(command)) {
return _.map(
parseCommand(processCommandString(command)),
processCommandParsedArrayElement,
);
}
return command;
}
export function getCommand(
composeCommand: string | string[] | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string[] {
if (composeCommand != null) {
return commandAsArray(composeCommand);
}
const imgCommand = _.get(imageInfo, 'Config.Cmd', []);
return commandAsArray(imgCommand);
}
export function getEntryPoint(
composeEntry: string | string[] | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string[] {
if (composeEntry != null) {
return commandAsArray(composeEntry);
}
const imgEntry = _.get(imageInfo, 'Config.Entrypoint', []);
return commandAsArray(imgEntry);
}
// Note that the typings for the compose file stop signal
// say that this can only be a string, but the yaml parser
// could pass it through as a number, so support that here
export function getStopSignal(
composeStop: string | number | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string {
if (composeStop != null) {
if (!_.isString(composeStop)) {
return composeStop.toString();
}
return composeStop;
}
return _.get(imageInfo, 'Config.StopSignal', 'SIGTERM');
}
// TODO: Move healthcheck stuff into seperate module
export function dockerHealthcheckToServiceHealthcheck(
healthcheck?: Dockerode.DockerHealthcheck,
): ServiceHealthcheck {
if (healthcheck == null || _.isEmpty(healthcheck)) {
return { test: ['NONE'] };
}
const serviceHC: ServiceHealthcheck = {
test: healthcheck.Test,
};
if (healthcheck.Interval != null) {
serviceHC.interval = healthcheck.Interval;
}
if (healthcheck.Timeout != null) {
serviceHC.timeout = healthcheck.Timeout;
}
if (healthcheck.StartPeriod != null) {
serviceHC.startPeriod = healthcheck.StartPeriod;
}
if (healthcheck.Retries != null) {
serviceHC.retries = healthcheck.Retries;
}
return serviceHC;
}
function buildHealthcheckTest(test: string | string[]): string[] {
if (_.isString(test)) {
return ['CMD-SHELL', test];
}
return test;
}
function getNanoseconds(timeStr: string): number {
return new Duration(timeStr).nanoseconds();
}
export function composeHealthcheckToServiceHealthcheck(
healthcheck: ComposeHealthcheck | null | undefined,
): ServiceHealthcheck | {} {
if (healthcheck == null) {
return {};
}
if (healthcheck.disable) {
return { test: ['NONE'] };
}
const serviceHC: ServiceHealthcheck = {
test: buildHealthcheckTest(healthcheck.test),
};
if (healthcheck.interval != null) {
serviceHC.interval = getNanoseconds(healthcheck.interval);
}
if (healthcheck.timeout != null) {
serviceHC.timeout = getNanoseconds(healthcheck.timeout);
}
if (healthcheck.startPeriod != null) {
serviceHC.startPeriod = getNanoseconds(healthcheck.startPeriod);
}
if (healthcheck.retries != null) {
serviceHC.retries = healthcheck.retries;
}
return serviceHC;
}
export function getHealthcheck(
composeHealthcheck: ComposeHealthcheck | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): ServiceHealthcheck {
// get the image info healtcheck
const imageServiceHealthcheck = dockerHealthcheckToServiceHealthcheck(
_.get(imageInfo, 'Config.Healthcheck', null),
);
const composeServiceHealthcheck = composeHealthcheckToServiceHealthcheck(
composeHealthcheck,
);
// Overlay any compose healthcheck fields on the image healthchecks
return _.assign(
{ test: ['NONE'] },
imageServiceHealthcheck,
composeServiceHealthcheck,
);
}
export function serviceHealthcheckToDockerHealthcheck(
healthcheck: ServiceHealthcheck,
): Dockerode.DockerHealthcheck {
return {
Test: healthcheck.test,
Interval: healthcheck.interval,
Retries: healthcheck.retries,
StartPeriod: healthcheck.startPeriod,
Timeout: healthcheck.timeout,
};
}
export function getWorkingDir(
workingDir: string | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string {
return (workingDir != null
? workingDir
: _.get(imageInfo, 'Config.WorkingDir', '')
).replace(/(^.+)\/$/, '$1');
}
export function getUser(
user: string | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string {
return user != null ? user : _.get(imageInfo, 'Config.User', '');
}
export function sanitiseExposeFromCompose(portStr: string): string {
if (/^[0-9]*$/.test(portStr)) {
return `${portStr}/tcp`;
}
return portStr;
}
export function formatDevice(deviceStr: string): DockerDevice {
const [pathOnHost, ...parts] = deviceStr.split(':');
let [pathInContainer, cgroup] = parts;
if (pathInContainer == null) {
pathInContainer = pathOnHost;
}
if (cgroup == null) {
cgroup = 'rwm';
}
return {
PathOnHost: pathOnHost,
PathInContainer: pathInContainer,
CgroupPermissions: cgroup,
};
}
export function dockerDeviceToStr(device: DockerDevice): string {
return `${device.PathOnHost}:${device.PathInContainer}:${device.CgroupPermissions}`;
}
// TODO: Export these strings to a constant lib, to
// enable changing them easily
// Mutates service
export function addFeaturesFromLabels(
service: Service,
options: DeviceMetadata,
): void {
const setEnvVariables = function (key: string, val: string) {
service.config.environment[`RESIN_${key}`] = val;
service.config.environment[`BALENA_${key}`] = val;
};
const features = {
'io.balena.features.dbus': () =>
service.config.volumes.push('/run/dbus:/host/run/dbus'),
'io.balena.features.kernel-modules': () =>
options.hostPathExists.modules
? service.config.volumes.push('/lib/modules:/lib/modules')
: null,
'io.balena.features.firmware': () =>
options.hostPathExists.firmware
? service.config.volumes.push('/lib/firmware:/lib/firmware')
: null,
'io.balena.features.balena-socket': () => {
service.config.volumes.push(
`${constants.dockerSocket}:${constants.dockerSocket}`,
);
if (service.config.environment['DOCKER_HOST'] == null) {
service.config.environment[
'DOCKER_HOST'
] = `unix://${constants.dockerSocket}`;
}
// We keep balena.sock for backwards compatibility
if (constants.dockerSocket !== '/var/run/balena.sock') {
service.config.volumes.push(
`${constants.dockerSocket}:/var/run/balena.sock`,
);
}
},
'io.balena.features.balena-api': () => {
setEnvVariables('API_KEY', options.deviceApiKey);
setEnvVariables('API_URL', options.apiEndpoint);
},
'io.balena.features.supervisor-api': () => {
setEnvVariables('SUPERVISOR_PORT', options.listenPort.toString());
setEnvVariables('SUPERVISOR_API_KEY', options.apiSecret);
let host: string;
if (service.config.networkMode === 'host') {
host = '127.0.0.1';
} else {
host = options.supervisorApiHost;
service.config.networks[constants.supervisorNetworkInterface] = {};
}
setEnvVariables('SUPERVISOR_HOST', host);
setEnvVariables(
'SUPERVISOR_ADDRESS',
`http://${host}:${options.listenPort}`,
);
},
'io.balena.features.sysfs': () => service.config.volumes.push('/sys:/sys'),
'io.balena.features.procfs': () =>
service.config.volumes.push('/proc:/proc'),
'io.balena.features.gpu': () =>
// TODO once the compose-spec has an implementation we
// should probably follow that, for now we copy the
// bahavior of docker cli
// https://github.com/balena-os/balena-engine-cli/blob/19.03-balena/opts/gpus.go#L81-L89
service.config.deviceRequests.push({
Count: 1,
Capabilities: [['gpu']],
} as Dockerode.DeviceRequest),
};
_.each(features, (fn, label) => {
if (checkTruthy(service.config.labels[label])) {
fn();
}
});
// This is a special case, and folding it into the
// structure above would unnecessarily complicate things.
// If we get more labels which would require different
// functions to be called, switch up the above code
if (
!checkTruthy(service.config.labels['io.balena.features.supervisor-api'])
) {
// Ensure that the user hasn't added 'supervisor0' to the service's list
// of networks
delete service.config.networks[constants.supervisorNetworkInterface];
}
}
export function serviceUlimitsToDockerUlimits(
ulimits: ServiceConfig['ulimits'] | null | undefined,
): Array<{ Name: string; Soft: number; Hard: number }> {
const ret: Array<{ Name: string; Soft: number; Hard: number }> = [];
_.each(ulimits, ({ soft, hard }, name) => {
ret.push({ Name: name, Soft: soft, Hard: hard });
});
return ret;
}
export function serviceRestartToDockerRestartPolicy(
restart: string,
): { Name: string; MaximumRetryCount: number } {
return {
Name: restart,
MaximumRetryCount: 0,
};
}
export function serviceNetworksToDockerNetworks(
networks: ServiceConfig['networks'],
): Dockerode.ContainerCreateOptions['NetworkingConfig'] {
const dockerNetworks: Dockerode.ContainerCreateOptions['NetworkingConfig'] = {
EndpointsConfig: {},
};
_.each(networks, (net, name) => {
// WHY??? This shouldn't be necessary, as we define it above...
if (dockerNetworks.EndpointsConfig != null) {
dockerNetworks.EndpointsConfig[name] = {};
const conf = dockerNetworks.EndpointsConfig[name];
conf.IPAMConfig = {};
conf.Aliases = [];
_.each(net, (v, k) => {
// We know that IPAMConfig is set because of the intialisation
// above, but typescript doesn't agree, so use !
switch (k) {
case 'ipv4Address':
conf.IPAMConfig!.IPv4Address = v as string;
break;
case 'ipv6Address':
conf.IPAMConfig!.IPv6Address = v as string;
break;
case 'linkLocalIps':
conf.IPAMConfig!.LinkLocalIPs = v as string[];
break;
case 'aliases':
conf.Aliases = v as string[];
break;
}
});
}
});
return dockerNetworks;
}
export function dockerNetworkToServiceNetwork(
dockerNetworks: Dockerode.ContainerInspectInfo['NetworkSettings']['Networks'],
): ServiceConfig['networks'] {
// Take the input network object, filter out any nullish fields, extract things to
// the correct level and return
const networks: ServiceConfig['networks'] = {};
_.each(dockerNetworks, (net, name) => {
networks[name] = {};
if (net.Aliases != null && !_.isEmpty(net.Aliases)) {
networks[name].aliases = net.Aliases;
}
if (net.IPAMConfig != null) {
const ipam = net.IPAMConfig;
if (ipam.IPv4Address != null && !_.isEmpty(ipam.IPv4Address)) {
networks[name].ipv4Address = ipam.IPv4Address;
}
if (ipam.IPv6Address != null && !_.isEmpty(ipam.IPv6Address)) {
networks[name].ipv6Address = ipam.IPv6Address;
}
if (ipam.LinkLocalIps != null && !_.isEmpty(ipam.LinkLocalIps)) {
networks[name].linkLocalIps = ipam.LinkLocalIps;
}
}
});
return networks;
}
// Mutates obj
export function normalizeNullValues(obj: Dictionary<any>): void {
_.each(obj, (v, k) => {
if (v == null) {
obj[k] = undefined;
} else if (_.isObject(v)) {
normalizeNullValues(v);
}
});
}
export function normalizeLabels(labels: {
[key: string]: string;
}): { [key: string]: string } {
const legacyLabels = _.mapKeys(
_.pickBy(labels, (_v, k) => _.startsWith(k, 'io.resin.')),
(_v, k) => {
return k.replace(/resin/g, 'balena'); // e.g. io.resin.features.resin-api -> io.balena.features.balena-api
},
);
const balenaLabels = _.pickBy(labels, (_v, k) =>
_.startsWith(k, 'io.balena.'),
);
const otherLabels = _.pickBy(
labels,
(_v, k) => !(_.startsWith(k, 'io.balena.') || _.startsWith(k, 'io.resin.')),
);
return _.assign({}, otherLabels, legacyLabels, balenaLabels);
}
function compareArrayField(
arr1: unknown[],
arr2: unknown[],
ordered: boolean,
): boolean {
if (!ordered) {
arr1 = _.sortBy(arr1);
arr2 = _.sortBy(arr2);
}
return _.isEqual(arr1, arr2);
}
export function compareArrayFields<T extends Dictionary<unknown>>(
obj1: T,
obj2: T,
nonOrderedFields: Array<keyof T>,
orderedFields: Array<keyof T>,
): { equal: false; difference: string[] };
export function compareArrayFields<T extends Dictionary<unknown>>(
obj1: T,
obj2: T,
nonOrderedFields: Array<keyof T>,
orderedFields: Array<keyof T>,
): { equal: true };
export function compareArrayFields<T extends Dictionary<unknown>>(
obj1: T,
obj2: T,
nonOrderedFields: Array<keyof T>,
orderedFields: Array<keyof T>,
): { equal: boolean; difference?: string[] } {
let equal = true;
const difference: string[] = [];
for (const { fields, ordered } of [
{ fields: nonOrderedFields, ordered: false },
{ fields: orderedFields, ordered: true },
]) {
for (const field of fields) {
if (
!compareArrayField(
obj1[field] as unknown[],
obj2[field] as unknown[],
ordered,
)
) {
equal = false;
difference.push(field as string);
}
}
}
if (equal) {
return { equal };
} else {
return { equal, difference };
}
}