UNPKG

@sap/cds-mtxs

Version:

SAP Cloud Application Programming Model - Multitenancy library

328 lines (301 loc) 14 kB
const https = require('https') const { inspect } = require('util') const cds = require('@sap/cds') const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx|sm') const { uuid, fs, path } = cds.utils const { cacheBindings = true } = cds.env.requires.multitenancy ?? {} const { sm_url, url, clientid, clientsecret, certurl, certificate, key } = cds.env.requires.db.credentials const COLORS = !!process.stdout.isTTY && !!process.stderr.isTTY && !process.env.NO_COLOR || process.env.FORCE_COLOR const axios = require('axios') const pruneAxiosErrors = require('../../../lib/pruneAxiosErrors') // In-memory storage -> later also distribute w/ Redis const instanceLocations = new Map, bindingLocations = new Map /* API */ async function create(tenant, parameters) { LOG.info('creating HDI container for', { tenant }, ...(parameters ? ['with', { ...parameters }] : [])) const name = await _instanceName4(tenant), service_plan_id = await _planId() const { binding_parameters, provisioning_parameters } = parameters ?? {} let service_instance_id if (instanceLocations.has(tenant)) { const storedLocation = instanceLocations.get(tenant) LOG.info('polling ongoing instance creation for', { tenant }) const polledInstance = await _poll(storedLocation) service_instance_id = polledInstance.resource_id instanceLocations.delete(tenant) } else { try { const _instance = await fetchApi('service_instances?async=true', { method: 'POST', data: { name, service_plan_id, parameters: provisioning_parameters, labels: { tenant_id: [tenant] }, } }) instanceLocations.set(tenant, _instance.headers.location) service_instance_id = (await _poll(_instance.headers.location)).resource_id instanceLocations.delete(tenant) } catch (e) { instanceLocations.delete(tenant) const status = e.status ?? 500 if (status === 409 || e.error === 'Conflict') { const instance = await _instance4(tenant) if (!instance.ready || !instance.usable) { const { type, state, errors, resource_type } = instance?.last_operation ?? {} LOG.info(`detected unusable instance for tenant '${tenant}' in state '${state}' for operation type '${type}'`) if (type === 'create' && state === 'failed') { LOG.info(`removing and recreating faulty instance for tenant '${tenant}'`, DEBUG ? `with error: ${e.error}: ${e.description}. Last operation: ${errors?.error} ${errors?.description}` : '' ) await remove(tenant) return create(tenant, parameters) } else if (type === 'create' && state === 'in progress') { const location = resource_type + '/' + instance.id + '/operations/' + instance.last_operation.id LOG.info(`polling ongoing instance creation for tenant '${tenant}' at location '${location}'`) instanceLocations.set(tenant, location) await _poll(location) instanceLocations.delete(tenant) } else { e.message ??= '' e.message += `${e.error}: ${e.description}. Last operation: ${errors?.error} ${errors?.description}` throw e } } service_instance_id = instance.id } else { cds.error(_errorMessage(e, 'creating', tenant), { status }) } } } if (bindingLocations.has(tenant)) { const storedLocation = bindingLocations.get(tenant) LOG.info(`ongoing binding creation for tenant ${tenant}, polling existing request`) try { await _poll(storedLocation) } finally { bindingLocations.delete(tenant) } } else { const _binding = await fetchApi('service_bindings?async=true', { method: 'POST', data: { name: tenant + `-${uuid()}`, service_instance_id, binding_parameters, labels: { tenant_id: [tenant], service_plan_id: [service_plan_id], managing_client_lib: ['instance-manager-client-lib'] } } }) bindingLocations.set(tenant, _binding.headers.location) await _poll(_binding.headers.location) bindingLocations.delete(tenant) } const binding = { ...await get(tenant), tags: ['hana'] } return cacheBindings ? _bindings4.cached[tenant] = binding : binding } async function acquire(tenant, parameters) { try { return await get(tenant, { disableCache: true }) } catch (e) { if (e.status === 404) return create(tenant, parameters) throw e } } async function get(tenant, options) { let credentials, result try { [{ credentials } = {}] = await _bindings4([tenant], options) if (!credentials) cds.error(`Tenant '${tenant}' does not exist`, { status: 404 }) credentials.tenant ??= tenant result = { name: await _instanceName4(tenant), tenant_id: tenant, credentials, tags: ['hana'] } } catch (e) { cds.error(_errorMessage(e, 'getting', tenant), { status: e.status ?? 500 }) } return result } function getAll(tenants = '*', options) { return _bindings4(tenants, options) } function deploy(container, tenant, out, options, deployEnv) { return require('./hdi').deploy(container, tenant, out, options, deployEnv) } async function remove(tenant) { const instance = await _instance4(tenant) const bindings = []; let token if (instance) { const fieldQuery = `service_instance_id eq '${instance.id}'` let token do { const { data } = await fetchApi('service_bindings', { params: { token, fieldQuery } }) const { items, token: nextPageToken } = data bindings.push(...items) token = nextPageToken } while (token) } do { const { data } = await fetchApi('service_bindings', { params: { token, labelQuery: `tenant_id eq '${tenant}'` } }) const { items, token: nextPageToken } = data bindings.push(...items) token = nextPageToken } while (token) const deduped = [...new Map(bindings.map(b => [b.id, b])).values()] const _deleteBindings = deduped.map(async ({ id }) => _poll((await fetchApi(`service_bindings/${id}?async=true`, { method: 'DELETE' })).headers.location) ) if (cacheBindings) delete _bindings4.cached[tenant] const failedDeletions = (await Promise.allSettled(_deleteBindings)).filter(d => d.status === 'rejected') if (failedDeletions.length > 0) throw new AggregateError(failedDeletions.map(d => d.reason)) if (instance) { const _deleteInstance = await fetchApi(`service_instances/${instance.id}?async=true`, { method: 'DELETE' }) if (_deleteInstance.headers.location) await _poll(_deleteInstance.headers.location) } } module.exports = { create, get, getAll, acquire, deploy, delete: remove } /* Private helpers */ async function _instance4(tenant) { const fieldQuery = `name eq '${await _instanceName4(tenant)}'` const instances = await fetchApi('service_instances?async=true&attach_last_operations=true', { params: { fieldQuery } }) return instances.data.items[0] } async function _instanceName4(tenant) { if (cds.requires.multitenancy?.humanReadableInstanceName) return tenant // Compatible with @sap/instance-manager-created instances return require('crypto').createHash('sha256').update(`${await _planId()}_${tenant}`).digest('base64') } _bindings4.cached = {} async function _bindings4(tenants, { disableCache = false } = {}) { const useCache = cacheBindings && !disableCache && tenants !== '*' const uncached = useCache ? tenants.filter(t => !(t in _bindings4.cached)) : tenants DEBUG?.('retrieving', { tenants }, { uncached }) if (uncached.length === 0) return tenants.map(t => _bindings4.cached[t]) const _tenantFilter = () => ` and tenant_id in (${uncached.map(t => `'${t}'`).join(', ')})` const tenantFilter = tenants === '*' ? '' : _tenantFilter() const labelQuery = `service_plan_id eq '${await _planId()}'` + tenantFilter const fieldQuery = `ready eq 'true'` const fetched = []; let token do { const { data } = await fetchApi('service_bindings', { params: { token, labelQuery, fieldQuery } }) const { items, token: nextPageToken } = data fetched.push(...items) token = nextPageToken } while (token) const cacheMisses = Object.fromEntries(fetched.filter(b => b.labels?.tenant_id).map(b => [b.labels.tenant_id[0], b])) Object.assign(_bindings4.cached, cacheMisses) if (useCache) { return tenants.map(t => _bindings4.cached[t]) } return fetched } async function _planId() { if (_planId.cached) return _planId.cached const fieldQuery = `catalog_name eq 'hdi-shared' and service_offering_id eq '${await _offeringId()}'` const { data } = await fetchApi('service_plans', { params: { fieldQuery } }) const [planId] = data.items if (!planId) cds.error(`Could not find service plan with ${fieldQuery}`) return _planId.cached = data.items[0].id } async function _offeringId() { if (_offeringId.cached) return _offeringId.cached const fieldQuery = `catalog_name eq 'hana'` const { data } = await fetchApi('service_offerings', { params: { fieldQuery } }) const [offeringId] = data.items if (!offeringId) cds.error(`Could not find service offering with ${fieldQuery}`) return _offeringId.cached = data.items[0].id } async function _token() { if (!_token.cached || _token.cached.expiry < Date.now() + 30000) { const auth = certificate ? { maxRedirects: 0, httpsAgent: new https.Agent({ cert: certificate, key }) } : { auth: { username: clientid, password: clientsecret } } const authUrl = `${certurl ?? url}/oauth/token` const data = `grant_type=client_credentials&client_id=${encodeURI(clientid)}` const config = { method: 'POST', timeout: 5000, data, ...auth } const { access_token, expires_in } = (await fetchResiliently(authUrl, config)).data _token.cached = { access_token, expiry: Date.now() + expires_in * 1000 } } return `Bearer ${_token.cached.access_token}` } function _poll(location) { let attempts = 0, maxAttempts = 60, pollingTimeout = 3000, maxTime = pollingTimeout * maxAttempts/1000 const _next = async (resolve, reject) => { const { data, data: { state, errors } } = await fetchApi(location.slice('/v1/'.length)) if (state === 'succeeded') return resolve(data) if (state === 'failed') return reject(errors[0] ?? errors) if (attempts > maxAttempts) return reject(new Error(`Polling ${location} timed out after ${maxTime} seconds with state ${state}`)) setTimeout(++attempts && _next, pollingTimeout, resolve, reject) } return new Promise(_next) } function _errorMessage(e, action, tenant) { const msg = `Error ${action} tenant ${tenant}: ${e.response?.data?.error ?? e.code ?? e.message ?? 'unknown error'}` const cause = e.description || e.cause ? require('os').EOL + `Root Cause: ${e.description ?? e.cause}` : '' return msg + cause } const { version } = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../package.json'), 'utf8')) const fetchApi = async (url, conf = {}) => { conf.headers ??= {} conf.headers.Authorization ??= await _token() conf.headers['Content-Type'] ??= 'application/json' conf.headers['Client-ID'] ??= 'cap-mtx-sidecar' conf.headers['Client-Version'] ??= version conf.headers['X-CorrelationID'] ??= cds.context?.id conf.headers['X-Correlation-ID'] ??= cds.context?.id conf.baseURL ??= sm_url + '/v1/' return fetchResiliently(conf.baseURL + url, conf) } const SECRETS = /(passw)|(cert)|(ca)|(secret)|(key)|(access_token)|(imageUrl)/i /** * Masks password-like strings, also reducing clutter in output * @param {any} cred - object or array with credentials * @returns {any} */ const _redacted = function _redacted(cred) { if (!cred) return cred if (Array.isArray(cred)) return cred.map(c => _redacted(c)) if (typeof cred === 'object') { const newCred = Object.assign({}, cred) Object.keys(newCred).forEach(k => (typeof newCred[k] === 'string' && SECRETS.test(k)) ? (newCred[k] = '...') : (newCred[k] = _redacted(newCred[k]))) return newCred } return cred } const maxRetries = cds.requires?.multitenancy?.serviceManager?.retries ?? 3 const fetchResiliently = module.exports.fetchResiliently = async function (url, conf, retriesLeft = maxRetries) { conf.method ??= 'GET' try { DEBUG?.('>', conf.method.toUpperCase(), url, inspect({ ...(conf.headers && { headers: { ...conf.headers, Authorization: conf.headers.Authorization.split(' ')?.[0] + ' ...' } }), ...(conf.params && { params: conf.params }), ...(conf.data && { data: conf.data }) }, { depth: 11, compact: false, colors: COLORS })) const response = await axios(url, conf) const { status, statusText } = response DEBUG?.('<', conf.method.toUpperCase(), url, status, statusText, inspect(_redacted(response.data), { depth: 11, colors: COLORS })) return response } catch (error) { const { status, headers } = error.response ?? { status: 500 } if (status in { 401: 1, 403: 1, 404: 1 } || retriesLeft === 0) return pruneAxiosErrors(error) const attempt = maxRetries - retriesLeft + 1 if (LOG._debug) { const e = error.toJSON?.() ?? error DEBUG(`fetching ${url} attempt ${attempt} failed with`, { ...(e.name && { name: e.name }), ...(e.message && { message: e.message }), ...(e.description && { description: e.description }) }) } let delay = 0 if (status === 429) { const retryAfter = headers['retry-after'] if (retryAfter) delay = parseInt(retryAfter, 10) * 1000 else return pruneAxiosErrors(error) } else { // S-curve instead of exponential backoff to allow for high number of reattempts (∞) const maxDelay = 30000, midpoint = 6, steepness = 0.4 delay = maxDelay * (1 + Math.tanh(steepness * (attempt - midpoint))) / 2 } await new Promise((resolve) => setTimeout(resolve, delay)) if (conf.headers?.Authorization) conf.headers.Authorization = await _token() return fetchResiliently(url, conf, retriesLeft - 1) } }