@cap-js-community/mtx-tool
Version:
Multitenancy and Extensibility Tool is a cli to reduce operational overhead for multitenant Cloud Foundry applications
474 lines (429 loc) • 15.3 kB
JavaScript
;
const urllib = require("url");
const pathlib = require("path");
const { version } = require("../package.json");
const {
tryReadJsonSync,
tryAccessSync,
writeJsonSync,
spawnAsync,
safeUnshift,
escapeRegExp,
makeOneTime,
} = require("./shared/static");
const { assert, fail } = require("./shared/error");
const { request } = require("./shared/request");
const { getUaaTokenFromCredentials: sharedUaaTokenFromCredentials } = require("./shared/oauth");
const { LazyCache, ExpiringLazyCache } = require("./shared/cache");
const { Logger } = require("./shared/logger");
const { CONFIG_TYPE, CONFIG_INFOS } = require("./config");
const ENV = Object.freeze({
APP_SUFFIX: "MTX_APP_SUFFIX",
});
const APP_SUFFIXES = safeUnshift(["", "-{UUID}", "-blue", "-green"], process.env[ENV.APP_SUFFIX]);
const APP_SUFFIXES_READONLY = APP_SUFFIXES.concat(["-live"]);
const HOME = process.env.HOME || process.env.USERPROFILE;
const CF = Object.freeze({
EXEC: "cf",
HOME: process.env.CF_HOME || HOME,
});
const LOCATION = Object.freeze({
LOCAL: "LOCAL",
GLOBAL: "GLOBAL",
});
const FILENAME = Object.freeze({
CONFIG: ".mtxrc.json",
CACHE: ".mtxcache.json",
});
const CACHE_GAP = 43200000; // 12 hours in milliseconds
const UAA_TOKEN_CACHE_EXPIRY_GAP = 60000; // 1 minute
const logger = Logger.getInstance();
const _run = async (command, ...args) => {
return await spawnAsync(command, args, {
env: {
PATH: process.env.PATH,
CF_HOME: CF.HOME,
},
});
};
const _cfAuthToken = async () => {
try {
const [stdout, stderr] = await _run(CF.EXEC, "oauth-token");
assert(!stderr, "got stderr output from cf oauth-token\n%s", stderr);
return stdout.trim();
} catch (err) {
return fail(
"caught error during cf oauth-token\n%s",
[err.message, err.stdout, err.stderr].filter((s) => s && s.length).join("\n")
);
}
};
const _cfRequest = async (cfInfo, path) => {
try {
const response = await request({
url: cfInfo.config.Target,
path,
headers: {
Accept: "application/json",
Authorization: cfInfo.token,
},
logged: false,
});
return await response.json();
} catch (err) {
return fail("caught error during cf request %s\n%s", path, err.message);
}
};
const _cfRequestPaged = async (cfInfo, path) => {
let result = [];
while (true) {
const { pagination, resources } = await _cfRequest(cfInfo, path);
if (resources) {
result = result.concat(resources);
} else {
break;
}
if (pagination && pagination.next && pagination.next.href) {
const { path: nextPath } = urllib.parse(pagination.next.href);
path = nextPath;
} else {
break;
}
}
return result;
};
const _readCfConfig = () => {
const cfConfigPath = pathlib.join(CF.HOME, ".cf", "config.json");
const cfConfig = tryReadJsonSync(cfConfigPath);
assert(cfConfig, "could not open cf config in location", cfConfigPath);
const { OrganizationFields, SpaceFields, Target } = cfConfig || {};
if (
!cfConfig ||
!OrganizationFields ||
!OrganizationFields.GUID ||
!OrganizationFields.Name ||
!SpaceFields ||
!SpaceFields.GUID ||
!SpaceFields.Name ||
!Target
) {
return fail("no cf org/space targeted");
}
logger.info(`targeting cf api ${Target} / org "${OrganizationFields.Name}" / space "${SpaceFields.Name}"`);
return cfConfig;
};
const _resolveDir = (filename) => {
let subdirs = process.cwd().split(pathlib.sep);
while (true) {
const dir = subdirs.length === 0 ? HOME : subdirs.join(pathlib.sep);
const filepath = dir + pathlib.sep + filename;
if (tryAccessSync(filepath)) {
return {
dir,
filepath,
location: dir === HOME ? LOCATION.GLOBAL : LOCATION.LOCAL,
};
}
if (subdirs.length === 0) {
return null;
}
subdirs = subdirs.slice(0, -1);
}
};
const readRuntimeConfig = (filepath, { logged = false, checkConfig = true } = {}) => {
const rawRuntimeConfig = filepath ? tryReadJsonSync(filepath) : null;
if (checkConfig && !rawRuntimeConfig) {
return fail(`failed reading runtime configuration, run setup`);
}
if (logged && filepath) {
logger.info("using runtime config", filepath);
}
return rawRuntimeConfig
? Object.values(CONFIG_INFOS).reduce((result, value) => {
result[value.config] = rawRuntimeConfig[value.config];
return result;
}, Object.create(null))
: {};
};
const _readRawAppPersistedCache = (location, filepath, orgGuid, spaceGuid, appName) => {
const fullCache = tryReadJsonSync(filepath) || {};
const appKey = orgGuid + "##" + spaceGuid + "##" + appName;
if (!Object.prototype.hasOwnProperty.call(fullCache, appKey)) {
return null;
}
const appCache = fullCache[appKey];
const isOverdue = Date.now() - new Date(appCache.timestamp).getTime() > CACHE_GAP;
if (isOverdue) {
return null;
}
if (appCache.version !== version) {
return null;
}
logger.info(`using ${location.toLowerCase()} cache for "${appName}"`);
return appCache;
};
const _writeRawAppPersistedCache = (newRuntimeCache, filepath, orgGuid, spaceGuid, appName) => {
const fullCache = tryReadJsonSync(filepath) || {};
const appKey = orgGuid + "##" + spaceGuid + "##" + appName;
fullCache[appKey] = newRuntimeCache;
try {
writeJsonSync(filepath, fullCache);
} catch (err) {
fail("caught error while writing app cache:", err.message);
}
};
const _cfSsh = async (appName, { logged, localPort, remotePort, remoteHostname, appInstance, command } = {}) => {
const args = [CF.EXEC, "ssh", appName];
if (localPort !== undefined && localPort !== null && remotePort !== undefined && remotePort !== null) {
args.push(
"-L",
localPort + ":" + (remoteHostname || "0.0.0.0") + ":" + remotePort,
"--skip-remote-execution",
"--disable-pseudo-tty"
);
}
if (appInstance !== undefined && appInstance !== null) {
args.push("--app-instance-index", appInstance);
}
if (command !== undefined && command !== null) {
args.push("--command", command);
}
logged && logger.info("running", args.join(" "));
try {
const [stdout, stderr] = await _run(...args);
logged && stderr && logger.error(stderr);
logged && stdout && logger.info(stdout);
return [stdout, stderr];
} catch (err) {
return fail(
"caught error during cf ssh: %s",
[err.message, err.stdout, err.stderr].filter((s) => s && s.length).join("\n")
);
}
};
const _getCfApps = async (cfInfo) => _cfRequestPaged(cfInfo, `/v3/apps?space_guids=${cfInfo.config.SpaceFields.GUID}`);
const newContext = async ({ usePersistedCache = true, isReadonlyCommand = false } = {}) => {
const cfInfo = { config: _readCfConfig(), token: await _cfAuthToken() };
const { filepath: configPath, dir, location } = _resolveDir(FILENAME.CONFIG) || {};
const runtimeConfig = readRuntimeConfig(configPath);
const cachePath = pathlib.join(dir, FILENAME.CACHE);
const cfApps = await _getCfApps(cfInfo);
const cfUaaTokenCache = new ExpiringLazyCache({ expirationGap: UAA_TOKEN_CACHE_EXPIRY_GAP });
const settingTypeToAppNameCache = new LazyCache();
const appNameToCfAppCache = new LazyCache();
let rawAppMemoryCache = {};
const getRawAppInfo = async (cfApp) => {
const cfBuildpack = cfApp.lifecycle?.data?.buildpacks?.[0];
const cfEnv = await _cfRequest(cfInfo, `/v3/apps/${cfApp.guid}/env`);
const [cfProcess] = await _cfRequestPaged(cfInfo, `/v3/apps/${cfApp.guid}/processes`);
const cfEnvServices = cfEnv.system_env_json?.VCAP_SERVICES;
const cfEnvApp = cfEnv.application_env_json?.VCAP_APPLICATION;
const cfEnvVariables = cfEnv.environment_variables;
const cfRoutes = await _cfRequestPaged(cfInfo, `/v3/apps/${cfApp.guid}/routes`);
const cfRoute = cfRoutes?.[0];
const cfDomainGuid = cfRoute?.relationships?.domain?.data?.guid;
const cfRouteDomain = cfDomainGuid && (await _cfRequest(cfInfo, `/v3/domains/${cfDomainGuid}`));
return {
timestamp: new Date().toISOString(),
version,
cfApp,
cfProcess,
cfBuildpack,
cfEnvServices,
cfEnvApp,
cfEnvVariables,
cfRoute,
cfRouteDomain,
};
};
const getRawAppInfoCached = async (cfApp) => {
const { name: appName } = cfApp;
// check memory cache
if (!Object.prototype.hasOwnProperty.call(rawAppMemoryCache, appName)) {
// check persisted cache
let rawAppPersistedCache = usePersistedCache
? _readRawAppPersistedCache(
location,
cachePath,
cfInfo.config.OrganizationFields.GUID,
cfInfo.config.SpaceFields.GUID,
appName
)
: null;
if (!rawAppPersistedCache) {
// get fresh data
rawAppPersistedCache = await getRawAppInfo(cfApp);
// update persisted cache
_writeRawAppPersistedCache(
rawAppPersistedCache,
cachePath,
cfInfo.config.OrganizationFields.GUID,
cfInfo.config.SpaceFields.GUID,
appName
);
}
// update memory cache
rawAppMemoryCache[appName] = rawAppPersistedCache;
}
return rawAppMemoryCache[appName];
};
const processRawAppInfo = (appName, rawAppInfo, { requireServices, requireRoute } = {}) => {
const { cfApp, cfBuildpack, cfEnvServices, cfEnvApp, cfEnvVariables, cfRoute, cfRouteDomain, cfProcess } =
rawAppInfo;
let cfService = null;
if (Array.isArray(requireServices)) {
assert(cfEnvServices, "no vcap service information in environment, check cf user permissions");
const cfEnvServicesFlat = [].concat(...Object.values(cfEnvServices));
const matchingServices = requireServices
.map(({ label: aLabel, plan: aPlan }) =>
cfEnvServicesFlat.find(({ label: bLabel, plan: bPlan }) => aLabel === bLabel && aPlan === bPlan)
)
.filter((a) => a !== undefined);
cfService = matchingServices.length > 0 ? matchingServices[0] : null;
assert(
cfService,
`could not access required service-bindings for app "${appName}" services "${JSON.stringify(requireServices)}"`
);
}
const cfRouteUrl =
cfRoute &&
cfRouteDomain &&
urllib.format({
protocol: "https",
host: `${cfRoute.host === "*" ? cfInfo.config.OrganizationFields.Name : cfRoute.host}.${cfRouteDomain.name}`,
});
if (requireRoute) {
assert(cfRouteUrl, `could not obtain required route url for app "${appName}"`);
}
const cfSsh = async (options) => _cfSsh(appName, options);
const cfAppName = cfApp.name;
const cfAppGuid = cfApp.guid;
return {
cfAppName,
cfAppGuid,
cfBuildpack,
cfProcess,
cfEnvServices,
cfEnvApp,
cfEnvVariables,
cfService,
cfRouteUrl,
cfSsh,
};
};
const _getAppNameFromSettingType = (type) =>
settingTypeToAppNameCache.getSetCb(type, () => {
const setting = CONFIG_INFOS[type];
// determine configured appName
const configAppName = runtimeConfig[setting.config];
const envAppName = (setting.envVariable && process.env[setting.envVariable]) || null;
if (envAppName && configAppName !== envAppName) {
if (configAppName) {
logger.info(
'overriding configured %s "%s" with "%s" from environment variable %s',
setting.name,
configAppName,
envAppName,
setting.envVariable
);
} else {
logger.info('using %s "%s" from environment variable %s', setting.name, envAppName, setting.envVariable);
}
}
const appName = envAppName || configAppName;
assert(appName, setting.failMessage);
return appName;
});
const _getAppNameCandidates = (appName) => {
const appSuffixes = isReadonlyCommand ? APP_SUFFIXES_READONLY : APP_SUFFIXES;
return appSuffixes.map((suffix) => {
const label = appName + suffix;
const isTemplate = /{UUID}/g.test(label);
let regexp;
if (isTemplate) {
const [front, back] = label.split("{UUID}");
regexp = new RegExp(
escapeRegExp(front) +
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +
escapeRegExp(back)
);
}
return {
suffix,
label,
regexp,
};
});
};
const _getCfAppFromAppName = (appName) =>
appNameToCfAppCache.getSetCb(appName, () => {
// determine matching cfApp considering suffixes
// NOTE: the appNameCandidates order should take precedence over cfApps order.
const appNameCandidates = _getAppNameCandidates(appName);
let cfApp;
let cfAppSuffix;
for (const { suffix, label, regexp } of appNameCandidates) {
cfApp = regexp ? cfApps.find(({ name }) => regexp.test(name)) : cfApps.find(({ name }) => label === name);
if (cfApp) {
cfAppSuffix = suffix;
break;
}
}
assert(
cfApp,
`no cf app found for name "${appName}", tried candidates "${appNameCandidates.map(({ label }) => label)}"`
);
if (appName !== cfApp.name) {
logger.info('using app "%s" based on suffix "%s"', cfApp.name, cfAppSuffix);
}
return cfApp;
});
const getAppInfoCached = (type) => async () => {
const appName = _getAppNameFromSettingType(type);
const cfApp = _getCfAppFromAppName(appName);
const rawAppInfo = await getRawAppInfoCached(cfApp);
const setting = CONFIG_INFOS[type];
return processRawAppInfo(cfApp.name, rawAppInfo, setting);
};
const getAppNameInfoCached = async (appName, setting) => {
assert(appName, "used getAppNameInfoCached without appName parameter");
const cfApp = _getCfAppFromAppName(appName);
const rawAppInfo = await getRawAppInfoCached(cfApp);
return processRawAppInfo(cfApp.name, rawAppInfo, setting);
};
const getUaaInfo = makeOneTime(getAppInfoCached(CONFIG_TYPE.UAA_APP));
const getRegInfo = makeOneTime(getAppInfoCached(CONFIG_TYPE.REGISTRY_APP));
const getCdsInfo = makeOneTime(getAppInfoCached(CONFIG_TYPE.CDS_APP));
const getHdiInfo = makeOneTime(getAppInfoCached(CONFIG_TYPE.HDI_APP));
const getSrvInfo = makeOneTime(getAppInfoCached(CONFIG_TYPE.SERVER_APP));
const getCachedUaaTokenFromCredentials = async (credentials, options) =>
await cfUaaTokenCache.getSetCb(
credentials.clientid,
async () => await sharedUaaTokenFromCredentials(credentials, options),
{
expirationExtractor: ({ expires_in }) => Date.now() + expires_in * 1000,
valueExtractor: ({ access_token }) => access_token,
}
);
const getCachedUaaToken = async (options) => {
const {
cfService: { credentials },
} = await getUaaInfo();
return getCachedUaaTokenFromCredentials(credentials, options);
};
return {
runtimeConfig,
getUaaInfo,
getRegInfo,
getCdsInfo,
getHdiInfo,
getSrvInfo,
getCachedUaaTokenFromCredentials,
getCachedUaaToken,
getAppNameInfoCached,
};
};
module.exports = {
newContext,
readRuntimeConfig,
};