Switch to a new image management system keeping the docker image ID in the database, allowing deltas and proper comparison for images that have a digest.

Signed-off-by: Pablo Carranza Velez <pablo@resin.io>
This commit is contained in:
Pablo Carranza Velez 2017-12-14 21:15:59 -08:00
parent d84bcf0fb4
commit 3a710506a6
7 changed files with 182 additions and 92 deletions

View File

@ -10,6 +10,7 @@ process.env.DOCKER_HOST ?= "unix://#{constants.dockerSocket}"
Docker = require './lib/docker-utils' Docker = require './lib/docker-utils'
updateLock = require './lib/update-lock' updateLock = require './lib/update-lock'
{ checkTruthy, checkInt, checkString } = require './lib/validation' { checkTruthy, checkInt, checkString } = require './lib/validation'
{ NotFoundError } = require './lib/errors'
ServiceManager = require './compose/service-manager' ServiceManager = require './compose/service-manager'
Service = require './compose/service' Service = require './compose/service'
@ -28,7 +29,7 @@ serviceAction = (action, serviceId, current, target, options) ->
# TODO: move this to an Image class? # TODO: move this to an Image class?
imageForService = (service) -> imageForService = (service) ->
return { return {
name: service.image name: service.imageName
appId: service.appId appId: service.appId
serviceId: service.serviceId serviceId: service.serviceId
serviceName: service.serviceName serviceName: service.serviceName
@ -186,7 +187,7 @@ module.exports = class ApplicationManager extends EventEmitter
@services.kill(step.current) @services.kill(step.current)
.then => .then =>
if step.options?.removeImage if step.options?.removeImage
@images.remove(imageForService(step.current)) @images.removeByDockerId(step.current.image)
updateMetadata: (step) => updateMetadata: (step) =>
@services.updateMetadata(step.current, step.target) @services.updateMetadata(step.current, step.target)
purge: (step, { force = false } = {}) => purge: (step, { force = false } = {}) =>
@ -238,8 +239,8 @@ module.exports = class ApplicationManager extends EventEmitter
@reportCurrentState(update_downloaded: true) @reportCurrentState(update_downloaded: true)
removeImage: (step) => removeImage: (step) =>
@images.remove(step.image) @images.remove(step.image)
updateImage: (step) => saveImage: (step) =>
@images.update(step.target) @images.save(step.image)
cleanup: (step) => cleanup: (step) =>
@images.cleanup() @images.cleanup()
createNetworkOrVolume: (step) => createNetworkOrVolume: (step) =>
@ -431,6 +432,7 @@ module.exports = class ApplicationManager extends EventEmitter
target: targetServicesPerId[serviceId] target: targetServicesPerId[serviceId]
serviceId serviceId
}) })
return { removePairs, installPairs, updatePairs } return { removePairs, installPairs, updatePairs }
_compareNetworksOrVolumesForUpdate: (model, { current, target }, appId) -> _compareNetworksOrVolumesForUpdate: (model, { current, target }, appId) ->
@ -480,19 +482,6 @@ module.exports = class ApplicationManager extends EventEmitter
compareVolumesForUpdate: ({ current, target }, appId) => compareVolumesForUpdate: ({ current, target }, appId) =>
@_compareNetworksOrVolumesForUpdate(@volumes, { current, target }, appId) @_compareNetworksOrVolumesForUpdate(@volumes, { current, target }, appId)
# TODO: should we consider the case where several services use the same image?
# In such case we should do more complex matching to allow several image objects
# with the same name but different metadata. For now this shouldn't be necessary
# because the Resin model requires a different imageId and name for each service.
compareImagesForMetadataUpdate: (availableImages, targetServices) ->
pairs = []
targetImages = _.map(targetServices, imageForService)
for target in targetImages
imageWithSameContent = _.find(availableImages, (img) => @images.isSameImage(img, target))
if imageWithSameContent? and !_.find(availableImages, (img) -> _.isEqual(_.omit(img, 'id'), target))
pairs.push({ current: imageWithSameContent, target, serviceId: target.serviceId })
return pairs
# Checks if a service is using a network or volume that is about to be updated # Checks if a service is using a network or volume that is about to be updated
_hasCurrentNetworksOrVolumes: (service, networkPairs, volumePairs) -> _hasCurrentNetworksOrVolumes: (service, networkPairs, volumePairs) ->
if !service? if !service?
@ -537,7 +526,7 @@ module.exports = class ApplicationManager extends EventEmitter
if target.dependsOn? if target.dependsOn?
for dependency in target.dependsOn for dependency in target.dependsOn
dependencyService = _.find(targetApp.services, (s) -> s.serviceName == dependency) dependencyService = _.find(targetApp.services, (s) -> s.serviceName == dependency)
if !_.find(availableImages, (image) => @images.isSameImage(image, { name: dependencyService.image }))? if !_.find(availableImages, (image) => @images.isSameImage(image, { name: dependencyService.imageName }))?
return false return false
return true return true
@ -619,7 +608,7 @@ module.exports = class ApplicationManager extends EventEmitter
# There is already a step in progress for this service, so we wait # There is already a step in progress for this service, so we wait
return null return null
needsDownload = !_.some(availableImages, (image) => @images.isSameImage(image, { name: target.image })) needsDownload = !_.some(availableImages, (image) => @images.isSameImage(image, { name: target.imageName }))
dependenciesMetForStart = => dependenciesMetForStart = =>
@_dependenciesMetForServiceStart(target, networkPairs, volumePairs, installPairs.concat(updatePairs), stepsInProgress) @_dependenciesMetForServiceStart(target, networkPairs, volumePairs, installPairs.concat(updatePairs), stepsInProgress)
dependenciesMetForKill = => dependenciesMetForKill = =>
@ -656,7 +645,6 @@ module.exports = class ApplicationManager extends EventEmitter
networkPairs = @compareNetworksForUpdate({ current: currentApp.networks, target: targetApp.networks }, appId) networkPairs = @compareNetworksForUpdate({ current: currentApp.networks, target: targetApp.networks }, appId)
volumePairs = @compareVolumesForUpdate({ current: currentApp.volumes, target: targetApp.volumes }, appId) volumePairs = @compareVolumesForUpdate({ current: currentApp.volumes, target: targetApp.volumes }, appId)
{ removePairs, installPairs, updatePairs } = @compareServicesForUpdate(currentApp.services, targetApp.services) { removePairs, installPairs, updatePairs } = @compareServicesForUpdate(currentApp.services, targetApp.services)
imagePairs = @compareImagesForMetadataUpdate(availableImages, targetApp.services)
steps = [] steps = []
# All removePairs get a 'kill' action # All removePairs get a 'kill' action
for pair in removePairs for pair in removePairs
@ -676,8 +664,6 @@ module.exports = class ApplicationManager extends EventEmitter
for pair in volumePairs for pair in volumePairs
pairSteps = @_nextStepsForVolume(pair, currentApp, removePairs.concat(updatePairs)) pairSteps = @_nextStepsForVolume(pair, currentApp, removePairs.concat(updatePairs))
steps = steps.concat(pairSteps) steps = steps.concat(pairSteps)
for pair in imagePairs
steps.push(_.assign({ action: 'updateImage' }, pair))
return steps return steps
normaliseAppForDB: (app) => normaliseAppForDB: (app) =>
@ -705,13 +691,16 @@ module.exports = class ApplicationManager extends EventEmitter
createTargetService: (service, opts) -> createTargetService: (service, opts) ->
@images.inspectByName(service.image) @images.inspectByName(service.image)
.catchReturn(undefined) .catchReturn(NotFoundError, undefined)
.then (imageInfo) -> .then (imageInfo) ->
serviceOpts = { serviceOpts = {
serviceName: service.serviceName serviceName: service.serviceName
imageInfo imageInfo
} }
_.assign(serviceOpts, opts) _.assign(serviceOpts, opts)
service.imageName = service.image
if imageInfo?.Id?
service.image = imageInfo.Id
return new Service(service, serviceOpts) return new Service(service, serviceOpts)
normaliseAndExtendAppFromDB: (app) => normaliseAndExtendAppFromDB: (app) =>
@ -803,30 +792,40 @@ module.exports = class ApplicationManager extends EventEmitter
return availableImage.name return availableImage.name
return 'resin/scratch' return 'resin/scratch'
# return images that: # returns:
# imagesToRemove: images that
# - are not used in the current state, and # - are not used in the current state, and
# - are not going to be used in the target state, and # - are not going to be used in the target state, and
# - are not needed for delta source / pull caching or would be used for a service with delete-then-download as strategy # - are not needed for delta source / pull caching or would be used for a service with delete-then-download as strategy
_unnecessaryImages: (current, target, available) => # imagesToSave: images that
# - are locally available (i.e. an image with the same digest exists)
# - are not saved to the DB with all their metadata (serviceId, serviceName, etc)
_compareImages: (current, target, available) =>
allImagesForApp = (app) -> _.map(app.services, imageForService) allImagesForTargetApp = (app) -> _.map(app.services, imageForService)
allImagesForCurrentApp = (app) ->
currentImages = _.flatten(_.map(current.local.apps, allImagesForApp)) _.map app.services, (service) ->
targetImages = _.flatten(_.map(target.local.apps, allImagesForApp)) _.omit(_.find(available, (image) -> image.dockerImageId == service.image), [ 'dockerImageId', 'id' ])
availableAndUnused = _.filter available, (image) => availableWithoutIds = _.map(available, (image) -> _.omit(image, [ 'dockerImageId', 'id' ]))
!_.some currentImages.concat(targetImages), (imageInUse) => @images.isSameImage(image, imageInUse) currentImages = _.flatten(_.map(current.local.apps, allImagesForCurrentApp))
targetImages = _.flatten(_.map(target.local.apps, allImagesForTargetApp))
availableAndUnused = _.filter availableWithoutIds, (image) ->
!_.some currentImages.concat(targetImages), (imageInUse) -> _.isEqual(image, imageInUse)
imagesToDownload = _.filter targetImages, (targetImage) => imagesToDownload = _.filter targetImages, (targetImage) =>
!_.some available, (availableImage) => @images.isSameImage(availableImage, targetImage) !_.some available, (availableImage) => @images.isSameImage(availableImage, targetImage)
# Images that are available but we don't have them in the DB with the exact metadata:
imagesToSave = _.filter targetImages, (targetImage) =>
_.some(available, (availableImage) => @images.isSameImage(availableImage, targetImage)) and
!_.find(availableWithoutIds, (img) -> _.isEqual(img, targetImage))?
deltaSources = _.map imagesToDownload, (image) => deltaSources = _.map imagesToDownload, (image) =>
return @bestDeltaSource(image, available) return @bestDeltaSource(image, available)
proxyvisorImages = @proxyvisor.imagesInUse(current, target) proxyvisorImages = @proxyvisor.imagesInUse(current, target)
imagesToRemove = _.filter availableAndUnused, (image) =>
return _.filter availableAndUnused, (image) =>
notUsedForDelta = !_.some deltaSources, (deltaSource) -> deltaSource == image.name notUsedForDelta = !_.some deltaSources, (deltaSource) -> deltaSource == image.name
notUsedByProxyvisor = !_.some proxyvisorImages, (proxyvisorImage) => @images.isSameImage(image, { name: proxyvisorImage }) notUsedByProxyvisor = !_.some proxyvisorImages, (proxyvisorImage) => @images.isSameImage(image, { name: proxyvisorImage })
return notUsedForDelta and notUsedByProxyvisor return notUsedForDelta and notUsedByProxyvisor
return { imagesToSave, imagesToRemove }
_inferNextSteps: (cleanupNeeded, availableImages, supervisorNetworkReady, current, target, stepsInProgress) => _inferNextSteps: (cleanupNeeded, availableImages, supervisorNetworkReady, current, target, stepsInProgress) =>
Promise.try => Promise.try =>
@ -839,9 +838,12 @@ module.exports = class ApplicationManager extends EventEmitter
if !_.some(stepsInProgress, (step) -> step.action == 'fetch') if !_.some(stepsInProgress, (step) -> step.action == 'fetch')
if cleanupNeeded if cleanupNeeded
nextSteps.push({ action: 'cleanup' }) nextSteps.push({ action: 'cleanup' })
imagesToRemove = @_unnecessaryImages(current, target, availableImages) { imagesToRemove, imagesToSave } = @_compareImages(current, target, availableImages)
for image in imagesToRemove for image in imagesToSave
nextSteps.push({ action: 'removeImage', image }) nextSteps.push({ action: 'saveImage', image })
if _.isEmpty(imagesToSave)
for image in imagesToRemove
nextSteps.push({ action: 'removeImage', image })
# If we have to remove any images, we do that before anything else # If we have to remove any images, we do that before anything else
if _.isEmpty(nextSteps) if _.isEmpty(nextSteps)
allAppIds = _.union(_.keys(currentByAppId), _.keys(targetByAppId)) allAppIds = _.union(_.keys(currentByAppId), _.keys(targetByAppId))

