Files
cloudron-box/src/docker.js
Girish Ramakrishnan b4e4f26361 Rework cpuShares into cpuQuota
cpuShares is the relative weight wrt other apps. This is used when
there is contention for CPU. If we want this, maybe we implement
a UI where we show all the apps and let the user re-order them.
As it stands, it is confusing.

cpuQuota is a more straightforward "hard limit" of the CPU% that you
want the app to consume.

Can be tested with : stress -c 8 -t 20s
2024-04-10 18:25:14 +02:00

687 lines
26 KiB
JavaScript

'use strict';
exports = module.exports = {
removePrivateFields,
getRegistryConfig,
setRegistryConfig,
ping,
info,
df,
downloadImage,
createContainer,
startContainer,
restartContainer,
stopContainer,
stopContainers,
deleteContainer,
deleteImage,
deleteContainers,
createSubcontainer,
inspect,
getContainerIp,
getEvents,
memoryUsage,
update,
parseImageName,
createExec,
startExec,
getExec,
resizeExec
};
const apps = require('./apps.js'),
assert = require('assert'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
dashboard = require('./dashboard.js'),
debug = require('debug')('box:docker'),
Docker = require('dockerode'),
fs = require('fs'),
os = require('os'),
paths = require('./paths.js'),
promiseRetry = require('./promise-retry.js'),
services = require('./services.js'),
settings = require('./settings.js'),
semver = require('semver'),
shell = require('./shell.js'),
safe = require('safetydance'),
timers = require('timers/promises'),
volumes = require('./volumes.js');
const DOCKER_SOCKET_PATH = '/var/run/docker.sock';
const gConnection = new Docker({ socketPath: DOCKER_SOCKET_PATH });
async function testRegistryConfig(config) {
assert.strictEqual(typeof config, 'object');
if (config.provider === 'noop') return;
const [error] = await safe(gConnection.checkAuth(config)); // this returns a 500 even for auth errors
if (error) throw new BoxError(BoxError.BAD_FIELD, `Invalid serverAddress: ${error.message}`);
}
function injectPrivateFields(newConfig, currentConfig) {
if (newConfig.password === constants.SECRET_PLACEHOLDER) newConfig.password = currentConfig.password;
}
function removePrivateFields(registryConfig) {
assert.strictEqual(typeof registryConfig, 'object');
if (registryConfig.password) registryConfig.password = constants.SECRET_PLACEHOLDER;
return registryConfig;
}
async function ping() {
// do not let the request linger
const connection = new Docker({ socketPath: DOCKER_SOCKET_PATH, timeout: 1000 });
const [error, result] = await safe(connection.ping());
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
if (Buffer.isBuffer(result) && result.toString('utf8') === 'OK') return; // sometimes it returns buffer
if (result === 'OK') return;
throw new BoxError(BoxError.DOCKER_ERROR, 'Unable to ping the docker daemon');
}
async function getAuthConfig(image) {
// https://github.com/docker/distribution/blob/release/2.7/reference/normalize.go#L62
const parts = image.split('/');
if (parts.length === 1 || (parts[0].match(/[.:]/) === null)) return null; // public docker registry
const registryConfig = await getRegistryConfig();
// https://github.com/apocas/dockerode#pull-from-private-repos
const autoConfig = {
username: registryConfig.username,
password: registryConfig.password,
auth: registryConfig.auth || '', // the auth token at login time
email: registryConfig.email || '',
serveraddress: registryConfig.serverAddress
};
return autoConfig;
}
async function pullImage(manifest) {
const authConfig = await getAuthConfig(manifest.dockerImage);
debug(`pullImage: will pull ${manifest.dockerImage}. auth: ${authConfig ? 'yes' : 'no'}`);
const [error, stream] = await safe(gConnection.pull(manifest.dockerImage, { authconfig: authConfig }));
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND, `Unable to pull image ${manifest.dockerImage}. message: ${error.message} statusCode: ${error.statusCode}`);
if (error) throw new BoxError(BoxError.DOCKER_ERROR, `Unable to pull image ${manifest.dockerImage}. Please check the network or if the image needs authentication. statusCode: ${error.statusCode}`);
return new Promise((resolve, reject) => {
// https://github.com/dotcloud/docker/issues/1074 says each status message is emitted as a chunk
let layerError = null;
stream.on('data', function (chunk) {
const data = safe.JSON.parse(chunk) || { };
debug('pullImage: %j', data);
// The data.status here is useless because this is per layer as opposed to per image
if (!data.status && data.error) { // data is { errorDetail: { message: xx } , error: xx }
debug(`pullImage error ${manifest.dockerImage}: ${data.errorDetail.message}`);
layerError = data.errorDetail;
}
});
stream.on('end', function () {
debug(`downloaded image ${manifest.dockerImage} . error: ${!!layerError}`);
if (!layerError) return resolve();
reject(new BoxError(layerError.message.includes('no space') ? BoxError.FS_ERROR : BoxError.DOCKER_ERROR, layerError.message));
});
stream.on('error', function (error) { // this is only hit for stream error and not for some download error
debug('error pulling image %s: %o', manifest.dockerImage, error);
reject(new BoxError(BoxError.DOCKER_ERROR, error.message));
});
});
}
async function downloadImage(manifest) {
assert.strictEqual(typeof manifest, 'object');
debug(`downloadImage ${manifest.dockerImage}`);
const image = gConnection.getImage(manifest.dockerImage);
const [error, result] = await safe(image.inspect());
if (!error && result) return; // image is already present locally
await promiseRetry({ times: 10, interval: 5000, debug, retry: (pullError) => pullError.reason !== BoxError.NOT_FOUND && pullError.reason !== BoxError.FS_ERROR }, async () => {
await pullImage(manifest);
});
}
async function getVolumeMounts(app) {
assert.strictEqual(typeof app, 'object');
let mounts = [];
if (app.mounts.length === 0) return [];
const result = await volumes.list();
let volumesById = {};
result.forEach(r => volumesById[r.id] = r);
for (const mount of app.mounts) {
const volume = volumesById[mount.volumeId];
mounts.push({
Source: volume.hostPath,
Target: `/media/${volume.name}`,
Type: 'bind',
ReadOnly: mount.readOnly
});
}
return mounts;
}
async function getAddonMounts(app) {
assert.strictEqual(typeof app, 'object');
let mounts = [];
const addons = app.manifest.addons;
if (!addons) return mounts;
for (const addon of Object.keys(addons)) {
switch (addon) {
case 'localstorage': {
const storageDir = await apps.getStorageDir(app);
mounts.push({
Target: '/app/data',
Source: storageDir,
Type: 'bind',
ReadOnly: false
});
break;
}
case 'tls': {
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
mounts.push({
Target: '/etc/certs',
Source: certificateDir,
Type: 'bind',
ReadOnly: true
});
break;
}
default:
break;
}
}
return mounts;
}
async function getMounts(app) {
assert.strictEqual(typeof app, 'object');
const volumeMounts = await getVolumeMounts(app);
const addonMounts = await getAddonMounts(app);
return volumeMounts.concat(addonMounts);
}
// This only returns ipv4 addresses
// We dont bind to ipv6 interfaces, public prefix changes and container restarts wont work
async function getAddressesForPort53() {
const [error, deviceLinks] = await safe(fs.promises.readdir('/sys/class/net')); // https://man7.org/linux/man-pages/man5/sysfs.5.html
if (error) return [];
const devices = deviceLinks.map(d => { return { name: d, link: safe.fs.readlinkSync(`/sys/class/net/${d}`) }; });
const physicalDevices = devices.filter(d => d.link && !d.link.includes('virtual'));
const addresses = [];
for (const phy of physicalDevices) {
const [error, output] = await safe(shell.exec('getAddressesForPort53', `ip -f inet -j addr show dev ${phy.name} scope global`, {}));
if (error) continue;
const inet = safe.JSON.parse(output) || [];
for (const r of inet) {
const address = safe.query(r, 'addr_info[0].local');
if (address) addresses.push(address);
}
}
return addresses;
}
async function createSubcontainer(app, name, cmd, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof name, 'string');
assert(!cmd || Array.isArray(cmd));
assert.strictEqual(typeof options, 'object');
let isAppContainer = !cmd; // non app-containers are like scheduler
const manifest = app.manifest;
const exposedPorts = {}, dockerPortBindings = { };
const domain = app.fqdn;
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
const stdEnv = [
'CLOUDRON=1',
'CLOUDRON_PROXY_IP=172.18.0.1',
`CLOUDRON_APP_HOSTNAME=${app.id}`,
`CLOUDRON_WEBADMIN_ORIGIN=https://${dashboardFqdn}`,
`CLOUDRON_API_ORIGIN=https://${dashboardFqdn}`,
`CLOUDRON_APP_ORIGIN=https://${domain}`,
`CLOUDRON_APP_DOMAIN=${domain}`
];
if (app.manifest.multiDomain) stdEnv.push(`CLOUDRON_ALIAS_DOMAINS=${app.aliasDomains.map(ad => ad.fqdn).join(',')}`);
const secondaryDomainsEnv = app.secondaryDomains.map(sd => `${sd.environmentVariable}=${sd.fqdn}`);
const portEnv = [];
for (const portName in app.portBindings) {
const hostPort = app.portBindings[portName];
const portType = (manifest.tcpPorts && portName in manifest.tcpPorts) ? 'tcp' : 'udp';
const ports = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
const containerPort = ports[portName].containerPort || hostPort;
const portCount = ports[portName].portCount || 1;
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
portEnv.push(`${portName}=${hostPort}`);
if (portCount > 1) portEnv.push(`${portName}_COUNT=${portCount}`);
// docker portBindings requires ports to be exposed
for (let i = 0; i < portCount; ++i) {
exposedPorts[`${containerPort+i}/${portType}`] = {};
dockerPortBindings[`${containerPort+i}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: (hostPort + i) + '' }; });
}
}
const appEnv = [];
Object.keys(app.env).forEach(function (name) { appEnv.push(`${name}=${app.env[name]}`); });
let memoryLimit = apps.getMemoryLimit(app);
// give scheduler tasks twice the memory limit since background jobs take more memory
// if required, we can make this a manifest and runtime argument later
if (!isAppContainer) memoryLimit *= 2;
const mounts = await getMounts(app);
const addonEnv = await services.getEnvironment(app);
const runtimeVolumes = {
'/tmp': {},
'/run': {},
};
if (app.manifest.runtimeDirs) {
app.manifest.runtimeDirs.forEach(dir => runtimeVolumes[dir] = {});
}
const containerOptions = {
name: name, // for referencing containers
Tty: isAppContainer,
Image: app.manifest.dockerImage,
Cmd: (isAppContainer && app.debugMode && app.debugMode.cmd) ? app.debugMode.cmd : cmd,
Env: stdEnv.concat(addonEnv).concat(portEnv).concat(appEnv).concat(secondaryDomainsEnv),
ExposedPorts: isAppContainer ? exposedPorts : { },
Volumes: runtimeVolumes,
Labels: {
'fqdn': app.fqdn,
'appId': app.id,
'isSubcontainer': String(!isAppContainer),
'isCloudronManaged': String(true)
},
HostConfig: {
Mounts: mounts,
LogConfig: {
Type: 'syslog',
Config: {
'tag': app.id,
'syslog-address': `unix://${paths.SYSLOG_SOCKET_FILE}`,
'syslog-format': 'rfc5424'
}
},
Memory: memoryLimit,
MemorySwap: -1, // Unlimited swap
PortBindings: isAppContainer ? dockerPortBindings : { },
PublishAllPorts: false,
ReadonlyRootfs: app.debugMode ? !!app.debugMode.readonlyRootfs : true,
RestartPolicy: {
'Name': isAppContainer ? 'unless-stopped' : 'no',
'MaximumRetryCount': 0
},
// CpuPeriod (100000 microseconds) and CpuQuota(app.cpuQuota% of CpuPeriod)
// 1000000000 is one core https://github.com/moby/moby/issues/24713#issuecomment-233167619 and https://stackoverflow.com/questions/52391877/set-the-number-of-cpu-cores-of-a-container-using-docker-engine-api
NanoCPUs: app.cpuQuota === 100 ? 0 : (os.cpus().length * app.cpuQuota/100).toFixed(2) * 1000000000,
VolumesFrom: isAppContainer ? null : [ app.containerId + ':rw' ],
SecurityOpt: [ 'apparmor=docker-cloudron-app' ],
CapAdd: [],
CapDrop: [],
Sysctls: {}
}
};
// do no set hostname of containers to location as it might conflict with addons names. for example, an app installed in mail
// location may not reach mail container anymore by DNS. We cannot set hostname to fqdn either as that sets up the dns
// name to look up the internal docker ip. this makes curl from within container fail
// Note that Hostname has no effect on DNS. We have to use the --net-alias for dns.
// Hostname cannot be set with container NetworkMode. Subcontainers run is the network space of the app container
// This is done to prevent lots of up/down events and iptables locking
if (isAppContainer) {
containerOptions.Hostname = app.id;
containerOptions.HostConfig.NetworkMode = 'cloudron'; // user defined bridge network
containerOptions.HostConfig.Dns = ['172.18.0.1']; // use internal dns
containerOptions.HostConfig.DnsSearch = ['.']; // use internal dns
containerOptions.NetworkingConfig = {
EndpointsConfig: {
cloudron: {
IPAMConfig: {
IPv4Address: app.containerIp
},
Aliases: [ name ] // adds hostname entry with container name
}
}
};
} else {
containerOptions.HostConfig.NetworkMode = `container:${app.containerId}`; // scheduler containers must have same IP as app for various addon auth
}
const capabilities = manifest.capabilities || [];
// https://docs-stage.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
if (capabilities.includes('net_admin')) {
containerOptions.HostConfig.CapAdd.push('NET_ADMIN', 'NET_RAW');
// ipv6 for new interfaces is disabled in the container. this prevents the openvpn tun device having ipv6
// See https://github.com/moby/moby/issues/20569 and https://github.com/moby/moby/issues/33099
containerOptions.HostConfig.Sysctls['net.ipv6.conf.all.disable_ipv6'] = '0';
containerOptions.HostConfig.Sysctls['net.ipv6.conf.all.forwarding'] = '1';
}
if (capabilities.includes('mlock')) containerOptions.HostConfig.CapAdd.push('IPC_LOCK'); // mlock prevents swapping
if (!capabilities.includes('ping')) containerOptions.HostConfig.CapDrop.push('NET_RAW'); // NET_RAW is included by default by Docker
if (capabilities.includes('vaapi') && safe.fs.existsSync('/dev/dri')) {
containerOptions.HostConfig.Devices = [
{ PathOnHost: '/dev/dri', PathInContainer: '/dev/dri', CgroupPermissions: 'rwm' }
];
}
const mergedOptions = Object.assign({}, containerOptions, options);
const [createError, container] = await safe(gConnection.createContainer(mergedOptions));
if (createError && createError.statusCode === 409) throw new BoxError(BoxError.ALREADY_EXISTS, createError);
if (createError) throw new BoxError(BoxError.DOCKER_ERROR, createError);
return container;
}
async function createContainer(app) {
return await createSubcontainer(app, app.id /* name */, null /* cmd */, { } /* options */);
}
async function startContainer(containerId) {
assert.strictEqual(typeof containerId, 'string');
const container = gConnection.getContainer(containerId);
const [error] = await safe(container.start());
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND);
if (error && error.statusCode === 400) throw new BoxError(BoxError.BAD_FIELD, error); // e.g start.sh is not executable
if (error && error.statusCode !== 304) throw new BoxError(BoxError.DOCKER_ERROR, error); // 304 means already started
}
async function restartContainer(containerId) {
assert.strictEqual(typeof containerId, 'string');
const container = gConnection.getContainer(containerId);
const [error] = await safe(container.restart());
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND);
if (error && error.statusCode === 400) throw new BoxError(BoxError.BAD_FIELD, error); // e.g start.sh is not executable
if (error && error.statusCode !== 204) throw new BoxError(BoxError.DOCKER_ERROR, error);
}
async function stopContainer(containerId) {
assert(!containerId || typeof containerId === 'string');
if (!containerId) {
debug('No previous container to stop');
return;
}
const container = gConnection.getContainer(containerId);
const options = {
t: 10 // wait for 10 seconds before killing it
};
let [error] = await safe(container.stop(options));
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) throw new BoxError(BoxError.DOCKER_ERROR, 'Error stopping container:' + error.message);
[error] = await safe(container.wait());
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) throw new BoxError(BoxError.DOCKER_ERROR, 'Error waiting on container:' + error.message);
}
async function deleteContainer(containerId) { // id can also be name
assert(!containerId || typeof containerId === 'string');
if (containerId === null) return null;
const container = gConnection.getContainer(containerId);
const removeOptions = {
force: true, // kill container if it's running
v: true // removes volumes associated with the container (but not host mounts)
};
const [error] = await safe(container.remove(removeOptions));
if (error && error.statusCode === 404) return;
if (error) {
debug('Error removing container %s : %o', containerId, error);
throw new BoxError(BoxError.DOCKER_ERROR, error);
}
}
async function deleteContainers(appId, options) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof options, 'object');
const labels = [ 'appId=' + appId ];
if (options.managedOnly) labels.push('isCloudronManaged=true');
const [error, containers] = await safe(gConnection.listContainers({ all: 1, filters: JSON.stringify({ label: labels }) }));
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
for (const container of containers) {
await deleteContainer(container.Id);
}
}
async function stopContainers(appId) {
assert.strictEqual(typeof appId, 'string');
const [error, containers] = await safe(gConnection.listContainers({ all: 1, filters: JSON.stringify({ label: [ 'appId=' + appId ] }) }));
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
for (const container of containers) {
await stopContainer(container.Id);
}
}
async function deleteImage(manifest) {
assert(!manifest || typeof manifest === 'object');
const dockerImage = manifest ? manifest.dockerImage : null;
if (!dockerImage) return;
if (dockerImage.includes('//') || dockerImage.startsWith('/')) return; // a common mistake is to paste a https:// as docker image. this results in a crash at runtime in dockerode module (https://github.com/apocas/dockerode/issues/548)
const removeOptions = {
force: false, // might be shared with another instance of this app
noprune: false // delete untagged parents
};
// registry v1 used to pull down all *tags*. this meant that deleting image by tag was not enough (since that
// just removes the tag). we used to remove the image by id. this is not required anymore because aliases are
// not created anymore after https://github.com/docker/docker/pull/10571
const [error] = await safe(gConnection.getImage(dockerImage).remove(removeOptions));
if (error && error.statusCode === 400) return; // invalid image format. this can happen if user installed with a bad --docker-image
if (error && error.statusCode === 404) return; // not found
if (error && error.statusCode === 409) return; // another container using the image
if (error) {
debug('Error removing image %s : %o', dockerImage, error);
throw new BoxError(BoxError.DOCKER_ERROR, error);
}
}
async function inspect(containerId) {
assert.strictEqual(typeof containerId, 'string');
const container = gConnection.getContainer(containerId);
const [error, result] = await safe(container.inspect());
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND, `Unable to find container ${containerId}`);
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
return result;
}
async function getContainerIp(containerId) {
assert.strictEqual(typeof containerId, 'string');
if (constants.TEST) return '127.0.5.5';
const result = await inspect(containerId);
const ip = safe.query(result, 'NetworkSettings.Networks.cloudron.IPAddress', null);
if (!ip) throw new BoxError(BoxError.DOCKER_ERROR, 'Error getting container IP');
return ip;
}
async function createExec(containerId, options) {
assert.strictEqual(typeof containerId, 'string');
assert.strictEqual(typeof options, 'object');
const container = gConnection.getContainer(containerId);
const [error, exec] = await safe(container.exec(options));
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND);
if (error && error.statusCode === 409) throw new BoxError(BoxError.BAD_STATE, error.message); // container restarting/not running
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
return exec.id;
}
async function startExec(execId, options) {
assert.strictEqual(typeof execId, 'string');
assert.strictEqual(typeof options, 'object');
const exec = gConnection.getExec(execId);
const [error, stream] = await safe(exec.start(options)); /* in hijacked mode, stream is a net.socket */
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND);
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
return stream;
}
async function getExec(execId) {
assert.strictEqual(typeof execId, 'string');
const exec = gConnection.getExec(execId);
const [error, result] = await safe(exec.inspect());
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND, `Unable to find exec container ${execId}`);
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
return { exitCode: result.ExitCode, running: result.Running };
}
async function resizeExec(execId, options) {
assert.strictEqual(typeof execId, 'string');
assert.strictEqual(typeof options, 'object');
const exec = gConnection.getExec(execId);
const [error] = await safe(exec.resize(options)); // { h, w }
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND);
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
}
async function getEvents(options) {
assert.strictEqual(typeof options, 'object');
const [error, stream] = await safe(gConnection.getEvents(options));
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
return stream;
}
async function memoryUsage(containerId) {
assert.strictEqual(typeof containerId, 'string');
const container = gConnection.getContainer(containerId);
const [error, result] = await safe(container.stats({ stream: false }));
if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND);
if (error) throw new BoxError(BoxError.DOCKER_ERROR, error);
return result;
}
async function info() {
const [error, result] = await safe(gConnection.info());
if (error) throw new BoxError(BoxError.DOCKER_ERROR, 'Error connecting to docker');
return result;
}
async function df() {
const [error, result] = await safe(gConnection.df());
if (error) throw new BoxError(BoxError.DOCKER_ERROR, 'Error connecting to docker');
return result;
}
async function update(name, memory) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof memory, 'number');
// scale back db containers, if possible. this is retried because updating memory constraints can fail
// with failed to write to memory.memsw.limit_in_bytes: write /sys/fs/cgroup/memory/docker/xx/memory.memsw.limit_in_bytes: device or resource busy
for (let times = 0; times < 10; times++) {
const [error] = await safe(shell.exec(`update(${name})`, `docker update --memory ${memory} --memory-swap -1 ${name}`, {}));
if (!error) return;
await timers.setTimeout(60 * 1000);
}
throw new BoxError(BoxError.DOCKER_ERROR, 'Unable to update container');
}
async function getRegistryConfig() {
const value = await settings.getJson(settings.REGISTRY_CONFIG_KEY);
return value || { provider: 'noop' };
}
async function setRegistryConfig(registryConfig) {
assert.strictEqual(typeof registryConfig, 'object');
const currentConfig = await getRegistryConfig();
injectPrivateFields(registryConfig, currentConfig);
await testRegistryConfig(registryConfig);
await settings.setJson(settings.REGISTRY_CONFIG_KEY, registryConfig);
}
function parseImageName(imageName) {
const repository = imageName.split(':', 1)[0];
const tag = imageName.substr(repository.length + 1).split('@', 1)[0];
const digest = imageName.substr(repository.length + 1 + tag.length + 1).split(':', 2)[1];
return { repository, tag, version: semver.parse(tag), digest };
}