UNPKG

@mountainpass/hooked-cli

Version:
255 lines (254 loc) 9.36 kB
import { isDefined, isString, sortCaseInsensitive } from '../types.js'; import logger from './logger.js'; /** * Retrieves all ${...} references from a string. * @param str e.g. 'hello ${name}' * @returns e.g. ['name'] */ export const getEnvVarRefs = (str) => { const regex = /\${([^}]+)}/g; return Object.keys([...str.matchAll(regex)].reduce((prev, curr) => { // allow environment variable defaults (shell & bash syntax) const envvar = curr[1]; // exclude env vars that have a fallback default value const hasDefault = envvar.includes(':') || envvar.includes('='); if (!hasDefault) { prev[envvar] = 1; } return prev; }, {})); }; export function toJsonString(env, pretty = false) { const sorted = Object.fromEntries(Object.entries(env) .sort((a, b) => sortCaseInsensitive(a[0], b[0]))); return pretty ? JSON.stringify(sorted, null, 2) : JSON.stringify(sorted); } export class Environment { constructor() { /** Used for resolving variables, but not intended to be "kept". */ this.global = {}; /** Used for resolved variables, intended to be "kept". */ this.resolved = {}; /** Transient variables, that can be explicitly purged. */ this.secrets = {}; /** All keys defined here, should be excluded from resolution. */ this.doNotResolveList = []; } purgeSecrets() { this.secrets = {}; } /** * Returns all environment variables, excluding secret variables. * @returns */ getAll() { // N.B. order is important, because resolved variables should override global variables return Object.assign(Object.assign({}, this.global), this.resolved); // N.B. secrets go in... but secrets should not come out! } setDoNotResolve(keys) { this.doNotResolveList = [...keys]; } hasSecret(key) { return typeof this.secrets[key] === 'string'; } isSecret(key) { return /^.*secret.*$/i.test(key); } // put putGlobal(key, value) { // reject invalid values if ((isString(value) && value.trim() === '') || !isDefined(value) || value === null) { logger.debug(`Received invalid global value '${value}' for key '${key}', ignoring...`); } if (this.isSecret(key)) { this.secrets[key] = value; } else { this.global[key] = value; } return this; } putResolved(key, value) { // reject invalid values if ((isString(value) && value.trim() === '') || !isDefined(value) || value === null) { logger.debug(`Received invalid resolved value '${value}' for key '${key}', ignoring...`); } if (this.isSecret(key)) { this.putSecret(key, value); } else { this.resolved[key] = value; } return this; } putSecret(key, value) { // reject invalid values if ((isString(value) && value.trim() === '') || !isDefined(value) || value === null) { logger.debug(`Received invalid secret value '${value}' for key '${key}', ignoring...`); } this.secrets[key] = value; return this; } // putAll putAllGlobal(env) { Object.entries(env).forEach(([key, value]) => { this.putGlobal(key, value); }); return this; } /** * Put's all values into the resolved environment. * @param env * @param overwrite if true, then overwrite existing values. if false, then keep existing values. (default = true) * @returns */ putAllResolved(env, overwrite = true) { Object.entries(env).forEach(([key, value]) => { if (isDefined(this.resolved[key]) && !overwrite) { // do nothing... keep existing value } else { this.putResolved(key, value); } }); return this; } putAllSecrets(env) { Object.entries(env).forEach(([key, value]) => { this.putSecret(key, value); }); return this; } putOverwrite(key, value) { const g = this.global[key]; if (isString(g) && g.trim().length > 0) { this.putGlobal(key, value); } const r = this.resolved[key]; if (isString(r) && r.trim().length > 0) { this.putResolved(key, value); } const s = this.secrets[key]; if (isString(s) && s.trim().length > 0) { this.putSecret(key, value); } return this; } willNotBeResolved(key) { return this.doNotResolveList.includes(key); } getMissingRequiredKeys(resolveMe) { if (typeof resolveMe !== 'string') throw new Error(`resolveMe must be a string, but was ${typeof resolveMe}`); const requiredKeys = getEnvVarRefs(resolveMe); const all = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets); const missingKeys = requiredKeys.filter(key => typeof all[key] === 'undefined' || all[key] === null || (typeof all[key] === 'string' && all[key] === '')); return missingKeys; } // RESOLVING VARIABLES isResolvableByKey(key) { const value = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets)[key]; const isResolvable = isString(value) && value.trim().length > 0; // logger.debug(`isResolvableByKey('${key}') = ${String(isResolvable)}`) return isResolvable; } resolveByKey(key) { // OLD return this.getAll()[key] - we want to resolve JUST IN TIME now... not before! if (this.isResolvableByKey(key)) { const value = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets)[key]; return this.resolve(value, key); } else { throw new Error(`Environment key '${key}' is not present.`); } } /** * Resolve the value of a string, using the current environment. * @param resolveMe * @param key * @returns */ resolve(resolveMe, key = 'NOT_DEFINED') { if (typeof resolveMe !== 'string') throw new Error(`resolveMe must be a string, but was ${typeof resolveMe}`); // EXEMPT_ENVIRONMENT_KEYWORDS are special exemptions - that are internally resolved! if (this.willNotBeResolved(key)) return resolveMe; // check for missing environment variables // const requiredKeys = getEnvVarRefs(resolveMe) const missingKeys = this.getMissingRequiredKeys(resolveMe); if (missingKeys.length > 0) { // eslint-disable-next-line max-len const foundString = `Found: ${toJsonString(this.getAll(), true)}`; throw new Error(`Environment '${key}' is missing required environment variables: ${JSON.stringify(missingKeys .sort(sortCaseInsensitive))}.\n${foundString}`); } // use string replacement to resolve from the resolvedEnv const all = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets); const newValue = resolveMe.replace(/\${([^}]+)}/g, (match, p1) => all[p1]); return newValue; } /** * Resolve the value, and put it in the resolved environment. * @param key * @param resolveMe * @returns */ resolveAndPutResolved(key, resolveMe) { const value = this.resolve(resolveMe, key); this.putResolved(key, value); return value; } /** * Clones this object to a new instance. Includes secrets. * @returns */ clone() { const env = new Environment(); env.global = Object.assign({}, this.global); env.resolved = Object.assign({}, this.resolved); env.secrets = Object.assign({}, this.secrets); env.doNotResolveList = [...this.doNotResolveList]; return env; } /** * Replaces the provided environment variables with this instance. Excludes secrets. * @param env */ replace(env) { this.global = Object.assign({}, env.global); this.resolved = Object.assign({}, env.resolved); // this.secrets = { ...env.secrets } this.doNotResolveList = [...env.doNotResolveList]; } /** * Converts the resolved environment variables to a docker .env file string. * @returns */ envToDockerEnvfile() { return Object.entries(this.resolved).map(([k, v]) => `${k}=${v}\n`) .sort(sortCaseInsensitive) .join(''); } /** * Converts the resolved environment variables to a shell exports string. * @returns */ envToShellExports() { const entries = Object.entries(this.resolved); if (entries.length === 0) return ''; return '\n' + entries.map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"\n`) .sort(sortCaseInsensitive) .join('') + '\n'; } toJsonStringResolved(pretty = false) { return toJsonString(this.resolved, pretty); } toString() { return JSON.stringify(this.getAll()); } }