Merge pull request #1340 from balena-io/1249-log-healthcheck-failure

log healthcheck failure
This commit is contained in:
bulldozer-balena[bot] 2020-05-29 22:17:31 +00:00 committed by GitHub
commit cf50a8797e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 297 additions and 18 deletions

View File

@ -1,5 +1,6 @@
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';
@ -113,24 +114,49 @@ export class APIBinder {
'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(this.lastTargetStateFetch);
const timeSinceLastFetchMs =
timeSinceLastFetch[0] * 1000 + timeSinceLastFetch[1] / 1e6;
const stateFetchHealthy = timeSinceLastFetchMs < 2 * appUpdatePollInterval;
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 ||
this.stateReportErrors < 3;
return stateFetchHealthy && stateReportHealthy;
if (!stateReportHealthy) {
log.info(
stripIndent`
Healthcheck failure - Atleast ONE of the following conditions must be true:
- No connectivityCheckEnabled ? ${!(connectivityCheckEnabled === true)}
- device state is disconnected ? ${!(this.deviceState.connected === true)}
- stateReportErrors less then 3 ? ${this.stateReportErrors < 3}`,
);
return false;
}
// All tests pass!
return true;
}
public async initClient() {

View File

@ -1,5 +1,6 @@
import * as Bluebird from 'bluebird';
import * as bodyParser from 'body-parser';
import { stripIndent } from 'common-tags';
import { EventEmitter } from 'events';
import * as express from 'express';
import * as _ from 'lodash';
@ -285,19 +286,36 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
public async healthcheck() {
const unmanaged = await this.config.get('unmanaged');
// Don't have to perform checks for unmanaged
if (unmanaged) {
return true;
}
const cycleTime = process.hrtime(this.lastApplyStart);
const cycleTimeMs = cycleTime[0] * 1000 + cycleTime[1] / 1e6;
const cycleTimeWithinInterval =
cycleTimeMs - this.applications.timeSpentFetching < 2 * this.maxPollTime;
// Check if target is healthy
const applyTargetHealthy =
unmanaged ||
!this.applyInProgress ||
this.applications.fetchesInProgress > 0 ||
cycleTimeWithinInterval;
return applyTargetHealthy;
if (!applyTargetHealthy) {
log.info(
stripIndent`
Healthcheck failure - Atleast ONE of the following conditions must be true:
- No applyInProgress ? ${!(this.applyInProgress === true)}
- fetchesInProgress ? ${this.applications.fetchesInProgress > 0}
- cycleTimeWithinInterval ? ${cycleTimeWithinInterval}`,
);
return false;
}
// All tests pass!
return true;
}
public async init() {

View File

@ -1,23 +1,22 @@
import * as Bluebird from 'bluebird';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import { stub } from 'sinon';
import { SinonSpy, SinonStub, spy, stub } from 'sinon';
import chai = require('./lib/chai-config');
import prepare = require('./lib/prepare');
// tslint:disable-next-line
chai.use(require('chai-events'));
const { expect } = chai;
import Log from '../src/lib/supervisor-console';
import Config from '../src/config';
import { RPiConfigBackend } from '../src/config/backend';
import DeviceState from '../src/device-state';
import { loadTargetFromFile } from '../src/device-state/preload';
import Service from '../src/compose/service';
import { intialiseContractRequirements } from '../src/lib/contracts';
// tslint:disable-next-line
chai.use(require('chai-events'));
const { expect } = chai;
const mockedInitialConfig = {
RESIN_SUPERVISOR_CONNECTIVITY_CHECK: 'true',
RESIN_SUPERVISOR_DELTA: 'false',
@ -384,4 +383,63 @@ describe('deviceState', () => {
it('applies the target state for device config');
it('applies the target state for applications');
describe('healthchecks', () => {
let configStub: SinonStub;
let infoLobSpy: SinonSpy;
beforeEach(() => {
// This configStub will be modified in each test case so we can
// create the exact conditions we want to for testing healthchecks
configStub = stub(Config.prototype, 'get');
infoLobSpy = spy(Log, 'info');
});
afterEach(() => {
configStub.restore();
infoLobSpy.restore();
});
it('passes with correct conditions', async () => {
// Setup passing condition
const previousValue = deviceState.applyInProgress;
deviceState.applyInProgress = false;
expect(await deviceState.healthcheck()).to.equal(true);
// Restore value
deviceState.applyInProgress = previousValue;
});
it('passes if unmanaged is true and exit early', async () => {
// Setup failing conditions
const previousValue = deviceState.applyInProgress;
deviceState.applyInProgress = true;
// Verify this causes healthcheck to fail
expect(await deviceState.healthcheck()).to.equal(false);
// Do it again but set unmanaged to true
configStub.resolves({
unmanaged: true,
});
expect(await deviceState.healthcheck()).to.equal(true);
// Restore value
deviceState.applyInProgress = previousValue;
});
it('fails when applyTargetHealthy is false', async () => {
// Copy previous values to restore later
const previousValue = deviceState.applyInProgress;
// Setup failing conditions
deviceState.applyInProgress = true;
expect(await deviceState.healthcheck()).to.equal(false);
expect(Log.info).to.be.calledOnce;
expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
stripIndent`
Healthcheck failure - Atleast ONE of the following conditions must be true:
- No applyInProgress ? false
- fetchesInProgress ? false
- cycleTimeWithinInterval ? false`,
);
// Restore value
deviceState.applyInProgress = previousValue;
});
});
});

View File

@ -1,3 +1,4 @@
import { stripIndent } from 'common-tags';
import { fs } from 'mz';
import { Server } from 'net';
import { SinonSpy, SinonStub, spy, stub } from 'sinon';
@ -279,4 +280,108 @@ describe('ApiBinder', () => {
});
});
});
describe('healthchecks', () => {
const components: Dictionary<any> = {};
let configStub: SinonStub;
let infoLobSpy: SinonSpy;
before(async () => {
await initModels(components, '/config-apibinder.json');
});
beforeEach(() => {
// This configStub will be modified in each test case so we can
// create the exact conditions we want to for testing healthchecks
configStub = stub(Config.prototype, 'getMany');
infoLobSpy = spy(Log, 'info');
});
afterEach(() => {
configStub.restore();
infoLobSpy.restore();
});
it('passes with correct conditions', async () => {
// Set unmanaged to false so we check all values
// The other values are stubbed to make it pass
configStub.resolves({
unmanaged: false,
appUpdatePollInterval: 1000,
connectivityCheckEnabled: false,
});
expect(await components.apiBinder.healthcheck()).to.equal(true);
});
it('passes if unmanaged is true and exit early', async () => {
// Setup failing conditions
configStub.resolves({
unmanaged: false,
appUpdatePollInterval: null,
connectivityCheckEnabled: false,
});
// Verify this causes healthcheck to fail
expect(await components.apiBinder.healthcheck()).to.equal(false);
// Do it again but set unmanaged to true
configStub.resolves({
unmanaged: true,
appUpdatePollInterval: null,
connectivityCheckEnabled: false,
});
expect(await components.apiBinder.healthcheck()).to.equal(true);
});
it('fails if appUpdatePollInterval not set in config and exit early', async () => {
configStub.resolves({
unmanaged: false,
appUpdatePollInterval: null,
connectivityCheckEnabled: false,
});
expect(await components.apiBinder.healthcheck()).to.equal(false);
expect(Log.info).to.be.calledOnce;
expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
'Healthcheck failure - Config value `appUpdatePollInterval` cannot be null',
);
});
it("fails when hasn't checked target state within poll interval", async () => {
configStub.resolves({
unmanaged: false,
appUpdatePollInterval: 1,
connectivityCheckEnabled: false,
});
expect(await components.apiBinder.healthcheck()).to.equal(false);
expect(Log.info).to.be.calledOnce;
expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
'Healthcheck failure - Device has not fetched target state within appUpdatePollInterval limit',
);
});
it('fails when stateReportHealthy is false', async () => {
configStub.resolves({
unmanaged: false,
appUpdatePollInterval: 1000,
connectivityCheckEnabled: true,
});
// 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
components.apiBinder.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`
Healthcheck failure - Atleast ONE of the following conditions must be true:
- No connectivityCheckEnabled ? false
- device state is disconnected ? false
- stateReportErrors less then 3 ? false`,
);
// Restore previous values
components.apiBinder.stateReportErrors = previousStateReportErrors;
components.apiBinder.deviceState.connected = previousDeviceStateConnected;
});
});
});

