@sap/cds-dk 10.1.0 → 10.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/bind.js +4 -0
  3. package/bin/build.js +1 -0
  4. package/bin/compile/index.js +12 -11
  5. package/bin/completion/index.js +21 -10
  6. package/bin/completion/scripts/cds.fish +179 -127
  7. package/bin/completion/scripts/cds.ps1 +88 -14
  8. package/bin/completion/scripts/cds.sh +71 -5
  9. package/bin/completion/scripts/cds.zsh +81 -15
  10. package/bin/completion/templates/cds-tmpl.ps1 +15 -6
  11. package/bin/deploy/index.js +7 -3
  12. package/bin/env.js +4 -1
  13. package/bin/export.js +9 -1
  14. package/bin/import.js +4 -1
  15. package/bin/init.js +1 -0
  16. package/bin/inspect.js +3 -1
  17. package/bin/lint.js +5 -3
  18. package/bin/minify.js +6 -1
  19. package/bin/repl.js +11 -2
  20. package/bin/run.js +12 -1
  21. package/bin/serve.js +13 -2
  22. package/bin/up.js +4 -1
  23. package/bin/util/containerize.js +2 -0
  24. package/bin/watch/index.js +8 -1
  25. package/lib/build/util.js +2 -4
  26. package/lib/init/constants.js +1 -1
  27. package/lib/init/template/data/as-json.js +3 -0
  28. package/node_modules/@cap-js/db-service/CHANGELOG.md +17 -0
  29. package/node_modules/@cap-js/db-service/lib/common/generic-pool.js +4 -7
  30. package/node_modules/@cap-js/db-service/lib/cqn2pql.js +1 -1
  31. package/node_modules/@cap-js/db-service/lib/cqn2sql.js +15 -11
  32. package/node_modules/@cap-js/db-service/lib/cqn4sql.js +119 -19
  33. package/node_modules/@cap-js/db-service/lib/infer/index.js +27 -16
  34. package/node_modules/@cap-js/db-service/lib/recursion-guard.js +40 -0
  35. package/node_modules/@cap-js/db-service/lib/search.js +1 -1
  36. package/node_modules/@cap-js/db-service/package.json +1 -1
  37. package/node_modules/@cap-js/sqlite/CHANGELOG.md +19 -0
  38. package/node_modules/@cap-js/sqlite/lib/SQLiteService.js +110 -14
  39. package/node_modules/@cap-js/sqlite/package.json +3 -3
  40. package/node_modules/@sap/cds-compiler/CHANGELOG.md +15 -0
  41. package/node_modules/@sap/cds-compiler/lib/compiler/resolve.js +7 -3
  42. package/node_modules/@sap/cds-compiler/lib/model/csnUtils.js +2 -0
  43. package/node_modules/@sap/cds-compiler/lib/transform/db/applyTransformations.js +12 -0
  44. package/node_modules/@sap/cds-compiler/lib/transform/db/associations.js +5 -3
  45. package/node_modules/@sap/cds-compiler/lib/transform/db/flattening.js +4 -3
  46. package/node_modules/@sap/cds-compiler/lib/transform/effective/flattening.js +17 -1
  47. package/node_modules/@sap/cds-compiler/lib/transform/effective/main.js +19 -0
  48. package/node_modules/@sap/cds-compiler/lib/transform/fioriTreeViews.js +12 -1
  49. package/node_modules/@sap/cds-compiler/lib/transform/forRelationalDB.js +1 -1
  50. package/node_modules/@sap/cds-compiler/lib/transform/odata/adaptAnnotationRefs.js +11 -5
  51. package/node_modules/@sap/cds-compiler/lib/transform/translateAssocsToJoins.js +29 -2
  52. package/node_modules/@sap/cds-compiler/package.json +1 -1
  53. package/node_modules/@sap/cds-mtxs/CHANGELOG.md +34 -0
  54. package/node_modules/@sap/cds-mtxs/bin/cds-mtx-migrate.js +26 -5
  55. package/node_modules/@sap/cds-mtxs/bin/cds-mtx.js +3 -0
  56. package/node_modules/@sap/cds-mtxs/lib/utils.js +5 -4
  57. package/node_modules/@sap/cds-mtxs/package.json +1 -1
  58. package/node_modules/@sap/cds-mtxs/srv/cf/abstract-provisioning-service.js +62 -7
  59. package/node_modules/@sap/cds-mtxs/srv/cf/saas-provisioning-service.js +1 -1
  60. package/node_modules/@sap/cds-mtxs/srv/cf/sms-provisioning-service.js +23 -3
  61. package/node_modules/@sap/cds-mtxs/srv/extensibility/compat/update.js +15 -0
  62. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/annotations.js +2 -2
  63. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/config.js +2 -0
  64. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/index.js +2 -0
  65. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/protected-namespace.js +45 -0
  66. package/node_modules/@sap/cds-mtxs/srv/extensibility-service.js +8 -3
  67. package/node_modules/@sap/cds-mtxs/srv/jobs-service.js +49 -10
  68. package/node_modules/@sap/cds-mtxs/srv/model-provider.js +11 -2
  69. package/node_modules/@sap/cds-mtxs/srv/plugins/common.js +19 -12
  70. package/node_modules/@sap/cds-mtxs/srv/plugins/hana/hdi-ctnr-mgr.js +6 -3
  71. package/node_modules/@sap/cds-mtxs/srv/plugins/hana/serverless.js +271 -0
  72. package/node_modules/@sap/cds-mtxs/srv/plugins/hana.js +3 -2
  73. package/node_modules/@sap/cds-mtxs/srv/worker/job-worker-pool.js +212 -0
  74. package/node_modules/@sap/cds-mtxs/srv/worker/job-worker.js +61 -0
  75. package/package.json +2 -2
  76. /package/node_modules/@sap/cds-mtxs/srv/worker/{pool.js → model-worker-pool.js} +0 -0
  77. /package/node_modules/@sap/cds-mtxs/srv/worker/{load.js → model-worker.js} +0 -0
