@cap-js-community/mtx-tool
Version:
Multitenancy and Extensibility Tool is a cli to reduce operational overhead for multitenant Cloud Foundry applications
540 lines (495 loc) • 19 kB
JavaScript
/**
* APIs of the saas-registry
* - https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/ed08c7dcb35d4082936c045e7d7b3ecd.html
* - https://saas-manager.cfapps.sap.hana.ondemand.com/api (Application Operations)
* - https://saas-manager.cfapps.sap.hana.ondemand.com/api?scope=saas-registry-service (Service Operations) [not supported anymore]
*
* APIs of the subscription-manager
* - https://int.api.hana.ondemand.com/api/APISubscriptionManagerService/resource/IAS_Subscription_Operations_for_Providers_or_Systems
*/
"use strict";
const {
isUUID,
isDashedWord,
sleep,
tableList,
dateDiffInDays,
dateDiffInSeconds,
formatTimestampsWithRelativeDays,
resolveTenantArg,
parseIntWithFallback,
compareFor,
} = require("../shared/static");
const { assert, fail } = require("../shared/error");
const { request } = require("../shared/request");
const { Logger } = require("../shared/logger");
const { limiter } = require("../shared/funnel");
const ENV = Object.freeze({
REG_CONCURRENCY: "MTX_REG_CONCURRENCY",
REG_FREQUENCY: "MTX_REG_FREQUENCY",
});
const HTTP_ACCEPTED = 202;
const HTTP_NO_CONTENT = 204;
const REGISTRY_PAGE_SIZE = 200;
const REGISTRY_JOB_POLL_FREQUENCY_FALLBACK = 15000;
const REGISTRY_REQUEST_CONCURRENCY_FALLBACK = 6;
const SUBSCRIPTION_CALL_IS_SUCCESS = Symbol("IS_SUCCESS");
const JOB_STATE = Object.freeze({
STARTED: "STARTED",
SUCCEEDED: "SUCCEEDED",
FAILED: "FAILED",
});
const SUBSCRIPTION_STATE = Object.freeze({
IN_PROCESS: "IN_PROCESS",
SUBSCRIBED: "SUBSCRIBED",
SUBSCRIBE_FAILED: "SUBSCRIBE_FAILED",
UPDATE_FAILED: "UPDATE_FAILED",
});
const SUBSCRIPTION_SOURCE = Object.freeze({
SUBSCRIPTION_MANAGER: "SUBSCRIPTION_MANAGER",
SAAS_REGISTRY: "SAAS_REGISTRY",
});
const UPDATABLE_STATES = [SUBSCRIPTION_STATE.SUBSCRIBED, SUBSCRIPTION_STATE.UPDATE_FAILED];
const logger = Logger.getInstance();
const compareForTenantId = compareFor((subscription) => subscription.tenantId.toUpperCase());
const regRequestConcurrency = parseIntWithFallback(
process.env[ENV.REG_CONCURRENCY],
REGISTRY_REQUEST_CONCURRENCY_FALLBACK
);
const regPollFrequency = parseIntWithFallback(process.env[ENV.REG_FREQUENCY], REGISTRY_JOB_POLL_FREQUENCY_FALLBACK);
const _callSms = async (context, reqOptions) => {
const credentials = (await context.getSmsInfo()).cfService.credentials;
const token = await context.getCachedUaaTokenFromCredentials(credentials);
return await request({
...reqOptions,
url: credentials.subscription_manager_url,
auth: { token },
});
};
const _callReg = async (context, reqOptions) => {
const credentials = (await context.getRegInfo()).cfService.credentials;
const token = await context.getCachedUaaTokenFromCredentials(credentials);
return await request({
...reqOptions,
url: credentials.saas_registry_url,
auth: { token },
});
};
const _call = async (context, source, reqOptions) => {
switch (source) {
case SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER: {
return await _callSms(context, reqOptions);
}
case SUBSCRIPTION_SOURCE.SAAS_REGISTRY: {
return await _callReg(context, reqOptions);
}
}
};
const _getSubscriptionsPage = async (context, source, { filterTenantId, size, page }) => {
switch (source) {
case SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER: {
const credentials = (await context.getSmsInfo()).cfService.credentials;
return await _callSms(context, {
pathname: "/subscription-manager/v1/subscriptions",
query: {
appName: credentials.app_name,
...(filterTenantId && { app_tid: filterTenantId }),
size,
page,
},
headers: { Accept: "application/json" },
});
}
case SUBSCRIPTION_SOURCE.SAAS_REGISTRY: {
const credentials = (await context.getRegInfo()).cfService.credentials;
return await _callReg(context, {
pathname: "/saas-manager/v1/application/subscriptions",
query: {
appName: credentials.appName,
...(filterTenantId && { tenantId: filterTenantId }),
size,
page,
},
headers: { Accept: "application/json" },
});
}
}
};
const _getSubscriptions = async (context, source, { filterTenantId }) => {
let subscriptions = [];
let page = 1;
while (true) {
const response = await _getSubscriptionsPage(context, source, {
filterTenantId,
size: REGISTRY_PAGE_SIZE,
page: page++,
});
const { subscriptions: pageSubscriptions, morePages } = await response.json();
subscriptions = subscriptions.concat(pageSubscriptions);
if (!morePages) {
return subscriptions;
}
}
};
const _normalizedSubscriptionFromSms = (subscription) => ({
source: SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER,
id: subscription.subscriptionGUID,
tenantId: subscription.subscriber.app_tid,
globalAccountId: subscription.subscriber.globalAccountId,
subdomain: subscription.subscriber.subaccountSubdomain,
appName: subscription.provider.appName,
plan: subscription.subscriptionPlanName,
state: subscription.subscriptionState,
url: subscription.subscriptionUrl,
createdOn: subscription.createdDate,
updatedOn: subscription.modifiedDate,
});
const _normalizedSubscriptionFromReg = (subscription) => ({
source: SUBSCRIPTION_SOURCE.SAAS_REGISTRY,
id: subscription.subscriptionGUID,
tenantId: subscription.consumerTenantId,
globalAccountId: subscription.globalAccountId,
subdomain: subscription.subdomain,
appName: subscription.appName,
plan: subscription.code,
state: subscription.state,
url: subscription.url,
createdOn: subscription.createdOn,
updatedOn: subscription.changedOn,
});
const _filterNormalizedSubscription = (
normalizedSubscription,
{ filterSubdomain, onlyFailed, onlyUpdatable, onlyStale }
) =>
(!filterSubdomain || normalizedSubscription.subdomain === filterSubdomain) &&
(!onlyFailed || normalizedSubscription.state === SUBSCRIPTION_STATE.UPDATE_FAILED) &&
(!onlyUpdatable || UPDATABLE_STATES.includes(normalizedSubscription.state)) &&
(!onlyStale || dateDiffInDays(new Date(normalizedSubscription.updatedOn), new Date()) > 0);
const _getSubscriptionInfos = async (context, { tenant, onlyFailed, onlyStale, onlyUpdatable } = {}) => {
assert(context.hasSmsInfo || context.hasRegInfo, "found no subscription-manager or saas-registry configuration");
const { subdomain: filterSubdomain, tenantId: filterTenantId } = resolveTenantArg(tenant);
filterSubdomain && assert(isDashedWord(filterSubdomain), `argument "${filterSubdomain}" is not a valid subdomain`);
const [smsSubscriptionsUnfiltered, regSubscriptionsUnfiltered] = await Promise.all([
context.hasSmsInfo ? _getSubscriptions(context, SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER, { filterTenantId }) : [],
context.hasRegInfo ? _getSubscriptions(context, SUBSCRIPTION_SOURCE.SAAS_REGISTRY, { filterTenantId }) : [],
]);
const smsSubscriptions = [];
const regSubscriptions = [];
const normalizedSubscriptions = [];
for (const subscription of smsSubscriptionsUnfiltered) {
const normalizedSubscription = _normalizedSubscriptionFromSms(subscription);
if (
_filterNormalizedSubscription(normalizedSubscription, { filterSubdomain, onlyFailed, onlyStale, onlyUpdatable })
) {
smsSubscriptions.push(subscription);
normalizedSubscriptions.push(normalizedSubscription);
}
}
for (const subscription of regSubscriptionsUnfiltered) {
const normalizedSubscription = _normalizedSubscriptionFromReg(subscription);
if (
_filterNormalizedSubscription(normalizedSubscription, { filterSubdomain, onlyFailed, onlyStale, onlyUpdatable })
) {
regSubscriptions.push(subscription);
normalizedSubscriptions.push(normalizedSubscription);
}
}
normalizedSubscriptions.sort(compareForTenantId);
return { smsSubscriptions, regSubscriptions, normalizedSubscriptions };
};
const registryListSubscriptions = async (
context,
[tenant],
[doTimestamps, doJsonOutput, doOnlyStale, doOnlyFailed]
) => {
const subscriptionInfos = await _getSubscriptionInfos(context, {
tenant,
onlyStale: doOnlyStale,
onlyFailed: doOnlyFailed,
});
const { smsSubscriptions, regSubscriptions, normalizedSubscriptions } = subscriptionInfos;
if (doJsonOutput) {
return { smsSubscriptions, regSubscriptions };
}
const headerRow = [
"consumerTenantId",
"subscriptionId",
"globalAccountId",
"subdomain",
"appName",
"plan",
"state",
"url",
];
doTimestamps && headerRow.push("created_on", "updated_on");
const nowDate = new Date();
const subscriptionMap = (normalizedSubscription) => {
const row = [
normalizedSubscription.tenantId,
normalizedSubscription.id,
normalizedSubscription.globalAccountId,
normalizedSubscription.subdomain,
normalizedSubscription.appName,
normalizedSubscription.plan ?? "",
normalizedSubscription.state,
normalizedSubscription.url,
];
doTimestamps &&
row.push(
...formatTimestampsWithRelativeDays(
[normalizedSubscription.createdOn, normalizedSubscription.updatedOn],
nowDate
)
);
return row;
};
const table =
normalizedSubscriptions && normalizedSubscriptions.length
? [headerRow].concat(normalizedSubscriptions.map(subscriptionMap))
: null;
return tableList(table, { withRowNumber: !tenant });
};
const registryLongListSubscriptions = async (context, [tenant], [, doOnlyStale, doOnlyFailed]) => {
const { smsSubscriptions, regSubscriptions } = await _getSubscriptionInfos(context, {
tenant,
onlyStale: doOnlyStale,
onlyFailed: doOnlyFailed,
});
return { smsSubscriptions, regSubscriptions };
};
const registryServiceConfig = async (context) => {
return {
...(context.hasSmsInfo && {
smsServiceConfig: JSON.parse((await context.getSmsInfo()).cfService.credentials.app_urls),
}),
...(context.hasRegInfo && {
regServiceConfig: JSON.parse((await context.getRegInfo()).cfService.credentials.appUrls),
}),
};
};
const _callAndPollAndMarkInner = async (context, source, reqOptions) => {
try {
const initialResponse = await _call(context, source, reqOptions);
assert(
initialResponse.status === HTTP_ACCEPTED,
"got unexpected response code for polling from %s",
reqOptions.pathname
);
const [location] = initialResponse.headers.raw().location;
assert(location, "missing location header for polling from %s", reqOptions.pathname);
logger.info("polling subscription %s with interval %isec", location, regPollFrequency / 1000);
while (true) {
await sleep(regPollFrequency);
const pollResponse = await _call(context, source, { pathname: location });
switch (source) {
case SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER: {
if (pollResponse.status === HTTP_NO_CONTENT && reqOptions.method === "DELETE") {
return {
info: "delete succeeded",
[SUBSCRIPTION_CALL_IS_SUCCESS]: true,
};
}
const pollResponseBody = await pollResponse.json();
const { subscriptionId, subscriptionState, subscriptionStateDetails } = pollResponseBody;
assert(subscriptionState, "got subscription poll response without state\n%j", pollResponseBody);
if (subscriptionState !== SUBSCRIPTION_STATE.IN_PROCESS) {
return {
subscriptionId,
subscriptionState,
...(subscriptionStateDetails && { error: subscriptionStateDetails }),
[SUBSCRIPTION_CALL_IS_SUCCESS]: subscriptionState === SUBSCRIPTION_STATE.SUBSCRIBED,
};
}
break;
}
case SUBSCRIPTION_SOURCE.SAAS_REGISTRY: {
const pollResponseBody = await pollResponse.json();
const { id: jobId, state: jobState, error: err } = pollResponseBody;
assert(jobState, "got subscription poll response without state\n%j", pollResponseBody);
if (jobState !== JOB_STATE.STARTED) {
return {
jobId,
jobState,
...(err && { error: err.message }),
[SUBSCRIPTION_CALL_IS_SUCCESS]: jobState === JOB_STATE.SUCCEEDED,
};
}
break;
}
}
}
} catch (err) {
return {
error: err.message,
[SUBSCRIPTION_CALL_IS_SUCCESS]: false,
};
}
};
const _callAndPollAndMark = async (context, source, tenantId, reqOptions) => {
const startTime = new Date();
const result = await _callAndPollAndMarkInner(context, source, reqOptions);
return {
tenantId,
duration: `${dateDiffInSeconds(startTime, new Date()).toFixed(0)} sec`,
...result,
};
};
const _callAndPollAndAssert = async (context, source, tenantId, reqOptions) => {
const result = await _callAndPollAndMark(context, source, tenantId, reqOptions);
if (!result[SUBSCRIPTION_CALL_IS_SUCCESS]) {
logger.error(JSON.stringify(result, null, 2));
return fail("call failed for tenant %s", tenantId);
}
return result;
};
const _patchUpdateDependenciesPathname = (subscription) => {
switch (subscription.source) {
case SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER: {
return `/subscription-manager/v1/subscriptions/${subscription.tenantId}`;
}
case SUBSCRIPTION_SOURCE.SAAS_REGISTRY: {
return `/saas-manager/v1/application/tenants/${subscription.tenantId}/subscriptions`;
}
}
};
const _callAndMarkInner = async (context, source, reqOptions) => {
try {
await _call(context, source, reqOptions);
const operation = reqOptions.method.toLowerCase().replace("patch", "update");
return {
info: `${operation} succeeded`,
[SUBSCRIPTION_CALL_IS_SUCCESS]: true,
};
} catch (err) {
return {
error: err.message,
[SUBSCRIPTION_CALL_IS_SUCCESS]: false,
};
}
};
const _callAndMark = async (context, source, tenantId, reqOptions) => {
const startTime = new Date();
const result = await _callAndMarkInner(context, source, reqOptions);
return {
tenantId,
duration: `${dateDiffInSeconds(startTime, new Date()).toFixed(0)} sec`,
...result,
};
};
const _patchUpdateDependencies = async (context, { filterOptions, query, isPoll = true }) => {
const { normalizedSubscriptions: subscriptions } = await _getSubscriptionInfos(context, filterOptions);
const _callExecutor = isPoll ? _callAndPollAndMark : _callAndMark;
const results = await limiter(
regRequestConcurrency,
subscriptions,
async (subscription) =>
await _callExecutor(context, subscription.source, subscription.tenantId, {
method: "PATCH",
pathname: _patchUpdateDependenciesPathname(subscription),
query,
})
);
const failedResults = results.filter((result) => !result[SUBSCRIPTION_CALL_IS_SUCCESS]);
if (failedResults.length) {
logger.error(JSON.stringify(results, null, 2));
return fail("call failed for tenants %s", failedResults.map((result) => result.tenantId).join(", "));
}
return results;
};
const registryUpdateDependencies = async (context, [tenantId], [doSkipUnchanged]) =>
await _patchUpdateDependencies(context, {
filterOptions: { tenant: tenantId },
query: {
...(doSkipUnchanged && { skipUnchangedDependencies: doSkipUnchanged }),
},
});
const registryUpdateAllDependencies = async (context, _, [doSkipUnchanged, doOnlyStale, doOnlyFailed]) =>
await _patchUpdateDependencies(context, {
filterOptions: {
onlyStale: doOnlyStale,
onlyFailed: doOnlyFailed,
onlyUpdatable: true,
},
query: {
...(doSkipUnchanged && { skipUnchangedDependencies: doSkipUnchanged }),
},
});
const registryUpdateApplicationURL = async (context, [tenantId], [doOnlyStale, doOnlyFailed]) =>
await _patchUpdateDependencies(context, {
filterOptions: tenantId
? { tenant: tenantId }
: {
onlyStale: doOnlyStale,
onlyFailed: doOnlyFailed,
onlyUpdatable: true,
},
query: { updateApplicationURL: true, skipUpdatingDependencies: true },
isPoll: false,
});
const _resolveUniqueSubscription = async (context, tenantId) => {
const { normalizedSubscriptions: subscriptions } = await _getSubscriptionInfos(context, { tenant: tenantId });
assert(
Array.isArray(subscriptions) && subscriptions.length === 1,
"could not find unique subscription for tenantId %s",
tenantId
);
return subscriptions[0];
};
const registryMigrate = async (context, [tenantId]) => {
assert(
context.hasRegInfo && context.hasSmsInfo,
"registry migrate needs both subscription-manager and saas-registry configuration"
);
assert(isUUID(tenantId), "TENANT_ID is not a uuid", tenantId);
const subscription = await _resolveUniqueSubscription(context, tenantId);
assert(
subscription.source === SUBSCRIPTION_SOURCE.SAAS_REGISTRY,
"registry migrate is only supported for tenants in saas registry"
);
return await _callAndPollAndAssert(context, SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER, tenantId, {
method: "PATCH",
pathname: `/subscription-manager/v1/subscriptions/${tenantId}/moveFromSaasProvisioning`,
});
};
const _deleteOffboardPathname = (subscription) => {
switch (subscription.source) {
case SUBSCRIPTION_SOURCE.SUBSCRIPTION_MANAGER: {
return `/subscription-manager/v1/subscriptions/${subscription.id}`;
}
case SUBSCRIPTION_SOURCE.SAAS_REGISTRY: {
return `/saas-manager/v1/application/tenants/${subscription.tenantId}/subscriptions`;
}
}
};
const registryOffboardSubscription = async (context, [tenantId]) => {
assert(isUUID(tenantId), "TENANT_ID is not a uuid", tenantId);
const subscription = await _resolveUniqueSubscription(context, tenantId);
return await _callAndPollAndAssert(context, subscription.source, subscription.tenantId, {
method: "DELETE",
pathname: _deleteOffboardPathname(subscription),
});
};
const registryOffboardSubscriptionSkip = async (context, [tenantId, skipApps]) => {
assert(isUUID(tenantId), "TENANT_ID is not a uuid", tenantId);
const subscription = await _resolveUniqueSubscription(context, tenantId);
assert(
subscription.source === SUBSCRIPTION_SOURCE.SAAS_REGISTRY,
"registry offboard with skipping apps is only supported for saas registry"
);
return await _callAndPollAndAssert(context, SUBSCRIPTION_SOURCE.SAAS_REGISTRY, subscription.tenantId, {
method: "DELETE",
pathname: _deleteOffboardPathname(subscription),
query: { noCallbacksAppNames: skipApps },
});
};
module.exports = {
registryListSubscriptions,
registryLongListSubscriptions,
registryServiceConfig,
registryUpdateDependencies,
registryUpdateAllDependencies,
registryUpdateApplicationURL,
registryMigrate,
registryOffboardSubscription,
registryOffboardSubscriptionSkip,
};