View File

@ -8,13 +8,14 @@ validation = require '../lib/validation'
{ NotFoundError } = require '../lib/errors' { NotFoundError } = require '../lib/errors'
# image = { # image = {
# name: image registry/repo:tag # name: image registry/repo@digest or registry/repo:tag
# appId # appId
# serviceId # serviceId
# serviceName # serviceName
# imageId (from resin API) # imageId (from resin API)
# releaseId # releaseId
# dependent # dependent
# dockerImageId
# status Downloading, Downloaded, Deleting # status Downloading, Downloaded, Deleting
# downloadProgress # downloadProgress
# } # }
@ -45,15 +46,25 @@ module.exports = class Images extends EventEmitter
@markAsSupervised(image) @markAsSupervised(image)
.then => .then =>
@inspectByName(imageName) @inspectByName(imageName)
.tap (img) =>
@db.models('image').update({ dockerImageId: img.Id }).where(image)
.catch => .catch =>
@reportChange(image.imageId, _.merge(_.clone(image), { status: 'Downloading', downloadProgress: 0 })) @reportChange(image.imageId, _.merge(_.clone(image), { status: 'Downloading', downloadProgress: 0 }))
Promise.try => Promise.try =>
if validation.checkTruthy(opts.delta) if validation.checkTruthy(opts.delta)
@logger.logSystemEvent(logTypes.downloadImageDelta, { image }) @logger.logSystemEvent(logTypes.downloadImageDelta, { image })
@docker.rsyncImageWithProgress(imageName, opts, onProgress) Promise.try =>
if opts.deltaSource
@inspectByName(opts.deltaSource)
.then (srcImage) ->
opts.deltaSourceId = srcImage.Id
.then =>
@docker.rsyncImageWithProgress(imageName, opts, onProgress)
else else
@logger.logSystemEvent(logTypes.downloadImage, { image }) @logger.logSystemEvent(logTypes.downloadImage, { image })
@docker.fetchImageWithProgress(imageName, opts, onProgress) @docker.fetchImageWithProgress(imageName, opts, onProgress)
.then (id) =>
@db.models('image').update({ dockerImageId: id }).where(image)
.then => .then =>
@logger.logSystemEvent(logTypes.downloadImageSuccess, { image }) @logger.logSystemEvent(logTypes.downloadImageSuccess, { image })
@inspectByName(imageName) @inspectByName(imageName)
@ -68,7 +79,8 @@ module.exports = class Images extends EventEmitter
image.serviceName ?= null image.serviceName ?= null
image.imageId ?= null image.imageId ?= null
image.releaseId ?= null image.releaseId ?= null
image.dependent ?= false image.dependent ?= 0
image.dockerImageId ?= null
return _.omit(image, 'id') return _.omit(image, 'id')
markAsSupervised: (image) => markAsSupervised: (image) =>
@ -79,39 +91,64 @@ module.exports = class Images extends EventEmitter
image = @format(image) image = @format(image)
@db.models('image').update(image).where(name: image.name) @db.models('image').update(image).where(name: image.name)
_removeImageIfNotNeeded: (image) => save: (image) =>
@inspectByName(image.name) @inspectByName(image.name)
.then (img) => .then (img) =>
@db.models('image').where(name: image.name).select() image = _.clone(image)
.then (imagesFromDB) => image.dockerImageId = img.Id
if imagesFromDB.length == 1 and _.isEqual(@format(imagesFromDB[0]), @format(image)) @markAsSupervised(image)
@docker.getImage(image.name).remove(force: true)
.return(true)
.catchReturn(NotFoundError, false)
remove: (image) => _removeImageIfNotNeeded: (image) =>
@reportChange(image.imageId, _.merge(_.clone(image), { status: 'Deleting' })) # We first fetch the image from the DB to ensure it exists,
@logger.logSystemEvent(logTypes.deleteImage, { image }) # and get the dockerImageId and any other missing field
@_removeImageIfNotNeeded(image) @db.models('image').select().where(image)
.tap => .then (images) =>
@db.models('image').del().where(image) if images.length == 0
return false
img = images[0]
Promise.try =>
if !img.dockerImageId?
# Legacy image from before we started using dockerImageId, so we try to remove it by name
@docker.getImage(img.name).remove(force: true)
.return(true)
else
@db.models('image').where(dockerImageId: img.dockerImageId).select()
.then (imagesFromDB) =>
if imagesFromDB.length == 1 and _.isEqual(@format(imagesFromDB[0]), @format(img))
@reportChange(image.imageId, _.merge(_.clone(image), { status: 'Deleting' }))
@logger.logSystemEvent(logTypes.deleteImage, { image })
@docker.getImage(img.dockerImageId).remove(force: true)
.return(true)
else
return false
.catchReturn(NotFoundError, false)
.tap =>
@db.models('image').del().where(id: img.id)
.then (removed) => .then (removed) =>
if removed if removed
@logger.logSystemEvent(logTypes.deleteImageSuccess, { image }) @logger.logSystemEvent(logTypes.deleteImageSuccess, { image })
else .finally =>
@logger.logSystemEvent(logTypes.imageAlreadyDeleted, { image }) @reportChange(image.imageId)
remove: (image) =>
@_removeImageIfNotNeeded(image)
.catch (err) => .catch (err) =>
@logger.logSystemEvent(logTypes.deleteImageError, { image, error: err }) @logger.logSystemEvent(logTypes.deleteImageError, { image, error: err })
throw err throw err
.finally =>
@reportChange(image.imageId) getByDockerId: (id) =>
@db.models('image').where(dockerImageId: id).first()
removeByDockerId: (id) =>
@getByDockerId(id)
.then(@remove)
getNormalisedTags: (image) -> getNormalisedTags: (image) ->
Promise.map(image.RepoTags ? [], (tag) => @normalise(tag)) Promise.map(image.RepoTags ? [], (tag) => @normalise(tag))
_withImagesFromDockerAndDB: (callback) => _withImagesFromDockerAndDB: (callback) =>
Promise.join( Promise.join(
@docker.listImages() @docker.listImages(digests: true)
.map (image) => .map (image) =>
image.NormalisedRepoTags = @getNormalisedTags(image) image.NormalisedRepoTags = @getNormalisedTags(image)
Promise.props(image) Promise.props(image)
@ -119,9 +156,13 @@ module.exports = class Images extends EventEmitter
callback callback
) )
_isAvailableInDocker: (image, dockerImages) -> _matchesTagOrDigest: (image, dockerImage) ->
_.some dockerImages, (dockerImage) -> return _.includes(dockerImage.NormalisedRepoTags, image.name) or
_.includes(dockerImage.NormalisedRepoTags, image.name) or _.includes(dockerImage.RepoDigests, image.name) _.some(dockerImage.RepoDigests, (digest) -> Images.hasSameDigest(image.name, digest))
_isAvailableInDocker: (image, dockerImages) =>
_.some dockerImages, (dockerImage) =>
@_matchesTagOrDigest(image, dockerImage) or image.dockerImageId == dockerImage.Id
# Gets all images that are supervised, in an object containing name, appId, serviceId, serviceName, imageId, dependent. # Gets all images that are supervised, in an object containing name, appId, serviceId, serviceName, imageId, dependent.
getAvailable: => getAvailable: =>
@ -130,7 +171,17 @@ module.exports = class Images extends EventEmitter
cleanupDatabase: => cleanupDatabase: =>
@_withImagesFromDockerAndDB (dockerImages, supervisedImages) => @_withImagesFromDockerAndDB (dockerImages, supervisedImages) =>
return _.filter(supervisedImages, (image) => !@_isAvailableInDocker(image, dockerImages)) Promise.map supervisedImages, (image) =>
# If the supervisor was interrupted between fetching an image and storing its id,
# some entries in the db might need to have the dockerImageId populated
if !image.dockerImageId?
id = _.find(dockerImages, (dockerImage) => @_matchesTagOrDigest(image, dockerImage))?.Id
if id?
@db.models('image').update(dockerImageId: id).where(image)
.then ->
image.dockerImageId = id
.then =>
_.filter(supervisedImages, (image) => !@_isAvailableInDocker(image, dockerImages))
.then (imagesToRemove) => .then (imagesToRemove) =>
ids = _.map(imagesToRemove, 'id') ids = _.map(imagesToRemove, 'id')
@db.models('image').del().whereIn('id', ids) @db.models('image').del().whereIn('id', ids)
@ -147,35 +198,56 @@ module.exports = class Images extends EventEmitter
status[image.imageId] ?= image status[image.imageId] ?= image
return _.values(status) return _.values(status)
_getOldSupervisorsForCleanup: => _getImagesForCleanup: =>
images = [] images = []
@docker.getRegistryAndName(constants.supervisorImage) Promise.join(
.then (supervisorImageInfo) => @docker.getRegistryAndName(constants.supervisorImage)
@docker.listImages() @db.models('image').select('dockerImageId')
.map (image) => .map((image) -> image.dockerImageId)
Promise.map image.RepoTags ? [], (repoTag) => (supervisorImageInfo, usedImageIds) =>
@docker.getRegistryAndName(repoTag) @docker.listImages(digests: true)
.then ({ imageName, tagName }) -> .map (image) =>
if imageName == supervisorImageInfo.imageName and tagName != supervisorImageInfo.tagName # Cleanup should remove truly dangling images (i.e. dangling and with no digests)
images.push(repoTag) if _.isEmpty(image.RepoTags) and _.isEmpty(image.RepoDigests) and not image.Id in usedImageIds
images.push(image.Id)
else if !_.isEmpty(image.RepoTags)
# We also remove images from the supervisor repository with a different tag
Promise.map image.RepoTags, (repoTag) =>
@docker.getRegistryAndName(repoTag)
.then ({ imageName, tagName }) ->
if imageName == supervisorImageInfo.imageName and tagName != supervisorImageInfo.tagName
images.push(image.Id)
)
.then(_.uniq)
.then => .then =>
return _.filter images, (image) => return _.filter images, (image) =>
!@imageCleanupFailures[image]? or Date.now() - @imageCleanupFailures[image] > constants.imageCleanupErrorIgnoreTimeout !@imageCleanupFailures[image]? or Date.now() - @imageCleanupFailures[image] > constants.imageCleanupErrorIgnoreTimeout
inspectByName: (imageName) => inspectByName: (imageName) =>
@docker.getImage(imageName).inspect() @docker.getImage(imageName).inspect()
.catch NotFoundError, (err) =>
digest = imageName.split('@')[1]
if !digest?
throw err
@db.models('image').where('name', 'like', "%@#{digest}").select()
.then (imagesFromDB) =>
for image in imagesFromDB
if image.dockerImageId?
return @docker.getImage(image.dockerImageId).inspect()
throw err
normalise: (imageName) => normalise: (imageName) =>
@docker.normaliseImageName(imageName) @docker.normaliseImageName(imageName)
isCleanupNeeded: => isCleanupNeeded: =>
@_getOldSupervisorsForCleanup() @_getImagesForCleanup()
.then (imagesForCleanup) -> .then (imagesForCleanup) ->
return !_.isEmpty(imagesForCleanup) return !_.isEmpty(imagesForCleanup)
# Delete old supervisor images # Delete dangling images and old supervisor images
cleanup: => cleanup: =>
@_getOldSupervisorsForCleanup() @_getImagesForCleanup()
.map (image) => .map (image) =>
console.log("Cleaning up #{image}") console.log("Cleaning up #{image}")
@docker.getImage(image).remove(force: true) @docker.getImage(image).remove(force: true)
@ -185,9 +257,12 @@ module.exports = class Images extends EventEmitter
@logger.logSystemMessage("Error cleaning up #{image}: #{err.message} - will ignore for 1 hour", { error: err }, 'Image cleanup error') @logger.logSystemMessage("Error cleaning up #{image}: #{err.message} - will ignore for 1 hour", { error: err }, 'Image cleanup error')
@imageCleanupFailures[image] = Date.now() @imageCleanupFailures[image] = Date.now()
@hasSameDigest: (name1, name2) ->
hash1 = name1.split('@')[1]
hash2 = name2.split('@')[1]
return hash1? and hash1 == hash2
@isSameImage: (image1, image2) -> @isSameImage: (image1, image2) ->
hash1 = image1.name.split('@')[1] return image1.name == image2.name or Images.hasSameDigest(image1.name, image2.name)
hash2 = image2.name.split('@')[1]
return image1.name == image2.name or (hash1? and hash1 == hash2)
isSameImage: @isSameImage isSameImage: @isSameImage

