_ = require 'lodash' Promise = require 'bluebird' knex = require './db' utils = require './utils' { resinApi } = require './request' device = exports config = require './config' configPath = '/boot/config.json' request = Promise.promisifyAll(require('request')) execAsync = Promise.promisify(require('child_process').exec) fs = Promise.promisifyAll(require('fs')) exports.getID = do -> deviceIdPromise = null return -> # We initialise the rejected promise just before we catch in order to avoid a useless first unhandled error warning. deviceIdPromise ?= Promise.rejected() # Only fetch the device id once (when successful, otherwise retry for each request) deviceIdPromise = deviceIdPromise.catch -> Promise.all([ knex('config').select('value').where(key: 'apiKey') knex('config').select('value').where(key: 'uuid') ]) .spread ([{ value: apiKey }], [{ value: uuid }]) -> resinApi.get( resource: 'device' options: select: 'id' filter: uuid: uuid customOptions: apikey: apiKey ) .then (devices) -> if devices.length is 0 throw new Error('Could not find this device?!') return devices[0].id exports.reboot = -> request.postAsync(config.gosuperAddress + '/v1/reboot') exports.hostConfigEnvVarPrefix = 'RESIN_HOST_' bootConfigEnvVarPrefix = 'RESIN_HOST_CONFIG_' bootBlockDevice = '/dev/mmcblk0p1' bootMountPoint = '/mnt/root/boot' bootConfigPath = bootMountPoint + '/config.txt' configRegex = new RegExp('(' + _.escapeRegExp(bootConfigEnvVarPrefix) + ')(.+)') forbiddenConfigKeys = [ 'disable_commandline_tags' 'cmdline' 'kernel' 'kernel_address' 'kernel_old' 'ramfsfile' 'ramfsaddr' 'initramfs' 'device_tree_address' 'init_uart_baud' 'init_uart_clock' 'init_emmc_clock' 'boot_delay' 'boot_delay_ms' 'avoid_safe_mode' ] parseBootConfigFromEnv = (env) -> # We ensure env doesn't have garbage parsedEnv = _.pick env, (val, key) -> return _.startsWith(key, bootConfigEnvVarPrefix) parsedEnv = _.mapKeys parsedEnv, (val, key) -> key.replace(configRegex, '$2') parsedEnv = _.omit(parsedEnv, forbiddenConfigKeys) return parsedEnv exports.setHostConfig = (env, logMessage) -> Promise.join setBootConfig(env, logMessage), setLogToDisplay(env, logMessage), (bootConfigApplied, logToDisplayChanged) -> return (bootConfigApplied or logToDisplayChanged) setLogToDisplay = (env, logMessage) -> if env['RESIN_HOST_LOG_TO_DISPLAY']? enable = env['RESIN_HOST_LOG_TO_DISPLAY'] != '0' request.postAsync(config.gosuperAddress + '/v1/set-log-to-display', { json: true, body: Enable: enable }) .spread (response, body) -> if response.statusCode != 200 logMessage("Error setting log to display: #{body.Error}, Status:, #{response.statusCode}", { error: body.Error }, 'Set log to display error') return false else if body.Data == true logMessage("#{if enable then 'Enabled' else 'Disabled'} logs to display") return body.Data .catch (err) -> logMessage("Error setting log to display: #{err}", { error: err }, 'Set log to display error') return false else return Promise.resolve(false) setBootConfig = (env, logMessage) -> device.getDeviceType() .then (deviceType) -> throw new Error('This is not a Raspberry Pi') if !_.startsWith(deviceType, 'raspberry') Promise.join parseBootConfigFromEnv(env), fs.readFileAsync(bootConfigPath, 'utf8'), (configFromApp, configTxt ) -> throw new Error('No boot config to change') if _.isEmpty(configFromApp) configFromFS = {} configPositions = [] configStatements = configTxt.split(/\r?\n/) _.each configStatements, (configStr) -> keyValue = /^([^#=]+)=(.+)/.exec(configStr) if keyValue? configPositions.push(keyValue[1]) configFromFS[keyValue[1]] = keyValue[2] else # This will ensure config.txt filters are in order configPositions.push(configStr) # configFromApp and configFromFS now have compatible formats keysFromApp = _.keys(configFromApp) keysFromFS = _.keys(configFromFS) toBeAdded = _.difference(keysFromApp, keysFromFS) toBeChanged = _.intersection(keysFromApp, keysFromFS) toBeChanged = _.filter toBeChanged, (key) -> configFromApp[key] != configFromFS[key] throw new Error('Nothing to change') if _.isEmpty(toBeChanged) and _.isEmpty(toBeAdded) logMessage("Applying boot config: #{JSON.stringify(configFromApp)}", {}, 'Apply boot config in progress') # We add the keys to be added first so they are out of any filters outputConfig = _.map toBeAdded, (key) -> "#{key}=#{configFromApp[key]}" outputConfig = outputConfig.concat _.map configPositions, (key, index) -> configStatement = null if _.includes(toBeChanged, key) configStatement = "#{key}=#{configFromApp[key]}" else configStatement = configStatements[index] return configStatement # Here's the dangerous part: execAsync("mount -t vfat -o remount,rw #{bootBlockDevice} #{bootMountPoint}") .then -> fs.writeFileAsync(bootConfigPath + '.new', outputConfig.join('\n')) .then -> fs.renameAsync(bootConfigPath + '.new', bootConfigPath) .then -> execAsync('sync') .then -> logMessage("Applied boot config: #{JSON.stringify(configFromApp)}", {}, 'Apply boot config success') return true .catch (err) -> logMessage("Error setting boot config: #{err}", { error: err }, 'Apply boot config error') throw err .catch (err) -> console.log('Will not set boot config: ', err) return false exports.getDeviceType = do -> deviceTypePromise = null return -> deviceTypePromise ?= Promise.rejected() deviceTypePromise = deviceTypePromise.catch -> fs.readFileAsync(configPath, 'utf8') .then(JSON.parse) .then (configFromFile) -> if !configFromFile.deviceType? throw new Error('Device type not specified in config file') return configFromFile.deviceType do -> applyPromise = Promise.resolve() targetState = {} actualState = {} updateState = { update_pending: false, update_failed: false, update_downloaded: false } getStateDiff = -> _.omit targetState, (value, key) -> actualState[key] is value applyState = -> stateDiff = getStateDiff() if _.size(stateDiff) is 0 return applyPromise = Promise.join( knex('config').select('value').where(key: 'apiKey') device.getID() ([{ value: apiKey }], deviceID) -> stateDiff = getStateDiff() if _.size(stateDiff) is 0 || !apiKey? return resinApi.patch resource: 'device' id: deviceID body: stateDiff customOptions: apikey: apiKey .then -> # Update the actual state. _.merge(actualState, stateDiff) ) .catch (error) -> utils.mixpanelTrack('Device info update failure', { error, stateDiff }) # Delay 5s before retrying a failed update Promise.delay(5000) .finally -> # Check if any more state diffs have appeared whilst we've been processing this update. applyState() exports.setUpdateState = (value) -> _.merge(updateState, value) exports.getState = -> fieldsToOmit = ['api_secret', 'logs_channel', 'provisioning_progress', 'provisioning_state'] state = _.omit(targetState, fieldsToOmit) _.merge(state, updateState) return state # Calling this function updates the local device state, which is then used to synchronise # the remote device state, repeating any failed updates until successfully synchronised. # This function will also optimise updates by merging multiple updates and only sending the latest state. exports.updateState = (updatedState = {}, retry = false) -> # Remove any updates that match the last we successfully sent. _.merge(targetState, updatedState) # Only trigger applying state if an apply isn't already in progress. if !applyPromise.isPending() applyState() return exports.getOSVersion = -> return utils.getOSVersion(config.hostOsVersionPath)