Various bugfixes and sytlistic improvements

* Use the correct defaults for the delta config variables that have them

* Only mount /lib/firmware and /lib/modules if they exist on the host

* hardcode-migrations.js: Nicer line separation

* APIBinder: switch to using a header for authentication, and keep credentials saved in the API clients

* Fix hrtime measurements in milliseconds

* Do not uses classes for routers

* compose: properly initialize networkMode to the first entry in networks if there is one

* Fix some details regarding defaults in validation and service

Signed-off-by: Pablo Carranza Velez <pablo@resin.io>
This commit is contained in:
Pablo Carranza Velez 2018-02-13 15:23:44 -08:00
parent ec22bfcb29
commit 58b167b43d
28 changed files with 747 additions and 853 deletions

View File

@ -2,6 +2,8 @@
// This hack makes the migrations directory a constant so that at least we can use webpack contexts for the // This hack makes the migrations directory a constant so that at least we can use webpack contexts for the
// require. // require.
module.exports = function (source) { module.exports = function (source) {
return source.toString().replace("require(directory + '/' + name);", "require('./migrations/' + name);") return source
.replace("require(_path2.default.join(this._absoluteConfigDir(), name));", "require('./migrations/' + name);") .toString()
.replace("require(directory + '/' + name);", "require('./migrations/' + name);")
.replace("require(_path2.default.join(this._absoluteConfigDir(), name));", "require('./migrations/' + name);")
} }

View File

