balena-supervisor/src/api-binder.ts
Cameron Diver 975129188a Remove superfluous current state reporting code from api-binder
Change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
2020-10-12 11:53:19 +01:00

592 lines
15 KiB
TypeScript

import * as Bluebird from 'bluebird';
import * as bodyParser from 'body-parser';
import { stripIndent } from 'common-tags';
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 * as url from 'url';
import * as deviceRegister from './lib/register-device';
import * as config from './config';
import * as deviceConfig from './device-config';
import * as eventTracker from './event-tracker';
import { loadBackupFromMigration } from './lib/migration';
import {
ContractValidationError,
ContractViolationError,
InternalInconsistencyError,
} from './lib/errors';
import * as request from './lib/request';
import log from './lib/supervisor-console';
import * as deviceState from './device-state';
import * as globalEventBus from './event-bus';
import * as TargetState from './device-state/target-state';
import * as logger from './logger';
import * as apiHelper from './lib/api-helper';
import { Device } from './lib/api-helper';
import {
startReporting,
stateReportErrors,
} from './device-state/current-state';
interface DevicePinInfo {
app: number;
commit: string;
}
interface DeviceTag {
id: number;
name: string;
value: string;
}
let readyForUpdates = false;
export async function 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 ||
!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 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 reportInitialConfig(
apiEndpoint,
deviceId,
bootstrapRetryDelay,
conf.initialDeviceName ?? undefined,
);
}
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(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<unknown>,
) {
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: Partial<Device>,
): Promise<Device> {
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',
);
}
startReporting();
}
export async function fetchDeviceTags(): Promise<DeviceTag[]> {
if (balenaApi == null) {
throw new InternalInconsistencyError(
'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,
};
});
}
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(
'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!',
);
}
// We force a fresh get to make sure we have the latest state
// and can guarantee we don't clash with any already reported config
const targetConfigUnformatted = (await TargetState.get())?.local?.config;
if (targetConfigUnformatted == null) {
throw new InternalInconsistencyError(
'Attempt to report initial state with malformed target state',
);
}
await 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}`);
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',
);
}
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.getCurrentState();
const targetConfig = await deviceConfig.formatConfigKeys(
targetConfigUnformatted,
);
if (!currentState.local.config) {
throw new InternalInconsistencyError(
'No config defined in reportInitialEnv',
);
}
const currentConfig: Dictionary<string> = 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 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<void> {
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<void> {
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<void> {
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 let balenaApi: PinejsClientRequest | null = null;
export const initialized = (async () => {
await config.initialized;
await eventTracker.initialized;
await deviceState.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);
}
});