Skip to content

Commit 45aed63

Browse files
committed
CLDSRV-1031: Report the health of every configured KMS
The deep health check has one entry per configured KMS, keyed by its providerName, each with its own cache; KMS failures stay informational.
1 parent 4811fc1 commit 45aed63

7 files changed

Lines changed: 286 additions & 80 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ KMSs are configured as a `kms` array in `config.json`. Each entry has a
132132
`protocol`, a unique `providerName`, the settings of that protocol, and exactly
133133
one entry has `"default": true`. Keys are created in the default KMS, and each
134134
key is sent to the KMS its ARN names. A key ID without ARN in a request goes to
135-
the default KMS.
135+
the default KMS. The deep health check has one entry per KMS, keyed by its
136+
`providerName`; a failing KMS never fails start-up or readiness.
136137

137138
| Protocol | Description |
138139
| --------- | ------------------------------------------------------ |

‎lib/kms/wrapper.js‎

Lines changed: 93 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ const { KmsAWSClient } = require('@scality/arsenal').network;
1212
const Common = require('./common');
1313
const vault = require('../auth/vault');
1414
const Cache = require('./Cache');
15-
const cache = new Cache();
1615
const {
1716
KmsProtocol,
1817
makeBackend,
@@ -69,13 +68,14 @@ function getClientIdentifier(backend) {
6968
* Build a client for every entry of the `kms` config.
7069
* @param {object[]} entries - `config.kms`
7170
* @returns {object} `{ clientInstances, defaultInstance }`, with
72-
* `clientInstances` keyed by `type:protocol:provider`
71+
* `clientInstances` keyed by `type:protocol:provider`, each with its own
72+
* health check cache
7373
*/
7474
function createClients(entries) {
7575
const instances = {};
7676
let defaultInstance;
7777
entries.forEach(entry => {
78-
const instance = getClient(entry.protocol, entry.settings);
78+
const instance = { ...getClient(entry.protocol, entry.settings), healthCache: new Cache() };
7979
instances[getClientIdentifier(instance.client.backend)] = instance;
8080
if (entry.default) {
8181
defaultInstance = instance;
@@ -99,8 +99,11 @@ const { clientInstances, defaultInstance } = createClients(config.kms);
9999
const { client, implName } = defaultInstance;
100100
const currentIdentifier = getClientIdentifier(client.backend);
101101

102+
// Taken before the SSE-migration client is added, which is not health-checked
103+
const configuredInstances = Object.values(clientInstances);
104+
102105
// Keys of every configured KMS are accepted
103-
const availableBackends = Object.values(clientInstances).map(instance => instance.client.backend);
106+
const availableBackends = configuredInstances.map(instance => instance.client.backend);
104107

105108
let previousBackend;
106109
let previousIdentifier;
@@ -168,6 +171,84 @@ function getClientForKey(key, log) {
168171
};
169172
}
170173

174+
/**
175+
* Health of one KMS, from its cache when the last check is recent.
176+
* @param {object} instance - client instance, from createClients
177+
* @param {object} log - logger object
178+
* @param {function} cb - callback, called with (null, result)
179+
* @returns {undefined}
180+
*/
181+
function checkKmsHealth(instance, log, cb) {
182+
const { client: kmsClient, healthCache } = instance;
183+
const { provider } = kmsClient.backend;
184+
if (!kmsClient.healthcheck) {
185+
return cb(null, { code: 200, message: 'OK' });
186+
}
187+
188+
const cachedResult = healthCache.getResult();
189+
logger.debug('current KMS cache state', { provider, result: cachedResult });
190+
191+
const shouldRefreshCache = healthCache.shouldRefresh();
192+
193+
if (shouldRefreshCache) {
194+
logger.debug('health check for KMS backend', { provider });
195+
return kmsClient.healthcheck(log, err => {
196+
let res;
197+
if (err) {
198+
res = {
199+
// The following response makes sure that if KMS is down,
200+
// cloudserver health check is still healthy.
201+
// Simply including an error code in the response won't cause the health check to fail.
202+
// Instead, the healthCheck logic detects errors by checking for the "error" field.
203+
code: err.code,
204+
message: 'KMS health check failed',
205+
description: err.description,
206+
};
207+
logger.warn('KMS health check failed', { provider, errorCode: err.code, error: err.description });
208+
} else {
209+
res = {
210+
code: 200,
211+
message: 'OK',
212+
};
213+
logger.info('KMS health check succeeded', { provider, res });
214+
}
215+
216+
healthCache.setResult(res);
217+
const updatedResult = healthCache.getResult();
218+
logger.debug('updated KMS cache:', { provider, result: updatedResult });
219+
220+
return cb(null, updatedResult);
221+
});
222+
}
223+
224+
// Use cached healthcheck result if within a 1-hour window
225+
logger.debug('using cached KMS health check', { provider, cachedResult });
226+
return cb(null, cachedResult);
227+
}
228+
229+
/**
230+
* Health of each KMS, checked in parallel.
231+
* @param {object[]} instances - client instances, from createClients
232+
* @param {object} log - logger object
233+
* @param {function} cb - callback, called with (null, result) where result
234+
* has one entry per KMS, keyed by provider name
235+
* @returns {undefined}
236+
*/
237+
function checkKmsHealthByProvider(instances, log, cb) {
238+
async.map(
239+
instances,
240+
(instance, done) => checkKmsHealth(instance, log, done),
241+
// No error: a failed check is reported in the KMS entry
242+
(err, results) => {
243+
const respBody = {};
244+
instances.forEach((instance, i) => {
245+
respBody[instance.client.backend.provider] = results[i];
246+
});
247+
return cb(null, respBody);
248+
},
249+
);
250+
}
251+
171252
class KMS {
172253
/** Access to client for tests (to create a key without vault check and bucket) */
173254
static get client() {
@@ -547,55 +628,17 @@ class KMS {
547628
);
548629
}
549630

631+
/**
632+
* Health of every configured KMS, keyed by provider name.
633+
* @param {object} log - logger object
634+
* @param {function} cb - callback, called with (null, result)
635+
* @returns {undefined}
636+
*/
550637
static checkHealth(log, cb) {
551-
if (!client.healthcheck) {
552-
return cb(null, {
553-
[implName]: { code: 200, message: 'OK' },
554-
});
555-
}
556-
557-
const cachedResult = cache.getResult();
558-
logger.debug('current KMS cache state', { result: cachedResult });
559-
560-
const shouldRefreshCache = cache.shouldRefresh();
561-
562-
if (shouldRefreshCache) {
563-
logger.debug('health check for KMS backend');
564-
return client.healthcheck(log, err => {
565-
let res;
566-
if (err) {
567-
res = {
568-
// The following response makes sure that if KMS is down,
569-
// cloudserver health check is still healthy.
570-
// Simply including an error code in the response won't cause the health check to fail.
571-
// Instead, the healthCheck logic detects errors by checking for the "error" field.
572-
code: err.code,
573-
message: 'KMS health check failed',
574-
description: err.description,
575-
};
576-
logger.warn('KMS health check failed', { errorCode: err.code, error: err.description });
577-
} else {
578-
res = {
579-
code: 200,
580-
message: 'OK',
581-
};
582-
logger.info('KMS health check succeeded', { res });
583-
}
584-
585-
cache.setResult(res);
586-
const updatedResult = cache.getResult();
587-
logger.debug('updated KMS cache:', { result: updatedResult });
588-
589-
const respBody = { [implName]: updatedResult };
590-
return cb(null, respBody);
591-
});
592-
}
593-
594-
// Use cached healthcheck result if within a 1-hour window
595-
logger.debug('using cached KMS health check', { cachedResult });
596-
return cb(null, { [implName]: cachedResult });
638+
return checkKmsHealthByProvider(configuredInstances, log, cb);
597639
}
598640
}
599641

600642
module.exports = KMS;
601643
module.exports.createClients = createClients;
644+
module.exports.checkKmsHealthByProvider = checkKmsHealthByProvider;

‎tests/functional/kmip/serverside_encryption.js‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,30 @@ describe('KMIP backed server-side encryption with other KMSs configured', () =>
425425
assert.rejects(putBucketEncryption(bucketName, keyArn), assertRefusedNamingOnlyTheKey));
426426
});
427427
});
428+
429+
describe('KMS health in the deep health check', () => {
430+
function getDeepHealthcheck() {
431+
return new Promise((resolve, reject) => {
432+
const req = http.get({ host: config.ipAddress, port: serverConfig.metricsPort, path: '/ready' }, res => {
433+
let body = '';
434+
res.on('data', chunk => {
435+
body += chunk;
436+
});
437+
res.on('end', () => resolve({ statusCode: res.statusCode, body: JSON.parse(body) }));
438+
});
439+
req.on('error', reject);
440+
});
441+
}
442+
443+
it('should report every configured KMS, keyed by provider name', async () => {
444+
const { statusCode, body } = await getDeepHealthcheck();
445+
446+
assert.strictEqual(statusCode, 200);
447+
serverConfig.kms.forEach(({ protocol, providerName }) => {
448+
const { lastChecked, ...health } = body[providerName];
449+
assert.deepStrictEqual(health, { code: 200, message: 'OK' }, `KMS ${providerName}`);
450+
// A KMS whose client has no health check reports OK without being checked; today only the KMIP client has one
451+
assert.strictEqual(lastChecked !== undefined, protocol === KmsProtocol.kmip, `KMS ${providerName}`);
452+
});
453+
});
454+
});

‎tests/unit/encryption/checkHealth.js‎

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ const sinon = require('sinon');
33
const { errors } = require('@scality/arsenal');
44

55
const Cache = require('../../../lib/kms/Cache');
6-
const { DummyRequestLogger } = require('../helpers');
6+
const { DummyRequestLogger, kmsTestEntries, parseKmsEntries } = require('../helpers');
77
const memBackend = require('../../../lib/kms/in_memory/backend');
88
const kms = require('../../../lib/kms/wrapper');
9+
const logger = require('../../../lib/utilities/logger');
910

1011
const log = new DummyRequestLogger();
1112

@@ -37,7 +38,7 @@ describe('KMS.checkHealth', () => {
3738
kms.checkHealth(log, (err, result) => {
3839
assert.ifError(err);
3940
assert.deepStrictEqual(result, {
40-
memoryKms: { code: 200, message: 'OK' },
41+
scality: { code: 200, message: 'OK' },
4142
});
4243

4344
assert(shouldRefreshStub.notCalled, 'shouldRefresh should not be called');
@@ -56,7 +57,7 @@ describe('KMS.checkHealth', () => {
5657
const expectedLastChecked = new Date(clock.now).toISOString();
5758

5859
assert.deepStrictEqual(result, {
59-
memoryKms: { code: 200, message: 'OK', lastChecked: expectedLastChecked },
60+
scality: { code: 200, message: 'OK', lastChecked: expectedLastChecked },
6061
});
6162

6263
assert(shouldRefreshStub.calledOnce, 'shouldRefresh should be called once');
@@ -81,7 +82,7 @@ describe('KMS.checkHealth', () => {
8182
const expectedLastChecked = new Date(clock.now).toISOString();
8283

8384
assert.deepStrictEqual(result, {
84-
memoryKms: {
85+
scality: {
8586
code: 500,
8687
message: 'KMS health check failed',
8788
description: 'We encountered an internal error. Please try again.',
@@ -116,7 +117,7 @@ describe('KMS.checkHealth', () => {
116117

117118
const expectedLastChecked = new Date(clock.now).toISOString();
118119
assert.deepStrictEqual(result, {
119-
memoryKms: {
120+
scality: {
120121
code: 200,
121122
message: 'OK',
122123
lastChecked: expectedLastChecked,
@@ -134,3 +135,93 @@ describe('KMS.checkHealth', () => {
134135
});
135136
});
136137
});
138+
139+
describe('KMS health of multiple KMSs', () => {
140+
const { kmip: kmipEntry, otherKmip: otherKmipEntry, awsKms: awsEntry, file: fileEntry } = kmsTestEntries;
141+
let clock;
142+
let warnStub;
143+
144+
beforeEach(() => {
145+
clock = sinon.useFakeTimers({
146+
now: 1625077800000,
147+
toFake: ['Date'],
148+
});
149+
sinon.stub(logger, 'info');
150+
warnStub = sinon.stub(logger, 'warn');
151+
});
152+
153+
afterEach(() => {
154+
sinon.restore();
155+
clock.restore();
156+
});
157+
158+
function createInstances(entries) {
159+
const { clientInstances } = kms.createClients(parseKmsEntries(entries));
160+
return clientInstances;
161+
}
162+
163+
function checkKmsHealthByProvider(instances) {
164+
return new Promise(resolve =>
165+
kms.checkKmsHealthByProvider(instances, log, (err, result) => {
166+
assert.ifError(err);
167+
resolve(result);
168+
}),
169+
);
170+
}
171+
172+
it('should report one entry per KMS, keyed by provider name', async () => {
173+
const instances = createInstances([kmipEntry, awsEntry, fileEntry]);
174+
const kmipHealthcheck = sinon
175+
.stub(instances['external:kmip:thales'].client, 'healthcheck')
176+
.callsFake((log, cb) => cb(null));
177+
178+
const result = await checkKmsHealthByProvider(Object.values(instances));
179+
180+
// AWS KMS and file have no health check, they report OK without being checked
181+
assert.deepStrictEqual(result, {
182+
thales: { code: 200, message: 'OK', lastChecked: new Date(clock.now).toISOString() },
183+
acme: { code: 200, message: 'OK' },
184+
scality: { code: 200, message: 'OK' },
185+
});
186+
assert(kmipHealthcheck.calledOnce);
187+
});
188+
189+
it('should report a failing KMS next to healthy ones', async () => {
190+
const instances = createInstances([kmipEntry, awsEntry]);
191+
sinon
192+
.stub(instances['external:kmip:thales'].client, 'healthcheck')
193+
.callsFake((log, cb) => cb(errors.InternalError));
194+
195+
const result = await checkKmsHealthByProvider(Object.values(instances));
196+
197+
assert.deepStrictEqual(result, {
198+
thales: {
199+
code: 500,
200+
message: 'KMS health check failed',
201+
description: 'We encountered an internal error. Please try again.',
202+
lastChecked: new Date(clock.now).toISOString(),
203+
},
204+
acme: { code: 200, message: 'OK' },
205+
});
206+
assert(warnStub.calledOnceWith('KMS health check failed', sinon.match({ provider: 'thales', errorCode: 500 })));
207+
});
208+
209+
it('should keep a health check cache per KMS', async () => {
210+
const instances = createInstances([kmipEntry, otherKmipEntry]);
211+
const thales = instances['external:kmip:thales'];
212+
const hytrust = instances['external:kmip:hytrust'];
213+
const thalesHealthcheck = sinon.stub(thales.client, 'healthcheck').callsFake((log, cb) => cb(null));
214+
const hytrustHealthcheck = sinon
215+
.stub(hytrust.client, 'healthcheck')
216+
.callsFake((log, cb) => cb(errors.InternalError));
217+
218+
await checkKmsHealthByProvider([thales]);
219+
const result = await checkKmsHealthByProvider([thales, hytrust]);
220+
221+
// thales comes from its cache, hytrust has no cached result yet
222+
assert(thalesHealthcheck.calledOnce);
223+
assert(hytrustHealthcheck.calledOnce);
224+
assert.strictEqual(result.thales.code, 200);
225+
assert.strictEqual(result.hytrust.code, 500);
226+
});
227+
});

0 commit comments

Comments
 (0)