View File

@ -10,9 +10,11 @@ module.exports = class Networks
# TODO: parse supported config fields # TODO: parse supported config fields
format: (network) -> format: (network) ->
[ appId, name ] = network.Name.split('_') m = /^([0-9]+)_(.+)$/.match(network.Name)
appId = checkInt(m[1])
name = m[2]
return { return {
appId: checkInt(appId) appId: appId
name: name name: name
config: {} config: {}
} }

View File

@ -6,7 +6,6 @@ constants = require '../lib/constants'
conversions = require '../lib/conversions' conversions = require '../lib/conversions'
Duration = require 'duration-js' Duration = require 'duration-js'
Images = require './images'
validRestartPolicies = [ 'no', 'always', 'on-failure', 'unless-stopped' ] validRestartPolicies = [ 'no', 'always', 'on-failure', 'unless-stopped' ]
@ -112,6 +111,7 @@ module.exports = class Service
constructor: (serviceProperties, opts = {}) -> constructor: (serviceProperties, opts = {}) ->
{ {
@image @image
@imageName
@expose @expose
@ports @ports
@networkMode @networkMode
@ -553,6 +553,7 @@ module.exports = class Service
isSameContainer: (otherService) => isSameContainer: (otherService) =>
propertiesToCompare = [ propertiesToCompare = [
'image'
'command' 'command'
'entrypoint' 'entrypoint'
'networkMode' 'networkMode'
@ -590,8 +591,7 @@ module.exports = class Service
'extraHosts' 'extraHosts'
'ulimitsArray' 'ulimitsArray'
] ]
isEq = Images.isSameImage({ name: @image }, { name: otherService.image }) and isEq = _.isEqual(_.pick(this, propertiesToCompare), _.pick(otherService, propertiesToCompare)) and
_.isEqual(_.pick(this, propertiesToCompare), _.pick(otherService, propertiesToCompare)) and
@hasSameNetworks(otherService) and @hasSameNetworks(otherService) and
_.every arraysToCompare, (property) => _.every arraysToCompare, (property) =>
_.isEmpty(_.xorWith(this[property], otherService[property], _.isEqual)) _.isEmpty(_.xorWith(this[property], otherService[property], _.isEqual))

View File

@ -12,10 +12,12 @@ module.exports = class Volumes
constructor: ({ @docker, @logger }) -> constructor: ({ @docker, @logger }) ->
format: (volume) => format: (volume) =>
[ appId, name ] = volume.Name.split('_') m = /^([0-9]+)_(.+)$/.match(volume.Name)
appId = checkInt(m[1])
name = m[2]
return { return {
name: name name: name
appId: checkInt(appId) appId: appId
config: { config: {
labels: _.omit(volume.Labels, _.keys(@defaultLabels(appId))) labels: _.omit(volume.Labels, _.keys(@defaultLabels(appId)))
driverOpts: volume.Options driverOpts: volume.Options

View File

@ -49,13 +49,14 @@ module.exports = class DockerUtils extends DockerToolbelt
{ {
deltaRequestTimeout, deltaApplyTimeout, deltaRetryCount, deltaRetryInterval, deltaRequestTimeout, deltaApplyTimeout, deltaRetryCount, deltaRetryInterval,
uuid, currentApiKey, deltaEndpoint, resinApiEndpoint, uuid, currentApiKey, deltaEndpoint, resinApiEndpoint,
deltaSource, startFromEmpty = false deltaSource, deltaSourceId, startFromEmpty = false
} = fullDeltaOpts } = fullDeltaOpts
retryCount = checkInt(deltaRetryCount) retryCount = checkInt(deltaRetryCount)
retryInterval = checkInt(deltaRetryInterval) retryInterval = checkInt(deltaRetryInterval)
requestTimeout = checkInt(deltaRequestTimeout) requestTimeout = checkInt(deltaRequestTimeout)
applyTimeout = checkInt(deltaApplyTimeout) applyTimeout = checkInt(deltaApplyTimeout)
deltaSource = 'resin/scratch' if startFromEmpty or !deltaSource? deltaSource = 'resin/scratch' if startFromEmpty or !deltaSource?
deltaSourceId ?= deltaSource
# I'll leave this debug log here in case we ever wonder what delta source a device is using in production # I'll leave this debug log here in case we ever wonder what delta source a device is using in production
console.log("Using delta source #{deltaSource}") console.log("Using delta source #{deltaSource}")
Promise.join @getRegistryAndName(imgDest), @getRegistryAndName(deltaSource), (dstInfo, srcInfo) -> Promise.join @getRegistryAndName(imgDest), @getRegistryAndName(deltaSource), (dstInfo, srcInfo) ->
@ -92,14 +93,10 @@ module.exports = class DockerUtils extends DockerToolbelt
if deltaSource is 'resin/scratch' if deltaSource is 'resin/scratch'
deltaSrc = null deltaSrc = null
else else
deltaSrc = deltaSource deltaSrc = deltaSourceId
resumeOpts = { maxRetries: retryCount, retryInterval } resumeOpts = { maxRetries: retryCount, retryInterval }
resolve(applyDelta(deltaSrc, deltaUrl, { requestTimeout, applyTimeout, resumeOpts }, onProgress)) resolve(applyDelta(deltaSrc, deltaUrl, { requestTimeout, applyTimeout, resumeOpts }, onProgress))
.on 'error', reject .on 'error', reject
.then (id) =>
@getRepoAndTag(imgDest)
.then ({ repo, tag }) =>
@getImage(id).tag({ repo, tag, force: true })
.catch dockerDelta.OutOfSyncError, (err) => .catch dockerDelta.OutOfSyncError, (err) =>
throw err if startFromEmpty throw err if startFromEmpty
console.log('Falling back to delta-from-empty') console.log('Falling back to delta-from-empty')
@ -109,13 +106,15 @@ module.exports = class DockerUtils extends DockerToolbelt
fetchImageWithProgress: (image, { uuid, currentApiKey }, onProgress) => fetchImageWithProgress: (image, { uuid, currentApiKey }, onProgress) =>
@getRegistryAndName(image) @getRegistryAndName(image)
.then ({ registry, imageName, tagName }) => .then ({ registry }) =>
dockerOptions = dockerOptions =
authconfig: authconfig:
username: 'd_' + uuid, username: 'd_' + uuid,
password: currentApiKey, password: currentApiKey,
serveraddress: registry serveraddress: registry
@dockerProgress.pull(image, onProgress, dockerOptions) @dockerProgress.pull(image, onProgress, dockerOptions)
.then =>
@getImage(image).inspect().get('Id')
getImageEnv: (id) -> getImageEnv: (id) ->
@getImage(id).inspect() @getImage(id).inspect()

View File

@ -0,0 +1,10 @@
// Adds a dockerImageId column to the image table to identify images downloaded with deltas
exports.up = function (knex, Promise) {
return knex.schema.table('image', (t) => {
t.string('dockerImageId')
})
}
exports.down = function(knex, Promise) {
return Promise.try(() => { throw new Error('Not implemented') })
}