@ -2,7 +2,7 @@ Promise = require 'bluebird'
_ = require 'lodash' _ = require 'lodash'
url = require 'url' url = require 'url'
TypedError = require 'typed-error' TypedError = require 'typed-error'
PlatformAPI = require 'pinejs-client' PinejsClient = require 'pinejs-client'
deviceRegister = require 'resin-register-device' deviceRegister = require 'resin-register-device'
express = require 'express' express = require 'express'
bodyParser = require 'body-parser' bodyParser = require 'body-parser'
@ -18,17 +18,16 @@ ExchangeKeyError = class ExchangeKeyError extends TypedError
REPORT_SUCCESS_DELAY = 1000 REPORT_SUCCESS_DELAY = 1000
REPORT_RETRY_DELAY = 5000 REPORT_RETRY_DELAY = 5000
class APIBinderRouter createAPIBinderRouter = (apiBinder) ->
constructor: (@apiBinder) -> router = express.Router()
{ @eventTracker } = @apiBinder router.use(bodyParser.urlencoded(extended: true))
@router = express.Router() router.use(bodyParser.json())
@router.use(bodyParser.urlencoded(extended: true)) router.post '/v1/update', (req, res) ->
@router.use(bodyParser.json()) apiBinder.eventTracker.track('Update notification')
@router.post '/v1/update', (req, res) => if apiBinder.readyForUpdates
@eventTracker.track('Update notification') apiBinder.getAndSetTargetState(req.body.force)
if @apiBinder.readyForUpdates res.sendStatus(204)
@apiBinder.getAndSetTargetState(req.body.force) return router
res.sendStatus(204)
module.exports = class APIBinder module.exports = class APIBinder
constructor: ({ @config, @db, @deviceState, @eventTracker }) -> constructor: ({ @config, @db, @deviceState, @eventTracker }) ->
@ -41,8 +40,7 @@ module.exports = class APIBinder
@_targetStateInterval = null @_targetStateInterval = null
@reportPending = false @reportPending = false
@stateReportErrors = 0 @stateReportErrors = 0
@_router = new APIBinderRouter(this) @router = createAPIBinderRouter(this)
@router = @_router.router
_lock = new Lock() _lock = new Lock()
@_writeLock = Promise.promisify(_lock.async.writeLock) @_writeLock = Promise.promisify(_lock.async.writeLock)
@readyForUpdates = false @readyForUpdates = false
@ -52,7 +50,9 @@ module.exports = class APIBinder
.then (conf) => .then (conf) =>
if conf.offlineMode if conf.offlineMode
return true return true
stateFetchHealthy = process.hrtime(@lastTargetStateFetch)[0] < 2 * conf.appUpdatePollInterval timeSinceLastFetch = process.hrtime(@lastTargetStateFetch)
timeSinceLastFetchMs = timeSinceLastFetch[0] * 1000 + timeSinceLastFetch[1] / 1e6
stateFetchHealthy = timeSinceLastFetchMs < 2 * conf.appUpdatePollInterval
stateReportHealthy = !conf.connectivityCheckEnabled or !@deviceState.connected or @stateReportErrors < 3 stateReportHealthy = !conf.connectivityCheckEnabled or !@deviceState.connected or @stateReportErrors < 3
return stateFetchHealthy and stateReportHealthy return stateFetchHealthy and stateReportHealthy
@ -61,19 +61,22 @@ module.exports = class APIBinder
release() release()
init: (startServices = true) -> init: (startServices = true) ->
@config.getMany([ 'offlineMode', 'resinApiEndpoint', 'bootstrapRetryDelay' ]) @config.getMany([ 'offlineMode', 'resinApiEndpoint', 'bootstrapRetryDelay', 'currentApiKey' ])
.then ({ offlineMode, resinApiEndpoint, bootstrapRetryDelay }) => .then ({ offlineMode, resinApiEndpoint, bootstrapRetryDelay, currentApiKey }) =>
if offlineMode if offlineMode
console.log('Offline Mode is set, skipping API binder initialization') console.log('Offline Mode is set, skipping API binder initialization')
return return
baseUrl = url.resolve(resinApiEndpoint, '/v4/') baseUrl = url.resolve(resinApiEndpoint, '/v4/')
@resinApi = new PlatformAPI passthrough = _.cloneDeep(requestOpts)
passthrough.headers ?= {}
passthrough.headers.Authorization = "Bearer #{currentApiKey}"
@resinApi = new PinejsClient
apiPrefix: baseUrl apiPrefix: baseUrl
passthrough: requestOpts passthrough: passthrough
baseUrlLegacy = url.resolve(resinApiEndpoint, '/v2/') baseUrlLegacy = url.resolve(resinApiEndpoint, '/v2/')
@resinApiLegacy = new PlatformAPI @resinApiLegacy = new PinejsClient
apiPrefix: baseUrlLegacy apiPrefix: baseUrlLegacy
passthrough: requestOpts passthrough: passthrough
@cachedResinApi = @resinApi.clone({}, cache: {}) @cachedResinApi = @resinApi.clone({}, cache: {})
if !startServices if !startServices
return return
@ -100,8 +103,8 @@ module.exports = class APIBinder
options: options:
filter: filter:
uuid: uuid uuid: uuid
customOptions: passthrough:
apikey: apiKey headers: Authorization: "Bearer: #{apiKey}"
.get(0) .get(0)
.catchReturn(null) .catchReturn(null)
.timeout(timeout) .timeout(timeout)
@ -121,20 +124,21 @@ module.exports = class APIBinder
return device return device
# If it's not valid/doesn't exist then we try to use the user/provisioning api key for the exchange # If it's not valid/doesn't exist then we try to use the user/provisioning api key for the exchange
@fetchDevice(opts.uuid, opts.provisioningApiKey, opts.apiTimeout) @fetchDevice(opts.uuid, opts.provisioningApiKey, opts.apiTimeout)
.then (device) -> .tap (device) ->
if not device? if not device?
throw new ExchangeKeyError("Couldn't fetch device with provisioning key") throw new ExchangeKeyError("Couldn't fetch device with provisioning key")
# We found the device, we can try to register a working device key for it # We found the device, we can try to register a working device key for it
request.postAsync("#{opts.apiEndpoint}/api-key/device/#{device.id}/device-key?apikey=#{opts.provisioningApiKey}", { request.postAsync("#{opts.apiEndpoint}/api-key/device/#{device.id}/device-key", {
json: true json: true
body: body:
apiKey: opts.deviceApiKey apiKey: opts.deviceApiKey
headers:
Authorization: "Bearer #{opts.provisioningApiKey}"
}) })
.spread (res, body) -> .spread (res, body) ->
if res.statusCode != 200 if res.statusCode != 200
throw new ExchangeKeyError("Couldn't register device key with provisioning key") throw new ExchangeKeyError("Couldn't register device key with provisioning key")
.timeout(opts.apiTimeout) .timeout(opts.apiTimeout)
.return(device)
_exchangeKeyAndGetDeviceOrRegenerate: (opts) => _exchangeKeyAndGetDeviceOrRegenerate: (opts) =>
@_exchangeKeyAndGetDevice(opts) @_exchangeKeyAndGetDevice(opts)
@ -167,6 +171,7 @@ module.exports = class APIBinder
console.log('Device is registered but we still have an apiKey, attempting key exchange') console.log('Device is registered but we still have an apiKey, attempting key exchange')
@_exchangeKeyAndGetDevice(opts) @_exchangeKeyAndGetDevice(opts)
.then ({ id }) => .then ({ id }) =>
@resinApi.passthrough.headers.Authorization = "Bearer #{opts.deviceApiKey}"
configToUpdate = { configToUpdate = {
registered_at: opts.registered_at registered_at: opts.registered_at
deviceId: id deviceId: id
@ -200,7 +205,6 @@ module.exports = class APIBinder
@config.getMany([ @config.getMany([
'offlineMode' 'offlineMode'
'provisioned' 'provisioned'
'currentApiKey'
'apiTimeout' 'apiTimeout'
'userId' 'userId'
'deviceId' 'deviceId'
@ -221,8 +225,6 @@ module.exports = class APIBinder
@resinApi.post @resinApi.post
resource: 'device' resource: 'device'
body: device body: device
customOptions:
apikey: conf.currentApiKey
.timeout(conf.apiTimeout) .timeout(conf.apiTimeout)
# This uses resin API v2 for now, as the proxyvisor expects to be able to patch the device's commit # This uses resin API v2 for now, as the proxyvisor expects to be able to patch the device's commit
@ -230,7 +232,6 @@ module.exports = class APIBinder
@config.getMany([ @config.getMany([
'offlineMode' 'offlineMode'
'provisioned' 'provisioned'
'currentApiKey'
'apiTimeout' 'apiTimeout'
]) ])
.then (conf) => .then (conf) =>
@ -242,8 +243,6 @@ module.exports = class APIBinder
resource: 'device' resource: 'device'
id: id id: id
body: updatedFields body: updatedFields
customOptions:
apikey: conf.currentApiKey
.timeout(conf.apiTimeout) .timeout(conf.apiTimeout)
# Creates the necessary config vars in the API to match the current device state, # Creates the necessary config vars in the API to match the current device state,
@ -252,8 +251,8 @@ module.exports = class APIBinder
Promise.join( Promise.join(
@deviceState.getCurrentForComparison() @deviceState.getCurrentForComparison()
@getTargetState() @getTargetState()
@config.getMany([ 'currentApiKey', 'deviceId' ]) @config.get('deviceId')
(currentState, targetState, conf) => (currentState, targetState, deviceId) =>
currentConfig = currentState.local.config currentConfig = currentState.local.config
targetConfig = targetState.local.config targetConfig = targetState.local.config
Promise.mapSeries _.toPairs(currentConfig), ([ key, value ]) => Promise.mapSeries _.toPairs(currentConfig), ([ key, value ]) =>
@ -263,14 +262,12 @@ module.exports = class APIBinder
if !targetConfig[key]? if !targetConfig[key]?
envVar = { envVar = {
value value
device: conf.deviceId device: deviceId
name: key name: key
} }
@resinApi.post @resinApi.post
resource: 'device_config_variable' resource: 'device_config_variable'
body: envVar body: envVar
customOptions:
apikey: conf.currentApiKey
) )
.then => .then =>
@config.set({ initialConfigReported: 'true' }) @config.set({ initialConfigReported: 'true' })
@ -284,13 +281,13 @@ module.exports = class APIBinder
@reportInitialConfig(retryDelay) @reportInitialConfig(retryDelay)
getTargetState: => getTargetState: =>
@config.getMany([ 'uuid', 'currentApiKey', 'resinApiEndpoint', 'apiTimeout' ]) @config.getMany([ 'uuid', 'resinApiEndpoint', 'apiTimeout' ])
.then ({ uuid, currentApiKey, resinApiEndpoint, apiTimeout }) => .then ({ uuid, resinApiEndpoint, apiTimeout }) =>
endpoint = url.resolve(resinApiEndpoint, "/device/v2/#{uuid}/state") endpoint = url.resolve(resinApiEndpoint, "/device/v2/#{uuid}/state")
requestParams = _.extend requestParams = _.extend
method: 'GET' method: 'GET'
url: "#{endpoint}?&apikey=#{currentApiKey}" url: "#{endpoint}"
, @cachedResinApi.passthrough , @cachedResinApi.passthrough
@cachedResinApi._request(requestParams) @cachedResinApi._request(requestParams)
@ -312,11 +309,10 @@ module.exports = class APIBinder
@lastTargetStateFetch = process.hrtime() @lastTargetStateFetch = process.hrtime()
_pollTargetState: => _pollTargetState: =>
if @_targetStateInterval?
clearInterval(@_targetStateInterval)
@_targetStateInterval = null
@config.get('appUpdatePollInterval') @config.get('appUpdatePollInterval')
.then (appUpdatePollInterval) => .then (appUpdatePollInterval) =>
if @_targetStateInterval?
clearInterval(@_targetStateInterval)
@_targetStateInterval = setInterval(@getAndSetTargetState, appUpdatePollInterval) @_targetStateInterval = setInterval(@getAndSetTargetState, appUpdatePollInterval)
@getAndSetTargetState() @getAndSetTargetState()
return return
@ -336,20 +332,20 @@ module.exports = class APIBinder
dependent: _.omitBy @stateForReport.dependent, (val, key) => dependent: _.omitBy @stateForReport.dependent, (val, key) =>
_.isEqual(@lastReportedState.dependent[key], val) _.isEqual(@lastReportedState.dependent[key], val)
} }
return _.pickBy(diff, (val) -> !_.isEmpty(val)) return _.pickBy(diff, _.negate(_.isEmpty))
_sendReportPatch: (stateDiff, conf) => _sendReportPatch: (stateDiff, conf) =>
endpoint = url.resolve(conf.resinApiEndpoint, "/device/v2/#{conf.uuid}/state") endpoint = url.resolve(conf.resinApiEndpoint, "/device/v2/#{conf.uuid}/state")
requestParams = _.extend requestParams = _.extend
method: 'PATCH' method: 'PATCH'
url: "#{endpoint}?&apikey=#{conf.currentApiKey}" url: "#{endpoint}"
body: stateDiff body: stateDiff
, @cachedResinApi.passthrough , @cachedResinApi.passthrough
@cachedResinApi._request(requestParams) @cachedResinApi._request(requestParams)
_report: => _report: =>
@config.getMany([ 'currentApiKey', 'deviceId', 'apiTimeout', 'resinApiEndpoint', 'uuid' ]) @config.getMany([ 'deviceId', 'apiTimeout', 'resinApiEndpoint', 'uuid' ])
.then (conf) => .then (conf) =>
stateDiff = @_getStateDiff() stateDiff = @_getStateDiff()
if _.size(stateDiff) is 0 if _.size(stateDiff) is 0

View File

@ -3,10 +3,11 @@ _ = require 'lodash'
EventEmitter = require 'events' EventEmitter = require 'events'
express = require 'express' express = require 'express'
bodyParser = require 'body-parser' bodyParser = require 'body-parser'
fs = Promise.promisifyAll(require('fs'))
path = require 'path'
constants = require './lib/constants' constants = require './lib/constants'
process.env.DOCKER_HOST ?= "unix://#{constants.dockerSocket}"
Docker = require './lib/docker-utils' Docker = require './lib/docker-utils'
updateLock = require './lib/update-lock' updateLock = require './lib/update-lock'
{ checkTruthy, checkInt, checkString } = require './lib/validation' { checkTruthy, checkInt, checkString } = require './lib/validation'
@ -20,11 +21,8 @@ Volumes = require './compose/volumes'
Proxyvisor = require './proxyvisor' Proxyvisor = require './proxyvisor'
serviceAction = (action, serviceId, current, target, options) -> serviceAction = (action, serviceId, current, target, options = {}) ->
obj = { action, serviceId, current, target } return { action, serviceId, current, target, options }
if options?
obj.options = options
return obj
# TODO: move this to an Image class? # TODO: move this to an Image class?
imageForService = (service) -> imageForService = (service) ->
@ -44,234 +42,193 @@ fetchAction = (service) ->
image: imageForService(service) image: imageForService(service)
serviceId: service.serviceId serviceId: service.serviceId
} }
pathExistsOnHost = (p) ->
fs.statAsync(path.join(constants.rootMountPoint, p))
.return(true)
.catchReturn(false)
appNotFoundMsg = "App not found: an app needs to be installed for this endpoint to work.
If you've recently moved this device from another app,
please push an app and wait for it to be installed first."
# TODO: implement additional v2 endpoints # TODO: implement additional v2 endpoints
# v1 endpoins only work for single-container apps as they assume the app has a single service. # Some v1 endpoins only work for single-container apps as they assume the app has a single service.
class ApplicationManagerRouter createApplicationManagerRouter = (applications) ->
constructor: (@applications) -> { eventTracker, deviceState, _lockingIfNecessary, logger } = applications
{ @proxyvisor, @eventTracker, @deviceState, @_lockingIfNecessary, @logger } = @applications router = express.Router()
@router = express.Router() router.use(bodyParser.urlencoded(extended: true))
@router.use(bodyParser.urlencoded(extended: true)) router.use(bodyParser.json())
@router.use(bodyParser.json())
doRestart = (appId, force) => doRestart = (appId, force) ->
@_lockingIfNecessary appId, { force }, => _lockingIfNecessary appId, { force }, ->
@deviceState.getCurrentForComparison() deviceState.getCurrentForComparison()
.then (currentState) => .then (currentState) ->
app = currentState.local.apps[appId] app = currentState.local.apps[appId]
imageIds = _.map(app.services, 'imageId') imageIds = _.map(app.services, 'imageId')
@applications.clearTargetVolatileForServices(imageIds) applications.clearTargetVolatileForServices(imageIds)
stoppedApp = _.cloneDeep(app) stoppedApp = _.cloneDeep(app)
stoppedApp.services = [] stoppedApp.services = []
currentState.local.apps[appId] = stoppedApp currentState.local.apps[appId] = stoppedApp
@deviceState.pausingApply => deviceState.pausingApply ->
@deviceState.applyIntermediateTarget(currentState, { skipLock: true }) deviceState.applyIntermediateTarget(currentState, { skipLock: true })
.then => .then ->
currentState.local.apps[appId] = app currentState.local.apps[appId] = app
@deviceState.applyIntermediateTarget(currentState, { skipLock: true }) deviceState.applyIntermediateTarget(currentState, { skipLock: true })
.finally => .finally ->
@deviceState.triggerApplyTarget() deviceState.triggerApplyTarget()
doPurge = (appId, force) => doPurge = (appId, force) ->
@logger.logSystemMessage("Purging data for app #{appId}", { appId }, 'Purge data') logger.logSystemMessage("Purging data for app #{appId}", { appId }, 'Purge data')
@_lockingIfNecessary appId, { force }, => _lockingIfNecessary appId, { force }, ->
@deviceState.getCurrentForComparison() deviceState.getCurrentForComparison()
.then (currentState) => .then (currentState) ->
app = currentState.local.apps[appId] app = currentState.local.apps[appId]
purgedApp = _.cloneDeep(app) if !app?
purgedApp.services = [] throw new Error(appNotFoundMsg)
purgedApp.volumes = {} purgedApp = _.cloneDeep(app)
currentState.local.apps[appId] = purgedApp purgedApp.services = []
@deviceState.pausingApply => purgedApp.volumes = {}
@deviceState.applyIntermediateTarget(currentState, { skipLock: true }) currentState.local.apps[appId] = purgedApp
.then => deviceState.pausingApply ->
currentState.local.apps[appId] = app deviceState.applyIntermediateTarget(currentState, { skipLock: true })
@deviceState.applyIntermediateTarget(currentState, { skipLock: true }) .then ->
.finally => currentState.local.apps[appId] = app
@deviceState.triggerApplyTarget() deviceState.applyIntermediateTarget(currentState, { skipLock: true })
.tap => .finally ->
@logger.logSystemMessage('Purged data', { appId }, 'Purge data success') deviceState.triggerApplyTarget()
.catch (err) => .tap ->
@logger.logSystemMessage("Error purging data: #{err}", { appId, error: err }, 'Purge data error') logger.logSystemMessage('Purged data', { appId }, 'Purge data success')
throw err .tapCatch (err) ->
logger.logSystemMessage("Error purging data: #{err}", { appId, error: err }, 'Purge data error')
@router.post '/v1/restart', (req, res) => router.post '/v1/restart', (req, res) ->
appId = checkInt(req.body.appId) appId = checkInt(req.body.appId)
force = checkTruthy(req.body.force) force = checkTruthy(req.body.force)
@eventTracker.track('Restart container (v1)', { appId }) eventTracker.track('Restart container (v1)', { appId })
if !appId? if !appId?
return res.status(400).send('Missing app id') return res.status(400).send('Missing app id')
doRestart(appId, force) doRestart(appId, force)
.then -> .then ->
res.status(200).send('OK') res.status(200).send('OK')
.catch (err) -> .catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error') res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v1/apps/:appId/stop', (req, res) => v1StopOrStart = (req, res, action) ->
appId = checkInt(req.params.appId) appId = checkInt(req.params.appId)
force = checkTruthy(req.body.force) force = checkTruthy(req.body.force)
if !appId? if !appId?
return res.status(400).send('Missing app id') return res.status(400).send('Missing app id')
@applications.getCurrentApp(appId) applications.getCurrentApp(appId)
.then (app) => .then (app) ->
service = app?.services?[0]
if !service?
return res.status(400).send('App not found')
if app.services.length > 1
return res.status(400).send('Some v1 endpoints are only allowed on single-container apps')
applications.setTargetVolatileForService(service.imageId, running: action != 'stop')
applications.executeStepAction(serviceAction(action, service.serviceId, service), { force })
.then (service) ->
res.status(200).json({ containerId: service.containerId })
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
router.post '/v1/apps/:appId/stop', (req, res) ->
v1StopOrStart(req, res, 'stop')
router.post '/v1/apps/:appId/start', (req, res) ->
v1StopOrStart(req, res, 'start')
router.get '/v1/apps/:appId', (req, res) ->
appId = checkInt(req.params.appId)
eventTracker.track('GET app (v1)', appId)
if !appId?
return res.status(400).send('Missing app id')
Promise.join(
applications.getCurrentApp(appId)
applications.getStatus()
(app, status) ->
service = app?.services?[0] service = app?.services?[0]
if !service? if !service?
return res.status(400).send('App not found') return res.status(400).send('App not found')
if app.services.length > 1 if app.services.length > 1
return res.status(400).send('Some v1 endpoints are only allowed on single-container apps') return res.status(400).send('Some v1 endpoints are only allowed on single-container apps')
@applications.setTargetVolatileForService(service.imageId, running: false) # Don't return data that will be of no use to the user
@applications.executeStepAction(serviceAction('stop', service.serviceId, service), { force }) appToSend = {
.then (service) -> appId
res.status(200).json({ containerId: service.containerId }) containerId: service.containerId
.catch (err) -> env: _.omit(service.environment, constants.privateAppEnvVars)
res.status(503).send(err?.message or err or 'Unknown error') releaseId: service.releaseId
imageId: service.image
}
if status.commit?
appToSend.commit = status.commit
res.json(appToSend)
)
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v1/apps/:appId/start', (req, res) => router.post '/v1/purge', (req, res) ->
appId = checkInt(req.params.appId) appId = checkInt(req.body.appId)
force = checkTruthy(req.body.force) force = checkTruthy(req.body.force)
if !appId? if !appId?
return res.status(400).send('Missing app id') errMsg = 'Invalid or missing appId'
@applications.getCurrentApp(appId) return res.status(400).send(errMsg)
.then (app) => doPurge(appId, force)
service = app?.services?[0] .then ->
res.status(200).json(Data: 'OK', Error: '')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
router.post '/v2/applications/:appId/purge', (req, res) ->
{ force } = req.body
{ appId } = req.params
doPurge(appId, force)
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
handleServiceAction = (req, res, action) ->
{ imageId, force } = req.body
{ appId } = req.params
_lockingIfNecessary appId, { force }, ->
applications.getCurrentApp(appId)
.then (app) ->
if !app?
return res.status(404).send(appNotFoundMsg)
service = _.find(app.services, { imageId })
if !service? if !service?
return res.status(400).send('App not found') errMsg = 'Service not found, a container must exist for this endpoint to work.'
if app.services.length > 1 return res.status(404).send(errMsg)
return res.status(400).send('Some v1 endpoints are only allowed on single-container apps') applications.setTargetVolatileForService(service.imageId, running: action != 'stop')
@applications.setTargetVolatileForService(service.imageId, running: true) applications.executeStepAction(serviceAction(action, service.serviceId, service, service, { wait: true }), { skipLock: true })
@applications.executeStepAction(serviceAction('start', service.serviceId, null, service), { force }) .then ->
.then (service) -> res.status(200).send('OK')
res.status(200).json({ containerId: service.containerId }) .catch (err) ->
.catch (err) -> res.status(503).send(err?.message or err or 'Unknown error')
res.status(503).send(err?.message or err or 'Unknown error')
@router.get '/v1/apps/:appId', (req, res) => router.post '/v2/applications/:appId/restart-service', (req, res) ->
appId = checkInt(req.params.appId) handleServiceAction(req, res, 'restart')
@eventTracker.track('GET app (v1)', appId)
if !appId?
return res.status(400).send('Missing app id')
Promise.join(
@applications.getCurrentApp(appId)
@applications.getStatus()
(app, status) ->
service = app?.services?[0]
if !service?
return res.status(400).send('App not found')
if app.services.length > 1
return res.status(400).send('Some v1 endpoints are only allowed on single-container apps')
# Don't return data that will be of no use to the user
appToSend = {
appId
containerId: service.containerId
env: _.omit(service.environment, constants.privateAppEnvVars)
releaseId: service.releaseId
imageId: service.image
}
if status.commit?
appToSend.commit = status.commit
res.json(appToSend)
)
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v1/purge', (req, res) -> router.post '/v2/applications/:appId/stop-service', (req, res) ->
appId = checkInt(req.body.appId) handleServiceAction(req, res, 'stop')
force = checkTruthy(req.body.force)
if !appId?
errMsg = "App not found: an app needs to be installed for purge to work.
If you've recently moved this device from another app,
please push an app and wait for it to be installed first."
return res.status(400).send(errMsg)
doPurge(appId, force)
.then ->
res.status(200).json(Data: 'OK', Error: '')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v2/applications/:appId/purge', (req, res) -> router.post '/v2/applications/:appId/start-service', (req, res) ->
{ force } = req.body handleServiceAction(req, res, 'start')
{ appId } = req.params
doPurge(appId, force)
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v2/applications/:appId/restart-service', (req, res) => router.post '/v2/applications/:appId/restart', (req, res) ->
{ imageId, force } = req.body { force } = req.body
{ appId } = req.params { appId } = req.params
@_lockingIfNecessary appId, { force }, => doRestart(appId, force)
@applications.getCurrentApp(appId) .then ->
.then (app) => res.status(200).send('OK')
if !app? .catch (err) ->
errMsg = "App not found: an app needs to be installed for restart-service to work. res.status(503).send(err?.message or err or 'Unknown error')
If you've recently moved this device from another app,
please push an app and wait for it to be installed first."
return res.status(404).send(errMsg)
service = _.find(app.services, (s) -> s.imageId == imageId)
if !service?
errMsg = 'Service not found, a container must exist for service restart to work.'
return res.status(404).send(errMsg)
@applications.setTargetVolatileForService(service.imageId, running: true)
@applications.executeStepAction(serviceAction('restart', service.serviceId, service, service), { skipLock: true })
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v2/applications/:appId/stop-service', (req, res) => router.use(applications.proxyvisor.router)
{ imageId, force } = req.body
{ appId } = req.params
@_lockingIfNecessary appId, { force }, =>
@applications.getCurrentApp(appId)
.then (app) =>
if !app?
errMsg = "App not found: an app needs to be installed for stop-service to work.
If you've recently moved this device from another app,
please push an app and wait for it to be installed first."
return res.status(404).send(errMsg)
service = _.find(app.services, (s) -> s.imageId == imageId)
if !service?
errMsg = 'Service not found, a container must exist for service stop to work.'
return res.status(404).send(errMsg)
@applications.setTargetVolatileForService(service.imageId, running: false)
@applications.executeStepAction(serviceAction('stop', service.serviceId, service, service, { wait: true }), { skipLock: true })
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v2/applications/:appId/start-service', (req, res) => return router
{ imageId, force } = req.body
{ appId } = req.params
@_lockingIfNecessary appId, { force }, =>
@applications.getCurrentApp(appId)
.then (app) =>
if !app?
errMsg = "App not found: an app needs to be installed for stop-service to work.
If you've recently moved this device from another app,
please push an app and wait for it to be installed first."
return res.status(404).send(errMsg)
service = _.find(app.services, (s) -> s.imageId == imageId)
if !service?
errMsg = 'Service not found, a container must exist for service start to work.'
return res.status(404).send(errMsg)
@applications.setTargetVolatileForService(service.imageId, running: true)
@applications.executeStepAction(serviceAction('start', service.serviceId, service, service), { skipLock: true })
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v2/applications/:appId/restart', (req, res) ->
{ force } = req.body
{ appId } = req.params
doRestart(appId, force)
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
@router.use(@proxyvisor.router)
module.exports = class ApplicationManager extends EventEmitter module.exports = class ApplicationManager extends EventEmitter
constructor: ({ @logger, @config, @db, @eventTracker, @deviceState }) -> constructor: ({ @logger, @config, @db, @eventTracker, @deviceState }) ->
@ -304,8 +261,7 @@ module.exports = class ApplicationManager extends EventEmitter
@services.updateMetadata(step.current, step.target) @services.updateMetadata(step.current, step.target)
restart: (step, { force = false, skipLock = false } = {}) => restart: (step, { force = false, skipLock = false } = {}) =>
@_lockingIfNecessary step.current.appId, { force, skipLock: skipLock or step.options?.skipLock }, => @_lockingIfNecessary step.current.appId, { force, skipLock: skipLock or step.options?.skipLock }, =>
Promise.try => @services.kill(step.current, { wait: true })
@services.kill(step.current, { wait: true })
.then => .then =>
delete @_containerStarted[step.current.containerId] delete @_containerStarted[step.current.containerId]
.then => .then =>
@ -331,8 +287,13 @@ module.exports = class ApplicationManager extends EventEmitter
opts.deltaSource = @bestDeltaSource(step.image, availableImages) opts.deltaSource = @bestDeltaSource(step.image, availableImages)
@images.triggerFetch step.image, opts, (success) => @images.triggerFetch step.image, opts, (success) =>
@fetchesInProgress -= 1 @fetchesInProgress -= 1
@timeSpentFetching += process.hrtime(startTime)[0] elapsed = process.hrtime(startTime)
elapsedMs = elapsed[0] * 1000 + elapsed[1] / 1e6
@timeSpentFetching += elapsedMs
if success if success
# update_downloaded is true if *any* image has been downloaded,
# and it's relevant mostly for the legacy GET /v1/device endpoint
# that assumes a single-container app
@reportCurrentState(update_downloaded: true) @reportCurrentState(update_downloaded: true)
) )
removeImage: (step) => removeImage: (step) =>
@ -351,9 +312,7 @@ module.exports = class ApplicationManager extends EventEmitter
@networks.ensureSupervisorNetwork() @networks.ensureSupervisorNetwork()
} }
@validActions = _.keys(@actionExecutors).concat(@proxyvisor.validActions) @validActions = _.keys(@actionExecutors).concat(@proxyvisor.validActions)
@_router = new ApplicationManagerRouter(this) @router = createApplicationManagerRouter(this)
@router = @_router.router
@images.on('change', @reportCurrentState) @images.on('change', @reportCurrentState)
@services.on('change', @reportCurrentState) @services.on('change', @reportCurrentState)
@ -372,7 +331,6 @@ module.exports = class ApplicationManager extends EventEmitter
@services.listenToEvents() @services.listenToEvents()
# Returns the status of applications and their services # Returns the status of applications and their services
# TODO: discuss: I think commit could be deduced by the UI looking at the image_installs on the API?
getStatus: => getStatus: =>
Promise.join( Promise.join(
@services.getStatus() @services.getStatus()
@ -382,11 +340,13 @@ module.exports = class ApplicationManager extends EventEmitter
apps = {} apps = {}
dependent = {} dependent = {}
releaseId = null releaseId = null
creationTimesAndReleases = {}
# We iterate over the current running services and add them to the current state # We iterate over the current running services and add them to the current state
# of the app they belong to. # of the app they belong to.
for service in services for service in services
appId = service.appId appId = service.appId
apps[appId] ?= {} apps[appId] ?= {}
creationTimesAndReleases[appId] = {}
apps[appId].services ?= {} apps[appId].services ?= {}
# We only send commit if all services have the same release, and it matches the target release # We only send commit if all services have the same release, and it matches the target release
if !releaseId? if !releaseId?
@ -395,11 +355,11 @@ module.exports = class ApplicationManager extends EventEmitter
releaseId = false releaseId = false
if !apps[appId].services[service.imageId]? if !apps[appId].services[service.imageId]?
apps[appId].services[service.imageId] = _.pick(service, [ 'status', 'releaseId' ]) apps[appId].services[service.imageId] = _.pick(service, [ 'status', 'releaseId' ])
creationTimesAndReleases[appId][service.imageId] = _.pick(service, [ 'createdAt', 'releaseId' ])
apps[appId].services[service.imageId].download_progress = null apps[appId].services[service.imageId].download_progress = null
else else
# There's two containers with the same imageId, so this has to be a handover # There's two containers with the same imageId, so this has to be a handover
previousReleaseId = apps[appId].services[service.imageId].releaseId apps[appId].services[service.imageId].releaseId = _.minBy([ creationTimesAndReleases[appId][service.imageId], service ], 'createdAt').releaseId
apps[appId].services[service.imageId].releaseId = Math.max(previousReleaseId, service.releaseId)
apps[appId].services[service.imageId].status = 'Handing over' apps[appId].services[service.imageId].status = 'Handing over'
for image in images for image in images
@ -409,14 +369,13 @@ module.exports = class ApplicationManager extends EventEmitter
apps[appId].services ?= {} apps[appId].services ?= {}
apps[appId].services[image.imageId] ?= _.pick(image, [ 'status', 'releaseId' ]) apps[appId].services[image.imageId] ?= _.pick(image, [ 'status', 'releaseId' ])
apps[appId].services[image.imageId].download_progress = image.downloadProgress apps[appId].services[image.imageId].download_progress = image.downloadProgress
else if image.imageId?
dependent[appId] ?= {}
dependent[appId].images ?= {}
dependent[appId].images[image.imageId] = _.pick(image, [ 'status' ])
dependent[appId].images[image.imageId].download_progress = image.downloadProgress
else else
if image.imageId? console.log('Ignoring legacy dependent image', image)
dependent[appId] ?= {}
dependent[appId].images ?= {}
dependent[appId].images[image.imageId] = _.pick(image, [ 'status' ])
dependent[appId].images[image.imageId].download_progress = image.downloadProgress
else
console.log('Ignoring legacy dependent image', image)
obj = { local: apps, dependent } obj = { local: apps, dependent }
if releaseId and targetApps[0]?.releaseId == releaseId if releaseId and targetApps[0]?.releaseId == releaseId
@ -454,8 +413,7 @@ module.exports = class ApplicationManager extends EventEmitter
@services.getAll() @services.getAll()
@networks.getAll() @networks.getAll()
@volumes.getAll() @volumes.getAll()
(services, networks, volumes) => @_buildApps
return @_buildApps(services, networks, volumes)
) )
getCurrentApp: (appId) => getCurrentApp: (appId) =>
@ -463,9 +421,8 @@ module.exports = class ApplicationManager extends EventEmitter
@services.getAllByAppId(appId) @services.getAllByAppId(appId)
@networks.getAllByAppId(appId) @networks.getAllByAppId(appId)
@volumes.getAllByAppId(appId) @volumes.getAllByAppId(appId)
(services, networks, volumes) => @_buildApps
return @_buildApps(services, networks, volumes)[appId] ).get(appId)
)
getTargetApp: (appId) => getTargetApp: (appId) =>
@db.models('app').where({ appId }).select() @db.models('app').where({ appId }).select()
@ -486,7 +443,7 @@ module.exports = class ApplicationManager extends EventEmitter
toBeRemoved = _.difference(currentServiceIds, targetServiceIds) toBeRemoved = _.difference(currentServiceIds, targetServiceIds)
for serviceId in toBeRemoved for serviceId in toBeRemoved
servicesToRemove = _.filter(currentServices, (s) -> s.serviceId == serviceId) servicesToRemove = _.filter(currentServices, { serviceId })
for service in servicesToRemove for service in servicesToRemove
removePairs.push({ removePairs.push({
current: service current: service
@ -496,7 +453,7 @@ module.exports = class ApplicationManager extends EventEmitter
toBeInstalled = _.difference(targetServiceIds, currentServiceIds) toBeInstalled = _.difference(targetServiceIds, currentServiceIds)
for serviceId in toBeInstalled for serviceId in toBeInstalled
serviceToInstall = _.find(targetServices, (s) -> s.serviceId == serviceId) serviceToInstall = _.find(targetServices, { serviceId })
if serviceToInstall? if serviceToInstall?
installPairs.push({ installPairs.push({
current: null current: null
@ -508,8 +465,7 @@ module.exports = class ApplicationManager extends EventEmitter
currentServicesPerId = {} currentServicesPerId = {}
targetServicesPerId = _.keyBy(targetServices, 'serviceId') targetServicesPerId = _.keyBy(targetServices, 'serviceId')
for serviceId in toBeMaybeUpdated for serviceId in toBeMaybeUpdated
currentServiceContainers = _.filter currentServices, (service) -> currentServiceContainers = _.filter(currentServices, { serviceId })
return service.serviceId == serviceId
if currentServiceContainers.length > 1 if currentServiceContainers.length > 1
currentServicesPerId[serviceId] = _.maxBy(currentServiceContainers, 'createdAt') currentServicesPerId[serviceId] = _.maxBy(currentServiceContainers, 'createdAt')
# All but the latest container for this service are spurious and should be removed # All but the latest container for this service are spurious and should be removed
@ -525,12 +481,14 @@ module.exports = class ApplicationManager extends EventEmitter
# Returns true if a service matches its target except it should be running and it is not, but we've # Returns true if a service matches its target except it should be running and it is not, but we've
# already started it before. In this case it means it just exited so we don't want to start it again. # already started it before. In this case it means it just exited so we don't want to start it again.
alreadyStarted = (serviceId) => alreadyStarted = (serviceId) =>
currentServicesPerId[serviceId].isEqualExceptForRunningState(targetServicesPerId[serviceId]) and return (
currentServicesPerId[serviceId].isEqualExceptForRunningState(targetServicesPerId[serviceId]) and
targetServicesPerId[serviceId].running and targetServicesPerId[serviceId].running and
@_containerStarted[currentServicesPerId[serviceId].containerId] @_containerStarted[currentServicesPerId[serviceId].containerId]
)
needUpdate = _.filter toBeMaybeUpdated, (serviceId) -> needUpdate = _.filter toBeMaybeUpdated, (serviceId) ->
return !currentServicesPerId[serviceId].isEqual(targetServicesPerId[serviceId]) and !alreadyStarted(serviceId) !currentServicesPerId[serviceId].isEqual(targetServicesPerId[serviceId]) and !alreadyStarted(serviceId)
for serviceId in needUpdate for serviceId in needUpdate
updatePairs.push({ updatePairs.push({
current: currentServicesPerId[serviceId] current: currentServicesPerId[serviceId]
@ -599,26 +557,24 @@ module.exports = class ApplicationManager extends EventEmitter
name = _.split(volume, ':')[0] name = _.split(volume, ':')[0]
_.some volumePairs, (pair) -> _.some volumePairs, (pair) ->
"#{service.appId}_#{pair.current?.name}" == name "#{service.appId}_#{pair.current?.name}" == name
if hasVolume return hasVolume
return true
return false
# TODO: account for volumes-from, networks-from, links, etc # TODO: account for volumes-from, networks-from, links, etc
# TODO: support networks instead of only networkMode # TODO: support networks instead of only networkMode
_dependenciesMetForServiceStart: (target, networkPairs, volumePairs, pendingPairs) -> _dependenciesMetForServiceStart: (target, networkPairs, volumePairs, pendingPairs) ->
# for dependsOn, check no install or update pairs have that service # for dependsOn, check no install or update pairs have that service
dependencyUnmet = _.some target.dependsOn ? [], (dependency) -> dependencyUnmet = _.some target.dependsOn ? [], (dependency) ->
_.find(pendingPairs, (pair) -> pair.target?.serviceName == dependency)? _.some(pendingPairs, (pair) -> pair.target?.serviceName == dependency)
if dependencyUnmet if dependencyUnmet
return false return false
# for networks and volumes, check no network pairs have that volume name # for networks and volumes, check no network pairs have that volume name
if _.find(networkPairs, (pair) -> "#{target.appId}_#{pair.target?.name}" == target.networkMode)? if _.some(networkPairs, (pair) -> "#{target.appId}_#{pair.target?.name}" == target.networkMode)
return false return false
volumeUnmet = _.some target.volumes, (volumeDefinition) -> volumeUnmet = _.some target.volumes, (volumeDefinition) ->
[ sourceName, destName ] = volumeDefinition.split(':') [ sourceName, destName ] = volumeDefinition.split(':')
if !destName? # If this is not a named volume, ignore it if !destName? # If this is not a named volume, ignore it
return false return false
return _.find(volumePairs, (pair) -> "#{target.appId}_#{pair.target?.name}" == sourceName)? return _.some(volumePairs, (pair) -> "#{target.appId}_#{pair.target?.name}" == sourceName)
return !volumeUnmet return !volumeUnmet
# Unless the update strategy requires an early kill (i.e. kill-then-download, delete-then-download), we only want # Unless the update strategy requires an early kill (i.e. kill-then-download, delete-then-download), we only want
@ -627,8 +583,8 @@ module.exports = class ApplicationManager extends EventEmitter
_dependenciesMetForServiceKill: (target, targetApp, availableImages) => _dependenciesMetForServiceKill: (target, targetApp, availableImages) =>
if target.dependsOn? if target.dependsOn?
for dependency in target.dependsOn for dependency in target.dependsOn
dependencyService = _.find(targetApp.services, (s) -> s.serviceName == dependency) dependencyService = _.find(targetApp.services, serviceName: dependency)
if !_.find(availableImages, (image) => image.dockerImageId == dependencyService.image or @images.isSameImage(image, { name: dependencyService.imageName }))? if !_.some(availableImages, (image) => image.dockerImageId == dependencyService.image or @images.isSameImage(image, { name: dependencyService.imageName }))
return false return false
return true return true
@ -644,7 +600,7 @@ module.exports = class ApplicationManager extends EventEmitter
# we have to kill them before removing the network/volume (e.g. when we're only updating the network config) # we have to kill them before removing the network/volume (e.g. when we're only updating the network config)
steps = [] steps = []
for dependency in dependencies for dependency in dependencies
if dependency.status != 'Stopping' and !_.some(changingPairs, (pair) -> pair.serviceId == dependency.serviceId) if dependency.status != 'Stopping' and !_.some(changingPairs, serviceId: dependency.serviceId)
steps.push(serviceAction('kill', dependency.serviceId, dependency)) steps.push(serviceAction('kill', dependency.serviceId, dependency))
return steps return steps
else if target? else if target?
@ -696,7 +652,7 @@ module.exports = class ApplicationManager extends EventEmitter
'hand-over': (current, target, needsDownload, dependenciesMetForStart, dependenciesMetForKill, needsSpecialKill, timeout) -> 'hand-over': (current, target, needsDownload, dependenciesMetForStart, dependenciesMetForKill, needsSpecialKill, timeout) ->
if needsDownload if needsDownload
return fetchAction(target) return fetchAction(target)
else if needsSpecialKill && dependenciesMetForKill() else if needsSpecialKill and dependenciesMetForKill()
return serviceAction('kill', target.serviceId, current, target) return serviceAction('kill', target.serviceId, current, target)
else if dependenciesMetForStart() else if dependenciesMetForStart()
return serviceAction('handover', target.serviceId, current, target, timeout: timeout) return serviceAction('handover', target.serviceId, current, target, timeout: timeout)
@ -747,8 +703,7 @@ module.exports = class ApplicationManager extends EventEmitter
else else
# Create the default network for the target app # Create the default network for the target app
targetApp.networks['default'] ?= {} targetApp.networks['default'] ?= {}
if !currentApp? currentApp ?= emptyApp
currentApp = emptyApp
if currentApp.services?.length == 1 and targetApp.services?.length == 1 and if currentApp.services?.length == 1 and targetApp.services?.length == 1 and
targetApp.services[0].serviceName == currentApp.services[0].serviceName and targetApp.services[0].serviceName == currentApp.services[0].serviceName and
checkTruthy(currentApp.services[0].labels['io.resin.legacy-container']) checkTruthy(currentApp.services[0].labels['io.resin.legacy-container'])
@ -828,10 +783,15 @@ module.exports = class ApplicationManager extends EventEmitter
@config.get('extendedEnvOptions') @config.get('extendedEnvOptions')
@docker.getNetworkGateway(constants.supervisorNetworkInterface) @docker.getNetworkGateway(constants.supervisorNetworkInterface)
.catchReturn('127.0.0.1') .catchReturn('127.0.0.1')
(opts, supervisorApiHost) => Promise.props({
firmware: pathExistsOnHost('/lib/firmware')
modules: pathExistsOnHost('/lib/modules')
})
(opts, supervisorApiHost, hostPathExists) =>
configOpts = { configOpts = {
appName: app.name appName: app.name
supervisorApiHost supervisorApiHost
hostPathExists
} }
_.assign(configOpts, opts) _.assign(configOpts, opts)
volumes = JSON.parse(app.volumes) volumes = JSON.parse(app.volumes)
@ -931,10 +891,10 @@ module.exports = class ApplicationManager extends EventEmitter
allImagesForTargetApp = (app) -> _.map(app.services, imageForService) allImagesForTargetApp = (app) -> _.map(app.services, imageForService)
allImagesForCurrentApp = (app) -> allImagesForCurrentApp = (app) ->
_.map app.services, (service) -> _.map app.services, (service) ->
_.omit(_.find(available, (image) -> image.dockerImageId == service.image and image.imageId == service.imageId), [ 'dockerImageId', 'id' ]) _.omit(_.find(available, { dockerImageId: service.image, imageId: service.imageId }), [ 'dockerImageId', 'id' ])
availableWithoutIds = _.map(available, (image) -> _.omit(image, [ 'dockerImageId', 'id' ])) availableWithoutIds = _.map(available, (image) -> _.omit(image, [ 'dockerImageId', 'id' ]))
currentImages = _.flatten(_.map(current.local.apps, allImagesForCurrentApp)) currentImages = _.flatMap(current.local.apps, allImagesForCurrentApp)
targetImages = _.flatten(_.map(target.local.apps, allImagesForTargetApp)) targetImages = _.flatMap(target.local.apps, allImagesForTargetApp)
availableAndUnused = _.filter availableWithoutIds, (image) -> availableAndUnused = _.filter availableWithoutIds, (image) ->
!_.some currentImages.concat(targetImages), (imageInUse) -> _.isEqual(image, imageInUse) !_.some currentImages.concat(targetImages), (imageInUse) -> _.isEqual(image, imageInUse)
imagesToDownload = _.filter targetImages, (targetImage) => imagesToDownload = _.filter targetImages, (targetImage) =>
@ -942,13 +902,13 @@ module.exports = class ApplicationManager extends EventEmitter
# Images that are available but we don't have them in the DB with the exact metadata: # Images that are available but we don't have them in the DB with the exact metadata:
imagesToSave = _.filter targetImages, (targetImage) => imagesToSave = _.filter targetImages, (targetImage) =>
_.some(available, (availableImage) => @images.isSameImage(availableImage, targetImage)) and _.some(available, (availableImage) => @images.isSameImage(availableImage, targetImage)) and
!_.find(availableWithoutIds, (img) -> _.isEqual(img, targetImage))? !_.some(availableWithoutIds, (img) -> _.isEqual(img, targetImage))
deltaSources = _.map imagesToDownload, (image) => deltaSources = _.map imagesToDownload, (image) =>
return @bestDeltaSource(image, available) return @bestDeltaSource(image, available)
proxyvisorImages = @proxyvisor.imagesInUse(current, target) proxyvisorImages = @proxyvisor.imagesInUse(current, target)
imagesToRemove = _.filter availableAndUnused, (image) => imagesToRemove = _.filter availableAndUnused, (image) =>
notUsedForDelta = !_.some deltaSources, (deltaSource) -> deltaSource == image.name notUsedForDelta = !_.includes(deltaSources, image.name)
notUsedByProxyvisor = !_.some proxyvisorImages, (proxyvisorImage) => @images.isSameImage(image, { name: proxyvisorImage }) notUsedByProxyvisor = !_.some proxyvisorImages, (proxyvisorImage) => @images.isSameImage(image, { name: proxyvisorImage })
return notUsedForDelta and notUsedByProxyvisor return notUsedForDelta and notUsedByProxyvisor
return { imagesToSave, imagesToRemove } return { imagesToSave, imagesToRemove }
@ -986,7 +946,7 @@ module.exports = class ApplicationManager extends EventEmitter
if !ignoreImages and delta and newDownloads > 0 if !ignoreImages and delta and newDownloads > 0
downloadsToBlock = downloading.length + newDownloads - constants.maxDeltaDownloads downloadsToBlock = downloading.length + newDownloads - constants.maxDeltaDownloads
while downloadsToBlock > 0 while downloadsToBlock > 0
_.pull(nextSteps, _.find(nextSteps, (s) -> s.action == 'fetch')) _.pull(nextSteps, _.find(nextSteps, action: 'fetch'))
downloadsToBlock -= 1 downloadsToBlock -= 1
if !ignoreImages and _.isEmpty(nextSteps) and !_.isEmpty(downloading) if !ignoreImages and _.isEmpty(nextSteps) and !_.isEmpty(downloading)
nextSteps.push({ action: 'noop' }) nextSteps.push({ action: 'noop' })
@ -1002,7 +962,7 @@ module.exports = class ApplicationManager extends EventEmitter
_lockingIfNecessary: (appId, { force = false, skipLock = false } = {}, fn) => _lockingIfNecessary: (appId, { force = false, skipLock = false } = {}, fn) =>
if skipLock if skipLock
return Promise.try( -> fn()) return Promise.try(fn)
@config.get('lockOverride') @config.get('lockOverride')
.then (lockOverride) -> .then (lockOverride) ->
return checkTruthy(lockOverride) or force return checkTruthy(lockOverride) or force
@ -1026,7 +986,7 @@ module.exports = class ApplicationManager extends EventEmitter
(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, conf) => (cleanupNeeded, availableImages, downloading, supervisorNetworkReady, conf) =>
@_inferNextSteps(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, currentState, targetState, ignoreImages, conf) @_inferNextSteps(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, currentState, targetState, ignoreImages, conf)
.then (nextSteps) => .then (nextSteps) =>
if ignoreImages and _.some(nextSteps, (step) -> step.action == 'fetch') if ignoreImages and _.some(nextSteps, action: 'fetch')
throw new Error('Cannot fetch images while executing an API action') throw new Error('Cannot fetch images while executing an API action')
@proxyvisor.getRequiredSteps(availableImages, downloading, currentState, targetState, nextSteps) @proxyvisor.getRequiredSteps(availableImages, downloading, currentState, targetState, nextSteps)
.then (proxyvisorSteps) -> .then (proxyvisorSteps) ->

View File

@ -21,7 +21,7 @@ validation = require '../lib/validation'
# } # }
hasDigest = (name) -> hasDigest = (name) ->
name?.split?('@')?[1]? ? false name?.split?('@')?[1]?
module.exports = class Images extends EventEmitter module.exports = class Images extends EventEmitter
constructor: ({ @docker, @logger, @db }) -> constructor: ({ @docker, @logger, @db }) ->
@ -136,10 +136,10 @@ module.exports = class Images extends EventEmitter
# Image has a regular tag, so we might have to remove unnecessary tags # Image has a regular tag, so we might have to remove unnecessary tags
@docker.getImage(img.dockerImageId).inspect() @docker.getImage(img.dockerImageId).inspect()
.then (dockerImg) => .then (dockerImg) =>
differentTags = _.filter(imagesFromDB, (dbImage) -> dbImage.name != img.name) differentTags = _.reject(imagesFromDB, name: img.name)
if dockerImg.RepoTags.length > 1 and if dockerImg.RepoTags.length > 1 and
_.some(dockerImg.RepoTags, (tag) -> tag == img.name) and _.includes(dockerImg.RepoTags, img.name) and
_.some(dockerImg.RepoTags, (tag) -> _.some(differentTags, (dbImage) -> dbImage.name == tag)) _.some(dockerImg.RepoTags, (tag) -> _.some(differentTags, name: tag))
@docker.getImage(img.name).remove(noprune: true) @docker.getImage(img.name).remove(noprune: true)
.return(false) .return(false)
else else
@ -155,9 +155,8 @@ module.exports = class Images extends EventEmitter
remove: (image) => remove: (image) =>
@_removeImageIfNotNeeded(image) @_removeImageIfNotNeeded(image)
.catch (err) => .tapCatch (err) =>
@logger.logSystemEvent(logTypes.deleteImageError, { image, error: err }) @logger.logSystemEvent(logTypes.deleteImageError, { image, error: err })
throw err
getByDockerId: (id) => getByDockerId: (id) =>
@db.models('image').where(dockerImageId: id).first() @db.models('image').where(dockerImageId: id).first()
@ -167,7 +166,7 @@ module.exports = class Images extends EventEmitter
.then(@remove) .then(@remove)
getNormalisedTags: (image) -> getNormalisedTags: (image) ->
Promise.map(image.RepoTags ? [], (tag) => @normalise(tag)) Promise.map(image.RepoTags ? [], @normalise)
_withImagesFromDockerAndDB: (callback) => _withImagesFromDockerAndDB: (callback) =>
Promise.join( Promise.join(
@ -194,7 +193,7 @@ module.exports = class Images extends EventEmitter
getDownloadingImageIds: => getDownloadingImageIds: =>
Promise.try => Promise.try =>
return _.map(_.keys(_.pickBy(@volatileState, (s) -> s.status == 'Downloading')), validation.checkInt) return _.map(_.keys(_.pickBy(@volatileState, status: 'Downloading')), validation.checkInt)
cleanupDatabase: => cleanupDatabase: =>
@_withImagesFromDockerAndDB (dockerImages, supervisedImages) => @_withImagesFromDockerAndDB (dockerImages, supervisedImages) =>
@ -252,7 +251,6 @@ module.exports = class Images extends EventEmitter
.then => .then =>
toCleanup = _.filter _.uniq(images), (image) => toCleanup = _.filter _.uniq(images), (image) =>
!@imageCleanupFailures[image]? or Date.now() - @imageCleanupFailures[image] > constants.imageCleanupErrorIgnoreTimeout !@imageCleanupFailures[image]? or Date.now() - @imageCleanupFailures[image] > constants.imageCleanupErrorIgnoreTimeout
#console.log(toCleanup)
return toCleanup return toCleanup
inspectByName: (imageName) => inspectByName: (imageName) =>
@docker.getImage(imageName).inspect() @docker.getImage(imageName).inspect()

View File

@ -47,16 +47,14 @@ module.exports = class Networks
'io.resin.supervised': 'true' 'io.resin.supervised': 'true'
} }
}) })
.catch (err) => .tapCatch (err) =>
@logger.logSystemEvent(logTypes.createNetworkError, { network: { name, appId }, error: err }) @logger.logSystemEvent(logTypes.createNetworkError, { network: { name, appId }, error: err })
throw err
remove: ({ name, appId }) => remove: ({ name, appId }) =>
@logger.logSystemEvent(logTypes.removeNetwork, { network: { name, appId } }) @logger.logSystemEvent(logTypes.removeNetwork, { network: { name, appId } })
@docker.getNetwork("#{appId}_#{name}").remove() @docker.getNetwork("#{appId}_#{name}").remove()
.catch (err) => .tapCatch (err) =>
@logger.logSystemEvent(logTypes.removeNetworkError, { network: { name, appId }, error: err }) @logger.logSystemEvent(logTypes.removeNetworkError, { network: { name, appId }, error: err })
throw err
supervisorNetworkReady: => supervisorNetworkReady: =>
# For mysterious reasons sometimes the balena/docker network exists # For mysterious reasons sometimes the balena/docker network exists
@ -80,8 +78,7 @@ module.exports = class Networks
removeIt() removeIt()
else else
fs.statAsync("/sys/class/net/#{constants.supervisorNetworkInterface}") fs.statAsync("/sys/class/net/#{constants.supervisorNetworkInterface}")
.catch ENOENT, -> .catch(ENOENT, removeIt)
removeIt()
.catch NotFoundError, => .catch NotFoundError, =>
console.log('Creating supervisor0 network') console.log('Creating supervisor0 network')
@docker.createNetwork({ @docker.createNetwork({

View File

@ -66,7 +66,6 @@ module.exports = class ServiceManager extends EventEmitter
throw err throw err
.tap => .tap =>
delete @containerHasDied[containerId] delete @containerHasDied[containerId]
.tap =>
@logger.logSystemEvent(logTypes.stopServiceSuccess, { service }) @logger.logSystemEvent(logTypes.stopServiceSuccess, { service })
.catch (err) => .catch (err) =>
@logger.logSystemEvent(logTypes.stopServiceError, { service, error: err }) @logger.logSystemEvent(logTypes.stopServiceError, { service, error: err })
@ -101,13 +100,11 @@ module.exports = class ServiceManager extends EventEmitter
.tap (container) => .tap (container) =>
service.containerId = container.id service.containerId = container.id
Promise.map nets, ({ name, endpointConfig }) => Promise.map nets, ({ name, endpointConfig }) =>
console.log('Connecting ' + container.id + ' to network ' + name)
@docker.getNetwork(name).connect({ Container: container.id, EndpointConfig: endpointConfig }) @docker.getNetwork(name).connect({ Container: container.id, EndpointConfig: endpointConfig })
.tap => .tap =>
@logger.logSystemEvent(logTypes.installServiceSuccess, { service }) @logger.logSystemEvent(logTypes.installServiceSuccess, { service })
.catch (err) => .tapCatch (err) =>
@logger.logSystemEvent(logTypes.installServiceError, { service, error: err }) @logger.logSystemEvent(logTypes.installServiceError, { service, error: err })
throw err
.finally => .finally =>
@reportChange(mockContainerId) @reportChange(mockContainerId)
@ -135,12 +132,12 @@ module.exports = class ServiceManager extends EventEmitter
else else
# rethrow the same error # rethrow the same error
throw err throw err
.catch (err) => .tapCatch (err) =>
# If starting the container failed, we remove it so that it doesn't litter # If starting the container failed, we remove it so that it doesn't litter
container.remove(v: true) container.remove(v: true)
.finally => .catch(_.noop)
.then =>
@logger.logSystemEvent(logTypes.startServiceError, { service, error: err }) @logger.logSystemEvent(logTypes.startServiceError, { service, error: err })
throw err
.then => .then =>
@logger.attach(@docker, container.id, service.serviceId) @logger.attach(@docker, container.id, service.serviceId)
.tap => .tap =>
@ -176,7 +173,7 @@ module.exports = class ServiceManager extends EventEmitter
.then (services) => .then (services) =>
status = _.clone(@volatileState) status = _.clone(@volatileState)
for service in services for service in services
status[service.containerId] ?= _.pick(service, [ 'appId', 'imageId', 'status', 'releaseId', 'commit' ]) status[service.containerId] ?= _.pick(service, [ 'appId', 'imageId', 'status', 'releaseId', 'commit', 'createdAt' ])
return _.values(status) return _.values(status)
getByDockerContainerId: (containerId) => getByDockerContainerId: (containerId) =>
@ -233,7 +230,7 @@ module.exports = class ServiceManager extends EventEmitter
@docker.getEvents(filters: type: [ 'container' ]) @docker.getEvents(filters: type: [ 'container' ])
.then (stream) => .then (stream) =>
stream.on 'error', (err) -> stream.on 'error', (err) ->
console.error('Error on docker events stream:', err, err.stack) console.error('Error on docker events stream:', err)
parser = JSONStream.parse() parser = JSONStream.parse()
parser.on 'data', (data) => parser.on 'data', (data) =>
if data?.status in ['die', 'start'] if data?.status in ['die', 'start']
@ -257,9 +254,8 @@ module.exports = class ServiceManager extends EventEmitter
parser parser
.on 'error', (err) -> .on 'error', (err) ->
console.error('Error on docker events stream', err) console.error('Error on docker events stream', err)
reject() reject(err)
.on 'end', -> .on 'end', ->
console.error('Docker events stream ended, restarting listener')
resolve() resolve()
stream.pipe(parser) stream.pipe(parser)
.catch (err) -> .catch (err) ->

View File

@ -24,55 +24,33 @@ parseMemoryNumber = (numAsString, defaultVal) ->
# Construct a restart policy based on its name. # Construct a restart policy based on its name.
# The default policy (if name is not a valid policy) is "always". # The default policy (if name is not a valid policy) is "always".
createRestartPolicy = (name) -> createRestartPolicy = (name) ->
if not (name in validRestartPolicies) if name not in validRestartPolicies
name = 'always' name = 'always'
return { Name: name, MaximumRetryCount: 0 } return { Name: name, MaximumRetryCount: 0 }
getCommand = (service, imageInfo) -> getCommand = (service, imageInfo) ->
cmd = null cmd = service.command ? imageInfo?.Config?.Cmd ? null
if service.command?
cmd = service.command
else if imageInfo?.Config?.Cmd?
cmd = imageInfo.Config.Cmd
if _.isString(cmd) if _.isString(cmd)
cmd = parseCommand(cmd) cmd = parseCommand(cmd)
return cmd return cmd
getEntrypoint = (service, imageInfo) -> getEntrypoint = (service, imageInfo) ->
entry = null entry = service.entrypoint ? imageInfo?.Config?.Entrypoint ? null
if service.entrypoint?
entry = service.entrypoint
else if imageInfo?.Config?.Entrypoint?
entry = imageInfo.Config.Entrypoint
if _.isString(entry) if _.isString(entry)
entry = parseCommand(entry) entry = parseCommand(entry)
return entry return entry
getStopSignal = (service, imageInfo) -> getStopSignal = (service, imageInfo) ->
sig = null sig = service.stopSignal ? imageInfo?.Config?.StopSignal ? null
if service.stop_signal?
sig = service.stop_signal
else if imageInfo?.Config?.StopSignal?
sig = imageInfo.Config.StopSignal
if sig? and !_.isString(sig) # In case the YAML was parsed as a number if sig? and !_.isString(sig) # In case the YAML was parsed as a number
sig = sig.toString() sig = sig.toString()
return sig return sig
getUser = (service, imageInfo) -> getUser = (service, imageInfo) ->
user = '' return service.user ? imageInfo?.Config?.User ? ''
if service.user?
user = service.user
else if imageInfo?.Config?.User?
user = imageInfo.Config.User
return user
getWorkingDir = (service, imageInfo) -> getWorkingDir = (service, imageInfo) ->
workingDir = '' return service.workingDir ? imageInfo?.Config?.WorkingDir ? ''
if service.workingDir?
workingDir = service.workingDir
else if imageInfo?.Config?.WorkingDir?
workingDir = imageInfo.Config.WorkingDir
return workingDir
buildHealthcheckTest = (test) -> buildHealthcheckTest = (test) ->
if _.isString(test) if _.isString(test)
@ -101,13 +79,11 @@ overrideHealthcheckFromCompose = (serviceHealthcheck, imageHealthcheck = {}) ->
return imageHealthcheck return imageHealthcheck
getHealthcheck = (service, imageInfo) -> getHealthcheck = (service, imageInfo) ->
healthcheck = null healthcheck = imageInfo?.Config?.Healthcheck ? null
if imageInfo?.Config?.Healthcheck?
healthcheck = imageInfo.Config.Healthcheck
if service.healthcheck? if service.healthcheck?
healthcheck = overrideHealthcheckFromCompose(service.healthcheck, healthcheck) healthcheck = overrideHealthcheckFromCompose(service.healthcheck, healthcheck)
# Set invalid healthchecks back to null # Set invalid healthchecks back to null
if healthcheck and (!healthcheck.Test? or _.isEqual(healthcheck.Test, [])) if healthcheck? and (!healthcheck.Test? or _.isEqual(healthcheck.Test, []))
healthcheck = null healthcheck = null
return healthcheck return healthcheck
@ -128,7 +104,8 @@ formatDevices = (devices) ->
# TODO: Support configuration for "networks" # TODO: Support configuration for "networks"
module.exports = class Service module.exports = class Service
constructor: (serviceProperties, opts = {}) -> constructor: (props, opts = {}) ->
serviceProperties = _.mapKeys(props, (v, k) -> _.camelCase(k))
{ {
@image @image
@imageName @imageName
@ -194,7 +171,7 @@ module.exports = class Service
@macAddress @macAddress
@user @user
@workingDir @workingDir
} = _.mapKeys(serviceProperties, (v, k) -> _.camelCase(k)) } = serviceProperties
@networks ?= {} @networks ?= {}
@privileged ?= false @privileged ?= false
@ -255,15 +232,18 @@ module.exports = class Service
# If the service has no containerId, it is a target service and has to be normalised and extended # If the service has no containerId, it is a target service and has to be normalised and extended
if !@containerId? if !@containerId?
@networkMode ?= 'default' if !@networkMode?
if !_.isEmpty(@networks)
@networkMode = _.keys(@networks)[0]
else
@networkMode = 'default'
if @networkMode not in [ 'host', 'bridge', 'none' ] if @networkMode not in [ 'host', 'bridge', 'none' ]
@networkMode = "#{@appId}_#{@networkMode}" @networkMode = "#{@appId}_#{@networkMode}"
@networks = _.mapKeys @networks, (v, k) => @networks = _.mapKeys @networks, (v, k) =>
if k not in [ 'host', 'bridge', 'none' ] if k not in [ 'host', 'bridge', 'none' ]
return "#{@appId}_#{k}" return "#{@appId}_#{k}"
else return k
return k
@networks[@networkMode] ?= {} @networks[@networkMode] ?= {}
@ -282,14 +262,11 @@ module.exports = class Service
@devices = formatDevices(@devices) @devices = formatDevices(@devices)
@addFeaturesFromLabels(opts) @addFeaturesFromLabels(opts)
if @dns? if @dns?
if !Array.isArray(@dns) @dns = _.castArray(@dns)
@dns = [ @dns ]
if @dnsSearch? if @dnsSearch?
if !Array.isArray(@dnsSearch) @dnsSearch = _.castArray(@dnsSearch)
@dnsSearch = [ @dns ]
if @tmpfs? if @tmpfs?
if !Array.isArray(@tmpfs) @tmpfs = _.castArray(@tmpfs)
@tmpfs = [ @tmpfs ]
@nanoCpus = Math.round(Number(@cpus) * 10 ** 9) @nanoCpus = Math.round(Number(@cpus) * 10 ** 9)
@ -330,9 +307,9 @@ module.exports = class Service
addFeaturesFromLabels: (opts) => addFeaturesFromLabels: (opts) =>
if checkTruthy(@labels['io.resin.features.dbus']) if checkTruthy(@labels['io.resin.features.dbus'])
@volumes.push('/run/dbus:/host/run/dbus') @volumes.push('/run/dbus:/host/run/dbus')
if checkTruthy(@labels['io.resin.features.kernel-modules']) if checkTruthy(@labels['io.resin.features.kernel-modules']) and opts.hostPathExists.modules
@volumes.push('/lib/modules:/lib/modules') @volumes.push('/lib/modules:/lib/modules')
if checkTruthy(@labels['io.resin.features.firmware']) if checkTruthy(@labels['io.resin.features.firmware']) and opts.hostPathExists.firmware
@volumes.push('/lib/firmware:/lib/firmware') @volumes.push('/lib/firmware:/lib/firmware')
if checkTruthy(@labels['io.resin.features.balena-socket']) if checkTruthy(@labels['io.resin.features.balena-socket'])
@volumes.push('/var/run/balena.sock:/var/run/balena.sock') @volumes.push('/var/run/balena.sock:/var/run/balena.sock')
@ -388,7 +365,7 @@ module.exports = class Service
extendAndSanitiseVolumes: (imageInfo) => extendAndSanitiseVolumes: (imageInfo) =>
volumes = [] volumes = []
for vol in @volumes for vol in @volumes
isBind = /:/.test(vol) isBind = _.includes(vol, ':')
if isBind if isBind
[ bindSource, bindDest, mode ] = vol.split(':') [ bindSource, bindDest, mode ] = vol.split(':')
if !path.isAbsolute(bindSource) if !path.isAbsolute(bindSource)
@ -409,7 +386,7 @@ module.exports = class Service
getNamedVolumes: => getNamedVolumes: =>
defaults = @defaultBinds() defaults = @defaultBinds()
validVolumes = _.map @volumes, (vol) -> validVolumes = _.map @volumes, (vol) ->
if _.includes(defaults, vol) or !/:/.test(vol) if _.includes(defaults, vol) or !_.includes(vol, ':')
return null return null
bindSource = vol.split(':')[0] bindSource = vol.split(':')[0]
if !path.isAbsolute(bindSource) if !path.isAbsolute(bindSource)
@ -417,7 +394,7 @@ module.exports = class Service
return m[1] return m[1]
else else
return null return null
return _.filter(validVolumes, (v) -> !_.isNull(v)) return _.reject(validVolumes, _.isNil)
lockPath: => lockPath: =>
return updateLock.lockPath(@appId) return updateLock.lockPath(@appId)
@ -562,7 +539,7 @@ module.exports = class Service
binds = [] binds = []
volumes = {} volumes = {}
for vol in @volumes for vol in @volumes
isBind = /:/.test(vol) isBind = _.includes(vol, ':')
if isBind if isBind
binds.push(vol) binds.push(vol)
else else
@ -714,11 +691,11 @@ module.exports = class Service
# This can be very useful for debugging so I'm leaving it commented for now. # This can be very useful for debugging so I'm leaving it commented for now.
# Uncomment to see the services whenever they don't match. # Uncomment to see the services whenever they don't match.
#if !isEq if !isEq
# console.log(JSON.stringify(this, null, 2)) console.log(JSON.stringify(this, null, 2))
# console.log(JSON.stringify(otherService, null, 2)) console.log(JSON.stringify(otherService, null, 2))
# diff = _.omitBy this, (prop, k) -> _.isEqual(prop, otherService[k]) diff = _.omitBy this, (prop, k) -> _.isEqual(prop, otherService[k])
# console.log(JSON.stringify(diff, null, 2)) console.log(JSON.stringify(diff, null, 2))
return isEq return isEq

View File

@ -1,6 +1,5 @@
Promise = require 'bluebird' Promise = require 'bluebird'
_ = require 'lodash' _ = require 'lodash'
fs = Promise.promisifyAll(require('fs'))
path = require 'path' path = require 'path'
logTypes = require '../lib/log-types' logTypes = require '../lib/log-types'
@ -8,6 +7,7 @@ constants = require '../lib/constants'
{ checkInt } = require '../lib/validation' { checkInt } = require '../lib/validation'
{ NotFoundError } = require '../lib/errors' { NotFoundError } = require '../lib/errors'
{ defaultLegacyVolume } = require '../lib/migration' { defaultLegacyVolume } = require '../lib/migration'
{ safeRename } = require '../lib/fs-utils'
module.exports = class Volumes module.exports = class Volumes
constructor: ({ @docker, @logger }) -> constructor: ({ @docker, @logger }) ->
@ -31,18 +31,16 @@ module.exports = class Volumes
volumes = response.Volumes ? [] volumes = response.Volumes ? []
Promise.map volumes, (volume) => Promise.map volumes, (volume) =>
@docker.getVolume(volume.Name).inspect() @docker.getVolume(volume.Name).inspect()
.then (vol) => .then(@format)
@format(vol)
getAllByAppId: (appId) => getAllByAppId: (appId) =>
@getAll() @getAll()
.then (volumes) -> .then (volumes) ->
_.filter(volumes, (v) -> v.appId == appId) _.filter(volumes, { appId })
get: ({ name, appId }) -> get: ({ name, appId }) ->
@docker.getVolume("#{appId}_#{name}").inspect() @docker.getVolume("#{appId}_#{name}").inspect()
.then (volume) => .then(@format)
return @format(volume)
defaultLabels: -> defaultLabels: ->
return { return {
@ -67,25 +65,17 @@ module.exports = class Volumes
Labels: labels Labels: labels
DriverOpts: driverOpts DriverOpts: driverOpts
}) })
.catch (err) => .tapCatch (err) =>
@logger.logSystemEvent(logTypes.createVolumeError, { volume: { name }, error: err }) @logger.logSystemEvent(logTypes.createVolumeError, { volume: { name }, error: err })
throw err
createFromLegacy: (appId) => createFromLegacy: (appId) =>
name = defaultLegacyVolume() name = defaultLegacyVolume()
@create({ name, appId }) @create({ name, appId })
.then (v) -> .call('inspect')
v.inspect()
.then (v) -> .then (v) ->
volumePath = path.join(constants.rootMountPoint, v.Mountpoint) volumePath = path.join(constants.rootMountPoint, v.Mountpoint)
legacyPath = path.join(constants.rootMountPoint, constants.dataPath, appId.toString()) legacyPath = path.join(constants.rootMountPoint, constants.dataPath, appId.toString())
fs.renameAsync(legacyPath, volumePath) safeRename(legacyPath, volumePath)
.then ->
fs.openAsync(path.dirname(volumePath))
.then (parent) ->
fs.fsyncAsync(parent)
.then ->
fs.closeAsync(parent)
.catch (err) -> .catch (err) ->
@logger.logSystemMessage("Warning: could not migrate legacy /data volume: #{err.message}", { error: err }, 'Volume migration error') @logger.logSystemMessage("Warning: could not migrate legacy /data volume: #{err.message}", { error: err }, 'Volume migration error')

View File

@ -4,7 +4,7 @@ Lock = require 'rwlock'
deviceRegister = require 'resin-register-device' deviceRegister = require 'resin-register-device'
fs = Promise.promisifyAll(require('fs')) fs = Promise.promisifyAll(require('fs'))
EventEmitter = require 'events' EventEmitter = require 'events'
path = require 'path'
{ writeAndSyncFile, writeFileAtomic } = require './lib/fs-utils' { writeAndSyncFile, writeFileAtomic } = require './lib/fs-utils'
osRelease = require './lib/os-release' osRelease = require './lib/os-release'
supervisorVersion = require './lib/supervisor-version' supervisorVersion = require './lib/supervisor-version'
@ -12,10 +12,6 @@ constants = require './lib/constants'
module.exports = class Config extends EventEmitter module.exports = class Config extends EventEmitter
constructor: ({ @db, @configPath }) -> constructor: ({ @db, @configPath }) ->
# These are values that come from env vars or hardcoded defaults and can be resolved synchronously
# Defaults needed for both gosuper and node supervisor are declared in entry.sh
@constants = constants
@funcs = @funcs =
version: -> version: ->
Promise.resolve(supervisorVersion) Promise.resolve(supervisorVersion)
@ -40,18 +36,18 @@ module.exports = class Config extends EventEmitter
# Fall back to checking if an API endpoint was passed via env vars if there's none in config.json (legacy) # Fall back to checking if an API endpoint was passed via env vars if there's none in config.json (legacy)
@get('apiEndpoint') @get('apiEndpoint')
.then (apiEndpoint) -> .then (apiEndpoint) ->
return apiEndpoint ? @constants.apiEndpointFromEnv return apiEndpoint ? constants.apiEndpointFromEnv
provisioned: => provisioned: =>
@getMany([ 'uuid', 'resinApiEndpoint', 'registered_at', 'deviceId' ]) @getMany([ 'uuid', 'resinApiEndpoint', 'registered_at', 'deviceId' ])
.then (requiredValues) -> .then (requiredValues) ->
return _.every(_.values(requiredValues), Boolean) return _.every(_.values(requiredValues), Boolean)
osVersion: => osVersion: ->
osRelease.getOSVersion(@constants.hostOSVersionPath) osRelease.getOSVersion(constants.hostOSVersionPath)
osVariant: => osVariant: ->
osRelease.getOSVariant(@constants.hostOSVersionPath) osRelease.getOSVariant(constants.hostOSVersionPath)
provisioningOptions: => provisioningOptions: =>
@getMany([ @getMany([
@ -111,9 +107,9 @@ module.exports = class Config extends EventEmitter
registered_at: { source: 'config.json', mutable: true } registered_at: { source: 'config.json', mutable: true }
applicationId: { source: 'config.json' } applicationId: { source: 'config.json' }
appUpdatePollInterval: { source: 'config.json', mutable: true, default: 60000 } appUpdatePollInterval: { source: 'config.json', mutable: true, default: 60000 }
pubnubSubscribeKey: { source: 'config.json', default: @constants.defaultPubnubSubscribeKey } pubnubSubscribeKey: { source: 'config.json', default: constants.defaultPubnubSubscribeKey }
pubnubPublishKey: { source: 'config.json', default: @constants.defaultPubnubPublishKey } pubnubPublishKey: { source: 'config.json', default: constants.defaultPubnubPublishKey }
mixpanelToken: { source: 'config.json', default: @constants.defaultMixpanelToken } mixpanelToken: { source: 'config.json', default: constants.defaultMixpanelToken }
bootstrapRetryDelay: { source: 'config.json', default: 30000 } bootstrapRetryDelay: { source: 'config.json', default: 30000 }
supervisorOfflineMode: { source: 'config.json', default: false } supervisorOfflineMode: { source: 'config.json', default: false }
hostname: { source: 'config.json', mutable: true } hostname: { source: 'config.json', mutable: true }
@ -141,41 +137,41 @@ module.exports = class Config extends EventEmitter
loggingEnabled: { source: 'db', mutable: true, default: 'true' } loggingEnabled: { source: 'db', mutable: true, default: 'true' }
connectivityCheckEnabled: { source: 'db', mutable: true, default: 'true' } connectivityCheckEnabled: { source: 'db', mutable: true, default: 'true' }
delta: { source: 'db', mutable: true, default: 'false' } delta: { source: 'db', mutable: true, default: 'false' }
deltaRequestTimeout: { source: 'db', mutable: true, default: '' } deltaRequestTimeout: { source: 'db', mutable: true, default: '30000' }
deltaApplyTimeout: { source: 'db', mutable: true, default: '' } deltaApplyTimeout: { source: 'db', mutable: true, default: '' }
deltaRetryCount: { source: 'db', mutable: true, default: '' } deltaRetryCount: { source: 'db', mutable: true, default: '30' }
deltaRetryInterval: { source: 'db', mutable: true, default: '' } deltaRetryInterval: { source: 'db', mutable: true, default: '10000' }
lockOverride: { source: 'db', mutable: true, default: 'false' } lockOverride: { source: 'db', mutable: true, default: 'false' }
legacyAppsPresent: { source: 'db', mutable: true, default: 'false' } legacyAppsPresent: { source: 'db', mutable: true, default: 'false' }
} }
@configJsonCache = {} @configJsonCache = {}
@_lock = new Lock() _lock = new Lock()
@_writeLock = Promise.promisify(@_lock.async.writeLock) _writeLock = Promise.promisify(_lock.async.writeLock)
@writeLockConfigJson = => @writeLockConfigJson = ->
@_writeLock('config.json') _writeLock('config.json')
.disposer (release) -> .disposer (release) ->
release() release()
@_readLock = Promise.promisify(@_lock.async.readLock) _readLock = Promise.promisify(_lock.async.readLock)
@readLockConfigJson = => @readLockConfigJson = ->
@_readLock('config.json') _readLock('config.json')
.disposer (release) -> .disposer (release) ->
release() release()
writeConfigJson: => writeConfigJson: =>
atomicWritePossible = true atomicWritePossible = true
@configJsonPathOnHost() @configJsonPathOnHost()
.catch (err) => .catch (err) ->
console.error(err.message) console.error(err.message)
atomicWritePossible = false atomicWritePossible = false
return @constants.configJsonNonAtomicPath return constants.configJsonNonAtomicPath
.then (path) => .then (configPath) =>
if atomicWritePossible if atomicWritePossible
writeFileAtomic(path, JSON.stringify(@configJsonCache)) writeFileAtomic(configPath, JSON.stringify(@configJsonCache))
else else
writeAndSyncFile(path, JSON.stringify(@configJsonCache)) writeAndSyncFile(configPath, JSON.stringify(@configJsonCache))
configJsonSet: (keyVals) => configJsonSet: (keyVals) =>
@ -195,48 +191,46 @@ module.exports = class Config extends EventEmitter
configJsonRemove: (key) => configJsonRemove: (key) =>
changed = false changed = false
Promise.using @writeLockConfigJson(), => Promise.using @writeLockConfigJson(), =>
Promise.try => if @configJsonCache[key]?
if @configJsonCache[key]? delete @configJsonCache[key]
delete @configJsonCache[key] changed = true
changed = true if changed
.then => @writeConfigJson()
if changed
@writeConfigJson()
configJsonPathOnHost: => configJsonPathOnHost: =>
Promise.try => Promise.try =>
if @configPath? if @configPath?
return @configPath return @configPath
if @constants.configJsonPathOnHost? if constants.configJsonPathOnHost?
return @constants.configJsonPathOnHost return constants.configJsonPathOnHost
osRelease.getOSVersion(@constants.hostOSVersionPath) osRelease.getOSVersion(constants.hostOSVersionPath)
.then (osVersion) => .then (osVersion) ->
if /^Resin OS 2./.test(osVersion) if /^Resin OS 2./.test(osVersion)
return "#{@constants.bootMountPointFromEnv}/config.json" return path.join(constants.bootMountPointFromEnv, 'config.json')
else if /^Resin OS 1./.test(osVersion) else if /^Resin OS 1./.test(osVersion)
# In Resin OS 1.12, $BOOT_MOUNTPOINT was added and it coincides with config.json's path # In Resin OS 1.12, $BOOT_MOUNTPOINT was added and it coincides with config.json's path
if @constants.bootMountPointFromEnv if constants.bootMountPointFromEnv
return "#{@constants.bootMountPointFromEnv}/config.json" return path.join(constants.bootMountPointFromEnv, 'config.json')
# Older 1.X versions have config.json here # Older 1.X versions have config.json here
return '/mnt/conf/config.json' return '/mnt/conf/config.json'
else else
# In non-resinOS hosts (or older than 1.0.0), if CONFIG_JSON_PATH wasn't passed then we can't do atomic changes # In non-resinOS hosts (or older than 1.0.0), if CONFIG_JSON_PATH wasn't passed then we can't do atomic changes
# (only access to config.json we have is in /boot, which is assumed to be a file bind mount where rename is impossible) # (only access to config.json we have is in /boot, which is assumed to be a file bind mount where rename is impossible)
throw new Error('Could not determine config.json path on host, atomic write will not be possible') throw new Error('Could not determine config.json path on host, atomic write will not be possible')
.then (path) => .then (pathOnHost) ->
return "#{@constants.rootMountPoint}#{path}" return path.join(constants.rootMountPoint, pathOnHost)
configJsonPath: => configJsonPath: =>
@configJsonPathOnHost() @configJsonPathOnHost()
.catch (err) => .catch (err) ->
console.error(err.message) console.error(err.message)
return @constants.configJsonNonAtomicPath return constants.configJsonNonAtomicPath
readConfigJson: => readConfigJson: =>
@configJsonPath() @configJsonPath()
.then (path) -> .then (configPath) ->
fs.readFileAsync(path) fs.readFileAsync(configPath)
.then(JSON.parse) .then(JSON.parse)
newUniqueKey: -> newUniqueKey: ->
deviceRegister.generateUniqueKey() deviceRegister.generateUniqueKey()
@ -282,7 +276,7 @@ module.exports = class Config extends EventEmitter
getMany: (keys, trx) => getMany: (keys, trx) =>
# Get the values for several keys in an array # Get the values for several keys in an array
Promise.all(_.map(keys, (key) => @get(key, trx) )) Promise.map(keys, (key) => @get(key, trx))
.then (values) -> .then (values) ->
out = {} out = {}
for key, i in keys for key, i in keys

View File

@ -44,10 +44,10 @@ module.exports = class DeviceConfig
connectivityCheckEnabled: { envVarName: 'RESIN_SUPERVISOR_CONNECTIVITY_CHECK', varType: 'bool', defaultValue: 'true' } connectivityCheckEnabled: { envVarName: 'RESIN_SUPERVISOR_CONNECTIVITY_CHECK', varType: 'bool', defaultValue: 'true' }
loggingEnabled: { envVarName: 'RESIN_SUPERVISOR_LOG_CONTROL', varType: 'bool', defaultValue: 'true' } loggingEnabled: { envVarName: 'RESIN_SUPERVISOR_LOG_CONTROL', varType: 'bool', defaultValue: 'true' }
delta: { envVarName: 'RESIN_SUPERVISOR_DELTA', varType: 'bool', defaultValue: 'false' } delta: { envVarName: 'RESIN_SUPERVISOR_DELTA', varType: 'bool', defaultValue: 'false' }
deltaRequestTimeout: { envVarName: 'RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT', varType: 'int' } deltaRequestTimeout: { envVarName: 'RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT', varType: 'int', defaultValue: '30000' }
deltaApplyTimeout: { envVarName: 'RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT', varType: 'int' } deltaApplyTimeout: { envVarName: 'RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT', varType: 'int' }
deltaRetryCount: { envVarName: 'RESIN_SUPERVISOR_DELTA_RETRY_COUNT', varType: 'int' } deltaRetryCount: { envVarName: 'RESIN_SUPERVISOR_DELTA_RETRY_COUNT', varType: 'int', defaultValue: '30' }
deltaRetryInterval: { envVarName: 'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL', varType: 'int' } deltaRetryInterval: { envVarName: 'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL', varType: 'int', defaultValue: '10000' }
lockOverride: { envVarName: 'RESIN_SUPERVISOR_OVERRIDE_LOCK', varType: 'bool', defaultValue: 'false' } lockOverride: { envVarName: 'RESIN_SUPERVISOR_OVERRIDE_LOCK', varType: 'bool', defaultValue: 'false' }
} }
@validKeys = [ 'RESIN_HOST_LOG_TO_DISPLAY', 'RESIN_SUPERVISOR_VPN_CONTROL', 'RESIN_OVERRIDE_LOCK' ].concat(_.map(@configKeys, 'envVarName')) @validKeys = [ 'RESIN_HOST_LOG_TO_DISPLAY', 'RESIN_SUPERVISOR_VPN_CONTROL', 'RESIN_OVERRIDE_LOCK' ].concat(_.map(@configKeys, 'envVarName'))
@ -57,18 +57,16 @@ module.exports = class DeviceConfig
@config.set(step.target) @config.set(step.target)
.then => .then =>
@logger.logConfigChange(step.humanReadableTarget, { success: true }) @logger.logConfigChange(step.humanReadableTarget, { success: true })
.catch (err) => .tapCatch (err) =>
@logger.logConfigChange(step.humanReadableTarget, { err }) @logger.logConfigChange(step.humanReadableTarget, { err })
throw err
setLogToDisplay: (step) => setLogToDisplay: (step) =>
logValue = { RESIN_HOST_LOG_TO_DISPLAY: step.target } logValue = { RESIN_HOST_LOG_TO_DISPLAY: step.target }
@logger.logConfigChange(logValue) @logger.logConfigChange(logValue)
@setLogToDisplay(step.target) @setLogToDisplay(step.target)
.then => .then =>
@logger.logConfigChange(logValue, { success: true }) @logger.logConfigChange(logValue, { success: true })
.catch (err) => .tapCatch (err) =>
@logger.logConfigChange(logValue, { err }) @logger.logConfigChange(logValue, { err })
throw err
setVPNEnabled: (step, { initial = false } = {}) => setVPNEnabled: (step, { initial = false } = {}) =>
logValue = { RESIN_SUPERVISOR_VPN_CONTROL: step.target } logValue = { RESIN_SUPERVISOR_VPN_CONTROL: step.target }
if !initial if !initial
@ -77,9 +75,8 @@ module.exports = class DeviceConfig
.then => .then =>
if !initial if !initial
@logger.logConfigChange(logValue, { success: true }) @logger.logConfigChange(logValue, { success: true })
.catch (err) => .tapCatch (err) =>
@logger.logConfigChange(logValue, { err }) @logger.logConfigChange(logValue, { err })
throw err
setBootConfig: (step) => setBootConfig: (step) =>
@config.get('deviceType') @config.get('deviceType')
.then (deviceType) => .then (deviceType) =>
@ -138,7 +135,7 @@ module.exports = class DeviceConfig
@logger.logSystemMessage(err, { error: err }, 'Apply boot config error') @logger.logSystemMessage(err, { error: err }, 'Apply boot config error')
throw new Error(err) throw new Error(err)
return true return true
else return false return false
getRequiredSteps: (currentState, targetState) => getRequiredSteps: (currentState, targetState) =>
current = _.clone(currentState.local?.config ? {}) current = _.clone(currentState.local?.config ? {})
@ -191,8 +188,7 @@ module.exports = class DeviceConfig
action: 'reboot' action: 'reboot'
}) })
return return
.then -> .return(steps)
return steps
executeStepAction: (step, opts) => executeStepAction: (step, opts) =>
@actionExecutors[step.action](step, opts) @actionExecutors[step.action](step, opts)
@ -251,7 +247,7 @@ module.exports = class DeviceConfig
gosuper.get('/v1/log-to-display', { json: true }) gosuper.get('/v1/log-to-display', { json: true })
.spread (res, body) -> .spread (res, body) ->
if res.statusCode == 404 if res.statusCode == 404
return undefined return
if res.statusCode != 200 if res.statusCode != 200
throw new Error("Error getting log to display status: #{res.statusCode} #{body.Error}") throw new Error("Error getting log to display status: #{res.statusCode} #{body.Error}")
return Boolean(body.Data) return Boolean(body.Data)
@ -293,9 +289,8 @@ module.exports = class DeviceConfig
@logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success') @logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success')
@rebootRequired = true @rebootRequired = true
return true return true
.catch (err) => .tapCatch (err) =>
@logger.logSystemMessage("Error setting boot config: #{err}", { error: err }, 'Apply boot config error') @logger.logSystemMessage("Error setting boot config: #{err}", { error: err }, 'Apply boot config error')
throw err
getVPNEnabled: -> getVPNEnabled: ->
gosuper.get('/v1/vpncontrol', { json: true }) gosuper.get('/v1/vpncontrol', { json: true })
@ -304,9 +299,8 @@ module.exports = class DeviceConfig
throw new Error("Error getting vpn status: #{res.statusCode} #{body.Error}") throw new Error("Error getting vpn status: #{res.statusCode} #{body.Error}")
@gosuperHealthy = true @gosuperHealthy = true
return Boolean(body.Data) return Boolean(body.Data)
.catch (err) => .tapCatch (err) =>
@gosuperHealthy = false @gosuperHealthy = false
throw err
setVPNEnabled: (val) -> setVPNEnabled: (val) ->
enable = checkTruthy(val) ? true enable = checkTruthy(val) ? true

View File

@ -41,78 +41,77 @@ validateState = Promise.method (state) ->
if state.dependent? if state.dependent?
validateDependentState(state.dependent) validateDependentState(state.dependent)
class DeviceStateRouter createDeviceStateRouter = (deviceState) ->
constructor: (@deviceState) -> router = express.Router()
{ @applications, @config } = @deviceState router.use(bodyParser.urlencoded(extended: true))
@router = express.Router() router.use(bodyParser.json())
@router.use(bodyParser.urlencoded(extended: true))
@router.use(bodyParser.json())
@router.post '/v1/reboot', (req, res) => router.post '/v1/reboot', (req, res) ->
force = validation.checkTruthy(req.body.force) force = validation.checkTruthy(req.body.force)
@deviceState.executeStepAction({ action: 'reboot' }, { force }) deviceState.executeStepAction({ action: 'reboot' }, { force })
.then (response) -> .then (response) ->
res.status(202).json(response) res.status(202).json(response)
.catch (err) -> .catch (err) ->
if err instanceof updateLock.UpdatesLockedError if err instanceof updateLock.UpdatesLockedError
status = 423 status = 423
else else
status = 500 status = 500
res.status(status).json({ Data: '', Error: err?.message or err or 'Unknown error' }) res.status(status).json({ Data: '', Error: err?.message or err or 'Unknown error' })
@router.post '/v1/shutdown', (req, res) => router.post '/v1/shutdown', (req, res) ->
force = validation.checkTruthy(req.body.force) force = validation.checkTruthy(req.body.force)
@deviceState.executeStepAction({ action: 'shutdown' }, { force }) deviceState.executeStepAction({ action: 'shutdown' }, { force })
.then (response) -> .then (response) ->
res.status(202).json(response) res.status(202).json(response)
.catch (err) -> .catch (err) ->
if err instanceof updateLock.UpdatesLockedError if err instanceof updateLock.UpdatesLockedError
status = 423 status = 423
else else
status = 500 status = 500
res.status(status).json({ Data: '', Error: err?.message or err or 'Unknown error' }) res.status(status).json({ Data: '', Error: err?.message or err or 'Unknown error' })
@router.get '/v1/device/host-config', (req, res) -> router.get '/v1/device/host-config', (req, res) ->
hostConfig.get() hostConfig.get()
.then (conf) -> .then (conf) ->
res.json(conf) res.json(conf)
.catch (err) -> .catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error') res.status(503).send(err?.message or err or 'Unknown error')
@router.patch '/v1/device/host-config', (req, res) => router.patch '/v1/device/host-config', (req, res) ->
hostConfig.patch(req.body, @config) hostConfig.patch(req.body, deviceState.config)
.then -> .then ->
res.status(200).send('OK') res.status(200).send('OK')
.catch (err) -> .catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error') res.status(503).send(err?.message or err or 'Unknown error')
@router.get '/v1/device', (req, res) => router.get '/v1/device', (req, res) ->
@deviceState.getStatus() deviceState.getStatus()
.then (state) -> .then (state) ->
stateToSend = _.pick(state.local, [ stateToSend = _.pick(state.local, [
'api_port' 'api_port'
'ip_address' 'ip_address'
'os_version' 'os_version'
'supervisor_version' 'supervisor_version'
'update_pending' 'update_pending'
'update_failed' 'update_failed'
'update_downloaded' 'update_downloaded'
]) ])
if state.local.is_on__commit? if state.local.is_on__commit?
stateToSend.commit = state.local.is_on__commit stateToSend.commit = state.local.is_on__commit
# Will produce nonsensical results for multicontainer apps... # Will produce nonsensical results for multicontainer apps...
service = _.toPairs(_.toPairs(state.local.apps)[0]?[1]?.services)[0]?[1] service = _.toPairs(_.toPairs(state.local.apps)[0]?[1]?.services)[0]?[1]
if service? if service?
stateToSend.status = service.status stateToSend.status = service.status
# For backwards compatibility, we adapt Running to the old "Idle" # For backwards compatibility, we adapt Running to the old "Idle"
if stateToSend.status == 'Running' if stateToSend.status == 'Running'
stateToSend.status = 'Idle' stateToSend.status = 'Idle'
stateToSend.download_progress = service.download_progress stateToSend.download_progress = service.download_progress
res.json(stateToSend) res.json(stateToSend)
.catch (err) -> .catch (err) ->
res.status(500).json({ Data: '', Error: err?.message or err or 'Unknown error' }) res.status(500).json({ Data: '', Error: err?.message or err or 'Unknown error' })
@router.use(@applications.router) router.use(deviceState.applications.router)
return router
module.exports = class DeviceState extends EventEmitter module.exports = class DeviceState extends EventEmitter
constructor: ({ @db, @config, @eventTracker }) -> constructor: ({ @db, @config, @eventTracker }) ->
@ -131,27 +130,20 @@ module.exports = class DeviceState extends EventEmitter
@lastApplyStart = process.hrtime() @lastApplyStart = process.hrtime()
@scheduledApply = null @scheduledApply = null
@shuttingDown = false @shuttingDown = false
@_router = new DeviceStateRouter(this) @router = createDeviceStateRouter(this)
@router = @_router.router
@on 'apply-target-state-end', (err) -> @on 'apply-target-state-end', (err) ->
if err? if err?
console.log("Apply error #{err}") console.log("Apply error #{err}")
else else
console.log('Apply success!') console.log('Apply success!')
#@on 'step-completed', (err) ->
# if err?
# console.log("Step completed with error #{err}")
# else
# console.log('Step success!')
#@on 'step-error', (err) ->
# console.log("Step error #{err}")
@applications.on('change', @reportCurrentState) @applications.on('change', @reportCurrentState)
healthcheck: => healthcheck: =>
@config.getMany([ 'appUpdatePollInterval', 'offlineMode' ]) @config.getMany([ 'appUpdatePollInterval', 'offlineMode' ])
.then (conf) => .then (conf) =>
cycleTimeWithinInterval = process.hrtime(@lastApplyStart)[0] - @applications.timeSpentFetching < 2 * conf.appUpdatePollInterval cycleTime = process.hrtime(@lastApplyStart)
cycleTimeMs = cycleTime[0] * 1000 + cycleTime[1] / 1e6
cycleTimeWithinInterval = cycleTimeMs - @applications.timeSpentFetching < 2 * conf.appUpdatePollInterval
applyTargetHealthy = conf.offlineMode or !@applyInProgress or @applications.fetchesInProgress > 0 or cycleTimeWithinInterval applyTargetHealthy = conf.offlineMode or !@applyInProgress or @applications.fetchesInProgress > 0 or cycleTimeWithinInterval
return applyTargetHealthy and @deviceConfig.gosuperHealthy return applyTargetHealthy and @deviceConfig.gosuperHealthy
@ -228,7 +220,7 @@ module.exports = class DeviceState extends EventEmitter
@reportCurrentState( @reportCurrentState(
ip_address: addresses.join(' ') ip_address: addresses.join(' ')
) )
, @config.constants.ipAddressUpdateInterval , constants.ipAddressUpdateInterval
saveInitialConfig: => saveInitialConfig: =>
@deviceConfig.getCurrent() @deviceConfig.getCurrent()
@ -331,7 +323,7 @@ module.exports = class DeviceState extends EventEmitter
if _.isArray(stateFromFile) if _.isArray(stateFromFile)
# This is a legacy apps.json # This is a legacy apps.json
stateFromFile = @_convertLegacyAppsJson(stateFromFile) stateFromFile = @_convertLegacyAppsJson(stateFromFile)
images = _.flatten(_.map(stateFromFile.apps, (app, appId) => images = _.flatMap stateFromFile.apps, (app, appId) =>
_.map app.services, (service, serviceId) => _.map app.services, (service, serviceId) =>
svc = { svc = {
imageName: service.image imageName: service.image
@ -342,7 +334,6 @@ module.exports = class DeviceState extends EventEmitter
appId appId
} }
return @applications.imageForService(svc) return @applications.imageForService(svc)
))
Promise.map images, (img) => Promise.map images, (img) =>
@applications.images.normalise(img.name) @applications.images.normalise(img.name)
.then (name) => .then (name) =>
@ -350,12 +341,12 @@ module.exports = class DeviceState extends EventEmitter
@applications.images.save(img) @applications.images.save(img)
.then => .then =>
@deviceConfig.getCurrent() @deviceConfig.getCurrent()
.then (deviceConf) => .then (deviceConf) =>
_.defaults(stateFromFile.config, deviceConf) _.defaults(stateFromFile.config, deviceConf)
stateFromFile.name ?= '' stateFromFile.name ?= ''
@setTarget({ @setTarget({
local: stateFromFile local: stateFromFile
}) })
.catch (err) => .catch (err) =>
@eventTracker.track('Loading preloaded apps failed', { error: err }) @eventTracker.track('Loading preloaded apps failed', { error: err })
@ -364,17 +355,18 @@ module.exports = class DeviceState extends EventEmitter
.then => .then =>
@logger.logSystemMessage('Rebooting', {}, 'Reboot') @logger.logSystemMessage('Rebooting', {}, 'Reboot')
device.reboot() device.reboot()
.tap => .tap =>
@emit('shutdown') @shuttingDown = true
@emitAsync('shutdown')
shutdown: (force, skipLock) => shutdown: (force, skipLock) =>
@applications.stopAll({ force, skipLock }) @applications.stopAll({ force, skipLock })
.then => .then =>
@logger.logSystemMessage('Shutting down', {}, 'Shutdown') @logger.logSystemMessage('Shutting down', {}, 'Shutdown')
device.shutdown() device.shutdown()
.tap => .tap =>
@shuttingDown = true @shuttingDown = true
@emitAsync('shutdown') @emitAsync('shutdown')
executeStepAction: (step, { force, initial, skipLock }) => executeStepAction: (step, { force, initial, skipLock }) =>
Promise.try => Promise.try =>
@ -397,27 +389,25 @@ module.exports = class DeviceState extends EventEmitter
if @shuttingDown if @shuttingDown
return return
@executeStepAction(step, { force, initial, skipLock }) @executeStepAction(step, { force, initial, skipLock })
.catch (err) => .tapCatch (err) =>
@emitAsync('step-error', err, step) @emitAsync('step-error', err, step)
throw err
.then (stepResult) => .then (stepResult) =>
@emitAsync('step-completed', null, step, stepResult) @emitAsync('step-completed', null, step, stepResult)
applyError: (err, { force, initial, intermediate }) => applyError: (err, { force, initial, intermediate }) =>
@emitAsync('apply-target-state-error', err) @emitAsync('apply-target-state-error', err)
@emitAsync('apply-target-state-end', err) @emitAsync('apply-target-state-end', err)
if !intermediate if intermediate
@failedUpdates += 1
@reportCurrentState(update_failed: true)
if @scheduledApply?
console.log("Updating failed, but there's another update scheduled immediately: ", err)
else
delay = Math.min((2 ** @failedUpdates) * 500, 30000)
# If there was an error then schedule another attempt briefly in the future.
console.log('Scheduling another update attempt due to failure: ', delay, err)
@triggerApplyTarget({ force, delay, initial })
else
throw err throw err
@failedUpdates += 1
@reportCurrentState(update_failed: true)
if @scheduledApply?
console.log("Updating failed, but there's another update scheduled immediately: ", err)
else
delay = Math.min((2 ** @failedUpdates) * 500, 30000)
# If there was an error then schedule another attempt briefly in the future.
console.log('Scheduling another update attempt due to failure: ', delay, err)
@triggerApplyTarget({ force, delay, initial })
applyTarget: ({ force = false, initial = false, intermediate = false, skipLock = false } = {}) => applyTarget: ({ force = false, initial = false, intermediate = false, skipLock = false } = {}) =>
nextDelay = 200 nextDelay = 200
@ -453,8 +443,7 @@ module.exports = class DeviceState extends EventEmitter
nextDelay = 1000 nextDelay = 1000
Promise.map steps, (step) => Promise.map steps, (step) =>
@applyStep(step, { force, initial, intermediate, skipLock }) @applyStep(step, { force, initial, intermediate, skipLock })
.then -> .delay(nextDelay)
Promise.delay(nextDelay)
.then => .then =>
@applyTarget({ force, initial, intermediate, skipLock }) @applyTarget({ force, initial, intermediate, skipLock })
.catch (err) => .catch (err) =>
@ -497,13 +486,13 @@ module.exports = class DeviceState extends EventEmitter
@lastApplyStart = process.hrtime() @lastApplyStart = process.hrtime()
console.log('Applying target state') console.log('Applying target state')
@applyTarget({ force, initial }) @applyTarget({ force, initial })
.finally => .finally =>
@applyInProgress = false @applyInProgress = false
@reportCurrentState() @reportCurrentState()
if @scheduledApply? if @scheduledApply?
@triggerApplyTarget(@scheduledApply) @triggerApplyTarget(@scheduledApply)
@scheduledApply = null @scheduledApply = null
return return null
applyIntermediateTarget: (intermediateTarget, { force = false, skipLock = false } = {}) => applyIntermediateTarget: (intermediateTarget, { force = false, skipLock = false } = {}) =>
@intermediateTarget = _.cloneDeep(intermediateTarget) @intermediateTarget = _.cloneDeep(intermediateTarget)

View File

@ -27,6 +27,7 @@ module.exports = class EventTracker
if properties instanceof Error if properties instanceof Error
properties = error: properties properties = error: properties
properties = _.cloneDeep(properties)
# If the properties has an error argument that is an Error object then it treats it nicely, # If the properties has an error argument that is an Error object then it treats it nicely,
# rather than letting it become `{}` # rather than letting it become `{}`
if properties.error instanceof Error if properties.error instanceof Error
@ -34,8 +35,6 @@ module.exports = class EventTracker
message: properties.error.message message: properties.error.message
stack: properties.error.stack stack: properties.error.stack
properties = _.cloneDeep(properties)
# Don't log private env vars (e.g. api keys) or other secrets - use a whitelist to mask what we send # Don't log private env vars (e.g. api keys) or other secrets - use a whitelist to mask what we send
properties = mask(properties, mixpanelMask) properties = mask(properties, mixpanelMask)
@_logEvent('Event:', ev, JSON.stringify(properties)) @_logEvent('Event:', ev, JSON.stringify(properties))

View File

@ -37,3 +37,5 @@ module.exports =
ipAddressUpdateInterval: 30 * 1000 ipAddressUpdateInterval: 30 * 1000
imageCleanupErrorIgnoreTimeout: 3600 * 1000 imageCleanupErrorIgnoreTimeout: 3600 * 1000
maxDeltaDownloads: 3 maxDeltaDownloads: 3
process.env.DOCKER_HOST ?= "unix://#{module.exports.dockerSocket}"

View File

@ -4,6 +4,5 @@ exports.envArrayToObject = (env) ->
# env is an array of strings that say 'key=value' # env is an array of strings that say 'key=value'
toPair = (keyVal) -> toPair = (keyVal) ->
m = keyVal.match(/^([^=]+)=(.*)$/) m = keyVal.match(/^([^=]+)=(.*)$/)
[ _unused, key, val ] = m return m[1..]
return [ key, val ]
_.fromPairs(_.map(env, toPair)) _.fromPairs(_.map(env, toPair))

View File

@ -1,15 +1,14 @@
gosuper = require './gosuper' gosuper = require './gosuper'
exports.reboot = -> gosuperAction = (action) ->
gosuper.post('/v1/reboot', { json: true }) gosuper.post("/v1/#{action}", { json: true })
.spread (res, body) -> .spread (res, body) ->
if res.statusCode != 202 if res.statusCode != 202
throw new Error(body.Error) throw new Error(body.Error)
return body return body
exports.reboot = ->
gosuperAction('reboot')
exports.shutdown = -> exports.shutdown = ->
gosuper.post('/v1/shutdown', { json: true }) gosuperAction('shutdown')
.spread (res, body) ->
if res.statusCode != 202
throw new Error(body.Error)
return body

View File

@ -31,8 +31,8 @@ module.exports = class DockerUtils extends DockerToolbelt
super(opts) super(opts)
@dockerProgress = new DockerProgress(dockerToolbelt: this) @dockerProgress = new DockerProgress(dockerToolbelt: this)
@supervisorTagPromise = @normaliseImageName(constants.supervisorImage) @supervisorTagPromise = @normaliseImageName(constants.supervisorImage)
@InvalidNetGatewayError = class InvalidNetGatewayError extends TypedError
return this InvalidNetGatewayError: class InvalidNetGatewayError extends TypedError
getRepoAndTag: (image) => getRepoAndTag: (image) =>
@getRegistryAndName(image) @getRegistryAndName(image)
@ -71,14 +71,14 @@ module.exports = class DockerUtils extends DockerToolbelt
url = "#{tokenEndpoint}?service=#{dstInfo.registry}&scope=repository:#{dstInfo.imageName}:pull&scope=repository:#{srcInfo.imageName}:pull" url = "#{tokenEndpoint}?service=#{dstInfo.registry}&scope=repository:#{dstInfo.imageName}:pull&scope=repository:#{srcInfo.imageName}:pull"
request.getAsync(url, opts) request.getAsync(url, opts)
.get(1) .get(1)
.then (b) -> .then (responseBody) ->
opts = opts =
followRedirect: false followRedirect: false
timeout: requestTimeout timeout: requestTimeout
if b?.token? if responseBody?.token?
opts.auth = opts.auth =
bearer: b.token bearer: responseBody.token
sendImmediately: true sendImmediately: true
new Promise (resolve, reject) -> new Promise (resolve, reject) ->
request.get("#{deltaEndpoint}/api/v2/delta?src=#{deltaSource}&dest=#{imgDest}", opts) request.get("#{deltaEndpoint}/api/v2/delta?src=#{deltaSource}&dest=#{imgDest}", opts)
@ -116,8 +116,7 @@ module.exports = class DockerUtils extends DockerToolbelt
getImageEnv: (id) -> getImageEnv: (id) ->
@getImage(id).inspect() @getImage(id).inspect()
.get('Config').get('Env') .get('Config').get('Env')
.then (env) -> .then(envArrayToObject)
envArrayToObject(env)
.catch (err) -> .catch (err) ->
console.log('Error getting env from image', err, err.stack) console.log('Error getting env from image', err, err.stack)
return {} return {}

View File

@ -2,3 +2,4 @@
exports.NotFoundError = (err) -> checkInt(err.statusCode) is 404 exports.NotFoundError = (err) -> checkInt(err.statusCode) is 404
exports.ENOENT = (err) -> err.code is 'ENOENT' exports.ENOENT = (err) -> err.code is 'ENOENT'
exports.EEXIST = (err) -> err.code is 'EEXIST'

View File

@ -1,5 +1,6 @@
Promise = require 'bluebird' Promise = require 'bluebird'
fs = Promise.promisifyAll(require('fs')) fs = Promise.promisifyAll(require('fs'))
path = require 'path'
exports.writeAndSyncFile = (path, data) -> exports.writeAndSyncFile = (path, data) ->
fs.openAsync(path, 'w') fs.openAsync(path, 'w')
@ -14,3 +15,10 @@ exports.writeFileAtomic = (path, data) ->
exports.writeAndSyncFile("#{path}.new", data) exports.writeAndSyncFile("#{path}.new", data)
.then -> .then ->
fs.renameAsync("#{path}.new", path) fs.renameAsync("#{path}.new", path)
exports.safeRename = (src, dest) ->
fs.renameAsync(src, dest)
.then ->
fs.openAsync(path.dirname(dest))
.tap(fs.fsyncAsync)
.then(fs.closeAsync)

View File

@ -3,13 +3,13 @@ childProcess = Promise.promisifyAll(require('child_process'))
clearAndAppendIptablesRule = (rule) -> clearAndAppendIptablesRule = (rule) ->
childProcess.execAsync("iptables -D #{rule}") childProcess.execAsync("iptables -D #{rule}")
.catch(->) .catchReturn()
.then -> .then ->
childProcess.execAsync("iptables -A #{rule}") childProcess.execAsync("iptables -A #{rule}")
clearAndInsertIptablesRule = (rule) -> clearAndInsertIptablesRule = (rule) ->
childProcess.execAsync("iptables -D #{rule}") childProcess.execAsync("iptables -D #{rule}")
.catch(->) .catchReturn()
.then -> .then ->
childProcess.execAsync("iptables -I #{rule}") childProcess.execAsync("iptables -I #{rule}")

View File

@ -19,16 +19,9 @@ exports.singleToMulticontainerApp = (app) ->
} }
defaultVolume = exports.defaultLegacyVolume() defaultVolume = exports.defaultLegacyVolume()
newApp.volumes[defaultVolume] = {} newApp.volumes[defaultVolume] = {}
updateStrategy = conf['RESIN_SUPERVISOR_UPDATE_STRATEGY'] updateStrategy = conf['RESIN_SUPERVISOR_UPDATE_STRATEGY'] ? 'download-then-kill'
if !updateStrategy? handoverTimeout = conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT'] ? ''
updateStrategy = 'download-then-kill' restartPolicy = conf['RESIN_APP_RESTART_POLICY'] ? 'always'
handoverTimeout = conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT']
if !handoverTimeout?
handoverTimeout = ''
restartPolicy = conf['RESIN_APP_RESTART_POLICY']
if !restartPolicy?
restartPolicy = 'always'
newApp.services = { newApp.services = {
'1': { '1': {
appId: appId appId: appId

View File

@ -39,25 +39,23 @@ exports.lock = do ->
Promise.map _.clone(locksTaken), (lockName) -> Promise.map _.clone(locksTaken), (lockName) ->
_.pull(locksTaken, lockName) _.pull(locksTaken, lockName)
lockFile.unlockAsync(lockName) lockFile.unlockAsync(lockName)
.finally -> .finally(release)
release()
_writeLock(appId) _writeLock(appId)
.tap (release) -> .tap (release) ->
theLockDir = path.join(constants.rootMountPoint, baseLockPath(appId)) theLockDir = path.join(constants.rootMountPoint, baseLockPath(appId))
fs.readdirAsync(theLockDir) fs.readdirAsync(theLockDir)
.catch ENOENT, -> [] .catchReturn(ENOENT, [])
.mapSeries (serviceName) -> .mapSeries (serviceName) ->
tmpLockName = lockFileOnHost(appId, serviceName) tmpLockName = lockFileOnHost(appId, serviceName)
Promise.try -> Promise.try ->
lockFile.unlockAsync(tmpLockName) if force == true lockFile.unlockAsync(tmpLockName) if force == true
.then -> .then ->
lockFile.lockAsync(tmpLockName) lockFile.lockAsync(tmpLockName)
.then -> .then ->
locksTaken.push(tmpLockName) locksTaken.push(tmpLockName)
.catch ENOENT, _.noop .catchReturn(ENOENT, null)
.catch (err) -> .catch (err) ->
dispose(release) dispose(release)
.finally -> .throw(new exports.UpdatesLockedError("Updates are locked: #{err.message}"))
throw new exports.UpdatesLockedError("Updates are locked: #{err.message}")
.disposer(dispose) .disposer(dispose)
Promise.using takeTheLock(), -> fn() Promise.using takeTheLock(), -> fn()

View File

@ -14,7 +14,7 @@ exports.checkInt = checkInt = (s, options = {}) ->
exports.checkString = (s) -> exports.checkString = (s) ->
# Make sure `s` exists and is not an empty string, or 'null' or 'undefined'. # Make sure `s` exists and is not an empty string, or 'null' or 'undefined'.
# This might happen if the parsing of config.json on the host using jq is wrong (it is buggy in some versions). # This might happen if the parsing of config.json on the host using jq is wrong (it is buggy in some versions).
if !s? or !_.isString(s) or s == 'null' or s == 'undefined' or s == '' if !s? or !_.isString(s) or s in [ 'null', 'undefined', '' ]
return return
return s return s
@ -28,11 +28,11 @@ exports.isValidShortText = isValidShortText = (t) ->
_.isString(t) and t.length <= 255 _.isString(t) and t.length <= 255
exports.isValidEnv = isValidEnv = (obj) -> exports.isValidEnv = isValidEnv = (obj) ->
_.isObject(obj) and _.every obj, (val, key) -> _.isObject(obj) and _.every obj, (val, key) ->
isValidShortText(key) and /^[a-zA-Z_]+[a-zA-Z0-9_]*$/.test(key) and _.isString(val) isValidShortText(key) and /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key) and _.isString(val)
exports.isValidLabelsObject = isValidLabelsObject = (obj) -> exports.isValidLabelsObject = isValidLabelsObject = (obj) ->
_.isObject(obj) and _.every obj, (val, key) -> _.isObject(obj) and _.every obj, (val, key) ->
isValidShortText(key) and /^[a-zA-Z_]+[a-zA-Z0-9\.\-]*$/.test(key) and _.isString(val) isValidShortText(key) and /^[a-zA-Z][a-zA-Z0-9\.\-]*$/.test(key) and _.isString(val)
undefinedOrValidEnv = (val) -> undefinedOrValidEnv = (val) ->
if val? and !isValidEnv(val) if val? and !isValidEnv(val)
@ -41,39 +41,54 @@ undefinedOrValidEnv = (val) ->
exports.isValidDependentAppsObject = (apps) -> exports.isValidDependentAppsObject = (apps) ->
return false if !_.isObject(apps) return false if !_.isObject(apps)
return false if !_.every apps, (val, appId) -> return _.every apps, (val, appId) ->
val = _.defaults(_.clone(val), { config: undefined, environment: undefined, commit: undefined, image: undefined })
return false if !isValidShortText(appId) or !checkInt(appId)? return false if !isValidShortText(appId) or !checkInt(appId)?
return false if !isValidShortText(val.name) return _.conformsTo(val, {
return false if val.commit? and (!isValidShortText(val.image) or !isValidShortText(val.commit)) name: isValidShortText
return undefinedOrValidEnv(val.config) and undefinedOrValidEnv(val.environment) image: (i) -> !val.commit? or isValidShortText(i)
return true commit: (c) -> !c? or isValidShortText(c)
config: undefinedOrValidEnv
environment: undefinedOrValidEnv
})
isValidService = (service, serviceId) -> isValidService = (service, serviceId) ->
return false if !isValidEnv(service.environment)
return false if !isValidShortText(service.serviceName)
return false if !isValidShortText(service.image)
return false if !isValidShortText(serviceId) or !checkInt(serviceId) return false if !isValidShortText(serviceId) or !checkInt(serviceId)
return false if !checkInt(service.imageId)? return _.conformsTo(service, {
return false if !isValidLabelsObject(service.labels) serviceName: isValidShortText
return true image: isValidShortText
environment: isValidEnv
imageId: (i) -> checkInt(i)?
labels: isValidLabelsObject
})
exports.isValidAppsObject = (obj) -> exports.isValidAppsObject = (obj) ->
return false if !_.isObject(obj) return false if !_.isObject(obj)
return false if !_.every obj, (val, appId) -> return _.every obj, (val, appId) ->
return false if !isValidShortText(appId) or !checkInt(appId)? return false if !isValidShortText(appId) or !checkInt(appId)?
return false if !isValidShortText(val.name) or (val.releaseId? and !checkInt(val.releaseId)?) return _.conformsTo(_.defaults(_.clone(val), { releaseId: undefined }), {
return false if !_.isObject(val.services) name: isValidShortText
return false if !_.every(val.services, isValidService) releaseId: (r) -> !r? or checkInt(r)?
return true services: (s) -> _.isObject(s) and _.every(s, isValidService)
return true })
exports.isValidDependentDevicesObject = (devices) -> exports.isValidDependentDevicesObject = (devices) ->
return false if !_.isObject(devices) return false if !_.isObject(devices)
return false if !_.every devices, (val, uuid) -> return _.every devices, (val, uuid) ->
return false if !isValidShortText(uuid) return false if !isValidShortText(uuid)
return false if !isValidShortText(val.name) return _.conformsTo(val, {
return false if !_.isObject(val.apps) or _.isEmpty(val.apps) name: isValidShortText
return false if !_.every val.apps, (app) -> apps: (a) ->
return undefinedOrValidEnv(app.config) and undefinedOrValidEnv(app.environment) return (
return true _.isObject(a) and
return true !_.isEmpty(a) and
_.every a, (app) ->
app = _.defaults(_.clone(app), { config: undefined, environment: undefined })
_.conformsTo(app, { config: undefinedOrValidEnv, environment: undefinedOrValidEnv })
)
})
exports.validStringOrUndefined = (s) ->
_.isUndefined(s) or (_.isString(s) and !_.isEmpty(s))
exports.validObjectOrUndefined = (o) ->
_.isUndefined(o) or _.isObject(o)

View File

@ -8,7 +8,7 @@
// history without actually adding drop statements (mostly just becoming unused, but still there). // history without actually adding drop statements (mostly just becoming unused, but still there).
exports.up = function (knex, Promise) { exports.up = function (knex, Promise) {
let addColumn = function (table, column, type) { const addColumn = function (table, column, type) {
return knex.schema.hasColumn(table, column) return knex.schema.hasColumn(table, column)
.then((exists) => { .then((exists) => {
if (!exists) { if (!exists) {
@ -16,7 +16,7 @@ exports.up = function (knex, Promise) {
} }
}) })
} }
let dropColumn = function (table, column) { const dropColumn = function (table, column) {
return knex.schema.hasColumn(table, column) return knex.schema.hasColumn(table, column)
.then((exists) => { .then((exists) => {
if (exists) { if (exists) {
@ -24,7 +24,7 @@ exports.up = function (knex, Promise) {
} }
}) })
} }
let createTableOrRun = function (tableName, tableCreator, runIfTableExists) { const createTableOrRun = function (tableName, tableCreator, runIfTableExists) {
return knex.schema.hasTable(tableName) return knex.schema.hasTable(tableName)
.then((exists) => { .then((exists) => {
if (!exists) { if (!exists) {
@ -34,7 +34,7 @@ exports.up = function (knex, Promise) {
} }
}) })
} }
let dropTable = function (tableName) { const dropTable = function (tableName) {
return knex.schema.hasTable(tableName) return knex.schema.hasTable(tableName)
.then((exists) => { .then((exists) => {
if (exists) { if (exists) {
@ -132,5 +132,5 @@ exports.up = function (knex, Promise) {
} }
exports.down = function(knex, Promise) { exports.down = function(knex, Promise) {
return Promise.try(() => { throw new Error('Not implemented') }) return Promise.reject(new Error('Not implemented'))
} }

View File

@ -15,16 +15,16 @@ var tryParse = function (obj) {
var singleToMulticontainerApp = function (app) { var singleToMulticontainerApp = function (app) {
// From *very* old supervisors, env or config may be null // From *very* old supervisors, env or config may be null
// so we ignore errors parsing them // so we ignore errors parsing them
let conf = tryParse(app.config) const conf = tryParse(app.config)
let env = tryParse(app.env) const env = tryParse(app.env)
let environment = {} const environment = {}
let appId = parseInt(app.appId) const appId = parseInt(app.appId)
for (let key in env) { for (let key in env) {
if (!/^RESIN_/.test(key)) { if (!/^RESIN_/.test(key)) {
environment[key] = env[key] environment[key] = env[key]
} }
} }
let newApp = { const newApp = {
appId: appId, appId: appId,
commit: app.commit, commit: app.commit,
name: app.name, name: app.name,
@ -32,7 +32,7 @@ var singleToMulticontainerApp = function (app) {
networks: {}, networks: {},
volumes: {} volumes: {}
} }
let defaultVolume = defaultLegacyVolume(appId) const defaultVolume = defaultLegacyVolume(appId)
newApp.volumes[defaultVolume] = {} newApp.volumes[defaultVolume] = {}
let updateStrategy = conf['RESIN_SUPERVISOR_UPDATE_STRATEGY'] let updateStrategy = conf['RESIN_SUPERVISOR_UPDATE_STRATEGY']
if (updateStrategy == null) { if (updateStrategy == null) {
@ -79,7 +79,7 @@ var singleToMulticontainerApp = function (app) {
} }
var jsonifyAppFields = function (app) { var jsonifyAppFields = function (app) {
let newApp = Object.assign({}, app) const newApp = Object.assign({}, app)
newApp.services = JSON.stringify(app.services) newApp.services = JSON.stringify(app.services)
newApp.networks = JSON.stringify(app.networks) newApp.networks = JSON.stringify(app.networks)
newApp.volumes = JSON.stringify(app.volumes) newApp.volumes = JSON.stringify(app.volumes)
@ -87,7 +87,7 @@ var jsonifyAppFields = function (app) {
} }
var imageForApp = function (app) { var imageForApp = function (app) {
let service = app.services[0] const service = app.services[0]
return { return {
name: service.image, name: service.image,
appId: service.appId, appId: service.appId,
@ -147,7 +147,7 @@ exports.up = function (knex, Promise) {
}) })
}) })
.map((app) => { .map((app) => {
let migratedApp = singleToMulticontainerApp(app) const migratedApp = singleToMulticontainerApp(app)
return knex('app').insert(jsonifyAppFields(migratedApp)) return knex('app').insert(jsonifyAppFields(migratedApp))
.then(() => knex('image').insert(imageForApp(migratedApp))) .then(() => knex('image').insert(imageForApp(migratedApp)))
}) })
@ -160,9 +160,8 @@ exports.up = function (knex, Promise) {
.then((deviceConf) => { .then((deviceConf) => {
return knex.schema.dropTable('deviceConfig') return knex.schema.dropTable('deviceConfig')
.then(() => { .then(() => {
let values = JSON.parse(deviceConf[0].values) const values = JSON.parse(deviceConf[0].values)
let promises = [] const configKeys = {
let configKeys = {
'RESIN_SUPERVISOR_POLL_INTERVAL': 'appUpdatePollInterval', 'RESIN_SUPERVISOR_POLL_INTERVAL': 'appUpdatePollInterval',
'RESIN_SUPERVISOR_LOCAL_MODE': 'localMode', 'RESIN_SUPERVISOR_LOCAL_MODE': 'localMode',
'RESIN_SUPERVISOR_CONNECTIVITY_CHECK': 'connectivityCheckEnabled', 'RESIN_SUPERVISOR_CONNECTIVITY_CHECK': 'connectivityCheckEnabled',
@ -174,12 +173,11 @@ exports.up = function (knex, Promise) {
'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL': 'deltaRequestTimeout', 'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL': 'deltaRequestTimeout',
'RESIN_SUPERVISOR_OVERRIDE_LOCK': 'lockOverride' 'RESIN_SUPERVISOR_OVERRIDE_LOCK': 'lockOverride'
} }
for (let envVarName in values) { return Promise.map(Object.keys(values), (envVarName) => {
if (configKeys[envVarName] != null) { if (configKeys[envVarName] != null) {
promises.push(knex('config').insert({ key: configKeys[envVarName], value: values[envVarName]})) return knex('config').insert({ key: configKeys[envVarName], value: values[envVarName]})
} }
} })
return Promise.all(promises)
}) })
}) })
.then(() => { .then(() => {
@ -222,7 +220,7 @@ exports.up = function (knex, Promise) {
}) })
.then(() => { .then(() => {
return Promise.map(dependentApps, (app) => { return Promise.map(dependentApps, (app) => {
let newApp = { const newApp = {
appId: parseInt(app.appId), appId: parseInt(app.appId),
parentApp: parseInt(app.parentAppId), parentApp: parseInt(app.parentAppId),
image: app.imageId, image: app.imageId,
@ -232,7 +230,7 @@ exports.up = function (knex, Promise) {
config: JSON.stringify(tryParse(app.config)), config: JSON.stringify(tryParse(app.config)),
environment: JSON.stringify(tryParse(app.environment)) environment: JSON.stringify(tryParse(app.environment))
} }
let image = imageForDependentApp(newApp) const image = imageForDependentApp(newApp)
return knex('image').insert(image) return knex('image').insert(image)
.then(() => knex('dependentApp').insert(newApp)) .then(() => knex('dependentApp').insert(newApp))
.then(() => knex('dependentAppTarget').insert(newApp)) .then(() => knex('dependentAppTarget').insert(newApp))
@ -276,7 +274,7 @@ exports.up = function (knex, Promise) {
}) })
.then(() => { .then(() => {
return Promise.map(dependentDevices, (device) => { return Promise.map(dependentDevices, (device) => {
let newDevice = Object.assign({}, device) const newDevice = Object.assign({}, device)
newDevice.appId = parseInt(device.appId) newDevice.appId = parseInt(device.appId)
newDevice.deviceId = parseInt(device.deviceId) newDevice.deviceId = parseInt(device.deviceId)
if (device.is_managed_by != null) { if (device.is_managed_by != null) {
@ -289,7 +287,7 @@ exports.up = function (knex, Promise) {
if (newDevice.markedForDeletion == null) { if (newDevice.markedForDeletion == null) {
newDevice.markedForDeletion = false newDevice.markedForDeletion = false
} }
let deviceTarget = { const deviceTarget = {
uuid: device.uuid, uuid: device.uuid,
name: device.name, name: device.name,
apps: {} apps: {}
@ -307,5 +305,5 @@ exports.up = function (knex, Promise) {
} }
exports.down = function(knex, Promise) { exports.down = function(knex, Promise) {
return Promise.try(() => { throw new Error('Not implemented') }) return Promise.reject(new Error('Not implemented'))
} }

View File

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

View File

@ -8,6 +8,7 @@ fs = Promise.promisifyAll(require('fs'))
constants = require './lib/constants' constants = require './lib/constants'
{ checkTruthy } = require './lib/validation' { checkTruthy } = require './lib/validation'
blink = require './lib/blink' blink = require './lib/blink'
{ EEXIST } = require './lib/errors'
networkPattern = networkPattern =
blinks: 4 blinks: 4
@ -40,9 +41,6 @@ vpnStatusInotifyCallback = ->
.catch -> .catch ->
pauseConnectivityCheck = false pauseConnectivityCheck = false
# Use the following to catch EEXIST errors
EEXIST = (err) -> err.code is 'EEXIST'
exports.startConnectivityCheck = _.once (apiEndpoint, enable, onChangeCallback) -> exports.startConnectivityCheck = _.once (apiEndpoint, enable, onChangeCallback) ->
exports.enableConnectivityCheck(enable) exports.enableConnectivityCheck(enable)
if !apiEndpoint? if !apiEndpoint?
@ -62,8 +60,7 @@ exports.startConnectivityCheck = _.once (apiEndpoint, enable, onChangeCallback)
port: parsedUrl.port ? (if parsedUrl.protocol is 'https:' then 443 else 80) port: parsedUrl.port ? (if parsedUrl.protocol is 'https:' then 443 else 80)
interval: 10 * 1000 interval: 10 * 1000
(connected) -> (connected) ->
if onChangeCallback? onChangeCallback?(connected)
onChangeCallback(connected)
if connected if connected
console.log('Internet Connectivity: OK') console.log('Internet Connectivity: OK')
blink.pattern.stop() blink.pattern.stop()
@ -91,9 +88,9 @@ exports.getIPAddresses = ->
# - the docker network for the supervisor API (supervisor0) # - the docker network for the supervisor API (supervisor0)
# - custom docker network bridges (br- + 12 hex characters) # - custom docker network bridges (br- + 12 hex characters)
_.flatten(_.map(_.omitBy(os.networkInterfaces(), (interfaceFields, interfaceName) -> _.flatten(_.map(_.omitBy(os.networkInterfaces(), (interfaceFields, interfaceName) ->
/^(balena[0-9]+)|(docker[0-9]+)|(rce[0-9]+)|(tun[0-9]+)|(resin-vpn)|(lo)|(resin-dns)|(supervisor0)|(br-[0-9a-f]{12})$/.test(interfaceName)) /^(?:balena|docker|rce|tun)[0-9]+|tun[0-9]+|resin-vpn|lo|resin-dns|supervisor0|br-[0-9a-f]{12}$/.test(interfaceName))
, (validInterfaces) -> , (validInterfaces) ->
_.map(_.omitBy(validInterfaces, (a) -> a.family != 'IPv4' ), 'address')) _.map(_.pickBy(validInterfaces, family: 'IPv4'), 'address'))
) )
exports.startIPAddressUpdate = do -> exports.startIPAddressUpdate = do ->

View File

@ -4,7 +4,7 @@ express = require 'express'
fs = Promise.promisifyAll require 'fs' fs = Promise.promisifyAll require 'fs'
{ request } = require './lib/request' { request } = require './lib/request'
constants = require './lib/constants' constants = require './lib/constants'
{ checkInt } = require './lib/validation' { checkInt, validStringOrUndefined, validObjectOrUndefined } = require './lib/validation'
path = require 'path' path = require 'path'
mkdirp = Promise.promisify(require('mkdirp')) mkdirp = Promise.promisify(require('mkdirp'))
bodyParser = require 'body-parser' bodyParser = require 'body-parser'
@ -22,12 +22,6 @@ parseDeviceFields = (device) ->
device.targetEnvironment = JSON.parse(device.targetEnvironment ? '{}') device.targetEnvironment = JSON.parse(device.targetEnvironment ? '{}')
return _.omit(device, 'markedForDeletion', 'logs_channel') return _.omit(device, 'markedForDeletion', 'logs_channel')
# TODO move to lib/validation
validStringOrUndefined = (s) ->
_.isUndefined(s) or !_.isEmpty(s)
validObjectOrUndefined = (o) ->
_.isUndefined(o) or _.isObject(o)
tarDirectory = (appId) -> tarDirectory = (appId) ->
return "/data/dependent-assets/#{appId}" return "/data/dependent-assets/#{appId}"
@ -54,11 +48,9 @@ cleanupTars = (appId, commit) ->
.catchReturn([]) .catchReturn([])
.then (files) -> .then (files) ->
if fileToKeep? if fileToKeep?
files = _.filter files, (file) -> files = _.reject(files, fileToKeep)
return file isnt fileToKeep
Promise.map files, (file) -> Promise.map files, (file) ->
if !fileToKeep? or (file isnt fileToKeep) fs.unlinkAsync(path.join(dir, file))
fs.unlinkAsync(path.join(dir, file))
formatTargetAsState = (device) -> formatTargetAsState = (device) ->
return { return {
@ -76,168 +68,168 @@ formatCurrentAsState = (device) ->
config: device.config config: device.config
} }
class ProxyvisorRouter createProxyvisorRouter = (proxyvisor) ->
constructor: (@proxyvisor) -> { db } = proxyvisor
{ @config, @logger, @db, @docker } = @proxyvisor router = express.Router()
@router = express.Router() router.use(bodyParser.urlencoded(extended: true))
@router.use(bodyParser.urlencoded(extended: true)) router.use(bodyParser.json())
@router.use(bodyParser.json()) router.get '/v1/devices', (req, res) ->
@router.get '/v1/devices', (req, res) => db.models('dependentDevice').select()
@db.models('dependentDevice').select() .map(parseDeviceFields)
.map(parseDeviceFields) .then (devices) ->
.then (devices) -> res.json(devices)
res.json(devices) .catch (err) ->
.catch (err) -> res.status(503).send(err?.message or err or 'Unknown error')
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v1/devices', (req, res) => router.post '/v1/devices', (req, res) ->
{ appId, device_type } = req.body { appId, device_type } = req.body
if !appId? or _.isNaN(parseInt(appId)) or parseInt(appId) <= 0 if !appId? or _.isNaN(parseInt(appId)) or parseInt(appId) <= 0
res.status(400).send('appId must be a positive integer') res.status(400).send('appId must be a positive integer')
return
device_type = 'generic' if !device_type?
d =
belongs_to__application: req.body.appId
device_type: device_type
proxyvisor.apiBinder.provisionDependentDevice(d)
.then (dev) ->
# If the response has id: null then something was wrong in the request
# but we don't know precisely what.
if !dev.id?
res.status(400).send('Provisioning failed, invalid appId or credentials')
return return
device_type = 'generic' if !device_type? deviceForDB = {
d = uuid: dev.uuid
belongs_to__application: req.body.appId appId
device_type: device_type device_type: dev.device_type
@proxyvisor.apiBinder.provisionDependentDevice(d) deviceId: dev.id
.then (dev) => name: dev.name
# If the response has id: null then something was wrong in the request status: dev.status
# but we don't know precisely what. logs_channel: dev.logs_channel
if !dev.id?
res.status(400).send('Provisioning failed, invalid appId or credentials')
return
deviceForDB = {
uuid: dev.uuid
appId
device_type: dev.device_type
deviceId: dev.id
name: dev.name
status: dev.status
logs_channel: dev.logs_channel
}
@db.models('dependentDevice').insert(deviceForDB)
.then ->
res.status(201).send(dev)
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
@router.get '/v1/devices/:uuid', (req, res) =>
uuid = req.params.uuid
@db.models('dependentDevice').select().where({ uuid })
.then ([ device ]) ->
return res.status(404).send('Device not found') if !device?
return res.status(410).send('Device deleted') if device.markedForDeletion
res.json(parseDeviceFields(device))
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
@router.post '/v1/devices/:uuid/logs', (req, res) =>
uuid = req.params.uuid
m = {
message: req.body.message
timestamp: req.body.timestamp or Date.now()
} }
m.isSystem = req.body.isSystem if req.body.isSystem? db.models('dependentDevice').insert(deviceForDB)
.then ->
res.status(201).send(dev)
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
@db.models('dependentDevice').select().where({ uuid }) router.get '/v1/devices/:uuid', (req, res) ->
.then ([ device ]) => uuid = req.params.uuid
return res.status(404).send('Device not found') if !device? db.models('dependentDevice').select().where({ uuid })
return res.status(410).send('Device deleted') if device.markedForDeletion .then ([ device ]) ->
@logger.log(m, { channel: "device-#{device.logs_channel}-logs" }) return res.status(404).send('Device not found') if !device?
res.status(202).send('OK') return res.status(410).send('Device deleted') if device.markedForDeletion
.catch (err) -> res.json(parseDeviceFields(device))
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack) .catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error') console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
@router.put '/v1/devices/:uuid', (req, res) => router.post '/v1/devices/:uuid/logs', (req, res) ->
uuid = req.params.uuid uuid = req.params.uuid
{ status, is_online, commit, releaseId, environment, config } = req.body m = {
validateDeviceFields = -> message: req.body.message
if isDefined(is_online) and !_.isBoolean(is_online) timestamp: req.body.timestamp or Date.now()
return 'is_online must be a boolean' }
if !validStringOrUndefined(status) m.isSystem = req.body.isSystem if req.body.isSystem?
return 'status must be a non-empty string'
if !validStringOrUndefined(commit)
return 'commit must be a non-empty string'
if !validStringOrUndefined(releaseId)
return 'commit must be a non-empty string'
if !validObjectOrUndefined(environment)
return 'environment must be an object'
if !validObjectOrUndefined(config)
return 'config must be an object'
return null
requestError = validateDeviceFields()
if requestError?
res.status(400).send(requestError)
return
environment = JSON.stringify(environment) if isDefined(environment) db.models('dependentDevice').select().where({ uuid })
config = JSON.stringify(config) if isDefined(config) .then ([ device ]) ->
return res.status(404).send('Device not found') if !device?
return res.status(410).send('Device deleted') if device.markedForDeletion
proxyvisor.logger.log(m, { channel: "device-#{device.logs_channel}-logs" })
res.status(202).send('OK')
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
fieldsToUpdateOnDB = _.pickBy({ status, is_online, commit, releaseId, config, environment }, isDefined) router.put '/v1/devices/:uuid', (req, res) ->
fieldsToUpdateOnAPI = _.pick(fieldsToUpdateOnDB, 'status', 'is_online', 'commit', 'releaseId') uuid = req.params.uuid
{ status, is_online, commit, releaseId, environment, config } = req.body
validateDeviceFields = ->
if isDefined(is_online) and !_.isBoolean(is_online)
return 'is_online must be a boolean'
if !validStringOrUndefined(status)
return 'status must be a non-empty string'
if !validStringOrUndefined(commit)
return 'commit must be a non-empty string'
if !validStringOrUndefined(releaseId)
return 'commit must be a non-empty string'
if !validObjectOrUndefined(environment)
return 'environment must be an object'
if !validObjectOrUndefined(config)
return 'config must be an object'
return null
requestError = validateDeviceFields()
if requestError?
res.status(400).send(requestError)
return
if _.isEmpty(fieldsToUpdateOnDB) environment = JSON.stringify(environment) if isDefined(environment)
res.status(400).send('At least one device attribute must be updated') config = JSON.stringify(config) if isDefined(config)
return
@db.models('dependentDevice').select().where({ uuid }) fieldsToUpdateOnDB = _.pickBy({ status, is_online, commit, releaseId, config, environment }, isDefined)
.then ([ device ]) => fieldsToUpdateOnAPI = _.pick(fieldsToUpdateOnDB, 'status', 'is_online', 'commit', 'releaseId')
return res.status(404).send('Device not found') if !device?
return res.status(410).send('Device deleted') if device.markedForDeletion
throw new Error('Device is invalid') if !device.deviceId?
Promise.try =>
if !_.isEmpty(fieldsToUpdateOnAPI)
@proxyvisor.apiBinder.patchDevice(device.deviceId, fieldsToUpdateOnAPI)
.then =>
@db.models('dependentDevice').update(fieldsToUpdateOnDB).where({ uuid })
.then =>
@db.models('dependentDevice').select().where({ uuid })
.then ([ device ]) ->
res.json(parseDeviceFields(device))
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
@router.get '/v1/dependent-apps/:appId/assets/:commit', (req, res) => if _.isEmpty(fieldsToUpdateOnDB)
@db.models('dependentApp').select().where(_.pick(req.params, 'appId', 'commit')) res.status(400).send('At least one device attribute must be updated')
.then ([ app ]) => return
return res.status(404).send('Not found') if !app
dest = tarPath(app.appId, app.commit)
fs.lstatAsync(dest)
.catch =>
Promise.using @docker.imageRootDirMounted(app.image), (rootDir) ->
getTarArchive(rootDir + '/assets', dest)
.then ->
res.sendFile(dest)
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
@router.get '/v1/dependent-apps', (req, res) => db.models('dependentDevice').select().where({ uuid })
@db.models('dependentApp').select() .then ([ device ]) ->
.map (app) -> return res.status(404).send('Device not found') if !device?
return { return res.status(410).send('Device deleted') if device.markedForDeletion
id: parseInt(app.appId) throw new Error('Device is invalid') if !device.deviceId?
commit: app.commit Promise.try ->
name: app.name if !_.isEmpty(fieldsToUpdateOnAPI)
config: JSON.parse(app.config ? '{}') proxyvisor.apiBinder.patchDevice(device.deviceId, fieldsToUpdateOnAPI)
} .then ->
.then (apps) -> db.models('dependentDevice').update(fieldsToUpdateOnDB).where({ uuid })
res.json(apps) .then ->
.catch (err) -> db.models('dependentDevice').select().where({ uuid })
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack) .then ([ device ]) ->
res.status(503).send(err?.message or err or 'Unknown error') res.json(parseDeviceFields(device))
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
router.get '/v1/dependent-apps/:appId/assets/:commit', (req, res) ->
db.models('dependentApp').select().where(_.pick(req.params, 'appId', 'commit'))
.then ([ app ]) ->
return res.status(404).send('Not found') if !app
dest = tarPath(app.appId, app.commit)
fs.lstatAsync(dest)
.catch ->
Promise.using proxyvisor.docker.imageRootDirMounted(app.image), (rootDir) ->
getTarArchive(rootDir + '/assets', dest)
.then ->
res.sendFile(dest)
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
router.get '/v1/dependent-apps', (req, res) ->
db.models('dependentApp').select()
.map (app) ->
return {
id: parseInt(app.appId)
commit: app.commit
name: app.name
config: JSON.parse(app.config ? '{}')
}
.then (apps) ->
res.json(apps)
.catch (err) ->
console.error("Error on #{req.method} #{url.parse(req.url).pathname}", err, err.stack)
res.status(503).send(err?.message or err or 'Unknown error')
return router
module.exports = class Proxyvisor module.exports = class Proxyvisor
constructor: ({ @config, @logger, @db, @docker, @images, @applications }) -> constructor: ({ @config, @logger, @db, @docker, @images, @applications }) ->
@acknowledgedState = {} @acknowledgedState = {}
@lastRequestForDevice = {} @lastRequestForDevice = {}
@_router = new ProxyvisorRouter(this) @router = createProxyvisorRouter(this)
@router = @_router.router
@actionExecutors = { @actionExecutors = {
updateDependentTargets: (step) => updateDependentTargets: (step) =>
@config.getMany([ 'currentApiKey', 'apiTimeout' ]) @config.getMany([ 'currentApiKey', 'apiTimeout' ])
@ -460,7 +452,7 @@ module.exports = class Proxyvisor
return images return images
_imageAvailable: (image, available) -> _imageAvailable: (image, available) ->
_.some(available, (availableImage) -> availableImage.name == image) _.some(available, name: image)
_getHookStep: (currentDevices, appId) => _getHookStep: (currentDevices, appId) =>
hookStep = { hookStep = {
@ -572,7 +564,7 @@ module.exports = class Proxyvisor
for appId in allAppIds for appId in allAppIds
devicesForApp = (devices) -> devicesForApp = (devices) ->
_.filter devices, (d) -> _.filter devices, (d) ->
_.includes(_.keys(d.apps), appId) _.has(d.apps, appId)
currentDevices = devicesForApp(current.dependent.devices) currentDevices = devicesForApp(current.dependent.devices)
targetDevices = devicesForApp(target.dependent.devices) targetDevices = devicesForApp(target.dependent.devices)
@ -590,8 +582,7 @@ module.exports = class Proxyvisor
.then (parentApp) => .then (parentApp) =>
Promise.map parentApp?.services ? [], (service) => Promise.map parentApp?.services ? [], (service) =>
@docker.getImageEnv(service.image) @docker.getImageEnv(service.image)
.then (imageEnv) -> .get('RESIN_DEPENDENT_DEVICES_HOOK_ADDRESS')
return imageEnv.RESIN_DEPENDENT_DEVICES_HOOK_ADDRESS
.then (imageHookAddresses) -> .then (imageHookAddresses) ->
for addr in imageHookAddresses for addr in imageHookAddresses
return addr if addr? return addr if addr?

View File

@ -7,6 +7,8 @@ APIBinder = require './api-binder'
DeviceState = require './device-state' DeviceState = require './device-state'
SupervisorAPI = require './supervisor-api' SupervisorAPI = require './supervisor-api'
constants = require './lib/constants'
startupConfigFields = [ startupConfigFields = [
'uuid' 'uuid'
'listenPort' 'listenPort'
@ -44,7 +46,7 @@ module.exports = class Supervisor extends EventEmitter
.then => .then =>
# initialize API # initialize API
console.log('Starting API server') console.log('Starting API server')
@api.listen(@config.constants.allowedInterfaces, conf.listenPort, conf.apiTimeout) @api.listen(constants.allowedInterfaces, conf.listenPort, conf.apiTimeout)
@deviceState.on('shutdown', => @api.stop()) @deviceState.on('shutdown', => @api.stop())
.then => .then =>
@apiBinder.init() # this will first try to provision if it's a new device @apiBinder.init() # this will first try to provision if it's a new device