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