2017-11-01 00:24:54 -07:00
|
|
|
Promise = require 'bluebird'
|
|
|
|
_ = require 'lodash'
|
|
|
|
Lock = require 'rwlock'
|
|
|
|
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'
|
|
|
|
|
|
|
|
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-11-21 18:22:20 -08:00
|
|
|
{ ENOENT, EISDIR, NotFoundError } = require './lib/errors'
|
2017-11-01 00:24:54 -07:00
|
|
|
|
|
|
|
DeviceConfig = require './device-config'
|
|
|
|
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) ->
|
|
|
|
force = validation.checkTruthy(req.body.force) or validation.checkTruthy(lockOverride)
|
|
|
|
deviceState.executeStepAction({ action }, { force })
|
2018-02-13 15:23:44 -08:00
|
|
|
.then (response) ->
|
|
|
|
res.status(202).json(response)
|
|
|
|
.catch (err) ->
|
|
|
|
if err instanceof updateLock.UpdatesLockedError
|
|
|
|
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 = {}
|
|
|
|
_lock = new Lock()
|
|
|
|
@_writeLock = Promise.promisify(_lock.async.writeLock)
|
2017-11-29 13:32:57 -08:00
|
|
|
@_readLock = Promise.promisify(_lock.async.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: =>
|
|
|
|
@config.getMany([ 'appUpdatePollInterval', 'offlineMode' ])
|
|
|
|
.then (conf) =>
|
2018-02-13 15:23:44 -08:00
|
|
|
cycleTime = process.hrtime(@lastApplyStart)
|
|
|
|
cycleTimeMs = cycleTime[0] * 1000 + cycleTime[1] / 1e6
|
|
|
|
cycleTimeWithinInterval = cycleTimeMs - @applications.timeSpentFetching < 2 * conf.appUpdatePollInterval
|
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
|
|
|
applyTargetHealthy = conf.offlineMode 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:
|
|
|
|
filter:
|
|
|
|
belongs_to__application: app.appId
|
|
|
|
commit: app.commit
|
|
|
|
status: 'success'
|
|
|
|
expand:
|
|
|
|
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?
|
2018-08-26 17:17:11 -07:00
|
|
|
@logger.enable(validation.checkTruthy(changedConfig.loggingEnabled))
|
2018-02-19 17:36:57 -08:00
|
|
|
if changedConfig.apiSecret?
|
|
|
|
@reportCurrentState(api_secret: changedConfig.apiSecret)
|
|
|
|
|
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',
|
2018-11-27 14:29:55 +00:00
|
|
|
'targetStateSet', 'offlineMode'
|
2017-11-01 00:24:54 -07:00
|
|
|
])
|
|
|
|
.then (conf) =>
|
2018-11-21 18:22:20 -08:00
|
|
|
@applications.init()
|
2017-11-01 00:24:54 -07:00
|
|
|
.then =>
|
|
|
|
if !validation.checkTruthy(conf.initialConfigSaved)
|
|
|
|
@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) =>
|
|
|
|
if !conf.provisioned or (_.isEmpty(targetApps) and !validation.checkTruthy(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-11-27 14:29:55 +00:00
|
|
|
initNetworkChecks: ({ apiEndpoint, connectivityCheckEnabled, offlineMode }) =>
|
|
|
|
return if validation.checkTruthy(offlineMode)
|
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-09-28 14:32:38 +01:00
|
|
|
if localSource
|
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
|
|
|
|
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
|
|
|
|
pinDevice: JSON.stringify {
|
|
|
|
commit: commitToPin,
|
|
|
|
app: appToPin,
|
|
|
|
}
|
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 =>
|
|
|
|
if _.includes(@deviceConfig.validActions, 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
|
|
|
|
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 })
|
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 =>
|
|
|
|
Promise.join(
|
|
|
|
@getCurrentForComparison()
|
|
|
|
@getTarget({ initial, intermediate })
|
|
|
|
(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
|
2018-01-26 17:29:19 -08:00
|
|
|
@applications.getRequiredSteps(currentState, targetState, 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
|