diff --git a/src/api-binder.ts b/src/api-binder.ts index b9de9359..bedd654e 100644 --- a/src/api-binder.ts +++ b/src/api-binder.ts @@ -5,7 +5,7 @@ import * as express from 'express'; import { isLeft } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import * as _ from 'lodash'; -import { PinejsClientRequest } from 'pinejs-client-request'; +import { PinejsClientRequest, StatusError } from 'pinejs-client-request'; import * as url from 'url'; import * as deviceRegister from './lib/register-device'; @@ -17,9 +17,7 @@ import { loadBackupFromMigration } from './lib/migration'; import { ContractValidationError, ContractViolationError, - ExchangeKeyError, InternalInconsistencyError, - isHttpConflictError, } from './lib/errors'; import * as request from './lib/request'; @@ -28,14 +26,21 @@ import log from './lib/supervisor-console'; import DeviceState from './device-state'; import * as globalEventBus from './event-bus'; import * as TargetState from './device-state/target-state'; -import * as CurrentState from './device-state/current-state'; import * as logger from './logger'; -interface Device { - id: number; +import * as apiHelper from './lib/api-helper'; +import { Device } from './lib/api-helper'; +import { DeviceStatus } from './types/state'; +import constants = require('./lib/constants'); - [key: string]: unknown; -} +// The exponential backoff starts at 15s +const MINIMUM_BACKOFF_DELAY = 15000; + +const INTERNAL_STATE_KEYS = [ + 'update_pending', + 'update_downloaded', + 'update_failed', +]; interface DevicePinInfo { app: number; @@ -48,390 +53,470 @@ interface DeviceTag { value: string; } -type KeyExchangeOpts = config.ConfigType<'provisioningOptions'>; +export let deviceState: DeviceState; +const lastReportedState: DeviceStatus = { + local: {}, + dependent: {}, +}; +const stateForReport: DeviceStatus = { + local: {}, + dependent: {}, +}; +let reportPending = false; +export let stateReportErrors = 0; +let readyForUpdates = false; -export class APIBinder { - public router: express.Router; +export function setDeviceState(newState: DeviceState) { + deviceState = newState; +} - private deviceState: DeviceState; +export async function healthcheck() { + const { + appUpdatePollInterval, + unmanaged, + connectivityCheckEnabled, + } = await config.getMany([ + 'appUpdatePollInterval', + 'unmanaged', + 'connectivityCheckEnabled', + ]); - public balenaApi: PinejsClientRequest | null = null; - private readyForUpdates = false; - - public constructor() { - this.router = this.createAPIBinderRouter(this); - } - - public setDeviceState(deviceState: DeviceState) { - this.deviceState = deviceState; - } - - public async healthcheck() { - const { - appUpdatePollInterval, - unmanaged, - connectivityCheckEnabled, - } = await config.getMany([ - 'appUpdatePollInterval', - 'unmanaged', - 'connectivityCheckEnabled', - ]); - - // Don't have to perform checks for unmanaged - if (unmanaged) { - return true; - } - - if (appUpdatePollInterval == null) { - log.info( - 'Healthcheck failure - Config value `appUpdatePollInterval` cannot be null', - ); - return false; - } - - // Check last time target state has been polled - const timeSinceLastFetch = process.hrtime(TargetState.lastFetch); - const timeSinceLastFetchMs = - timeSinceLastFetch[0] * 1000 + timeSinceLastFetch[1] / 1e6; - - if (!(timeSinceLastFetchMs < 2 * appUpdatePollInterval)) { - log.info( - 'Healthcheck failure - Device has not fetched target state within appUpdatePollInterval limit', - ); - return false; - } - - // Check if state report is healthy - const stateReportHealthy = - !connectivityCheckEnabled || - !this.deviceState.connected || - CurrentState.stateReportErrors < 3; - - if (!stateReportHealthy) { - log.info( - stripIndent` - Healthcheck failure - At least ONE of the following conditions must be true: - - No connectivityCheckEnabled ? ${!(connectivityCheckEnabled === true)} - - device state is disconnected ? ${!(this.deviceState.connected === true)} - - stateReportErrors less then 3 ? ${CurrentState.stateReportErrors < 3}`, - ); - return false; - } - - // All tests pass! + // Don't have to perform checks for unmanaged + if (unmanaged) { return true; } - public async initClient() { - const { unmanaged, apiEndpoint, currentApiKey } = await config.getMany([ - 'unmanaged', - 'apiEndpoint', - 'currentApiKey', - ]); - - if (unmanaged) { - log.debug('Unmanaged mode is set, skipping API client initialization'); - return; - } - - const baseUrl = url.resolve(apiEndpoint, '/v6/'); - const passthrough = _.cloneDeep(await request.getRequestOptions()); - passthrough.headers = - passthrough.headers != null ? passthrough.headers : {}; - passthrough.headers.Authorization = `Bearer ${currentApiKey}`; - this.balenaApi = new PinejsClientRequest({ - apiPrefix: baseUrl, - passthrough, - }); + if (appUpdatePollInterval == null) { + log.info( + 'Healthcheck failure - Config value `appUpdatePollInterval` cannot be null', + ); + return false; } - public async start() { - const conf = await config.getMany([ - 'apiEndpoint', - 'unmanaged', - 'bootstrapRetryDelay', - 'initialDeviceName', - ]); - let { apiEndpoint } = conf; - const { unmanaged, bootstrapRetryDelay } = conf; + // Check last time target state has been polled + const timeSinceLastFetch = process.hrtime(TargetState.lastFetch); + const timeSinceLastFetchMs = + timeSinceLastFetch[0] * 1000 + timeSinceLastFetch[1] / 1e6; - if (unmanaged) { - log.info('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 - // the config and hardward-specific options won't be lost - if (!apiEndpoint) { - await config.set({ initialConfigReported: '' }); - } - return; + if (!(timeSinceLastFetchMs < 2 * appUpdatePollInterval)) { + log.info( + 'Healthcheck failure - Device has not fetched target state within appUpdatePollInterval limit', + ); + return false; + } + + // Check if state report is healthy + const stateReportHealthy = + !connectivityCheckEnabled || + !deviceState.connected || + stateReportErrors < 3; + + if (!stateReportHealthy) { + log.info( + stripIndent` + Healthcheck failure - At least ONE of the following conditions must be true: + - No connectivityCheckEnabled ? ${!(connectivityCheckEnabled === true)} + - device state is disconnected ? ${!(deviceState.connected === true)} + - stateReportErrors less then 3 ? ${stateReportErrors < 3}`, + ); + return false; + } + + // All tests pass! + return true; +} + +export async function start() { + const conf = await config.getMany([ + 'apiEndpoint', + 'unmanaged', + 'bootstrapRetryDelay', + 'initialDeviceName', + ]); + let { apiEndpoint } = conf; + const { unmanaged, bootstrapRetryDelay } = conf; + + if (unmanaged) { + log.info('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 + // the config and hardward-specific options won't be lost + if (!apiEndpoint) { + await config.set({ initialConfigReported: '' }); } + return; + } - log.debug('Ensuring device is provisioned'); - await this.provisionDevice(); - const conf2 = await config.getMany([ - 'initialConfigReported', - 'apiEndpoint', - ]); - apiEndpoint = conf2.apiEndpoint; - const { initialConfigReported } = conf2; + log.debug('Ensuring device is provisioned'); + await provisionDevice(); + const conf2 = await config.getMany(['initialConfigReported', 'apiEndpoint']); + apiEndpoint = conf2.apiEndpoint; + const { initialConfigReported } = conf2; - // Either we haven't reported our initial config or we've been re-provisioned - if (apiEndpoint !== initialConfigReported) { - log.info('Reporting initial configuration'); - // We fetch the deviceId here to ensure it's been set - const deviceId = await config.get('deviceId'); - if (deviceId == null) { - throw new InternalInconsistencyError( - `Attempt to report initial configuration without a device ID`, - ); - } - await this.reportInitialConfig( - apiEndpoint, - deviceId, - bootstrapRetryDelay, - conf.initialDeviceName ?? undefined, + // Either we haven't reported our initial config or we've been re-provisioned + if (apiEndpoint !== initialConfigReported) { + log.info('Reporting initial configuration'); + // We fetch the deviceId here to ensure it's been set + const deviceId = await config.get('deviceId'); + if (deviceId == null) { + throw new InternalInconsistencyError( + `Attempt to report initial configuration without a device ID`, ); } - - log.debug('Starting current state report'); - await CurrentState.startReporting(this.deviceState); - - // When we've provisioned, try to load the backup. We - // must wait for the provisioning because we need a - // target state on which to apply the backup - globalEventBus.getInstance().once('targetStateChanged', async (state) => { - await loadBackupFromMigration( - this.deviceState, - state, - bootstrapRetryDelay, - ); - }); - - this.readyForUpdates = true; - log.debug('Starting target state poll'); - TargetState.startPoll(); - TargetState.emitter.on( - 'target-state-update', - async (targetState, force, isFromApi) => { - try { - await this.deviceState.setTarget(targetState); - this.deviceState.triggerApplyTarget({ force, isFromApi }); - } catch (err) { - if ( - err instanceof ContractValidationError || - err instanceof ContractViolationError - ) { - log.error(`Could not store target state for device: ${err}`); - // the dashboard does not display lines correctly, - // split them explcitly here - const lines = err.message.split(/\r?\n/); - lines[0] = `Could not move to new release: ${lines[0]}`; - for (const line of lines) { - logger.logSystemMessage(line, {}, 'targetStateRejection', false); - } - } else { - log.error(`Failed to get target state for device: ${err}`); - } - } - }, + await reportInitialConfig( + apiEndpoint, + deviceId, + bootstrapRetryDelay, + conf.initialDeviceName ?? undefined, ); } - public async fetchDevice( - uuid: string, - apiKey: string, - timeout: number, - ): Promise { - if (this.balenaApi == null) { - throw new InternalInconsistencyError( - 'fetchDevice called without an initialized API client', + log.debug('Starting current state report'); + await startCurrentStateReport(); + + // When we've provisioned, try to load the backup. We + // must wait for the provisioning because we need a + // target state on which to apply the backup + globalEventBus.getInstance().once('targetStateChanged', async (state) => { + await loadBackupFromMigration(deviceState, state, bootstrapRetryDelay); + }); + + readyForUpdates = true; + log.debug('Starting target state poll'); + TargetState.startPoll(); + TargetState.emitter.on( + 'target-state-update', + async (targetState, force, isFromApi) => { + try { + await deviceState.setTarget(targetState); + deviceState.triggerApplyTarget({ force, isFromApi }); + } catch (err) { + if ( + err instanceof ContractValidationError || + err instanceof ContractViolationError + ) { + log.error(`Could not store target state for device: ${err}`); + // the dashboard does not display lines correctly, + // split them explcitly here + const lines = err.message.split(/\r?\n/); + lines[0] = `Could not move to new release: ${lines[0]}`; + for (const line of lines) { + logger.logSystemMessage(line, {}, 'targetStateRejection', false); + } + } else { + log.error(`Failed to get target state for device: ${err}`); + } + } + }, + ); +} + +export async function patchDevice( + id: number, + updatedFields: Dictionary, +) { + const conf = await config.getMany(['unmanaged', 'provisioned', 'apiTimeout']); + + if (conf.unmanaged) { + throw new Error('Cannot update device in unmanaged mode'); + } + + if (!conf.provisioned) { + throw new Error('DEvice must be provisioned to update a device'); + } + + if (balenaApi == null) { + throw new InternalInconsistencyError( + 'Attempt to patch device without an API client', + ); + } + + return Bluebird.resolve( + balenaApi.patch({ + resource: 'device', + id, + body: updatedFields, + }), + ).timeout(conf.apiTimeout); +} + +export async function provisionDependentDevice( + device: Device, +): Promise { + const conf = await config.getMany([ + 'unmanaged', + 'provisioned', + 'apiTimeout', + 'deviceId', + ]); + + 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', + ); + } + if (balenaApi == null) { + throw new InternalInconsistencyError( + 'Attempt to provision a dependent device without an API client', + ); + } + + _.defaults(device, { + is_managed_by__device: conf.deviceId, + uuid: deviceRegister.generateUniqueKey(), + registered_at: Math.floor(Date.now() / 1000), + }); + + return (await Bluebird.resolve( + balenaApi.post({ resource: 'device', body: device }), + ).timeout(conf.apiTimeout)) as Device; +} + +export function startCurrentStateReport() { + if (balenaApi == null) { + throw new InternalInconsistencyError( + 'Trying to start state reporting without initializing API client', + ); + } + deviceState.on('change', () => { + if (!reportPending) { + // A latency of 100ms should be acceptable and + // allows avoiding catching docker at weird states + reportCurrentState(); + } + }); + reportCurrentState(); +} + +export async function fetchDeviceTags(): Promise { + if (balenaApi == null) { + throw new Error( + 'Attempt to communicate with API, without initialized client', + ); + } + + const deviceId = await config.get('deviceId'); + if (deviceId == null) { + throw new Error('Attempt to retrieve device tags before provision'); + } + const tags = (await balenaApi.get({ + resource: 'device_tag', + options: { + $select: ['id', 'tag_key', 'value'], + $filter: { device: deviceId }, + }, + })); + + return tags.map((tag) => { + // Do some type safe decoding and throw if we get an unexpected value + const id = t.number.decode(tag.id); + const name = t.string.decode(tag.tag_key); + const value = t.string.decode(tag.value); + if (isLeft(id) || isLeft(name) || isLeft(value)) { + throw new Error( + `There was an error parsing device tags from the api. Device tag: ${JSON.stringify( + tag, + )}`, ); } + return { + id: id.right, + name: name.right, + value: value.right, + }; + }); +} - try { - return (await Bluebird.resolve( - this.balenaApi.get({ - resource: 'device', - id: { - uuid, - }, - passthrough: { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - }), - ).timeout(timeout)) as Device; - } catch (e) { - return null; +function getStateDiff(): DeviceStatus { + const lastReportedLocal = lastReportedState.local; + const lastReportedDependent = lastReportedState.dependent; + if (lastReportedLocal == null || lastReportedDependent == null) { + throw new InternalInconsistencyError( + `No local or dependent component of lastReportedLocal in ApiBinder.getStateDiff: ${JSON.stringify( + lastReportedState, + )}`, + ); + } + + const diff = { + local: _(stateForReport.local) + .omitBy((val, key: keyof DeviceStatus['local']) => + _.isEqual(lastReportedLocal[key], val), + ) + .omit(INTERNAL_STATE_KEYS) + .value(), + dependent: _(stateForReport.dependent) + .omitBy((val, key: keyof DeviceStatus['dependent']) => + _.isEqual(lastReportedDependent[key], val), + ) + .omit(INTERNAL_STATE_KEYS) + .value(), + }; + + return _.omitBy(diff, _.isEmpty); +} + +async function sendReportPatch( + stateDiff: DeviceStatus, + conf: { apiEndpoint: string; uuid: string; localMode: boolean }, +) { + if (balenaApi == null) { + throw new InternalInconsistencyError( + 'Attempt to send report patch without an API client', + ); + } + + let body = stateDiff; + if (conf.localMode) { + body = stripDeviceStateInLocalMode(stateDiff); + // In local mode, check if it still makes sense to send any updates after data strip. + if (_.isEmpty(body.local)) { + // Nothing to send. + return; } } - public async patchDevice(id: number, updatedFields: Dictionary) { - const conf = await config.getMany([ - 'unmanaged', - 'provisioned', - 'apiTimeout', - ]); + const endpoint = url.resolve( + conf.apiEndpoint, + `/device/v2/${conf.uuid}/state`, + ); - if (conf.unmanaged) { - throw new Error('Cannot update device in unmanaged mode'); - } + const requestParams = _.extend( + { + method: 'PATCH', + url: endpoint, + body, + }, + balenaApi.passthrough, + ); - if (!conf.provisioned) { - throw new Error('DEvice must be provisioned to update a device'); - } + await balenaApi._request(requestParams); +} - if (this.balenaApi == null) { - throw new InternalInconsistencyError( - 'Attempt to patch device without an API client', - ); - } +// Returns an object that contains only status fields relevant for the local mode. +// It basically removes information about applications state. +export function stripDeviceStateInLocalMode(state: DeviceStatus): DeviceStatus { + return { + local: _.cloneDeep( + _.omit(state.local, 'apps', 'is_on__commit', 'logs_channel'), + ), + }; +} - return Bluebird.resolve( - this.balenaApi.patch({ - resource: 'device', - id, - body: updatedFields, - }), +const report = _.throttle(async () => { + const stateDiff = getStateDiff(); + if (_.size(stateDiff) === 0) { + return 0; + } + + const conf = await config.getMany([ + 'deviceId', + 'apiTimeout', + 'apiEndpoint', + 'uuid', + 'localMode', + ]); + + const { apiEndpoint, uuid, localMode } = conf; + if (uuid == null || apiEndpoint == null) { + throw new InternalInconsistencyError( + 'No uuid or apiEndpoint provided to ApiBinder.report', + ); + } + + try { + await Bluebird.resolve( + sendReportPatch(stateDiff, { apiEndpoint, uuid, localMode }), ).timeout(conf.apiTimeout); - } - public async provisionDependentDevice(device: Device): Promise { - const conf = await config.getMany([ - 'unmanaged', - 'provisioned', - 'apiTimeout', - 'deviceId', - ]); - - 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', + stateReportErrors = 0; + _.assign(lastReportedState.local, stateDiff.local); + _.assign(lastReportedState.dependent, stateDiff.dependent); + } catch (e) { + if (e instanceof StatusError) { + // We don't want this to be classed as a report error, as this will cause + // the watchdog to kill the supervisor - and killing the supervisor will + // not help in this situation + log.error( + `Non-200 response from the API! Status code: ${e.statusCode} - message:`, + e, ); - } - if (this.balenaApi == null) { - throw new InternalInconsistencyError( - 'Attempt to provision a dependent device without an API client', - ); - } - - _.defaults(device, { - is_managed_by__device: conf.deviceId, - uuid: deviceRegister.generateUniqueKey(), - registered_at: Math.floor(Date.now() / 1000), - }); - - return (await Bluebird.resolve( - this.balenaApi.post({ resource: 'device', body: device }), - ).timeout(conf.apiTimeout)) as Device; - } - - public async fetchDeviceTags(): Promise { - if (this.balenaApi == null) { - throw new Error( - 'Attempt to communicate with API, without initialized client', - ); - } - - const deviceId = await config.get('deviceId'); - if (deviceId == null) { - throw new Error('Attempt to retrieve device tags before provision'); - } - const tags = await this.balenaApi.get({ - resource: 'device_tag', - options: { - $select: ['id', 'tag_key', 'value'], - $filter: { device: deviceId }, - }, - }); - - return tags.map((tag) => { - // Do some type safe decoding and throw if we get an unexpected value - const id = t.number.decode(tag.id); - const name = t.string.decode(tag.tag_key); - const value = t.string.decode(tag.value); - if (isLeft(id) || isLeft(name) || isLeft(value)) { - throw new Error( - `There was an error parsing device tags from the api. Device tag: ${JSON.stringify( - tag, - )}`, - ); - } - return { - id: id.right, - name: name.right, - value: value.right, - }; - }); - } - - private async pinDevice({ app, commit }: DevicePinInfo) { - if (this.balenaApi == null) { - throw new InternalInconsistencyError( - 'Attempt to pin device without an API client', - ); - } - - try { - const deviceId = await config.get('deviceId'); - - if (deviceId == null) { - throw new InternalInconsistencyError( - 'Device ID not defined in ApiBinder.pinDevice', - ); - } - - const release = await this.balenaApi.get({ - resource: 'release', - options: { - $filter: { - belongs_to__application: app, - commit, - status: 'success', - }, - $select: 'id', - }, - }); - - const releaseId = release[0]?.id; - if (releaseId == null) { - throw new Error( - 'Cannot continue pinning preloaded device! No release found!', - ); - } - - await this.balenaApi.patch({ - resource: 'device', - id: deviceId, - body: { - should_be_running__release: releaseId, - }, - }); - - // Set the config value for pinDevice to null, so that we know the - // task has been completed - await config.remove('pinDevice'); - } catch (e) { - log.error(`Could not pin device to release! ${e}`); + } else { throw e; } } +}, constants.maxReportFrequency); - // Creates the necessary config vars in the API to match the current device state, - // without overwriting any variables that are already set. - private async reportInitialEnv( - apiEndpoint: string, - deviceId: number, - initialName?: string, - ) { - if (this.balenaApi == null) { +function reportCurrentState(): null { + (async () => { + reportPending = true; + try { + const currentDeviceState = await deviceState.getStatus(); + _.assign(stateForReport.local, currentDeviceState.local); + _.assign(stateForReport.dependent, currentDeviceState.dependent); + + const stateDiff = getStateDiff(); + if (_.size(stateDiff) === 0) { + reportPending = false; + return null; + } + + await report(); + reportCurrentState(); + } catch (e) { + eventTracker.track('Device state report failure', { error: e }); + // We use the poll interval as the upper limit of + // the exponential backoff + const maxDelay = await config.get('appUpdatePollInterval'); + const delay = Math.min( + 2 ** stateReportErrors * MINIMUM_BACKOFF_DELAY, + maxDelay, + ); + + ++stateReportErrors; + await Bluebird.delay(delay); + reportCurrentState(); + } + })(); + return null; +} + +async function pinDevice({ app, commit }: DevicePinInfo) { + if (balenaApi == null) { + throw new InternalInconsistencyError( + 'Attempt to pin device without an API client', + ); + } + + try { + const deviceId = await config.get('deviceId'); + + if (deviceId == null) { throw new InternalInconsistencyError( - 'Attempt to report initial environment without an API client', + 'Device ID not defined in ApiBinder.pinDevice', + ); + } + + const release = await balenaApi.get({ + resource: 'release', + options: { + $filter: { + belongs_to__application: app, + commit, + status: 'success', + }, + $select: 'id', + }, + }); + + const releaseId: number | undefined = release?.[0]?.id; + if (releaseId == null) { + throw new Error( + 'Cannot continue pinning preloaded device! No release found!', ); } @@ -443,309 +528,242 @@ export class APIBinder { 'Attempt to report initial state with malformed target state', ); } - - const defaultConfig = deviceConfig.getDefaults(); - - const currentState = await this.deviceState.getCurrentForComparison(); - const targetConfig = deviceConfig.formatConfigKeys(targetConfigUnformatted); - - if (!currentState.local.config) { - throw new InternalInconsistencyError( - 'No config defined in reportInitialEnv', - ); - } - const currentConfig: Dictionary = currentState.local.config; - for (const [key, value] of _.toPairs(currentConfig)) { - let varValue = value; - // We want to disable local mode when joining a cloud - if (key === 'SUPERVISOR_LOCAL_MODE') { - varValue = 'false'; - } - // We never want to disable VPN if, for instance, it failed to start so far - if (key === 'SUPERVISOR_VPN_CONTROL') { - varValue = 'true'; - } - - if (targetConfig[key] == null && varValue !== defaultConfig[key]) { - const envVar = { - value: varValue, - device: deviceId, - name: 'RESIN_' + key, - }; - await this.balenaApi.post({ - resource: 'device_config_variable', - body: envVar, - }); - } - } - - if (initialName != null) { - await this.reportInitialName(deviceId, initialName); - } - - await config.set({ initialConfigReported: apiEndpoint }); - } - - private async reportInitialConfig( - apiEndpoint: string, - deviceId: number, - retryDelay: number, - initialName?: string, - ): Promise { - try { - await this.reportInitialEnv(apiEndpoint, deviceId, initialName); - } catch (err) { - log.error('Error reporting initial configuration, will retry', err); - await Bluebird.delay(retryDelay); - await this.reportInitialConfig( - apiEndpoint, - deviceId, - retryDelay, - initialName, - ); - } - } - - private async exchangeKeyAndGetDevice( - opts?: KeyExchangeOpts, - ): Promise { - if (opts == null) { - opts = await config.get('provisioningOptions'); - } - - const uuid = opts.uuid; - const apiTimeout = opts.apiTimeout; - if (!(uuid && apiTimeout)) { - throw new InternalInconsistencyError( - 'UUID and apiTimeout should be defined in exchangeKeyAndGetDevice', - ); - } - - // If we have an existing device key we first check if it's - // valid, becaise of it is we can just use that - if (opts.deviceApiKey != null) { - const deviceFromApi = await this.fetchDevice( - uuid, - opts.deviceApiKey, - apiTimeout, - ); - if (deviceFromApi != null) { - return deviceFromApi; - } - } - - // If it's not valid or doesn't exist then we try to use the - // user/provisioning api key for the exchange - if (!opts.provisioningApiKey) { - throw new InternalInconsistencyError( - 'Required a provisioning key in exchangeKeyAndGetDevice', - ); - } - const device = await this.fetchDevice( - uuid, - opts.provisioningApiKey, - apiTimeout, - ); - - if (device == null) { - throw new ExchangeKeyError(`Couldn't fetch device with provisioning key`); - } - - // We found the device so we can try to register a working device key for it - const [res] = await (await request.getRequestInstance()) - .postAsync(`${opts.apiEndpoint}/api-key/device/${device.id}/device-key`, { - json: true, - body: { - apiKey: opts.deviceApiKey, - }, - headers: { - Authorization: `Bearer ${opts.provisioningApiKey}`, - }, - }) - .timeout(apiTimeout); - - if (res.statusCode !== 200) { - throw new ExchangeKeyError( - `Couldn't register device key with provisioning key`, - ); - } - - return device; - } - - private async exchangeKeyAndGetDeviceOrRegenerate( - opts?: KeyExchangeOpts, - ): Promise { - try { - const device = await this.exchangeKeyAndGetDevice(opts); - log.debug('Key exchange succeeded'); - return device; - } catch (e) { - if (e instanceof ExchangeKeyError) { - log.error('Exchanging key failed, re-registering...'); - await config.regenerateRegistrationFields(); - } - throw e; - } - } - - private async provision() { - let device: Device | null = null; - const opts = await config.get('provisioningOptions'); - if ( - opts.registered_at == null || - opts.deviceId == null || - opts.provisioningApiKey != null - ) { - if (opts.registered_at != null && opts.deviceId == null) { - log.debug( - 'Device is registered but no device id available, attempting key exchange', - ); - device = (await this.exchangeKeyAndGetDeviceOrRegenerate(opts)) || null; - } else if (opts.registered_at == null) { - log.info('New device detected. Provisioning...'); - try { - device = await deviceRegister.register(opts).timeout(opts.apiTimeout); - } catch (err) { - if ( - err instanceof deviceRegister.ApiError && - isHttpConflictError(err.response) - ) { - log.debug('UUID already registered, trying a key exchange'); - device = await this.exchangeKeyAndGetDeviceOrRegenerate(opts); - } else { - throw err; - } - } - opts.registered_at = Date.now(); - } else if (opts.provisioningApiKey != null) { - log.debug( - 'Device is registered but we still have an apiKey, attempting key exchange', - ); - device = await this.exchangeKeyAndGetDevice(opts); - } - - if (!device) { - // TODO: Type this? - throw new Error(`Failed to provision device!`); - } - const { id } = device; - if (!this.balenaApi) { - throw new InternalInconsistencyError( - 'Attempting to provision a device without an initialized API client', - ); - } - this.balenaApi.passthrough.headers.Authorization = `Bearer ${opts.deviceApiKey}`; - - const configToUpdate = { - registered_at: opts.registered_at, - deviceId: id, - apiKey: null, - }; - await config.set(configToUpdate); - eventTracker.track('Device bootstrap success'); - } - - // Now check if we need to pin the device - const pinValue = await config.get('pinDevice'); - - if (pinValue != null) { - if (pinValue.app == null || pinValue.commit == null) { - log.error( - `Malformed pinDevice fields in supervisor database: ${pinValue}`, - ); - return; - } - log.info('Attempting to pin device to preloaded release...'); - return this.pinDevice(pinValue); - } - } - - private async provisionOrRetry(retryDelay: number): Promise { - eventTracker.track('Device bootstrap'); - try { - await this.provision(); - } catch (e) { - eventTracker.track(`Device bootstrap failed, retrying`, { - error: e, - delay: retryDelay, - }); - await Bluebird.delay(retryDelay); - return this.provisionOrRetry(retryDelay); - } - } - - private async provisionDevice() { - if (this.balenaApi == null) { - throw new Error( - 'Trying to provision a device without initializing API client', - ); - } - const conf = await config.getMany([ - 'provisioned', - 'bootstrapRetryDelay', - 'apiKey', - 'pinDevice', - ]); - - if (!conf.provisioned || conf.apiKey != null || conf.pinDevice != null) { - await this.provisionOrRetry(conf.bootstrapRetryDelay); - globalEventBus.getInstance().emit('deviceProvisioned'); - return; - } - - return conf; - } - - private createAPIBinderRouter(apiBinder: APIBinder): express.Router { - const router = express.Router(); - - router.use(bodyParser.urlencoded({ limit: '10mb', extended: true })); - router.use(bodyParser.json({ limit: '10mb' })); - - router.post('/v1/update', (req, res, next) => { - eventTracker.track('Update notification'); - if (apiBinder.readyForUpdates) { - config - .get('instantUpdates') - .then((instantUpdates) => { - if (instantUpdates) { - TargetState.update(req.body.force, true).catch(_.noop); - res.sendStatus(204); - } else { - log.debug( - 'Ignoring update notification because instant updates are disabled', - ); - res.sendStatus(202); - } - }) - .catch(next); - } else { - res.sendStatus(202); - } + await balenaApi.patch({ + resource: 'device', + id: deviceId, + body: { + should_be_running__release: releaseId, + }, }); - return router; + // Set the config value for pinDevice to null, so that we know the + // task has been completed + await config.remove('pinDevice'); + } catch (e) { + log.error(`Could not pin device to release! ${e}`); + throw e; + } +} + +// Creates the necessary config vars in the API to match the current device state, +// without overwriting any variables that are already set. +async function reportInitialEnv( + apiEndpoint: string, + deviceId: number, + initialName?: string, +) { + if (balenaApi == null) { + throw new InternalInconsistencyError( + 'Attempt to report initial environment without an API client', + ); } - private async reportInitialName( - deviceId: number, - name: string, - ): Promise { - if (this.balenaApi == null) { - throw new InternalInconsistencyError( - `Attempt to set an initial device name without an API client`, - ); + const targetConfigUnformatted = _.get( + await TargetState.get(), + 'local.config', + ); + if (targetConfigUnformatted == null) { + throw new InternalInconsistencyError( + 'Attempt to report initial state with malformed target state', + ); + } + + const defaultConfig = deviceConfig.getDefaults(); + + const currentState = await deviceState.getCurrentForComparison(); + const targetConfig = await deviceConfig.formatConfigKeys( + targetConfigUnformatted, + ); + + if (!currentState.local.config) { + throw new InternalInconsistencyError( + 'No config defined in reportInitialEnv', + ); + } + const currentConfig: Dictionary = currentState.local.config; + for (const [key, value] of _.toPairs(currentConfig)) { + let varValue = value; + // We want to disable local mode when joining a cloud + if (key === 'SUPERVISOR_LOCAL_MODE') { + varValue = 'false'; + } + // We never want to disable VPN if, for instance, it failed to start so far + if (key === 'SUPERVISOR_VPN_CONTROL') { + varValue = 'true'; } - await this.balenaApi.patch({ + if (targetConfig[key] == null && varValue !== defaultConfig[key]) { + const envVar = { + value: varValue, + device: deviceId, + name: 'RESIN_' + key, + }; + await balenaApi.post({ + resource: 'device_config_variable', + body: envVar, + }); + } + } + + if (initialName != null) { + await reportInitialName(deviceId, initialName); + } + + await config.set({ initialConfigReported: apiEndpoint }); +} + +async function reportInitialConfig( + apiEndpoint: string, + deviceId: number, + retryDelay: number, + initialName?: string, +): Promise { + try { + await reportInitialEnv(apiEndpoint, deviceId, initialName); + } catch (err) { + log.error('Error reporting initial configuration, will retry', err); + await Bluebird.delay(retryDelay); + await reportInitialConfig(apiEndpoint, deviceId, retryDelay, initialName); + } +} + +async function provision() { + if (!balenaApi) { + throw new InternalInconsistencyError( + 'Attempting to provision a device without an initialized API client', + ); + } + + const opts = await config.get('provisioningOptions'); + await apiHelper.provision(balenaApi, opts); + + // Now check if we need to pin the device + const pinValue = await config.get('pinDevice'); + + if (pinValue != null) { + if (pinValue.app == null || pinValue.commit == null) { + log.error( + `Malformed pinDevice fields in supervisor database: ${pinValue}`, + ); + return; + } + log.info('Attempting to pin device to preloaded release...'); + return pinDevice(pinValue); + } +} + +async function provisionOrRetry(retryDelay: number): Promise { + eventTracker.track('Device bootstrap'); + try { + await provision(); + } catch (e) { + eventTracker.track(`Device bootstrap failed, retrying`, { + error: e, + delay: retryDelay, + }); + await Bluebird.delay(retryDelay); + return provisionOrRetry(retryDelay); + } +} + +async function provisionDevice() { + if (balenaApi == null) { + throw new Error( + 'Trying to provision a device without initializing API client', + ); + } + + const conf = await config.getMany([ + 'apiKey', + 'bootstrapRetryDelay', + 'pinDevice', + 'provisioned', + ]); + + if (!conf.provisioned || conf.apiKey != null || conf.pinDevice != null) { + await provisionOrRetry(conf.bootstrapRetryDelay); + globalEventBus.getInstance().emit('deviceProvisioned'); + return; + } + + return conf; +} + +async function reportInitialName( + deviceId: number, + name: string, +): Promise { + if (balenaApi == null) { + throw new InternalInconsistencyError( + `Attempt to set an initial device name without an API client`, + ); + } + + try { + await balenaApi.patch({ resource: 'device', id: deviceId, body: { device_name: name, }, }); + } catch (err) { + log.error("Unable to report initial device name to API"); + logger.logSystemMessage("Unable to report initial device name to API", err, 'reportInitialNameError'); } } -export default APIBinder; +export let balenaApi: PinejsClientRequest | null = null; + +export const initialized = (async () => { + await config.initialized; + await eventTracker.initialized; + + const { unmanaged, apiEndpoint, currentApiKey } = await config.getMany([ + 'unmanaged', + 'apiEndpoint', + 'currentApiKey', + ]); + + if (unmanaged) { + log.debug('Unmanaged mode is set, skipping API client initialization'); + return; + } + + const baseUrl = url.resolve(apiEndpoint, '/v6/'); + const passthrough = _.cloneDeep(await request.getRequestOptions()); + passthrough.headers = passthrough.headers != null ? passthrough.headers : {}; + passthrough.headers.Authorization = `Bearer ${currentApiKey}`; + balenaApi = new PinejsClientRequest({ + apiPrefix: baseUrl, + passthrough, + }); + + log.info(`API Binder bound to: ${baseUrl}`); +})(); + +export const router = express.Router(); +router.use(bodyParser.urlencoded({ limit: '10mb', extended: true })); +router.use(bodyParser.json({ limit: '10mb' })); + +router.post('/v1/update', (req, res, next) => { + eventTracker.track('Update notification'); + if (readyForUpdates) { + config + .get('instantUpdates') + .then((instantUpdates) => { + if (instantUpdates) { + TargetState.update(req.body.force, true).catch(_.noop); + res.sendStatus(204); + } else { + log.debug( + 'Ignoring update notification because instant updates are disabled', + ); + res.sendStatus(202); + } + }) + .catch(next); + } else { + res.sendStatus(202); + } +}); diff --git a/src/device-state.ts b/src/device-state.ts index e009ece5..39715758 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -26,7 +26,7 @@ import * as updateLock from './lib/update-lock'; import * as validation from './lib/validation'; import * as network from './network'; -import APIBinder from './api-binder'; +import * as APIBinder from './api-binder'; import { ApplicationManager } from './application-manager'; import * as deviceConfig from './device-config'; import { ConfigStep } from './device-config'; @@ -178,7 +178,7 @@ function createDeviceStateRouter(deviceState: DeviceState) { } interface DeviceStateConstructOpts { - apiBinder: APIBinder; + apiBinder: typeof APIBinder; } interface DeviceStateEvents { diff --git a/src/lib/api-helper.ts b/src/lib/api-helper.ts new file mode 100644 index 00000000..a0335aa9 --- /dev/null +++ b/src/lib/api-helper.ts @@ -0,0 +1,213 @@ +import { PinejsClientRequest } from 'pinejs-client-request'; + +import * as Bluebird from 'bluebird'; +import * as config from '../config'; +import * as eventTracker from '../event-tracker'; + +import * as request from './request'; +import * as deviceRegister from './register-device'; +import { + DeviceNotFoundError, + ExchangeKeyError, + FailedToProvisionDeviceError, + InternalInconsistencyError, + isHttpConflictError, +} from './errors'; +import log from './supervisor-console'; + +export type KeyExchangeOpts = config.ConfigType<'provisioningOptions'>; + +export interface Device { + id: number; + + [key: string]: unknown; +} + +export const fetchDevice = async ( + balenaApi: PinejsClientRequest, + uuid: string, + apiKey: string, + timeout: number, +) => { + if (balenaApi == null) { + throw new InternalInconsistencyError( + 'fetchDevice called without an initialized API client', + ); + } + + const reqOpts = { + resource: 'device', + options: { + $filter: { + uuid, + }, + }, + passthrough: { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + }; + + try { + const [device] = (await Bluebird.resolve(balenaApi.get(reqOpts)).timeout( + timeout, + )) as Device[]; + + if (device == null) { + throw new DeviceNotFoundError(); + } + + return device; + } catch (e) { + throw new DeviceNotFoundError(); + } +}; + +export const exchangeKeyAndGetDeviceOrRegenerate = async ( + balenaApi: PinejsClientRequest, + opts: KeyExchangeOpts, +): Promise => { + try { + const device = await exchangeKeyAndGetDevice(balenaApi, opts); + log.debug('Key exchange succeeded'); + return device; + } catch (e) { + if (e instanceof ExchangeKeyError) { + log.error('Exchanging key failed, re-registering...'); + await config.regenerateRegistrationFields(); + } + throw e; + } +}; + +export const exchangeKeyAndGetDevice = async ( + balenaApi: PinejsClientRequest, + opts: Partial, +): Promise => { + const uuid = opts.uuid; + const apiTimeout = opts.apiTimeout; + if (!(uuid && apiTimeout)) { + throw new InternalInconsistencyError( + 'UUID and apiTimeout should be defined in exchangeKeyAndGetDevice', + ); + } + + // If we have an existing device key we first check if it's + // valid, because if it is then we can just use that + if (opts.deviceApiKey != null) { + try { + return await fetchDevice(balenaApi, uuid, opts.deviceApiKey, apiTimeout); + } catch (e) { + if (e instanceof DeviceNotFoundError) { + // do nothing... + } else { + throw e; + } + } + } + + // If it's not valid or doesn't exist then we try to use the + // user/provisioning api key for the exchange + if (!opts.provisioningApiKey) { + throw new InternalInconsistencyError( + 'Required a provisioning key in exchangeKeyAndGetDevice', + ); + } + + let device: Device; + try { + device = await fetchDevice( + balenaApi, + uuid, + opts.provisioningApiKey, + apiTimeout, + ); + } catch (err) { + throw new ExchangeKeyError(`Couldn't fetch device with provisioning key`); + } + + // We found the device so we can try to register a working device key for it + const [res] = await (await request.getRequestInstance()) + .postAsync(`${opts.apiEndpoint}/api-key/device/${device.id}/device-key`, { + json: true, + body: { + apiKey: opts.deviceApiKey, + }, + headers: { + Authorization: `Bearer ${opts.provisioningApiKey}`, + }, + }) + .timeout(apiTimeout); + + if (res.statusCode !== 200) { + throw new ExchangeKeyError( + `Couldn't register device key with provisioning key`, + ); + } + + return device; +}; + +export const provision = async ( + balenaApi: PinejsClientRequest, + opts: KeyExchangeOpts, +) => { + await config.initialized; + await eventTracker.initialized; + + let device: Device | null = null; + + if ( + opts.registered_at == null || + opts.deviceId == null || + opts.provisioningApiKey != null + ) { + if (opts.registered_at != null && opts.deviceId == null) { + log.debug( + 'Device is registered but no device id available, attempting key exchange', + ); + + device = await exchangeKeyAndGetDeviceOrRegenerate(balenaApi, opts); + } else if (opts.registered_at == null) { + log.info('New device detected. Provisioning...'); + try { + device = await deviceRegister.register(opts).timeout(opts.apiTimeout); + } catch (err) { + if ( + err instanceof deviceRegister.ApiError && + isHttpConflictError(err.response) + ) { + log.debug('UUID already registered, trying a key exchange'); + device = await exchangeKeyAndGetDeviceOrRegenerate(balenaApi, opts); + } else { + throw err; + } + } + opts.registered_at = Date.now(); + } else if (opts.provisioningApiKey != null) { + log.debug( + 'Device is registered but we still have an apiKey, attempting key exchange', + ); + device = await exchangeKeyAndGetDevice(balenaApi, opts); + } + + if (!device) { + throw new FailedToProvisionDeviceError(); + } + + const { id } = device; + balenaApi.passthrough.headers.Authorization = `Bearer ${opts.deviceApiKey}`; + + const configToUpdate = { + registered_at: opts.registered_at, + deviceId: id, + apiKey: null, + }; + + await config.set(configToUpdate); + eventTracker.track('Device bootstrap success'); + } + + return device; +}; diff --git a/src/lib/errors.ts b/src/lib/errors.ts index d961727d..02c455b9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -14,6 +14,8 @@ interface CodedSysError extends Error { code?: string; } +export class DeviceNotFoundError extends TypedError {} + export function NotFoundError(err: StatusCodeError): boolean { return checkInt(err.statusCode) === 404; } @@ -50,6 +52,12 @@ export function isHttpConflictError(err: StatusCodeError | Response): boolean { return checkInt(err.statusCode) === 409; } +export class FailedToProvisionDeviceError extends TypedError { + public constructor() { + super('Failed to provision device'); + } +} + export class ExchangeKeyError extends TypedError {} export class InternalInconsistencyError extends TypedError {} diff --git a/src/proxyvisor.js b/src/proxyvisor.js index 2c7dd6df..f5865670 100644 --- a/src/proxyvisor.js +++ b/src/proxyvisor.js @@ -20,6 +20,7 @@ import * as db from './db'; import * as config from './config'; import * as dockerUtils from './lib/docker-utils'; import * as logger from './logger'; +import * as apiHelper from './lib/api-helper'; const mkdirpAsync = Promise.promisify(mkdirp); @@ -406,8 +407,8 @@ export class Proxyvisor { } // If the device is not in the DB it means it was provisioned externally // so we need to fetch it. - return this.apiBinder - .fetchDevice(uuid, currentApiKey, apiTimeout) + return apiHelper + .fetchDevice(this.apiBinder.balenaApi, uuid, currentApiKey, apiTimeout) .then((dev) => { const deviceForDB = { uuid, diff --git a/src/supervisor.ts b/src/supervisor.ts index c9eb91a4..d55bc047 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -1,4 +1,4 @@ -import APIBinder from './api-binder'; +import * as apiBinder from './api-binder'; import * as db from './db'; import * as config from './config'; import DeviceState from './device-state'; @@ -32,25 +32,24 @@ const startupConfigFields: config.ConfigKey[] = [ export class Supervisor { private deviceState: DeviceState; - private apiBinder: APIBinder; private api: SupervisorAPI; public constructor() { - this.apiBinder = new APIBinder(); this.deviceState = new DeviceState({ - apiBinder: this.apiBinder, + apiBinder, }); + // workaround the circular dependency - this.apiBinder.setDeviceState(this.deviceState); + apiBinder.setDeviceState(this.deviceState); // FIXME: rearchitect proxyvisor to avoid this circular dependency // by storing current state and having the APIBinder query and report it / provision devices - this.deviceState.applications.proxyvisor.bindToAPI(this.apiBinder); + this.deviceState.applications.proxyvisor.bindToAPI(apiBinder); this.api = new SupervisorAPI({ - routers: [this.apiBinder.router, this.deviceState.router], + routers: [apiBinder.router, this.deviceState.router], healthchecks: [ - this.apiBinder.healthcheck.bind(this.apiBinder), + apiBinder.healthcheck, this.deviceState.healthcheck.bind(this.deviceState), ], }); @@ -78,14 +77,14 @@ export class Supervisor { await firewall.initialised; log.debug('Starting api binder'); - await this.apiBinder.initClient(); + await apiBinder.initialized; logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start'); - if (conf.legacyAppsPresent && this.apiBinder.balenaApi != null) { + if (conf.legacyAppsPresent && apiBinder.balenaApi != null) { log.info('Legacy app detected, running migration'); await normaliseLegacyDatabase( this.deviceState.applications, - this.apiBinder.balenaApi, + apiBinder.balenaApi, ); } @@ -95,7 +94,7 @@ export class Supervisor { this.api.listen(conf.listenPort, conf.apiTimeout); this.deviceState.on('shutdown', () => this.api.stop()); - await this.apiBinder.start(); + await apiBinder.start(); } } diff --git a/test/00-init.ts b/test/00-init.ts index 2904f883..8bddcb52 100644 --- a/test/00-init.ts +++ b/test/00-init.ts @@ -7,6 +7,7 @@ process.env.DATABASE_PATH_3 = './test/data/database3.sqlite'; process.env.LED_FILE = './test/data/led_file'; import './lib/mocked-iptables'; +import './lib/mocked-event-tracker'; import * as dbus from 'dbus'; import { DBusError, DBusInterface } from 'dbus'; diff --git a/test/11-api-binder.spec.ts b/test/11-api-binder.spec.ts index e9b89b3a..b598a434 100644 --- a/test/11-api-binder.spec.ts +++ b/test/11-api-binder.spec.ts @@ -3,11 +3,9 @@ import { fs } from 'mz'; import { Server } from 'net'; import { SinonSpy, SinonStub, spy, stub } from 'sinon'; -import ApiBinder from '../src/api-binder'; import prepare = require('./lib/prepare'); import * as config from '../src/config'; import DeviceState from '../src/device-state'; -import * as eventTracker from '../src/event-tracker'; import Log from '../src/lib/supervisor-console'; import chai = require('./lib/chai-config'); import balenaAPI = require('./lib/mocked-balena-api'); @@ -16,15 +14,25 @@ import ConfigJsonConfigBackend from '../src/config/configJson'; import * as TargetState from '../src/device-state/target-state'; import { DeviceStatus } from '../src/types/state'; import * as CurrentState from '../src/device-state/current-state'; +import * as ApiHelper from '../src/lib/api-helper'; + +import { TypedError } from 'typed-error'; +import { DeviceNotFoundError } from '../src/lib/errors'; + +import { eventTrackSpy } from './lib/mocked-event-tracker'; const { expect } = chai; +let ApiBinder: typeof import('../src/api-binder'); + +class ExpectedError extends TypedError {} -const defaultConfigBackend = config.configJsonBackend; const initModels = async (obj: Dictionary, filename: string) => { await prepare(); + // @ts-expect-error setting read-only property config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename); - config.generateRequiredFields(); + await config.generateRequiredFields(); + // @ts-expect-error using private properties config.configJsonBackend.cache = await config.configJsonBackend.read(); await config.generateRequiredFields(); @@ -35,15 +43,15 @@ const initModels = async (obj: Dictionary, filename: string) => { }, } as any; - obj.apiBinder = new ApiBinder(); + ApiBinder = await import('../src/api-binder'); + await ApiBinder.initialized; + obj.apiBinder = ApiBinder; obj.deviceState = new DeviceState({ apiBinder: obj.apiBinder, }); obj.apiBinder.setDeviceState(obj.deviceState); - - await obj.apiBinder.initClient(); // Initializes the clients but doesn't trigger provisioning }; const mockProvisioningOpts = { @@ -55,17 +63,12 @@ const mockProvisioningOpts = { }; describe('ApiBinder', () => { + const defaultConfigBackend = config.configJsonBackend; let server: Server; - beforeEach(() => { - stub(eventTracker, 'track'); - }); - afterEach(() => { - // @ts-expect-error Restoring a non-stub type function - eventTracker.track.restore(); - }); + before(async () => { + delete require.cache[require.resolve('../src/api-binder')]; - before(() => { spy(balenaAPI.balenaBackend!, 'registerHandler'); server = balenaAPI.listen(3000); }); @@ -83,31 +86,39 @@ describe('ApiBinder', () => { // We do not support older OS versions anymore, so we only test this case describe('on an OS with deviceApiKey support', () => { const components: Dictionary = {}; - before(() => { - return initModels(components, '/config-apibinder.json'); + + before(async () => { + await initModels(components, '/config-apibinder.json'); + }); + + afterEach(() => { + eventTrackSpy.resetHistory(); }); after(async () => { + eventTrackSpy.restore(); + // @ts-expect-error setting read-only property config.configJsonBackend = defaultConfigBackend; await config.generateRequiredFields(); }); - it('provisions a device', () => { - const promise = components.apiBinder.provisionDevice(); + it('provisions a device', async () => { + const opts = await config.get('provisioningOptions'); + await ApiHelper.provision(components.apiBinder.balenaApi, opts); - return expect(promise).to.be.fulfilled.then(() => { - expect(balenaAPI.balenaBackend!.registerHandler).to.be.calledOnce; + expect(balenaAPI.balenaBackend!.registerHandler).to.be.calledOnce; + expect(eventTrackSpy).to.be.called; + expect(eventTrackSpy).to.be.calledWith('Device bootstrap success'); - // @ts-expect-error function does not exist on type - balenaAPI.balenaBackend!.registerHandler.resetHistory(); - expect(eventTracker.track).to.be.calledWith('Device bootstrap success'); - }); + // @ts-expect-error function does not exist on type + balenaAPI.balenaBackend!.registerHandler.resetHistory(); }); it('exchanges keys if resource conflict when provisioning', async () => { // Get current config to extend const currentConfig = await config.get('provisioningOptions'); + // Stub config values so we have correct conditions const configStub = stub(config, 'get').resolves({ ...currentConfig, @@ -115,30 +126,32 @@ describe('ApiBinder', () => { provisioningApiKey: '123', // Previous test case deleted the provisioningApiKey so add one uuid: 'not-unique', // This UUID is used in mocked-balena-api as an existing registered UUID }); + // If api-binder reaches this function then tests pass + // We throw an error so we don't have to keep stubbing const functionToReach = stub( - components.apiBinder, + ApiHelper, 'exchangeKeyAndGetDeviceOrRegenerate', - ).rejects('expected-rejection'); // We throw an error so we don't have to keep stubbing + ).throws(new ExpectedError()); + spy(Log, 'debug'); try { - await components.apiBinder.provision(); + const opts = await config.get('provisioningOptions'); + await ApiHelper.provision(components.apiBinder.balenaApi, opts); } catch (e) { // Check that the error thrown is from this test - if (e.name !== 'expected-rejection') { - throw e; - } + expect(e).to.be.instanceOf(ExpectedError); } - expect(functionToReach).to.be.calledOnce; + expect(functionToReach.called).to.be.true; expect((Log.debug as SinonSpy).lastCall.lastArg).to.equal( 'UUID already registered, trying a key exchange', ); // Restore stubs - functionToReach.restore(); configStub.restore(); + functionToReach.restore(); (Log.debug as SinonStub).restore(); }); @@ -184,7 +197,8 @@ describe('ApiBinder', () => { api_key: 'verysecure', }; - const device = await components.apiBinder.fetchDevice( + const device = await ApiHelper.fetchDevice( + components.apiBinder.balenaApi, 'abcd', 'someApiKey', 30000, @@ -207,27 +221,31 @@ describe('ApiBinder', () => { it('returns the device if it can fetch it with the deviceApiKey', async () => { spy(balenaAPI.balenaBackend!, 'deviceKeyHandler'); - const fetchDeviceStub = stub(components.apiBinder, 'fetchDevice'); + const fetchDeviceStub = stub(ApiHelper, 'fetchDevice'); fetchDeviceStub.onCall(0).resolves({ id: 1 }); - const device = await components.apiBinder.exchangeKeyAndGetDevice( + const device = await ApiHelper.exchangeKeyAndGetDevice( + components.apiBinder.balenaApi, mockProvisioningOpts, ); expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called; expect(device).to.deep.equal({ id: 1 }); - expect(components.apiBinder.fetchDevice).to.be.calledOnce; + expect(fetchDeviceStub).to.be.calledOnce; - components.apiBinder.fetchDevice.restore(); // @ts-expect-error function does not exist on type balenaAPI.balenaBackend.deviceKeyHandler.restore(); + fetchDeviceStub.restore(); }); it('throws if it cannot get the device with any of the keys', () => { spy(balenaAPI.balenaBackend!, 'deviceKeyHandler'); - stub(components.apiBinder, 'fetchDevice').returns(Promise.resolve(null)); + const fetchDeviceStub = stub(ApiHelper, 'fetchDevice').throws( + new DeviceNotFoundError(), + ); - const promise = components.apiBinder.exchangeKeyAndGetDevice( + const promise = ApiHelper.exchangeKeyAndGetDevice( + components.apiBinder.balenaApi, mockProvisioningOpts, ); promise.catch(() => { @@ -236,8 +254,8 @@ describe('ApiBinder', () => { return expect(promise).to.be.rejected.then(() => { expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called; - expect(components.apiBinder.fetchDevice).to.be.calledTwice; - components.apiBinder.fetchDevice.restore(); + expect(fetchDeviceStub).to.be.calledTwice; + fetchDeviceStub.restore(); // @ts-expect-error function does not exist on type balenaAPI.balenaBackend.deviceKeyHandler.restore(); }); @@ -245,17 +263,18 @@ describe('ApiBinder', () => { it('exchanges the key and returns the device if the provisioning key is valid', async () => { spy(balenaAPI.balenaBackend!, 'deviceKeyHandler'); - const fetchDeviceStub = stub(components.apiBinder, 'fetchDevice'); - fetchDeviceStub.onCall(0).returns(Promise.resolve(null)); + const fetchDeviceStub = stub(ApiHelper, 'fetchDevice'); + fetchDeviceStub.onCall(0).throws(new DeviceNotFoundError()); fetchDeviceStub.onCall(1).returns(Promise.resolve({ id: 1 })); - const device = await components.apiBinder.exchangeKeyAndGetDevice( + const device = await ApiHelper.exchangeKeyAndGetDevice( + components.apiBinder.balenaApi, mockProvisioningOpts as any, ); expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.be.calledOnce; expect(device).to.deep.equal({ id: 1 }); - expect(components.apiBinder.fetchDevice).to.be.calledTwice; - components.apiBinder.fetchDevice.restore(); + expect(fetchDeviceStub).to.be.calledTwice; + fetchDeviceStub.restore(); // @ts-expect-error function does not exist on type balenaAPI.balenaBackend.deviceKeyHandler.restore(); }); @@ -436,17 +455,22 @@ describe('ApiBinder', () => { appUpdatePollInterval: 1000, connectivityCheckEnabled: true, }); + // Set lastFetch to now so it is within appUpdatePollInterval (TargetState as any).lastFetch = process.hrtime(); + // Copy previous values to restore later const previousStateReportErrors = components.apiBinder.stateReportErrors; const previousDeviceStateConnected = components.apiBinder.deviceState.connected; + // Set additional conditions not in configStub to cause a fail // @ts-expect-error CurrentState.stateReportErrors = 4; components.apiBinder.deviceState.connected = true; + expect(await components.apiBinder.healthcheck()).to.equal(false); + expect(Log.info).to.be.calledOnce; expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal( stripIndent` @@ -455,6 +479,7 @@ describe('ApiBinder', () => { - device state is disconnected ? false - stateReportErrors less then 3 ? false`, ); + // Restore previous values // @ts-expect-error CurrentState.stateReportErrors = previousStateReportErrors; diff --git a/test/18-startup.spec.ts b/test/18-startup.spec.ts index 257e3854..630fca02 100644 --- a/test/18-startup.spec.ts +++ b/test/18-startup.spec.ts @@ -1,6 +1,6 @@ import { SinonStub, stub } from 'sinon'; -import APIBinder from '../src/api-binder'; +import * as APIBinder from '../src/api-binder'; import { ApplicationManager } from '../src/application-manager'; import DeviceState from '../src/device-state'; import * as constants from '../src/lib/constants'; @@ -10,7 +10,6 @@ import { expect } from './lib/chai-config'; import _ = require('lodash'); describe('Startup', () => { - let initClientStub: SinonStub; let reportCurrentStateStub: SinonStub; let startStub: SinonStub; let vpnStatusPathStub: SinonStub; @@ -19,16 +18,11 @@ describe('Startup', () => { let dockerStub: SinonStub; before(() => { - initClientStub = stub(APIBinder.prototype as any, 'initClient').returns( - Promise.resolve(), - ); reportCurrentStateStub = stub( DeviceState.prototype as any, 'reportCurrentState', ).resolves(); - startStub = stub(APIBinder.prototype as any, 'start').returns( - Promise.resolve(), - ); + startStub = stub(APIBinder as any, 'start').returns(Promise.resolve()); appManagerStub = stub(ApplicationManager.prototype, 'init').returns( Promise.resolve(), ); @@ -40,7 +34,6 @@ describe('Startup', () => { }); after(() => { - initClientStub.restore(); startStub.restore(); appManagerStub.restore(); vpnStatusPathStub.restore(); diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index e2a58ee2..68d0e563 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { spy, stub, SinonStub } from 'sinon'; import * as supertest from 'supertest'; -import APIBinder from '../src/api-binder'; +import * as apiBinder from '../src/api-binder'; import DeviceState from '../src/device-state'; import Log from '../src/lib/supervisor-console'; import * as images from '../src/compose/images'; @@ -24,9 +24,11 @@ describe('SupervisorAPI', () => { const originalGetStatus = images.getStatus; before(async () => { + await apiBinder.initialized; + // Stub health checks so we can modify them whenever needed healthCheckStubs = [ - stub(APIBinder.prototype, 'healthcheck'), + stub(apiBinder, 'healthcheck'), stub(DeviceState.prototype, 'healthcheck'), ]; // The mockedAPI contains stubs that might create unexpected results @@ -37,7 +39,7 @@ describe('SupervisorAPI', () => { images.getStatus = () => Promise.resolve([]); // Start test API - return api.listen(mockedOptions.listenPort, mockedOptions.timeout); + await api.listen(mockedOptions.listenPort, mockedOptions.timeout); }); after(async () => { diff --git a/test/lib/mocked-balena-api.ts b/test/lib/mocked-balena-api.ts index 9363a143..53b3f5bb 100644 --- a/test/lib/mocked-balena-api.ts +++ b/test/lib/mocked-balena-api.ts @@ -52,6 +52,12 @@ api.get(/\/v6\/device\(uuid=%27([0-9a-f]+)%27\)/, (req, res) => api.balenaBackend!.getDeviceHandler(req, res, _.noop), ); +api.get(/\/v6\/device/, (req, res) => { + const [, uuid] = /uuid eq '([0-9a-f]+)'/i.exec(req.query['$filter']) ?? []; + req.params[0] = uuid; + return api.balenaBackend!.getDeviceHandler(req, res, _.noop); +}); + api.post('/api-key/device/:deviceId/device-key', (req, res) => api.balenaBackend!.deviceKeyHandler(req, res, _.noop), ); diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index 9bf75394..430ea468 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -10,7 +10,7 @@ import * as config from '../../src/config'; import * as db from '../../src/db'; import { createV1Api } from '../../src/device-api/v1'; import { createV2Api } from '../../src/device-api/v2'; -import APIBinder from '../../src/api-binder'; +import * as APIBinder from '../../src/api-binder'; import DeviceState from '../../src/device-state'; import SupervisorAPI from '../../src/supervisor-api'; @@ -109,7 +109,7 @@ async function createAPIOpts(): Promise { const deviceState = new DeviceState({ apiBinder: null as any, }); - const apiBinder = new APIBinder(); + const apiBinder = APIBinder; return { deviceState, apiBinder, @@ -170,7 +170,7 @@ function restoreStubs() { interface SupervisorAPIOpts { deviceState: DeviceState; - apiBinder: APIBinder; + apiBinder: typeof APIBinder; } export = { create, cleanUp, STUBBED_VALUES }; diff --git a/test/lib/mocked-event-tracker.ts b/test/lib/mocked-event-tracker.ts new file mode 100644 index 00000000..a8800654 --- /dev/null +++ b/test/lib/mocked-event-tracker.ts @@ -0,0 +1,4 @@ +import * as eventTracker from '../../src/event-tracker'; +import { spy } from 'sinon'; + +export const eventTrackSpy = spy(eventTracker, 'track');