UNPKG

@cap-js-community/mtx-tool

Version:

Multitenancy and Extensibility Tool is a cli to reduce operational overhead for multitenant Cloud Foundry applications

682 lines (606 loc) 23.6 kB
"use strict"; const { tableList, isPortFree, formatTimestampsWithRelativeDays, compareFor, limiter, randomString, tryJsonParse, isObject, makeOneTime, parseIntWithFallback, resetOneTime, } = require("../shared/static"); const { assert } = require("../shared/error"); const { request } = require("../shared/request"); const { Logger } = require("../shared/logger"); const ENV = Object.freeze({ HDI_CONCURRENCY: "MTX_HDI_CONCURRENCY", }); const TUNNEL_LOCAL_PORT = 30015; const HIDDEN_PASSWORD_TEXT = "*** show with --reveal ***"; const SERVICE_MANAGER_REQUEST_CONCURRENCY_FALLBACK = 10; const SERVICE_MANAGER_IDEAL_BINDING_COUNT = 1; const SENSITIVE_CREDENTIAL_FIELDS = ["password", "hdi_password"]; const HDI_SHARED_SERVICE_OFFERING = "hana"; const HDI_SHARED_SERVICE_PLAN = "hdi-shared"; const logger = Logger.getInstance(); const hdiRequestConcurrency = parseIntWithFallback( process.env[ENV.HDI_CONCURRENCY], SERVICE_MANAGER_REQUEST_CONCURRENCY_FALLBACK ); const isValidTenantId = (input) => input && /^[0-9a-z-_/]+$/i.test(input); const compareForServiceManagerTenantId = compareFor((a) => a.labels.tenant_id[0].toUpperCase()); const compareForServiceManagerBindingUpdatedAtDesc = compareFor((a) => a.updated_at, true); const _formatOutput = (output) => JSON.stringify(Array.isArray(output) && output.length === 1 ? output[0] : output, null, 2); const _hidePasswordsInBindingOrInstance = (entry) => { for (let field of SENSITIVE_CREDENTIAL_FIELDS) { if (entry?.credentials?.[field]) { entry.credentials[field] = HIDDEN_PASSWORD_TEXT; } } }; const _createBindingServiceManager = async ( sm_url, token, service_instance_id, tenant_id, service_plan_id, { name = randomString(32), parameters } = {} ) => { await request({ method: "POST", url: sm_url, pathname: `/v1/service_bindings`, query: { async: false }, auth: { token }, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, service_instance_id, labels: { managing_client_lib: ["instance-manager-client-lib"], tenant_id: [tenant_id], service_plan_id: [service_plan_id], }, ...(parameters && { parameters }), }), }); }; const _createBindingServiceManagerFromBinding = async (sm_url, token, binding, options) => await _createBindingServiceManager( sm_url, token, binding.service_instance_id, binding.labels.tenant_id[0], binding.labels.service_plan_id[0], options ); const _createBindingServiceManagerFromInstance = async (sm_url, token, instance, options) => await _createBindingServiceManager( sm_url, token, instance.id, instance.labels.tenant_id[0], instance.service_plan_id, options ); const _deleteBindingServiceManager = async (sm_url, token, id) => { await request({ method: "DELETE", url: sm_url, pathname: `/v1/service_bindings/${id}`, query: { async: false }, auth: { token }, }); }; const _deleteInstanceServiceManager = async (sm_url, token, id) => { await request({ method: "DELETE", url: sm_url, pathname: `/v1/service_instances/${id}`, query: { async: false }, auth: { token }, }); }; const _getQuery = (filters) => Object.entries(filters) .reduce((acc, [key, value]) => { acc.push(`${key} eq '${value}'`); return acc; }, []) .join(" and "); const _getServicePlanId = async (sm_url, token, serviceOfferingName, servicePlanName) => { const responseOfferings = await request({ url: sm_url, pathname: "/v1/service_offerings", query: { fieldQuery: _getQuery({ name: serviceOfferingName }) }, auth: { token }, }); const responseOfferingsData = (await responseOfferings.json()) || {}; const serviceOfferingId = responseOfferingsData.items?.[0]?.id; assert(serviceOfferingId, `could not find service offering with name ${serviceOfferingName}`); const responsePlans = await request({ url: sm_url, pathname: "/v1/service_plans", query: { fieldQuery: _getQuery({ service_offering_id: serviceOfferingId, name: servicePlanName }) }, auth: { token }, }); const responsePlansData = (await responsePlans.json()) || {}; const servicePlanId = responsePlansData.items?.[0]?.id; assert(servicePlanId, `could not find service plan with name ${servicePlanName}`); return servicePlanId; }; const _getHdiSharedPlanId = makeOneTime( async (sm_url, token) => await _getServicePlanId(sm_url, token, HDI_SHARED_SERVICE_OFFERING, HDI_SHARED_SERVICE_PLAN) ); const _hdiInstancesServiceManager = async (context, { filterTenantId, doEnsureTenantLabel = true } = {}) => { const { cfService: { credentials }, } = await context.getHdiInfo(); const { sm_url } = credentials; const token = await context.getCachedUaaTokenFromCredentials(credentials); const servicePlanId = await _getHdiSharedPlanId(sm_url, token); const response = await request({ url: sm_url, pathname: "/v1/service_instances", query: { fieldQuery: _getQuery({ service_plan_id: servicePlanId }), ...(filterTenantId && { labelQuery: _getQuery({ tenant_id: filterTenantId }) }), }, auth: { token }, }); const responseData = (await response.json()) || {}; let instances = responseData.items || []; if (doEnsureTenantLabel) { instances = instances.filter((instance) => instance.labels.tenant_id !== undefined); } return instances; }; const _hdiBindingsServiceManager = async ( context, { filterTenantId, doReveal = false, doAssertFoundSome = false, doEnsureTenantLabel = true } = {} ) => { const { cfService: { credentials }, } = await context.getHdiInfo(); const { sm_url } = credentials; const token = await context.getCachedUaaTokenFromCredentials(credentials); const servicePlanId = await _getHdiSharedPlanId(sm_url, token); const getBindingsResponse = await request({ url: sm_url, pathname: "/v1/service_bindings", query: { labelQuery: _getQuery({ service_plan_id: servicePlanId, ...(filterTenantId && { tenant_id: filterTenantId }), }), }, auth: { token }, }); const responseData = (await getBindingsResponse.json()) || {}; let bindings = responseData.items || []; if (doEnsureTenantLabel) { bindings = bindings.filter((instance) => instance.labels.tenant_id !== undefined); } if (doAssertFoundSome) { if (filterTenantId) { assert( Array.isArray(bindings) && bindings.length >= 1, "could not find hdi service binding for tenant %s", filterTenantId ); } else { assert(Array.isArray(bindings) && bindings.length >= 1, "could not find any hdi service bindings"); } } if (!doReveal) { bindings.forEach(_hidePasswordsInBindingOrInstance); } return bindings; }; const _hdiRebindBindingServiceManager = async (sm_url, token, binding, options) => { await _createBindingServiceManagerFromBinding(sm_url, token, binding, options); await _deleteBindingServiceManager(sm_url, token, binding.id); }; const _hdiRebindTenantServiceManager = async (context, filterTenantId, parameters) => { const { cfService: { credentials }, } = await context.getHdiInfo(); const { sm_url } = credentials; const token = await context.getCachedUaaTokenFromCredentials(credentials); const bindings = await _hdiBindingsServiceManager(context, { filterTenantId, doAssertFoundSome: true }); for (const binding of bindings) { await _hdiRebindBindingServiceManager(sm_url, token, binding, { parameters }); } }; const _hdiRebindAllServiceManager = async (context, parameters) => { const { cfService: { credentials }, } = await context.getHdiInfo(); const { sm_url } = credentials; const token = await context.getCachedUaaTokenFromCredentials(credentials); const bindings = await _hdiBindingsServiceManager(context); bindings.sort(compareForServiceManagerTenantId); const tenantIds = bindings.map( ({ labels: { tenant_id: [tenant_id], }, }) => tenant_id ); logger.info("rebinding tenants %s", tenantIds.join(", ")); await limiter( hdiRequestConcurrency, bindings, async (binding) => await _hdiRebindBindingServiceManager(sm_url, token, binding, { parameters }) ); }; const _hdiRepairBindingsServiceManager = async (context, { instances, bindings, parameters } = {}) => { const { cfService: { credentials }, } = await context.getHdiInfo(); const { sm_url } = credentials; const token = await context.getCachedUaaTokenFromCredentials(credentials); instances = instances ?? (await _hdiInstancesServiceManager(context)); bindings = bindings ?? (await _hdiBindingsServiceManager(context)); const bindingsByInstance = _getBindingsByInstance(bindings); instances.sort(compareForServiceManagerTenantId); const changes = []; for (const instance of instances) { const tenantId = instance.labels.tenant_id[0]; const instanceBindings = (bindingsByInstance[instance.id] || []).filter((binding) => binding.ready); instanceBindings.sort(compareForServiceManagerBindingUpdatedAtDesc); if (instanceBindings.length < SERVICE_MANAGER_IDEAL_BINDING_COUNT) { const missingBindingCount = SERVICE_MANAGER_IDEAL_BINDING_COUNT - instanceBindings.length; for (let i = 0; i < missingBindingCount; i++) { changes.push(async () => { await _createBindingServiceManagerFromInstance(sm_url, token, instance, { parameters }); logger.info( "created %i missing binding%s for tenant %s", missingBindingCount, missingBindingCount === 1 ? "" : "s", tenantId ); }); } } else if (instanceBindings.length > SERVICE_MANAGER_IDEAL_BINDING_COUNT) { const ambivalentBindings = instanceBindings.slice(1); for (const { id } of ambivalentBindings) { changes.push(async () => { await _deleteBindingServiceManager(sm_url, token, id); logger.info( "deleted %i ambivalent binding%s for tenant %s", ambivalentBindings.length, ambivalentBindings.length === 1 ? "" : "s", tenantId ); }); } } } await limiter(hdiRequestConcurrency, changes, async (fn) => await fn()); changes.length === 0 && logger.info("found exactly one binding for %i instances, all is well", instances.length); }; const _nextFreeSidPort = async () => { const maxPort = TUNNEL_LOCAL_PORT + 9900; for (let port = TUNNEL_LOCAL_PORT; port <= maxPort; port += 100) { if (await isPortFree(port)) { return port; } } return null; }; const _hdiTunnelHanaCloudWarning = () => { logger.warning( "warning: detected port 443, which is used by HANA Cloud. SSH port forwarding, which is required for tunneling, will not work with HANA Cloud." ); }; const _hdiTunnel = async (context, filterTenantId, doReveal = false) => { const { cfSsh } = await context.getHdiInfo(); const bindings = await _hdiBindingsServiceManager(context, { filterTenantId, doReveal, doAssertFoundSome: true }); assert( bindings.every((binding) => binding.credentials), "found binding without credentials for tenant %s", filterTenantId ); const credentials = bindings.map(({ credentials }) => credentials); const { host, port } = credentials[0]; assert( credentials.slice(1).every((binding) => binding.host === host && binding.port === port), "found more than one host and port combination in binding credentials for tenant %s", filterTenantId ); const localPort = await _nextFreeSidPort(); if (localPort !== TUNNEL_LOCAL_PORT) { logger.warning("warning: using local port %i, because %i was not free", localPort, TUNNEL_LOCAL_PORT); } assert(localPort !== null, "could not find free sid port 3xx15"); for (let credentialIndex = 0; credentialIndex < credentials.length; credentialIndex++) { const { host, port, url, user, password, schema, hdi_user: hdiUser, hdi_password: hdiPassword, } = credentials[credentialIndex]; const localUrl = url .replace(host, "localhost") .replace(":" + port, ":" + localPort) .replace("validateCertificate=true&", "validateCertificate=false&"); const runtimeTable = [ ["localUrl", localUrl], ["remoteUrl", url], ["user", user], ["password", password], ]; const designtimeTable = [ ["localUrl", localUrl.replace(schema, schema + "#DI")], ["remoteUrl", url.replace(schema, schema + "#DI")], ["user", hdiUser], ["password", hdiPassword], ]; if (credentials.length > 1) { logger.info(); logger.info("binding #%i", credentialIndex + 1); } logger.info(); logger.info("runtime"); logger.info(tableList(runtimeTable, { sortCol: null, noHeader: true, withRowNumber: false })); logger.info(); logger.info("designtime"); logger.info(tableList(designtimeTable, { sortCol: null, noHeader: true, withRowNumber: false })); logger.info(); } if (port === "443") { _hdiTunnelHanaCloudWarning(); } return cfSsh({ localPort, remotePort: port, remoteHostname: host }); }; const _hdiDeleteServiceManager = async (context, { filterTenantId } = {}) => { const { cfService: { credentials }, } = await context.getHdiInfo(); const { sm_url } = credentials; const token = await context.getCachedUaaTokenFromCredentials(credentials); // NOTE: deleting both the bindings and service instance best mimics the behavior of instance manager const bindings = await _hdiBindingsServiceManager(context, { filterTenantId }); for (const { id } of bindings) { await _deleteBindingServiceManager(sm_url, token, id); } const instances = await _hdiInstancesServiceManager(context, { filterTenantId }); for (const { id } of instances) { await _deleteInstanceServiceManager(sm_url, token, id); } }; const _getBindingsByInstance = (bindings) => { return bindings.reduce((result, binding) => { const instance_id = binding.service_instance_id; if (result[instance_id]) { result[instance_id].push(binding); } else { result[instance_id] = [binding]; } return result; }, {}); }; const _hdiListServiceManager = async (context, { filterTenantId, doTimestamps, doJsonOutput } = {}) => { const [instances, bindings] = await Promise.all([ _hdiInstancesServiceManager(context, { filterTenantId }), _hdiBindingsServiceManager(context, { filterTenantId }), ]); if (doJsonOutput) { return { instances, bindings }; } const bindingsByInstance = _getBindingsByInstance(bindings); instances.sort(compareForServiceManagerTenantId); const doShowDbTenantColumn = bindings.some((binding) => binding.credentials?.tenantId); const headerRow = ["tenant_id", "host", "schema", "ready"]; doShowDbTenantColumn && headerRow.splice(1, 0, "db_tenant_id"); doTimestamps && headerRow.push("created_on", "updated_on"); const nowDate = new Date(); const instanceMap = (instance) => { const [binding] = bindingsByInstance[instance.id] || []; const row = [ instance.labels.tenant_id[0], binding ? binding.credentials?.host + ":" + binding.credentials?.port : "missing binding", binding ? binding.credentials?.schema : "", binding ? instance.ready && binding.ready : "", ]; doShowDbTenantColumn && row.splice(1, 0, binding ? (binding.credentials?.tenantId ?? "") : "missing binding"); doTimestamps && row.push(...formatTimestampsWithRelativeDays([instance.created_at, instance.updated_at], nowDate)); return row; }; const table = instances && instances.length ? [headerRow].concat(instances.map(instanceMap)) : null; return tableList(table, { withRowNumber: !filterTenantId }); }; const hdiList = async (context, [filterTenantId], [doTimestamps, doJsonOutput]) => await _hdiListServiceManager(context, { filterTenantId, doTimestamps, doJsonOutput }); const _hdiLongListServiceManager = async (context, { filterTenantId, doJsonOutput, doReveal } = {}) => { const [instances, bindings] = await Promise.all([ _hdiInstancesServiceManager(context, { filterTenantId, doEnsureTenantLabel: false }), _hdiBindingsServiceManager(context, { filterTenantId, doReveal, doEnsureTenantLabel: false }), ]); if (doJsonOutput) { return { instances, bindings }; } return ` === container instance${instances.length === 1 ? "" : "s"} === ${_formatOutput(instances)} === container binding${bindings.length === 1 ? "" : "s"} === ${_formatOutput(bindings)} `; }; const hdiLongList = async (context, [filterTenantId], [doJsonOutput, doReveal]) => await _hdiLongListServiceManager(context, { filterTenantId, doJsonOutput, doReveal }); const _hdiListRelationsServiceManager = async (context, { filterTenantId, doTimestamps, doJsonOutput }) => { const [instances, bindings] = await Promise.all([ _hdiInstancesServiceManager(context, { filterTenantId }), _hdiBindingsServiceManager(context, { filterTenantId }), ]); const bindingsByInstance = _getBindingsByInstance(bindings); instances.sort(compareForServiceManagerTenantId); const nowDate = new Date(); const headerRow = ["tenant_id", "instance_id", "", "binding_id", "ready"]; doTimestamps && headerRow.push("created_on", "updated_on"); const table = [headerRow]; if (doJsonOutput) { return { instances: instances.map((instance) => { return { ...instance, bindings: bindingsByInstance[instance.id] }; }), }; } for (const instance of instances) { const instanceBindings = bindingsByInstance[instance.id]; if (instanceBindings) { for (const [index, binding] of instanceBindings.entries()) { const row = []; if (index === 0) { row.push( instance.labels.tenant_id[0], instance.id, instanceBindings.length === 1 ? "---" : "-+-", binding.id, binding.ready ); } else { row.push("", "", index === instanceBindings.length - 1 ? " \\-" : " |-", binding.id, binding.ready); } doTimestamps && row.push(...formatTimestampsWithRelativeDays([binding.created_at, binding.updated_at], nowDate)); table.push(row); } } else { table.push([instance.labels.tenant_id[0], instance.id, "-x"]); } } return tableList(table, { sortCol: null, withRowNumber: !filterTenantId }); }; const hdiListRelations = async (context, [tenantId], [doTimestamps, doJsonOutput]) => await _hdiListRelationsServiceManager(context, { filterTenantId: tenantId, doTimestamps, doJsonOutput }); const hdiRebindTenant = async (context, [tenantId, rawParameters]) => { assert(isValidTenantId(tenantId), `argument "${tenantId}" is not a valid hdi tenant id`); const parameters = tryJsonParse(rawParameters); assert(!rawParameters || isObject(parameters), `argument "${rawParameters}" needs to be a valid JSON object`); return await _hdiRebindTenantServiceManager(context, tenantId, parameters); }; const hdiRebindAll = async (context, [rawParameters]) => { const parameters = tryJsonParse(rawParameters); assert(!rawParameters || isObject(parameters), `argument "${rawParameters}" needs to be a valid JSON object`); return await _hdiRebindAllServiceManager(context, parameters); }; const hdiRepairBindings = async (context, [rawParameters]) => { const parameters = tryJsonParse(rawParameters); assert(!rawParameters || isObject(parameters), `argument "${rawParameters}" needs to be a valid JSON object`); return await _hdiRepairBindingsServiceManager(context, { parameters }); }; const hdiTunnelTenant = async (context, [tenantId], [doReveal]) => { assert(isValidTenantId(tenantId), `argument "${tenantId}" is not a valid hdi tenant id`); return await _hdiTunnel(context, tenantId, doReveal); }; const hdiDeleteTenant = async (context, [tenantId]) => { assert(isValidTenantId(tenantId), `argument "${tenantId}" is not a valid hdi tenant id`); return await _hdiDeleteServiceManager(context, { filterTenantId: tenantId }); }; const hdiDeleteAll = async (context) => await _hdiDeleteServiceManager(context); const hdiEnableNative = async (context, [tenantId]) => { assert(!tenantId || isValidTenantId(tenantId), `argument "${tenantId}" is not a valid hdi tenant id`); const { cfService: { credentials }, } = await context.getHdiInfo(); const { sm_url } = credentials; const token = await context.getCachedUaaTokenFromCredentials(credentials); // get all instances and bindings const [instances, bindings] = await Promise.all([ _hdiInstancesServiceManager(context, { filterTenantId: tenantId }), _hdiBindingsServiceManager(context, { filterTenantId: tenantId }), ]); // filter instances and bindings const migrationInstances = []; const alreadyMigratedTenants = []; await limiter(hdiRequestConcurrency, instances, async (instance) => { const parametersResponse = await request({ url: sm_url, pathname: `/v1/service_instances/${instance.id}/parameters`, auth: { token }, logged: false, }); const parameters = await parametersResponse.json(); if (parameters.enableTenant) { alreadyMigratedTenants.push(instance.labels.tenant_id[0]); } else { migrationInstances.push(instance); } }); const migrationTenants = migrationInstances.map((instance) => instance.labels.tenant_id[0]); const migrationBindings = bindings.filter((binding) => migrationInstances.some((instance) => instance.id === binding.service_instance_id) ); if (alreadyMigratedTenants.length) { logger.info("skipping %i already enabled tenants", alreadyMigratedTenants.length); } if (migrationInstances.length && migrationTenants.length) { logger.info("enabling %i tenants %s", migrationTenants.length, migrationTenants.join(", ")); } else { return; } // delete all bindings related to migration instances logger.info("deleting %i bindings to protect enablement", migrationBindings.length); await limiter( hdiRequestConcurrency, migrationBindings, async (binding) => await _deleteBindingServiceManager(sm_url, token, binding.id) ); // send enable tenant patch request and poll until succeeded try { await limiter(hdiRequestConcurrency, migrationInstances, async (instance) => { const enableResponse = await request({ method: "PATCH", url: sm_url, pathname: `/v1/service_instances/${instance.id}`, query: { async: false }, auth: { token }, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parameters: { enableTenant: true, }, }), }); const checkData = await enableResponse.json(); assert( checkData.last_operation?.state === "succeeded", "enable tenant operation was not marked succeeded\n%j", checkData ); }); } finally { // repair bindings for migrated tenants if (migrationInstances.length) { await _hdiRepairBindingsServiceManager(context, { instances: migrationInstances, bindings: [] }); } } }; module.exports = { hdiList, hdiLongList, hdiListRelations, hdiRebindTenant, hdiRebindAll, hdiRepairBindings, hdiTunnelTenant, hdiDeleteTenant, hdiDeleteAll, hdiEnableNative, _: { _reset() { resetOneTime(_getHdiSharedPlanId); }, }, };