UNPKG

@sap/cds-mtxs

Version:

SAP Cloud Application Programming Model - Multitenancy library

308 lines (279 loc) 12.7 kB
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 } }