UNPKG

@sap/cds-mtxs

Version:

SAP Cloud Application Programming Model - Multitenancy library

298 lines (270 loc) 13.3 kB
const cds = require('@sap/cds') const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx|sm') const { uuid } = cds.utils const AbstractContainerManagerClient = require('./ctnr-mgr-base') const { cacheBindings = true } = cds.env.requires.multitenancy ?? {} /* API */ class ServiceManagerClient extends AbstractContainerManagerClient { constructor({ sm_url, url, clientid, clientsecret, certurl, certificate, key }) { super({ url, clientid, clientsecret }) this.sm_url = sm_url this.url = url this.clientid = clientid this.clientsecret = clientsecret this.certurl = certurl this.certificate = certificate this.key = key // In-memory storage -> later also distribute w/ Redis this.instanceLocations = new Map() this.bindingLocations = new Map() this._bindings4.cached = {} this.delete = this.remove } static defaultInstance() { ServiceManagerClient._defaultInstance ??= ServiceManagerClient.newInstance(cds.env.requires.db.credentials) return ServiceManagerClient._defaultInstance } // TODO introduce instance cache static newInstance(credentials) { if (!credentials?.sm_url) cds.error('Service Manager URL is not configured in credentials') return new ServiceManagerClient(credentials) } create = async (tenant, parameters) => { LOG.info('creating HDI container for', { tenant }, ...(parameters ? ['with', { ...parameters }] : [])) const name = await this._instanceName4(tenant), service_plan_id = await this._planId() const { binding_parameters, provisioning_parameters, customLabels = {} } = parameters ?? {} let service_instance_id if (this.instanceLocations.has(tenant)) { const storedLocation = this.instanceLocations.get(tenant) LOG.info('polling ongoing instance creation for', { tenant }) const { data: polledInstance } = await this._poll(storedLocation) service_instance_id = polledInstance.resource_id this.instanceLocations.delete(tenant) } else { try { const _instance = await this.fetchApi('service_instances?async=true', { method: 'POST', data: { name, service_plan_id, parameters: provisioning_parameters, labels: { tenant_id: [tenant], ...customLabels }, } }) this.instanceLocations.set(tenant, _instance.headers.location) service_instance_id = (await this._poll(_instance.headers.location)).data?.resource_id this.instanceLocations.delete(tenant) } catch (e) { this.instanceLocations.delete(tenant) const status = e.status ?? 500 if (status === 409 || e.error === 'Conflict') { const instance = await this._instance4(tenant) if (!instance.ready || !instance.usable) { const { type, state, errors, resource_type } = instance?.last_operation ?? {} LOG.info('detected unusable instance for tenant', tenant, 'in state', state, 'for operation type', type) if (state === 'failed' && (type === 'create' || type === 'delete')) { LOG.info('removing and recreating faulty instance for tenant', tenant, DEBUG ? `with error: ${e.error}: ${e.description}. Last operation: ${errors?.error} ${errors?.description}` : '' ) await this.remove(tenant) return this.create(tenant, parameters) } else if (type === 'create' && state === 'in progress') { const location = resource_type + '/' + instance.id + '/operations/' + instance.last_operation.id LOG.info('polling ongoing instance creation for tenant', tenant, 'at location', location) this.instanceLocations.set(tenant, location) await this._poll(location) this.instanceLocations.delete(tenant) } else { e.message ??= '' e.message += `${e.error}: ${e.description}. Last operation: ${errors?.error} ${errors?.description}` throw e } } service_instance_id = instance.id } else { cds.error(ServiceManagerClient._errorMessage(e, 'creating', tenant), { status }) } } } if (this.bindingLocations.has(tenant)) { const storedLocation = this.bindingLocations.get(tenant) LOG.info('ongoing binding creation for tenant', tenant, 'polling existing request') try { await this._poll(storedLocation) } finally { this.bindingLocations.delete(tenant) } } else { const _binding = await this.fetchApi('service_bindings?async=true', { method: 'POST', data: { name: tenant + `-${uuid()}`, service_instance_id, parameters: binding_parameters, labels: { tenant_id: [tenant], service_plan_id: [service_plan_id], managing_client_lib: ['instance-manager-client-lib'], ...customLabels } } }) this.bindingLocations.set(tenant, _binding.headers.location) await this._poll(_binding.headers.location) this.bindingLocations.delete(tenant) } const binding = { ...await this.get(tenant), tags: ['hana'] } return cacheBindings ? this._bindings4.cached[tenant] = binding : binding } acquire = async (tenant, parameters) => { try { return await this.get(tenant, { disableCache: true }) } catch (e) { if (e.status === 404) return this.create(tenant, parameters) throw e } } get = async (tenant, options = {}) => { const { invalidCredentials, retryUntil } = options try { const previousFailures = this.failures.has(tenant) ? this.failures.get(tenant).count : 0 const [binding] = await this._bindings4([tenant], { ...options, tenant, failures: previousFailures, retryUntil }) if (!binding?.credentials) cds.error(`Tenant '${tenant}' does not exist`, { status: 404 }) const { credentials } = binding credentials.tenant ??= tenant if (invalidCredentials && JSON.stringify(credentials) === JSON.stringify(invalidCredentials)) { LOG.info('previously rejected credentials provided by Service Manager → re-fetching') if (!this.failures.has(tenant)) this.failures.set(tenant, { count: 1 }) else this.failures.get(tenant).count++ return this.get(tenant, options) } this.failures.delete(tenant) return { name: await this._instanceName4(tenant), tenant_id: tenant, credentials, tags: ['hana'] } } catch (e) { if (e.status) cds.error(ServiceManagerClient._errorMessage(e, 'getting', tenant), { status: e.status ?? 500, cause: e.stack }) else throw e } } getAll = async (tenants = '*', options) => { return this._bindings4(tenants, options) } deploy = async (container, tenant, out, options, deployEnv) => { return require('./hdi').deploy(container, tenant, out, options, deployEnv) } remove = async (tenant) => { const instance = await this._instance4(tenant, true) const bindings = []; let token if (instance) { const fieldQuery = `service_instance_id eq '${instance.id}'` let token do { const params = token ? { token, fieldQuery } : { fieldQuery } const { data } = await this.fetchApi('service_bindings', { params }) const { items, token: nextPageToken } = data bindings.push(...items) token = nextPageToken } while (token) } do { const labelQuery = `tenant_id eq '${tenant}'` const params = token ? { token, labelQuery } : { labelQuery } const { data } = await this.fetchApi('service_bindings', { params }) const { items, token: nextPageToken } = data bindings.push(...items) token = nextPageToken } while (token) const deduped = [...new Map(bindings.map(b => [b.id, b])).values()] const _deleteBindings = deduped.map(async ({ id }) => this._poll((await this.fetchApi(`service_bindings/${id}?async=true`, { method: 'DELETE' })).headers.location) ) if (cacheBindings) delete this._bindings4.cached[tenant] this.failures.delete(tenant) const failedDeletions = (await Promise.allSettled(_deleteBindings)).filter(d => d.status === 'rejected') if (failedDeletions.length > 0) throw new AggregateError(failedDeletions.map(d => d.reason)) if (instance) { const _deleteInstance = await this.fetchApi(`service_instances/${instance.id}?async=true`, { method: 'DELETE' }) if (_deleteInstance.headers.location) await this._poll(_deleteInstance.headers.location) } } // REVISIT: Move to provisioning services _encryptionParams(data) { return (data?.globalAccountGUID ?? data?.subscriber?.globalAccountId) ? { subscriptionContext: { // crmId: '', globalAccountID: data.globalAccountGUID ?? data.subscriber.globalAccountId, subAccountID: data.subscribedSubaccountId ?? data.subscriber.subaccountId, applicationName: data.subscriptionAppName ?? data.rootApplication?.appName } } : {} } // merges configured default parameters with parameters derived from metadata and tenant-specific parameters _createParams = (tenant, params = {}, metadata) => { const hdiParams = this._hdiParams(tenant, params, metadata) const final = {} if (Object.keys(hdiParams?.create ?? {}).length > 0) final.provisioning_parameters = hdiParams.create if (Object.keys(hdiParams?.bind ?? {}).length > 0) final.binding_parameters = hdiParams.bind if (hdiParams?.create?.custom_label) { const customLabel = hdiParams.create.custom_label const [k, v] = customLabel.split('=') if (k === 'tenant_id') cds.error('"tenant_id" is not allowed as a custom label key') final.customLabels = { [k]: [v] } } return Object.keys(final).length > 0 ? final : null } /* Private helpers */ async _instance4(tenant, useLabel) { const fieldQuery = `name eq '${await this._instanceName4(tenant)}'` const labelQuery = `tenant_id eq '${tenant}'` const instances = await this.fetchApi('service_instances?async=true&attach_last_operations=true', { params: useLabel ? { labelQuery } : { fieldQuery } }) return instances.data.items[0] } async _instanceName4(tenant) { if (cds.requires.multitenancy?.humanReadableInstanceName) return tenant // Compatible with @sap/instance-manager-created instances return require('crypto').createHash('sha256').update(`${await this._planId()}_${tenant}`).digest('base64') } async _bindings4(tenants, { disableCache = false, failures, retryUntil } = {}) { if (failures) disableCache = true const useCache = cacheBindings && !disableCache && tenants !== '*' const uncached = useCache ? tenants.filter(t => !(t in this._bindings4.cached)) : tenants DEBUG?.('retrieving', { tenants }, { uncached }) if (uncached.length === 0) return tenants.map(t => this._bindings4.cached[t]) // split uncached into chunks of 1000 entries to avoid BAD_REQUEST errors const chunkSize = 1000 const tenantChunks = [] for (let i = 0; i < uncached.length; i += chunkSize) { tenantChunks.push(uncached.slice(i, i + chunkSize)) } const fetched = [] let chunkIndex = 1 for (const tenantChunk of tenantChunks) { DEBUG?.('retrieving', `${chunkIndex++}.`, tenantChunk.length, 'bindings') const _tenantFilter = () => ` and tenant_id in (${tenantChunk.map(t => `'${t}'`).join(', ')})` const tenantFilter = tenants === '*' ? '' : _tenantFilter() const labelQuery = `service_plan_id eq '${await this._planId()}'` + tenantFilter const fieldQuery = `ready eq 'true'` let token do { const params = token ? { token, labelQuery, fieldQuery } : { labelQuery, fieldQuery } const { data } = await this.fetchApi('service_bindings', { params }, { failures, retryUntil }) const { items, token: nextPageToken } = data fetched.push(...items) token = nextPageToken } while (token) } const cacheMisses = Object.fromEntries(fetched.filter(b => b.labels?.tenant_id).map(b => [b.labels.tenant_id[0], b])) Object.assign(this._bindings4.cached, cacheMisses) if (useCache) { return tenants.map(t => this._bindings4.cached[t]) } return fetched } async _planId() { if (this._planId.cached) return this._planId.cached const fieldQuery = `catalog_name eq 'hdi-shared' and service_offering_id eq '${await this._offeringId()}'` const { data } = await this.fetchApi('service_plans', { params: { fieldQuery } }) const [planId] = data.items if (!planId) cds.error(`Could not find service plan with ${fieldQuery}. Make sure it is entitled in your BTP subaccount.`) return this._planId.cached = data.items[0].id } async _offeringId() { if (this._offeringId.cached) return this._offeringId.cached const fieldQuery = `catalog_name eq 'hana'` const { data } = await this.fetchApi('service_offerings', { params: { fieldQuery } }) const [offeringId] = data.items if (!offeringId) cds.error(`Could not find service offering with ${fieldQuery}. Make sure the 'hdi-shared' service plan is entitled in your BTP subaccount.`) return this._offeringId.cached = data.items[0].id } } module.exports = ServiceManagerClient