View File

@ -1,7 +1,9 @@
import { expect } from 'chai';
import { spy } from 'sinon';
import { spy, stub, SinonStub } from 'sinon';
import * as supertest from 'supertest';
import APIBinder from '../src/api-binder';
import DeviceState from '../src/device-state';
import Log from '../src/lib/supervisor-console';
import SupervisorAPI from '../src/supervisor-api';
import sampleResponses = require('./data/device-api-responses.json');
@ -17,9 +19,15 @@ const ALLOWED_INTERFACES = ['lo']; // Only need loopback since this is for testi
describe('SupervisorAPI', () => {
let api: SupervisorAPI;
let healthCheckStubs: SinonStub[];
const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`);
before(async () => {
// Stub health checks so we can modify them whenever needed
healthCheckStubs = [
stub(APIBinder.prototype, 'healthcheck'),
stub(DeviceState.prototype, 'healthcheck'),
];
// The mockedAPI contains stubs that might create unexpected results
// See the module to know what has been stubbed
api = await mockedAPI.create();
@ -39,6 +47,8 @@ describe('SupervisorAPI', () => {
throw e;
}
}
// Restore healthcheck stubs
healthCheckStubs.forEach((hc) => hc.restore);
// Remove any test data generated
await mockedAPI.cleanUp();
});
@ -56,7 +66,43 @@ describe('SupervisorAPI', () => {
});
});
describe.skip('V1 endpoints', () => {
describe('V1 endpoints', () => {
describe('GET /v1/healthy', () => {
it('returns OK because all checks pass', async () => {
// Make all healthChecks pass
healthCheckStubs.forEach((hc) => hc.resolves(true));
await request
.get('/v1/healthy')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.expect(sampleResponses.V1.GET['/healthy'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V1.GET['/healthy'].body,
);
expect(response.text).to.deep.equal(
sampleResponses.V1.GET['/healthy'].text,
);
});
});
it('Fails because some checks did not pass', async () => {
// Make one of the healthChecks fail
healthCheckStubs[0].resolves(false);
await request
.get('/v1/healthy')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`)
.expect(sampleResponses.V1.GET['/healthy [2]'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V1.GET['/healthy [2]'].body,
);
expect(response.text).to.deep.equal(
sampleResponses.V1.GET['/healthy [2]'].text,
);
});
});
});
// TODO: add tests for V1 endpoints
});

