compose/service: Convert module to typescript and add network config

Stability improvements;
* Printing of unsupported compose fields
* Added a lot of tests
* All compose configuration has a default value, enabling better
comparison

Change-type: minor
Signed-off-by: Cameron Diver <cameron@resin.io>
This commit is contained in:
Cameron Diver 2018-09-18 15:36:59 +01:00
parent e0231f15e9
commit 892d227cc2
No known key found for this signature in database
GPG Key ID: 69264F9C923F55C1
27 changed files with 2624 additions and 863 deletions

View File

@ -36,6 +36,7 @@
"@types/mz": "0.0.32",
"@types/node": "^10.3.1",
"@types/rwlock": "^5.0.2",
"@types/shell-quote": "^1.6.0",
"JSONStream": "^1.1.2",
"blinking": "~0.0.2",
"bluebird": "^3.5.0",
@ -46,6 +47,7 @@
"coffee-script": "~1.11.0",
"copy-webpack-plugin": "^4.2.3",
"dbus-native": "^0.2.5",
"deep-object-diff": "^1.1.0",
"docker-delta": "^2.1.0",
"docker-progress": "^2.7.2",
"docker-toolbelt": "^3.3.2",

View File

@ -14,7 +14,7 @@ updateLock = require './lib/update-lock'
{ NotFoundError } = require './lib/errors'
ServiceManager = require './compose/service-manager'
Service = require './compose/service'
{ Service } = require './compose/service'
Images = require './compose/images'
{ NetworkManager } = require './compose/network-manager'
{ Network } = require './compose/network'
@ -89,12 +89,12 @@ module.exports = class ApplicationManager extends EventEmitter
.then =>
delete @_containerStarted[step.current.containerId]
if step.options?.removeImage
@images.removeByDockerId(step.current.image)
@images.removeByDockerId(step.current.config.image)
remove: (step) =>
# Only called for dead containers, so no need to take locks or anything
@services.remove(step.current)
updateMetadata: (step, { force = false, skipLock = false } = {}) =>
skipLock or= checkTruthy(step.current.labels['io.resin.legacy-container'])
skipLock or= checkTruthy(step.current.config.labels['io.resin.legacy-container'])
@_lockingIfNecessary step.current.appId, { force, skipLock: skipLock or step.options?.skipLock }, =>
@services.updateMetadata(step.current, step.target)
restart: (step, { force = false, skipLock = false } = {}) =>
@ -144,10 +144,12 @@ module.exports = class ApplicationManager extends EventEmitter
@images.cleanup()
createNetworkOrVolume: (step) =>
if step.model is 'network'
# TODO: These step targets should be the actual compose objects,
# rather than recreating them
Network.fromComposeObject({ @docker, @logger },
step.target.name,
step.appId,
step.target
step.target.config
).create()
else
@volumes.create(step.target)
@ -156,7 +158,7 @@ module.exports = class ApplicationManager extends EventEmitter
Network.fromComposeObject({ @docker, @logger },
step.current.name,
step.appId,
step.current
step.current.config
).remove()
else
@volumes.remove(step.current)
@ -329,6 +331,7 @@ module.exports = class ApplicationManager extends EventEmitter
currentServiceContainers = _.filter(currentServices, { serviceId })
if currentServiceContainers.length > 1
currentServicesPerId[serviceId] = _.maxBy(currentServiceContainers, 'createdAt')
# All but the latest container for this service are spurious and should be removed
for service in _.without(currentServiceContainers, currentServicesPerId[serviceId])
removePairs.push({
@ -344,12 +347,13 @@ module.exports = class ApplicationManager extends EventEmitter
alreadyStarted = (serviceId) =>
return (
currentServicesPerId[serviceId].isEqualExceptForRunningState(targetServicesPerId[serviceId]) and
targetServicesPerId[serviceId].running and
targetServicesPerId[serviceId].config.running and
@_containerStarted[currentServicesPerId[serviceId].containerId]
)
needUpdate = _.filter toBeMaybeUpdated, (serviceId) ->
!currentServicesPerId[serviceId].isEqual(targetServicesPerId[serviceId]) and !alreadyStarted(serviceId)
for serviceId in needUpdate
updatePairs.push({
current: currentServicesPerId[serviceId]
@ -399,7 +403,7 @@ module.exports = class ApplicationManager extends EventEmitter
opts,
name,
appId,
target[name].config
target[name]
)
return !currentNet.isEqualConfig(targetNet)
else
@ -488,13 +492,14 @@ module.exports = class ApplicationManager extends EventEmitter
_nextStepsForNetwork: ({ current, target }, currentApp, changingPairs) =>
dependencyComparisonFn = (service, current) ->
service.networkMode == "#{service.appId}_#{current?.name}"
service.config.networkMode == "#{service.appId}_#{current?.name}"
@_nextStepsForNetworkOrVolume({ current, target }, currentApp, changingPairs, dependencyComparisonFn, 'network')
_nextStepsForVolume: ({ current, target }, currentApp, changingPairs) ->
# Check none of the currentApp.services use this network or volume
dependencyComparisonFn = (service, current) ->
_.some service.volumes, (volumeDefinition) ->
_.some service.config.volumes, (volumeDefinition) ->
[ sourceName, destName ] = volumeDefinition.split(':')
destName? and sourceName == "#{service.appId}_#{current?.name}"
@_nextStepsForNetworkOrVolume({ current, target }, currentApp, changingPairs, dependencyComparisonFn, 'volume')
@ -503,7 +508,7 @@ module.exports = class ApplicationManager extends EventEmitter
_updateContainerStep: (current, target) ->
if current.releaseId != target.releaseId or current.imageId != target.imageId
return serviceAction('updateMetadata', target.serviceId, current, target)
else if target.running
else if target.config.running
return serviceAction('start', target.serviceId, current, target)
else
return serviceAction('stop', target.serviceId, current, target)
@ -551,7 +556,7 @@ module.exports = class ApplicationManager extends EventEmitter
return serviceAction('remove', current.serviceId, current)
needsDownload = !_.some availableImages, (image) =>
image.dockerImageId == target?.image or @images.isSameImage(image, { name: target.imageName })
image.dockerImageId == target?.config.image or @images.isSameImage(image, { name: target.imageName })
# This service needs an image download but it's currently downloading, so we wait
if needsDownload and target?.imageId in downloading
@ -566,18 +571,18 @@ module.exports = class ApplicationManager extends EventEmitter
# even if its strategy is handover
needsSpecialKill = @_hasCurrentNetworksOrVolumes(current, networkPairs, volumePairs)
if current?.isSameContainer(target)
if current?.isEqualConfig(target)
# We're only stopping/starting it
return @_updateContainerStep(current, target)
else if !current?
# Either this is a new service, or the current one has already been killed
return @_fetchOrStartStep(current, target, needsDownload, dependenciesMetForStart)
else
strategy = checkString(target.labels['io.resin.update.strategy'])
strategy = checkString(target.config.labels['io.resin.update.strategy'])
validStrategies = [ 'download-then-kill', 'kill-then-download', 'delete-then-download', 'hand-over' ]
if !_.includes(validStrategies, strategy)
strategy = 'download-then-kill'
timeout = checkInt(target.labels['io.resin.update.handover-timeout'])
timeout = checkInt(target.config.labels['io.resin.update.handover-timeout'])
return @_strategySteps[strategy](current, target, needsDownload, dependenciesMetForStart, dependenciesMetForKill, needsSpecialKill, timeout)
_nextStepsForAppUpdate: (currentApp, targetApp, availableImages = [], downloading = []) =>
@ -590,12 +595,12 @@ module.exports = class ApplicationManager extends EventEmitter
currentApp ?= emptyApp
if currentApp.services?.length == 1 and targetApp.services?.length == 1 and
targetApp.services[0].serviceName == currentApp.services[0].serviceName and
checkTruthy(currentApp.services[0].labels['io.resin.legacy-container'])
checkTruthy(currentApp.services[0].config.labels['io.resin.legacy-container'])
# This is a legacy preloaded app or container, so we didn't have things like serviceId.
# We hack a few things to avoid an unnecessary restart of the preloaded app
# (but ensuring it gets updated if it actually changed)
targetApp.services[0].labels['io.resin.legacy-container'] = currentApp.services[0].labels['io.resin.legacy-container']
targetApp.services[0].labels['io.resin.service-id'] = currentApp.services[0].labels['io.resin.service-id']
targetApp.services[0].config.labels['io.resin.legacy-container'] = currentApp.services[0].labels['io.resin.legacy-container']
targetApp.services[0].config.labels['io.resin.service-id'] = currentApp.services[0].labels['io.resin.service-id']
targetApp.services[0].serviceId = currentApp.services[0].serviceId
appId = targetApp.appId ? currentApp.appId
@ -609,6 +614,7 @@ module.exports = class ApplicationManager extends EventEmitter
steps.push(serviceAction('kill', pair.current.serviceId, pair.current, null))
else
steps.push({ action: 'noop' })
# next step for install pairs in download - start order, but start requires dependencies, networks and volumes met
# next step for update pairs in order by update strategy. start requires dependencies, networks and volumes met.
for pair in installPairs.concat(updatePairs)
@ -668,7 +674,7 @@ module.exports = class ApplicationManager extends EventEmitter
service.imageName = service.image
if imageInfo?.Id?
service.image = imageInfo.Id
return new Service(service, serviceOpts)
return Service.fromComposeObject(service, serviceOpts)
normaliseAndExtendAppFromDB: (app) =>
Promise.join(
@ -787,15 +793,19 @@ module.exports = class ApplicationManager extends EventEmitter
allImagesForTargetApp = (app) -> _.map(app.services, imageForService)
allImagesForCurrentApp = (app) ->
_.map app.services, (service) ->
img = _.find(available, { dockerImageId: service.image, imageId: service.imageId }) ? _.find(available, { dockerImageId: service.image })
img = _.find(available, { dockerImageId: service.config.image, imageId: service.imageId }) ? _.find(available, { dockerImageId: service.config.image })
return _.omit(img, [ 'dockerImageId', 'id' ])
availableWithoutIds = _.map(available, (image) -> _.omit(image, [ 'dockerImageId', 'id' ]))
currentImages = _.flatMap(current.local.apps, allImagesForCurrentApp)
targetImages = _.flatMap(target.local.apps, allImagesForTargetApp)
availableAndUnused = _.filter availableWithoutIds, (image) ->
!_.some currentImages.concat(targetImages), (imageInUse) -> _.isEqual(image, imageInUse)
imagesToDownload = _.filter targetImages, (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
@ -804,6 +814,7 @@ module.exports = class ApplicationManager extends EventEmitter
deltaSources = _.map imagesToDownload, (image) =>
return @bestDeltaSource(image, available)
proxyvisorImages = @proxyvisor.imagesInUse(current, target)
imagesToRemove = _.filter availableAndUnused, (image) =>
notUsedForDelta = !_.includes(deltaSources, image.name)
notUsedByProxyvisor = !_.some proxyvisorImages, (proxyvisorImage) => @images.isSameImage(image, { name: proxyvisorImage })

View File

@ -61,6 +61,13 @@ export class PortMap {
};
}
public toExposedPortArray(): string[] {
const internalRange = this.generatePortRange(this.ports.internalStart, this.ports.internalEnd);
return _.map(internalRange, (internal) => {
return `${internal}/${this.ports.protocol}`;
});
}
/**
* fromDockerOpts
*

77
src/compose/sanitise.ts Normal file
View File

@ -0,0 +1,77 @@
import * as _ from 'lodash';
import { ConfigMap, ServiceComposeConfig } from './types/service';
// TODO: Generate these fields from the interface we define
// in service-types.
const supportedComposeFields = [
'capAdd',
'capDrop',
'command',
'cgroupParent',
'devices',
'dns',
'dnsOpt',
'dnsSearch',
'tmpfs',
'entrypoint',
'environment',
'expose',
'extraHosts',
'groupAdd',
'healthcheck',
'image',
'init',
'labels',
'running',
'networkMode',
'networks',
'pid',
'pidsLimit',
'ports',
'securityOpt',
'stopGracePeriod',
'stopSignal',
'storageOpt',
'sysctls',
'ulimits',
'usernsMode',
'volumes',
'restart',
'cpuShares',
'cpuQuota',
'cpus',
'cpuset',
'domainname',
'hostname',
'ipc',
'macAddress',
'memLimit',
'memReservation',
'oomKillDisable',
'oomScoreAdj',
'privileged',
'readOnly',
'shmSize',
'user',
'workingDir',
];
export function sanitiseComposeConfig(
composeConfig: ConfigMap,
): ServiceComposeConfig {
const filtered: string[] = [];
const toReturn = _.pickBy(composeConfig, (_v, k) => {
const included = _.includes(supportedComposeFields, k);
if (!included) {
filtered.push(k);
}
return included;
}) as ServiceComposeConfig;
if (filtered.length > 0) {
console.log(`Warning: Ignoring unsupported or unknown compose fields: ${filtered.join(', ')}`);
}
return toReturn;
}

View File

@ -8,7 +8,7 @@ logTypes = require '../lib/log-types'
{ checkInt, isValidDeviceName } = require '../lib/validation'
constants = require '../lib/constants'
Service = require './service'
{ Service } = require './service'
{ NotFoundError } = require '../lib/errors'
@ -100,6 +100,8 @@ module.exports = class ServiceManager extends EventEmitter
.then (existingService) =>
return @docker.getContainer(existingService.containerId)
.catch NotFoundError, =>
conf = service.toDockerContainer()
nets = service.extraNetworksToJoin()
@config.get('name')
.then (deviceName) =>
@ -109,9 +111,9 @@ module.exports = class ServiceManager extends EventEmitter
'Please fix the device name.'
)
service.environment['RESIN_DEVICE_NAME_AT_INIT'] = deviceName
# TODO: Don't mutate service like this, use an interface
service.config.environment['RESIN_DEVICE_NAME_AT_INIT'] = deviceName
conf = service.toContainerConfig()
nets = service.extraNetworksToJoin()
@logger.logSystemEvent(logTypes.installService, { service })
@ -121,7 +123,7 @@ module.exports = class ServiceManager extends EventEmitter
.tap (container) =>
service.containerId = container.id
Promise.map nets, ({ name, endpointConfig }) =>
Promise.all _.map nets, (endpointConfig, name) =>
@docker
.getNetwork(name)
.connect({ Container: container.id, EndpointConfig: endpointConfig })
@ -180,7 +182,7 @@ module.exports = class ServiceManager extends EventEmitter
@docker.listContainers({ all: true, filters })
.mapSeries (container) =>
@docker.getContainer(container.Id).inspect()
.then(Service.fromContainer)
.then(Service.fromDockerContainer)
.then (service) =>
if @volatileState[service.containerId]?.status?
service.status = @volatileState[service.containerId].status
@ -191,7 +193,7 @@ module.exports = class ServiceManager extends EventEmitter
# Returns the first container matching a service definition
get: (service) =>
@getAll("io.resin.service-id=#{service.serviceId}")
.filter((currentService) -> currentService.isSameContainer(service))
.filter((currentService) -> currentService.isEqualConfig(service))
.then (services) ->
if services.length == 0
e = new Error('Could not find a container matching this service definition')
@ -220,7 +222,7 @@ module.exports = class ServiceManager extends EventEmitter
.then (container) ->
if !container.Config.Labels['io.resin.supervised']?
return null
return Service.fromContainer(container)
return Service.fromDockerContainer(container)
waitToKill: (service, timeout) ->
pollInterval = 100
@ -260,7 +262,7 @@ module.exports = class ServiceManager extends EventEmitter
.then =>
@start(targetService)
.then =>
@waitToKill(currentService, targetService.labels['io.resin.update.handover-timeout'])
@waitToKill(currentService, targetService.config.labels['io.resin.update.handover-timeout'])
.then =>
@kill(currentService)

View File

@ -1,735 +0,0 @@
_ = require 'lodash'
path = require 'path'
os = require 'os'
{ checkTruthy, checkInt } = require '../lib/validation'
updateLock = require '../lib/update-lock'
constants = require '../lib/constants'
conversions = require '../lib/conversions'
parseCommand = require('shell-quote').parse
Duration = require 'duration-js'
{ PortMap } = require './ports'
validRestartPolicies = [ 'no', 'always', 'on-failure', 'unless-stopped' ]
parseMemoryNumber = (numAsString, defaultVal) ->
m = numAsString?.toString().match(/^([0-9]+)([bkmg]?)b?$/i)
if !m? and defaultVal?
return parseMemoryNumber(defaultVal)
num = m[1]
pow = { '': 0, 'b': 0, 'B': 0, 'K': 1, 'k': 1, 'm': 2, 'M': 2, 'g': 3, 'G': 3 }
return parseInt(num) * 1024 ** pow[m[2]]
# Construct a restart policy based on its name.
# The default policy (if name is not a valid policy) is "always".
createRestartPolicy = (name) ->
if name not in validRestartPolicies
name = 'always'
return { Name: name, MaximumRetryCount: 0 }
processCommandStr = (s) ->
# Escape dollars
s.replace(/(\$)/g, '\\$1')
processCommandParsedArrayElement = (arg) ->
if _.isObject(arg)
if arg.op == 'glob'
return arg.pattern
return arg.op
return arg
ensureCommandIsArray = (s) ->
if _.isString(s)
s = _.map(parseCommand(processCommandStr(s)), processCommandParsedArrayElement)
return s
getCommand = (service, imageInfo) ->
cmd = service.command ? imageInfo?.Config?.Cmd ? null
return ensureCommandIsArray(cmd)
getEntrypoint = (service, imageInfo) ->
entry = service.entrypoint ? imageInfo?.Config?.Entrypoint ? null
return ensureCommandIsArray(entry)
getStopSignal = (service, imageInfo) ->
sig = service.stopSignal ? imageInfo?.Config?.StopSignal ? null
if sig? and !_.isString(sig) # In case the YAML was parsed as a number
sig = sig.toString()
return sig
getUser = (service, imageInfo) ->
return service.user ? imageInfo?.Config?.User ? ''
getWorkingDir = (service, imageInfo) ->
return (service.workingDir ? imageInfo?.Config?.WorkingDir ? '').replace(/(^.+)\/$/, '$1')
buildHealthcheckTest = (test) ->
if _.isString(test)
return [ 'CMD-SHELL', test ]
else
return test
getNanoseconds = (duration) ->
d = new Duration(duration)
return d.nanoseconds()
# Mutates imageHealthcheck
overrideHealthcheckFromCompose = (serviceHealthcheck, imageHealthcheck = {}) ->
if serviceHealthcheck.disable
imageHealthcheck.Test = [ 'NONE' ]
else
imageHealthcheck.Test = buildHealthcheckTest(serviceHealthcheck.test)
if serviceHealthcheck.interval?
imageHealthcheck.Interval = getNanoseconds(serviceHealthcheck.interval)
if serviceHealthcheck.timeout?
imageHealthcheck.Timeout = getNanoseconds(serviceHealthcheck.timeout)
if serviceHealthcheck.start_period?
imageHealthcheck.StartPeriod = getNanoseconds(serviceHealthcheck.start_period)
if serviceHealthcheck.retries?
imageHealthcheck.Retries = parseInt(serviceHealthcheck.retries)
return imageHealthcheck
getHealthcheck = (service, imageInfo) ->
healthcheck = imageInfo?.Config?.Healthcheck ? null
if service.healthcheck?
healthcheck = overrideHealthcheckFromCompose(service.healthcheck, healthcheck)
# Set invalid healthchecks back to null
if healthcheck? and (!healthcheck.Test? or _.isEqual(healthcheck.Test, []))
healthcheck = null
return healthcheck
killmePath = (appId, serviceName) ->
return updateLock.lockPath(appId, serviceName)
defaultBinds = (appId, serviceName) ->
return [
"#{updateLock.lockPath(appId, serviceName)}:/tmp/resin"
]
formatDevices = (devices) ->
return _.map devices, (device) ->
[ PathOnHost, PathInContainer, CgroupPermissions ] = device.split(':')
PathInContainer ?= PathOnHost
CgroupPermissions ?= 'rwm'
return { PathOnHost, PathInContainer, CgroupPermissions }
# TODO: Support configuration for "networks"
module.exports = class Service
constructor: (props, opts = {}) ->
serviceProperties = _.mapKeys(props, (v, k) -> _.camelCase(k))
{
@image
@imageName
@expose
@ports
@networkMode
@privileged
@releaseId
@imageId
@serviceId
@appId
@serviceName
@containerId
@running
@createdAt
@environment
@command
@entrypoint
@labels
@volumes
@restartPolicy
@dependsOn
@capAdd
@capDrop
@status
@devices
@portMappings
@networks
@memLimit
@memReservation
@shmSize
@cpuShares
@cpuQuota
@cpus
@cpuset
@nanoCpus
@domainname
@oomKillDisable
@oomScoreAdj
@dns
@dnsSearch
@dnsOpt
@tmpfs
@extraHosts
@ulimits
@ulimitsArray
@stopSignal
@stopGracePeriod
@init
@healthcheck
@readOnly
@sysctls
@hostname
@cgroupParent
@groupAdd
@pid
@pidsLimit
@securityOpt
@storageOpt
@usernsMode
@ipc
@macAddress
@user
@workingDir
} = serviceProperties
@networks ?= {}
@privileged ?= false
@volumes ?= []
@labels ?= {}
@environment ?= {}
@running ?= true
@ports ?= []
@expose ?= []
@capAdd ?= []
@capDrop ?= []
@devices ?= []
@memLimit = parseMemoryNumber(@memLimit, '0')
@memReservation = parseMemoryNumber(@memReservation, '0')
@shmSize = parseMemoryNumber(@shmSize, '64m')
@cpuShares ?= 0
@cpuQuota ?= 0
@cpus ?= 0
@nanoCpus ?= 0
@cpuset ?= ''
@domainname ?= ''
@oomScoreAdj ?= 0
@oomKillDisable ?= false
@tmpfs ?= []
@extraHosts ?= []
@dns ?= []
@dnsSearch ?= []
@dnsOpt ?= []
@ulimitsArray ?= []
@groupAdd ?= []
@stopSignal ?= null
@stopGracePeriod ?= null
@healthcheck ?= null
@init ?= null
@readOnly ?= false
@macAddress ?= null
@sysctls ?= {}
@hostname ?= ''
@cgroupParent ?= ''
@pid ?= ''
@pidsLimit ?= 0
@securityOpt ?= []
@storageOpt ?= {}
@usernsMode ?= ''
@user ?= ''
@workingDir ?= ''
if _.isEmpty(@ipc)
@ipc = 'shareable'
@portMappings ?= @getPortsAndPortBindings()
# If the service has no containerId, it is a target service and has to be normalised and extended
if !@containerId?
if !@networkMode?
if !_.isEmpty(@networks)
@networkMode = _.keys(@networks)[0]
else
@networkMode = 'default'
if @networkMode not in [ 'host', 'bridge', 'none' ]
@networkMode = "#{@appId}_#{@networkMode}"
@networks = _.mapKeys @networks, (v, k) =>
if k not in [ 'host', 'bridge', 'none' ]
return "#{@appId}_#{k}"
return k
if @networkMode == 'host' and @hostname == ''
@hostname = opts.hostnameOnHost
@networks[@networkMode] ?= {}
@restartPolicy = createRestartPolicy(serviceProperties.restart)
@command = getCommand(serviceProperties, opts.imageInfo)
@entrypoint = getEntrypoint(serviceProperties, opts.imageInfo)
@stopSignal = getStopSignal(serviceProperties, opts.imageInfo)
@healthcheck = getHealthcheck(serviceProperties, opts.imageInfo)
@workingDir = getWorkingDir(serviceProperties, opts.imageInfo)
@user = getUser(serviceProperties, opts.imageInfo)
@extendEnvVars(opts)
@extendLabels(opts.imageInfo)
@extendAndSanitiseVolumes(opts.imageInfo)
@extendAndSanitiseExposedPorts(opts.imageInfo)
@devices = formatDevices(@devices)
@addFeaturesFromLabels(opts)
if @dns?
@dns = _.castArray(@dns)
if @dnsSearch?
@dnsSearch = _.castArray(@dnsSearch)
if @tmpfs?
@tmpfs = _.castArray(@tmpfs)
@nanoCpus = Math.round(Number(@cpus) * 10 ** 9)
@ulimitsArray = _.map @ulimits, (value, name) ->
if _.isNumber(value) or _.isString(value)
return { Name: name, Soft: checkInt(value), Hard: checkInt(value) }
else
return { Name: name, Soft: checkInt(value.soft), Hard: checkInt(value.hard) }
if @init
@init = true
if @stopGracePeriod?
d = new Duration(@stopGracePeriod)
@stopGracePeriod = d.seconds()
@oomKillDisable = Boolean(@oomKillDisable)
@readOnly = Boolean(@readOnly)
if Array.isArray(@sysctls)
@sysctls = _.fromPairs(_.map(@sysctls, (v) -> _.split(v, '=')))
@sysctls = _.mapValues(@sysctls, String)
# Avoid problems with yaml parsing numbers as strings
for key in [ 'cpuShares', 'cpuQuota', 'oomScoreAdj' ]
this[key] = checkInt(this[key])
_addSupervisorApi: (opts) =>
@environment['RESIN_SUPERVISOR_PORT'] = opts.listenPort.toString()
@environment['RESIN_SUPERVISOR_API_KEY'] = opts.apiSecret
if @networkMode == 'host'
@environment['RESIN_SUPERVISOR_HOST'] = '127.0.0.1'
@environment['RESIN_SUPERVISOR_ADDRESS'] = "http://127.0.0.1:#{opts.listenPort}"
else
@environment['RESIN_SUPERVISOR_HOST'] = opts.supervisorApiHost
@environment['RESIN_SUPERVISOR_ADDRESS'] = "http://#{opts.supervisorApiHost}:#{opts.listenPort}"
@networks[constants.supervisorNetworkInterface] = {}
addFeaturesFromLabels: (opts) =>
if checkTruthy(@labels['io.resin.features.dbus'])
@volumes.push('/run/dbus:/host/run/dbus')
if checkTruthy(@labels['io.resin.features.kernel-modules']) and opts.hostPathExists.modules
@volumes.push('/lib/modules:/lib/modules')
if checkTruthy(@labels['io.resin.features.firmware']) and opts.hostPathExists.firmware
@volumes.push('/lib/firmware:/lib/firmware')
if checkTruthy(@labels['io.resin.features.balena-socket'])
@volumes.push('/var/run/balena.sock:/var/run/balena.sock')
@environment['DOCKER_HOST'] ?= 'unix:///var/run/balena.sock'
if checkTruthy(@labels['io.resin.features.supervisor-api'])
@_addSupervisorApi(opts)
else
# We ensure the user hasn't added "supervisor0" to the service's networks
delete @networks[constants.supervisorNetworkInterface]
if checkTruthy(@labels['io.resin.features.resin-api'])
@environment['RESIN_API_KEY'] = opts.deviceApiKey
extendEnvVars: ({ imageInfo, uuid, appName, name, version, deviceType, osVersion }) =>
newEnv =
RESIN_APP_ID: @appId.toString()
RESIN_APP_NAME: appName
RESIN_SERVICE_NAME: @serviceName
RESIN_DEVICE_UUID: uuid
RESIN_DEVICE_TYPE: deviceType
RESIN_HOST_OS_VERSION: osVersion
RESIN_SUPERVISOR_VERSION: version
RESIN_APP_LOCK_PATH: '/tmp/resin/resin-updates.lock'
RESIN_SERVICE_KILL_ME_PATH: '/tmp/resin/resin-kill-me'
RESIN: '1'
USER: 'root'
if @environment?
_.defaults(newEnv, @environment)
_.defaults(newEnv, conversions.envArrayToObject(imageInfo?.Config?.Env ? []))
@environment = newEnv
return @environment
extendLabels: (imageInfo) =>
@labels = _.clone(@labels)
_.defaults(@labels, imageInfo?.Config?.Labels ? {})
@labels['io.resin.supervised'] = 'true'
@labels['io.resin.app-id'] = @appId.toString()
@labels['io.resin.service-id'] = @serviceId.toString()
@labels['io.resin.service-name'] = @serviceName
return @labels
extendAndSanitiseExposedPorts: (imageInfo) =>
@expose = _.map @expose, (p) ->
p = new String(p)
if /^[0-9]*$/.test(p)
p += '/tcp'
return p
if imageInfo?.Config?.ExposedPorts?
for own port, _v of imageInfo.Config.ExposedPorts
if !_.find(@expose, port)
@expose.push(port)
return @expose
extendAndSanitiseVolumes: (imageInfo) =>
volumes = []
for vol in @volumes
isBind = _.includes(vol, ':')
if isBind
[ bindSource, bindDest, mode ] = vol.split(':')
if !path.isAbsolute(bindSource)
# Rewrite named volumes to namespace by appId
volDefinition = "#{@appId}_#{bindSource}:#{bindDest}"
if mode?
volDefinition += ":#{mode}"
volumes.push(volDefinition)
else
console.log("Ignoring invalid bind mount #{vol}")
else
volumes.push(vol)
volumes = volumes.concat(@defaultBinds())
volumes = _.union(_.keys(imageInfo?.Config?.Volumes), volumes)
@volumes = volumes
return @volumes
getNamedVolumes: =>
defaults = @defaultBinds()
validVolumes = _.map @volumes, (vol) ->
if _.includes(defaults, vol) or !_.includes(vol, ':')
return null
bindSource = vol.split(':')[0]
if !path.isAbsolute(bindSource)
m = bindSource.match(/[0-9]+_(.+)/)
return m[1]
else
return null
return _.reject(validVolumes, _.isNil)
lockPath: =>
return updateLock.lockPath(@appId)
killmePath: =>
return killmePath(@appId, @serviceName)
killmeFullPathOnHost: =>
return "#{constants.rootMountPoint}#{@killmePath()}/resin-kill-me"
defaultBinds: =>
return defaultBinds(@appId, @serviceName)
@fromContainer: (container, containerToService) ->
if container.State.Running
status = 'Running'
else if container.State.Status == 'created'
status = 'Installed'
else if container.State.Status == 'dead'
status = 'Dead'
else
status = 'Stopped'
boundContainerPorts = []
ports = []
expose = []
for own port, conf of container.HostConfig.PortBindings
containerPort = port.match(/^([0-9]*)\/tcp$/)?[1]
if containerPort?
boundContainerPorts.push(containerPort)
hostPort = conf[0]?.HostPort
if !_.isEmpty(hostPort)
ports.push("#{hostPort}:#{containerPort}")
else
ports.push(containerPort)
for own port, conf of container.Config.ExposedPorts
containerPort = port.match(/^([0-9]*)\/tcp$/)?[1]
if containerPort? and !_.includes(boundContainerPorts, containerPort)
expose.push(containerPort)
portMappings = PortMap.fromDockerOpts(container.HostConfig.PortBindings)
appId = checkInt(container.Config.Labels['io.resin.app-id'])
serviceId = checkInt(container.Config.Labels['io.resin.service-id'])
serviceName = container.Config.Labels['io.resin.service-name']
nameComponents = container.Name.match(/.*_(\d+)_(\d+)$/)
imageId = checkInt(nameComponents?[1])
releaseId = checkInt(nameComponents?[2])
networkMode = container.HostConfig.NetworkMode
if _.startsWith(networkMode, 'container:')
networkMode = 'service:' + containerToService[_.replace(networkMode, 'container:', '')]
hostname = container.Config.Hostname
# A hostname equal to the first part of the container ID actually
# means no hostname was specified
if hostname.length is 12 and container.Id.startsWith(hostname)
hostname = ''
service = {
appId: appId
serviceId: serviceId
serviceName: serviceName
imageId: imageId
command: container.Config.Cmd
entrypoint: container.Config.Entrypoint
networkMode: networkMode
volumes: _.concat(container.HostConfig.Binds ? [], _.keys(container.Config.Volumes ? {}))
image: container.Config.Image
environment: conversions.envArrayToObject(container.Config.Env)
privileged: container.HostConfig.Privileged
releaseId: releaseId
labels: container.Config.Labels
running: container.State.Running
createdAt: new Date(container.Created)
restartPolicy: container.HostConfig.RestartPolicy
portMappings: portMappings
containerId: container.Id
capAdd: container.HostConfig.CapAdd
capDrop: container.HostConfig.CapDrop
devices: container.HostConfig.Devices
status
exposedPorts: container.Config.ExposedPorts
portBindings: container.HostConfig.PortBindings
networks: container.NetworkSettings.Networks
memLimit: container.HostConfig.Memory
memReservation: container.HostConfig.MemoryReservation
shmSize: container.HostConfig.ShmSize
cpuShares: container.HostConfig.CpuShares
cpuQuota: container.HostConfig.CpuQuota
nanoCpus: container.HostConfig.NanoCpus
cpuset: container.HostConfig.CpusetCpus
domainname: container.Config.Domainname
oomKillDisable: container.HostConfig.OomKillDisable
oomScoreAdj: container.HostConfig.OomScoreAdj
dns: container.HostConfig.Dns
dnsSearch: container.HostConfig.DnsSearch
dnsOpt: container.HostConfig.DnsOpt
tmpfs: _.keys(container.HostConfig.Tmpfs ? {})
extraHosts: container.HostConfig.ExtraHosts
ulimitsArray: container.HostConfig.Ulimits
stopSignal: container.Config.StopSignal
stopGracePeriod: container.Config.StopTimeout
healthcheck: container.Config.Healthcheck
init: container.HostConfig.Init
readOnly: container.HostConfig.ReadonlyRootfs
sysctls: container.HostConfig.Sysctls
hostname: hostname
cgroupParent: container.HostConfig.CgroupParent
groupAdd: container.HostConfig.GroupAdd
pid: container.HostConfig.PidMode
pidsLimit: container.HostConfig.PidsLimit
securityOpt: container.HostConfig.SecurityOpt
storageOpt: container.HostConfig.StorageOpt
usernsMode: container.HostConfig.UsernsMode
ipc: container.HostConfig.IpcMode
macAddress: container.Config.MacAddress
user: container.Config.User
workingDir: container.Config.WorkingDir
}
# I've seen docker use either 'no' or '' for no restart policy, so we normalise to 'no'.
if service.restartPolicy.Name == ''
service.restartPolicy.Name = 'no'
return new Service(service)
# TODO: map ports for any of the possible formats "container:host/protocol", port ranges, etc.
getPortsAndPortBindings: =>
portMaps = _.map @ports, (p) -> new PortMap(p)
return PortMap.normalisePortMaps(portMaps)
generatePortBindings: =>
portBindings = {}
exposedPorts = {}
for portMap in @portMappings
ports = portMap.toDockerOpts()
_.merge(portBindings, ports.portBindings)
_.merge(exposedPorts, ports.exposedPorts)
# Any additonal exposed ports
if @expose?
for port in @expose
exposedPorts[port] = {}
return {
portBindings,
exposedPorts
}
getBindsAndVolumes: =>
binds = []
volumes = {}
for vol in @volumes
isBind = _.includes(vol, ':')
if isBind
binds.push(vol)
else
volumes[vol] = {}
return { binds, volumes }
toContainerConfig: ->
{ binds, volumes } = @getBindsAndVolumes()
tmpfs = {}
for dir in @tmpfs
tmpfs[dir] = ''
networkMode = @networkMode
if _.startsWith(networkMode, 'service:')
networkMode = "container:#{_.replace(networkMode, 'service:', '')}_#{@imageId}_#{@releaseId}"
# Generate port options
{ portBindings, exposedPorts } = @generatePortBindings()
conf = {
name: "#{@serviceName}_#{@imageId}_#{@releaseId}"
Image: @image
Cmd: @command
Entrypoint: @entrypoint
Tty: true
Volumes: volumes
Env: _.map @environment, (v, k) -> k + '=' + v
ExposedPorts: exposedPorts
Labels: @labels
Domainname: @domainname
User: @user
WorkingDir: @workingDir
HostConfig:
Memory: @memLimit
MemoryReservation: @memReservation
ShmSize: @shmSize
Privileged: @privileged
NetworkMode: networkMode
PortBindings: portBindings
Binds: binds
CapAdd: @capAdd
CapDrop: @capDrop
Devices: @devices
CpuShares: @cpuShares
NanoCpus: @nanoCpus
CpuQuota: @cpuQuota
CpusetCpus: @cpuset
OomScoreAdj: @oomScoreAdj
OomKillDisable: @oomKillDisable
Tmpfs: tmpfs
Dns: @dns
DnsSearch: @dnsSearch
DnsOpt: @dnsOpt
Ulimits: @ulimitsArray
ReadonlyRootfs: @readOnly
Sysctls: @sysctls
CgroupParent: @cgroupParent
ExtraHosts: @extraHosts
GroupAdd: @groupAdd
PidMode: @pid
PidsLimit: @pidsLimit
SecurityOpt: @securityOpt
UsernsMode: @usernsMode
IpcMode: @ipc
}
if @stopSignal?
conf.StopSignal = @stopSignal
if @stopGracePeriod?
conf.StopTimeout = @stopGracePeriod
if @healthcheck?
conf.Healthcheck = @healthcheck
if @restartPolicy.Name != 'no'
conf.HostConfig.RestartPolicy = @restartPolicy
# If network mode is the default network for this app, add alias for serviceName
if @networkMode == "#{@appId}_default"
conf.NetworkingConfig = {
EndpointsConfig: {
"#{@appId}_default": {
Aliases: [ @serviceName ]
}
}
}
if @init
conf.HostConfig.Init = true
if !_.isEmpty(@hostname)
conf.Hostname = @hostname
if !_.isEmpty(@storageOpt)
conf.HostConfig.StorageOpt = @storageOpt
if @macAddress?
conf.MacAddress = @macAddress
return conf
# TODO: when we support network configuration properly, return endpointConfig: conf
extraNetworksToJoin: ->
_.map _.pickBy(@networks, (conf, net) => net != @networkMode), (conf, net) ->
return { name: net, endpointConfig: {} }
# TODO: compare configuration, not only network names
hasSameNetworks: (otherService) =>
_.isEmpty(_.xor(_.keys(@networks), _.keys(otherService.networks)))
isSameContainer: (otherService) =>
propertiesToCompare = [
'image'
'command'
'entrypoint'
'networkMode'
'privileged'
'restartPolicy'
'labels'
'portMappings'
'shmSize'
'memLimit'
'cpuShares'
'cpuQuota'
'nanoCpus'
'cpuset'
'domainname'
'oomScoreAdj'
'oomKillDisable'
'healthcheck'
'stopSignal'
'stopGracePeriod'
'init'
'readOnly'
'sysctls'
'hostname'
'cgroupParent'
'pid'
'pidsLimit'
'storageOpt'
'usernsMode'
'ipc'
'macAddress'
'user'
'workingDir'
]
arraysToCompare = [
'volumes'
'devices'
'capAdd'
'capDrop'
'dns'
'dnsSearch'
'dnsOpt'
'tmpfs'
'extraHosts'
'ulimitsArray'
'groupAdd'
'securityOpt'
]
equalProps = _.isEqual(_.pick(this, propertiesToCompare), _.pick(otherService, propertiesToCompare))
equalEnv = _.isEqual(
_.omit(@environment, [ 'RESIN_DEVICE_NAME_AT_INIT' ]),
_.omit(otherService.environment, [ 'RESIN_DEVICE_NAME_AT_INIT' ])
)
equalNetworks = @hasSameNetworks(otherService)
equalArrays = _.every arraysToCompare, (property) =>
_.isEmpty(_.xorWith(this[property], otherService[property], _.isEqual))
equal = equalProps and equalEnv and equalNetworks and equalArrays
return equal
isEqualExceptForRunningState: (otherService) =>
return @isSameContainer(otherService) and
@releaseId == otherService.releaseId and
@imageId == otherService.imageId
isEqual: (otherService) =>
return @isEqualExceptForRunningState(otherService) and
@running == otherService.running

820
src/compose/service.ts Normal file
View File

@ -0,0 +1,820 @@
import { detailedDiff as diff } from 'deep-object-diff';
import * as Dockerode from 'dockerode';
import Duration = require('duration-js');
import * as _ from 'lodash';
import * as path from 'path';
import * as conversions from '../lib/conversions';
import { checkInt } from '../lib/validation';
import { DockerPortOptions, PortMap } from './ports';
import {
ConfigMap,
DeviceMetadata,
DockerDevice,
ServiceComposeConfig,
ServiceConfig,
ServiceConfigArrayField,
} from './types/service';
import * as ComposeUtils from './utils';
import * as updateLock from '../lib/update-lock';
import { sanitiseComposeConfig } from './sanitise';
export class Service {
public appId: number | null;
public imageId: number | null;
public config: ServiceConfig;
public serviceName: string | null;
public releaseId: number | null;
public serviceId: number | null;
public imageName: string | null;
public containerId: string | null;
public dependsOn: string | null;
public status: string;
public createdAt: Date | null;
private static configArrayFields: ServiceConfigArrayField[] = [
'volumes',
'devices',
'capAdd',
'capDrop',
'dns',
'dnsSearch',
'dnsOpt',
'tmpfs',
'extraHosts',
'expose',
'ulimitsArray',
'groupAdd',
'securityOpt',
];
// A list of fields to ignore when comparing container configuration
private static omitFields = [
'networks',
'running',
'containerId',
// This field is passed at container creation, but is not
// reported on a container inspect, so we cannot use it
// to compare containers
'storageOpt',
'cpus',
].concat(Service.configArrayFields);
private constructor() {
}
// The type here is actually ServiceComposeConfig, except that the
// keys must be camelCase'd first
public static fromComposeObject(
appConfig: ConfigMap,
options: DeviceMetadata,
): Service {
const service = new Service();
appConfig = ComposeUtils.camelCaseConfig(appConfig);
// Seperate the application information from the docker
// container configuration
service.imageId = appConfig.imageId;
delete appConfig.imageId;
service.serviceName = appConfig.serviceName;
delete appConfig.serviceName;
service.appId = appConfig.appId;
delete appConfig.appId;
service.releaseId = appConfig.releaseId;
delete appConfig.releaseId;
service.serviceId = appConfig.serviceId;
delete appConfig.serviceId;
service.imageName = appConfig.imageName;
delete appConfig.imageName;
service.dependsOn = appConfig.dependsOn || null;
delete appConfig.dependsOn;
service.createdAt = appConfig.createdAt;
delete appConfig.createdAt;
// We don't need this value
delete appConfig.commit;
// Get rid of any extra values and report them to the user
const config = sanitiseComposeConfig(appConfig);
// Process some values into the correct format, delete them from
// the original object, and add them to the defaults object below
// We do it using defaults, as the types may be slightly different.
// For any types which do not change, we change config[value] directly
// First process the networks correctly
let networks: ServiceConfig['networks'] = { };
if (_.isArray(config.networks)) {
_.each(config.networks, (name) => {
networks[name] = { };
});
} else if(_.isObject(config.networks)) {
networks = config.networks || { };
}
// Prefix the network entries with the app id
networks = _.mapKeys(networks, (_v, k) => `${service.appId}_${k}`);
delete config.networks;
// Check for unsupported networkMode entries
if (config.networkMode != null) {
if (/service:(\s*)?.+/.test(config.networkMode)) {
console.log('Warning: A network_mode referencing a service is not yet supported. Ignoring.');
delete config.networkMode;
} else if (/container:(\s*)?.+/.test(config.networkMode)) {
console.log('Warning: A network_mode referencing a container is not supported. Ignoring.');
delete config.networkMode;
}
}
// memory strings
const memLimit = ComposeUtils.parseMemoryNumber(config.memLimit, '0');
const memReservation = ComposeUtils.parseMemoryNumber(config.memReservation, '0');
const shmSize = ComposeUtils.parseMemoryNumber(config.shmSize, '64m');
delete config.memLimit;
delete config.memReservation;
delete config.shmSize;
// time strings
let stopGracePeriod = 0;
if (config.stopGracePeriod != null) {
stopGracePeriod = new Duration(config.stopGracePeriod).seconds();
}
delete config.stopGracePeriod;
// ulimits
const ulimits: ServiceConfig['ulimits'] = { };
_.each(config.ulimits, (limit, name) => {
if (_.isNumber(limit)) {
ulimits[name] = { soft: limit, hard: limit };
return;
}
ulimits[name] = { soft: limit.soft, hard: limit.hard };
});
delete config.ulimits;
// string or array of strings - normalise to an array
if (_.isString(config.dns)) {
config.dns = [ config.dns ];
}
if (_.isString(config.dnsSearch)) {
config.dnsSearch = [ config.dnsSearch ];
}
// Assign network_mode to a default value if necessary
if (!config.networkMode) {
if (!_.isEmpty(networks)) {
config.networkMode = _.keys(networks)[0];
} else {
config.networkMode = 'default';
}
}
if (
config.networkMode !== 'host' &&
config.networkMode !== 'bridge' &&
config.networkMode !== 'none'
) {
if (networks[config.networkMode] == null) {
// The network mode has not been set explicitly
config.networkMode = `${service.appId}_${config.networkMode}`;
// If we don't have any networks, we need to
// create the default with some default options
networks[config.networkMode] = {
aliases: [ service.serviceName || '' ],
};
}
}
// Add default environment variables and labels
config.environment = Service.extendEnvVars(
config.environment || { },
options,
service.appId || 0,
service.serviceName || '',
);
config.labels = Service.extendLabels(
config.labels || { },
options,
service.appId || 0,
service.serviceId || 0,
service.serviceName || '',
);
// Any other special case handling
if (config.networkMode === 'host' && !config.hostname) {
config.hostname = options.hostnameOnHost;
}
config.restart = ComposeUtils.createRestartPolicy(config.restart);
config.command = ComposeUtils.getCommand(config.command, options.imageInfo);
config.entrypoint = ComposeUtils.getEntryPoint(config.entrypoint, options.imageInfo);
config.stopSignal = ComposeUtils.getStopSignal(config.stopSignal, options.imageInfo);
config.workingDir = ComposeUtils.getWorkingDir(config.workingDir, options.imageInfo);
config.user = ComposeUtils.getUser(config.user, options.imageInfo);
const healthcheck = ComposeUtils.getHealthcheck(config.healthcheck, options.imageInfo);
delete config.healthcheck;
config.volumes = Service.extendAndSanitiseVolumes(
config.volumes,
options.imageInfo,
service.appId || 0,
service.serviceName || '',
);
let portMaps: PortMap[] = [];
if (config.ports != null) {
portMaps = _.map(config.ports, (p) => new PortMap(p));
}
delete config.ports;
// get the exposed ports, both from the image and the compose file
let expose: string[] = [];
if (config.expose != null) {
expose = _.map(config.expose, ComposeUtils.sanitiseExposeFromCompose);
}
const imageExposedPorts = _.get(options.imageInfo, 'Config.ExposedPorts', { });
expose = expose.concat(_.keys(imageExposedPorts));
expose = _.uniq(expose);
// Also add any exposed ports which are implied from the portMaps
const exposedFromPortMappings = _.flatMap(portMaps, (port) => port.toExposedPortArray());
expose = expose.concat(exposedFromPortMappings);
delete config.expose;
let devices: DockerDevice[] = [];
if (config.devices != null) {
devices = _.map(config.devices, ComposeUtils.formatDevice);
}
delete config.devices;
// Sanity check the incoming boolean values
config.oomKillDisable = Boolean(config.oomKillDisable);
config.readOnly = Boolean(config.readOnly);
if (_.isArray(config.sysctls)) {
config.sysctls = _.fromPairs(_.map(config.sysctls, (v) => _.split(v, '=')));
}
config.sysctls = _.mapValues(config.sysctls, String);
_.each([ 'cpuShares', 'cpuQuota', 'oomScoreAdj' ], (key)=> {
const numVal = checkInt(config[key]);
if (numVal) {
config[key] = numVal;
} else {
delete config[key];
}
});
if (config.cpus != null) {
config.cpus = Math.round(Number(config.cpus) * 10 ** 9);
if (_.isNaN(config.cpus)) {
console.log('Warning: config.cpus value cannot be parsed. Ignoring.');
console.log(` Value: ${config.cpus}`);
config.cpus = undefined;
}
}
let tmpfs: string[] = [];
if (config.tmpfs != null) {
if (_.isString(config.tmpfs)) {
tmpfs = [ config.tmpfs ];
} else {
tmpfs = config.tmpfs;
}
}
delete config.tmpfs;
// Normalise the config before passing it to defaults
ComposeUtils.normalizeNullValues(config);
service.config = _.defaults(config, {
portMaps,
capAdd: [ ],
capDrop:[ ],
command: [ ],
cgroupParent: '',
devices,
dnsOpt: [ ],
entrypoint: '',
extraHosts: [ ],
expose,
networks,
dns: [ ],
dnsSearch: [ ],
environment: { },
labels: { },
networkMode: '',
ulimits,
groupAdd: [ ],
healthcheck,
pid: '',
pidsLimit: 0,
securityOpt: [ ],
stopGracePeriod,
stopSignal: '',
storageOpt: { },
sysctls: { },
tmpfs,
usernsMode: '',
volumes: [ ],
restart: 'always',
cpuShares: 0,
cpuQuota: 0,
cpus: 0,
cpuset: '',
domainname: '',
ipc: 'shareable',
macAddress: '',
memLimit,
memReservation,
oomKillDisable: false,
oomScoreAdj: 0,
privileged: false,
readOnly: false,
shmSize,
hostname: '',
user: '',
workingDir: '',
});
// Mutate service with extra features
ComposeUtils.addFeaturesFromLabels(service, options);
return service;
}
public static fromDockerContainer(container: Dockerode.ContainerInspectInfo): Service {
const svc = new Service();
if (container.State.Running) {
svc.status = 'Running';
} else if(container.State.Status === 'created') {
svc.status = 'Installed';
} else if(container.State.Status === 'dead') {
svc.status = 'Dead';
} else {
svc.status = container.State.Status;
}
svc.createdAt = new Date(container.Created);
svc.containerId = container.Id;
let hostname = container.Config.Hostname;
if (hostname.length === 12 && _.startsWith(container.Id, hostname)) {
// A hostname equal to the first part of the container ID actually
// means no hostname was specified
hostname = '';
}
let networks: ServiceConfig['networks'] = { };
if (_.get(container, 'NetworkSettings.Networks', null) != null) {
networks = ComposeUtils.dockerNetworkToServiceNetwork(container.NetworkSettings.Networks);
}
const ulimits: ServiceConfig['ulimits'] = { };
_.each(container.HostConfig.Ulimits, ({ Name, Soft, Hard }) => {
ulimits[Name] = { soft: Soft, hard: Hard };
});
const portMaps = PortMap.fromDockerOpts(container.HostConfig.PortBindings);
let expose = _.flatMap(
_.flatMap(portMaps, (p) => p.toDockerOpts().exposedPorts),
_.keys,
);
if (container.Config.ExposedPorts != null) {
expose = expose.concat(_.map(container.Config.ExposedPorts, (_v, k) => k.toString()));
}
expose = _.uniq(expose);
const tmpfs: string[] = [];
_.each((container.HostConfig as any).Tmpfs, (_v, key) => {
tmpfs.push(key);
});
// We cannot use || for this value, as the empty string is a
// valid restart policy but will equate to null in an OR
let restart = (container.HostConfig.RestartPolicy || {}).Name;
if (restart == null) {
restart = 'always';
}
// Define the service config with the same defaults that are used
// when creating from a compose object, so comparisons will work
// correctly
// TODO: We have extended HostConfig interface to keep up with the
// missing typings, but we cannot do the same the Config sub-object
// as it is not defined as it's own type. We need to either recreate
// the entire ContainerInspectInfo object, or upstream the extra
// fields to DefinitelyTyped
svc.config = {
networkMode: container.HostConfig.NetworkMode,
portMaps,
expose,
hostname,
command: container.Config.Cmd || '',
entrypoint: container.Config.Entrypoint || '',
volumes: _.concat(container.HostConfig.Binds || [], _.keys(container.Config.Volumes || { })),
image: container.Config.Image,
environment: conversions.envArrayToObject(container.Config.Env || [ ]),
privileged: container.HostConfig.Privileged || false,
labels: container.Config.Labels || { },
running: container.State.Running,
restart,
capAdd: container.HostConfig.CapAdd || [ ],
capDrop: container.HostConfig.CapDrop || [ ],
devices: container.HostConfig.Devices || [ ],
networks,
memLimit: container.HostConfig.Memory || 0 ,
memReservation: container.HostConfig.MemoryReservation || 0,
shmSize: container.HostConfig.ShmSize || 0,
cpuShares: container.HostConfig.CpuShares || 0,
cpuQuota: container.HostConfig.CpuQuota || 0,
// Not present on a container inspect
cpus: 0,
cpuset: container.HostConfig.CpusetCpus || '',
domainname: container.Config.Domainname || '',
oomKillDisable: container.HostConfig.OomKillDisable || false,
oomScoreAdj: container.HostConfig.OomScoreAdj || 0,
dns: container.HostConfig.Dns || [ ],
dnsSearch: container.HostConfig.DnsSearch || [ ],
dnsOpt: container.HostConfig.DnsOptions || [ ],
tmpfs,
extraHosts: container.HostConfig.ExtraHosts || [ ],
ulimits,
stopSignal: (container.Config as any).StopSignal || '',
stopGracePeriod: (container.Config as any).StopTimeout || 0,
healthcheck: ComposeUtils.dockerHealthcheckToServiceHealthcheck(
(container.Config as any).Healthcheck || { },
),
readOnly: container.HostConfig.ReadonlyRootfs || false,
sysctls: container.HostConfig.Sysctls || { },
cgroupParent: container.HostConfig.CgroupParent || '',
groupAdd: container.HostConfig.GroupAdd || [ ],
pid: container.HostConfig.PidMode || '',
pidsLimit: container.HostConfig.PidsLimit || 0,
securityOpt: container.HostConfig.SecurityOpt || [ ],
// StorageOpt is present on container creation, but not
// when you inspect the container
storageOpt: { },
usernsMode: container.HostConfig.UsernsMode || '',
ipc: container.HostConfig.IpcMode || '',
macAddress: (container.Config as any).MacAddress || '',
user: container.Config.User || '',
workingDir: container.Config.WorkingDir || '',
};
svc.appId = checkInt(container.Config.Labels['io.resin.app-id']) || null;
svc.serviceId = checkInt(container.Config.Labels['io.resin.service-id']) || null;
svc.serviceName = container.Config.Labels['io.resin.service-name'];
const nameMatch = container.Name.match(/.*_(\d+)_(\d+)$/);
svc.imageId = nameMatch != null ? checkInt(nameMatch[1]) || null : null;
svc.releaseId = nameMatch != null ? checkInt(nameMatch[2]) || null : null;
svc.containerId = container.Id;
return svc;
}
public toDockerContainer(): Dockerode.ContainerCreateOptions {
const { binds, volumes } = this.getBindsAndVolumes();
const { exposedPorts, portBindings } = this.generateExposeAndPorts();
const tmpFs: Dictionary<''> = { };
_.each(this.config.tmpfs, (tmp) => {
tmpFs[tmp] = '';
});
return {
Tty: true,
Cmd: this.config.command,
Volumes: volumes,
// Typings are wrong here, the docker daemon accepts a string or string[],
Entrypoint: this.config.entrypoint as string,
Env: conversions.envObjectToArray(this.config.environment),
ExposedPorts: exposedPorts,
Image: this.config.image,
Labels: this.config.labels,
NetworkingConfig: ComposeUtils.serviceNetworksToDockerNetworks(this.config.networks),
StopSignal: this.config.stopSignal,
Domainname: this.config.domainname,
Hostname: this.config.hostname,
// Typings are wrong here, it says MacAddress is a bool (wtf?) but it is
// in fact a string
MacAddress: this.config.macAddress as any,
User: this.config.user,
WorkingDir: this.config.workingDir,
HostConfig: {
CapAdd: this.config.capAdd,
CapDrop: this.config.capDrop,
Binds: binds,
CgroupParent: this.config.cgroupParent,
Devices: this.config.devices,
Dns: this.config.dns,
DnsOptions: this.config.dnsOpt,
DnsSearch: this.config.dnsSearch,
PortBindings: portBindings,
ExtraHosts: this.config.extraHosts,
GroupAdd: this.config.groupAdd,
NetworkMode: this.config.networkMode,
PidMode: this.config.pid,
PidsLimit: this.config.pidsLimit,
SecurityOpt: this.config.securityOpt,
Sysctls: this.config.sysctls,
Ulimits: ComposeUtils.serviceUlimitsToDockerUlimits(this.config.ulimits),
RestartPolicy: ComposeUtils.serviceRestartToDockerRestartPolicy(this.config.restart),
CpuShares: this.config.cpuShares,
CpuQuota: this.config.cpuQuota,
// Type missing, and HostConfig isn't defined as a seperate object
// so we cannot extend it easily
CpusetCpus: this.config.cpuset,
Memory: this.config.memLimit,
MemoryReservation: this.config.memReservation,
OomKillDisable: this.config.oomKillDisable,
OomScoreAdj: this.config.oomScoreAdj,
Privileged: this.config.privileged,
ReadonlyRootfs: this.config.readOnly,
ShmSize: this.config.shmSize,
Tmpfs: tmpFs,
StorageOpt: this.config.storageOpt,
UsernsMode: this.config.usernsMode,
NanoCpus: this.config.cpus,
IpcMode: this.config.ipc,
} as Dockerode.ContainerCreateOptions['HostConfig'],
Healthcheck: ComposeUtils.serviceHealthcheckToDockerHealthcheck(this.config.healthcheck),
StopTimeout: this.config.stopGracePeriod,
};
}
public isEqualConfig(service: Service): boolean {
// Check all of the networks for any changes
let sameNetworks = true;
_.each(service.config.networks, (network, name) => {
if (this.config.networks[name] == null) {
sameNetworks = false;
return;
}
sameNetworks = sameNetworks && this.isSameNetwork(this.config.networks[name], network);
});
// Check the configuration for any changes
const thisOmitted = _.omit(this.config, Service.omitFields);
const otherOmitted = _.omit(service.config, Service.omitFields);
let sameConfig = _.isEqual(
thisOmitted,
otherOmitted,
);
const nonArrayEquals = sameConfig;
// Check for array fields which don't match
const differentArrayFields: string[] = [];
sameConfig = sameConfig && _.every(Service.configArrayFields, (field: ServiceConfigArrayField) => {
return _.isEmpty(
_.xorWith(
// TODO: The typings here aren't accepted, even though we
// know it's fine
(this.config as any)[field],
(service.config as any)[field],
(a, b) => {
const eq = _.isEqual(a, b);
if (!eq) {
differentArrayFields.push(field);
}
return eq;
},
),
);
});
if (!(sameConfig && sameNetworks)) {
// Add some console output for why a service is not matching
// so that if we end up in a restart loop, we know exactly why
console.log(`Replacing container for service ${this.serviceName} because of config changes:`);
if (!nonArrayEquals) {
console.log(' Non-array fields: ', JSON.stringify(diff(
thisOmitted,
otherOmitted,
)));
}
if (differentArrayFields.length > 0) {
console.log(' Array Fields: ', differentArrayFields.join(','));
}
if (!sameNetworks) {
console.log(' Network changes detected');
}
}
return sameNetworks && sameConfig;
}
public extraNetworksToJoin(): ServiceConfig['networks'] {
return _.omit(this.config.networks, this.config.networkMode);
}
public isEqualExceptForRunningState(service: Service): boolean {
return this.isEqualConfig(service) &&
this.releaseId === service.releaseId &&
this.imageId === service.imageId;
}
public isEqual(service: Service): boolean {
return this.isEqualExceptForRunningState(service) &&
this.config.running === service.config.running;
}
public getNamedVolumes() {
const defaults = Service.defaultBinds(this.appId || 0, this.serviceName || '');
const validVolumes = _.map(this.config.volumes, (volume) => {
if (_.includes(defaults, volume) || !_.includes(volume, ':')) {
return null;
}
const bindSource = volume.split(':')[0];
if (!path.isAbsolute(bindSource)) {
const match = bindSource.match(/[0-9]+_(.+)/);
if (match == null) {
console.log('Error: There was an error parsing a volume bind source, ignoring.');
console.log(' bind source: ', bindSource);
return null;
}
return match[1];
}
return null;
});
return _.reject(validVolumes, _.isNil);
}
private getBindsAndVolumes(): {
binds: string[],
volumes: { [volName: string]: { } }
} {
const binds: string[] = [ ];
const volumes: { [volName: string]: { } } = { };
_.each(this.config.volumes, (volume) => {
if (_.includes(volume, ':')) {
binds.push(volume);
} else {
volumes[volume] = { };
}
});
return { binds, volumes };
}
private generateExposeAndPorts(): DockerPortOptions {
const exposed: DockerPortOptions['exposedPorts'] = { };
const ports: DockerPortOptions['portBindings'] = { };
_.each(this.config.portMaps, (pmap) => {
const { exposedPorts, portBindings } = pmap.toDockerOpts();
_.merge(exposed, exposedPorts);
_.merge(ports, portBindings);
});
// We also want to merge the compose and image exposedPorts
// into the list of exposedPorts
const composeExposed: DockerPortOptions['exposedPorts'] = { };
_.each(this.config.expose, (port) => {
composeExposed[port] = { };
});
_.merge(exposed, composeExposed);
return { exposedPorts: exposed, portBindings: ports };
}
private static extendEnvVars(
environment: { [envVarName: string]: string } | null | undefined,
options: DeviceMetadata,
appId: number,
serviceName: string,
): { [envVarName: string]: string } {
let env = _.defaults(environment, {
RESIN_APP_ID: appId.toString(),
RESIN_APP_NAME: options.appName,
RESIN_SERVICE_NAME: serviceName,
RESIN_DEVICE_UUID: options.uuid,
RESIN_DEVICE_TYPE: options.deviceType,
RESIN_HOST_OS_VERSION: options.osVersion,
RESIN_SUPERVISOR_VERSION: options.version,
RESIN_APP_LOCK_PATH: '/tmp/resin/resin-updates.lock',
RESIN_SERVICE_KILL_ME_PATH: '/tmp/resin/resin-kill-me',
RESIN: '1',
USER: 'root',
});
const imageInfoEnv = _.get(options.imageInfo, 'Config.Env', []);
env = _.defaults(env, conversions.envArrayToObject(imageInfoEnv));
return env;
}
private isSameNetwork(
current: ServiceConfig['networks'][0],
target: ServiceConfig['networks'][0],
): boolean {
let sameNetwork = true;
// Compare only the values which are defined in the target, as the current
// values get set to defaults by docker
if (target.aliases != null) {
if (current.aliases == null) {
sameNetwork = false;
} else {
// Remove the auto-added docker container id
const currentAliases = _.filter(current.aliases, (alias: string) => {
return !_.startsWith(this.containerId!, alias);
});
const targetAliases = _.filter(current.aliases, (alias: string) => {
return !_.startsWith(this.containerId!, alias);
});
// Docker adds container ids to the alias list, directly after
// the service name, to detect this, check for both target having
// exactly half of the amount of entries as the current, and check
// that every second entry (starting from 0) is equal
if (currentAliases.length === targetAliases.length * 2) {
sameNetwork = _(currentAliases)
.filter((_v, k) => k % 2 === 0)
.isEqual(targetAliases);
} else {
// Otherwise compare them literally
sameNetwork = _.isEmpty(_.xorWith(currentAliases, targetAliases, _.isEqual));
}
}
}
if (target.ipv4Address != null) {
sameNetwork = sameNetwork && _.isEqual(current.ipv4Address, target.ipv4Address);
}
if (target.ipv6Address != null) {
sameNetwork = sameNetwork && _.isEqual(current.ipv6Address, target.ipv6Address);
}
if (target.linkLocalIps != null) {
sameNetwork = sameNetwork && _.isEqual(current.linkLocalIps, target.linkLocalIps);
}
return sameNetwork;
}
private static extendLabels(
labels: { [labelName: string]: string } | null | undefined,
{ imageInfo }: DeviceMetadata,
appId: number,
serviceId: number,
serviceName: string,
): { [labelName: string]: string } {
let newLabels = _.defaults(labels, {
'io.resin.supervised': 'true',
'io.resin.app-id': appId.toString(),
'io.resin.service-id': serviceId.toString(),
'io.resin.service-name': serviceName,
});
const imageLabels = _.get(imageInfo, 'Config.Labels', { });
newLabels = _.defaults(newLabels, imageLabels);
return newLabels;
}
private static extendAndSanitiseVolumes(
composeVolumes: ServiceComposeConfig['volumes'],
imageInfo: Dockerode.ImageInspectInfo | undefined,
appId: number,
serviceName: string,
): ServiceConfig['volumes'] {
let volumes: ServiceConfig['volumes'] = [];
_.each(composeVolumes, (volume) => {
const isBind = _.includes(volume, ':');
if (isBind) {
const [ bindSource, bindDest, mode ] = volume.split(':');
if (!path.isAbsolute(bindSource)) {
// namespace our volumes by appId
let volumeDef = `${appId}_${bindSource}:${bindDest}`;
if (mode != null) {
volumeDef = `${volumeDef}:${mode}`;
}
volumes.push(volumeDef);
} else {
console.log(`Ignoring invalid bind mount ${volume}`);
}
} else {
volumes.push(volume);
}
});
// Now add the default and image binds
volumes = volumes.concat(Service.defaultBinds(appId, serviceName));
volumes = _.union(_.keys(_.get(imageInfo, 'Config.Volumes')), volumes);
return volumes;
}
private static defaultBinds(appId: number, serviceName: string): string[] {
return [
`${updateLock.lockPath(appId, serviceName)}:/tmp/resin`,
];
}
}

View File

@ -0,0 +1,204 @@
import * as Dockerode from 'dockerode';
import { PortMap } from '../ports';
export interface ComposeHealthcheck {
test: string | string[];
interval?: string;
timeout?: string;
startPeriod?: string;
retries?: number;
disable?: boolean;
}
export interface ServiceHealthcheck {
test: string[];
interval?: number;
timeout?: number;
startPeriod?: number;
retries?: number;
}
// This is the config directly from the compose file (after running it
// through _.camelCase)
export interface ServiceComposeConfig {
// Used for converting these fields in a programmatic fashion
// Unfortunately even keys known at compiler time don't work
// with the type system, as least at the moment
[key: string]: any;
capAdd?: string[];
capDrop?: string[];
command?: string[] | string;
cgroupParent?: string;
devices?: string[];
dns?: string | string[];
dnsOpt?: string[];
dnsSearch?: string | string[];
tmpfs?: string | string[];
entrypoint?: string | string[];
environment?: { [envVarName: string]: string };
expose?: string[];
extraHosts?: string[];
groupAdd?: string[];
healthcheck?: ComposeHealthcheck;
image: string;
init?: string | boolean;
labels?: { [labelName: string]: string };
running: boolean;
networkMode?: string;
networks?: string[] | {
[networkName: string]: {
aliases?: string[];
ipv4Address?: string;
ipv6Address?: string;
linkLocalIps?: string[];
}
};
pid?: string;
pidsLimit?: number;
ports?: string[];
securityOpt?: string[];
stopGracePeriod?: string;
stopSignal?: string;
storageOpt?: { [opt: string]: string };
sysctls?: { [name: string]: string };
ulimits?: {
[ulimitName: string]: number | { soft: number, hard: number };
};
usernsMode?: string;
volumes?: string[];
restart?: string;
cpuShares?: number;
cpuQuota?: number;
cpus?: number;
cpuset?: string;
domainname?: string;
hostname?: string;
ipc?: string;
macAddress?: string;
memLimit?: string;
memReservation?: string;
oomKillDisable?: boolean;
oomScoreAdj?: number;
privileged?: boolean;
readOnly?: boolean;
shmSize?: string;
user?: string;
workingDir?: string;
}
// This is identical to ServiceComposeConfig, except for the
// cases where these values are represented by higher level types.
export interface ServiceConfig {
portMaps: PortMap[];
capAdd: string[];
capDrop: string[];
command: string[];
cgroupParent: string;
devices: DockerDevice[];
dns: string | string[];
dnsOpt: string[];
dnsSearch: string | string[];
tmpfs: string[];
entrypoint: string | string[];
environment: { [envVarName: string]: string };
expose: string[];
extraHosts: string[];
groupAdd: string[];
healthcheck: ServiceHealthcheck;
image: string;
labels: { [labelName: string]: string };
running: boolean;
networkMode: string;
networks: {
[networkName: string]: {
aliases?: string[];
ipv4Address?: string;
ipv6Address?: string;
linkLocalIps?: string[];
}
};
pid: string;
pidsLimit: number;
securityOpt: string[];
stopGracePeriod: number;
stopSignal: string;
storageOpt: { [opt: string]: string };
sysctls: { [name: string]: string };
ulimits: {
[ulimitName: string]: { soft: number, hard: number };
};
usernsMode: string;
volumes: string[];
restart: string;
cpuShares: number;
cpuQuota: number;
cpus: number;
cpuset: string;
domainname: string;
hostname: string;
ipc: string;
macAddress: string;
memLimit: number;
memReservation: number;
oomKillDisable: boolean;
oomScoreAdj: number;
privileged: boolean;
readOnly: boolean;
shmSize: number;
user: string;
workingDir: string;
}
export type ServiceConfigArrayField = 'volumes' |
'devices' |
'capAdd' |
'capDrop' |
'dns' |
'dnsSearch' |
'dnsOpt' |
'expose' |
'tmpfs' |
'extraHosts' |
'ulimitsArray' |
'groupAdd' |
'securityOpt';
// The config directly from the application manager, which contains
// application information, plus the compose data
export interface ConfigMap {
[name: string]: any;
}
// When creating a service from the compose data, we need to extend the labels
// and environment variables which more information, defined below. Note
// that these do not need to be provided when create the service object from
// the docker inspect call, because they would have already been set by then.
// TODO: Move these to a more appropriate location once more of the supervisor
// is typescript
export interface DeviceMetadata {
imageInfo?: Dockerode.ImageInspectInfo;
uuid: string;
appName: string;
version: string;
deviceType: string;
deviceApiKey: string;
listenPort: number;
apiSecret: string;
supervisorApiHost: string;
osVersion: string;
hostnameOnHost: string;
hostPathExists: {
modules: boolean;
firmware: boolean;
};
}
export interface DockerDevice {
PathOnHost: string;
PathInContainer: string;
CgroupPermissions: string;
}

436
src/compose/utils.ts Normal file
View File

@ -0,0 +1,436 @@
import * as Dockerode from 'dockerode';
import Duration = require('duration-js');
import * as _ from 'lodash';
import { parse as parseCommand } from 'shell-quote';
import * as constants from '../lib/constants';
import { checkTruthy } from '../lib/validation';
import { Service } from './service';
import {
ComposeHealthcheck,
ConfigMap,
DeviceMetadata,
DockerDevice,
ServiceComposeConfig,
ServiceConfig,
ServiceHealthcheck,
} from './types/service';
export function camelCaseConfig(literalConfig: ConfigMap): ServiceComposeConfig {
const config = _.mapKeys(literalConfig, (_v, k) => _.camelCase(k));
if (_.isObject(config.networks)) {
const networksTmp = _.cloneDeep(config.networks);
_.each(networksTmp, (v, k) => {
config.networks[k] = _.mapKeys(v, (_v, k) => _.camelCase(k));
});
}
return config as ServiceComposeConfig;
}
export function parseMemoryNumber(valueAsString: string | null | undefined, defaultValue?: string): number {
if (valueAsString == null) {
if (defaultValue != null) {
return parseMemoryNumber(defaultValue);
}
return 0;
}
const match = valueAsString.toString().match(/^([0-9]+)([bkmg]?)b?$/i);
if (match == null) {
if (defaultValue != null) {
return parseMemoryNumber(defaultValue);
}
return 0;
}
const num = match[1];
const pow: { [key: string]: number } = { '': 0, b: 0, B: 0, K: 1, k: 1, m: 2, M: 2, g: 3, G: 3 };
return parseInt(num, 10) * 1024 ** pow[match[2]];
}
export const validRestartPolicies = [
'no',
'always',
'on-failure',
'unless-stopped',
];
export function createRestartPolicy(
name?: string,
): string {
if (name == null) {
return 'always';
}
// Ensure that name is a string, otherwise the below could
// throw
if (!_.isString(name)) {
console.log(`Warning: Non-string argument for restart field: ${name} - ignoring.`);
return 'always';
}
name = name.toLowerCase().trim();
if(!_.includes(validRestartPolicies, name)) {
return 'always';
}
if (name === 'no') {
return '';
}
return name;
}
function processCommandString(command: string): string {
return command.replace(/(\$)/g, '\\$1');
}
function processCommandParsedArrayElement(arg: string | { [key: string]: string}): string {
if (_.isString(arg)) {
return arg;
}
if (arg.op === 'glob') {
return arg.pattern;
}
return arg.op;
}
function commandAsArray(command: string | string[]): string[] {
if (_.isString(command)) {
return _.map(
parseCommand(processCommandString(command)),
processCommandParsedArrayElement,
);
}
return command;
}
export function getCommand(
composeCommand: string | string[] | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string[] {
if (composeCommand != null) {
return commandAsArray(composeCommand);
}
const imgCommand = _.get(imageInfo, 'Config.Cmd', []);
return commandAsArray(imgCommand);
}
export function getEntryPoint(
composeEntry: string | string[] | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string[] {
if (composeEntry != null) {
return commandAsArray(composeEntry);
}
const imgEntry = _.get(imageInfo, 'Config.Entrypoint', []);
return commandAsArray(imgEntry);
}
// Note that the typings for the compose file stop signal
// say that this can only be a string, but the yaml parser
// could pass it through as a number, so support that here
export function getStopSignal(
composeStop: string | number | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string {
if (composeStop != null) {
if (!_.isString(composeStop)) {
return composeStop.toString();
}
return composeStop;
}
return _.get(imageInfo, 'Config.StopSignal', '');
}
// TODO: Move healthcheck stuff into seperate module
export function dockerHealthcheckToServiceHealthcheck(
healthcheck?: Dockerode.DockerHealthcheck,
): ServiceHealthcheck {
if (healthcheck == null || _.isEmpty(healthcheck)) {
return { test: [ 'NONE' ] };
}
const serviceHC: ServiceHealthcheck = {
test: healthcheck.Test,
};
if (healthcheck.Interval != null) {
serviceHC.interval = healthcheck.Interval;
}
if (healthcheck.Timeout != null) {
serviceHC.timeout = healthcheck.Timeout;
}
if (healthcheck.StartPeriod != null) {
serviceHC.startPeriod = healthcheck.StartPeriod;
}
if (healthcheck.Retries != null) {
serviceHC.retries = healthcheck.Retries;
}
return serviceHC;
}
function buildHealthcheckTest(
test: string | string[],
): string[] {
if (_.isString(test)) {
return [ 'CMD-SHELL', test];
}
return test;
}
function getNanoseconds(timeStr: string): number {
return new Duration(timeStr).nanoseconds();
}
export function composeHealthcheckToServiceHealthcheck(
healthcheck: ComposeHealthcheck | null | undefined,
): ServiceHealthcheck {
if (healthcheck == null || healthcheck.disable) {
return { test: [ 'NONE' ] };
}
const serviceHC: ServiceHealthcheck = {
test: buildHealthcheckTest(healthcheck.test),
};
if (healthcheck.interval != null) {
serviceHC.interval = getNanoseconds(healthcheck.interval);
}
if (healthcheck.timeout != null) {
serviceHC.timeout = getNanoseconds(healthcheck.timeout);
}
if (healthcheck.startPeriod != null) {
serviceHC.startPeriod = getNanoseconds(healthcheck.startPeriod);
}
if (healthcheck.retries != null) {
serviceHC.retries = healthcheck.retries;
}
return serviceHC;
}
export function getHealthcheck(
composeHealthcheck: ComposeHealthcheck | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): ServiceHealthcheck {
// get the image info healtcheck
const imageServiceHealthcheck = dockerHealthcheckToServiceHealthcheck(
_.get(imageInfo, 'Config.Healthcheck', null),
);
const composeServiceHealthcheck = composeHealthcheckToServiceHealthcheck(
composeHealthcheck,
);
return _.defaults(composeServiceHealthcheck, imageServiceHealthcheck);
}
export function serviceHealthcheckToDockerHealthcheck(
healthcheck: ServiceHealthcheck,
): Dockerode.DockerHealthcheck {
return {
Test: healthcheck.test,
Interval: healthcheck.interval,
Retries: healthcheck.retries,
StartPeriod: healthcheck.startPeriod,
Timeout: healthcheck.timeout,
};
}
export function getWorkingDir(
workingDir: string | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string {
return (workingDir != null ? workingDir : _.get(imageInfo, 'Config.WorkingDir', ''))
.replace(/(^.+)\/$/, '$1');
}
export function getUser(
user: string | null | undefined,
imageInfo?: Dockerode.ImageInspectInfo,
): string {
return user != null ? user : _.get(imageInfo, 'Config.User', '');
}
export function sanitiseExposeFromCompose(
portStr: string,
): string {
if (/^[0-9]*$/.test(portStr)) {
return `${portStr}/tcp`;
}
return portStr;
}
export function formatDevice(
deviceStr: string,
): DockerDevice {
const [ pathOnHost, ...parts ] = deviceStr.split(':');
let [ pathInContainer, cgroup ] = parts;
if (pathInContainer == null) {
pathInContainer = pathOnHost;
}
if (cgroup == null) {
cgroup = 'rwm';
}
return {
PathOnHost: pathOnHost,
PathInContainer: pathInContainer,
CgroupPermissions: cgroup,
};
}
// TODO: Export these strings to a constant lib, to
// enable changing them easily
// Mutates service
export function addFeaturesFromLabels(
service: Service,
options: DeviceMetadata,
): void {
if (checkTruthy(service.config.labels['io.resin.features.dbus'])) {
service.config.volumes.push('/run/dbus:/host/run/dbus');
}
if (
checkTruthy(service.config.labels['io.resin.features.kernel-modules']) &&
options.hostPathExists.modules
) {
service.config.volumes.push('/lib/modules:/lib/modules');
}
if (
checkTruthy(service.config.labels['io.resin.features.firmware']) &&
options.hostPathExists.firmware
) {
service.config.volumes.push('/lib/firmware:/lib/firmware');
}
if (checkTruthy(service.config.labels['io.resin.features.balena-socket'])) {
service.config.volumes.push('/var/run/balena.sock:/var/run/balena.sock');
if (service.config.environment['DOCKER_HOST'] == null) {
service.config.environment['DOCKER_HOST'] = 'unix:///var/run/balena.sock';
}
}
if (checkTruthy('io.resin.features.resin-api')) {
service.config.environment['RESIN_API_KEY'] = options.deviceApiKey;
}
if (checkTruthy(service.config.labels['io.resin.features.supervisor-api'])) {
service.config.environment['RESIN_SUPERVISOR_PORT'] = options.listenPort.toString();
service.config.environment['RESIN_SUPERVISOR_API_KEY'] = options.apiSecret;
if (service.config.networkMode === 'host') {
service.config.environment['RESIN_SUPERVISOR_HOST'] = '127.0.0.1';
service.config.environment['RESIN_SUPERVISOR_ADDRESS'] = `http://127.0.0.1:${options.listenPort}`;
} else {
service.config.environment['RESIN_SUPERVISOR_HOST'] = options.supervisorApiHost;
service.config.environment['RESIN_SUPERVISOR_ADDRESS'] =
`http://${options.supervisorApiHost}:${options.listenPort}`;
service.config.networks[constants.supervisorNetworkInterface] = { };
}
} else {
// Ensure that the user hasn't added 'supervisor0' to the service's list
// of networks
delete service.config.networks[constants.supervisorNetworkInterface];
}
}
export function serviceUlimitsToDockerUlimits(
ulimits: ServiceConfig['ulimits'] | null | undefined,
): Array<{ Name: string, Soft: number, Hard: number }> {
const ret: Array<{ Name: string, Soft: number, Hard: number }> = [];
_.each(ulimits, ({ soft, hard }, name) => {
ret.push({ Name: name, Soft: soft, Hard: hard });
});
return ret;
}
export function serviceRestartToDockerRestartPolicy(restart: string): { Name: string, MaximumRetryCount: number } {
return {
Name: restart,
MaximumRetryCount: 0,
};
}
export function serviceNetworksToDockerNetworks(networks: ServiceConfig['networks'])
: Dockerode.ContainerCreateOptions['NetworkingConfig'] {
const dockerNetworks: Dockerode.ContainerCreateOptions['NetworkingConfig'] = {
EndpointsConfig: { },
};
_.each(networks, (net, name) => {
// WHY??? This shouldn't be necessary, as we define it above...
if (dockerNetworks.EndpointsConfig != null) {
dockerNetworks.EndpointsConfig[name] = { };
const conf = dockerNetworks.EndpointsConfig[name];
conf.IPAMConfig = { };
conf.Aliases = [ ];
_.each(net, (v, k) => {
switch(k) {
case 'ipv4Address':
conf.IPAMConfig.IPV4Address = v;
break;
case 'ipv6Address':
conf.IPAMConfig.IPV6Address = v;
break;
case 'linkLocalIps':
conf.IPAMConfig.LinkLocalIps = v;
break;
case 'aliases':
conf.Aliases = v;
break;
}
});
}
});
return dockerNetworks;
}
export function dockerNetworkToServiceNetwork(
dockerNetworks: Dockerode.ContainerInspectInfo['NetworkSettings']['Networks'],
): ServiceConfig['networks'] {
// Take the input network object, filter out any nullish fields, extract things to
// the correct level and return
const networks: ServiceConfig['networks'] = { };
_.each(dockerNetworks, (net, name) => {
networks[name] = { };
if (net.Aliases != null && !_.isEmpty(net.Aliases)) {
networks[name].aliases = net.Aliases;
}
if (net.IPAMConfig != null) {
const ipam = net.IPAMConfig;
if (ipam.IPv4Address != null && !_.isEmpty(ipam.IPv4Address)) {
networks[name].ipv4Address = ipam.IPv4Address;
}
if (ipam.IPv6Address != null && !_.isEmpty(ipam.IPv6Address)) {
networks[name].ipv6Address = ipam.IPv6Address;
}
if (ipam.LinkLocalIps != null && !_.isEmpty(ipam.LinkLocalIps)) {
networks[name].linkLocalIps = ipam.LinkLocalIps;
}
}
});
return networks;
}
// Mutates obj
export function normalizeNullValues(obj: Dictionary<any>): void {
_.each(obj, (v, k) => {
if (v == null) {
obj[k] = undefined;
} else if(_.isObject(v)) {
normalizeNullValues(v);
}
});
}

View File

@ -19,3 +19,7 @@ export function envArrayToObject(env: string[]): EnvVarObject {
.fromPairs()
.value();
}
export function envObjectToArray(env: EnvVarObject): string[] {
return _.map(env, (v, k) => `${k}=${v}`);
}

View File

@ -32,3 +32,9 @@ export function UnitNotLoadedError(err: string[]): boolean {
export class InvalidNetGatewayError extends TypedError { }
export class DeltaStillProcessingError extends TypedError { }
export class InvalidAppIdError extends TypedError {
public constructor(public appId: any) {
super(`Invalid appId: ${appId}`);
}
}

11
src/lib/update-lock.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
import TypedError = require('typed-error');
export interface LockCallback {
(appId: number, opts: { force: boolean }, fn: () => void): Promise<void>;
}
export class UpdatesLockedError extends TypedError {
}
export function lock(): LockCallback;
export function lockPath(appId: number, serviceName: string): string;

View File

@ -11,6 +11,7 @@ const ENV_VAR_KEY_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const LABEL_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9\.\-]*$/;
type NullableString = string | undefined | null;
type NullableLiteral = number | NullableString;
/**
* checkInt
@ -18,12 +19,14 @@ type NullableString = string | undefined | null;
* Check an input string as a number, optionally specifying a requirement
* to be positive
*/
export function checkInt(s: NullableString, options: CheckIntOptions = {}): number | void {
export function checkInt(s: NullableLiteral, options: CheckIntOptions = {}): number | void {
if (s == null) {
return;
}
const i = parseInt(s, 10);
// parseInt will happily take a number, but the typings won't accept it,
// simply cast it here
const i = parseInt(s as string, 10);
if (isNaN(i)) {
return;
@ -41,7 +44,7 @@ export function checkInt(s: NullableString, options: CheckIntOptions = {}): numb
*
* Check that a string exists, and is not an empty string, 'null', or 'undefined'
*/
export function checkString(s: NullableString): string | void {
export function checkString(s: NullableLiteral): string | void {
if (s == null || !_.isString(s) || _.includes([ 'null', 'undefined', '' ], s)) {
return;
}

View File

@ -198,10 +198,6 @@ class ResinLogBackend extends LogBackend {
}
}
interface LoggerConstructOptions {
eventTracker: EventTracker;
}
interface LoggerSetupOptions {
apiEndpoint: string;
uuid: string;
@ -217,6 +213,10 @@ enum OutputStream {
Stderr,
}
interface LoggerConstructOptions {
eventTracker: EventTracker;
}
export class Logger {
private writeLock: (key: string) => Bluebird<() => void> = Bluebird.promisify(
new Lock().async.writeLock,
@ -410,9 +410,9 @@ export class Logger {
}
if (eventObj.service != null &&
eventObj.service.serviceName != null &&
eventObj.service.image != null
eventObj.service.config.image != null
) {
return `${eventObj.service.serviceName} ${eventObj.service.image}`;
return `${eventObj.service.serviceName} ${eventObj.service.config.image}`;
}
if (eventObj.image != null) {
@ -427,6 +427,10 @@ export class Logger {
return eventObj.volume.name;
}
if (eventObj.fields != null) {
return eventObj.fields.join(',');
}
return null;
}
}

View File

@ -1,8 +1,24 @@
m = require 'mochainon'
{ expect } = m.chai
Service = require '../src/compose/service'
describe 'compose/service.cofee', ->
_ = require 'lodash'
{ Service } = require '../src/compose/service'
configs = {
simple: {
compose: require('./data/docker-states/simple/compose.json');
imageInfo: require('./data/docker-states/simple/imageInfo.json');
inspect: require('./data/docker-states/simple/inspect.json');
}
entrypoint: {
compose: require('./data/docker-states/entrypoint/compose.json');
imageInfo: require('./data/docker-states/entrypoint/imageInfo.json');
inspect: require('./data/docker-states/entrypoint/inspect.json');
}
}
describe 'compose/service.coffee', ->
it 'extends environment variables properly', ->
extendEnvVarsOpts = {
@ -24,9 +40,9 @@ describe 'compose/service.cofee', ->
FOO: 'bar'
A_VARIABLE: 'ITS_VALUE'
}
s = new Service(service, extendEnvVarsOpts)
s = Service.fromComposeObject(service, extendEnvVarsOpts)
expect(s.environment).to.deep.equal({
expect(s.config.environment).to.deep.equal({
FOO: 'bar'
A_VARIABLE: 'ITS_VALUE'
RESIN_APP_ID: '23'
@ -43,20 +59,20 @@ describe 'compose/service.cofee', ->
})
it 'returns the correct default bind mounts', ->
s = new Service({
s = Service.fromComposeObject({
appId: '1234'
serviceName: 'foo'
releaseId: 2
serviceId: 3
imageId: 4
})
binds = s.defaultBinds()
}, { appName: 'foo' })
binds = Service.defaultBinds(s.appId, s.serviceName)
expect(binds).to.deep.equal([
'/tmp/resin-supervisor/services/1234/foo:/tmp/resin'
])
it 'produces the correct port bindings and exposed ports', ->
s = new Service({
s = Service.fromComposeObject({
appId: '1234'
serviceName: 'foo'
releaseId: 2
@ -80,7 +96,7 @@ describe 'compose/service.cofee', ->
}
}
})
ports = s.generatePortBindings()
ports = s.generateExposeAndPorts()
expect(ports.portBindings).to.deep.equal({
'2344/tcp': [{
HostIp: '',
@ -106,7 +122,7 @@ describe 'compose/service.cofee', ->
})
it 'correctly handles port ranges', ->
s = new Service({
s = Service.fromComposeObject({
appId: '1234'
serviceName: 'foo'
releaseId: 2
@ -119,9 +135,9 @@ describe 'compose/service.cofee', ->
ports: [
'1000-1003:2000-2003'
]
})
}, { appName: 'test' })
ports = s.generatePortBindings()
ports = s.generateExposeAndPorts()
expect(ports.portBindings).to.deep.equal({
'2000/tcp': [
HostIp: ''
@ -152,7 +168,7 @@ describe 'compose/service.cofee', ->
it 'should correctly handle large port ranges', ->
@timeout(60000)
s = new Service({
s = Service.fromComposeObject({
appId: '1234'
serviceName: 'foo'
releaseId: 2
@ -162,65 +178,188 @@ describe 'compose/service.cofee', ->
'5-65536:5-65536/tcp'
'5-65536:5-65536/udp'
]
})
}, { appName: 'test' })
expect(s.generatePortBindings()).to.not.throw
expect(s.generateExposeAndPorts()).to.not.throw
it 'should correctly report implied exposed ports from portMappings', ->
service = Service.fromComposeObject({
appId: 123456,
serviceId: 123456,
serviceName: 'test',
ports: [
"80:80"
"100:100"
]
}, { appName: 'test' })
expect(service.config).to.have.property('expose').that.deep.equals(['80/tcp', '100/tcp'])
describe 'parseMemoryNumber()', ->
makeComposeServiceWithLimit = (memLimit) ->
new Service(
Service.fromComposeObject({
appId: 123456
serviceId: 123456
serviceName: 'foobar'
memLimit: memLimit
)
mem_limit: memLimit
}, { appName: 'test' })
it 'should correctly parse memory number strings without a unit', ->
expect(makeComposeServiceWithLimit('64').memLimit).to.equal(64)
expect(makeComposeServiceWithLimit('64').config.memLimit).to.equal(64)
it 'should correctly apply the default value', ->
expect(makeComposeServiceWithLimit(undefined).memLimit).to.equal(0)
expect(makeComposeServiceWithLimit(undefined).config.memLimit).to.equal(0)
it 'should correctly support parsing numbers as memory limits', ->
expect(makeComposeServiceWithLimit(64).memLimit).to.equal(64)
expect(makeComposeServiceWithLimit(64).config.memLimit).to.equal(64)
it 'should correctly parse memory number strings that use a byte unit', ->
expect(makeComposeServiceWithLimit('64b').memLimit).to.equal(64)
expect(makeComposeServiceWithLimit('64B').memLimit).to.equal(64)
expect(makeComposeServiceWithLimit('64b').config.memLimit).to.equal(64)
expect(makeComposeServiceWithLimit('64B').config.memLimit).to.equal(64)
it 'should correctly parse memory number strings that use a kilobyte unit', ->
expect(makeComposeServiceWithLimit('64k').memLimit).to.equal(65536)
expect(makeComposeServiceWithLimit('64K').memLimit).to.equal(65536)
expect(makeComposeServiceWithLimit('64k').config.memLimit).to.equal(65536)
expect(makeComposeServiceWithLimit('64K').config.memLimit).to.equal(65536)
expect(makeComposeServiceWithLimit('64kb').memLimit).to.equal(65536)
expect(makeComposeServiceWithLimit('64Kb').memLimit).to.equal(65536)
expect(makeComposeServiceWithLimit('64kb').config.memLimit).to.equal(65536)
expect(makeComposeServiceWithLimit('64Kb').config.memLimit).to.equal(65536)
it 'should correctly parse memory number strings that use a megabyte unit', ->
expect(makeComposeServiceWithLimit('64m').memLimit).to.equal(67108864)
expect(makeComposeServiceWithLimit('64M').memLimit).to.equal(67108864)
expect(makeComposeServiceWithLimit('64m').config.memLimit).to.equal(67108864)
expect(makeComposeServiceWithLimit('64M').config.memLimit).to.equal(67108864)
expect(makeComposeServiceWithLimit('64mb').memLimit).to.equal(67108864)
expect(makeComposeServiceWithLimit('64Mb').memLimit).to.equal(67108864)
expect(makeComposeServiceWithLimit('64mb').config.memLimit).to.equal(67108864)
expect(makeComposeServiceWithLimit('64Mb').config.memLimit).to.equal(67108864)
it 'should correctly parse memory number strings that use a gigabyte unit', ->
expect(makeComposeServiceWithLimit('64g').memLimit).to.equal(68719476736)
expect(makeComposeServiceWithLimit('64G').memLimit).to.equal(68719476736)
expect(makeComposeServiceWithLimit('64g').config.memLimit).to.equal(68719476736)
expect(makeComposeServiceWithLimit('64G').config.memLimit).to.equal(68719476736)
expect(makeComposeServiceWithLimit('64gb').memLimit).to.equal(68719476736)
expect(makeComposeServiceWithLimit('64Gb').memLimit).to.equal(68719476736)
expect(makeComposeServiceWithLimit('64gb').config.memLimit).to.equal(68719476736)
expect(makeComposeServiceWithLimit('64Gb').config.memLimit).to.equal(68719476736)
describe 'getWorkingDir', ->
makeComposeServiceWithWorkdir = (workdir) ->
new Service(
Service.fromComposeObject({
appId: 123456,
serviceId: 123456,
serviceName: 'foobar'
workingDir: workdir
)
}, { appName: 'test' })
it 'should remove a trailing slash', ->
expect(makeComposeServiceWithWorkdir('/usr/src/app/').workingDir).to.equal('/usr/src/app')
expect(makeComposeServiceWithWorkdir('/').workingDir).to.equal('/')
expect(makeComposeServiceWithWorkdir('/usr/src/app').workingDir).to.equal('/usr/src/app')
expect(makeComposeServiceWithWorkdir('').workingDir).to.equal('')
expect(makeComposeServiceWithWorkdir('/usr/src/app/').config.workingDir).to.equal('/usr/src/app')
expect(makeComposeServiceWithWorkdir('/').config.workingDir).to.equal('/')
expect(makeComposeServiceWithWorkdir('/usr/src/app').config.workingDir).to.equal('/usr/src/app')
expect(makeComposeServiceWithWorkdir('').config.workingDir).to.equal('')
describe 'Docker <-> Compose config', ->
omitConfigForComparison = (config) ->
return _.omit(config, ['running', 'networks'])
it 'should be identical when converting a simple service', ->
composeSvc = Service.fromComposeObject(configs.simple.compose, configs.simple.imageInfo)
dockerSvc = Service.fromDockerContainer(configs.simple.inspect)
composeConfig = omitConfigForComparison(composeSvc.config)
dockerConfig = omitConfigForComparison(dockerSvc.config)
expect(composeConfig).to.deep.equal(dockerConfig)
expect(dockerSvc.isEqualConfig(composeSvc)).to.be.true
it 'should correct convert formats with a null entrypoint', ->
composeSvc = Service.fromComposeObject(configs.entrypoint.compose, configs.entrypoint.imageInfo)
dockerSvc = Service.fromDockerContainer(configs.entrypoint.inspect)
composeConfig = omitConfigForComparison(composeSvc.config)
dockerConfig = omitConfigForComparison(dockerSvc.config)
expect(composeConfig).to.deep.equal(dockerConfig)
expect(dockerSvc.isEqualConfig(composeSvc)).to.equals(true)
describe 'Networks', ->
it 'should correctly convert networks from compose to docker format', ->
makeComposeServiceWithNetwork = (networks) ->
Service.fromComposeObject({
appId: 123456,
serviceId: 123456,
serviceName: 'test',
networks
}, { appName: 'test' })
expect(makeComposeServiceWithNetwork({
"balena": {
"ipv4Address": "1.2.3.4"
}
}).toDockerContainer().NetworkingConfig).to.deep.equal({
EndpointsConfig: {
"123456_balena": {
IPAMConfig: {
IPV4Address: "1.2.3.4"
},
Aliases: []
}
}
})
expect(makeComposeServiceWithNetwork({
balena: {
aliases: [ 'test', '1123']
ipv4Address: '1.2.3.4'
ipv6Address: '5.6.7.8'
linkLocalIps: [ '123.123.123' ]
}
}).toDockerContainer().NetworkingConfig).to.deep.equal({
EndpointsConfig: {
"123456_balena": {
IPAMConfig: {
IPV4Address: '1.2.3.4'
IPV6Address: '5.6.7.8'
LinkLocalIps: [ '123.123.123' ]
}
Aliases: [ 'test', '1123' ]
}
}
})
it 'should correctly convert Docker format to service format', ->
dockerCfg = require('./data/docker-states/simple/inspect.json');
makeServiceFromDockerWithNetwork = (networks) ->
Service.fromDockerContainer(
newConfig = _.cloneDeep(dockerCfg);
newConfig.NetworkSettings = { Networks: networks }
)
expect(makeServiceFromDockerWithNetwork({
'123456_balena': {
IPAMConfig: {
IPv4Address: "1.2.3.4"
},
Aliases: []
}
}).config.networks).to.deep.equal({
'123456_balena': {
"ipv4Address": "1.2.3.4"
}
})
expect(makeServiceFromDockerWithNetwork({
'123456_balena': {
IPAMConfig: {
IPv4Address: '1.2.3.4'
IPv6Address: '5.6.7.8'
LinkLocalIps: [ '123.123.123' ]
}
Aliases: [ 'test', '1123' ]
}
}).config.networks).to.deep.equal({
'123456_balena': {
ipv4Address: '1.2.3.4'
ipv6Address: '5.6.7.8'
linkLocalIps: [ '123.123.123' ]
aliases: [ 'test', '1123' ]
}
})

View File

@ -11,7 +11,7 @@ DB = require('../src/db')
Config = require('../src/config')
{ RPiConfigBackend } = require('../src/config/backend')
Service = require '../src/compose/service'
{ Service } = require '../src/compose/service'
mockedInitialConfig = {
'RESIN_SUPERVISOR_CONNECTIVITY_CHECK': 'true'
@ -199,9 +199,9 @@ describe 'deviceState', ->
eventTracker = {
track: console.log
}
stub(Service.prototype, 'extendEnvVars').callsFake (env) ->
@environment['ADDITIONAL_ENV_VAR'] = 'foo'
return @environment
stub(Service, 'extendEnvVars').callsFake (env) ->
env['ADDITIONAL_ENV_VAR'] = 'foo'
return env
@deviceState = new DeviceState({ @db, @config, eventTracker })
stub(@deviceState.applications.docker, 'getNetworkGateway').returns(Promise.resolve('172.17.0.1'))
stub(@deviceState.applications.images, 'inspectByName').callsFake ->
@ -215,7 +215,7 @@ describe 'deviceState', ->
@config.init()
after ->
Service.prototype.extendEnvVars.restore()
Service.extendEnvVars.restore()
@deviceState.applications.docker.getNetworkGateway.restore()
@deviceState.applications.images.inspectByName.restore()
@ -229,7 +229,7 @@ describe 'deviceState', ->
testTarget = _.cloneDeep(testTarget1)
testTarget.local.apps['1234'].services = _.map testTarget.local.apps['1234'].services, (s) ->
s.imageName = s.image
return new Service(s)
return Service.fromComposeObject(s, { appName: 'superapp' })
# We serialize and parse JSON to avoid checking fields that are functions or undefined
expect(JSON.parse(JSON.stringify(targetState))).to.deep.equal(JSON.parse(JSON.stringify(testTarget)))
@deviceState.applications.images.save.restore()
@ -261,7 +261,7 @@ describe 'deviceState', ->
.then (imageName) ->
s.image = imageName
s.imageName = imageName
new Service(s)
Service.fromComposeObject(s, { appName: 'supertest' })
.then (services) =>
testTarget.local.apps['1234'].services = services
@deviceState.setTarget(testTarget2)

View File

@ -10,9 +10,7 @@ prepare = require './lib/prepare'
DeviceState = require '../src/device-state'
DB = require('../src/db')
Config = require('../src/config')
Service = require '../src/compose/service'
{ currentState, targetState, availableImages } = require './lib/application-manager-test-states'
{ Service } = require '../src/compose/service'
appDBFormatNormalised = {
appId: 1234
@ -103,6 +101,8 @@ dependentStateFormatNormalised = {
imageId: 45
}
currentState = targetState = availableImages = null
dependentDBFormat = {
appId: 1234
image: 'foo/bar:latest'
@ -137,19 +137,19 @@ describe 'ApplicationManager', ->
}
})
stub(@applications.docker, 'getNetworkGateway').returns(Promise.resolve('172.17.0.1'))
stub(Service.prototype, 'extendEnvVars').callsFake (env) ->
@environment['ADDITIONAL_ENV_VAR'] = 'foo'
return @environment
stub(Service, 'extendEnvVars').callsFake (env) ->
env['ADDITIONAL_ENV_VAR'] = 'foo'
return env
@normaliseCurrent = (current) ->
Promise.map current.local.apps, (app) ->
Promise.map app.services, (service) ->
new Service(service)
Service.fromComposeObject(service, { appName: 'test' })
.then (normalisedServices) ->
appCloned = _.clone(app)
appCloned = _.cloneDeep(app)
appCloned.services = normalisedServices
return appCloned
.then (normalisedApps) ->
currentCloned = _.clone(current)
currentCloned = _.cloneDeep(current)
currentCloned.local.apps = normalisedApps
return currentCloned
@ -163,9 +163,9 @@ describe 'ApplicationManager', ->
# We mock what createTargetService does when an image is available
targetCloned.local.apps = _.map apps, (app) ->
app.services = _.map app.services, (service) ->
img = _.find(available, (i) -> i.name == service.image)
img = _.find(available, (i) -> i.name == service.config.image)
if img?
service.image = img.dockerImageId
service.config.image = img.dockerImageId
return service
return app
return targetCloned
@ -173,10 +173,13 @@ describe 'ApplicationManager', ->
.then =>
@config.init()
beforeEach ->
{ currentState, targetState, availableImages } = require './lib/application-manager-test-states'
after ->
@applications.images.inspectByName.restore()
@applications.docker.getNetworkGateway.restore()
Service.prototype.extendEnvVars.restore()
Service.extendEnvVars.restore()
it 'infers a start step when all that changes is a running state', ->
Promise.join(
@ -366,7 +369,7 @@ describe 'ApplicationManager', ->
opts = { imageInfo: { Config: { Cmd: [ 'someCommand' ], Entrypoint: [ 'theEntrypoint' ] } } }
appStateFormatWithDefaults.services = _.map appStateFormatWithDefaults.services, (service) ->
service.imageName = service.image
return new Service(service, opts)
return Service.fromComposeObject(service, opts)
expect(JSON.parse(JSON.stringify(app))).to.deep.equal(JSON.parse(JSON.stringify(appStateFormatWithDefaults)))
it 'converts a dependent app in DB format into state format', ->

View File

@ -0,0 +1,13 @@
{
"imageId": 478890,
"serviceName": "main",
"image": "sha256:7f54fa690ce19a1f625b04479ae1f12f44d36112a74be7edfefa777ecfdb194b",
"running": true,
"environment": {},
"labels": {},
"appId": 1011165,
"releaseId": 597007,
"serviceId": 43697,
"commit": "ff300a701054ac15281de1f9c0e84b8c",
"imageName": "registry2.resin.io/v2/bf9c649a5ac2fe147bbe350875042388@sha256:3a5c17b715b4f8265539c1a006dd1abdd2ff3b758aa23df99f77c792f40c3d43"
}

View File

@ -0,0 +1,102 @@
{
"serviceName": "main",
"imageInfo": {
"Id": "sha256:7f54fa690ce19a1f625b04479ae1f12f44d36112a74be7edfefa777ecfdb194b",
"RepoTags": [],
"RepoDigests": [
"registry2.resin.io/v2/90e3bf4c3dc1e59221b7b3e659a327f6@sha256:3a5c17b715b4f8265539c1a006dd1abdd2ff3b758aa23df99f77c792f40c3d43"
],
"Parent": "",
"Comment": "",
"Created": "2018-09-12T13:00:43.974720835Z",
"Container": "07cb0400e218ae235e67cf1fd283dc09559f57fbe2a36f5cc89302388371781c",
"ContainerConfig": {
"Hostname": "137f767087a2",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh",
"-c",
"#(nop) ",
"CMD [\"/bin/sh\" \"-c\" \"while true; do echo 'hello'; sleep 5; done;\"]"
],
"ArgsEscaped": true,
"Image": "sha256:8d68949dbddcb3ab1a61caeffa0aa1a6e27425ecc4f7665d04d8d0e5bfa03298",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": [],
"Labels": {}
},
"DockerVersion": "17.05.0-ce",
"Author": "",
"Config": {
"Hostname": "137f767087a2",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh",
"-c",
"while true; do echo 'hello'; sleep 5; done;"
],
"ArgsEscaped": true,
"Image": "sha256:8d68949dbddcb3ab1a61caeffa0aa1a6e27425ecc4f7665d04d8d0e5bfa03298",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": [],
"Labels": {}
},
"Architecture": "arm64",
"Os": "linux",
"Size": 104966431,
"VirtualSize": 104966431,
"GraphDriver": {
"Data": null,
"Name": "aufs"
},
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:a3075e9def48840598abcfe08c1ee564c989d1014d847082d950dca2c94098ec",
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
]
},
"Metadata": {
"LastTagTime": "0001-01-01T00:00:00Z"
}
},
"appName": "supervisortest",
"supervisorApiHost": "172.17.0.1",
"hostPathExists": {
"firmware": true,
"modules": true
},
"hostnameOnHost": "7dadabd",
"uuid": "a7feb967fac7f559ccf2a006a36bcf5d",
"listenPort": "48484",
"name": "Office",
"apiSecret": "d4bf8369519c32adaa5dd1f84367aa817403f2a3ce976be9c9bacd4d344fdd",
"deviceApiKey": "ff89e1d8db58a7ca52a435f2adea319a",
"version": "7.18.0",
"deviceType": "raspberrypi3",
"osVersion": "Resin OS 2.13.6+rev1"
}

View File

@ -0,0 +1,210 @@
{
"Id": "52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e",
"Created": "2018-09-12T14:38:42.696028995Z",
"Path": "/bin/sh",
"Args": [
"-c",
"while true; do echo 'hello'; sleep 5; done;"
],
"State": {
"Status": "exited",
"Running": false,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 0,
"ExitCode": 137,
"Error": "",
"StartedAt": "2018-09-12T14:38:45.408574694Z",
"FinishedAt": "2018-09-12T14:38:46.462783621Z"
},
"Image": "sha256:7f54fa690ce19a1f625b04479ae1f12f44d36112a74be7edfefa777ecfdb194b",
"ResolvConfPath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/hostname",
"HostsPath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/hosts",
"LogPath": "",
"Name": "/nifty_swartz",
"RestartCount": 0,
"Driver": "aufs",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "",
"ExecIDs": null,
"HostConfig": {
"Binds": [
"/tmp/resin-supervisor/services/1011165/main:/tmp/resin"
],
"ContainerIDFile": "",
"LogConfig": {
"Type": "journald",
"Config": {}
},
"NetworkMode": "1011165_default",
"PortBindings": {},
"RestartPolicy": {
"Name": "always",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": null,
"CapAdd": [],
"CapDrop": [],
"Dns": [],
"DnsOptions": [],
"DnsSearch": [],
"ExtraHosts": [],
"GroupAdd": [],
"IpcMode": "shareable",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": [],
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"ConsoleSize": [
0,
0
],
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": null,
"BlkioDeviceReadBps": null,
"BlkioDeviceWriteBps": null,
"BlkioDeviceReadIOps": null,
"BlkioDeviceWriteIOps": null,
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": [],
"DeviceCgroupRules": null,
"DiskQuota": 0,
"KernelMemory": 0,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": -1,
"OomKillDisable": false,
"PidsLimit": 0,
"Ulimits": [],
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0,
"Init": false
},
"GraphDriver": {
"Data": null,
"Name": "aufs"
},
"Mounts": [
{
"Type": "bind",
"Source": "/tmp/resin-supervisor/services/1011165/main",
"Destination": "/tmp/resin",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
"Hostname": "52cfd7a64d50",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": true,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"RESIN_APP_ID=1011165",
"RESIN_APP_NAME=supervisortest",
"RESIN_SERVICE_NAME=main",
"RESIN_DEVICE_UUID=a7feb967fac7f559ccf2a006a36bcf5d",
"RESIN_DEVICE_TYPE=raspberrypi3",
"RESIN_HOST_OS_VERSION=Resin OS 2.13.6+rev1",
"RESIN_SUPERVISOR_VERSION=7.18.0",
"RESIN_APP_LOCK_PATH=/tmp/resin/resin-updates.lock",
"RESIN_SERVICE_KILL_ME_PATH=/tmp/resin/resin-kill-me",
"RESIN=1",
"USER=root",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh",
"-c",
"while true; do echo 'hello'; sleep 5; done;"
],
"Healthcheck": {
"Test": [
"NONE"
]
},
"Image": "sha256:7f54fa690ce19a1f625b04479ae1f12f44d36112a74be7edfefa777ecfdb194b",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": {
"io.resin.app-id": "1011165",
"io.resin.service-id": "43697",
"io.resin.service-name": "main",
"io.resin.supervised": "true"
},
"StopTimeout": 0
},
"NetworkSettings": {
"Bridge": "",
"SandboxID": "bf4952b7f6695a8f05da1807946723b37e1041b8f41588678d6dece310270990",
"HairpinMode": false,
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"Ports": {},
"SandboxKey": "/var/run/balena/netns/bf4952b7f669",
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null,
"EndpointID": "",
"Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"MacAddress": "",
"Networks": {
"1011165_default": {
"IPAMConfig": {},
"Links": null,
"Aliases": [
"main",
"52cfd7a64d50"
],
"NetworkID": "f88716ed3d340f1b9aa61df22d92ce6ad8aa752d8bf8e4aa6e74142dea677465",
"EndpointID": "",
"Gateway": "",
"IPAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"MacAddress": "",
"DriverOpts": null
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"imageId": 431889,
"serviceName": "main",
"image": "sha256:f9e0fa6e3e68caedbcbb4ef35d5a8dce2a8d33e39cc94115d567800f25d826f4",
"running": true,
"appId": 1011165,
"releaseId": 572579,
"serviceId": 43697,
"commit": "b14730d691467ab0f448a308af6bf839",
"imageName": "registry2.resin.io/v2/8ddbe4a22e881f06def0f31400bfb6de@sha256:09b0db9e71cead5f91107fc9254b1af7088444cc6da55afa2da595940f72a34a"
}

View File

@ -0,0 +1,128 @@
{
"serviceName": "main",
"imageInfo": {
"Id": "sha256:f9e0fa6e3e68caedbcbb4ef35d5a8dce2a8d33e39cc94115d567800f25d826f4",
"RepoTags": [],
"RepoDigests": [
"registry2.resin.io/v2/8ddbe4a22e881f06def0f31400bfb6de@sha256:09b0db9e71cead5f91107fc9254b1af7088444cc6da55afa2da595940f72a34a"
],
"Parent": "",
"Comment": "",
"Created": "2018-08-15T12:43:06.43392045Z",
"Container": "b6cc9227f272b905512a58926b6d515b38de34b604386031aa3c21e94d9dbb4a",
"ContainerConfig": {
"Hostname": "f15babe8256c",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"UDEV=on"
],
"Cmd": [
"/bin/sh",
"-c",
"#(nop) ",
"CMD [\"/bin/sh\" \"-c\" \"./call-supervisor.sh\"]"
],
"ArgsEscaped": true,
"Image": "sha256:828d725f5e6d09ee9abc214f6c11fadf69192ba4871b050984cc9c4cec37b208",
"Volumes": null,
"WorkingDir": "/usr/src/app",
"Entrypoint": [
"/usr/bin/entry.sh"
],
"OnBuild": [],
"Labels": {
"io.resin.architecture": "armv7hf",
"io.resin.device-type": "raspberry-pi2",
"io.resin.qemu.version": "2.9.0.resin1-arm"
}
},
"DockerVersion": "17.05.0-ce",
"Author": "",
"Config": {
"Hostname": "f15babe8256c",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"UDEV=on"
],
"Cmd": [
"/bin/sh",
"-c",
"./call-supervisor.sh"
],
"ArgsEscaped": true,
"Image": "sha256:828d725f5e6d09ee9abc214f6c11fadf69192ba4871b050984cc9c4cec37b208",
"Volumes": null,
"WorkingDir": "/usr/src/app",
"Entrypoint": [
"/usr/bin/entry.sh"
],
"OnBuild": [],
"Labels": {
"io.resin.architecture": "armv7hf",
"io.resin.device-type": "raspberry-pi2",
"io.resin.qemu.version": "2.9.0.resin1-arm"
}
},
"Architecture": "arm64",
"Os": "linux",
"Size": 38692178,
"VirtualSize": 38692178,
"GraphDriver": {
"Data": null,
"Name": "aufs"
},
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:7c30ac6ce381873d5388b7d23b346af7d1e5f6af000a84b97e6203ed9e6dcab2",
"sha256:450b73019ae79e6a99774fcd37c18769f95065c8b271be936dfb3f93afadc4a8",
"sha256:54742c4169d9ff56328f60aea070a6c67f507d4b82389f3432a2c5d92742223c",
"sha256:1f609e3b26a8772335a4658ee1980e9b34019d55ac8ee5dcb281f1d4cd5e8e9c",
"sha256:c062d099c615146d6dc095254c11babbe120edf06d66419aeef955b88d8543ce",
"sha256:2b57e2af57a24bcbafc5bfa04d928ab11695232df7942c294a7c1ca115ba42ca",
"sha256:6eb88b69d374abd577336ddc8ab01b25b970020537bb6605676496dcb041b462",
"sha256:e410a938934a7ad4f44334cceca97084df7405a5654eefc30cede0aa5bbe8394",
"sha256:201b3de34ff5e12e1ada0331d0ce4d0b041059ff9350cb26d8ee15c7be50fe57",
"sha256:115ca022a36d9de6fb7a4ba3917545711a0c20564dacf3b189567f68e381e73e",
"sha256:4fdd323f81af620a5f19a544a1caa21093f2567d83671ebf24fbde77cefde67c",
"sha256:e9a758756b9b5537fe624f87d02f16dd7a523c27a1688de9820f5d2157e5d37d",
"sha256:2d082f247d32fd789dbd46fc50054e16810c79a64fa9ea47e0e4845226a0e011",
"sha256:6ab67aaf666bfb7001ab93deffe785f24775f4e0da3d6d421ad6096ba869fd0d"
]
},
"Metadata": {
"LastTagTime": "0001-01-01T00:00:00Z"
}
},
"appName": "supervisortest",
"supervisorApiHost": "172.17.0.1",
"hostPathExists": {
"firmware": true,
"modules": true
},
"hostnameOnHost": "7dadabd",
"uuid": "7dadabd4edec3067948d5952c2f2f26f",
"listenPort": "48484",
"name": "Office",
"apiSecret": "d4bf8369519c32adaa5dd1f84367aa817403f2a3ce976be9c9bacd4d344fdd",
"deviceApiKey": "ff89e1d8db58a7ca52a435f2adea319a",
"version": "7.16.6",
"deviceType": "raspberrypi3",
"osVersion": "Resin OS 2.13.6+rev1"
}

View File

@ -0,0 +1,227 @@
{
"Id": "5bff0c9e6ef8ae4fdc02b7ba05050078c8599775e8773ce039982e987107a4a4",
"Created": "2018-08-16T13:00:47.100056946Z",
"Path": "/usr/bin/entry.sh",
"Args": [
"/bin/sh",
"-c",
"./call-supervisor.sh"
],
"State": {
"Status": "restarting",
"Running": true,
"Paused": false,
"Restarting": true,
"OOMKilled": false,
"Dead": false,
"Pid": 0,
"ExitCode": 0,
"Error": "",
"StartedAt": "2018-08-16T13:22:17.991455639Z",
"FinishedAt": "2018-08-16T13:22:18.845432218Z"
},
"Image": "sha256:f9e0fa6e3e68caedbcbb4ef35d5a8dce2a8d33e39cc94115d567800f25d826f4",
"ResolvConfPath": "/var/lib/docker/containers/5bff0c9e6ef8ae4fdc02b7ba05050078c8599775e8773ce039982e987107a4a4/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/5bff0c9e6ef8ae4fdc02b7ba05050078c8599775e8773ce039982e987107a4a4/hostname",
"HostsPath": "/var/lib/docker/containers/5bff0c9e6ef8ae4fdc02b7ba05050078c8599775e8773ce039982e987107a4a4/hosts",
"LogPath": "",
"Name": "/main_431889_572579",
"RestartCount": 29,
"Driver": "aufs",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "",
"ExecIDs": null,
"HostConfig": {
"Binds": [
"/tmp/resin-supervisor/services/1011165/main:/tmp/resin"
],
"ContainerIDFile": "",
"LogConfig": {
"Type": "journald",
"Config": {}
},
"NetworkMode": "1011165_default",
"PortBindings": {},
"RestartPolicy": {
"Name": "always",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": null,
"CapAdd": [],
"CapDrop": [],
"Dns": [],
"DnsOptions": null,
"DnsSearch": [],
"ExtraHosts": [],
"GroupAdd": [],
"IpcMode": "shareable",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": [],
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"ConsoleSize": [
0,
0
],
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": null,
"BlkioDeviceReadBps": null,
"BlkioDeviceWriteBps": null,
"BlkioDeviceReadIOps": null,
"BlkioDeviceWriteIOps": null,
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": [],
"DeviceCgroupRules": null,
"DiskQuota": 0,
"KernelMemory": 0,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": -1,
"OomKillDisable": false,
"PidsLimit": 0,
"Ulimits": [],
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0
},
"GraphDriver": {
"Data": null,
"Name": "aufs"
},
"Mounts": [
{
"Type": "bind",
"Source": "/tmp/resin-supervisor/services/1011165/main",
"Destination": "/tmp/resin",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
"Hostname": "5bff0c9e6ef8",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": true,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"RESIN_APP_ID=1011165",
"RESIN_APP_NAME=supervisortest",
"RESIN_SERVICE_NAME=main",
"RESIN_DEVICE_UUID=7dadabd4edec3067948d5952c2f2f26f",
"RESIN_DEVICE_TYPE=raspberrypi3",
"RESIN_HOST_OS_VERSION=Resin OS 2.13.6+rev1",
"RESIN_SUPERVISOR_VERSION=7.16.6",
"RESIN_APP_LOCK_PATH=/tmp/resin/resin-updates.lock",
"RESIN_SERVICE_KILL_ME_PATH=/tmp/resin/resin-kill-me",
"RESIN=1",
"USER=root",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"UDEV=on"
],
"Cmd": [
"/bin/sh",
"-c",
"./call-supervisor.sh"
],
"Image": "sha256:f9e0fa6e3e68caedbcbb4ef35d5a8dce2a8d33e39cc94115d567800f25d826f4",
"Volumes": null,
"WorkingDir": "/usr/src/app",
"Entrypoint": [
"/usr/bin/entry.sh"
],
"OnBuild": null,
"Labels": {
"io.resin.app-id": "1011165",
"io.resin.architecture": "armv7hf",
"io.resin.device-type": "raspberry-pi2",
"io.resin.qemu.version": "2.9.0.resin1-arm",
"io.resin.service-id": "43697",
"io.resin.service-name": "main",
"io.resin.supervised": "true"
}
},
"NetworkSettings": {
"Bridge": "",
"SandboxID": "9124030ea083331f46529d0ffb1549f780b5ca913e912d438726e31fc5c716da",
"HairpinMode": false,
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"Ports": {},
"SandboxKey": "/var/run/balena/netns/9124030ea083",
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null,
"EndpointID": "",
"Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"MacAddress": "",
"Networks": {
"1011165_default": {
"IPAMConfig": null,
"Links": null,
"Aliases": [
"main",
"5bff0c9e6ef8"
],
"NetworkID": "4afe52d663d8de16ea7cbd3c3faaff0705109d2f246e4f18cbed3b2789ce0a7a",
"EndpointID": "",
"Gateway": "",
"IPAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"MacAddress": "",
"DriverOpts": null
},
"supervisor0": {
"IPAMConfig": null,
"Links": null,
"Aliases": [
"5bff0c9e6ef8"
],
"NetworkID": "2ad3a9f0a52d912fff9990837167cd7a3d9f1133e73a40cbed6438be81a96126",
"EndpointID": "",
"Gateway": "",
"IPAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"MacAddress": "",
"DriverOpts": null
}
}
}
}

View File

@ -334,9 +334,7 @@ currentState[0] = {
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
restart: 'always'
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
@ -349,8 +347,8 @@ currentState[0] = {
running: true
createdAt: new Date()
containerId: '1'
networkMode: '1234_default'
networks: { '1234_default': {} }
networkMode: 'default'
networks: { 'default': { aliases: [ 'aservice' ] } }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
},
@ -370,9 +368,7 @@ currentState[0] = {
'/tmp/resin-supervisor/services/1234/anotherService:/tmp/resin'
]
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
restart: 'always'
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '24'
@ -382,8 +378,8 @@ currentState[0] = {
running: false
createdAt: new Date()
containerId: '2'
networkMode: '1234_default'
networks: { '1234_default': {} }
networkMode: 'default'
networks: { 'default': { aliases: [ 'anotherService' ] } }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}
@ -447,9 +443,7 @@ currentState[2] = {
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
restart: 'always'
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
@ -462,8 +456,8 @@ currentState[2] = {
running: true
createdAt: new Date()
containerId: '1'
networkMode: '1234_default'
networks: { '1234_default': {} }
networkMode: 'default'
networks: { 'default': { aliases: [ 'aservice' ] } }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}
@ -505,9 +499,7 @@ currentState[3] = {
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
restart: 'always'
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
@ -520,8 +512,8 @@ currentState[3] = {
running: true
createdAt: new Date(0)
containerId: '1'
networkMode: '1234_default'
networks: { '1234_default': {} }
networkMode: 'default'
networks: { 'default': { aliases: [ 'aservice' ] } }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
},
@ -540,9 +532,7 @@ currentState[3] = {
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
restart: 'always'
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
@ -555,8 +545,8 @@ currentState[3] = {
running: true
createdAt: new Date(1)
containerId: '2'
networkMode: '1234_default'
networks: { '1234_default': {} }
networkMode: 'default'
networks: { 'default': { aliases: [ 'aservice' ] } }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}
@ -599,9 +589,7 @@ currentState[4] = {
'/tmp/resin-supervisor/services/1234/anotherService:/tmp/resin'
]
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
restart: 'always'
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '24'
@ -611,8 +599,8 @@ currentState[4] = {
running: false
createdAt: new Date()
containerId: '2'
networkMode: '1234_default'
networks: { '1234_default': {} }
networkMode: 'default'
networks: { 'default': { aliases: [ 'aservice' ] } }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}

View File

@ -10,7 +10,7 @@
"strictNullChecks": true,
"outDir": "./build/src/",
"lib": [
"es2015",
"es6"
]
},
"include": [

26
typings/dockerode-ext.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import { ContainerInspectInfo } from 'dockerode';
declare module 'dockerode' {
// Extend the HostConfig interface with the missing fields.
// TODO: Add these upstream to DefinitelyTyped
interface HostConfig {
Sysctls: { [sysctlsOpt: string]: string };
GroupAdd: string[];
UsernsMode: string;
}
export interface DockerHealthcheck {
Test: string[];
Interval?: number;
Timeout?: number;
Retries?: number;
StartPeriod?: number;
}
interface ContainerCreateOptions {
Healthcheck?: DockerHealthcheck;
StopTimeout?: number;
}
}

62
typings/duration-js.d.ts vendored Normal file
View File

@ -0,0 +1,62 @@
// From: https://github.com/icholy/Duration.js/pull/15
// Once the above is merged, use the inbuilt module types
declare module "duration-js" {
type DurationLike = Duration | string | number;
type DateLike = Date | number;
class Duration {
private _milliseconds: number;
constructor(value?: DurationLike);
static millisecond: Duration;
static second: Duration;
static minute: Duration;
static hour: Duration;
static day: Duration;
static week: Duration;
static milliseconds(milliseconds: number): Duration;
static seconds(seconds: number): Duration;
static minutes(minutes: number): Duration;
static hours(hours: number): Duration;
static days(days: number): Duration;
static weeks(weeks: number): Duration;
nanoseconds(): number;
microseconds(): number;
milliseconds(): number;
seconds(): number;
minutes(): number;
hours(): number;
days(): number;
weeks(): number;
toString(): string;
valueOf(): number;
isGreaterThan(duration: DurationLike): boolean;
isLessThan(duration: DurationLike): boolean;
isEqualTo(duration: DurationLike): boolean;
roundTo(duration: DurationLike): void;
after(date: DateLike): Date;
static since(date: DateLike): Duration;
static until(date: DateLike): Duration;
static between(a: DateLike, b: DateLike): Duration;
static parse(duration: string): Duration;
static fromMicroseconds(us: number): Duration;
static fromNanoseconds(ns: number): Duration;
static add(a: Duration, b: Duration): Duration;
static subtract(a: Duration, b: Duration): Duration;
static multiply(a: Duration, b: number): Duration;
static multiply(a: number, b: Duration): Duration;
static divide(a: Duration, b: Duration): number;
static abs(d: DurationLike): Duration;
}
export = Duration;
}