@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
345 lines (289 loc) • 12.5 kB
JavaScript
const path = require('path');
const cds = require('../../../lib/cds');
const { exists, read, write, mkdirp } = cds.utils;
const { build } = require('../../build');
const hdi = require('./hdiDeployUtil');
const cfUtil = require('../../util/cf');
const mtaUtil = require('./mtaUtil');
const { bold, info, warn } = require('../../util/term');
const { bind, storeBindings, env: bindingEnv } = require('../../bind/shared');
const { BUILD_TASK_HANA } = require('../../build/constants');
const { options, flags } = require('../index');
const allOptions = [...options, ...flags];
const DEBUG = cds.debug('cli');
/** @type { (typeof allOptions[number])[] } */
const allowList = [
'--auto-undeploy',
'--dry',
'--for',
'--no-build',
'--on',
'--out',
'--resolve-bindings',
'--store-credentials',
'--to',
'--tunnel-address',
'--vcap-file',
'--with-mocks'
]
module.exports = new class HanaDeployer {
getFromEnv(...varNames) {
const result = {}
for (const varName of varNames) {
if (process.env[varName]) {
try {
result[varName] = JSON.parse(process.env[varName]);
} catch {
throw new Error(`Error parsing environment variable ${varName}`);
}
}
}
return result;
}
async deploy(options = {}) {
if (options['store-credentials']) {
console.warn(warn('The option \'--store-credentials\' has been deprecated and will be removed with the next major release. Use command \'cds bind\' instead.'));
}
if (options.dry && options.out) { // dry run equals cds compile to hana
return await this._runCompileToHana(options);
}
let unknownOptions = Object.keys(options)
.map(key => `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`)
.filter(key => allOptions.includes(key) && !allowList.includes(key));
if (unknownOptions.length > 0) {
throw `One or more options are not supported by cds deploy to hana: ${unknownOptions.join(', ')}`;
}
let { model, serviceName: pServiceName, tunnelAddress, vcapFile, undeployWhitelist, hdiOptions = {} } = options;
options.for ??= 'hybrid';
if (model) {
throw `Specifying a CDS model for HANA deployment is not supported.
'cds deploy --to hana' performs 'cds build --for hana' which in turn uses the configured HANA build task.
Therefore, specifying the CDS model is not required when calling 'cds deploy'.`;
}
if (vcapFile) {
console.log(`Using VCAP_SERVICES from file ${vcapFile}.`);
}
const fromFile = vcapFile && exists(vcapFile) ? await read(vcapFile) : {};
const fromEnv = this.getFromEnv('VCAP_SERVICES', 'SERVICE_REPLACEMENTS');
const vcapEnv = { ...fromEnv, ...fromFile };
const warnings = [];
if (pServiceName && vcapEnv.VCAP_SERVICES?.hana?.[0].name !== pServiceName) {
if (vcapFile) {
warnings.push(`The specified service name '${pServiceName}' was used; the information coming from --vcap-service file was ignored.`);
} else if (Object.keys(fromEnv).length > 0) {
warnings.push(`The specified service name '${pServiceName}' was used; the information coming from environment variable VCAP_SERVICES was ignored.`);
}
}
if (cds.cli.options?.['no-build']) {
console.log(`Provided flag '--no-build', skipping cds build`)
} else {
await build({ for: BUILD_TASK_HANA });
}
if (options.out) {
await this._runCompileToHana(options);
}
if (options.dry) {
return;
}
if (process.env.TARGET_CONTAINER) {
vcapEnv.TARGET_CONTAINER = process.env.TARGET_CONTAINER;
}
// the primary database is always built and deployed
const dest = path.join(cds.root, cds.env.build.target, cds.env.folders.db);
if (undeployWhitelist) {
console.log('Writing undeploy.json');
await write(undeployWhitelist).to(dest, 'undeploy.json')
}
const hasVCAPEnv = Object.keys(vcapEnv).length > 0;
let bindOptions
let usesExistingK8sBinding = false
const { readProject } = require('../../init/projectReader')
const { hasKyma, hasMta } = readProject()
const onKyma = cds.cli.options.on === 'k8s' || (hasKyma && !hasMta && cds.cli.options.on !== 'cf')
if (onKyma) {
const cp = require('node:child_process')
const existing = cds.requires.db?.binding
if (!pServiceName && existing && (existing.name || existing.secret)) {
const envVars = await bindingEnv({ env: cds.env, silent: true })
if (envVars?.VCAP_SERVICES) {
vcapEnv.VCAP_SERVICES = JSON.parse(envVars.VCAP_SERVICES)
usesExistingK8sBinding = true
console.log(`using existing Kubernetes binding ${bold(existing.name || existing.secret)}`)
}
}
if (!usesExistingK8sBinding) {
const baseName = pServiceName ?? path.basename(cds.root)
const instanceName = baseName.endsWith('-db') ? baseName : `${baseName}-db`
const bindingName = `${instanceName}-binding`
const secretName = `${instanceName}-secret`
const instance =
`apiVersion: services.cloud.sap.com/v1
kind: ServiceInstance
metadata:
name: ${instanceName}
spec:
serviceOfferingName: hana
servicePlanName: hdi-shared
`
const binding =
`apiVersion: services.cloud.sap.com/v1
kind: ServiceBinding
metadata:
name: ${bindingName}
spec:
serviceInstanceName: ${instanceName}
secretName: ${secretName}
`
try {
cp.execSync('kubectl apply -f -', { input: instance })
cp.execSync('kubectl apply -f -', { input: binding })
cp.execSync(`kubectl wait --for=condition=Ready servicebinding/${bindingName} --timeout=180s`)
cp.execSync(`kubectl wait --for=condition=Ready serviceinstance/${instanceName} --timeout=180s`)
} catch (error) {
const descr = cp.execSync(`kubectl describe serviceinstance/${instanceName}`).toString()
const message = descr.match(/Message:\s+(.*)/)?.[1] || 'None'
throw error.message + `\nInstance Message: ${message}`
}
const { data: encoded } = JSON.parse(cp.execSync(`kubectl get secrets/${secretName} -o json`).toString())
const credentials = {}
for (const [key, value] of Object.entries(encoded)) {
credentials[key] = Buffer.from(value, 'base64').toString('utf-8');
}
vcapEnv.VCAP_SERVICES ??= {
hana: [{
tags: ['hana'],
credentials
}]
}
bindOptions = {
...options,
to: `${bindingName}`,
for: options.for,
kind: BUILD_TASK_HANA
}
}
} else {
let serviceName = pServiceName;
let serviceKeyName;
const { instance, key } = cds.requires.db?.binding ?? {};
if (!serviceName && instance) {
serviceName = instance;
serviceKeyName = key;
}
if (!serviceName && hasVCAPEnv) {
await mkdirp(dest);
} else {
const { cfServiceInstanceName, cfServiceInstanceKeyName, serviceKey } = await this._getOrCreateCFService(dest, serviceName, serviceKeyName, tunnelAddress);
serviceName ??= cfServiceInstanceName;
serviceKeyName ??= cfServiceInstanceKeyName
if (!vcapEnv.VCAP_SERVICES || serviceName) {
const base = process.env.VCAP_SERVICES ? JSON.parse(process.env.VCAP_SERVICES) : (vcapEnv.VCAP_SERVICES ?? {});
const hanas = Array.isArray(base.hana) ? base.hana : [];
let hanaTagged = hanas.filter(s => s?.name === cfServiceInstanceName || (Array.isArray(s?.tags) && s.tags.includes('hana')));
if (hanaTagged.length === 0) {
const entry = { name: cfServiceInstanceName, label: 'hana', tags: ['hana'], credentials: {} };
hanas.push(entry);
hanaTagged = [entry];
}
for (const entry of hanaTagged) {
entry.name ??= cfServiceInstanceName;
entry.tags = Array.from(new Set([...entry.tags || [], 'hana']));
entry.credentials = { ...(entry.credentials || {}), ...serviceKey };
}
base.hana = hanas;
vcapEnv.VCAP_SERVICES = base;
process.env.VCAP_SERVICES = JSON.stringify(base);
}
if (!hasVCAPEnv) {
bindOptions = {
...options,
to: `${serviceName}:${serviceKeyName}`,
for: options.for,
kind: BUILD_TASK_HANA
}
}
}
}
console.log(`${bold('starting deployment to SAP HANA ...')}`);
DEBUG?.({ dest, vcapEnv, hdiOptions })
await hdi.deploy(dest, vcapEnv, hdiOptions);
if (!hasVCAPEnv && !usesExistingK8sBinding) {
const services = await bind(bindOptions)
await storeBindings(services, bindOptions)
}
const { env4 } = require('../../init/projectReader')
if (env4('production').requires?.db?.kind !== 'hana') {
console.log(`use ${info('cds add hana')} to configure the project for SAP HANA.\n`);
}
console.log(`successfully finished deployment`);
if (warnings.length > 0) {
console.warn(warn('\nWARNING:'));
console.warn(warn(warnings.join('\n')));
}
}
async _runCompileToHana(options) {
const csn = await cds.load('*');
const files = cds.compile.to.hana(csn, options);
const outDir = path.resolve(cds.root, options.out);
for (const [content, { file }] of files) {
const filePath = path.join(outDir, file);
await cds.utils.write(content).to(filePath);
}
}
async _getOrCreateCFService(currentModelFolder, serviceName, serviceKeyName, tunnelAddress) {
// get from param
let cfServiceInstanceMta;
let cfServiceInstanceName;
if (serviceName) {
cfServiceInstanceName = serviceName;
} else {
const modelName = path.basename(currentModelFolder);
const cfServiceDescriptor = await mtaUtil.getHanaDbModuleDescriptor(cds.root, modelName);
cfServiceInstanceName = cfServiceDescriptor.hdiServiceName;
cfServiceInstanceMta = cfServiceDescriptor.hdiService
}
console.log();
// valid service name chars: alpha-numeric, hyphens, and underscores
if (/[^\w-_]+/g.exec(cfServiceInstanceName)) {
throw `Service name ${cfServiceInstanceName} must only contain alpha-numeric, hyphens, and underscores.`;
}
console.log(`using container ${bold(cfServiceInstanceName)}`);
const cfConfig = cfServiceInstanceMta?.parameters?.config;
const serviceInstance = await cfUtil.getOrCreateService('hana', 'hdi-shared', cfServiceInstanceName, cfConfig);
const cfServiceInstanceKeyName = serviceKeyName ?? `${cfServiceInstanceName}-key`;
let credentials = await cfUtil.getOrCreateServiceKey(serviceInstance, cfServiceInstanceKeyName, { permissions: 'development' });
this._validateCredentials(credentials, cfServiceInstanceKeyName);
if (tunnelAddress) {
console.log(`Using tunnel address ${bold(tunnelAddress)} (beta feature)`);
credentials = this._injectTunnelAddress(credentials, tunnelAddress)
}
return { cfServiceInstanceName, cfServiceInstanceKeyName, serviceKey: credentials, serviceInstance }
}
_validateCredentials(credentials, cfServiceInstanceKey) {
if (!credentials) {
throw `Could not create service key ${bold(cfServiceInstanceKey)}.`;
}
if (credentials['sm_url']) {
throw `Service key credentials are matching to a Service Manager instance of a multitenant environment. Make sure there is no conflict with ${cfServiceInstanceKey}.`;
}
const fields = ['schema', 'user', 'password', 'url'];
for (const field of fields) {
if (!credentials[field]) {
throw `Service key is missing mandatory field '${field}'. Make sure you are ${bold('not')} using a managed service.`;
}
}
}
_injectTunnelAddress(credentials, tunnelAddress) {
if (!/\w+:\d+/.test(tunnelAddress)) {
throw `Invalid tunnel address '${tunnelAddress}' - must be in form 'host:port'`;
}
const [tunnelHost, tunnelPort] = tunnelAddress.split(':')
const { host, port } = credentials
credentials.host = tunnelHost
credentials.port = tunnelPort
credentials.url = credentials.url.replace(`${host}:${port}`, tunnelAddress)
credentials.hostname_in_certificate = host // make cert. verification happy, see xs2/hdideploy.js#527
credentials.url = credentials.url + (credentials.url.includes('?') ? '&' : '?') + 'hostNameInCertificate=' + host
return credentials
}
}