UNPKG

@sap/cds-mtxs

Version:

SAP Cloud Application Programming Model - Multitenancy library

521 lines (477 loc) 21.6 kB
const cds = require('@sap/cds') const crypto = require('crypto') const AbstractContainerManagerClient = require('./ctnr-mgr-base') const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx|tms') /** * TMSClient class for managing HANA tenants and containers. * * Missing * - handling of UPDATE_SUCCEEDED, UPDATE_IN_PROGRESS, UPDATE_FAILED * - support of get all */ class TMSClient extends AbstractContainerManagerClient { constructor(credentials) { super(credentials.uaa) this.tmsHost = credentials.baseurl this.credentialCache = {} } static defaultInstance() { TMSClient._defaultInstance ??= TMSClient.newInstance(cds.env.requires.db.credentials) return TMSClient._defaultInstance } static newInstance(credentials) { if (!credentials?.baseurl) cds.error('TMS URL is not configured in credentials') return new TMSClient(credentials) } // Generate hana-tenant-id as a hash of hana-tenant-name // only to avoid collisions // not expected to be true later due to potential changes caused by backup/restore etc static generateTenantId(hanaTenantName) { const hash = crypto.createHash('sha256').update(hanaTenantName).digest('hex') return [hash.slice(0, 8), hash.slice(8, 12), hash.slice(12, 16), hash.slice(16, 20), hash.slice(20, 32)].join('-') } _findValidCredentials(credentialList) { if (!Array.isArray(credentialList)) { return null } // Filter credentials with 'CREATE_SUCCEEDED', 'CREATE_IN_PROGRESS', 'UPDATE_IN_PROGRESS' and 'UPDATE_SUCCEEDED' state const validCredentials = credentialList.filter(({ state }) => ['CREATE_SUCCEEDED', 'CREATE_IN_PROGRESS', 'UPDATE_IN_PROGRESS', 'UPDATE_SUCCEEDED'].includes(state)) if (validCredentials.length === 0) { return null } // Sort by createTimestamp (newest first) and return the most recent one const sorted = validCredentials.sort((a, b) => { return new Date(b.createTimestamp) - new Date(a.createTimestamp) }) const succeeded = sorted.find(c => this._validState(c.state)) if (succeeded) return succeeded return sorted[0] } _findValidContainer(hanatenant, btpTenantName) { const containers = hanatenant.containers?.filter(entry => Array.isArray(entry.labels) && entry.labels.includes(`tenant_id=${btpTenantName}`) ) if (containers?.length > 1) LOG.warn(`Multiple containers found for tenant ${btpTenantName}. Using first one.`) let container = containers?.[0] return container } // Find container and credentials, return {container, credentials, eTag} async findContainerAndCredentials(hanaTenantId, btpTenantName) { const response = await this.fetchApi(`/tenants/v2/tenants/${hanaTenantId}/containers?$expand=credentials`, { baseURL: `https://${this.tmsHost}`, method: 'GET' }) if (response.status === 404) throw new Error('404') const data = await response.data const container = data.data.find(entry => Array.isArray(entry.labels) && entry.labels.includes(`tenant_id=${btpTenantName}`) ) const credentials = this._findValidCredentials(container?.credentials) const eTag = response.headers['etag'] return { container, credentials, eTag } } /** * Finds a container and its credentials for a given BTP tenant name. * The returned credentials can be in various progress states, CREATE_IN_PROGRESS and UPDATE_IN_PROGRESS. * @async * @param {string} btpTenantName - The BTP tenant name to search for * @returns {Object} An object containing: * - hanatenant: The HANA tenant object * - container: The first matching container for the tenant * - credentials: Valid credentials from the container * @throws {Error} Throws error with status 404 if tenant not found or no containers available * @throws {Error} Throws error if API returns 404 status */ async findHanaTenantContainerAndCredentials(btpTenantName) { // TODO expanding credentials does not work currently, change if available again const response = await this.fetchApi(`/tenants/v2/tenants?containerFilter=hassubset(labels,["tenant_id=${btpTenantName}"])&$expand=containers/credentials&$top=1`, { baseURL: `https://${this.tmsHost}`, method: 'GET' }) if (response.status === 404) throw new Error('404') let hanatenant = response.data?.data?.find(tenant => tenant.containers) if (!hanatenant) throw Object.assign(new Error(`No container found for tenant ${btpTenantName}`), { status: 404 }) let container = this._findValidContainer(hanatenant, btpTenantName) if (!container) { throw Object.assign(new Error(`No container found for tenant ${btpTenantName}`), { status: 404 }) } const credentials = this._findValidCredentials(container?.credentials) return { hanatenant, container, credentials } } // only internal use async findHanaTenant(hanaTenantPrefix, hanaTenantName) { const response = await this.fetchApi(`/tenants/v2/tenants?$filter=hassubset(labels,["name=${this._hanaTenantLabel(hanaTenantName)}","prefix=${this._hanaTenantLabel(hanaTenantPrefix)}"])`, { baseURL: `https://${this.tmsHost}`, method: 'GET' }) if (response.status === 404 || !response.data?.data[0]) throw Object.assign(new Error(`No hana tenant found for hana tenant name ${hanaTenantName}`), { status: 404 }) const eTag = response.headers['etag'] return { hanatenant: response.data?.data[0], eTag } } async createHanaTenant(createOptions) { const response = await this.fetchApi(`/tenants/v2/tenants/${createOptions.hanaTenantId}`, { baseURL: `https://${this.tmsHost}`, method: 'PUT', data: { ...createOptions.tmsOptions, labels: [...createOptions.labels] } }) return response.headers['location'] } async getHanaTenant(hanaTenantId, expand = false) { const response = await this.fetchApi(`/tenants/v2/tenants/${hanaTenantId}${expand ? '?$expand=containers/credentials' : ''}`, { baseURL: `https://${this.tmsHost}`, method: 'GET' }) return { hanatenant: response.data, eTag: response.headers['etag'] } } // Check creation status // handles two variants: either location or existing ids checkCreationStatus = async ({ location, hanaTenantId, container, credentials, expand = false }) => { let url = location ? `https://${this.tmsHost}${location}` : `https://${this.tmsHost}/tenants/v2/tenants/${hanaTenantId}` if (!location) { if (this._validState(credentials?.state)) { return { credentials } } if (!credentials && !expand && this._validState(container?.status?.state)) { return { container } } if (container) url += `/containers/${container.id}` if (credentials) url += `/credentials/${credentials.id}` } const conf = expand ? { params: { $expand: !container ? 'containers/credentials' : 'credentials' } } : undefined const response = await this._poll(url, conf).catch(error => { throw error }) const data = await response.data const eTag = response.headers['etag'] return { container: container ? data : undefined, credentials: credentials ? data : undefined, data, eTag } } // poll helpers _validState(state) { return ['CREATE_SUCCEEDED', 'UPDATE_SUCCEEDED'].includes(state) } _succeeded = (response) => this._validState(response.data?.state) || response.data?.status?.ready || response.data?.status?.state === 'DELETED' _failed = (response) => response.data?.state === 'CREATE_FAILED' _pollError = (response) => response async createContainer(hanaTenantId, btpTenantName, eTag, customLabels = []) { if (!eTag) throw new Error('eTag is required to create container') const url = `https://${this.tmsHost}/tenants/v2/tenants/${hanaTenantId}/containers` const options = { method: 'POST', headers: { 'if-match': eTag, }, data: { type: 'hdi', labels: [`tenant_id=${btpTenantName}`, ...customLabels] } } try { const response = await this.fetchApi(url, options) const location = response.headers['location'] return { location } } catch (error) { if (error.status === 409) { const statusObj = await this.checkCreationStatus({ hanaTenantId, expand: true }) if (statusObj.data?.containers?.length > 0) { const container = statusObj.data.containers.find(c => c.labels?.includes(`tenant_id=${btpTenantName}`)) if (container) { const credentials = this._findValidCredentials(container.credentials) return { container, credentials } } } eTag = statusObj.eTag return this.createContainer(hanaTenantId, btpTenantName, eTag, customLabels) } else { throw error } } } // REVISIT 409? -> not a problem if more than one valid credentials exist async createCredentials(hanaTenantId, containerId) { const url = `https://${this.tmsHost}/tenants/v2/tenants/${hanaTenantId}/containers/${containerId}/credentials` const options = { method: 'POST', data: {}, } const response = await this.fetchApi(url, options) return response.headers['location'] } async deleteContainer(tenant, hanaTenantId, containerId, credentials) { const removed = {} let credentialsToDelete = credentials if (!credentials) { // determine credentials to delete? const container = await this._tmsGet(hanaTenantId, containerId) credentialsToDelete = container.credentials ?? [] } const deletions = credentialsToDelete.map(async cred => { const location = await this._tmsDelete(hanaTenantId, containerId, cred.id) try { await this.checkCreationStatus({ location }) } catch (error) { // expect 404 DEBUG?.('Finished deleting credentials for tenant', tenant, ':', error) } }) await Promise.all(deletions) removed.credentials = deletions.map((_, idx) => credentialsToDelete[idx].id) delete this.credentialCache[tenant] // Delete container const location = await this._tmsDelete(hanaTenantId, containerId) try { await this.checkCreationStatus({ location }) } catch (error) { // expect 404 if (error.status === 404) { DEBUG?.('Finished deleting container for tenant', tenant, ':', error) removed.container = containerId } else { LOG.error('Error deleting container for tenant', tenant, ':', error) throw error } } return removed } async _tmsDelete(hanaTenantId, containerId, credentialsId) { let url = `https://${this.tmsHost}/tenants/v2/tenants/${hanaTenantId}/containers/${containerId}` if (credentialsId) { url += `/credentials/${credentialsId}` } const options = { method: 'DELETE' } const response = await this.fetchApi(url, options) return response.headers['location'] } async _tmsGet(hanaTenantId, containerId, credentialsId) { let url = `https://${this.tmsHost}/tenants/v2/tenants/${hanaTenantId}` if (containerId) { url += `/containers/${containerId}` } if (credentialsId) { url += `/credentials/${credentialsId}` } const options = { method: 'GET' } const response = await this.fetchApi(url, options) return response.data } async deleteHanaTenant(hanaTenantId, tenant, deleteOptions = {}) { if (deleteOptions.deleteContainers) { const hanaTenant = await this._tmsGet(hanaTenantId) for (const container of hanaTenant.containers || []) { await this.deleteContainer(tenant, hanaTenantId, container.id) } } let url = `https://${this.tmsHost}/tenants/v2/tenants/${hanaTenantId}` if (deleteOptions.immediateDeletion) { url += '?immediate=true' } const options = { method: 'DELETE' } const response = await this.fetchApi(url, options) try { const result = await this.checkCreationStatus({ location: response.headers['location'] }) if (result.data?.status?.state === 'DELETED') DEBUG?.('Finished deleting hana tenant', tenant ? ' for tenant ' : '', tenant ?? hanaTenantId, ':') } catch (error) { // expect 404 if (error.status === 404) { DEBUG?.('Finished deleting hana tenant immediately', tenant ? ' for tenant ' : '', tenant ?? hanaTenantId, ':', error) } else { LOG.error('Error deleting hana tenant ', tenant ? ' for tenant ' : '', tenant ?? hanaTenantId, ':', error) throw error } } return true } get = async (tenant, options = { disableCache: false }) => { if (!tenant) throw new Error('Tenant name is required') if (options.disableCache || !this.credentialCache[tenant]) { const { container, credentials, hanatenant } = await this.findHanaTenantContainerAndCredentials(tenant) if (!container || !credentials || !container.status?.ready || !this._validState(credentials.state)) { DEBUG?.('Container or credentials not found for tenant', tenant) const cds = require('@sap/cds') cds.error(`Tenant container or credentials for '${tenant}' do not exist`, { status: 404 }) } this.credentialCache[tenant] = { credentials, tags: ['hana'], container, hanatenant } } return this.credentialCache[tenant] } getAll = async (tenants, options = {}) => { if (!tenants) { tenants = await this._getAll(options) } const results = [] for (const tenant of tenants) { const btpTenant = tenant.labels?.tenant_id[0] try { if (!btpTenant) continue if (options.skipCredentials) { results.push(tenant) continue } const credentials = await this.get(btpTenant, options) if (credentials) { results.push({ ...tenant, credentials: credentials.credentials }) } } catch (error) { if (error?.status === 404) { DEBUG?.('Skipping tenant', btpTenant, 'due to missing container or credentials') } else { throw error } } } return results } async _getAllHanaTenants() { const all = [] let skiptoken do { const response = await this.fetchApi(`/tenants/v2/tenants?${skiptoken ? `&$skiptoken=${skiptoken}` : ''}`, { baseURL: `https://${this.tmsHost}`, method: 'GET' }) all.push(...response.data.data) skiptoken = response.data.$skiptoken } while (skiptoken) return all } async _getAll(options) { const all = [] let skiptoken do { const response = await this.fetchApi(`/tenants/v2/tenants?$expand=containers${options?.databaseId ? `&serviceInstanceID=${options.databaseId}` : ''}${skiptoken ? `&$skiptoken=${skiptoken}` : ''}`, { baseURL: `https://${this.tmsHost}`, method: 'GET' }) all.push(...response.data.data.map(item => { // Extract lastTransitionTimestamp from hana tenant status const updated_at = item.status?.lastTransitionTimestamp // Extract tenant_id from container labels const containers = (item.containers ?? []) .filter(container => (container.labels?.[0] ?? '').startsWith('tenant_id=')) .map(container => { return { tenant_id: container.labels[0].split('=')[1], credentials: container.credentials, container, hanatenant: item } }) // REVISIT: error if > 1 container found for tenant_id? return containers.length > 0 ? { labels: { tenant_id: [containers[0].tenant_id] }, container: containers[0].container, hanatenant: item, updated_at, } : undefined })) skiptoken = response.data.$skiptoken } while (skiptoken) return all.filter(item => item !== undefined) } /** * Create a new HANA tenant and its HDI container with credentials */ create = async (btpTenantName, parameters) => { const createOptions = this._createOptions(btpTenantName, parameters?.tmsParams || {}) // REVISIT why 'create'? const customLabels = parameters?.customLabels ?? [] let container, credentials, eTag, hanatenant try { // existing hana tenant might differ in id ({ hanatenant, container, credentials } = await this.findHanaTenantContainerAndCredentials(btpTenantName)) // find by btpTenantName only - hanaTenantId is not guaranteed // If credentials are found and ready, return if (credentials) { return (await this.checkCreationStatus({ hanaTenantId: hanatenant.id, container, credentials })).credentials } } catch (err) { if (err.status === 404) { // No HANA tenant found, create it // What about 409 -> not specified. Using PUT with generated id instead const location = await this.createHanaTenant(createOptions) // Wait until tenant is ready and extract eTag ;({ eTag, data: hanatenant } = await this.checkCreationStatus({ location, expand: true })) container = this._findValidContainer(hanatenant, btpTenantName) // check if container has been created in the meantime } else throw err } if (!container) { const { location: containerLocation, container: newContainer } = await this.createContainer(hanatenant.id, btpTenantName, eTag, customLabels) const response = await this.checkCreationStatus({ location: containerLocation, hanaTenantId: hanatenant.id, container: newContainer }) container = response.container || response.data } else { ({ container } = await this.checkCreationStatus({ hanaTenantId: hanatenant.id, container, expand: true })) } // check potentially existing credentials credentials = this._findValidCredentials(container?.credentials) let credentialsLocation if (!credentials) { credentialsLocation = await this.createCredentials(hanatenant.id, container.id) } const response = await this.checkCreationStatus({ location: credentialsLocation, hanaTenantId: hanatenant.id, container, credentials }) credentials = response.credentials || response.data // return credentials or data depending on what is available this.credentialCache[btpTenantName] = { credentials, tags: ['hana'], container, hanatenant } return this.credentialCache[btpTenantName] } remove = async (tenant, parameters) => { const removed = {} const { cleanup_hana_tenants: cleanupHanaTenants, immediate_deletion: immediateDeletion } = parameters?.tmsParams || {} try { const { hanatenant: { id: hanaTenantId }, container } = await this.findHanaTenantContainerAndCredentials(tenant) if (!container) return removed const removed = await this.deleteContainer(tenant, hanaTenantId, container.id, container.credentials) // Delete hana tenant if requested if (!cleanupHanaTenants) return removed await this.deleteHanaTenant(hanaTenantId, tenant, { immediateDeletion }) removed.hanatenant = hanaTenantId } catch (error) { if (error.status === 404) { DEBUG?.('Container for tenant', tenant, 'not found, nothing to delete') } else if (error.code === 'CONTAINERS_REMAIN') { LOG.warn('Error deleting hana tenant for container for tenant', tenant, ':', error) } else { LOG.error('Error deleting container for tenant', tenant, ':', error) throw error } } return removed } delete = this.remove // 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 result = {} const { database_id, custom_label, ...tenantParams } = hdiParams?.create ?? {} result.tmsParams = { serviceInstanceID: database_id, ...tenantParams } if (custom_label) { const [k] = custom_label.split('=') if (k === 'tenant_id') cds.error('"tenant_id" is not allowed as a custom label key') result.customLabels = [custom_label] } return Object.keys(result).length > 0 ? result : null } // REVISIT exception? _hanaTenantLabel(label) { // return label.length > 63 ? label.substring(0, 60) + '...' : label -> ... cause exception, remove for now in favor of the original exception from tms return label } // separates CAP parameters from TMS parameters and generates hana tenant id if not provided _createOptions = (btpTenantName, params) => { // eslint-disable-next-line no-unused-vars const { hana_tenant_id, hana_tenant_name, hana_tenant_prefix, cleanup_hana_tenants, ...cleanOptions } = params const validTMSOptions = ['dataEncryption', 'serviceInstanceID', 'labels'] for (const key of Object.keys(cleanOptions)) { if (!validTMSOptions.includes(key)) { delete cleanOptions[key] } } const labels = [] let hanaTenantId = hana_tenant_id let hanaTenantPrefix = hana_tenant_prefix if (!hanaTenantId) { if (!hanaTenantPrefix) { throw new Error(`Neither "hana_tenant_prefix" nor "hana_tenant_id" provided, cannot generate a unique hana tenant name for ${btpTenantName}.`) } hanaTenantId = TMSClient.generateTenantId(`${hanaTenantPrefix}-${btpTenantName}`) // push labels labels.push(`prefix=${this._hanaTenantLabel(hanaTenantPrefix)}`, `name=${this._hanaTenantLabel(btpTenantName)}`) } return { hanaTenantId, labels, tmsOptions: cleanOptions, cleanupHanaTenants: cleanup_hana_tenants } } _encryptionParams = () => { return {} } } module.exports = TMSClient