View File

@ -1,5 +1,18 @@
{
"V1": {},
"V1": {
"GET": {
"/healthy": {
"statusCode": 200,
"body": {},
"text": "OK"
},
"/healthy [2]": {
"statusCode": 500,
"body": {},
"text": "Unhealthy"
}
}
},
"V2": {
"GET": {
"/device/vpn": {

View File

@ -11,6 +11,7 @@ import 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 DeviceState from '../../src/device-state';
import EventTracker from '../../src/event-tracker';
import SupervisorAPI from '../../src/supervisor-api';
@ -67,7 +68,12 @@ const STUBBED_VALUES = {
async function create(): Promise<SupervisorAPI> {
// Get SupervisorAPI construct options
const { config, eventTracker, deviceState } = await createAPIOpts();
const {
config,
eventTracker,
deviceState,
apiBinder,
} = await createAPIOpts();
// Stub functions
setupStubs();
// Create ApplicationManager
@ -83,7 +89,7 @@ async function create(): Promise<SupervisorAPI> {
config,
eventTracker,
routers: [buildRoutes(appManager)],
healthchecks: [],
healthchecks: [deviceState.healthcheck, apiBinder.healthcheck],
});
// Return SupervisorAPI that is not listening yet
return api;
@ -115,10 +121,16 @@ async function createAPIOpts(): Promise<SupervisorAPIOpts> {
logger: null as any,
apiBinder: null as any,
});
const apiBinder = new APIBinder({
config: mockedConfig,
eventTracker: tracker,
logger: null as any,
});
return {
config: mockedConfig,
eventTracker: tracker,
deviceState,
apiBinder,
};
}
@ -168,6 +180,7 @@ interface SupervisorAPIOpts {
config: Config;
eventTracker: EventTracker;
deviceState: DeviceState;
apiBinder: APIBinder;
}
export = { create, cleanUp, STUBBED_VALUES };