@sap/cds-dk 10.1.0 → 10.1.2
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.
- package/CHANGELOG.md +24 -0
- package/bin/bind.js +4 -0
- package/bin/build.js +1 -0
- package/bin/compile/index.js +12 -11
- package/bin/completion/index.js +21 -10
- package/bin/completion/scripts/cds.fish +179 -127
- package/bin/completion/scripts/cds.ps1 +88 -14
- package/bin/completion/scripts/cds.sh +71 -5
- package/bin/completion/scripts/cds.zsh +81 -15
- package/bin/completion/templates/cds-tmpl.ps1 +15 -6
- package/bin/deploy/index.js +7 -3
- package/bin/env.js +4 -1
- package/bin/export.js +9 -1
- package/bin/import.js +4 -1
- package/bin/init.js +1 -0
- package/bin/inspect.js +3 -1
- package/bin/lint.js +5 -3
- package/bin/minify.js +6 -1
- package/bin/repl.js +11 -2
- package/bin/run.js +12 -1
- package/bin/serve.js +13 -2
- package/bin/up.js +4 -1
- package/bin/util/containerize.js +2 -0
- package/bin/watch/index.js +8 -1
- package/lib/build/plugins/internal.js +0 -26
- package/lib/build/plugins/nodejs/index.js +8 -3
- package/lib/build/util.js +2 -4
- package/lib/cds.js +8 -2
- package/lib/init/constants.js +1 -1
- package/lib/init/merge.js +21 -17
- package/lib/init/template/cloud-logging/index.js +3 -3
- package/lib/init/template/data/as-json.js +3 -0
- package/lib/init/template/destination/index.js +3 -3
- package/lib/init/template/enterprise-messaging/index.js +4 -1
- package/lib/init/template/event-mesh/index.js +3 -0
- package/lib/init/template/multitenancy/index.js +2 -2
- package/lib/init/template/workzone-standard/index.js +1 -1
- package/node_modules/@cap-js/db-service/CHANGELOG.md +30 -0
- package/node_modules/@cap-js/db-service/lib/common/generic-pool.js +4 -7
- package/node_modules/@cap-js/db-service/lib/cqn2pql.js +1 -1
- package/node_modules/@cap-js/db-service/lib/cqn2sql.js +15 -11
- package/node_modules/@cap-js/db-service/lib/cqn4sql.js +125 -21
- package/node_modules/@cap-js/db-service/lib/infer/index.js +27 -16
- package/node_modules/@cap-js/db-service/lib/recursion-guard.js +40 -0
- package/node_modules/@cap-js/db-service/lib/search.js +1 -1
- package/node_modules/@cap-js/db-service/package.json +1 -1
- package/node_modules/@cap-js/sqlite/CHANGELOG.md +28 -0
- package/node_modules/@cap-js/sqlite/lib/SQLiteService.js +110 -14
- package/node_modules/@cap-js/sqlite/package.json +3 -3
- package/node_modules/@sap/cds/CHANGELOG.md +19 -0
- package/node_modules/@sap/cds/common.cds +3 -3
- package/node_modules/@sap/cds/lib/compile/minify.js +3 -1
- package/node_modules/@sap/cds/lib/env/cds-env.js +1 -1
- package/node_modules/@sap/cds/lib/env/cds-requires.js +1 -0
- package/node_modules/@sap/cds/libx/_runtime/common/generic/crud.js +7 -1
- package/node_modules/@sap/cds/libx/_runtime/common/utils/cqn.js +1 -1
- package/node_modules/@sap/cds/libx/_runtime/fiori/lean-draft.js +1 -1
- package/node_modules/@sap/cds/libx/_runtime/messaging/common-utils/normalizeIncomingMessage.js +1 -1
- package/node_modules/@sap/cds/libx/_runtime/messaging/kafka.js +1 -1
- package/node_modules/@sap/cds/libx/http/location.js +10 -11
- package/node_modules/@sap/cds/libx/odata/middleware/read.js +1 -1
- package/node_modules/@sap/cds/libx/queue/SchedulingService.js +4 -0
- package/node_modules/@sap/cds/libx/queue/index.js +44 -10
- package/node_modules/@sap/cds/libx/queue/processing.js +47 -16
- package/node_modules/@sap/cds/libx/queue/utils.js +3 -0
- package/node_modules/@sap/cds/package.json +1 -1
- package/node_modules/@sap/cds/tasks/enterprise-messaging-deploy.js +13 -5
- package/node_modules/@sap/cds-compiler/CHANGELOG.md +15 -0
- package/node_modules/@sap/cds-compiler/lib/compiler/resolve.js +7 -3
- package/node_modules/@sap/cds-compiler/lib/model/csnUtils.js +2 -0
- package/node_modules/@sap/cds-compiler/lib/transform/db/applyTransformations.js +12 -0
- package/node_modules/@sap/cds-compiler/lib/transform/db/associations.js +5 -3
- package/node_modules/@sap/cds-compiler/lib/transform/db/flattening.js +4 -3
- package/node_modules/@sap/cds-compiler/lib/transform/effective/flattening.js +17 -1
- package/node_modules/@sap/cds-compiler/lib/transform/effective/main.js +19 -0
- package/node_modules/@sap/cds-compiler/lib/transform/fioriTreeViews.js +12 -1
- package/node_modules/@sap/cds-compiler/lib/transform/forRelationalDB.js +1 -1
- package/node_modules/@sap/cds-compiler/lib/transform/odata/adaptAnnotationRefs.js +11 -5
- package/node_modules/@sap/cds-compiler/lib/transform/translateAssocsToJoins.js +29 -2
- package/node_modules/@sap/cds-compiler/package.json +1 -1
- package/node_modules/@sap/cds-mtxs/CHANGELOG.md +34 -0
- package/node_modules/@sap/cds-mtxs/bin/cds-mtx-migrate.js +26 -5
- package/node_modules/@sap/cds-mtxs/bin/cds-mtx.js +3 -0
- package/node_modules/@sap/cds-mtxs/lib/utils.js +5 -4
- package/node_modules/@sap/cds-mtxs/package.json +1 -1
- package/node_modules/@sap/cds-mtxs/srv/cf/abstract-provisioning-service.js +62 -7
- package/node_modules/@sap/cds-mtxs/srv/cf/saas-provisioning-service.js +1 -1
- package/node_modules/@sap/cds-mtxs/srv/cf/sms-provisioning-service.js +23 -3
- package/node_modules/@sap/cds-mtxs/srv/extensibility/compat/update.js +15 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/annotations.js +2 -2
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/config.js +2 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/index.js +2 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/protected-namespace.js +45 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility-service.js +8 -3
- package/node_modules/@sap/cds-mtxs/srv/jobs-service.js +49 -10
- package/node_modules/@sap/cds-mtxs/srv/model-provider.js +11 -2
- package/node_modules/@sap/cds-mtxs/srv/plugins/common.js +19 -12
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana/hdi-ctnr-mgr.js +6 -3
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana/serverless.js +271 -0
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana.js +3 -2
- package/node_modules/@sap/cds-mtxs/srv/worker/job-worker-pool.js +212 -0
- package/node_modules/@sap/cds-mtxs/srv/worker/job-worker.js +61 -0
- package/node_modules/proxy-addr/HISTORY.md +60 -54
- package/node_modules/proxy-addr/README.md +1 -1
- package/node_modules/proxy-addr/index.js +32 -0
- package/node_modules/proxy-addr/package.json +8 -4
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +2 -2
- /package/node_modules/@sap/cds-mtxs/srv/worker/{pool.js → model-worker-pool.js} +0 -0
- /package/node_modules/@sap/cds-mtxs/srv/worker/{load.js → model-worker.js} +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const LinterMessage = require('./message')
|
|
2
|
+
const { PROTECTED_NAMESPACES } = require('./config')
|
|
3
|
+
|
|
4
|
+
function _protectedNamespaceOf(name) {
|
|
5
|
+
if (!name) return null
|
|
6
|
+
return PROTECTED_NAMESPACES.find(ns => name === ns || name.startsWith(`${ns}.`)) ?? null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function locationString(element) {
|
|
10
|
+
const loc = element?.$location
|
|
11
|
+
if (!loc) return ''
|
|
12
|
+
const line = loc.line ? `${loc.line}:` : ''
|
|
13
|
+
return loc.col ? `${loc.file}:${line}${loc.col}:` : `${loc.file}:${line}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Rejects defining, extending or annotating artifacts in a protected namespace (e.g. 'cds.xt').
|
|
17
|
+
// Runs unconditionally, independent of linter configuration.
|
|
18
|
+
module.exports = class ProtectedNamespaceChecker {
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line no-unused-vars
|
|
21
|
+
check(reflectedCsn, fullCsn, compileDir, mtxConfig) {
|
|
22
|
+
const messages = []
|
|
23
|
+
|
|
24
|
+
for (const name of Object.keys(reflectedCsn.definitions ?? {})) {
|
|
25
|
+
if (_protectedNamespaceOf(name)) {
|
|
26
|
+
messages.push(this._createWarning(name, reflectedCsn.definitions[name]))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const extension of reflectedCsn.extensions ?? []) {
|
|
31
|
+
const target = extension.extend ?? extension.annotate
|
|
32
|
+
if (_protectedNamespaceOf(target)) {
|
|
33
|
+
messages.push(this._createWarning(target, extension))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return messages
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_createWarning(name, element) {
|
|
41
|
+
const namespace = _protectedNamespaceOf(name)
|
|
42
|
+
const message = `Extending, annotating or defining '${name}' in the protected namespace '${namespace}' is not supported in extensions`
|
|
43
|
+
return new LinterMessage(locationString(element) + message, element)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -58,7 +58,12 @@ class DbAdapter {
|
|
|
58
58
|
try {
|
|
59
59
|
const root = await mkdirp(path.join(temp, 'data'))
|
|
60
60
|
for (const csv in ext.csvs) {
|
|
61
|
-
|
|
61
|
+
// defense-in-depth: 'csv' is user-supplied and must not escape 'root'
|
|
62
|
+
const target = path.join(root, csv)
|
|
63
|
+
if (path.relative(root, target).match(/^\.\.($|[/\\])/) || path.isAbsolute(path.relative(root, target))) {
|
|
64
|
+
throw Error(`Invalid file name: "${csv}"`)
|
|
65
|
+
}
|
|
66
|
+
await write(target, ext.csvs[csv])
|
|
62
67
|
}
|
|
63
68
|
ext.archive = await tar.cz(temp)
|
|
64
69
|
} finally {
|
|
@@ -74,7 +79,7 @@ class DbAdapter {
|
|
|
74
79
|
const csn = JSON.stringify(ext.csn)
|
|
75
80
|
const i18n = ext.i18n ? JSON.stringify(ext.i18n) : null
|
|
76
81
|
const tag = ext.ID
|
|
77
|
-
await cds.tx({ tenant
|
|
82
|
+
await cds.tx({ tenant }, async tx => {
|
|
78
83
|
// remove current inactive extension with tag
|
|
79
84
|
if (tag) await tx.run(DELETE.from('cds.xt.Extensions').where({ tag, activated: { '!=': this.activated(ACTIVE) } }))
|
|
80
85
|
// will break with missing appid
|
|
@@ -455,7 +460,7 @@ module.exports = class ExtensibilityService extends cds.ApplicationService {
|
|
|
455
460
|
let extEntry
|
|
456
461
|
try {
|
|
457
462
|
// TODO: handle '*'?
|
|
458
|
-
await cds.tx(async () => {
|
|
463
|
+
await cds.tx({ tenant },async () => {
|
|
459
464
|
// use native table names to avoid issues with missing appid
|
|
460
465
|
extEntry = await SELECT(['ID','csn', 'tag','activated']).one.from('cds.xt.Extensions').where({ tag, activated: { '!=': 'database' } }).forUpdate() // select only entries with activated != 'database'
|
|
461
466
|
if (!extEntry) {
|
|
@@ -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
|
-
|
|
103
|
-
|
|
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 (
|
|
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 (
|
|
119
|
-
if (
|
|
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:
|
|
142
|
+
this.jobQueue.enqueue({ job_ID, clusters: openClusters, fn, onJobDone })
|
|
122
143
|
} else {
|
|
123
|
-
this.jobQueue.enqueue({ job_ID, clusters:
|
|
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/
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
ds.after(['upgrade', 'extend'], async (_, req) => {
|
|
76
|
+
const { tenant } = req.data
|
|
77
|
+
if (tenant === t0) return
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
// version: require(cds.root + '/package.json').version
|
|
77
|
-
// })
|
|
79
|
+
const version = _getVersion()
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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'
|