diff --git a/.eslintrc.json b/.eslintrc.json index 626c8c2..9a23719 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,6 @@ { "parserOptions": { - "ecmaVersion": 8, + "ecmaVersion": 2022, "sourceType": "module" }, "env": { diff --git a/lib/FetchEnvs.js b/lib/FetchEnvs.js index 57fc13f..87b11b3 100644 --- a/lib/FetchEnvs.js +++ b/lib/FetchEnvs.js @@ -14,235 +14,196 @@ * limitations under the License. */ -const objectPath = require('object-path'); -const clone = require('clone'); const merge = require('deepmerge'); const log = require('./bunyan-api').createLogger('fetchEnvs'); +const STRING = 'string'; +const OBJECT = 'object'; +const ERR_NODATA = 'make sure your data exists in the correct location and is in the expected format.'; +const KIND_MAP = new Map([ + ['secretKeyRef', 'Secret'], + ['secretMapRef', 'Secret'], + ['configMapRef', 'ConfigMap'], + ['configMapKeyRef', 'ConfigMap'] +]); + module.exports = class FetchEnvs { + + get [Symbol.toStringTag]() { + return 'FetchEnv'; + } + constructor(controllerObject) { if (!controllerObject) { throw Error('FetchEnvs must have: controller object instance'); } this.data = controllerObject.data; - this.namespace = objectPath.get(this.data, 'object.metadata.namespace'); + this.namespace = this.data?.object?.metadata?.namespace; this.kubeResourceMeta = controllerObject.kubeResourceMeta; this.kubeClass = controllerObject.kubeClass; - + this.api = this.kubeResourceMeta.request.bind(this.kubeResourceMeta); this.updateRazeeLogs = controllerObject.updateRazeeLogs ? ((logLevel, log) => { controllerObject.updateRazeeLogs(logLevel, log); }) : (() => { log.debug('\'updateRazeeLogs()\' not passed to fetchEnvs. will not update razeeLogs on failure to fetch envs'); }); } - async _lookupDataReference(valueFrom) { - let result; - let type; - let uri = ''; - let key = ''; - let kubeError; - if (valueFrom['secretKeyRef']) { - let secretName = objectPath.get(valueFrom, 'secretKeyRef.name'); - let namespace = objectPath.get(valueFrom, 'secretKeyRef.namespace', this.namespace); - key = objectPath.get(valueFrom, 'secretKeyRef.key'); - uri = `/api/v1/namespaces/${namespace}/secrets/${secretName}`; - let res; + #secretMapRef(conf) { + return this.#genericMapRef(conf, 'secretMapRef', true); + } + + #secretKeyRef(conf) { + return this.#genericKeyRef(conf, 'secretKeyRef', true); + } + + #configMapRef(conf) { + return this.#genericMapRef(conf, 'configMapRef'); + } + + #configMapKeyRef(conf) { + return this.#genericKeyRef(conf, 'configMapKeyRef'); + } + + async #genericMapRef(conf, valueFrom = 'genericMapRef', decode = false) { + let resource; + let kubeError = ERR_NODATA; + const ref = conf[valueFrom]; + const optional = !!conf.optional; + + const { + apiVersion = 'v1', + kind = KIND_MAP.get(valueFrom), + namespace = this.namespace, + name + } = ref; + + const krm = await this.kubeClass.getKubeResourceMeta(apiVersion, kind, 'update'); + + if (krm) { try { - res = await this.kubeResourceMeta.request({ uri: uri, json: true }); - } catch (e) { - log.warn(e); - kubeError = e.message; + resource = await krm.get(name, namespace); + } catch (error) { + log.warn(error); + kubeError = error.message; } - if (res && objectPath.has(res, ['data', key])) { - result = Buffer.from(objectPath.get(res, ['data', key]), 'base64').toString(); + } + + const data = resource?.data; + + if (!data) { + const msg = `failed to get envFrom ${JSON.stringify(ref)}, optional=${optional}: ${kubeError}`; + if (!optional) throw new Error(msg); + log.warn(msg); + this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); + return {...conf, data}; + } + + if (decode) { + for (const [key, value] of Object.entries(data)) { + data[key] = Buffer.from(value, 'base64').toString(); } - type = objectPath.get(valueFrom, 'secretKeyRef.type'); - } else if (valueFrom['configMapKeyRef']) { - let name = objectPath.get(valueFrom, 'configMapKeyRef.name'); - let namespace = objectPath.get(valueFrom, 'configMapKeyRef.namespace', this.namespace); - key = objectPath.get(valueFrom, 'configMapKeyRef.key'); - uri = `/api/v1/namespaces/${namespace}/configmaps/${name}`; - let res; + } + + return { ...conf, data }; + } + + async #genericKeyRef(conf, valueFrom = 'genericKeyRef', decode = false) { + let response; + let kubeError = ERR_NODATA; + const optional = !!conf.optional; + const defaultValue = conf.default; + const ref = conf.valueFrom[valueFrom]; + const strategy = conf.overrideStrategy; + const { + name, + key, + matchLabels, + type, + namespace = this.namespace, + kind = KIND_MAP.get(valueFrom), + apiVersion = 'v1' + } = ref; + + const krm = await this.kubeClass.getKubeResourceMeta( + apiVersion, + kind, + 'update' + ); + + const matchLabelsQS = labelSelectors(matchLabels); + + if (krm) { try { - res = await this.kubeResourceMeta.request({ uri: uri, json: true }); - } catch (e) { - log.warn(e); - kubeError = e.message; - } - result = objectPath.get(res, ['data', key]); - type = objectPath.get(valueFrom, 'configMapKeyRef.type'); - } else if (valueFrom['genericKeyRef']) { - let apiVersion = objectPath.get(valueFrom, 'genericKeyRef.apiVersion'); - let kind = objectPath.get(valueFrom, 'genericKeyRef.kind'); - let krm = await this.kubeClass.getKubeResourceMeta(apiVersion, kind, 'update'); - let name = objectPath.get(valueFrom, 'genericKeyRef.name'); - let namespace = objectPath.get(valueFrom, 'genericKeyRef.namespace', this.namespace); - let resource = {}; - if (krm) { - try { - resource = await krm.get(name, namespace); - } catch (e) { - log.warn(e); - kubeError = e.message; - } + response = await this.api({ + uri: krm.uri({ namespace, kind, name }), + json: true, + qs: matchLabelsQS + }); + } catch (error) { + kubeError = error.message; } - key = objectPath.get(valueFrom, 'genericKeyRef.key'); - result = objectPath.get(resource, ['data', key]); - type = objectPath.get(valueFrom, 'genericKeyRef.type'); } - if (type && result) { - switch (type) { - case 'number': - result = Number(result); - break; - case 'boolean': - result = (result.toLowerCase() === 'true'); - break; - case 'json': - result = JSON.parse(result); - break; - case 'jsonString': - // Stringify the jsonstring. This has the effect of double escaping the json, so that - // when we go to parse the final template to apply it to kube, it doesnt mistakenly - // turn our jsonString into actual json. - result = JSON.stringify(result); - // JSON.stringify adds quotes around the newly created json string. Kube forces us - // to wrap out curly braces in quotes so that it wont error on our templates. In order - // to avoid having 2 double quotes around the result, we need to remove the stringify - // quotes. slice(start of slice, end of slice) - result = result.slice(1, result.length - 1); - break; - case 'base64': - result = Buffer.from(result).toString('base64'); - break; + + let value = response?.data?.[key]; + + if (typeof matchLabelsQS === OBJECT) { + const output = response?.items.reduce( + reduceItemList(ref, strategy, decode), + Object.create(null) + ); + + value = output?.[key]; + decode = false; + } + + if (value === undefined) { + if (defaultValue === undefined) { + const msg = `failed to get env ${JSON.stringify(ref)}, optional=${optional}: ${kubeError}`; + if (!optional) throw new Error(msg); + log.warn(msg); + this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); + } else { + value = defaultValue; + const msg = `failed to get env '${JSON.stringify(ref)}', Using default value: ${defaultValue}`; + + log.warn(msg); + this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); } } - return kubeError === undefined ? (result) : { error: kubeError }; + + value = (decode && typeof value == STRING) + ? Buffer.from(value, 'base64').toString() + : value; + + return typeCast(value, type); } - async _processEnvFrom(envFrom) { - return await Promise.all(envFrom.map(async (element) => { - const mapData = clone(element); - let optional = objectPath.get(element, 'optional', false); - let kubeError; - if (objectPath.has(element, 'configMapRef')) { - let name = objectPath.get(element, 'configMapRef.name'); - let namespace = objectPath.get(element, 'configMapRef.namespace', this.namespace); - let res; - try { - res = await this.kubeResourceMeta.request({ uri: `/api/v1/namespaces/${namespace}/configmaps/${name}`, json: true }); - } catch (e) { - log.warn(e); - kubeError = e.message; - } - - let data = objectPath.get(res, 'data'); - if (kubeError === undefined) kubeError = 'no data returned from processEnvFrom. make sure your data exists in the correct location and is in the expected format.'; - - if (data) { - mapData.data = data; - } else if (!optional) { - throw new Error(`envFrom.configMapRef.${name}.data not found: ${kubeError}`); - } else { - let msg = `envFrom.configMapRef.${name}.data not found: ${kubeError}`; - log.warn(msg); - this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); - } - } else if (objectPath.has(element, 'secretMapRef')) { - let name = objectPath.get(element, 'secretMapRef.name'); - let namespace = objectPath.get(element, 'secretMapRef.namespace', this.namespace); - let res; - try { - res = await this.kubeResourceMeta.request({ uri: `/api/v1/namespaces/${namespace}/secrets/${name}`, json: true }); - } catch (e) { - log.warn(e); - kubeError = e.message; - } - - let data = objectPath.get(res, 'data'); - if (kubeError === undefined) kubeError = 'no data returned from processEnvFrom. make sure your data exists in the correct location and is in the expected format.'; - - if (data) { - for (const [key, value] of Object.entries(data)) { - data[key] = Buffer.from(value, 'base64').toString(); - } - mapData.data = data; - } else if (!optional) { - throw new Error(`envFrom.secretMapRef.${name}.data not found: ${kubeError}`); - } else { - let msg = `envFrom.secretMapRef.${name}.data not found: ${kubeError}`; - log.warn(msg); - this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); - } - } else if (objectPath.has(element, 'genericMapRef')) { - let apiVersion = objectPath.get(element, 'genericMapRef.apiVersion'); - let kind = objectPath.get(element, 'genericMapRef.kind'); - let krm = await this.kubeClass.getKubeResourceMeta(apiVersion, kind, 'update'); - let name = objectPath.get(element, 'genericMapRef.name'); - let namespace = objectPath.get(element, 'genericMapRef.namespace', this.namespace); - let resource = {}; - if (krm) { - try { - resource = await krm.get(name, namespace); - } catch (e) { - log.warn(e); - kubeError = e.message; - } - } - - let data = objectPath.get(resource, 'data'); - if (kubeError === undefined) kubeError = 'no data returned from processEnvFrom. make sure your data exists in the correct location and is in the expected format.'; - - if (data) { - mapData.data = data; - } else if (!optional) { - throw new Error(`envFrom.genericMapRef.${apiVersion}.${kind}.${name}.data not found: ${kubeError}`); - } else { - let msg = `envFrom.genericMapRef.${apiVersion}.${kind}.${name}.data not found: ${kubeError}`; - log.warn(msg); - this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); - } - } - return mapData; + + #processEnvFrom(envFrom) { + return Promise.all(envFrom.map((element) => { + const { configMapRef, secretMapRef, genericMapRef } = element; + if (configMapRef) return this.#configMapRef(element); + if (secretMapRef) return this.#secretMapRef(element); + if (genericMapRef) return this.#genericMapRef(element); + return element; })); } - async _processEnv(env) { - return await Promise.all(env.map(async (e) => { - let optional = objectPath.get(e, 'optional', false); - let defaultValue = objectPath.get(e, 'default'); - if (e.value) { - return e; - } else if (e.valueFrom) { - let value; - let kubeError; - try { - value = await this._lookupDataReference(e.valueFrom); - kubeError = objectPath.get(value, 'error'); - } catch (err) { - kubeError = err; - } - if (value === undefined || kubeError !== undefined) { - if (kubeError === undefined) kubeError = 'no value returned from lookupDataReference. make sure your data exists in the correct location and is in the expected format.'; - if (defaultValue === undefined) { - let msg = `failed to get env ${JSON.stringify(e.valueFrom)}, optional=${optional}: ${kubeError}`; - if (!optional) { - throw new Error(msg); - } else { - log.warn(msg); - this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); - } - } else { - e.value = defaultValue; - - let msg = `failed to get env '${JSON.stringify(e.valueFrom)}', Using default value: ${defaultValue}`; - log.warn(msg); - this.updateRazeeLogs('warn', { controller: 'FetchEnvs', message: msg }); - } - } else { - e.value = value; - } - return e; + #processEnv(envs) { + return Promise.all(envs.map(async (env) => { + if (env.value) return env; + const valueFrom = env.valueFrom || {}; + const { genericKeyRef, configMapKeyRef, secretKeyRef } = valueFrom; + + if (!genericKeyRef && !configMapKeyRef && !secretKeyRef) { + throw new Error(`oneOf genericKeyRef, configMapKeyRef, secretKeyRef must be defined. Got: ${JSON.stringify(env)}`); } + + let value; + if (secretKeyRef) value = await this.#secretKeyRef(env); + if (configMapKeyRef) value = await this.#configMapKeyRef(env); + if (genericKeyRef) value = await this.#genericKeyRef(env); + + return { ...env, value }; })); } @@ -251,25 +212,93 @@ module.exports = class FetchEnvs { // removes any number of '.' at the start and end of the path, and // removes the '.env' or '.envFrom' if the paths ends in either path = path.replace(/^\.*|\.*$|(\.envFrom\.*$)|(\.env\.*$)/g, ''); - let envFrom = objectPath.get(this.data, `object.${path}.envFrom`, []); - envFrom = await this._processEnvFrom(envFrom); - envFrom.forEach((e) => { - let data = objectPath.get(e, 'data', {}); - Object.assign(result, data); - }); - - let env = objectPath.get(this.data, `object.${path}.env`, []); - env = await this._processEnv(env); - env.forEach((e) => { - if (e.value !== undefined) { - if (e.overrideStrategy === 'merge' && typeof result[e.name] === 'object' && typeof e.value === 'object') { - const merged = merge(result[e.name], e.value); - result[e.name] = merged; - } else { - result[e.name] = e.value; - } - } - }); - return result; + + let envFrom = this.data?.object?.[path]?.envFrom ?? []; + envFrom = await this.#processEnvFrom(envFrom); + for (const env of envFrom) { + const data = env?.data ?? {}; + result = {...result, ...data}; + } + + const env = this.data?.object?.[path]?.env ?? []; + return (await this.#processEnv(env)).reduce(reduceEnv, result); } }; + +function reduceItemList(ref, strategy, decode) { + const { key, type } = ref; + return (output, item) => { + const tmp = item?.data?.[key]; + const value = (decode && typeof tmp === STRING) + ? typeCast(Buffer.from(tmp, 'base64').toString(), type) + : typeCast(tmp, type); + + if (value !== undefined) { + if (strategy === 'merge' && typeof output[key] === OBJECT && typeof value === OBJECT) { + output[key] = merge(output[key], value); + } else { + output[key] = value; + } + } + return output; + }; +} + +function reduceEnv(output, conf) { + const { value, overrideStrategy, name } = conf; + + if (value !== undefined) { + if (overrideStrategy === 'merge' && typeof output[name] === OBJECT && typeof value === OBJECT) { + output[name] = merge(output[name], value); + } else { + output[name] = value; + } + } + + return output; +} + +function labelSelectors(query) { + if (!query) return; + + const keys = Object.keys(query); + if (!keys.length) return; + + return { + labelSelector: keys.map((key) => { + return `${key}=${query[key]}`; + }).join(',') + }; +} + +function typeCast(value, type) { + if (!type) return value; + if (value == null) return; + if (typeof value !== STRING) return value; + + switch (type) { + case 'number': { + return Number(value); + } + case 'boolean': { + return (value.toLowerCase() === 'true'); + } + case 'json': { + return JSON.parse(value); + } + case 'jsonString': { + // Stringify the jsonstring. This has the effect of double escaping the json, so that + // when we go to parse the final template to apply it to kube, it doesnt mistakenly + // turn our jsonString into actual json. + const result = JSON.stringify(value); + // JSON.stringify adds quotes around the newly created json string. Kube forces us + // to wrap out curly braces in quotes so that it wont error on our templates. In order + // to avoid having 2 double quotes around the result, we need to remove the stringify + // quotes. slice(start of slice, end of slice) + return result.slice(1, result.length - 1); + } + case 'base64': { + return Buffer.from(value).toString('base64'); + } + } +}