UNPKG

@atomist/automation-client

Version:
727 lines 25 kB
"use strict"; /* * Copyright © 2018 Atomist, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ Object.defineProperty(exports, "__esModule", { value: true }); const appRoot = require("app-root-path"); const cluster = require("cluster"); const fs = require("fs-extra"); const glob = require("glob"); const stringify = require("json-stringify-safe"); const _ = require("lodash"); const p = require("path"); const semver = require("semver"); const globals_1 = require("./globals"); const config_1 = require("./internal/util/config"); const logger_1 = require("./internal/util/logger"); const string_1 = require("./internal/util/string"); const axiosHttpClient_1 = require("./spi/http/axiosHttpClient"); const packageJson_1 = require("./util/packageJson"); /** * Generate defaults for various configuration option values. These * will only be used if values are not provided by any source. Values * not provided here will be `undefined`. * * @return default configuration */ function defaultConfiguration() { const pj = packageJson_1.loadHostPackageJson() || {}; pj.name = pj.name || "atm-client-" + string_1.guid(); pj.version = pj.version || "0.0.0"; pj.keywords = pj.keywords || []; const cfg = loadDefaultConfiguration(); cfg.name = pj.name; cfg.version = pj.version; cfg.keywords = pj.keywords; cfg.application = pj.name.replace(/^@.*?\//, ""); return cfg; } exports.defaultConfiguration = defaultConfiguration; /** * Exposes the configuration for lookup of configuration values. * This is useful for components to obtain values eg. from configuration.custom * like user provided secrets etc. * @param {string} path the property path evaluated against the configuration instance * @returns {T} */ function configurationValue(path, defaultValue) { if (globals_1.automationClientInstance()) { const conf = globals_1.automationClientInstance().configuration; const value = _.get(conf, path); if (value != null) { return value; } else if (defaultValue !== undefined) { return defaultValue; } } else if (defaultValue) { return defaultValue; } throw new Error(`Required @Value '${path}' not available`); } exports.configurationValue = configurationValue; /** * Return the default configuration based on NODE_ENV or ATOMIST_ENV. * ATOMIST_ENV takes precedence if it is set. */ function loadDefaultConfiguration() { const cfg = exports.LocalDefaultConfiguration; let envSpecificCfg = {}; const nodeEnv = process.env.ATOMIST_ENV || process.env.NODE_ENV; if (nodeEnv === "production") { envSpecificCfg = exports.ProductionDefaultConfiguration; } else if (nodeEnv === "staging" || nodeEnv === "testing") { envSpecificCfg = exports.TestingDefaultConfiguration; } else if (nodeEnv) { cfg.environment = nodeEnv; } return mergeConfigs(cfg, envSpecificCfg); } /** * Return Atomist user configuration directory. */ function userConfigDir() { const home = process.env[process.platform === "win32" ? "USERPROFILE" : "HOME"]; return p.join(home, ".atomist"); } /** * Return user automation client configuration path. */ function userConfigPath() { const clientConfigFile = "client.config.json"; return p.join(userConfigDir(), clientConfigFile); } exports.userConfigPath = userConfigPath; /** * Write user config securely, creating directories as necessary. */ function writeUserConfig(cfg) { const cfgDir = userConfigDir(); return fs.ensureDir(cfgDir) .then(() => fs.chmod(cfgDir, 0o700)) .then(() => fs.writeJson(userConfigPath(), cfg, { spaces: 2, encoding: "utf8", mode: 0o600, })); } exports.writeUserConfig = writeUserConfig; /** * Read and return user config from UserConfigFile. */ function getUserConfig() { if (fs.existsSync(userConfigPath())) { try { const cfg = fs.readJsonSync(userConfigPath()); // user config should not have name or version if (cfg.name) { delete cfg.name; } if (cfg.version) { delete cfg.version; } return cfg; } catch (e) { e.message = `Failed to read user config: ${e.message}`; throw e; } } return undefined; } exports.getUserConfig = getUserConfig; /** * Log the loading of a configuration * * @param source name of configuration source */ function cfgLog(source) { if (cluster.isMaster) { logger_1.logger.debug(`Loading ${source} configuration`); } } /** * Overwrite values in the former configuration with values in the * latter. The start object is modified. * * @param obj starting configuration * @param override configuration values to add/override those in start * @return resulting merged configuration */ function mergeConfigs(obj, ...sources) { return _.mergeWith(obj, ...sources, (objValue, srcValue) => { if (_.isArray(srcValue)) { return srcValue; } }); } exports.mergeConfigs = mergeConfigs; /** * Merge a user's global and proper per-module configuration, if it * exists. Values from the per-module configuration take precedence * over the user-wide values. Per-module configuration is gotten from * the first per-module configuration that matches name and, * optionally, the version is within the per-module configuration's * version range. A module configuration without a version range * matches the named module with any version. If no version is * provided, any version range is satisfied, meaning the first * per-module configuration with a matching name is used. If no name * is provide, only the user configuration is loaded. The first * per-module match is used. This means if you have multiple * configurations for the same named module and you want to include a * default configuration for that module, put a configuration without * a version range _after_ all the configurations with version ranges. * Note that only values from the first per-module match are used. * * @param userConfig the user's configuration, which may include per-module configuration * @param name automation client package name to load as module config if it exists * @param version automation client package version to load as module config if * version satifies module config version range * @return the merged module and user configuration */ function resolveModuleConfig(userConfig, name, version) { const cfg = {}; if (userConfig) { cfgLog("user"); const uc = _.cloneDeep(userConfig); let mc = {}; if (userConfig.modules) { delete uc.modules; if (name) { let modCfg; const moduleConfigs = userConfig.modules.filter(m => m.name === name); if (version) { modCfg = moduleConfigs.find(m => !m.version || semver.satisfies(version, m.version)); } else if (moduleConfigs.length > 0) { modCfg = moduleConfigs[0]; } if (modCfg) { cfgLog("module"); if (modCfg.name) { delete modCfg.name; } if (modCfg.version) { delete modCfg.version; } mc = modCfg; } } } mergeConfigs(cfg, uc, mc); } return cfg; } exports.resolveModuleConfig = resolveModuleConfig; /** * Try to read user config, overriding its values with a per-module * configuration that matches this automation. * * @param name automation client package name to load as module config if it exists * @param version automation client package version to load as module config if * version satifies module config version range * @return module-specific config with user config supplying defaults */ function loadUserConfiguration(name, version) { const userConfig = getUserConfig(); return resolveModuleConfig(userConfig, name, version); } exports.loadUserConfiguration = loadUserConfiguration; /** * Load the automation configuration from the configuration object * exported from cfgPath and return it. If no configuration path is * provided, the package will be searched for a file named * atomist.config.js. If no atomist.config.js is found, an empty * object is returned. If more than one is found, an exception is * thrown. * * @param cfgPath location of automation configuration * @return automation configuration */ function loadAutomationConfig(cfgPath) { let cfg = {}; if (!cfgPath) { const cfgFile = "atomist.config.js"; const files = glob.sync(`${appRoot.path}/**/${cfgFile}`, { ignore: ["**/{.git,node_modules}/**"] }); if (files.length === 1) { cfgPath = files[0]; } else if (files.length > 1) { throw new Error(`More than one automation configuration found in package: ${files.join(", ")}`); } } if (cfgPath) { try { cfg = require(cfgPath).configuration; cfgLog("automation config"); } catch (e) { e.message = `Failed to load ${cfgPath}.configuration: ${e.message}`; throw e; } } return cfg; } exports.loadAutomationConfig = loadAutomationConfig; /** * Load configuration from the file defined by the ATOMIST_CONFIG_PATH * environment variable, if it the variable is defined and the file * exists, and return it. The contents of the ATOMIST_CONFIG_PATH * file should be serialized JSON of AutomationServerOptions. If the * environment variable is not defined or the file path specified by * its value cannot be read as JSON, an empty object is returned. * * @return automation server options */ function loadAtomistConfigPath() { let cfg = {}; if (process.env.ATOMIST_CONFIG_PATH) { try { cfg = fs.readJsonSync(process.env.ATOMIST_CONFIG_PATH); cfgLog("ATOMIST_CONFIG_PATH"); } catch (e) { e.message = `Failed to read ATOMIST_CONFIG_PATH: ${e.message}`; throw e; } } return cfg; } exports.loadAtomistConfigPath = loadAtomistConfigPath; /** * Load configuration from the ATOMIST_CONFIG environment variable, if * it the variable is defined, and merge it into the passed in * configuration. The value of the ATOMIST_CONFIG environment * variable should be serialized JSON of AutomationServerOptions. The * values from the environment variable will override values in the * passed in configuration. If the environment variable is not * defined, the passed in configuration is returned unchanged. * * @return automation server options */ function loadAtomistConfig() { let cfg = {}; if (process.env.ATOMIST_CONFIG) { try { cfg = JSON.parse(process.env.ATOMIST_CONFIG); cfgLog("ATOMIST_CONFIG"); } catch (e) { e.message = `Failed to parse contents of ATOMIST_CONFIG environment variable: ${e.message}`; throw e; } } return cfg; } exports.loadAtomistConfig = loadAtomistConfig; /** * Examine environment, config, and cfg for Atomist workspace IDs. * The ATOMIST_WORKSPACES environment variable takes precedence over * the configuration "workspaceIds", which takes precedence over * cfg.workspaceId, which may be undefined, null, or an empty array. * If the ATOMIST_WORKSPACES environment variable is not set, * workspaceIds is not set in config, and workspaceIds is falsey in * cfg and teamIds is resolvable from the configuration, workspaceIds * is set to teamIds. * * @param cfg current configuration, whose workspaceIds and teamIds * properties may be modified by this function * @return the resolved workspace IDs */ function resolveWorkspaceIds(cfg) { const teamIds = []; if (process.env.ATOMIST_WORKSPACES) { cfg.workspaceIds = process.env.ATOMIST_WORKSPACES.split(","); } else if (config_1.config("workspaceIds")) { cfg.workspaceIds = config_1.config("workspaceIds"); } else if ((!cfg.workspaceIds || cfg.workspaceIds.length < 1) && teamIds && teamIds.length > 0) { cfg.workspaceIds = teamIds; } return cfg.workspaceIds; } exports.resolveWorkspaceIds = resolveWorkspaceIds; /** * Resolve a value from a environment variables or configuration keys. * The environment variables are checked in order and take precedence * over the configuration key, which are also checked in order. If * no truthy values are found, undefined is returned. * * @param environmentVariables environment variables to check * @param configKeyPaths configuration keys, as JSON paths, to check * @param defaultValue value to use if no environment variables or config keys have values * @return first truthy value found, or defaultValue */ function resolveConfigurationValue(environmentVariables, configKeyPaths, defaultValue) { for (const ev of environmentVariables) { if (process.env[ev]) { return process.env[ev]; } } for (const cv of configKeyPaths) { if (config_1.config(cv)) { return config_1.config(cv); } } return defaultValue; } exports.resolveConfigurationValue = resolveConfigurationValue; /** * Resolve the HTTP port from the environment and configuration. The * PORT environment variable takes precedence over the config value. */ function resolvePort(cfg) { if (process.env.PORT) { cfg.http.port = parseInt(process.env.PORT, 10); } return cfg.http.port; } exports.resolvePort = resolvePort; const EnvironmentVariablePrefix = "ATOMIST_"; /** * Resolve ATOMIST_ environment variables and add them to config. * Variables of like ATOMIST_custom_foo_bar will be converted to * a json path of custom.foo.bar. * @param {Configuration} cfg */ function resolveEnvironmentVariables(cfg) { for (const key in process.env) { if (key.startsWith(EnvironmentVariablePrefix) && process.env.hasOwnProperty(key)) { const cleanKey = key.slice(EnvironmentVariablePrefix.length).split("_").join("."); if (cleanKey[0] !== cleanKey[0].toUpperCase()) { _.update(cfg, cleanKey, () => process.env[key]); } } } } exports.resolveEnvironmentVariables = resolveEnvironmentVariables; /** * Resolve placeholders against the process.env. * Placeholders should be of form ${ENV_VAR}. Placeholders support default values * in case they aren't defined: ${ENV_VAR:default value} * @param {Configuration} config */ function resolvePlaceholders(cfg) { resolvePlaceholdersRecursively(cfg); } exports.resolvePlaceholders = resolvePlaceholders; function resolvePlaceholdersRecursively(obj) { for (const property in obj) { if (obj.hasOwnProperty(property)) { if (typeof obj[property] === "object") { resolvePlaceholdersRecursively(obj[property]); } else if (typeof obj[property] === "string") { obj[property] = resolvePlaceholder(obj[property]); } } } } const PlaceholderExpression = /\$\{([.a-zA-Z_-]+)([.:0-9a-zA-Z-_ \" ]+)*\}/g; function resolvePlaceholder(value) { if (PlaceholderExpression.test(value)) { PlaceholderExpression.lastIndex = 0; let result; // tslint:disable-next-line:no-conditional-assignment while (result = PlaceholderExpression.exec(value)) { const fm = result[0]; const envValue = process.env[result[1]]; const defaultValue = result[2] ? result[2].trim().slice(1) : undefined; if (envValue) { value = value.split(fm).join(envValue); } else if (defaultValue) { value = value.split(fm).join(defaultValue); } else { throw new Error(`Environment variable '${result[1]}' is not defined`); } } } return value; } /** * Invoke postProcessors on the provided configuration. */ function invokePostProcessors(cfg) { return cfg.postProcessors.reduce((pp, fp) => pp.then(fp), Promise.resolve(cfg)); } exports.invokePostProcessors = invokePostProcessors; /** * Make sure final configuration has the minimum configuration it * needs. It will throw an error if required properties are missing. * * @param cfg final configuration */ function validateConfiguration(cfg) { if (!cfg) { throw new Error(`no configuration defined`); } const errors = []; if (!cfg.name) { errors.push("you must set a 'name' property in your configuration"); } if (!cfg.version) { errors.push("you must set a 'version' property in your configuration"); } if (!cfg.token && !cfg.apiKey) { console.info("INFO: To obtain an 'apiKey' visit https://app.atomist.com/apikeys and run 'atomist config' " + "to configure the apiKey in your local configuration"); errors.push("you must set an 'apiKey' property in your configuration"); } cfg.teamIds = cfg.teamIds || []; cfg.workspaceIds = cfg.workspaceIds || []; if (cfg.workspaceIds.length === 0) { cfg.workspaceIds = cfg.teamIds; } cfg.groups = cfg.groups || []; if (cfg.workspaceIds.length < 1 && cfg.groups.length < 1) { errors.push("you must either provide an array of 'groups' in your configuration or, more likely, provide " + "an array of 'workspaceIds' in your configuration or set the ATOMIST_WORKSPACES environment variable " + "to a comma-separated list of workspace IDs"); } if (cfg.workspaceIds.length > 0 && cfg.groups.length > 0) { errors.push("you cannot specify both 'workspaceIds' and 'groups' in your configuration, you must set one " + "to an empty array"); } if (errors.length > 0) { const msg = `Configuration (${stringify(cfg, string_1.obfuscateJson)}) is not correct: ${errors.join("; ")}`; throw new Error(msg); } } exports.validateConfiguration = validateConfiguration; /** * Load and populate the automation configuration. The configuration * is loaded from several locations with the following precedence from * highest to lowest. * * 0. Recognized environment variables (see below) * 1. The value of the ATOMIST_CONFIG environment variable, parsed as * JSON and cast to AutomationServerOptions * 2. The contents of the ATOMIST_CONFIG_PATH file as AutomationServerOptions * 3. The automation's atomist.config.js exported configuration as * Configuration * 4. The contents of the user's client.config.json as UserConfig * resolving user and per-module configuration into Configuration * 5. ProductionDefaultConfiguration if ATOMIST_ENV or NODE_ENV is set * to "production" or TestingDefaultConfiguration if ATOMIST_ENV or * NODE_ENV is set to "staging" or "testing", with ATOMIST_ENV * taking precedence over NODE_ENV. * 6. LocalDefaultConfiguration * * If any of the sources are missing, they are ignored. Any truthy * configuration values specified by sources of higher precedence * cause any values provided by sources of lower precedence to be * ignored. Arrays are replaced, not merged. Typically the only * required values in the configuration for a successful registration * are the apiKey or token and non-empty workspaceIds. * * Placeholder of the form `${ENV_VARIABLE}` in string configuration * values will get resolved against the environment. The resolution * happens at the very end when all configs have been merged. * * The configuration exported from the atomist.config.js is modified * to contain the final configuration values and returned from this * function. * * @param cfgPath path to file exporting the configuration object, if * not provided the package is searched for one * @return merged configuration object */ function loadConfiguration(cfgPath) { // Register the logger globally so that downstream modules can see it global.__logger = logger_1.logger; let cfg; try { const defCfg = defaultConfiguration(); const userCfg = loadUserConfiguration(defCfg.name, defCfg.version); const autoCfg = loadAutomationConfig(cfgPath); const atmPathCfg = loadAtomistConfigPath(); const atmCfg = loadAtomistConfig(); cfg = mergeConfigs({}, defCfg, userCfg, autoCfg, atmPathCfg, atmCfg); resolveWorkspaceIds(cfg); resolvePort(cfg); resolveEnvironmentVariables(cfg); resolvePlaceholders(cfg); } catch (e) { logger_1.logger.error(`Failed to load configuration: ${e.message}`); if (e.stack) { logger_1.logger.error(`Stack trace:\n${e.stack}`); } return Promise.reject(e); } return invokePostProcessors(cfg) .then(completeCfg => { completeCfg.postProcessors = []; try { validateConfiguration(completeCfg); } catch (e) { return Promise.reject(e); } return Promise.resolve(completeCfg); }); } exports.loadConfiguration = loadConfiguration; /** * Default configuration when running in neither testing or * production. */ exports.LocalDefaultConfiguration = { teamIds: [], workspaceIds: [], groups: [], environment: "local", policy: "ephemeral", endpoints: { api: "https://automation.atomist.com/registration", graphql: "https://automation.atomist.com/graphql/team", }, http: { enabled: true, host: "localhost", port: 2866, auth: { basic: { enabled: false, }, bearer: { enabled: false, }, }, customizers: [], client: { factory: axiosHttpClient_1.DefaultHttpClientFactory, }, }, ws: { enabled: true, termination: { graceful: false, gracePeriod: 10000, }, compress: false, timeout: 10000, }, applicationEvents: { enabled: false, }, cluster: { enabled: false, }, logging: { level: "debug", file: { enabled: true, level: "debug", }, banner: { enabled: true, contributors: [], }, }, statsd: { enabled: false, }, commands: null, events: null, ingesters: [], listeners: [], postProcessors: [], }; /** * Configuration defaults for production environments. */ exports.ProductionDefaultConfiguration = { environment: "production", policy: "durable", http: { auth: { basic: { enabled: true, }, bearer: { enabled: true, }, }, }, ws: { termination: { graceful: true, }, compress: true, }, applicationEvents: { enabled: true, }, cluster: { enabled: true, }, logging: { level: "info", file: { enabled: false, }, }, statsd: { enabled: true, }, }; /** * Configuration defaults for pre-production environments. */ exports.TestingDefaultConfiguration = { environment: "testing", policy: "durable", http: { auth: { basic: { enabled: true, }, bearer: { enabled: true, }, }, }, ws: { termination: { graceful: true, }, compress: true, }, applicationEvents: { enabled: true, }, cluster: { enabled: true, }, logging: { level: "info", file: { enabled: false, }, }, statsd: { enabled: true, }, }; //# sourceMappingURL=configuration.js.map