diff --git a/CHANGES b/CHANGES index d96797c2c..9ee7bc588 100644 --- a/CHANGES +++ b/CHANGES @@ -2668,4 +2668,5 @@ * mail: add virtual all mail mailbox * redirections: use 301 (permanent) instead of 302 (temporary) for redirections. this is better for SEO links * graphs: show old backup size if > 1GB +* docker: fix image pruning diff --git a/scripts/installer.sh b/scripts/installer.sh index 43ad9e1cc..c3019456b 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -154,15 +154,15 @@ if [[ ${try} -eq 10 ]]; then fi log "downloading new addon images" -images=$(node -e "let i = require('${box_src_tmp_dir}/src/infra_version.js'); console.log(i.baseImages.map(function (x) { return x.tag; }).join(' '), Object.keys(i.images).map(function (x) { return i.images[x].tag; }).join(' '));") +images=$(node -e "const i = require('${box_src_tmp_dir}/src/infra_version.js'); console.log(Object.keys(i.images).map(x => i.images[x]).join(' '));") log "\tPulling docker images: ${images}" for image in ${images}; do - while ! docker pull "registry.docker.com/${image}"; do # this pulls the image using the sha256 + while ! docker pull "${image}"; do # this pulls the image using the sha256 log "Could not pull ${image}" sleep 5 done - while ! docker pull "registry.docker.com/${image%@sha256:*}"; do # this will tag the image for readability + while ! docker pull "${image%@sha256:*}"; do # this will tag the image for readability log "Could not pull ${image%@sha256:*}" sleep 5 done diff --git a/src/docker.js b/src/docker.js index 7225c953f..6b9ebd47c 100644 --- a/src/docker.js +++ b/src/docker.js @@ -26,6 +26,8 @@ exports = module.exports = { update, + parseImageName, + createExec, startExec, getExec, @@ -42,6 +44,7 @@ const apps = require('./apps.js'), promiseRetry = require('./promise-retry.js'), services = require('./services.js'), settings = require('./settings.js'), + semver = require('semver'), shell = require('./shell.js'), safe = require('safetydance'), system = require('./system.js'), @@ -671,3 +674,11 @@ async function setRegistryConfig(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 }; +} diff --git a/src/infra_version.js b/src/infra_version.js index 6cb3291eb..adab799fe 100644 --- a/src/infra_version.js +++ b/src/infra_version.js @@ -6,22 +6,19 @@ exports = module.exports = { // a version change recreates all containers with latest docker config - 'version': '49.4.0', - - 'baseImages': [ - { repo: 'cloudron/base', tag: 'cloudron/base:4.0.0@sha256:31b195ed0662bdb06a6e8a5ddbedb6f191ce92e8bee04c03fb02dd4e9d0286df' } - ], + 'version': '49.5.0', // a major version bump in the db containers will trigger the restore logic that uses the db dumps // docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256 'images': { - 'turn': { repo: 'cloudron/turn', tag: 'cloudron/turn:1.6.0@sha256:7e7929505f646feb44795668f87e1e53651f9c146a90b281159684c3ddc696e3' }, - 'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:3.3.7@sha256:5c8fe784859a5bc8c839712d8b52427247a54bce9126fb2d50ca2535e6330647' }, - 'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:5.0.7@sha256:4be3401b9d1374d1e165bdbd1a49ea8cdee748f15f180538306637868abffbac' }, - 'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:4.3.7@sha256:6217723c33f1555fdaf5064a4ee87ab582523ac24fe15fafe9838b137e185296' }, - 'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:3.5.0@sha256:ee6da2599a72afaec1d80c41db9b5fe79c882fb920195659e871501ea2e94d18' }, - 'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.9.0@sha256:c990b6e1f928f846e2394219fdda1fdf388e45be08489965bc41f99f2a86a4ca' }, - 'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.3.0@sha256:005addac7e7576f3960b562404ce59442bc861626af0ae0f5122484f5bfcbbc1' }, - 'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:3.7.4@sha256:4c657982b46b4106c48b84a77ced68dfeb8d0c97cba64ad200ed9396064bb873' } + 'base': 'registry.docker.com/cloudron/base:4.0.0@sha256:31b195ed0662bdb06a6e8a5ddbedb6f191ce92e8bee04c03fb02dd4e9d0286df', + 'turn': 'registry.docker.com/cloudron/turn:1.6.0@sha256:7e7929505f646feb44795668f87e1e53651f9c146a90b281159684c3ddc696e3', + 'mysql': 'registry.docker.com/cloudron/mysql:3.3.7@sha256:5c8fe784859a5bc8c839712d8b52427247a54bce9126fb2d50ca2535e6330647', + 'postgresql': 'registry.docker.com/cloudron/postgresql:5.0.7@sha256:4be3401b9d1374d1e165bdbd1a49ea8cdee748f15f180538306637868abffbac', + 'mongodb': 'registry.docker.com/cloudron/mongodb:4.3.7@sha256:6217723c33f1555fdaf5064a4ee87ab582523ac24fe15fafe9838b137e185296', + 'redis': 'registry.docker.com/cloudron/redis:3.5.0@sha256:ee6da2599a72afaec1d80c41db9b5fe79c882fb920195659e871501ea2e94d18', + 'mail': 'registry.docker.com/cloudron/mail:3.9.0@sha256:c990b6e1f928f846e2394219fdda1fdf388e45be08489965bc41f99f2a86a4ca', + 'graphite': 'registry.docker.com/cloudron/graphite:3.3.0@sha256:005addac7e7576f3960b562404ce59442bc861626af0ae0f5122484f5bfcbbc1', + 'sftp': 'registry.docker.com/cloudron/sftp:3.7.4@sha256:4c657982b46b4106c48b84a77ced68dfeb8d0c97cba64ad200ed9396064bb873' } }; diff --git a/src/mailserver.js b/src/mailserver.js index 93d6ae21a..863f552e0 100644 --- a/src/mailserver.js +++ b/src/mailserver.js @@ -141,7 +141,7 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) { // MAIL_DOMAIN is the domain for which this server is relaying mails // mail container uses /app/data for backed up data and /run for restart-able data - const tag = infra.images.mail.tag; + const image = infra.images.mail; const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT; const memory = await system.getMemoryAllocation(memoryLimit); const cloudronToken = hat(8 * 128), relayToken = hat(8 * 128); @@ -187,7 +187,7 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) { -v "${paths.MAIL_CONFIG_DIR}:/etc/mail:ro" \ ${ports} \ --label isCloudronManaged=true \ - ${readOnly} -v /run -v /tmp ${tag} ${cmd}`; + ${readOnly} -v /run -v /tmp ${image} ${cmd}`; await shell.promises.exec('startMail', runCmd); } diff --git a/src/platform.js b/src/platform.js index 107c57884..5460a077e 100644 --- a/src/platform.js +++ b/src/platform.js @@ -13,6 +13,7 @@ const apps = require('./apps.js'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), debug = require('debug')('box:platform'), + docker = require('./docker.js'), fs = require('fs'), infra = require('./infra_version.js'), locker = require('./locker.js'), @@ -100,26 +101,28 @@ async function pruneInfraImages() { debug('pruneInfraImages: checking existing images'); // cannot blindly remove all unused images since redis image may not be used - const images = infra.baseImages.concat(Object.keys(infra.images).map(function (addon) { return infra.images[addon]; })); + const imageNames = Object.keys(infra.images).map(addon => infra.images[addon]); + const output = safe.child_process.execSync('docker images --digests --format "{{.ID}} {{.Repository}} {{.Tag}} {{.Digest}}"', { encoding: 'utf8' }); + if (output === null) { + debug(`Failed to list images ${safe.error.message}`); + throw safe.error; + } + const lines = output.trim().split('\n'); - for (const image of images) { - const output = safe.child_process.execSync(`docker images --digests ${image.repo} --format "{{.ID}} {{.Repository}}:{{.Tag}}@{{.Digest}}"`, { encoding: 'utf8' }); - if (output === null) { - debug(`Failed to list images of ${image}. %o`, safe.error); - throw safe.error; - } + for (const imageName of imageNames) { + const parsedTag = docker.parseImageName(imageName); - const lines = output.trim().split('\n'); for (const line of lines) { if (!line) continue; - const parts = line.split(' '); // [ ID, Repo:Tag@Digest ] - const normalizedTag = parts[1].replace('registry.ipv6.docker.com/', '').replace('registry-1.docker.io/', '').replace('registry.docker.com', ''); + const [, repo, tag, digest] = line.split(' '); // [ ID, Repo, Tag, Digest ] + if (!parsedTag.repository.endsWith(repo)) continue; // some other repo + if (imageName === `${repo}:${tag}@${digest}`) continue; // the image we want to keep - if (image.tag === normalizedTag) continue; // keep - debug(`pruneInfraImages: removing unused image of ${image.repo}: tag: ${parts[1]} id: ${parts[0]}`); + const imageIdToPrune = tag === '' ? `${repo}@${digest}` : `${repo}:${tag}`; // untagged, use digest + console.log(`pruneInfraImages: removing unused image of ${imageName}: ${imageIdToPrune}`); - let result = safe.child_process.execSync(`docker rmi ${parts[1].replace(':', '')}`, { encoding: 'utf8' }); // the none tag has to be removed - if (result === null) debug(`Error removing image ${parts[0]}: ${safe.error.mesage}`); + const result = safe.child_process.execSync(`docker rmi '${imageIdToPrune}'`, { encoding: 'utf8' }); + if (result === null) console.log(`Error removing image ${imageIdToPrune}: ${safe.error.mesage}`); } } } @@ -152,10 +155,10 @@ async function markApps(existingInfra, options) { await apps.configureInstalledApps(await apps.list(), AuditSource.PLATFORM); } else { let changedAddons = []; - if (infra.images.mysql.tag !== existingInfra.images.mysql.tag) changedAddons.push('mysql'); - if (infra.images.postgresql.tag !== existingInfra.images.postgresql.tag) changedAddons.push('postgresql'); - if (infra.images.mongodb.tag !== existingInfra.images.mongodb.tag) changedAddons.push('mongodb'); - if (infra.images.redis.tag !== existingInfra.images.redis.tag) changedAddons.push('redis'); + if (infra.images.mysql !== existingInfra.images.mysql) changedAddons.push('mysql'); + if (infra.images.postgresql !== existingInfra.images.postgresql) changedAddons.push('postgresql'); + if (infra.images.mongodb !== existingInfra.images.mongodb) changedAddons.push('mongodb'); + if (infra.images.redis !== existingInfra.images.redis) changedAddons.push('redis'); if (changedAddons.length) { // restart apps if docker image changes since the IP changes and any "persistent" connections fail diff --git a/src/services.js b/src/services.js index 31ea7e778..c82d274c4 100644 --- a/src/services.js +++ b/src/services.js @@ -57,7 +57,6 @@ const addonConfigs = require('./addonconfigs.js'), { pipeline } = require('stream'), promiseRetry = require('./promise-retry.js'), safe = require('safetydance'), - semver = require('semver'), settings = require('./settings.js'), sftp = require('./sftp.js'), shell = require('./shell.js'), @@ -267,16 +266,10 @@ const APP_SERVICES = { } }; -function parseImageTag(tag) { - let repository = tag.split(':', 1)[0]; - let version = tag.substr(repository.length + 1).split('@', 1)[0]; - let digest = tag.substr(repository.length + 1 + version.length + 1).split(':', 2)[1]; - - return { repository, version: semver.parse(version), digest }; -} - -function requiresUpgrade(existingTag, currentTag) { - let etag = parseImageTag(existingTag), ctag = parseImageTag(currentTag); +function requiresUpgrade(existingObjOrImageName, currentImageName) { + // we removed image.tag. remove this after 7.6 + const existingTag = typeof existingObjOrImageName === 'object' ? existingObjOrImageName.tag : existingObjOrImageName; + let etag = docker.parseImageName(existingTag), ctag = docker.parseImageName(currentImageName); return etag.version.major !== ctag.version.major; } @@ -794,14 +787,14 @@ async function startServices(existingInfra) { } else { assert.strictEqual(typeof existingInfra.images, 'object'); - if (infra.images.mail.tag !== existingInfra.images.mail.tag) startFuncs.push(mailServer.start); // start this first to reduce email downtime - if (infra.images.turn.tag !== existingInfra.images.turn.tag) startFuncs.push(startTurn); - if (infra.images.mysql.tag !== existingInfra.images.mysql.tag) startFuncs.push(startMysql); - if (infra.images.postgresql.tag !== existingInfra.images.postgresql.tag) startFuncs.push(startPostgresql); - if (infra.images.mongodb.tag !== existingInfra.images.mongodb.tag) startFuncs.push(startMongodb); - if (infra.images.redis.tag !== existingInfra.images.redis.tag) startFuncs.push(startRedis); - if (infra.images.graphite.tag !== existingInfra.images.graphite.tag) startFuncs.push(startGraphite); - if (infra.images.sftp.tag !== existingInfra.images.sftp.tag) startFuncs.push(sftp.start); + if (infra.images.mail !== existingInfra.images.mail) startFuncs.push(mailServer.start); // start this first to reduce email downtime + if (infra.images.turn !== existingInfra.images.turn) startFuncs.push(startTurn); + if (infra.images.mysql !== existingInfra.images.mysql) startFuncs.push(startMysql); + if (infra.images.postgresql !== existingInfra.images.postgresql) startFuncs.push(startPostgresql); + if (infra.images.mongodb !== existingInfra.images.mongodb) startFuncs.push(startMongodb); + if (infra.images.redis !== existingInfra.images.redis) startFuncs.push(startRedis); + if (infra.images.graphite !== existingInfra.images.graphite) startFuncs.push(startGraphite); + if (infra.images.sftp !== existingInfra.images.sftp) startFuncs.push(sftp.start); debug('startServices: existing infra. incremental service create %j', startFuncs.map(function (f) { return f.name; })); } @@ -919,7 +912,7 @@ async function startTurn(existingInfra) { assert.strictEqual(typeof existingInfra, 'object'); const serviceConfig = await getServiceConfig('turn'); - const tag = infra.images.turn.tag; + const image = infra.images.turn; const memoryLimit = serviceConfig.memoryLimit || SERVICES['turn'].defaultMemoryLimit; const memory = await system.getMemoryAllocation(memoryLimit); const realm = settings.dashboardFqdn(); @@ -951,7 +944,7 @@ async function startTurn(existingInfra) { -e CLOUDRON_TURN_SECRET="${turnSecret}" \ -e CLOUDRON_REALM="${realm}" \ --label isCloudronManaged=true \ - ${readOnly} -v /tmp -v /run "${tag}" ${cmd}`; + ${readOnly} -v /tmp -v /run "${image}" ${cmd}`; await shell.promises.exec('stopTurn', 'docker stop turn || true'); await shell.promises.exec('removeTurn', 'docker rm -f turn || true'); @@ -1124,12 +1117,12 @@ function mysqlDatabaseName(appId) { async function startMysql(existingInfra) { assert.strictEqual(typeof existingInfra, 'object'); - const tag = infra.images.mysql.tag; + const image = infra.images.mysql; const dataDir = paths.PLATFORM_DATA_DIR; const rootPassword = hat(8 * 128); const cloudronToken = hat(8 * 128); - const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.mysql.tag, tag); + const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.mysql, image); if (upgrading) { debug('startMysql: mysql will be upgraded'); @@ -1158,7 +1151,7 @@ async function startMysql(existingInfra) { -v "${dataDir}/mysql:/var/lib/mysql" \ --label isCloudronManaged=true \ --cap-add SYS_NICE \ - ${readOnly} -v /tmp -v /run "${tag}" ${cmd}`; + ${readOnly} -v /tmp -v /run "${image}" ${cmd}`; await shell.promises.exec('stopMysql', 'docker stop mysql || true'); await shell.promises.exec('removeMysql', 'docker rm -f mysql || true'); @@ -1342,12 +1335,12 @@ function postgreSqlNames(appId) { async function startPostgresql(existingInfra) { assert.strictEqual(typeof existingInfra, 'object'); - const tag = infra.images.postgresql.tag; + const image = infra.images.postgresql; const dataDir = paths.PLATFORM_DATA_DIR; const rootPassword = hat(8 * 128); const cloudronToken = hat(8 * 128); - const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.postgresql.tag, tag); + const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.postgresql, image); if (upgrading) { debug('startPostgresql: postgresql will be upgraded'); @@ -1375,7 +1368,7 @@ async function startPostgresql(existingInfra) { -e CLOUDRON_POSTGRESQL_TOKEN="${cloudronToken}" \ -v "${dataDir}/postgresql:/var/lib/postgresql" \ --label isCloudronManaged=true \ - ${readOnly} -v /tmp -v /run "${tag}" ${cmd}`; + ${readOnly} -v /tmp -v /run "${image}" ${cmd}`; await shell.promises.exec('stopPostgresql', 'docker stop postgresql || true'); await shell.promises.exec('removePostgresql', 'docker rm -f postgresql || true'); @@ -1486,12 +1479,12 @@ async function restorePostgreSql(app, options) { async function startMongodb(existingInfra) { assert.strictEqual(typeof existingInfra, 'object'); - const tag = infra.images.mongodb.tag; + const image = infra.images.mongodb; const dataDir = paths.PLATFORM_DATA_DIR; const rootPassword = hat(8 * 128); const cloudronToken = hat(8 * 128); - const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.mongodb.tag, tag); + const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.mongodb, image); if (upgrading) { debug('startMongodb: mongodb will be upgraded'); @@ -1518,7 +1511,7 @@ async function startMongodb(existingInfra) { -e CLOUDRON_MONGODB_TOKEN="${cloudronToken}" \ -v "${dataDir}/mongodb:/var/lib/mongodb" \ --label isCloudronManaged=true \ - ${readOnly} -v /tmp -v /run "${tag}" ${cmd}`; + ${readOnly} -v /tmp -v /run "${image}" ${cmd}`; await shell.promises.exec('stopMongodb', 'docker stop mongodb || true'); await shell.promises.exec('removeMongodb', 'docker rm -f mongodb || true'); @@ -1639,11 +1632,11 @@ async function startGraphite(existingInfra) { assert.strictEqual(typeof existingInfra, 'object'); const serviceConfig = await getServiceConfig('graphite'); - const tag = infra.images.graphite.tag; + const image = infra.images.graphite; const memoryLimit = serviceConfig.memoryLimit || 256 * 1024 * 1024; const memory = await system.getMemoryAllocation(memoryLimit); - const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.graphite.tag, tag); + const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.graphite, image); if (upgrading) debug('startGraphite: graphite will be upgraded'); @@ -1666,7 +1659,7 @@ async function startGraphite(existingInfra) { -p 127.0.0.1:2003:2003 \ -v "${paths.PLATFORM_DATA_DIR}/graphite:/var/lib/graphite" \ --label isCloudronManaged=true \ - ${readOnly} -v /tmp -v /run "${tag}" ${cmd}`; + ${readOnly} -v /tmp -v /run "${image}" ${cmd}`; await shell.promises.exec('stopGraphite', 'docker stop graphite || true'); await shell.promises.exec('removeGraphite', 'docker rm -f graphite || true'); @@ -1701,8 +1694,8 @@ async function teardownProxyAuth(app, options) { async function startRedis(existingInfra) { assert.strictEqual(typeof existingInfra, 'object'); - const tag = infra.images.redis.tag; - const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.redis.tag, tag); + const image = infra.images.redis; + const upgrading = existingInfra.version !== 'none' && requiresUpgrade(existingInfra.images.redis, image); const allApps = await apps.list(); @@ -1744,7 +1737,7 @@ async function setupRedis(app, options) { const readOnly = !recoveryMode ? '--read-only' : ''; const cmd = recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : ''; - const tag = infra.images.redis.tag; + const image = infra.images.redis; const label = app.fqdn; // note that we do not add appId label because this interferes with the stop/start app logic const runCmd = `docker run --restart=always -d --name=${redisName} \ @@ -1764,7 +1757,7 @@ async function setupRedis(app, options) { -e CLOUDRON_REDIS_TOKEN="${redisServiceToken}" \ -v "${paths.PLATFORM_DATA_DIR}/redis/${app.id}:/var/lib/redis" \ --label isCloudronManaged=true \ - ${readOnly} -v /tmp -v /run ${tag} ${cmd}`; + ${readOnly} -v /tmp -v /run ${image} ${cmd}`; const env = [ { name: 'CLOUDRON_REDIS_URL', value: 'redis://redisuser:' + redisPassword + '@redis-' + app.id }, diff --git a/src/sftp.js b/src/sftp.js index 564d97db8..fd9e86676 100644 --- a/src/sftp.js +++ b/src/sftp.js @@ -52,7 +52,7 @@ async function start(existingInfra) { debug('start: re-creating container'); const serviceConfig = await services.getServiceConfig('sftp'); - const tag = infra.images.sftp.tag; + const image = infra.images.sftp; const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT; const memory = await system.getMemoryAllocation(memoryLimit); const cloudronToken = hat(8 * 128); @@ -119,7 +119,7 @@ async function start(existingInfra) { -e CLOUDRON_SFTP_TOKEN="${cloudronToken}" \ -v "${paths.SFTP_KEYS_DIR}:/etc/ssh:ro" \ --label isCloudronManaged=true \ - ${readOnly} -v /tmp -v /run "${tag}" ${cmd}`; + ${readOnly} -v /tmp -v /run "${image}" ${cmd}`; // ignore error if container not found (and fail later) so that this code works across restarts await shell.promises.exec('stopSftp', 'docker stop sftp || true'); diff --git a/src/test/check-install b/src/test/check-install index 3ae80df31..86a040c20 100755 --- a/src/test/check-install +++ b/src/test/check-install @@ -47,7 +47,7 @@ if [[ ${#missing_scripts[@]} -gt 0 ]]; then exit 1 fi -images=$(node -e "let i = require('${SOURCE_DIR}/src/infra_version.js'); console.log(Object.keys(i.images).map(function (x) { return i.images[x].tag; }).join('\n'));"; echo $TEST_IMAGE) +images=$(node -e "const i = require('${box_src_tmp_dir}/src/infra_version.js'); console.log(Object.keys(i.images).map(x => i.images[x]).join(' '));") for image in ${images}; do if ! docker inspect "${image}" >/dev/null 2>/dev/null; then