@sap/cds-mtxs
Version:
SAP Cloud Application Programming Model - Multitenancy library
308 lines (279 loc) • 12.7 kB
JavaScript
const cds = require('@sap/cds/lib')
const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx')
const main = require('../config')
const { fetchResiliently, token } = require('../../lib/utils')
const migration = require('../../lib/migration/migration')
const DeploymentService = 'cds.xt.DeploymentService'
const JobsService = 'cds.xt.JobsService'
const Tenants = 'cds.xt.Tenants'
// 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
const tenant = this._getSubscribedTenant(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 { isSync } = this._parseHeaders(http?.req.headers)
const sps = await cds.connect.to(this.name)
const appUrl = await sps.getAppUrl(metadata, headers)
const skipDb = cds.env.requires[this.name]?.subscribe?.['skip-db'] ?? false
if (isSync) {
LOG.info('subscribing tenant', tenant)
if (skipDb) {
LOG.warn('Deployment will be skipped because skip-db is enabled for ', this.name)
return appUrl
}
try {
const { 'cds.xt.DeploymentService': ds } = cds.services
if (!ds) {
LOG.warn('tenant', tenant, 'will not be subscribed because DeploymentService is disabled')
return appUrl
}
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 {
if (skipDb) {
cds.error(500, 'Cannot subscribe tenant because skip-db is enabled set')
}
const { lazyT0 } = cds.requires[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() })
const result = await tx.enqueue(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)
})
if (context.data.eventType === 'UPDATE') {
return appUrl
}
return result
}
}
_toMetadataJson(metadata, subscribedTenantId) {
try {
return JSON.parse(metadata);
} catch (error) {
LOG.error('Failed to parse metadata to JSON', { error, metadata });
return { subscribedTenantId };
}
}
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, ...this._toMetadataJson(metadata, tenant), 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, ...this._toMetadataJson(metadata, ID), 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
DEBUG?.('received upgrade request with', { data: cds.utils.inspect(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 { 'cds.xt.DeploymentService': ds } = cds.services
const dbToTenants = (clusterSize > 1 && ds) ? 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(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)
const { 'cds.xt.DeploymentService': ds } = cds.services
const skipDb = cds.env.requires[this.name]?.subscribe?.['skip-db'] ?? false
let metadata = {}
if (ds) {
if (tenant === t0) {
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 }))
) ?? {}
metadata = JSON.parse(one.metadata ?? '{}')
}
if (isSync) {
if (skipDb) {
LOG.warn('Deployment will be skipped because skip-db is enabled for ', this.name)
return
}
if (!ds) {
LOG.warn('tenant', tenant, 'will not be unsubscribed because DeploymentService disabled')
return
}
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 {
if (skipDb) {
cds.error(500, 'Cannot unsubscribe tenant because db deployment is skipped due to skip-db being enabled')
}
const lcs = await cds.connect.to(JobsService)
const tx = lcs.tx({ tenant: context.tenant, user: new cds.User.Privileged() })
return tx.enqueue(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
const headers = { authorization, 'Content-Type': 'application/json' }
LOG.info('sending result callback request to', callbackUrl, 'with', { status, message, ...LOG._debug ? payload : {} })
if (customPayload) { // Java use case
Object.assign(headers, { status_callback: customPayload.saasCallbackUrl })
Object.assign(payload, customPayload)
}
let url
try { // check if callbackUrl is a valid URL
url = new URL(callbackUrl)
} catch (error) { // swallow TypeError on invalid URLs (crashing the server)
if (error.code === 'ERR_INVALID_URL') throw new Error(`Invalid callback URL: ${callbackUrl}`, { cause: error })
else throw error
}
return fetchResiliently(url.toString(), { method: 'PUT', headers, data: payload })
}
async _token() {
const serviceName = 'cds.xt.' + this.constructor.name
const cred = this._getCredentials()
const raw = await token(cred, { query: { response_type: 'token' } })
const { access_token } = JSON.parse(raw)
if (!access_token) cds.error(`could not get token for ${serviceName}: token is empty`)
return access_token
}
_getCredentials() {
const serviceName = 'cds.xt.' + this.constructor.name
const { multitenancy, [serviceName]: s } = cds.env.requires
const cred = s?.credentials ?? multitenancy?.credentials ?? {}
return cred
}
}