2017-11-01 00:24:54 -07:00
|
|
|
Promise = require 'bluebird'
|
|
|
|
_ = require 'lodash'
|
|
|
|
EventEmitter = require 'events'
|
|
|
|
fs = Promise.promisifyAll(require('fs'))
|
|
|
|
express = require 'express'
|
|
|
|
bodyParser = require 'body-parser'
|
2018-01-23 01:56:13 -03:00
|
|
|
hostConfig = require './host-config'
|
2017-11-01 00:24:54 -07:00
|
|
|
network = require './network'
|
2018-12-05 23:38:58 -03:00
|
|
|
execAsync = Promise.promisify(require('child_process').exec)
|
|
|
|
mkdirp = Promise.promisify(require('mkdirp'))
|
|
|
|
path = require 'path'
|
2018-12-11 14:10:55 -03:00
|
|
|
rimraf = Promise.promisify(require('rimraf'))
|
2017-11-01 00:24:54 -07:00
|
|
|
|
|
|
|
constants = require './lib/constants'
|
|
|
|
validation = require './lib/validation'
|
2018-03-16 11:01:50 -03:00
|
|
|
systemd = require './lib/systemd'
|
2017-11-01 00:24:54 -07:00
|
|
|
updateLock = require './lib/update-lock'
|
2018-03-09 14:10:11 +00:00
|
|
|
{ singleToMulticontainerApp } = require './lib/migration'
|
2018-12-13 14:01:34 +00:00
|
|
|
{ ENOENT, EISDIR, NotFoundError, UpdatesLockedError } = require './lib/errors'
|
2017-11-01 00:24:54 -07:00
|
|
|
|
2018-12-20 17:12:04 +00:00
|
|
|
{ DeviceConfig } = require './device-config'
|
2017-11-01 00:24:54 -07:00
|
|
|
ApplicationManager = require './application-manager'
|
|
|
|
|
|
|
|
validateLocalState = (state) ->
|
2018-11-29 11:23:05 +00:00
|
|
|
if state.name?
|
|
|
|
throw new Error('Invalid device name') if not validation.isValidShortText(state.name)
|
2017-11-29 13:32:57 -08:00
|
|
|
if !state.apps? or !validation.isValidAppsObject(state.apps)
|
2017-11-01 00:24:54 -07:00
|
|
|
throw new Error('Invalid apps')
|
2017-11-29 13:32:57 -08:00
|
|
|
if !state.config? or !validation.isValidEnv(state.config)
|
2017-11-01 00:24:54 -07:00
|
|
|
throw new Error('Invalid device configuration')
|
|
|
|
|
|
|
|
validateDependentState = (state) ->
|
|
|
|
if state.apps? and !validation.isValidDependentAppsObject(state.apps)
|
|
|
|
throw new Error('Invalid dependent apps')
|
|
|
|
if state.devices? and !validation.isValidDependentDevicesObject(state.devices)
|
|
|
|
throw new Error('Invalid dependent devices')
|
|
|
|
|
|
|
|
validateState = Promise.method (state) ->
|
2017-11-29 13:32:57 -08:00
|
|
|
if !_.isObject(state)
|
|
|
|
throw new Error('State must be an object')
|
|
|
|
if !_.isObject(state.local)
|
|
|
|
throw new Error('Local state must be an object')
|
|
|
|
validateLocalState(state.local)
|
|
|
|
if state.dependent?
|
|
|
|
validateDependentState(state.dependent)
|
2017-11-01 00:24:54 -07:00
|
|
|
|
2018-09-28 14:32:38 +01:00
|
|
|
# TODO (refactor): This shouldn't be here, and instead should be part of the other
|
|
|
|
# device api stuff in ./device-api
|
2018-02-13 15:23:44 -08:00
|
|
|
createDeviceStateRouter = (deviceState) ->
|
|
|
|
router = express.Router()
|
|
|
|
router.use(bodyParser.urlencoded(extended: true))
|
|
|
|
router.use(bodyParser.json())
|
|
|
|
|
2018-03-15 17:01:02 -03:00
|
|
|
rebootOrShutdown = (req, res, action) ->
|
|
|
|
deviceState.config.get('lockOverride')
|
|
|
|
.then (lockOverride) ->
|
2019-01-09 10:04:04 +00:00
|
|
|
force = validation.checkTruthy(req.body.force) or lockOverride
|
2018-03-15 17:01:02 -03:00
|
|
|
deviceState.executeStepAction({ action }, { force })
|
2018-02-13 15:23:44 -08:00
|
|
|
.then (response) ->
|
|
|
|
res.status(202).json(response)
|
|
|
|
.catch (err) ->
|
2018-12-13 14:01:34 +00:00
|
|
|
if err instanceof UpdatesLockedError
|
2018-02-13 15:23:44 -08:00
|
|
|
status = 423
|
|
|
|
else
|
|
|
|
status = 500
|
|
|
|
res.status(status).json({ Data: '', Error: err?.message or err or 'Unknown error' })
|
|
|
|
|
2018-03-15 17:01:02 -03:00
|
|
|
router.post '/v1/reboot', (req, res) ->
|
|
|
|
rebootOrShutdown(req, res, 'reboot')
|
|
|
|
|
2018-02-13 15:23:44 -08:00
|
|
|
router.post '/v1/shutdown', (req, res) ->
|
2018-03-15 17:01:02 -03:00
|
|
|
rebootOrShutdown(req, res, 'shutdown')
|
2018-02-13 15:23:44 -08:00
|
|
|
|
|
|
|
router.get '/v1/device/host-config', (req, res) ->
|
|
|
|
hostConfig.get()
|
|
|
|
.then (conf) ->
|
|
|
|
res.json(conf)
|
|
|
|
.catch (err) ->
|
|
|
|
res.status(503).send(err?.message or err or 'Unknown error')
|
|
|
|
|
|
|
|
router.patch '/v1/device/host-config', (req, res) ->
|
|
|
|
hostConfig.patch(req.body, deviceState.config)
|
|
|
|
.then ->
|
|
|
|
res.status(200).send('OK')
|
|
|
|
.catch (err) ->
|
|
|
|
res.status(503).send(err?.message or err or 'Unknown error')
|
|
|
|
|
|
|
|
router.get '/v1/device', (req, res) ->
|
|
|
|
deviceState.getStatus()
|
|
|
|
.then (state) ->
|
|
|
|
stateToSend = _.pick(state.local, [
|
|
|
|
'api_port'
|
|
|
|
'ip_address'
|
|
|
|
'os_version'
|
|
|
|
'supervisor_version'
|
|
|
|
'update_pending'
|
|
|
|
'update_failed'
|
|
|
|
'update_downloaded'
|
|
|
|
])
|
|
|
|
if state.local.is_on__commit?
|
|
|
|
stateToSend.commit = state.local.is_on__commit
|
|
|
|
# Will produce nonsensical results for multicontainer apps...
|
|
|
|
service = _.toPairs(_.toPairs(state.local.apps)[0]?[1]?.services)[0]?[1]
|
|
|
|
if service?
|
|
|
|
stateToSend.status = service.status
|
|
|
|
# For backwards compatibility, we adapt Running to the old "Idle"
|
|
|
|
if stateToSend.status == 'Running'
|
|
|
|
stateToSend.status = 'Idle'
|
|
|
|
stateToSend.download_progress = service.download_progress
|
|
|
|
res.json(stateToSend)
|
|
|
|
.catch (err) ->
|
|
|
|
res.status(500).json({ Data: '', Error: err?.message or err or 'Unknown error' })
|
|
|
|
|
|
|
|
router.use(deviceState.applications.router)
|
|
|
|
return router
|
2017-11-01 00:24:54 -07:00
|
|
|
|
|
|
|
module.exports = class DeviceState extends EventEmitter
|
2018-02-19 17:36:57 -08:00
|
|
|
constructor: ({ @db, @config, @eventTracker, @logger }) ->
|
2017-11-01 00:24:54 -07:00
|
|
|
@deviceConfig = new DeviceConfig({ @db, @config, @logger })
|
2018-01-19 16:31:02 -03:00
|
|
|
@applications = new ApplicationManager({ @config, @logger, @db, @eventTracker, deviceState: this })
|
2017-11-01 00:24:54 -07:00
|
|
|
@on 'error', (err) ->
|
|
|
|
console.error('Error in deviceState: ', err, err.stack)
|
|
|
|
@_currentVolatile = {}
|
2018-12-24 13:16:35 +00:00
|
|
|
@_writeLock = updateLock.writeLock
|
|
|
|
@_readLock = updateLock.readLock
|
2017-11-01 00:24:54 -07:00
|
|
|
@lastSuccessfulUpdate = null
|
|
|
|
@failedUpdates = 0
|
|
|
|
@applyInProgress = false
|
2017-12-07 17:18:21 -08:00
|
|
|
@lastApplyStart = process.hrtime()
|
2017-11-01 00:24:54 -07:00
|
|
|
@scheduledApply = null
|
|
|
|
@shuttingDown = false
|
2018-02-13 15:23:44 -08:00
|
|
|
@router = createDeviceStateRouter(this)
|
2017-11-01 00:24:54 -07:00
|
|
|
@on 'apply-target-state-end', (err) ->
|
|
|
|
if err?
|
|
|
|
console.log("Apply error #{err}")
|
|
|
|
else
|
|
|
|
console.log('Apply success!')
|
|
|
|
@applications.on('change', @reportCurrentState)
|
|
|
|
|
2017-12-07 17:18:21 -08:00
|
|
|
healthcheck: =>
|
2019-02-05 11:46:49 +00:00
|
|
|
@config.getMany([ 'unmanaged' ])
|
2017-12-07 17:18:21 -08:00
|
|
|
.then (conf) =>
|
2018-02-13 15:23:44 -08:00
|
|
|
cycleTime = process.hrtime(@lastApplyStart)
|
|
|
|
cycleTimeMs = cycleTime[0] * 1000 + cycleTime[1] / 1e6
|
2019-02-05 11:46:49 +00:00
|
|
|
cycleTimeWithinInterval = cycleTimeMs - @applications.timeSpentFetching < 2 * @maxPollTime
|
2018-12-13 14:14:15 +00:00
|
|
|
applyTargetHealthy = conf.unmanaged or !@applyInProgress or @applications.fetchesInProgress > 0 or cycleTimeWithinInterval
|
2018-03-16 11:01:50 -03:00
|
|
|
return applyTargetHealthy
|
2017-12-07 17:18:21 -08:00
|
|
|
|
2018-11-21 18:22:20 -08:00
|
|
|
migrateLegacyApps: (balenaApi) =>
|
|
|
|
console.log('Migrating ids for legacy app...')
|
|
|
|
@db.models('app').select()
|
|
|
|
.then (apps) =>
|
|
|
|
if apps.length == 0
|
|
|
|
console.log('No app to migrate')
|
|
|
|
return
|
|
|
|
app = apps[0]
|
|
|
|
services = JSON.parse(app.services)
|
|
|
|
# Check there's a main service, with legacy-container set
|
|
|
|
if services.length != 1
|
|
|
|
console.log("App doesn't have a single service, ignoring")
|
|
|
|
return
|
|
|
|
service = services[0]
|
|
|
|
if !service.labels['io.resin.legacy-container'] and !service.labels['io.balena.legacy-container']
|
|
|
|
console.log('Service is not marked as legacy, ignoring')
|
|
|
|
return
|
|
|
|
console.log("Getting release #{app.commit} for app #{app.appId} from API")
|
|
|
|
balenaApi.get(
|
|
|
|
resource: 'release'
|
|
|
|
options:
|
2018-11-05 14:49:06 +00:00
|
|
|
$filter:
|
2018-11-21 18:22:20 -08:00
|
|
|
belongs_to__application: app.appId
|
|
|
|
commit: app.commit
|
|
|
|
status: 'success'
|
2018-11-05 14:49:06 +00:00
|
|
|
$expand:
|
2018-11-21 18:22:20 -08:00
|
|
|
contains__image: [ 'image' ]
|
|
|
|
)
|
|
|
|
.then (releasesFromAPI) =>
|
|
|
|
if releasesFromAPI.length == 0
|
|
|
|
throw new Error('No compatible releases found in API')
|
|
|
|
release = releasesFromAPI[0]
|
|
|
|
releaseId = release.id
|
|
|
|
image = release.contains__image[0].image[0]
|
|
|
|
imageId = image.id
|
|
|
|
serviceId = image.is_a_build_of__service.__id
|
|
|
|
imageUrl = image.is_stored_at__image_location
|
|
|
|
if image.content_hash
|
|
|
|
imageUrl += "@#{image.content_hash}"
|
|
|
|
console.log("Found a release with releaseId #{releaseId}, imageId #{imageId}, serviceId #{serviceId}")
|
|
|
|
console.log("Image location is #{imageUrl}")
|
|
|
|
Promise.join(
|
|
|
|
@applications.docker.getImage(service.image).inspect().catchReturn(NotFoundError, null)
|
|
|
|
@db.models('image').where(name: service.image).select()
|
|
|
|
(imageFromDocker, imagesFromDB) =>
|
|
|
|
@db.transaction (trx) ->
|
|
|
|
Promise.try ->
|
|
|
|
if imagesFromDB.length > 0
|
|
|
|
console.log('Deleting existing image entry in db')
|
|
|
|
trx('image').where(name: service.image).del()
|
|
|
|
else
|
|
|
|
console.log('No image in db to delete')
|
|
|
|
.then ->
|
|
|
|
if imageFromDocker?
|
|
|
|
console.log('Inserting fixed image entry in db')
|
|
|
|
newImage = {
|
|
|
|
name: imageUrl,
|
|
|
|
appId: app.appId,
|
|
|
|
serviceId: serviceId,
|
|
|
|
serviceName: service.serviceName,
|
|
|
|
imageId: imageId,
|
|
|
|
releaseId: releaseId,
|
|
|
|
dependent: 0
|
|
|
|
dockerImageId: imageFromDocker.Id
|
|
|
|
}
|
|
|
|
trx('image').insert(newImage)
|
|
|
|
else
|
|
|
|
console.log('Image is not downloaded, so not saving it to db')
|
|
|
|
.then ->
|
|
|
|
service.image = imageUrl
|
|
|
|
service.serviceID = serviceId
|
|
|
|
service.imageId = imageId
|
|
|
|
service.releaseId = releaseId
|
|
|
|
delete service.labels['io.resin.legacy-container']
|
|
|
|
delete service.labels['io.balena.legacy-container']
|
|
|
|
app.services = JSON.stringify([ service ])
|
|
|
|
app.releaseId = releaseId
|
|
|
|
console.log('Updating app entry in db')
|
|
|
|
trx('app').update(app).where({ appId: app.appId })
|
|
|
|
)
|
|
|
|
|
|
|
|
normaliseLegacy: (balenaApi) =>
|
2017-11-29 13:32:57 -08:00
|
|
|
# When legacy apps are present, we kill their containers and migrate their /data to a named volume
|
2018-11-21 18:22:20 -08:00
|
|
|
# We also need to get the releaseId, serviceId, imageId and updated image URL
|
|
|
|
@migrateLegacyApps(balenaApi)
|
|
|
|
.then =>
|
|
|
|
console.log('Killing legacy containers')
|
|
|
|
@applications.services.killAllLegacy()
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
2017-11-29 13:32:57 -08:00
|
|
|
console.log('Migrating legacy app volumes')
|
|
|
|
@applications.getTargetApps()
|
2018-03-08 15:32:00 +00:00
|
|
|
.then(_.keys)
|
|
|
|
.map (appId) =>
|
|
|
|
@applications.volumes.createFromLegacy(appId)
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
2017-11-29 13:32:57 -08:00
|
|
|
@config.set({ legacyAppsPresent: 'false' })
|
2017-11-01 00:24:54 -07:00
|
|
|
|
|
|
|
init: ->
|
2018-02-19 17:36:57 -08:00
|
|
|
@config.on 'change', (changedConfig) =>
|
|
|
|
if changedConfig.loggingEnabled?
|
2019-01-09 10:04:04 +00:00
|
|
|
@logger.enable(changedConfig.loggingEnabled)
|
2018-02-19 17:36:57 -08:00
|
|
|
if changedConfig.apiSecret?
|
|
|
|
@reportCurrentState(api_secret: changedConfig.apiSecret)
|
2019-02-05 11:46:49 +00:00
|
|
|
if changedConfig.appUpdatePollInterval?
|
|
|
|
@maxPollTime = changedConfig.appUpdatePollInterval
|
2018-02-19 17:36:57 -08:00
|
|
|
|
2017-11-01 00:24:54 -07:00
|
|
|
@config.getMany([
|
2018-07-10 13:12:46 -07:00
|
|
|
'initialConfigSaved', 'listenPort', 'apiSecret', 'osVersion', 'osVariant',
|
2018-10-18 17:36:42 +02:00
|
|
|
'version', 'provisioned', 'apiEndpoint', 'connectivityCheckEnabled', 'legacyAppsPresent',
|
2019-02-05 11:46:49 +00:00
|
|
|
'targetStateSet', 'unmanaged', 'appUpdatePollInterval'
|
2017-11-01 00:24:54 -07:00
|
|
|
])
|
|
|
|
.then (conf) =>
|
2019-02-05 11:46:49 +00:00
|
|
|
@maxPollTime = conf.appUpdatePollInterval
|
2018-11-21 18:22:20 -08:00
|
|
|
@applications.init()
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
2019-01-09 10:04:04 +00:00
|
|
|
if !conf.initialConfigSaved
|
2017-11-01 00:24:54 -07:00
|
|
|
@saveInitialConfig()
|
|
|
|
.then =>
|
|
|
|
@initNetworkChecks(conf)
|
|
|
|
console.log('Reporting initial state, supervisor version and API info')
|
|
|
|
@reportCurrentState(
|
|
|
|
api_port: conf.listenPort
|
|
|
|
api_secret: conf.apiSecret
|
|
|
|
os_version: conf.osVersion
|
|
|
|
os_variant: conf.osVariant
|
|
|
|
supervisor_version: conf.version
|
|
|
|
provisioning_progress: null
|
|
|
|
provisioning_state: ''
|
2018-02-22 10:57:50 -08:00
|
|
|
status: 'Idle'
|
2018-07-10 13:12:46 -07:00
|
|
|
logs_channel: null
|
2017-11-01 00:24:54 -07:00
|
|
|
update_failed: false
|
|
|
|
update_pending: false
|
|
|
|
update_downloaded: false
|
|
|
|
)
|
|
|
|
.then =>
|
2018-10-18 17:36:42 +02:00
|
|
|
@applications.getTargetApps()
|
|
|
|
.then (targetApps) =>
|
2019-01-09 10:04:04 +00:00
|
|
|
if !conf.provisioned or (_.isEmpty(targetApps) and !conf.targetStateSet)
|
2017-11-29 13:32:57 -08:00
|
|
|
@loadTargetFromFile()
|
2018-10-18 17:36:42 +02:00
|
|
|
.finally =>
|
|
|
|
@config.set({ targetStateSet: 'true' })
|
|
|
|
else
|
|
|
|
console.log('Skipping preloading')
|
|
|
|
if conf.provisioned and !_.isEmpty(targetApps)
|
|
|
|
# If we're in this case, it's because we've updated from an older supervisor
|
|
|
|
# and we need to mark that the target state has been set so that
|
|
|
|
# the supervisor doesn't try to preload again if in the future target
|
|
|
|
# apps are empty again (which may happen with multi-app).
|
|
|
|
@config.set({ targetStateSet: 'true' })
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
2017-12-07 17:18:21 -08:00
|
|
|
@triggerApplyTarget({ initial: true })
|
2017-11-01 00:24:54 -07:00
|
|
|
|
2018-12-13 14:14:15 +00:00
|
|
|
initNetworkChecks: ({ apiEndpoint, connectivityCheckEnabled, unmanaged }) =>
|
2019-01-09 10:04:04 +00:00
|
|
|
return if unmanaged
|
2018-07-12 13:27:47 +01:00
|
|
|
network.startConnectivityCheck apiEndpoint, connectivityCheckEnabled, (connected) =>
|
2017-12-07 17:18:21 -08:00
|
|
|
@connected = connected
|
2017-11-01 00:24:54 -07:00
|
|
|
@config.on 'change', (changedConfig) ->
|
2017-11-29 13:32:57 -08:00
|
|
|
if changedConfig.connectivityCheckEnabled?
|
|
|
|
network.enableConnectivityCheck(changedConfig.connectivityCheckEnabled)
|
2017-11-01 00:24:54 -07:00
|
|
|
console.log('Starting periodic check for IP addresses')
|
2018-12-06 16:17:57 +00:00
|
|
|
network.startIPAddressUpdate() (addresses) =>
|
2017-11-01 00:24:54 -07:00
|
|
|
@reportCurrentState(
|
|
|
|
ip_address: addresses.join(' ')
|
|
|
|
)
|
2018-02-13 15:23:44 -08:00
|
|
|
, constants.ipAddressUpdateInterval
|
2017-11-01 00:24:54 -07:00
|
|
|
|
|
|
|
saveInitialConfig: =>
|
|
|
|
@deviceConfig.getCurrent()
|
|
|
|
.then (devConf) =>
|
|
|
|
@deviceConfig.setTarget(devConf)
|
|
|
|
.then =>
|
|
|
|
@config.set({ initialConfigSaved: 'true' })
|
|
|
|
|
|
|
|
emitAsync: (ev, args...) =>
|
|
|
|
setImmediate => @emit(ev, args...)
|
|
|
|
|
2017-11-29 13:32:57 -08:00
|
|
|
_readLockTarget: =>
|
2017-11-01 00:24:54 -07:00
|
|
|
@_readLock('target').disposer (release) ->
|
|
|
|
release()
|
2017-11-29 13:32:57 -08:00
|
|
|
_writeLockTarget: =>
|
2017-11-01 00:24:54 -07:00
|
|
|
@_writeLock('target').disposer (release) ->
|
|
|
|
release()
|
2017-11-29 13:32:57 -08:00
|
|
|
_inferStepsLock: =>
|
2017-11-01 00:24:54 -07:00
|
|
|
@_writeLock('inferSteps').disposer (release) ->
|
|
|
|
release()
|
|
|
|
|
2017-11-29 13:32:57 -08:00
|
|
|
usingReadLockTarget: (fn) =>
|
|
|
|
Promise.using @_readLockTarget, -> fn()
|
|
|
|
usingWriteLockTarget: (fn) =>
|
|
|
|
Promise.using @_writeLockTarget, -> fn()
|
|
|
|
usingInferStepsLock: (fn) =>
|
|
|
|
Promise.using @_inferStepsLock, -> fn()
|
|
|
|
|
2018-09-28 14:32:38 +01:00
|
|
|
setTarget: (target, localSource = false) ->
|
2018-05-18 13:27:41 +01:00
|
|
|
Promise.join(
|
2018-10-11 14:28:14 +01:00
|
|
|
@config.get('apiEndpoint'),
|
2018-05-18 13:27:41 +01:00
|
|
|
validateState(target),
|
2018-10-11 14:28:14 +01:00
|
|
|
(apiEndpoint) =>
|
2018-05-18 13:27:41 +01:00
|
|
|
@usingWriteLockTarget =>
|
|
|
|
# Apps, deviceConfig, dependent
|
|
|
|
@db.transaction (trx) =>
|
|
|
|
Promise.try =>
|
|
|
|
@config.set({ name: target.local.name }, trx)
|
|
|
|
.then =>
|
|
|
|
@deviceConfig.setTarget(target.local.config, trx)
|
|
|
|
.then =>
|
2018-12-12 13:21:03 +00:00
|
|
|
if localSource or not apiEndpoint
|
2018-10-11 14:28:14 +01:00
|
|
|
@applications.setTarget(target.local.apps, target.dependent, 'local', trx)
|
2018-09-28 14:32:38 +01:00
|
|
|
else
|
|
|
|
@applications.setTarget(target.local.apps, target.dependent, apiEndpoint, trx)
|
2018-05-18 13:27:41 +01:00
|
|
|
)
|
2017-11-01 00:24:54 -07:00
|
|
|
|
2018-01-19 16:31:02 -03:00
|
|
|
getTarget: ({ initial = false, intermediate = false } = {}) =>
|
2017-11-29 13:32:57 -08:00
|
|
|
@usingReadLockTarget =>
|
2018-01-19 16:31:02 -03:00
|
|
|
if intermediate
|
|
|
|
return @intermediateTarget
|
2017-11-01 00:24:54 -07:00
|
|
|
Promise.props({
|
|
|
|
local: Promise.props({
|
|
|
|
name: @config.get('name')
|
2017-12-07 17:18:21 -08:00
|
|
|
config: @deviceConfig.getTarget({ initial })
|
2017-11-01 00:24:54 -07:00
|
|
|
apps: @applications.getTargetApps()
|
|
|
|
})
|
|
|
|
dependent: @applications.getDependentTargets()
|
|
|
|
})
|
|
|
|
|
|
|
|
getStatus: ->
|
|
|
|
@applications.getStatus()
|
|
|
|
.then (appsStatus) =>
|
|
|
|
theState = { local: {}, dependent: {} }
|
|
|
|
_.merge(theState.local, @_currentVolatile)
|
|
|
|
theState.local.apps = appsStatus.local
|
|
|
|
theState.dependent.apps = appsStatus.dependent
|
2018-01-23 17:28:59 -08:00
|
|
|
if appsStatus.commit and !@applyInProgress
|
2017-11-29 13:32:57 -08:00
|
|
|
theState.local.is_on__commit = appsStatus.commit
|
2017-11-01 00:24:54 -07:00
|
|
|
return theState
|
|
|
|
|
|
|
|
getCurrentForComparison: ->
|
|
|
|
Promise.join(
|
|
|
|
@config.get('name')
|
|
|
|
@deviceConfig.getCurrent()
|
|
|
|
@applications.getCurrentForComparison()
|
|
|
|
@applications.getDependentState()
|
|
|
|
(name, devConfig, apps, dependent) ->
|
|
|
|
return {
|
|
|
|
local: {
|
|
|
|
name
|
|
|
|
config: devConfig
|
|
|
|
apps
|
|
|
|
}
|
|
|
|
dependent
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
reportCurrentState: (newState = {}) =>
|
|
|
|
_.assign(@_currentVolatile, newState)
|
|
|
|
@emitAsync('change')
|
|
|
|
|
2018-11-02 14:15:01 +00:00
|
|
|
_convertLegacyAppsJson: (appsArray) ->
|
|
|
|
Promise.try ->
|
|
|
|
deviceConf = _.reduce(appsArray, (conf, app) ->
|
2018-10-20 04:26:14 +02:00
|
|
|
return _.merge({}, conf, app.config)
|
|
|
|
, {})
|
|
|
|
apps = _.keyBy(_.map(appsArray, singleToMulticontainerApp), 'appId')
|
|
|
|
return { apps, config: deviceConf }
|
2018-01-18 16:34:25 -03:00
|
|
|
|
2018-12-05 23:38:58 -03:00
|
|
|
restoreBackup: (targetState) =>
|
|
|
|
@setTarget(targetState)
|
|
|
|
.then =>
|
|
|
|
appId = _.keys(targetState.local.apps)[0]
|
|
|
|
if !appId?
|
|
|
|
throw new Error('No appId in target state')
|
|
|
|
volumes = targetState.local.apps[appId].volumes
|
|
|
|
backupPath = path.join(constants.rootMountPoint, 'mnt/data/backup')
|
|
|
|
rimraf(backupPath) # We clear this path in case it exists from an incomplete run of this function
|
|
|
|
.then ->
|
|
|
|
mkdirp(backupPath)
|
|
|
|
.then ->
|
|
|
|
execAsync("tar -xzf backup.tgz -C #{backupPath} .", cwd: path.join(constants.rootMountPoint, 'mnt/data'))
|
|
|
|
.then ->
|
|
|
|
fs.readdirAsync(backupPath)
|
|
|
|
.then (dirContents) =>
|
|
|
|
Promise.mapSeries dirContents, (volumeName) =>
|
2018-12-10 18:04:08 -03:00
|
|
|
fs.statAsync(path.join(backupPath, volumeName))
|
|
|
|
.then (s) =>
|
|
|
|
if !s.isDirectory()
|
|
|
|
throw new Error("Invalid backup: #{volumeName} is not a directory")
|
|
|
|
if volumes[volumeName]?
|
|
|
|
console.log("Creating volume #{volumeName} from backup")
|
|
|
|
# If the volume exists (from a previous incomplete run of this restoreBackup), we delete it first
|
|
|
|
@applications.volumes.get({ appId, name: volumeName })
|
|
|
|
.then =>
|
|
|
|
@applications.volumes.remove({ appId, name: volumeName })
|
|
|
|
.catch(NotFoundError, _.noop)
|
|
|
|
.then =>
|
|
|
|
@applications.volumes.createFromPath({ appId, name: volumeName, config: volumes[volumeName] }, path.join(backupPath, volumeName))
|
|
|
|
else
|
|
|
|
throw new Error("Invalid backup: #{volumeName} is present in backup but not in target state")
|
2018-12-05 23:38:58 -03:00
|
|
|
.then ->
|
|
|
|
rimraf(backupPath)
|
|
|
|
.then ->
|
|
|
|
rimraf(path.join(constants.rootMountPoint, 'mnt/data', constants.migrationBackupFile))
|
|
|
|
|
2017-11-01 00:24:54 -07:00
|
|
|
loadTargetFromFile: (appsPath) ->
|
2018-10-18 17:36:42 +02:00
|
|
|
console.log('Attempting to load preloaded apps...')
|
2017-11-01 00:24:54 -07:00
|
|
|
appsPath ?= constants.appsJsonPath
|
|
|
|
fs.readFileAsync(appsPath, 'utf8')
|
|
|
|
.then(JSON.parse)
|
2018-10-17 10:47:11 +02:00
|
|
|
.then (stateFromFile) =>
|
|
|
|
if _.isArray(stateFromFile)
|
2018-11-02 14:15:01 +00:00
|
|
|
# This is a legacy apps.json
|
|
|
|
console.log('Legacy apps.json detected')
|
|
|
|
return @_convertLegacyAppsJson(stateFromFile)
|
2018-10-17 10:47:11 +02:00
|
|
|
else
|
|
|
|
return stateFromFile
|
2017-11-01 00:24:54 -07:00
|
|
|
.then (stateFromFile) =>
|
2018-05-30 19:22:22 +01:00
|
|
|
commitToPin = null
|
|
|
|
appToPin = null
|
2017-11-01 00:24:54 -07:00
|
|
|
if !_.isEmpty(stateFromFile)
|
2018-02-13 15:23:44 -08:00
|
|
|
images = _.flatMap stateFromFile.apps, (app, appId) =>
|
2018-05-30 19:22:22 +01:00
|
|
|
# multi-app warning!
|
|
|
|
# The following will need to be changed once running multiple applications is possible
|
|
|
|
commitToPin = app.commit
|
|
|
|
appToPin = appId
|
2017-11-01 00:24:54 -07:00
|
|
|
_.map app.services, (service, serviceId) =>
|
|
|
|
svc = {
|
2018-01-23 17:28:59 -08:00
|
|
|
imageName: service.image
|
2017-11-01 00:24:54 -07:00
|
|
|
serviceName: service.serviceName
|
|
|
|
imageId: service.imageId
|
|
|
|
serviceId
|
2018-01-23 17:28:59 -08:00
|
|
|
releaseId: app.releaseId
|
2017-11-01 00:24:54 -07:00
|
|
|
appId
|
|
|
|
}
|
|
|
|
return @applications.imageForService(svc)
|
|
|
|
Promise.map images, (img) =>
|
|
|
|
@applications.images.normalise(img.name)
|
|
|
|
.then (name) =>
|
|
|
|
img.name = name
|
2018-01-18 16:34:25 -03:00
|
|
|
@applications.images.save(img)
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
2018-01-18 16:34:25 -03:00
|
|
|
@deviceConfig.getCurrent()
|
2018-02-13 15:23:44 -08:00
|
|
|
.then (deviceConf) =>
|
2018-10-20 04:26:14 +02:00
|
|
|
@deviceConfig.formatConfigKeys(stateFromFile.config)
|
|
|
|
.then (formattedConf) =>
|
|
|
|
stateFromFile.config = _.defaults(formattedConf, deviceConf)
|
|
|
|
stateFromFile.name ?= ''
|
|
|
|
@setTarget({
|
|
|
|
local: stateFromFile
|
|
|
|
})
|
2018-05-30 19:22:22 +01:00
|
|
|
.then =>
|
2018-10-18 17:36:42 +02:00
|
|
|
console.log('Preloading complete')
|
2018-05-30 19:22:22 +01:00
|
|
|
if stateFromFile.pinDevice
|
|
|
|
# multi-app warning!
|
|
|
|
# The following will need to be changed once running multiple applications is possible
|
2018-10-18 17:36:42 +02:00
|
|
|
console.log('Device will be pinned')
|
2018-05-30 19:22:22 +01:00
|
|
|
if commitToPin? and appToPin?
|
|
|
|
@config.set
|
2019-01-09 10:04:04 +00:00
|
|
|
pinDevice: {
|
2018-05-30 19:22:22 +01:00
|
|
|
commit: commitToPin,
|
2019-01-09 10:04:04 +00:00
|
|
|
app: parseInt(appToPin, 10),
|
2018-05-30 19:22:22 +01:00
|
|
|
}
|
2018-11-27 14:09:44 +00:00
|
|
|
# Ensure that this is actually a file, and not an empty path
|
|
|
|
# It can be an empty path because if the file does not exist
|
|
|
|
# on host, the docker daemon creates an empty directory when
|
|
|
|
# the bind mount is added
|
|
|
|
.catch ENOENT, EISDIR, ->
|
|
|
|
console.log('No apps.json file present, skipping preload')
|
2017-11-01 00:24:54 -07:00
|
|
|
.catch (err) =>
|
|
|
|
@eventTracker.track('Loading preloaded apps failed', { error: err })
|
|
|
|
|
2018-01-19 16:31:02 -03:00
|
|
|
reboot: (force, skipLock) =>
|
|
|
|
@applications.stopAll({ force, skipLock })
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
|
|
|
@logger.logSystemMessage('Rebooting', {}, 'Reboot')
|
2018-03-16 11:01:50 -03:00
|
|
|
systemd.reboot()
|
2018-02-13 15:23:44 -08:00
|
|
|
.tap =>
|
|
|
|
@shuttingDown = true
|
|
|
|
@emitAsync('shutdown')
|
2017-11-01 00:24:54 -07:00
|
|
|
|
2018-01-19 16:31:02 -03:00
|
|
|
shutdown: (force, skipLock) =>
|
|
|
|
@applications.stopAll({ force, skipLock })
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
|
|
|
@logger.logSystemMessage('Shutting down', {}, 'Shutdown')
|
2018-03-16 11:01:50 -03:00
|
|
|
systemd.shutdown()
|
2018-02-13 15:23:44 -08:00
|
|
|
.tap =>
|
|
|
|
@shuttingDown = true
|
|
|
|
@emitAsync('shutdown')
|
2017-11-01 00:24:54 -07:00
|
|
|
|
2018-01-19 16:31:02 -03:00
|
|
|
executeStepAction: (step, { force, initial, skipLock }) =>
|
2017-11-01 00:24:54 -07:00
|
|
|
Promise.try =>
|
2018-12-20 17:12:04 +00:00
|
|
|
if @deviceConfig.isValidAction(step.action)
|
Add support for init, mem_reservation, shm_size, read_only and sysctls.
Also several bugfixes:
* Fix VPN control, logging in deviceConfig, and action executors in proxyvisor
* Fix bug in calculation of dependencies due to fields still using snake_case
* Fix snake_case in a migration, and remove unused lib/migration.coffee
* In healthcheck, count deviceState as healthy when a fetch is in progress (as in the non-multicontainer supervisor)
* Set always as default restart policy
* Fix healthcheck, stop_grace_period and mem_limit
* Lint and reduce some cyclomatic complexities
* Namespace volumes and networks by appId, switch default network name to 'default', fix dependencies in networks and volumes, fix duplicated kill steps, fix fat arrow on provisioning
* Check that supervisor network is okay every time we're applying target state
Signed-off-by: Pablo Carranza Velez <pablo@resin.io>
2017-12-11 16:35:23 -08:00
|
|
|
@deviceConfig.executeStepAction(step, { initial })
|
2017-11-01 00:24:54 -07:00
|
|
|
else if _.includes(@applications.validActions, step.action)
|
2018-01-19 16:31:02 -03:00
|
|
|
@applications.executeStepAction(step, { force, skipLock })
|
2017-11-01 00:24:54 -07:00
|
|
|
else
|
|
|
|
switch step.action
|
|
|
|
when 'reboot'
|
2018-03-29 12:37:38 +01:00
|
|
|
# There isn't really a way that these methods can fail,
|
|
|
|
# and if they do, we wouldn't know about it until after
|
|
|
|
# the response has been sent back to the API. Just return
|
|
|
|
# "OK" for this and the below action
|
|
|
|
@reboot(force, skipLock).return(Data: 'OK', Error: null)
|
2017-11-01 00:24:54 -07:00
|
|
|
when 'shutdown'
|
2018-03-29 12:37:38 +01:00
|
|
|
@shutdown(force, skipLock).return(Data: 'OK', Error: null)
|
2017-11-01 00:24:54 -07:00
|
|
|
when 'noop'
|
|
|
|
Promise.resolve()
|
|
|
|
else
|
|
|
|
throw new Error("Invalid action #{step.action}")
|
|
|
|
|
2018-01-26 17:29:19 -08:00
|
|
|
applyStep: (step, { force, initial, intermediate, skipLock }) =>
|
2017-11-29 13:32:57 -08:00
|
|
|
if @shuttingDown
|
|
|
|
return
|
2018-01-26 17:29:19 -08:00
|
|
|
@executeStepAction(step, { force, initial, skipLock })
|
2018-02-13 15:23:44 -08:00
|
|
|
.tapCatch (err) =>
|
2018-01-19 16:31:02 -03:00
|
|
|
@emitAsync('step-error', err, step)
|
|
|
|
.then (stepResult) =>
|
|
|
|
@emitAsync('step-completed', null, step, stepResult)
|
|
|
|
|
2018-01-26 17:29:19 -08:00
|
|
|
applyError: (err, { force, initial, intermediate }) =>
|
2018-01-23 11:50:43 -08:00
|
|
|
@emitAsync('apply-target-state-error', err)
|
|
|
|
@emitAsync('apply-target-state-end', err)
|
2018-02-13 15:23:44 -08:00
|
|
|
if intermediate
|
2018-01-23 11:50:43 -08:00
|
|
|
throw err
|
2018-02-13 15:23:44 -08:00
|
|
|
@failedUpdates += 1
|
|
|
|
@reportCurrentState(update_failed: true)
|
|
|
|
if @scheduledApply?
|
|
|
|
console.log("Updating failed, but there's another update scheduled immediately: ", err)
|
|
|
|
else
|
2019-02-05 11:46:49 +00:00
|
|
|
delay = Math.min((2 ** @failedUpdates) * constants.backoffIncrement, @maxPollTime)
|
2018-02-13 15:23:44 -08:00
|
|
|
# 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 })
|
2018-01-19 16:31:02 -03:00
|
|
|
|
2018-01-26 17:29:19 -08:00
|
|
|
applyTarget: ({ force = false, initial = false, intermediate = false, skipLock = false } = {}) =>
|
|
|
|
nextDelay = 200
|
2018-01-19 16:31:02 -03:00
|
|
|
Promise.try =>
|
|
|
|
if !intermediate
|
|
|
|
@applyBlocker
|
|
|
|
.then =>
|
|
|
|
@usingInferStepsLock =>
|
Fix a race condition that could cause an unnecessary restart of a service immediately after download
Up to now, there was a slim but non-zero chance that an image would be downloaded between the call to `@getTarget` inside deviceState
(which gets the target state and creates Service objects using information from available images), and the call to
`@images.getAvailable` in ApplicationManager (which is used to determine whether we should keep waiting for a download or start the
service). If this race condition happened, then the ApplicationManager would infer that a service was ready to be started (because
the image appears as available), but would have incomplete information about the service because the image wasn't available when
the Service object was created. The result would be that the service would be started, and then immediately on the next applyTarget
the ApplicationManager would try to kill it and restart it to update it with the complete information from the image.
This patch changes this behavior by ensuring that all of the additional information about the current state, which includes available images,
is gathered *before* building the current and target states that we compare. This means that if the image is downloaded after the call to getAvailable, the Service might be constructed with all the information about the image, but it won't be started until the next pass, because ApplicationManager will treat it as still downloading.
Change-type: patch
Signed-off-by: Pablo Carranza Velez <pablo@balena.io>
2018-12-12 20:56:14 -03:00
|
|
|
@applications.getExtraStateForComparison()
|
|
|
|
.then (extraState) =>
|
|
|
|
Promise.all([
|
|
|
|
@getCurrentForComparison()
|
|
|
|
@getTarget({ initial, intermediate })
|
|
|
|
])
|
|
|
|
.then ([ currentState, targetState ]) =>
|
2018-01-26 17:29:19 -08:00
|
|
|
@deviceConfig.getRequiredSteps(currentState, targetState)
|
2018-01-19 16:31:02 -03:00
|
|
|
.then (deviceConfigSteps) =>
|
|
|
|
if !_.isEmpty(deviceConfigSteps)
|
|
|
|
return deviceConfigSteps
|
|
|
|
else
|
Fix a race condition that could cause an unnecessary restart of a service immediately after download
Up to now, there was a slim but non-zero chance that an image would be downloaded between the call to `@getTarget` inside deviceState
(which gets the target state and creates Service objects using information from available images), and the call to
`@images.getAvailable` in ApplicationManager (which is used to determine whether we should keep waiting for a download or start the
service). If this race condition happened, then the ApplicationManager would infer that a service was ready to be started (because
the image appears as available), but would have incomplete information about the service because the image wasn't available when
the Service object was created. The result would be that the service would be started, and then immediately on the next applyTarget
the ApplicationManager would try to kill it and restart it to update it with the complete information from the image.
This patch changes this behavior by ensuring that all of the additional information about the current state, which includes available images,
is gathered *before* building the current and target states that we compare. This means that if the image is downloaded after the call to getAvailable, the Service might be constructed with all the information about the image, but it won't be started until the next pass, because ApplicationManager will treat it as still downloading.
Change-type: patch
Signed-off-by: Pablo Carranza Velez <pablo@balena.io>
2018-12-12 20:56:14 -03:00
|
|
|
@applications.getRequiredSteps(currentState, targetState, extraState, intermediate)
|
2018-01-19 16:31:02 -03:00
|
|
|
.then (steps) =>
|
2018-01-26 17:29:19 -08:00
|
|
|
if _.isEmpty(steps)
|
|
|
|
@emitAsync('apply-target-state-end', null)
|
2018-01-19 16:31:02 -03:00
|
|
|
if !intermediate
|
2017-11-01 00:24:54 -07:00
|
|
|
console.log('Finished applying target state')
|
2017-12-07 17:18:21 -08:00
|
|
|
@applications.timeSpentFetching = 0
|
2017-11-01 00:24:54 -07:00
|
|
|
@failedUpdates = 0
|
|
|
|
@lastSuccessfulUpdate = Date.now()
|
|
|
|
@reportCurrentState(update_failed: false, update_pending: false, update_downloaded: false)
|
2018-01-19 16:31:02 -03:00
|
|
|
return
|
|
|
|
if !intermediate
|
2017-11-01 00:24:54 -07:00
|
|
|
@reportCurrentState(update_pending: true)
|
2018-01-26 17:29:19 -08:00
|
|
|
if _.every(steps, (step) -> step.action == 'noop')
|
|
|
|
nextDelay = 1000
|
2018-01-19 16:31:02 -03:00
|
|
|
Promise.map steps, (step) =>
|
2018-01-26 17:29:19 -08:00
|
|
|
@applyStep(step, { force, initial, intermediate, skipLock })
|
2018-02-13 15:23:44 -08:00
|
|
|
.delay(nextDelay)
|
2018-01-19 16:31:02 -03:00
|
|
|
.then =>
|
2018-01-26 17:29:19 -08:00
|
|
|
@applyTarget({ force, initial, intermediate, skipLock })
|
|
|
|
.catch (err) =>
|
|
|
|
@applyError(err, { force, initial, intermediate })
|
2018-01-19 16:31:02 -03:00
|
|
|
|
2018-01-25 12:53:54 -08:00
|
|
|
pausingApply: (fn) =>
|
|
|
|
lock = =>
|
|
|
|
@_writeLock('pause').disposer (release) ->
|
|
|
|
release()
|
|
|
|
pause = =>
|
|
|
|
Promise.try =>
|
|
|
|
res = null
|
|
|
|
@applyBlocker = new Promise (resolve) ->
|
|
|
|
res = resolve
|
|
|
|
return res
|
|
|
|
.disposer (resolve) ->
|
|
|
|
resolve()
|
|
|
|
|
|
|
|
Promise.using lock(), ->
|
|
|
|
Promise.using pause(), ->
|
|
|
|
fn()
|
2018-01-19 16:31:02 -03:00
|
|
|
|
|
|
|
resumeNextApply: =>
|
|
|
|
@applyUnblocker?()
|
2018-01-25 12:53:54 -08:00
|
|
|
return
|
2017-11-01 00:24:54 -07:00
|
|
|
|
2017-12-07 17:18:21 -08:00
|
|
|
triggerApplyTarget: ({ force = false, delay = 0, initial = false } = {}) =>
|
2017-11-01 00:24:54 -07:00
|
|
|
if @applyInProgress
|
|
|
|
if !@scheduledApply?
|
|
|
|
@scheduledApply = { force, delay }
|
|
|
|
else
|
|
|
|
# If a delay has been set it's because we need to hold off before applying again,
|
|
|
|
# so we need to respect the maximum delay that has been passed
|
|
|
|
@scheduledApply.delay = Math.max(delay, @scheduledApply.delay)
|
|
|
|
@scheduledApply.force or= force
|
|
|
|
return
|
|
|
|
@applyInProgress = true
|
2018-01-19 16:31:02 -03:00
|
|
|
Promise.delay(delay)
|
|
|
|
.then =>
|
2017-12-07 17:18:21 -08:00
|
|
|
@lastApplyStart = process.hrtime()
|
2018-01-30 08:42:03 -08:00
|
|
|
console.log('Applying target state')
|
2017-12-07 17:18:21 -08:00
|
|
|
@applyTarget({ force, initial })
|
2018-02-13 15:23:44 -08:00
|
|
|
.finally =>
|
|
|
|
@applyInProgress = false
|
|
|
|
@reportCurrentState()
|
|
|
|
if @scheduledApply?
|
|
|
|
@triggerApplyTarget(@scheduledApply)
|
|
|
|
@scheduledApply = null
|
|
|
|
return null
|
2018-01-19 16:31:02 -03:00
|
|
|
|
|
|
|
applyIntermediateTarget: (intermediateTarget, { force = false, skipLock = false } = {}) =>
|
|
|
|
@intermediateTarget = _.cloneDeep(intermediateTarget)
|
|
|
|
@applyTarget({ intermediate: true, force, skipLock })
|
|
|
|
.then =>
|
|
|
|
@intermediateTarget = null
|