mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-30 02:28:53 +00:00
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:
parent
d84bcf0fb4
commit
3a710506a6
@ -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,7 +838,10 @@ 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 imagesToSave
|
||||||
|
nextSteps.push({ action: 'saveImage', image })
|
||||||
|
if _.isEmpty(imagesToSave)
|
||||||
for image in imagesToRemove
|
for image in imagesToRemove
|
||||||
nextSteps.push({ action: 'removeImage', image })
|
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
|
||||||
|
@ -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 })
|
||||||
|
Promise.try =>
|
||||||
|
if opts.deltaSource
|
||||||
|
@inspectByName(opts.deltaSource)
|
||||||
|
.then (srcImage) ->
|
||||||
|
opts.deltaSourceId = srcImage.Id
|
||||||
|
.then =>
|
||||||
@docker.rsyncImageWithProgress(imageName, opts, onProgress)
|
@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) =>
|
||||||
|
# We first fetch the image from the DB to ensure it exists,
|
||||||
|
# and get the dockerImageId and any other missing field
|
||||||
|
@db.models('image').select().where(image)
|
||||||
|
.then (images) =>
|
||||||
|
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' }))
|
@reportChange(image.imageId, _.merge(_.clone(image), { status: 'Deleting' }))
|
||||||
@logger.logSystemEvent(logTypes.deleteImage, { image })
|
@logger.logSystemEvent(logTypes.deleteImage, { image })
|
||||||
@_removeImageIfNotNeeded(image)
|
@docker.getImage(img.dockerImageId).remove(force: true)
|
||||||
|
.return(true)
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
.catchReturn(NotFoundError, false)
|
||||||
.tap =>
|
.tap =>
|
||||||
@db.models('image').del().where(image)
|
@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 = []
|
||||||
|
Promise.join(
|
||||||
@docker.getRegistryAndName(constants.supervisorImage)
|
@docker.getRegistryAndName(constants.supervisorImage)
|
||||||
.then (supervisorImageInfo) =>
|
@db.models('image').select('dockerImageId')
|
||||||
@docker.listImages()
|
.map((image) -> image.dockerImageId)
|
||||||
|
(supervisorImageInfo, usedImageIds) =>
|
||||||
|
@docker.listImages(digests: true)
|
||||||
.map (image) =>
|
.map (image) =>
|
||||||
Promise.map image.RepoTags ? [], (repoTag) =>
|
# Cleanup should remove truly dangling images (i.e. dangling and with no digests)
|
||||||
|
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)
|
@docker.getRegistryAndName(repoTag)
|
||||||
.then ({ imageName, tagName }) ->
|
.then ({ imageName, tagName }) ->
|
||||||
if imageName == supervisorImageInfo.imageName and tagName != supervisorImageInfo.tagName
|
if imageName == supervisorImageInfo.imageName and tagName != supervisorImageInfo.tagName
|
||||||
images.push(repoTag)
|
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
|
||||||
|
@ -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: {}
|
||||||
}
|
}
|
||||||
|
@ -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))
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
10
src/migrations/20171214172530_image_docker_id.js
Normal file
10
src/migrations/20171214172530_image_docker_id.js
Normal 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') })
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user