Merge pull request #844 from balena-io/local-mode-unmanaged

Unmanaged + local mode fixes
This commit is contained in:
Pablo Carranza Vélez 2018-12-17 13:57:37 -03:00 committed by GitHub
commit d3d0e19a16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 157 additions and 80 deletions

View File

@ -57,9 +57,9 @@ module.exports = class APIBinder
@readyForUpdates = false
healthcheck: =>
@config.getMany([ 'appUpdatePollInterval', 'offlineMode', 'connectivityCheckEnabled' ])
@config.getMany([ 'appUpdatePollInterval', 'unmanaged', 'connectivityCheckEnabled' ])
.then (conf) =>
if conf.offlineMode
if conf.unmanaged
return true
timeSinceLastFetch = process.hrtime(@lastTargetStateFetch)
timeSinceLastFetchMs = timeSinceLastFetch[0] * 1000 + timeSinceLastFetch[1] / 1e6
@ -72,10 +72,10 @@ module.exports = class APIBinder
release()
initClient: =>
@config.getMany([ 'offlineMode', 'apiEndpoint', 'currentApiKey' ])
.then ({ offlineMode, apiEndpoint, currentApiKey }) =>
if offlineMode
console.log('Offline Mode is set, skipping API client initialization')
@config.getMany([ 'unmanaged', 'apiEndpoint', 'currentApiKey' ])
.then ({ unmanaged, apiEndpoint, currentApiKey }) =>
if unmanaged
console.log('Unmanaged Mode is set, skipping API client initialization')
return
baseUrl = url.resolve(apiEndpoint, '/v5/')
passthrough = _.cloneDeep(requestOpts)
@ -102,10 +102,10 @@ module.exports = class APIBinder
@loadBackupFromMigration(retryDelay)
start: =>
@config.getMany([ 'apiEndpoint', 'offlineMode', 'bootstrapRetryDelay' ])
.then ({ apiEndpoint, offlineMode, bootstrapRetryDelay }) =>
if offlineMode
console.log('Offline Mode is set, skipping API binder initialization')
@config.getMany([ 'apiEndpoint', 'unmanaged', 'bootstrapRetryDelay' ])
.then ({ apiEndpoint, unmanaged, bootstrapRetryDelay }) =>
if unmanaged
console.log('Unmanaged Mode is set, skipping API binder initialization')
# If we are offline because there is no apiEndpoint, there's a chance
# we've went through a deprovision. We need to set the initialConfigReported
# value to '', to ensure that when we do re-provision, we'll report
@ -258,15 +258,15 @@ module.exports = class APIBinder
provisionDependentDevice: (device) =>
@config.getMany([
'offlineMode'
'unmanaged'
'provisioned'
'apiTimeout'
'userId'
'deviceId'
])
.then (conf) =>
if conf.offlineMode
throw new Error('Cannot provision dependent device in offline mode')
if conf.unmanaged
throw new Error('Cannot provision dependent device in unmanaged mode')
if !conf.provisioned
throw new Error('Device must be provisioned to provision a dependent device')
# TODO: when API supports it as per https://github.com/resin-io/hq/pull/949 remove userId
@ -283,13 +283,13 @@ module.exports = class APIBinder
patchDevice: (id, updatedFields) =>
@config.getMany([
'offlineMode'
'unmanaged'
'provisioned'
'apiTimeout'
])
.then (conf) =>
if conf.offlineMode
throw new Error('Cannot update dependent device in offline mode')
if conf.unmanaged
throw new Error('Cannot update dependent device in unmanaged mode')
if !conf.provisioned
throw new Error('Device must be provisioned to update a dependent device')
@balenaApi.patch
@ -338,6 +338,9 @@ module.exports = class APIBinder
(currentState, targetConfig, defaultConfig, deviceId) =>
currentConfig = currentState.local.config
Promise.mapSeries _.toPairs(currentConfig), ([ key, value ]) =>
# We want to disable local mode when joining a cloud
if key == 'SUPERVISOR_LOCAL_MODE'
value = 'false'
# We never want to disable VPN if, for instance, it failed to start so far
if key == 'SUPERVISOR_VPN_CONTROL'
value = 'true'

