k8s-features
Version:
A Cucumber-js base library for Kubernetes Gherkin tests, with base world class, basic steps, reusable utility functions and k8s client
392 lines (347 loc) • 9.38 kB
JavaScript
const assert = require('node:assert');
const { KindToResourceMapper } = require('../k8s/kindToResourceMapper.cjs');
const { sleep } = require('../util/sleep.cjs');
const { logger } = require('../util/logger.cjs');
class ResourceDeclaration {
deleteOnFinish = false;
/**
* @param {string} alias
* @param {string} kind
* @param {string} apiVersion
* @param {string} name
* @param {string | undefined} namespace
*/
constructor(alias, kind, apiVersion, name, namespace = undefined) {
/**
* @type {string}
*/
this.alias = alias;
/**
* @type {string}
*/
this.kind = kind;
/**
* @type {string}
*/
this.apiVersion = apiVersion;
/**
* @type {import("@kubernetes/client-node").V1APIResource | undefined}
*/
this.resource = undefined;
/**
* @type {string}
*/
this.name = name;
/**
* @type {string}
*/
this.namespace = namespace;
/**
* @type {import("@kubernetes/client-node").KubernetesObject | undefined}
*/
this.obj = undefined;
this.deleteOnFinish = false;
this.evaluated = false;
}
/**
*
* @returns {import("@kubernetes/client-node").KubernetesObject | undefined}
*/
getObj() {
return this.obj;
}
/**
* @returns {string}
*/
key() {
const result = `${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`;
return result;
}
}
class WatchedResources {
/**
* @param {import('./world.cjs').MyWorld} world
* @param {import("@kubernetes/client-node").KubeConfig} kc
*/
constructor(world, kc) {
this.started = false;
/**
* @type {import('./world.cjs').MyWorld}
* @private
* @readonly
*/
this.world = world;
/**
* @type KindToResourceMapper
* @private
* @readonly
*/
this.resourceMapper = new KindToResourceMapper(kc);
/**
* Map of alias => ResourceDeclaration
* @type {Map<string, ResourceDeclaration>}
* @readonly
* @private
*/
this.items = new Map();
/**
* @private
*/
this._watchCount = 0;
/**
* @type {Map<string, string>}
*/
this._evaluationErrorCache = new Map();
}
/**
*
* @returns {ResourceDeclaration[]}
*/
getCreatedItems() {
/** @type {ResourceDeclaration[]} */
let result = [];
/** @type {ResourceDeclaration[]} */
const items = this.items.values();
for (let item of items) {
if (item.deleteOnFinish) {
result.push(item);
}
}
return result;
}
/**
*
* @param {string} alias
* @param {string} kind
* @param {string} apiVersion
* @param {string} name
* @param {string} namespace
* @returns {void}
*/
add(alias, kind, apiVersion, name, namespace) {
const rx = /.+/;
if (this.items.has(alias)) {
assert.fail(`Resource ${alias} already declared`);
}
assert.match(alias, rx, "Alias must not be an empty string");
assert.match(kind, rx, "Kind must not be an empty string");
assert.match(apiVersion, rx, "ApiVersion must not be an empty string");
assert.match(name, rx, "Name must not be an empty string");
this.items.set(alias, new ResourceDeclaration(alias, kind, apiVersion, name, namespace));
}
/**
*
* @param {string} apiVersion
* @returns {Promise<import("@kubernetes/client-node").V1APIResource[]>}
*/
async getAllResourcesFromApiVersion(apiVersion) {
return await this.resourceMapper.getAllResourcesFromApiVersion(apiVersion);
}
/**
*
* @param {string} alias
* @returns {ResourceDeclaration | undefined}
*/
getItem(alias) {
return this.items.get(alias);
}
/**
*
* @param {string} alias
* @returns {import("@kubernetes/client-node").KubernetesObject | undefined}
*/
getObj(alias) {
const item = this.items.get(alias);
if (item) {
return item.getObj();
}
return undefined;
}
/**
* @returns <Object>
*/
contextObjects() {
const ctx = {
_: {},
};
for (/** @type [string, ResourceDeclaration] */ let [alias, item] of this.items) {
const obj = item.getObj();
ctx[alias] = obj;
ctx._[alias] = {
apiVersion: item.apiVersion,
kind: item.kind,
name: item.name,
namespace: item.namespace,
evaluated: item.evaluated,
deleteOnFinish: item.deleteOnFinish,
resource: item.resource,
obj,
};
}
return ctx
}
/**
* @private
*/
async _watchInterval() {
const exit = (function() {
this._watchCount++;
}).bind(this);
if (!this.started) {
return exit();
}
for (let item of this.items.values()) {
if (!this.started) {
return exit();
}
if (!item.resource) {
try {
item.resource = await this.resourceMapper.getResourceFromKind(item.apiVersion, item.kind);
} catch {
item.obj = undefined;
continue;
}
}
if (!item.evaluated) {
try {
const nameEvaluated = this.world.templateWithThrow(item.name);
if (!nameEvaluated || nameEvaluated.includes('undefined')) {
throw new Error('empty name');
}
item.name = nameEvaluated;
if (item.resource.namespaced) {
if (item.namespace) {
const namespaceEvaluated = this.world.templateWithThrow(item.namespace);
if (!namespaceEvaluated || namespaceEvaluated.includes('undefined')) {
throw new Error('empty namespace');
}
item.namespace = namespaceEvaluated;
} else {
item.namespace = this.world.parameters.namespace ?? 'default';
}
} else {
item.namespace = '';
}
item.evaluated = true;
this._evaluationErrorCache.delete(item.alias);
logger.info('Watched resource evaluated', {
alias: item.alias,
kind: item.kind,
apiVersion: item.apiVersion,
name: item.name,
namespace: item.namespace,
});
} catch (err) {
const keyObj = {
msg: `Error evaluating item ${item.alias}: ${err}`,
err,
};
const key = JSON.stringify(keyObj);
if (!this._evaluationErrorCache.has(item.alias) || this._evaluationErrorCache.get(item.alias) != key) {
this._evaluationErrorCache.set(item.alias, key);
logger.info(keyObj.msg, {
alias: item.alias,
kind: item.kind,
apiVersion: item.apiVersion,
name: item.name,
namespace: item.namespace,
errTxt: `${err}`,
err,
});
}
item.obj = undefined;
continue;
}
}
const spec = {
apiVersion: item.apiVersion,
kind: item.kind,
metadata: {
name: item.name,
}
};
if (!spec.apiVersion || !spec.kind || !spec.metadata.name) {
item.obj = undefined;
continue;
}
if (item.resource.namespaced) {
spec.metadata.namespace = item.namespace;
}
const oldObj = item.obj;
try {
const resp = await this.world.api.read(spec);
if (resp.body) {
item.obj = resp.body;
if (!oldObj) {
logger.info('Non-existing resource created', {
alias: item.alias,
kind: item.kind,
apiVersion: item.apiVersion,
name: item.name,
namespace: item.namespace,
state: item.obj.status ? item.obj.status.state : '',
conditions: item.obj.status && item.obj.status.conditions ?
item.obj.status.conditions.map(c => `{${c.type}/${c.reason}/${c.message}}`) : '',
});
}
} else {
item.obj = undefined;
}
} catch {
if (oldObj) {
logger.info('Existing resource deleted', {
alias: item.alias,
kind: item.kind,
apiVersion: item.apiVersion,
name: item.name,
namespace: item.namespace,
});
}
item.obj = undefined; // err.statusCode == 404
continue;
}
}
if (!this.started) {
return exit();
}
const repeat = function() {
this._watchInterval();
}
setTimeout(repeat.bind(this), 1000);
exit();
}
/**
* Blocks until watch loop has run at least once, so that all declareed items
* have fresh values.
* @returns {Promise<void>}
*/
async startWatches() {
const seenCount = this._watchCount;
if (!this.started) {
this.started = true;
this._watchInterval();
}
while (seenCount + 1 >= this._watchCount) {
await sleep(300);
}
}
/**
* Blocks until watch loop is stopped so watched items will not change anymore
* @returns {Promise<void>}
*/
async stopWatches() {
if (!this.started) {
await sleep(1000);
return;
}
const seenCount = this._watchCount;
this.started = false;
while (seenCount == this._watchCount) {
await sleep(300);
}
}
}
module.exports = {
ResourceDeclaration,
WatchedResources,
};