@sap/cds-mtxs
Version:
SAP Cloud Application Programming Model - Multitenancy library
298 lines (270 loc) • 13.3 kB
JavaScript
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