@sap/cds-mtxs
Version:
SAP Cloud Application Programming Model - Multitenancy library
249 lines (222 loc) • 10.6 kB
JavaScript
const cds = require('@sap/cds/lib')
const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx')
const main = require('../config')
const migration = require('../../lib/migration/migration')
const DeploymentService = 'cds.xt.DeploymentService'
const JobsService = 'cds.xt.JobsService'
const Tenants = 'cds.xt.Tenants'
const axiosInstance = require('axios').create()
axiosInstance.interceptors.response.use(response => response, require('../../lib/pruneAxiosErrors'))
// we need to run in multitenancy mode for t0 ops
if (!cds.env.requires.multitenancy) cds.env.requires.multitenancy = true
const { t0 = 't0' } = cds.env.requires.multitenancy
module.exports = class ProvisioningService extends cds.ApplicationService {
_options4(data) {
if (!data?.options && !data._ && !data._application_) return undefined
const _ = data._application_?.sap ? { hdi: { create: data._application_.sap['service-manager'] } } : data._
return { ...data.options, _ }
}
_getAppUrl(context) {
const { subscriptionPayload, subscriptionHeaders } = context?.data ?? {}
return subscriptionHeaders?.application_url
?? process.env.SUBSCRIPTION_URL?.replace(`\${tenant_subdomain}`, subscriptionPayload.subscribedSubdomain)
?? 'Tenant successfully subscribed - no application URL provided'
}
_getSubscribedTenant(context) {
const { data, params } = context ?? {}
const { subscribedTenantId } = data ?? {}
return subscribedTenantId ?? params?.[0]?.subscribedTenantId
}
async _create(context, metadata) {
const { headers, data, http } = context
DEBUG?.('received subscription request with', { data: require('util').inspect(data, { depth: 11 }) })
const options = this._options4(metadata)
// REVISIT: removing as they are polluting logs -> clearer data/options separation
delete data._; delete data._application_; delete data.options
const tenant = this._getSubscribedTenant(context)
const { isSync } = this._parseHeaders(http?.req.headers)
const sps = await cds.connect.to(this.name)
const appUrl = await sps.getAppUrl(metadata, headers)
if (isSync) {
LOG.info(`subscribing tenant ${tenant}`)
try {
const ds = await cds.connect.to(DeploymentService)
const tx = ds.tx(context)
await tx.subscribe(tenant, metadata, options)
await this._sendCallback('SUCCEEDED', 'Tenant creation succeeded', appUrl)
cds.context.http.res.set('content-type', 'text/plain')
} catch (error) {
await this._sendCallback('FAILED', 'Tenant creation failed')
throw error
}
return appUrl
} else {
const { lazyT0 } = cds.requires['cds.xt.DeploymentService'] ?? cds.requires.multitenancy ?? {}
if (lazyT0) {
await require('../plugins/common').resubscribeT0IfNeeded(options?._, true)
}
const js = await cds.connect.to(JobsService)
const tx = js.tx({ tenant: context.tenant, user: new cds.User.Privileged() })
return tx.enqueue('cds.xt.DeploymentService', 'subscribe', [new Set([tenant])], { data, options }, error => {
if (error) this._sendCallback('FAILED', `Tenant creation failed with error '${error}'`)
else this._sendCallback('SUCCEEDED', 'Tenant creation succeeded', appUrl)
})
}
}
async _read(context) {
const tenant = this._getSubscribedTenant(context)
if (tenant) {
const one = await cds.tx({ tenant: t0 }, tx =>
tx.run(SELECT.one.from(Tenants).columns(['metadata', 'createdAt', 'modifiedAt']).where({ ID: tenant }))
)
if (!one) cds.error(`Tenant ${tenant} not found`, { status: 404 })
const { metadata, createdAt, modifiedAt } = one
return { subscribedTenantId: tenant, ...JSON.parse(metadata ?? '{}'), createdAt, modifiedAt }
}
return (await cds.tx({ tenant: t0 }, tx =>
tx.run(SELECT.from(Tenants).columns(['ID', 'metadata', 'createdAt', 'modifiedAt']))
)).map(({ ID, metadata, createdAt, modifiedAt }) => ({ subscribedTenantId: ID, ...JSON.parse(metadata), createdAt, modifiedAt }))
}
async _getTenants() {
const ds = await cds.connect.to(DeploymentService)
const tenants = await ds.getTenants()
const mtxTenants = await migration.getMissingMtxTenants(tenants)
const all = [...tenants, ...mtxTenants]
if (cds.env.requires[this.name]?.upgrade?.ignoreNonExistingContainers) {
const containers = await ds.getContainers()
return all.filter(t => containers.includes(t))
}
return all
}
async _upgrade(context) {
const { tenants: tenantIds, options = {} } = context.data
if (!tenantIds?.length) return
const all = tenantIds.includes('*')
const sharedGenDir = !main.requires.extensibility
if (sharedGenDir) options.skipResources ??= sharedGenDir
const tenants = all ? await this._getTenants() : tenantIds
const { isSync } = this._parseHeaders(cds.context.http?.req.headers)
if (!tenants.length && isSync) return
if (sharedGenDir && cds.requires.db.kind === 'hana') { // REVISIT: Ideally part of HANA plugin
const { resources4, csvs4, directory4 } = require('../plugins/hana')
const out = await directory4('base', true)
await resources4(out)
await csvs4('base',out)
}
const { clusterSize = 1 } = cds.env.requires.multitenancy.jobs ?? cds.env.requires[this.name]?.jobs ?? {}
const ds = await cds.connect.to(DeploymentService)
const dbToTenants = clusterSize > 1 ? await ds.tenantsByDb(tenants) : [new Set(tenants)]
LOG.info('upgrading', { tenants })
const js = await cds.connect.to(JobsService)
return await new Promise((resolve, reject) => {
const tx = js.tx({ tenant: cds.context.tenant, user: new cds.User.Privileged() })
const job = tx.enqueue('cds.xt.DeploymentService', 'upgrade', dbToTenants, { options }, async error => {
if (error) {
await this._sendCallback('FAILED', `Tenant upgrade failed with error '${error}'`)
if (isSync) reject(new Error(error))
} else {
await this._sendCallback('SUCCEEDED', 'Tenant upgrade succeeded')
if (isSync) {
cds.context.http?.res.status(204)
resolve()
}
}
})
if (!isSync) resolve(job)
})
}
async _delete(context) {
DEBUG?.('received unsubscription request', context.data)
const { isSync } = this._parseHeaders(context.http?.req.headers)
const tenant = this._getSubscribedTenant(context) ?? context.query.DELETE.from?.ref?.[0]?.where?.find(e => e.val)?.val
LOG.info(`unsubscribing tenant ${tenant}`)
if (tenant === t0) {
const ds = await cds.connect.to(DeploymentService)
const tx = ds.tx(context)
return tx.unsubscribe(tenant)
}
const one = await cds.tx({ tenant: t0 }, tx =>
tx.run(SELECT.one.from(Tenants, { ID: tenant }, t => { t.metadata }))
) ?? {}
const metadata = JSON.parse(one?.metadata ?? '{}')
if (isSync) {
const ds = await cds.connect.to(DeploymentService)
const tx = ds.tx(context)
try {
await tx.unsubscribe(tenant, { metadata })
await this._sendCallback('SUCCEEDED', 'Tenant deletion succeeded')
} catch (error) {
if (error.statusCode === 404) {
LOG.info(`tenant ${tenant} is currently not subscribed`)
} else {
await this._sendCallback('FAILED', 'Tenant deletion failed')
throw error
}
}
} else {
const lcs = await cds.connect.to(JobsService)
const tx = lcs.tx({ tenant: context.tenant, user: new cds.User.Privileged() })
return tx.enqueue('cds.xt.DeploymentService', 'unsubscribe', [new Set([tenant])], { metadata }, error => {
if (error) this._sendCallback('FAILED', `Tenant deletion failed with error '${error}'`)
else this._sendCallback('SUCCEEDED', 'Tenant deletion succeeded')
})
}
}
_dependencies() {
// Compat for cds.requires.multitenancy.dependencies
const provisioning = cds.env.requires[this.name] ?? cds.env.requires.multitenancy
if (provisioning?.dependencies) {
return provisioning.dependencies.map(d => ({ xsappname: d }))
}
// Construct from cds.requires
const dependencies = []
for (const [name, req] of Object.entries(cds.env.requires)) {
const tree = req.subscriptionDependency
if (!tree) continue
const extractDependency = (node, root, path = []) => {
if (typeof node === 'object' && node !== null) {
for (const [key, value] of Object.entries(node)) {
const currentPath = [...path, key]
const next = root?.[key]
if (!next) throw new Error(`Cannot resolve dependency at path '${currentPath.join('.')}' in service '${name}'. Make sure the service is bound to the MTX sidecar.`)
extractDependency(value, next, currentPath)
}
} else if (typeof node === 'string') {
const currentPath = [...path, node]
const dep = root?.[node]
if (!dep) throw new Error(`Cannot resolve dependency at path '${currentPath.join('.')}' in service '${name}'`)
dependencies.push(dep)
}
}
extractDependency(tree, cds.requires[name].credentials)
}
LOG.info('using SaaS dependencies', dependencies)
return dependencies.map(d => ({ xsappname: d }))
}
async limiter(limit, payloads, fn) {
const pending = [], all = []
for (const payload of payloads) {
const execute = Promise.resolve().then(() => fn(payload))
all.push(execute)
const executeAndRemove = execute.then(() => pending.splice(pending.indexOf(executeAndRemove), 1))
pending.push(executeAndRemove)
if (pending.length >= limit) {
await Promise.race(pending)
}
}
return Promise.allSettled(all)
}
async sendResult(callbackUrl, payload, customPayload, authorization) {
const { status, message } = payload
// call to custom application callback -> piggyback original SaaS registry payload
if (customPayload) Object.assign(payload, customPayload)
const headers = { authorization }
LOG.info('sending result callback request to', callbackUrl, 'with', { status, message, ...LOG._debug ? payload : {} })
if (customPayload) Object.assign(headers, { status_callback: customPayload.saasCallbackUrl }) // Java use case
try {
return await axiosInstance(callbackUrl, { method: 'PUT', headers, data: payload })
} catch (error) {
cds.error('Error sending result callback to saas-registry: ' + error.message)
}
}
}