@@ -6,9 +6,13 @@ const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx')
6
6
  const Jobs = 'cds.xt.Jobs', Tasks = 'cds.xt.Tasks'
7
7
 
8
8
  const { t0_ } = require('../lib/utils')
9
+ const getDeploymentPool = require('./worker/job-worker-pool')
9
10
 
10
11
  const RUNNING = 'RUNNING', FINISHED = 'FINISHED', FAILED = 'FAILED', QUEUED = 'QUEUED', TIMEOUT = 'TIMEOUT'
11
12
 
13
+ // Pseudo-db-id for skipped tenants (e.g. already upgraded); their tasks are recorded FINISHED, not run.
14
+ const SKIPPED_DB = 'skipped'
15
+
12
16
  // A queue, implemented as a circular buffer for O(1) insert + delete
13
17
  class Queue {
14
18
  constructor(capacity) {
@@ -71,13 +75,13 @@ module.exports = class JobsService extends cds.ApplicationService {
71
75
 
72
76
  _config() {
73
77
  const {
74
- queueSize = 100, clusterSize = 3, workerSize = 4, poolSize = 1
78
+ queueSize = 100, clusterSize = 3, workerSize = 4, poolSize = 1, nativeWorkers = false
75
79
  } = {
76
80
  ...(cds.env.requires.multitenancy?.jobs ?? {}),
77
81
  ...(cds.env.requires['cds.xt.SaasProvisioningService']?.jobs ?? {}),
78
82
  ...(cds.env.requires['cds.xt.SmsProvisioningService']?.jobs ?? {})
79
83
  }
80
- return { queueSize, clusterSize, workerSize, poolSize }
84
+ return { queueSize, clusterSize, workerSize, poolSize, nativeWorkers }
81
85
  }
82
86
 
83
87
  async enqueue(service, op, clusters, args, onJobDone) {
@@ -99,32 +103,52 @@ module.exports = class JobsService extends cds.ApplicationService {
99
103
  const job_ID = uuid()
100
104
  const job = { ID: job_ID, service, op, args, status: QUEUED }
101
105
  const clustered = Object.entries(clusters).map(([database, cluster]) => {
102
- if (database !== '0') return Array.from(cluster).map(tenant => ({ job_ID, ID: uuid(), tenant, service, op, args, database, status: QUEUED }))
103
- else return Array.from(cluster).map(tenant => ({ job_ID, ID: uuid(), tenant, service, op, args, status: QUEUED }))
106
+ // Skipped-cluster tasks are recorded FINISHED right away; others are QUEUED for processing.
107
+ const status = database === SKIPPED_DB ? FINISHED : QUEUED
108
+ if (database !== '0') return Array.from(cluster).map(tenant => ({ job_ID, ID: uuid(), tenant, service, op, args, database, status }))
109
+ else return Array.from(cluster).map(tenant => ({ job_ID, ID: uuid(), tenant, service, op, args, status }))
104
110
  })
105
111
  const tasks = clustered.flat()
112
+ // Clusters still needing processing (excluding the skipped pseudo-cluster and empty ones).
113
+ const openClusters = clustered.filter(cluster => cluster.length && cluster[0].database !== SKIPPED_DB)
114
+ const openTasks = openClusters.flat()
106
115
  await t0_(async () => {
107
116
  await INSERT.into(Jobs, job)
108
- if (tasks.length) {
117
+ if (openTasks.length) {
109
118
  await INSERT.into(Tasks, tasks)
110
119
  job.tasks = tasks
111
120
  await this.emit('jobUpdate', { job })
112
121
  } else {
122
+ // Nothing to process (empty or only skipped) -> finish directly.
123
+ if (tasks.length) await INSERT.into(Tasks, tasks)
113
124
  await UPDATE(Jobs, { ID: job_ID }).with({ status: FINISHED })
114
- await this.emit('jobUpdate', { job: { ...job, status: FINISHED } })
125
+ await this.emit('jobUpdate', { job: { ...job, tasks, status: FINISHED } })
115
126
  }
116
127
  })
117
128
 
118
- if (tasks.length) {
119
- if (cds.services[service]) {
129
+ if (openTasks.length) {
130
+ if (_canUseWorkers(this._conf)) {
131
+ const pool = getDeploymentPool(this._conf.workerSize)
132
+ const fn = async task => {
133
+ const result = await pool.exec(service, op, task.tenant, args)
134
+ // Worker ran in an isolated thread; invalidate this process's stale
135
+ // pool for the tenant (worker's disconnect didn't affect us).
136
+ if (_invalidatesTenantPool(op)) cds.db?.disconnect(task.tenant)
137
+ return result
138
+ }
139
+ this.jobQueue.enqueue({ job_ID, clusters: openClusters, fn, onJobDone })
140
+ } else if (cds.services[service]) {
120
141
  const fn = task => cds.services[service].tx({ tenant: task.tenant }, tx => tx[op](task.tenant, ...Object.values(args)))
121
- this.jobQueue.enqueue({ job_ID, clusters: clustered, fn, onJobDone })
142
+ this.jobQueue.enqueue({ job_ID, clusters: openClusters, fn, onJobDone })
122
143
  } else {
123
- this.jobQueue.enqueue({ job_ID, clusters: clustered, fn: () => {
144
+ this.jobQueue.enqueue({ job_ID, clusters: openClusters, fn: () => {
124
145
  LOG.warn('service', service, 'is not available, skipping job')
125
146
  }, onJobDone })
126
147
  }
127
148
  this.pickJob()
149
+ } else if (tasks.length) {
150
+ // Only skipped tasks (already FINISHED) -> notify callback since the job won't run.
151
+ await onJobDone?.()
128
152
  }
129
153
 
130
154
  const url = process.env.VCAP_APPLICATION ? 'https://' + JSON.parse(process.env.VCAP_APPLICATION).uris?.[0] : cds.server.url
@@ -260,6 +284,8 @@ module.exports = class JobsService extends cds.ApplicationService {
260
284
  }
261
285
  }
262
286
 
287
+ module.exports.SKIPPED_DB = SKIPPED_DB
288
+
263
289
  function _errorMessage(e) {
264
290
  let message = e.message ?? 'Unknown error'
265
291
  if (e.error) message += ' ' + e.error
@@ -267,6 +293,19 @@ function _errorMessage(e) {
267
293
  return message.length > 5000 ? message.substring(0, 4997) + '...' : message // TODO use different datatype for errors -> next release
268
294
  }
269
295
 
296
+ function _canUseWorkers(conf) {
297
+ // Workers must be explicitly enabled via configuration since they run in
298
+ // isolated threads with their own CDS bootstrap (no shared handlers/state)
299
+ if (!conf?.nativeWorkers) return false
300
+ return true
301
+ }
302
+
303
+ // Operations that change or drop a tenant's database, requiring the serving
304
+ // process to disconnect its stale pooled connection for the tenant.
305
+ function _invalidatesTenantPool(op) {
306
+ return op === 'unsubscribe' || op === 'subscribe'
307
+ }
308
+
270
309
  /*********************************************
271
310
  * Clean-up
272
311
  *********************************************/
@@ -3,7 +3,7 @@ const fs = require('fs').promises
3
3
  const path = require('path')
4
4
  const cds = require('@sap/cds/lib'), { tar, rimraf, exists, read, merge } = cds.utils
5
5
  const { readData, readDataCsv } = require('../lib/utils')
6
- const { worker4 } = require('./worker/pool')
6
+ const { worker4 } = require('./worker/model-worker-pool')
7
7
  const conf = cds.requires['cds.xt.ModelProviderService'] || cds.requires.kinds['cds.xt.ModelProviderService']
8
8
  const TEMP_DIR = require('fs').realpathSync(require('os').tmpdir())
9
9
  const main = require('./config')
@@ -74,6 +74,15 @@ module.exports = class ModelProviderService extends cds.ApplicationService {
74
74
  if (invalid) return req.reject(400, `Unsupported input toggle param ${production ? '' : invalid}`)
75
75
  })
76
76
 
77
+ // `service` and `flavor` are interpolated into a file system path in `_getPrebuiltBundle`.
78
+ // Validate them here to prevent path traversal (e.g. reading arbitrary *.xml files).
79
+ this.before('getEdmx', req => {
80
+ const { service, flavor } = req.data
81
+ const _valid = v => v == null || /^(?!.*\.\.)[\w-.]+$/.test(v)
82
+ if (!_valid(service)) return req.reject(400, `Unsupported input service param ${service}`)
83
+ if (!_valid(flavor)) return req.reject(400, `Unsupported input flavor param ${flavor}`)
84
+ })
85
+
77
86
  this.on('getCsn', req => _getCsn(req))
78
87
 
79
88
  this.on('getExtCsn', req => {
@@ -202,7 +211,7 @@ module.exports = class ModelProviderService extends cds.ApplicationService {
202
211
 
203
212
  const skipMinify = (!!extensions || reqSkipMinify) && _delayMinify()
204
213
  let csn = await lru4(baseCache, JSON.stringify({ models, flavor, javaornode, skipMinify }), () =>
205
- worker4(path.join(__dirname, 'worker/load.js'), { models, flavor, javaornode, skipMinify })
214
+ worker4(path.join(__dirname, 'worker/model-worker.js'), { models, flavor, javaornode, skipMinify })
206
215
  )
207
216
 
208
217
  if (extensions) {
@@ -43,13 +43,17 @@ cds.on('serving:cds.xt.DeploymentService', ds => {
43
43
  if (existingExt) cds.error(`Extensions exist, but extensibility is disabled. Upgrade aborted to avoid data loss`, { status: 500 })
44
44
  })
45
45
 
46
+ const _getVersion = () => JSON.stringify({ version: require(cds.root + '/package.json').version })
47
+
46
48
  ds.after('subscribe', async (_, req) => {
47
49
  const { tenant, metadata, schema } = req.data
48
50
  if (tenant === t0) return
49
51
 
52
+ const version = _getVersion()
53
+
50
54
  try {
51
55
  // can't use UPSERT here so @cds.on.insert still works for createdAt
52
- await t0_(INSERT.into(Tenants, { ID: tenant, metadata: JSON.stringify(metadata), ...(schema ? { schema } : {}) }))
56
+ await t0_(INSERT.into(Tenants, { ID: tenant, metadata: JSON.stringify(metadata), ...(schema ? { schema } : {}), version }))
53
57
  } catch (e) {
54
58
  if (e.message === 'ENTITY_ALREADY_EXISTS' || e.code === 301 || e.message.match(/unique constraint/i)) {
55
59
  await t0_(async () => {
@@ -68,17 +72,15 @@ cds.on('serving:cds.xt.DeploymentService', ds => {
68
72
  LOG.info('subscribed tenant', tenant)
69
73
  })
70
74
 
71
- // ds.after(['upgrade', 'extend'], async (_, req) => {
72
- // const { tenant } = req.data
73
- // if (tenant === t0) return
75
+ ds.after(['upgrade', 'extend'], async (_, req) => {
76
+ const { tenant } = req.data
77
+ if (tenant === t0) return
74
78
 
75
- // const version = JSON.stringify({
76
- // version: require(cds.root + '/package.json').version
77
- // })
79
+ const version = _getVersion()
78
80
 
79
- // await t0_(UPSERT.into(Tenants, { ID: tenant, version }))
80
- // LOG.info('upgraded tenant', tenant)
81
- // })
81
+ await t0_(UPSERT.into(Tenants, { ID: tenant, version }))
82
+ LOG.info('upgraded tenant', tenant)
83
+ })
82
84
 
83
85
  ds.after('unsubscribe', async (_, req) => {
84
86
  const { tenant } = req.data
@@ -87,9 +89,14 @@ cds.on('serving:cds.xt.DeploymentService', ds => {
87
89
  })
88
90
 
89
91
  ds.prepend(() => {
90
- ds.on('getTenants', async () => {
92
+ ds.on('getTenants', async req => {
93
+ const { options } = req.data ?? {}
94
+ const skipAfter = options?.skipAfter
95
+ const query = skipAfter
96
+ ? SELECT.from(Tenants, tenant => { tenant.ID }).where('modifiedAt <', skipAfter)
97
+ : SELECT.from(Tenants, tenant => { tenant.ID })
91
98
  return (await cds.tx({ tenant: t0 }, tx =>
92
- tx.run(SELECT.from(Tenants, tenant => { tenant.ID }))
99
+ tx.run(query)
93
100
  )).map(({ ID }) => ID)
94
101
  })
95
102
  })
@@ -1,5 +1,6 @@
1
1
  const cds = require('@sap/cds')
2
- const { sm_url, baseurl } = cds.env.requires.db.credentials
2
+ const { db } = cds.env.requires
3
+ const { sm_url, baseurl, url, identity } = db.credentials || {}
3
4
  const LOG = cds.log('mtx')
4
5
 
5
6
  if (sm_url) {
@@ -9,7 +10,9 @@ if (sm_url) {
9
10
  LOG.warn('SaaS Provisioning Service is configured with ignoreNonExistingContainers. This can cause problems when using HANA TMS v2 as container manager.')
10
11
  }
11
12
  module.exports = require('./tms')
13
+ } else if (db.serverless || db.kind === 'hana-serverless' || (url && identity)) {
14
+ module.exports = require('./serverless')
12
15
  } else {
13
- LOG.warn('Neither Service Manager nor HANA TMS credentials found. No HANA HDI Containers will be accessible.')
16
+ LOG.warn('Neither Service Manager, HANA TMS, nor serverless credentials found. No HANA HDI Containers will be accessible.')
14
17
  module.exports = { defaultInstance: ()=> {} }
15
- }
18
+ }
@@ -0,0 +1,271 @@
1
+ const cds = require('@sap/cds')
2
+ const AbstractContainerManagerClient = require('./ctnr-mgr-base')
3
+ const { token, fetchResiliently } = require('../../../lib/utils')
4
+ const { uuid } = cds.utils
5
+ const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx|serverless')
6
+
7
+ const API = '/serverless/v1/containers'
8
+
9
+ class ServerlessClient extends AbstractContainerManagerClient {
10
+ constructor(credentials) {
11
+ super({ clientid: credentials.identity?.clientid, url: credentials.identity?.url })
12
+ this.gateway = credentials.url
13
+ this.identity = credentials.identity
14
+ this.certificate = this.identity?.certificate
15
+ this.key = this.identity?.key
16
+ this.certurl = this.identity?.certurl
17
+ this.url = this.identity?.url
18
+ this.clientid = this.identity?.clientid
19
+ this.credentialCache = {}
20
+ this.tokenCache = new Map()
21
+ this.delete = this.remove
22
+ }
23
+
24
+ static defaultInstance() {
25
+ ServerlessClient._defaultInstance ??= ServerlessClient.newInstance(ServerlessClient._resolveCredentials())
26
+ return ServerlessClient._defaultInstance
27
+ }
28
+
29
+ static _resolveCredentials() {
30
+ const credentials = { ...cds.env.requires.db.credentials }
31
+ let vcap
32
+ const _vcap = () => (vcap ??= (() => { try { return JSON.parse(process.env.VCAP_SERVICES || '{}') } catch { return {} } })())
33
+ if (!credentials.url) {
34
+ const serverless = (_vcap()['hana-cloud'] || []).find(s => s.plan === 'serverless') || (_vcap()['hana-cloud'] || [])[0]
35
+ const gw = credentials.credentials?.url || serverless?.credentials?.url || serverless?.credentials?.credentials?.url
36
+ if (gw) credentials.url = gw
37
+ }
38
+ const iasBinding = (_vcap().identity || [])[0]?.credentials
39
+ if (iasBinding) credentials.identity = iasBinding
40
+ else if (!credentials.identity) credentials.identity = credentials.credentials?.identity
41
+ if (!credentials.identity?.certificate) {
42
+ const identityBinding = ServerlessClient._resolveIdentityBinding()
43
+ if (identityBinding?.certificate) credentials.identity = identityBinding
44
+ }
45
+ return credentials
46
+ }
47
+
48
+ static _resolveIdentityBinding() {
49
+ const isIdentity = conf => conf?.kind === 'ias' || conf?.vcap?.label === 'identity'
50
+ for (const conf of Object.values(cds.env.requires || {})) {
51
+ if (isIdentity(conf) && conf.credentials?.certificate) return conf.credentials
52
+ }
53
+ return undefined
54
+ }
55
+
56
+ static newInstance(credentials) {
57
+ if (!credentials?.url) cds.error('Serverless gateway URL is not configured in credentials')
58
+ if (!credentials.identity?.certificate) cds.error('No identity (IAS) credentials found for the serverless Tenant API')
59
+ return new ServerlessClient(credentials)
60
+ }
61
+
62
+ _tokenFor = async () => {
63
+ const cached = this.tokenCache.get('provider')
64
+ if (cached && cached.expiry >= Date.now() + 30_000) return `Bearer ${cached.access_token}`
65
+ const raw = await token(this.identity, { form: { grant_type: 'client_credentials' }, path: '/oauth2/token' })
66
+ const { access_token, expires_in } = JSON.parse(raw)
67
+ this.tokenCache.set('provider', { access_token, expiry: Date.now() + expires_in * 1000 })
68
+ return `Bearer ${access_token}`
69
+ }
70
+
71
+ _api = async (tenant, path, conf = {}) => {
72
+ conf.headers ??= {}
73
+ conf.headers.Authorization ??= await this._tokenFor(tenant)
74
+ conf.headers['Accept'] ??= 'application/json'
75
+ conf.headers['Content-Type'] ??= 'application/json'
76
+ conf.headers['X-CorrelationID'] ??= cds.context?.id
77
+ const { retries, maxRetryAfter } = cds.requires?.multitenancy?.serviceManager ?? cds.requires?.multitenancy?.containerManager ?? {}
78
+ return fetchResiliently(this.gateway + path, conf, { retriesLeft: retries ?? 10, maxRetryAfter: maxRetryAfter ?? 5000 })
79
+ }
80
+
81
+ _pollLocation = async (tenant, location, extract) => {
82
+ let attempts = 0
83
+ const maxAttempts = 60, interval = 3000
84
+ for (;;) {
85
+ const { data } = await this._api(tenant, location.startsWith('http') ? location.slice(this.gateway.length) : location, { method: 'GET' })
86
+ const state = extract(data)
87
+ if (state === true) return data
88
+ if (state === 'FAILED') cds.error(`Serverless operation failed for ${location}: ${JSON.stringify(data)}`)
89
+ if (++attempts > maxAttempts) cds.error(`Polling ${location} timed out after ${(interval * maxAttempts) / 1000} seconds`)
90
+ await new Promise(r => setTimeout(r, interval))
91
+ }
92
+ }
93
+
94
+ _matchingContainers = (list, tenant) => {
95
+ const base = this._baseName(tenant)
96
+ return (list || []).filter(c => (c.labels || []).includes(`tenant_id=${tenant}`) || c.name === base || (c.name || '').startsWith(`${base}_`))
97
+ }
98
+
99
+ _findContainer = async (tenant) => {
100
+ const { data } = await this._api(tenant, API, { method: 'GET' })
101
+ const usable = c => !/(FAILED|DELETED|NOT_EXIST)$/.test(c.status?.state || '')
102
+ return this._matchingContainers(data.data, tenant).find(usable)
103
+ }
104
+
105
+ _removeStaleContainers = async (tenant) => {
106
+ const { data } = await this._api(tenant, API, { method: 'GET' })
107
+ const stale = this._matchingContainers(data.data, tenant).filter(c => /FAILED$/.test(c.status?.state || ''))
108
+ for (const c of stale) {
109
+ LOG.info('removing stale serverless container for', { tenant }, 'container', c.name, 'in state', c.status?.state)
110
+ const del = await this._api(tenant, `${API}/${c.id}`, { method: 'DELETE' }).catch(() => ({}))
111
+ const location = del.headers?.['location']
112
+ if (location) await this._pollLocation(tenant, location, d => /(SUCCEEDED|DELETED|NOT_EXIST)$/.test(d.status?.state || '') ? true : false).catch(() => {})
113
+ }
114
+ }
115
+
116
+ _findCredentials = async (tenant, containerId) => {
117
+ const { data } = await this._api(tenant, `${API}/${containerId}/credentials`, { method: 'GET' })
118
+ const list = data.data || []
119
+ return list.find(x => (x.state || '').endsWith('SUCCEEDED')) || list[0]
120
+ }
121
+
122
+ _credentialsDetail = async (tenant, containerId, credentialId) => {
123
+ const { data } = await this._api(tenant, `${API}/${containerId}/credentials/${credentialId}`, { method: 'GET' })
124
+ return this._toHdiCredentials(data)
125
+ }
126
+
127
+ _toHdiCredentials = (c) => ({
128
+ host: c.host,
129
+ port: String(c.port),
130
+ driver: c.driver || 'com.sap.db.jdbc.Driver',
131
+ schema: c.schema,
132
+ hdi_user: c.hdiUser,
133
+ hdi_password: c.hdiPassword,
134
+ user: c.user,
135
+ password: c.password,
136
+ certificate: c.certificate,
137
+ encrypt: true,
138
+ sslTrustAll: false,
139
+ validate_certificate: true,
140
+ })
141
+
142
+ get = async (tenant, options = {}) => {
143
+ const { disableCache } = options
144
+ if (!tenant) throw new Error('Tenant name is required')
145
+ if (!disableCache && this.credentialCache[tenant]) return this.credentialCache[tenant]
146
+ const container = await this._findContainer(tenant)
147
+ if (!container) cds.error(`Tenant '${tenant}' does not exist`, { status: 404 })
148
+ const credential = await this._findCredentials(tenant, container.id)
149
+ if (!credential) cds.error(`Tenant '${tenant}' has no usable credentials`, { status: 404 })
150
+ const credentials = await this._credentialsDetail(tenant, container.id, credential.id)
151
+ LOG.info('resolved serverless container for', { tenant }, 'container', container.name, 'schema', credentials.schema)
152
+ return this.credentialCache[tenant] = { name: container.name, tenant_id: tenant, credentials, container, tags: ['hana'] }
153
+ }
154
+
155
+ _waitContainerReady = async (tenant, containerId) => {
156
+ await this._pollLocation(tenant, `${API}/${containerId}`, d =>
157
+ d.status?.ready === true || d.status?.state?.endsWith('SUCCEEDED') ? true : (d.status?.state?.endsWith('FAILED') ? 'FAILED' : false)
158
+ )
159
+ }
160
+
161
+ _awaitBrokerContainer = async (tenant, attempts = 20, interval = 3000) => {
162
+ for (let i = 0; i < attempts; i++) {
163
+ const container = await this._findContainer(tenant)
164
+ if (container) return container
165
+ await new Promise(r => setTimeout(r, interval))
166
+ }
167
+ return undefined
168
+ }
169
+
170
+ create = async (tenant, parameters) => {
171
+ LOG.info('creating serverless HDI container for', { tenant })
172
+ let container = await this._findContainer(tenant)
173
+ const t0 = cds.env.requires.multitenancy?.t0 ?? 't0'
174
+ if (!container && tenant !== t0 && cds.env.requires.multitenancy?.serverless?.autoSubscription) {
175
+ LOG.info('awaiting broker-provisioned serverless container for', { tenant })
176
+ container = await this._awaitBrokerContainer(tenant)
177
+ }
178
+ if (!container) {
179
+ await this._removeStaleContainers(tenant)
180
+ const create = await this._api(tenant, API, {
181
+ method: 'POST',
182
+ data: { name: this._newContainerName(tenant), type: 'hdi', labels: [`tenant_id=${tenant}`, ...(parameters?.customLabels ?? [])] },
183
+ })
184
+ const location = create.headers['location']
185
+ await this._pollLocation(tenant, location, d => d.status?.state?.endsWith('SUCCEEDED') ? true : (d.status?.state?.endsWith('FAILED') ? 'FAILED' : false))
186
+ container = await this._findContainer(tenant)
187
+ if (!container) cds.error(`Serverless container for tenant '${tenant}' not found after creation`)
188
+ }
189
+ await this._waitContainerReady(tenant, container.id)
190
+ let credential = await this._findCredentials(tenant, container.id)
191
+ if (!credential) {
192
+ const create = await this._api(tenant, `${API}/${container.id}/credentials`, { method: 'POST', data: {} })
193
+ const location = create.headers['location']
194
+ await this._pollLocation(tenant, location, d => d.state?.endsWith('SUCCEEDED') ? true : (d.state?.endsWith('FAILED') ? 'FAILED' : false))
195
+ credential = await this._findCredentials(tenant, container.id)
196
+ }
197
+ const credentials = await this._credentialsDetail(tenant, container.id, credential.id)
198
+ LOG.info('created serverless container for', { tenant }, 'container', container.name, 'schema', credentials.schema)
199
+ return this.credentialCache[tenant] = { name: container.name, tenant_id: tenant, credentials, container, tags: ['hana'] }
200
+ }
201
+
202
+ getAll = async (tenants = '*', options = {}) => {
203
+ const seed = tenants === '*' ? null : tenants
204
+ const listTenant = seed?.[0]
205
+ let containers = []
206
+ if (listTenant) {
207
+ const { data } = await this._api(listTenant, API, { method: 'GET' })
208
+ containers = (data.data || []).filter(c => (c.labels || []).some(l => l.startsWith('tenant_id=')))
209
+ }
210
+ const results = []
211
+ for (const c of containers) {
212
+ const tenant_id = (c.labels.find(l => l.startsWith('tenant_id=')) || '').split('=')[1]
213
+ if (!tenant_id) continue
214
+ const entry = { labels: { tenant_id: [tenant_id] }, container: c }
215
+ if (options.skipCredentials) { results.push(entry); continue }
216
+ try {
217
+ const { credentials } = await this.get(tenant_id, options)
218
+ results.push({ ...entry, credentials })
219
+ } catch (e) {
220
+ if (e.status === 404) DEBUG?.('skipping tenant', tenant_id, 'without usable credentials')
221
+ else throw e
222
+ }
223
+ }
224
+ return results
225
+ }
226
+
227
+ remove = async (tenant) => {
228
+ const container = await this._findContainer(tenant)
229
+ if (!container) { delete this.credentialCache[tenant]; return {} }
230
+ const { data } = await this._api(tenant, `${API}/${container.id}/credentials`, { method: 'GET' })
231
+ for (const cred of data.data || []) {
232
+ const del = await this._api(tenant, `${API}/${container.id}/credentials/${cred.id}`, { method: 'DELETE' })
233
+ const location = del.headers['location']
234
+ if (location) await this._pollLocation(tenant, location, d => d.state?.endsWith('SUCCEEDED') || d.state?.endsWith('DELETED') ? true : false).catch(() => {})
235
+ }
236
+ const del = await this._api(tenant, `${API}/${container.id}`, { method: 'DELETE' })
237
+ const location = del.headers['location']
238
+ if (location) await this._pollLocation(tenant, location, d => d.status?.state?.endsWith('SUCCEEDED') || d.status?.state?.endsWith('DELETED') ? true : false).catch(() => {})
239
+ delete this.credentialCache[tenant]
240
+ return { container: container.id }
241
+ }
242
+
243
+ _createParams = (tenant, params = {}, metadata) => {
244
+ const hdiParams = this._hdiParams(tenant, params, metadata) ?? {}
245
+ const result = {}
246
+ const { custom_label } = hdiParams?.create ?? {}
247
+ if (custom_label) {
248
+ const [k] = custom_label.split('=')
249
+ if (k === 'tenant_id') cds.error('"tenant_id" is not allowed as a custom label key')
250
+ result.customLabels = [custom_label]
251
+ }
252
+ return Object.keys(result).length > 0 ? result : null
253
+ }
254
+
255
+ _encryptionParams = () => {
256
+ return {}
257
+ }
258
+
259
+ _baseName = (tenant) => {
260
+ let sanitized = String(tenant).replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
261
+ if (!/^[A-Za-z0-9]/.test(sanitized)) sanitized = `T${sanitized}`
262
+ return sanitized.slice(0, 24)
263
+ }
264
+
265
+ _newContainerName = (tenant) => {
266
+ const suffix = uuid().replace(/-/g, '').toUpperCase()
267
+ return `${this._baseName(tenant)}_${suffix}`.slice(0, 62)
268
+ }
269
+ }
270
+
271
+ module.exports = ServerlessClient
@@ -15,8 +15,9 @@ const resources = new Map()
15
15
 
16
16
  const { deepMerge } = require('../../lib/utils')
17
17
 
18
- if (db?.kind === 'hana') {
19
- if (!db.credentials?.sm_url && !db.credentials?.baseurl) cds.error('No Service Manager credentials found. Make sure the application is bound to a BTP Service Manager instance.')
18
+ if (db?.kind === 'hana' || db?.kind === 'hana-serverless') {
19
+ const serverless = db.serverless || db.kind === 'hana-serverless' || (db.credentials?.url && db.credentials?.identity)
20
+ if (!db.credentials?.sm_url && !db.credentials?.baseurl && !serverless) cds.error('No Service Manager credentials found. Make sure the application is bound to a BTP Service Manager instance.')
20
21
 
21
22
  const hana = require('./hana/hdi-ctnr-mgr').defaultInstance()
22
23
  exports.activated = 'HANA Database'