@sap/cds-mtxs
Advanced tools
Comparing version 1.11.0 to 1.12.0
@@ -9,3 +9,25 @@ # Change Log | ||
## Version 1.12.0 - 2023-10-05 | ||
### Added | ||
- Beta: Setting `cds.requires.multitenancy.humanReadableInstanceName = true` will create human-readable tenant IDs for instances created via Service Manager, instead of hashed strings. This is incompatible with existing tenants using hashed instance names. | ||
- Extensions of internal entities / services starting with `cds.xt.` are no longer allowed. | ||
- If no extensions exist, `ModelProviderService.getEdmx()` now uses edmx files prebuilt by `cds build`. | ||
- `ModelProviderService.getEdmx()` with empty `locale` parameter returns non-translated edmx. | ||
- `ModelProviderService.getI18n()` returns the translated texts for a given `locale` and `tenant`. | ||
- Migrated extension projects can now be downloaded using `cds extend <url> --download-migrated-projects`. | ||
### Changed | ||
- The allowlist for HDI deployment parameters (`HDI_DEPLOY_OPTIONS`) is removed. Any option can now be used. | ||
- `PUT /-/cds/saas-provisioning/tenant` now also allows non-UUID strings for `subscribedSubaccountId`. | ||
### Fixed | ||
- Synchronous call of `PUT /-/cds/saas-provisioning/tenant` now returns correct `content-type`: `text/plain`. | ||
- Update of extensions with different tags that depend on each other does no longer result in a compilation error. | ||
- Extensions containing new entities with associations to base entities do no longer cause a compilation error. | ||
- For HANA deployment, no recompilation is done any more for applications not using extensibility. | ||
## Version 1.11.0 - 2023-09-01 | ||
@@ -12,0 +34,0 @@ |
@@ -6,5 +6,7 @@ const path = require('path') | ||
const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx') | ||
const { mkdirp, fs: fsUtils } = require('@sap/cds').utils | ||
const fs = fsUtils.promises | ||
const { mkdirp, rimraf, tar, exists, readdir, read, write, isdir, isfile } = cds.utils | ||
const fs = cds.utils.fs.promises | ||
const linter = require('../../srv/extensibility/linter') | ||
// REVISIT: Exported private functions | ||
@@ -357,2 +359,73 @@ module.exports._hasMtEnv = () => cds.requires.multitenancy || (cds.env.requires['cds.xt.DeploymentService'] && cds.env.requires['cds.xt.ModelProviderService']) | ||
if (diffMessages.length) throw new Error(`Verification error for tenant ${tenant}:\n${diffMessages.join('\n')}`) | ||
} | ||
module.exports.getMigratedProjects = async (req, tagRule, tenant) => { | ||
const temp = await mtxAdapter.mkdirTemp() | ||
// run dry migration with force + own temp directory | ||
// { directory, dry, force, tagRule: tagRegex, tag: defaultTag, "skip-verification": skipVerification, "ignore-migrations": ignoreMigrations } | ||
await module.exports.migrate([tenant], { directory: temp, dry: true, skipVerification: true, tagRule, force: true}) | ||
const projectsLocation = path.join(temp, tenant) | ||
if (!exists(projectsLocation)) req.reject(404, `No migrated projects found for tenant ${tenant}`) | ||
// postprocessing: remove cds-based base model, adjust references | ||
await module.exports.fixBaseModelReferences(projectsLocation) | ||
try { | ||
return await tar.cz(projectsLocation) | ||
} finally { | ||
rimraf(temp) | ||
} | ||
} | ||
module.exports.fixBaseModelReferences = async (directory) => { | ||
await traverse(directory, async (file) => { | ||
// remove base cds files | ||
if (/.*\/node_modules\/_base\//.test(file)) { | ||
await rimraf(file) | ||
} | ||
// remove gen folder | ||
if (new RegExp(`${directory}\/.*\/gen$`).test(file)) { | ||
await rimraf(file) | ||
} | ||
// fix using statements | ||
if (/.*\.cds$/.test(file) && isfile(file)) { | ||
const content = await read(file, 'utf-8') | ||
const fixedContent = content.replace(/\'_base\/.*\'/, '\'_base\'/g') | ||
await write(file, fixedContent, 'utf-8') | ||
} | ||
}) | ||
// add index.csn and .cdsrc.json | ||
await traverse(directory, async (file) => { | ||
if (/.*\/node_modules\/_base$/.test(file)) { | ||
await addBaseAndConfig(file) | ||
} | ||
}) | ||
async function traverse(dir, visitor) { | ||
const list = await readdir(dir) | ||
for (const entry of list) { | ||
const fullPath = path.join(dir, entry) | ||
await visitor(fullPath) | ||
if (await isdir(fullPath)) { | ||
await traverse(fullPath, visitor) | ||
} | ||
} | ||
} | ||
} | ||
const addBaseAndConfig = async function (directory) { | ||
const { 'cds.xt.ModelProviderService': mps } = cds.services | ||
const csn = await mps.getCsn({ | ||
base: true, // without any custom extensions | ||
flavor: 'xtended' | ||
}) | ||
await write(path.join(directory, 'index.csn'), cds.compile.to.json(csn)) | ||
const config = linter.configCopyFrom(cds.env) | ||
await write(path.join(directory, '.cdsrc.json'), JSON.stringify(config, null, 2)) | ||
} |
@@ -105,12 +105,2 @@ const { readdir, isdir } = require('@sap/cds').utils | ||
const collectFiles = async (dir, extensions) => { | ||
const files = [] | ||
await Promise.all((await readdir(dir)).map(async dirent => { | ||
const abs = path.join(dir, dirent) | ||
if (isdir(abs) && !abs.includes('node_modules')) files.push(...await collectFiles(abs, extensions)) | ||
else if (!extensions || extensions.includes(path.extname(abs))) files.push(abs) | ||
})) | ||
return files | ||
} | ||
// REVISIT: opt-in retry mechanism on runtime layer? | ||
@@ -144,4 +134,3 @@ // Sketch: cds.tx({ tenant: 't1', retries: 2 }, ...) | ||
getCompilerError, | ||
collectFiles, | ||
retry | ||
} |
{ | ||
"name": "@sap/cds-mtxs", | ||
"version": "1.11.0", | ||
"version": "1.12.0", | ||
"description": "SAP Cloud Application Programming Model - Multitenancy library", | ||
@@ -5,0 +5,0 @@ "homepage": "https://cap.cloud.sap/", |
@@ -74,2 +74,3 @@ const cds = require('@sap/cds/lib') | ||
await this._sendCallback('SUCCEEDED', 'Tenant creation succeeded', appUrl) | ||
cds.context.http.res.set('content-type', 'text/plain') | ||
} catch (error) { | ||
@@ -76,0 +77,0 @@ await this._sendCallback('FAILED', 'Tenant creation failed') |
@@ -14,2 +14,4 @@ const cds = require('@sap/cds/lib') | ||
const { getMigratedProjects } = require('../lib/migration/migration') | ||
module.exports = class ExtensibilityService extends cds.ApplicationService { | ||
@@ -29,2 +31,12 @@ | ||
this.on('getMigratedProjects', (req) => { | ||
const { tagRule } = req.data | ||
// REVIEW check if access for arbitrary tenants needed | ||
const tenant = req.tenant | ||
if (!tenant) req.reject(401, 'User not assigned to any tenant') | ||
return getMigratedProjects(req, tagRule, tenant) | ||
}) | ||
const _in_prod = process.env.NODE_ENV === 'production' | ||
@@ -31,0 +43,0 @@ if (main.requires.extensibility?.code && !_in_prod && !main.requires.multitenancy) { |
const cds = require('@sap/cds/lib') | ||
const LOG = cds.log('mtx') | ||
const { validateExtension } = require('./validation') | ||
const { getCompilerError } = require('../../lib/utils') | ||
const handleDefaults = require('./defaults') | ||
@@ -66,6 +66,2 @@ const activateExt = require('./activate') | ||
const _addExtension = async function (extCsn, tag, bundles, csvs, tenant, activate, req) { | ||
LOG.info(`validating extension '${tag}' ...`) | ||
const { 'cds.xt.ModelProviderService': mps } = cds.services | ||
const csn = await mps.getCsn(tenant, ['*']) | ||
validateExtension(extCsn, csn, req) | ||
@@ -82,7 +78,16 @@ if (tenant) cds.context = { tenant } | ||
) | ||
const njCsn = cds.compile.for.nodejs(csn) | ||
LOG.info(`activating extension to '${activate}' ...`) | ||
if (activate === 'propertyBag' && extCsn.extensions) | ||
extCsn.extensions.forEach(async ext => await handleDefaults(ext, njCsn)) | ||
if (activate === 'database') await activateExt(ID, tag, tenant, csvs) | ||
LOG.info(`validating extension '${tag}' ...`) | ||
const { 'cds.xt.ModelProviderService': mps } = cds.services | ||
try { | ||
const csn = await mps.getCsn(tenant, ['*']) | ||
const njCsn = cds.compile.for.nodejs(csn) | ||
LOG.info(`activating extension to '${activate}' ...`) | ||
if (activate === 'propertyBag' && extCsn.extensions) | ||
extCsn.extensions.forEach(async ext => await handleDefaults(ext, njCsn)) | ||
if (activate === 'database') await activateExt(ID, tag, tenant, csvs) | ||
} catch (err) { | ||
req.reject(400, getCompilerError(err.messages)) | ||
} | ||
} | ||
@@ -89,0 +94,0 @@ |
@@ -9,3 +9,3 @@ const cds = require('@sap/cds/lib') | ||
const csn = { | ||
extensions: req.data.extensions.map(ext => JSON.parse(ext)) | ||
extensions: req.data.extensions?.map(ext => JSON.parse(ext)) ?? [] | ||
} | ||
@@ -53,3 +53,3 @@ | ||
for (const ext of req.data.extensions) { | ||
for (const ext of req.data.extensions ?? []) { | ||
const extension = JSON.parse(ext) | ||
@@ -56,0 +56,0 @@ await handleDefaults(extension, appCsn, false) |
@@ -17,2 +17,4 @@ const cds = require('@sap/cds/lib') | ||
const PROTECTED_NAMESPACES = ['cds.xt'] | ||
class Allowlist { | ||
@@ -59,2 +61,5 @@ constructor(mtxConfig, fullCsn) { | ||
isAllowed(kind, name) { | ||
// check protected internal namespaces | ||
if (PROTECTED_NAMESPACES.some( namespace => name.startsWith(`${namespace}.`))) return false | ||
return this.getPermission(kind, name) | ||
@@ -61,0 +66,0 @@ } |
@@ -66,2 +66,14 @@ const cds = require('@sap/cds/lib'), { fs, path, tar, rimraf } = cds.utils | ||
// insert and activate extension | ||
const ID = cds.utils.uuid() | ||
await INSERT.into('cds.xt.Extensions').entries({ | ||
ID, | ||
csn: JSON.stringify(extCsn), | ||
i18n: bundles ? JSON.stringify(bundles) : null, | ||
sources, | ||
activated: 'database', | ||
tag | ||
}) | ||
// do validation after extension table update - trust transaction handling for rollback | ||
// compiler validation | ||
@@ -71,5 +83,6 @@ LOG.info(`validating extension '${tag}' ...`) | ||
// REVISIT: Isn't that also done during activate? | ||
const csn = await mps.getCsn(tenant, Object.keys(cds.context.features || {})) | ||
let csn | ||
try { | ||
cds.extend(csn).with(extCsn) | ||
csn = await mps.getCsn(tenant, Object.keys(cds.context.features || {})) | ||
} catch (err) { | ||
@@ -87,13 +100,2 @@ return req.reject(400, getCompilerError(err.messages)) | ||
// insert and activate extension | ||
const ID = cds.utils.uuid() | ||
await INSERT.into('cds.xt.Extensions').entries({ | ||
ID, | ||
csn: JSON.stringify(extCsn), | ||
i18n: bundles ? JSON.stringify(bundles) : null, | ||
sources, | ||
activated: 'database', | ||
tag | ||
}) | ||
LOG.info(`activating extension '${tag}' ...`) | ||
@@ -100,0 +102,0 @@ const async = cds.context.http?.req?.headers?.prefer === 'respond-async' |
@@ -24,3 +24,3 @@ const cds = require('@sap/cds/lib'), { uuid } = cds.utils | ||
await retry(() => cds.tx({ tenant: t0 }, tx => tx.run(INSERT.into(Jobs, job))), LOG) | ||
const jobs = clusters.map(cluster => Array.from(cluster).map(tenant => ({ job_ID, ID: uuid(), tenant, op }))) | ||
const jobs = clusters?.map(cluster => Array.from(cluster).map(tenant => ({ job_ID, ID: uuid(), tenant, op }))) ?? [] | ||
const tasks = jobs.flat() | ||
@@ -27,0 +27,0 @@ |
const crypto = require('crypto') | ||
const fs = require('fs').promises | ||
const path = require('path') | ||
const cds = require('@sap/cds/lib'), { tar, rimraf } = cds.utils | ||
const cds = require('@sap/cds/lib'), { tar, rimraf, exists, read } = cds.utils | ||
const { readData } = require('./extensibility/utils') | ||
@@ -47,3 +47,3 @@ const conf = cds.requires['cds.xt.ModelProviderService'] || cds.requires.kinds['cds.xt.ModelProviderService'] | ||
this.before(['getCsn', 'getEdmx', 'getExtCsn'], req => { | ||
this.before(['getCsn', 'getEdmx', 'getExtCsn', 'getI18n'], req => { | ||
const regex = /^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$|^\$hash$|^\*$/ | ||
@@ -65,12 +65,36 @@ const toggles = req.data?.toggles | ||
// TODO save db requests, some lazy loading, abstract extension API | ||
// - loads needed data from extensions | ||
// - does merging | ||
// - improve _getExtension4 ? | ||
// - split merging | ||
this.on('getEdmx', async req => { | ||
const { res } = req._; if (res) res.set('Content-Type', 'application/xml') | ||
const { service, model, locale, flavor } = req.data | ||
const { service, model, locale, flavor, toggles, tenant } = req.data | ||
if (service === 'all') return req.reject(501, `Edmx request for 'all' services not supported`) | ||
let precompiledEdmx = await _getPrebuiltBundle(toggles, service, flavor, tenant) | ||
delete req.data.flavor // we need to delete the OData 'flavor' argument, as getCsn has a different CSN `flavor` argument | ||
const csn = model ? model : await _getCsn(req) | ||
if (!cds.context.http || cds.context.http.res.statusCode !== 304) { // wee need to check whether the eTag handling by calling _getCsn modified the response with no modified | ||
const edmx = cds.compile.to.edmx(csn, { service, flavor }) | ||
if (!precompiledEdmx) { | ||
const edmx = cds.compile.to.edmx(csn, { service, flavor }) | ||
const extBundle = await _getExtI18n(req) | ||
return locale?.at ? cds.localize(csn, locale, edmx, extBundle) : edmx | ||
} | ||
// localization of base model without any extra i18n | ||
return locale?.at ? cds.localize(csn, locale, precompiledEdmx) : precompiledEdmx | ||
} | ||
}) | ||
this.on('getI18n', async req => { | ||
const csn = await _getCsn(req) | ||
if (!cds.context.http || cds.context.http.res.statusCode !== 304) { | ||
const baseBundle = cds.localize.bundle4(csn, req.data.locale ) | ||
const extBundle = await _getExtI18n(req) | ||
return cds.localize(csn, locale, edmx, extBundle) | ||
return {...baseBundle, ...extBundle} | ||
} | ||
@@ -103,2 +127,21 @@ }) | ||
async function _getPrebuiltBundle(toggles, service, flavor, tenant) { | ||
if (!(await _needsEdmxCompile(toggles, tenant, service))) { | ||
const edmxPath = path.join(main.root, 'srv/odata', flavor ?? cds.env.odata.version,`${service}.xml`) | ||
if (exists(edmxPath)) { | ||
return read(edmxPath, 'utf-8') | ||
} | ||
DEBUG?.(`No precompiled bundle for ${service} found in ${edmxPath}`) | ||
} | ||
return null | ||
} | ||
async function _needsEdmxCompile(toggles, tenant, service) { | ||
const { 'cds.xt.ModelProviderService': mp } = cds.services | ||
if (await mp.isExtended(tenant)) { | ||
return true | ||
} | ||
return !!toggles?.length | ||
} | ||
/** Implementation for getCsn */ | ||
@@ -193,5 +236,9 @@ const baseCache = new Map, extensionCache = new Map | ||
let extBundle | ||
if (extBundles && extBundles.length) { | ||
extBundle = extBundles.reduce((acc, cur) => { | ||
return _mergeBundles(extBundles, locale) | ||
} | ||
function _mergeBundles(bundles, locale) { | ||
let mergedBundle | ||
if (bundles && bundles.length) { | ||
mergedBundle = bundles.reduce((acc, cur) => { | ||
const bundle = JSON.parse(cur.i18n) | ||
@@ -203,6 +250,5 @@ if (locale && bundle[locale]) acc[locale] = Object.assign(acc[locale] || {}, bundle[locale]) | ||
}, {}) | ||
extBundle = extBundle[locale] || extBundle[''] | ||
mergedBundle = mergedBundle[locale] || mergedBundle[''] | ||
} | ||
return extBundle | ||
return mergedBundle | ||
} | ||
@@ -209,0 +255,0 @@ |
@@ -20,2 +20,5 @@ const cds = require('@sap/cds/lib'), {db} = cds.env.requires | ||
// FIXME: Do that check in a better way, as ExtensibilityService is always on with mtx-sidecar preset | ||
const isExtensible = cds.requires.extensibility || cds.requires['cds.xt.ExtensibilityService'] | ||
if (db?.kind === 'hana') { | ||
@@ -155,8 +158,4 @@ | ||
function _isExtensible() { | ||
return cds.requires.extensibility || cds.requires['cds.xt.ExtensibilityService'] | ||
} | ||
async function _readExtCsvs(tenant) { | ||
if (!_isExtensible()) return | ||
if (!isExtensible) return | ||
const { 'cds.xt.ModelProviderService': mp } = cds.services | ||
@@ -221,28 +220,21 @@ const extensions = await mp.getExtResources(tenant) | ||
DEBUG?.('preparing HANA deployment artifacts') | ||
const _resources = csnFromParameter ? null : resources4(tenant) | ||
let container | ||
const [_csn] = await Promise.all([ | ||
(async () => { | ||
container = await _container // csn4 accesses tenant tables, container has to exist | ||
return csnFromParameter ?? csn4(tenant) | ||
})(), | ||
(async () => { | ||
// Note: currently the hana files are created twice, first from getResources, | ||
// then from local compile -2 hana. This has to be adapted depending on if | ||
// the project is extended or not. Ideally the base hana files would have to | ||
// be filtered already when getting the resources. | ||
let container = await _container // csn4 accesses tenant tables, container has to exist | ||
// 1. unpack what comes from getResources() | ||
await _resources | ||
})() | ||
]) | ||
// Note: currently the hana files are created twice, first from getResources, | ||
// then from local compile -2 hana. This has to be adapted depending on if | ||
// the project is extended or not. Ideally the base hana files would have to | ||
// be filtered already when getting the resources. | ||
// Can already start getting the csn if later required | ||
const _csn = isExtensible && !csnFromParameter ? csn4(tenant) : csnFromParameter | ||
// 1. Unpack what comes from getResources() | ||
if (!csnFromParameter) await resources4(tenant) | ||
// 2. Get csvs from extensions | ||
const updateCsvs = !csnFromParameter && !!await csvs4(tenant) | ||
// 3. run cds compile -2 hana with potentially extended model from getCsn() | ||
// FIXME: Do that check in a better way, as ExtensibilityService is always on by new sidecar presets | ||
const isExtensible = _isExtensible() | ||
if (isExtensible || csnFromParameter) { | ||
if (_csn) { | ||
// 3. Run cds compile -2 hana with potentially extended model from getCsn() | ||
const csn = await _csn | ||
@@ -249,0 +241,0 @@ if (csn) await build (csn,tenant,updateCsvs) |
@@ -55,22 +55,2 @@ const { join } = require('path') | ||
const hdi_opts = _parse_env ('HDI_DEPLOY_OPTIONS', options) | ||
const invalid = Object.keys(hdi_opts).filter (o => !(o in { | ||
// subset of https://help.sap.com/viewer/4505d0bdaf4948449b7f7379d24d0f0d/2.0.05/en-US/a4bbc2dd8a20442387dc7b706e8d3070.html | ||
lock_container_timeout:1, | ||
connection_timeout:1, | ||
write_timeout:1, | ||
exclude_filter:1, | ||
include_filter:1, | ||
path_parameter:1, | ||
working_set:1, | ||
auto_undeploy:1, | ||
undeploy:1, | ||
optimise_file_upload:1, | ||
verbose:1, | ||
trace:1, | ||
parameter:1, | ||
treat_unmodified_as_modified:1 | ||
})) | ||
if (invalid.length) { | ||
cds.error(`HDI deployment options '${invalid}' cannot be used with MTX deployment`, { status: options ? 400 : 500 }) | ||
} | ||
env.HDI_DEPLOY_OPTIONS = JSON.stringify (hdi_opts) | ||
@@ -77,0 +57,0 @@ return env |
@@ -29,4 +29,4 @@ const https = require('https') | ||
} catch (e) { | ||
if (e.response?.status === 409) service_instance_id = (await _instance4(tenant)).id | ||
else cds.error(_errorMessage(e, 'creating', tenant), { status: e.response?.status ?? 500 }) | ||
if (e.status === 409) service_instance_id = (await _instance4(tenant)).id | ||
else cds.error(_errorMessage(e, 'creating', tenant), { status: e.status ?? 500 }) | ||
} | ||
@@ -87,2 +87,3 @@ const { data } = await api.post('service_bindings', { | ||
async function _instanceName4(tenant) { | ||
if (cds.requires.multitenancy.humanReadableInstanceName) return tenant | ||
// Compatible with @sap/instance-manager-created instances | ||
@@ -89,0 +90,0 @@ return require('crypto').createHash('sha256').update(`${await _planId()}_${tenant}`).digest('base64') |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
228128
4400