@atomist/automation-client
Version:
Atomist API for software low-level client
727 lines • 25 kB
JavaScript
;
/*
* 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