View File

@ -9,7 +9,6 @@ import supervisorVersion = require('../lib/supervisor-version');
import * as constants from '../lib/constants';
import * as osRelease from '../lib/os-release';
import { ConfigValue } from '../lib/types';
import { checkTruthy } from '../lib/validation';
// A provider for schema entries with source 'func'
type ConfigProviderFunctionGetter = () => Bluebird<any>;
@ -47,17 +46,6 @@ export function createProviderFunctions(
});
},
},
offlineMode: {
get: () => {
return config
.getMany(['apiEndpoint', 'supervisorOfflineMode'])
.then(({ apiEndpoint, supervisorOfflineMode }) => {
return (
checkTruthy(supervisorOfflineMode as boolean) || !apiEndpoint
);
});
},
},
provisioned: {
get: () => {
return config
@ -149,5 +137,12 @@ export function createProviderFunctions(
]);
},
},
unmanaged: {
get: () => {
return config.get('apiEndpoint').then(apiEndpoint => {
return !apiEndpoint;
});
},
},
};
}

View File

@ -46,14 +46,11 @@ class Config extends EventEmitter {
default: constants.defaultMixpanelToken,
},
bootstrapRetryDelay: { source: 'config.json', default: 30000 },
supervisorOfflineMode: { source: 'config.json', default: false },
hostname: { source: 'config.json', mutable: true },
persistentLogging: { source: 'config.json', default: false, mutable: true },
localMode: { source: 'config.json', mutable: true, default: 'false' },
version: { source: 'func' },
currentApiKey: { source: 'func' },
offlineMode: { source: 'func' },
provisioned: { source: 'func' },
osVersion: { source: 'func' },
osVariant: { source: 'func' },
@ -61,6 +58,7 @@ class Config extends EventEmitter {
mixpanelHost: { source: 'func' },
extendedEnvOptions: { source: 'func' },
fetchOptions: { source: 'func' },
unmanaged: { source: 'func' },
// NOTE: all 'db' values are stored and loaded as *strings*,
apiSecret: { source: 'db', mutable: true },
@ -82,6 +80,7 @@ class Config extends EventEmitter {
pinDevice: { source: 'db', mutable: true, default: 'null' },
currentCommit: { source: 'db', mutable: true },
targetStateSet: { source: 'db', mutable: true, default: 'false' },
localMode: { source: 'db', mutable: true, default: 'false' },
};
public constructor({ db, configPath }: ConfigOpts) {
@ -187,7 +186,7 @@ class Config extends EventEmitter {
if (oldValues[key] !== value) {
return this.db.upsertModel(
'config',
{ key, value },
{ key, value: (value || '').toString() },
{ key },
tx,
);
@ -281,15 +280,15 @@ class Config extends EventEmitter {
'uuid',
'deviceApiKey',
'apiSecret',
'offlineMode',
]).then(({ uuid, deviceApiKey, apiSecret, offlineMode }) => {
'unmanaged',
]).then(({ uuid, deviceApiKey, apiSecret, unmanaged }) => {
// These fields need to be set regardless
if (uuid == null || apiSecret == null) {
uuid = uuid || this.newUniqueKey();
apiSecret = apiSecret || this.newUniqueKey();
}
return this.set({ uuid, apiSecret }).then(() => {
if (offlineMode) {
if (unmanaged) {
return;
}
if (!deviceApiKey) {

View File

@ -13,6 +13,7 @@ module.exports = class DeviceConfig
@rebootRequired = false
@configKeys = {
appUpdatePollInterval: { envVarName: 'SUPERVISOR_POLL_INTERVAL', varType: 'int', defaultValue: '60000' }
localMode: { envVarName: 'SUPERVISOR_LOCAL_MODE', varType: 'bool', defaultValue: 'false' }
connectivityCheckEnabled: { envVarName: 'SUPERVISOR_CONNECTIVITY_CHECK', varType: 'bool', defaultValue: 'true' }
loggingEnabled: { envVarName: 'SUPERVISOR_LOG_CONTROL', varType: 'bool', defaultValue: 'true' }
delta: { envVarName: 'SUPERVISOR_DELTA', varType: 'bool', defaultValue: 'false' }
@ -74,11 +75,16 @@ module.exports = class DeviceConfig
db('deviceConfig').update(confToUpdate)
getTarget: ({ initial = false } = {}) =>
@db.models('deviceConfig').select('targetValues')
.then ([ devConfig ]) =>
Promise.all([
@config.get('unmanaged')
@db.models('deviceConfig').select('targetValues')
])
.then ([unmanaged, [ devConfig ]]) =>
conf = JSON.parse(devConfig.targetValues)
if initial or !conf.SUPERVISOR_VPN_CONTROL?
conf.SUPERVISOR_VPN_CONTROL = 'true'
if unmanaged and !conf.SUPERVISOR_LOCAL_MODE?
conf.SUPERVISOR_LOCAL_MODE = 'true'
for own k, { envVarName, defaultValue } of @configKeys
conf[envVarName] ?= defaultValue
return conf
@ -131,10 +137,10 @@ module.exports = class DeviceConfig
target = _.clone(targetState.local?.config ? {})
steps = []
Promise.all [
@config.getMany([ 'deviceType', 'offlineMode' ])
@config.getMany([ 'deviceType', 'unmanaged' ])
@getConfigBackend()
]
.then ([{ deviceType, offlineMode }, configBackend ]) =>
.then ([{ deviceType, unmanaged }, configBackend ]) =>
configChanges = {}
humanReadableConfigChanges = {}
match = {
@ -162,7 +168,7 @@ module.exports = class DeviceConfig
return
# Check if we need to perform special case actions for the VPN
if !checkTruthy(offlineMode) &&
if !checkTruthy(unmanaged) &&
!_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) &&
@checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL')
steps.push({

View File

@ -140,12 +140,12 @@ module.exports = class DeviceState extends EventEmitter
@applications.on('change', @reportCurrentState)
healthcheck: =>
@config.getMany([ 'appUpdatePollInterval', 'offlineMode' ])
@config.getMany([ 'appUpdatePollInterval', 'unmanaged' ])
.then (conf) =>
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.unmanaged or !@applyInProgress or @applications.fetchesInProgress > 0 or cycleTimeWithinInterval
return applyTargetHealthy
migrateLegacyApps: (balenaApi) =>
@ -255,7 +255,7 @@ module.exports = class DeviceState extends EventEmitter
@config.getMany([
'initialConfigSaved', 'listenPort', 'apiSecret', 'osVersion', 'osVariant',
'version', 'provisioned', 'apiEndpoint', 'connectivityCheckEnabled', 'legacyAppsPresent',
'targetStateSet', 'offlineMode'
'targetStateSet', 'unmanaged'
])
.then (conf) =>
@applications.init()
@ -297,8 +297,8 @@ module.exports = class DeviceState extends EventEmitter
.then =>
@triggerApplyTarget({ initial: true })
initNetworkChecks: ({ apiEndpoint, connectivityCheckEnabled, offlineMode }) =>
return if validation.checkTruthy(offlineMode)
initNetworkChecks: ({ apiEndpoint, connectivityCheckEnabled, unmanaged }) =>
return if validation.checkTruthy(unmanaged)
network.startConnectivityCheck apiEndpoint, connectivityCheckEnabled, (connected) =>
@connected = connected
@config.on 'change', (changedConfig) ->
@ -351,7 +351,7 @@ module.exports = class DeviceState extends EventEmitter
.then =>
@deviceConfig.setTarget(target.local.config, trx)
.then =>
if localSource
if localSource or not apiEndpoint
@applications.setTarget(target.local.apps, target.dependent, 'local', trx)
else
@applications.setTarget(target.local.apps, target.dependent, apiEndpoint, trx)

View File

@ -11,7 +11,7 @@ export type EventTrackProperties = Dictionary<any>;
interface InitArgs {
uuid: string;
offlineMode: boolean;
unmanaged: boolean;
mixpanelHost: { host: string; path: string } | null;
mixpanelToken: string;
}
@ -41,7 +41,7 @@ export class EventTracker {
}
public init({
offlineMode,
unmanaged,
mixpanelHost,
mixpanelToken,
uuid,
@ -52,7 +52,7 @@ export class EventTracker {
uuid,
supervisorVersion,
};
if (offlineMode || mixpanelHost == null) {
if (unmanaged || mixpanelHost == null) {
return;
}
this.client = Mixpanel.init(mixpanelToken, {

View File

@ -40,9 +40,20 @@ export class LocalModeManager {
}
});
const localMode = checkTruthy(
(await this.config.get('localMode')) || false,
);
// On startup, check if we're in unmanaged mode,
// as local mode needs to be set
let unmanagedLocalMode = false;
if (checkTruthy((await this.config.get('unmanaged')) || false)) {
console.log('Starting up in unmanaged mode, activating local mode');
await this.config.set({ localMode: true });
unmanagedLocalMode = true;
}
const localMode =
// short circuit the next get if we know we're in local mode
unmanagedLocalMode ||
checkTruthy((await this.config.get('localMode')) || false);
if (!localMode) {
// Remove any leftovers if necessary
await this.removeLocalModeArtifacts();

View File

@ -17,7 +17,7 @@ interface LoggerSetupOptions {
apiEndpoint: string;
uuid: string;
deviceApiKey: string;
offlineMode: boolean;
unmanaged: boolean;
enableLogs: boolean;
localMode: boolean;
}
@ -59,7 +59,7 @@ export class Logger {
apiEndpoint,
uuid,
deviceApiKey,
offlineMode,
unmanaged,
enableLogs,
localMode,
}: LoggerSetupOptions) {
@ -68,7 +68,7 @@ export class Logger {
this.backend = localMode ? this.localBackend : this.balenaBackend;
this.backend.offlineMode = offlineMode;
this.backend.unmanaged = unmanaged;
this.backend.publishEnabled = enableLogs;
}

View File

@ -70,7 +70,7 @@ export class BalenaLogBackend extends LogBackend {
}
public log(message: LogMessage) {
if (this.offlineMode || !this.publishEnabled) {
if (this.unmanaged || !this.publishEnabled) {
return;
}

View File

@ -1,7 +1,7 @@
export type LogMessage = Dictionary<any>;
export abstract class LogBackend {
public offlineMode: boolean;
public unmanaged: boolean;
public publishEnabled: boolean = true;
public abstract log(message: LogMessage): void;

41
src/migrations/M00002.js Normal file
View File

@ -0,0 +1,41 @@
const fs = require('fs');
const configJsonPath = process.env.CONFIG_MOUNT_POINT;
const { checkTruthy } = require('../lib/validation');
exports.up = function (knex, Promise) {
return new Promise(resolve => {
if (!configJsonPath) {
console.log('Unable to locate config.json! Things may fail unexpectedly!');
return resolve(false);
}
fs.readFile(configJsonPath, (err, data) => {
if (err) {
console.log('Failed to read config.json! Things may fail unexpectedly!');
return resolve();
}
try {
const parsed = JSON.parse(data.toString());
if (parsed.localMode != null) {
return resolve(checkTruthy(parsed.localMode));
}
return resolve(false);
} catch (e) {
console.log('Failed to parse config.json! Things may fail unexpectedly!');
return resolve(false);
}
});
}).then(localMode => {
// We can be sure that this does not already exist in the db because of the previous
// migration
return knex('config').insert({
key: 'localMode',
value: localMode.toString(),
});
});
};
exports.down = function (knex, Promise) {
return Promise.reject(new Error('Not Implemented'));
};

View File

@ -11,19 +11,23 @@ authenticate = (config) ->
header = req.get('Authorization') ? ''
match = header.match(/^ApiKey (\w+)$/)
headerKey = match?[1]
config.getMany([ 'apiSecret', 'localMode' ])
config.getMany([ 'apiSecret', 'localMode', 'unmanaged', 'osVariant' ])
.then (conf) ->
if queryKey? && bufferEq(new Buffer(queryKey), new Buffer(conf.apiSecret))
next()
else if headerKey? && bufferEq(new Buffer(headerKey), new Buffer(conf.apiSecret))
next()
else if checkTruthy(conf.localMode)
next()
needsAuth = if conf.unmanaged
conf.osVariant is 'prod'
else
res.sendStatus(401)
not conf.localMode
if needsAuth
key = queryKey ? headerKey
if bufferEq(Buffer.from(key), Buffer.from(conf.apiSecret))
next()
else
res.sendStatus(401)
else
next()
.catch (err) ->
# This should never happen...
res.status(503).send('Invalid API key in supervisor')
res.status(503).send("Unexpected error: #{err}")
module.exports = class SupervisorAPI
constructor: ({ @config, @eventTracker, @routers, @healthchecks }) ->

View File

@ -17,7 +17,7 @@ startupConfigFields = [
'apiEndpoint'
'apiSecret'
'apiTimeout'
'offlineMode'
'unmanaged'
'deviceApiKey'
'mixpanelToken'
'mixpanelHost'
@ -57,7 +57,7 @@ module.exports = class Supervisor extends EventEmitter
apiEndpoint: conf.apiEndpoint,
uuid: conf.uuid,
deviceApiKey: conf.deviceApiKey,
offlineMode: checkTruthy(conf.offlineMode),
unmanaged: checkTruthy(conf.unmanaged),
enableLogs: checkTruthy(conf.loggingEnabled),
localMode: checkTruthy(conf.localMode)
})

View File

@ -21,6 +21,7 @@ mockedInitialConfig = {
'RESIN_SUPERVISOR_DELTA_RETRY_COUNT': '30'
'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL': '10000'
'RESIN_SUPERVISOR_DELTA_VERSION': '2'
'RESIN_SUPERVISOR_LOCAL_MODE': 'false'
'RESIN_SUPERVISOR_LOG_CONTROL': 'true'
'RESIN_SUPERVISOR_OVERRIDE_LOCK': 'false'
'RESIN_SUPERVISOR_POLL_INTERVAL': '60000'
@ -39,6 +40,7 @@ testTarget1 = {
'SUPERVISOR_DELTA_RETRY_COUNT': '30'
'SUPERVISOR_DELTA_RETRY_INTERVAL': '10000'
'SUPERVISOR_DELTA_VERSION': '2'
'SUPERVISOR_LOCAL_MODE': 'false'
'SUPERVISOR_LOG_CONTROL': 'true'
'SUPERVISOR_OVERRIDE_LOCK': 'false'
'SUPERVISOR_POLL_INTERVAL': '60000'
@ -120,6 +122,7 @@ testTargetWithDefaults2 = {
'SUPERVISOR_DELTA_RETRY_COUNT': '30'
'SUPERVISOR_DELTA_RETRY_INTERVAL': '10000'
'SUPERVISOR_DELTA_VERSION': '2'
'SUPERVISOR_LOCAL_MODE': 'false'
'SUPERVISOR_LOG_CONTROL': 'true'
'SUPERVISOR_OVERRIDE_LOCK': 'false'
'SUPERVISOR_POLL_INTERVAL': '60000'

View File

@ -23,9 +23,9 @@ describe 'EventTracker', ->
EventTracker.prototype.logEvent.restore()
mixpanel.init.restore()
it 'initializes in offline mode', ->
it 'initializes in unmanaged mode', ->
promise = @eventTrackerOffline.init({
offlineMode: true
unmanaged: true
uuid: 'foobar'
mixpanelHost: { host: '', path: '' }
})
@ -33,11 +33,11 @@ describe 'EventTracker', ->
.then =>
expect(@eventTrackerOffline.client).to.be.null
it 'logs events in offline mode, with the correct properties', ->
it 'logs events in unmanaged mode, with the correct properties', ->
@eventTrackerOffline.track('Test event', { appId: 'someValue' })
expect(@eventTrackerOffline.logEvent).to.be.calledWith('Event:', 'Test event', JSON.stringify({ appId: 'someValue' }))
it 'initializes a mixpanel client when not in offline mode', ->
it 'initializes a mixpanel client when not in unmanaged mode', ->
promise = @eventTracker.init({
mixpanelToken: 'someToken'
uuid: 'barbaz'

View File

@ -135,12 +135,12 @@ describe 'APIBinder', ->
@apiBinder.fetchDevice.restore()
balenaAPI.balenaBackend.deviceKeyHandler.restore()
describe 'offline mode', ->
describe 'unmanaged mode', ->
before ->
initModels.call(this, '/config-apibinder-offline.json')
it 'does not generate a key if the device is in offline mode', ->
@config.get('offlineMode').then (mode) =>
it 'does not generate a key if the device is in unmanaged mode', ->
@config.get('unmanaged').then (mode) =>
# Ensure offline mode is set
expect(mode).to.equal(true)
# Check that there is no deviceApiKey
@ -148,12 +148,12 @@ describe 'APIBinder', ->
expect(conf['deviceApiKey']).to.be.empty
expect(conf['uuid']).to.not.be.undefined
describe 'Minimal config offline mode', ->
describe 'Minimal config unmanaged mode', ->
before ->
initModels.call(this, '/config-apibinder-offline2.json')
it 'does not generate a key with the minimal config', ->
@config.get('offlineMode').then (mode) =>
@config.get('unmanaged').then (mode) =>
expect(mode).to.equal(true)
@config.getMany([ 'deviceApiKey', 'uuid' ]).then (conf) ->
expect(conf['deviceApiKey']).to.be.empty

View File

@ -31,7 +31,7 @@ describe 'Logger', ->
apiEndpoint: 'https://example.com'
uuid: 'deadbeef'
deviceApiKey: 'secretkey'
offlineMode: false
unmanaged: false
enableLogs: true
localMode: false
})

View File

@ -1 +1,16 @@
{"applicationName":"supertestrpi3","applicationId":78373,"deviceType":"raspberrypi3","userId":1001,"username":"someone","appUpdatePollInterval":3000,"listenPort":2345,"vpnPort":443,"apiEndpoint":"http://0.0.0.0:3000","vpnEndpoint":"vpn.resin.io","registryEndpoint":"registry2.resin.io","deltaEndpoint":"https://delta.resin.io","mixpanelToken":"baz","apiKey":"boo","version":"2.0.6+rev3.prod","supervisorOfflineMode":true}
{
"applicationName": "supertestrpi3",
"applicationId": 78373,
"deviceType": "raspberrypi3",
"userId": 1001,
"username": "someone",
"appUpdatePollInterval": 3000,
"listenPort": 2345,
"vpnPort": 443,
"vpnEndpoint": "vpn.resin.io",
"registryEndpoint": "registry2.resin.io",
"deltaEndpoint": "https://delta.resin.io",
"mixpanelToken": "baz",
"apiKey": "boo",
"version": "2.0.6+rev3.prod"
}