diff --git a/CHANGES b/CHANGES index e19fbce1a..e9e7c8346 100644 --- a/CHANGES +++ b/CHANGES @@ -2880,4 +2880,5 @@ * backups: implement app archive * notifications: per user email notification config * postgres: enable vector extension +* docker: fallback to downloading images from quay if dockerhub does not work diff --git a/src/apptask.js b/src/apptask.js index 40edbfe71..297b58930 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -315,7 +315,7 @@ async function install(app, args, progressCallback) { } if (oldManifest && oldManifest.dockerImage !== app.manifest.dockerImage) { - await docker.deleteImage(oldManifest); + await docker.deleteImage(oldManifest.dockerImage); } // allocating container ip here, lets the users "repair" an app if allocation fails at apps.add time @@ -632,7 +632,7 @@ async function update(app, args, progressCallback) { // we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings await progressCallback({ percent: 35, message: 'Deleting old containers' }); await deleteContainers(app, { managedOnly: true }); - if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest); + if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest.dockerImage); // only delete unused addons after backup await services.teardownAddons(app, unusedAddons); @@ -771,7 +771,7 @@ async function uninstall(app, args, progressCallback) { await deleteAppDir(app, { removeDirectory: true }); await progressCallback({ percent: 60, message: 'Deleting image' }); - await docker.deleteImage(app.manifest); + await docker.deleteImage(app.manifest.dockerImage); await progressCallback({ percent: 70, message: 'Unregistering domains' }); await dns.unregisterLocations([ { subdomain: app.subdomain, domain: app.domain } ].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback); diff --git a/src/docker.js b/src/docker.js index 6cf2aefb4..820e6f8d4 100644 --- a/src/docker.js +++ b/src/docker.js @@ -76,6 +76,27 @@ function removePrivateFields(registryConfig) { return registryConfig; } +function parseImageRef(imageRef) { + assert.strictEqual(typeof imageRef, 'string'); + + // a ref is like registry.docker.com/cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4 + // registry.docker.com is registry name . cloudron is namespace . base is image name . cloudron/base is repository path + // registry.docker.com/cloudron/base is fullRepositoryName + const result = { fullRepositoryName: null, registry: null, tag: null, digest: null }; + result.fullRepositoryName = imageRef.split(/[:@]/)[0]; + const parts = result.fullRepositoryName.split('/'); + result.registry = parts.length === 3 ? parts[0] : null; + let remaining = imageRef.substr(result.fullRepositoryName.length); + if (remaining.startsWith(':')) { + result.tag = remaining.substr(1).split('@', 1)[0]; + remaining = remaining.substr(result.tag.length + 1); // also ':' + } + + if (remaining.startsWith('@sha256:')) result.digest = remaining.substr(8); + + return result; +} + async function ping() { // do not let the request linger const connection = new Docker({ socketPath: DOCKER_SOCKET_PATH, timeout: 1000 }); @@ -88,11 +109,13 @@ async function ping() { throw new BoxError(BoxError.DOCKER_ERROR, 'Unable to ping the docker daemon'); } -async function getAuthConfig(image) { - const parsedRef = parseImageRef(image); +async function getAuthConfig(imageRef) { + assert.strictEqual(typeof imageRef, 'string'); + + const parsedRef = parseImageRef(imageRef); // images in our cloudron namespace are always unauthenticated to not interfere with any user limits - if (parsedRef.registry === null && image.startsWith('cloudron/')) return null; + if (parsedRef.registry === null && parsedRef.fullRepositoryName.startsWith('cloudron/')) return null; const registryConfig = await getRegistryConfig(); if (registryConfig.provider === 'noop') return null; @@ -114,18 +137,18 @@ async function getAuthConfig(image) { return autoConfig; } -async function pullImage(dockerImage) { - assert.strictEqual(typeof dockerImage, 'string'); +async function pullImage(imageRef) { + assert.strictEqual(typeof imageRef, 'string'); - const authConfig = await getAuthConfig(dockerImage); + const authConfig = await getAuthConfig(imageRef); - debug(`pullImage: will pull ${dockerImage}. auth: ${authConfig ? 'yes' : 'no'}`); + debug(`pullImage: will pull ${imageRef}. auth: ${authConfig ? 'yes' : 'no'}`); - const [error, stream] = await safe(gConnection.pull(dockerImage, { authconfig: authConfig })); - if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND, `Unable to pull image ${dockerImage}. message: ${error.message} statusCode: ${error.statusCode}`); + const [error, stream] = await safe(gConnection.pull(imageRef, { authconfig: authConfig })); + if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND, `Unable to pull image ${imageRef}. message: ${error.message} statusCode: ${error.statusCode}`); // toomanyrequests is flagged as a 500. dockerhub appears to have 10 pulls her hour per IP limit - if (error && error.statusCode === 500) throw new BoxError(BoxError.DOCKER_ERROR, `Unable to pull image ${dockerImage}. registry error: ${JSON.stringify(error)}`); - if (error) throw new BoxError(BoxError.DOCKER_ERROR, `Unable to pull image ${dockerImage}. Please check the network or if the image needs authentication. statusCode: ${error.statusCode}`); + if (error && error.statusCode === 500) throw new BoxError(BoxError.DOCKER_ERROR, `Unable to pull image ${imageRef}. registry error: ${JSON.stringify(error)}`); + if (error) throw new BoxError(BoxError.DOCKER_ERROR, `Unable to pull image ${imageRef}. 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 @@ -136,13 +159,13 @@ async function pullImage(dockerImage) { // 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 ${dockerImage}: ${data.errorDetail.message}`); + debug(`pullImage error ${imageRef}: ${data.errorDetail.message}`); layerError = data.errorDetail; } }); stream.on('end', function () { - debug(`downloaded image ${dockerImage} . error: ${!!layerError}`); + debug(`downloaded image ${imageRef} . error: ${!!layerError}`); if (!layerError) return resolve(); @@ -150,7 +173,7 @@ async function pullImage(dockerImage) { }); stream.on('error', function (error) { // this is only hit for stream error and not for some download error - debug(`error pulling image ${dockerImage}: %o`, error); + debug(`error pulling image ${imageRef}: %o`, error); reject(new BoxError(BoxError.DOCKER_ERROR, error.message)); }); }); @@ -159,15 +182,31 @@ async function pullImage(dockerImage) { async function downloadImage(manifest) { assert.strictEqual(typeof manifest, 'object'); - debug(`downloadImage ${manifest.dockerImage}`); + 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.dockerImage); + const parsedManifestRef = parseImageRef(manifest.dockerImage); + + await promiseRetry({ times: 10, interval: 5000, debug, retry: (pullError) => pullError.reason !== BoxError.FS_ERROR }, async () => { + if (parsedManifestRef.registry !== null || !parsedManifestRef.fullRepositoryName.startsWith('cloudron/')) return await pullImage(parsedManifestRef); + + let upstreamRef = `registry.docker.com/${manifest.dockerImage}`; + const [pullError] = await safe(pullImage(upstreamRef)); + if (pullError) { + debug(`downloadImage: failed to download image from dockerhub, trying quay.io`); + upstreamRef = `quay.io/${manifest.dockerImage}`; + await pullImage(upstreamRef); + } + + // retag the downloaded image to not have the registry name. this prevents 'docker run' from redownloading it + debug(`downloadImage: tagging ${upstreamRef} as ${parsedManifestRef.fullRepositoryName}:${parsedManifestRef.tag}`); + await gConnection.getImage(upstreamRef).tag({ repo: parsedManifestRef.fullRepositoryName, tag: parsedManifestRef.tag }); + debug(`downloadImage: untagging ${upstreamRef}`); + await deleteImage(upstreamRef); }); } @@ -532,12 +571,11 @@ async function stopContainers(appId) { } } -async function deleteImage(manifest) { - assert(!manifest || typeof manifest === 'object'); +async function deleteImage(imageRef) { + assert.strictEqual(typeof imageRef, 'string'); - 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) + if (!imageRef) return; + if (imageRef.includes('//') || imageRef.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 @@ -547,13 +585,13 @@ async function deleteImage(manifest) { // 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)); + const [error] = await safe(gConnection.getImage(imageRef).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); + debug(`Error removing image ${imageRef} : %o`, error); throw new BoxError(BoxError.DOCKER_ERROR, error); } } @@ -691,22 +729,3 @@ async function setRegistryConfig(registryConfig) { await settings.setJson(settings.REGISTRY_CONFIG_KEY, registryConfig); } - -function parseImageRef(ref) { - // a ref is like registry.docker.com/cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4 - // registry.docker.com is registry name . cloudron is namespace . base is image name . cloudron/base is repository path - // registry.docker.com/cloudron/base is fullRepositoryName - const result = { fullRepositoryName: null, registry: null, tag: null, digest: null }; - result.fullRepositoryName = ref.split(/[:@]/)[0]; - const parts = result.fullRepositoryName.split('/'); - result.registry = parts.length === 3 ? parts[0] : null; - let remaining = ref.substr(result.fullRepositoryName.length); - if (remaining.startsWith(':')) { - result.tag = remaining.substr(1).split('@', 1)[0]; - remaining = remaining.substr(result.tag.length + 1); // also ':' - } - - if (remaining.startsWith('@sha256:')) result.digest = remaining.substr(8); - - return result; -}