UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

328 lines (271 loc) 11.7 kB
const path = require('path'); const fs = require('fs').promises; 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 gitUtil = require('../../util/git'); const mtaUtil = require('./mtaUtil'); const { bold, info, warn } = require('../../util/term'); const { bind, storeBindings } = require('../../bind/shared'); const { BUILD_TASK_HANA, OUTPUT_MODE, OUTPUT_MODE_FILESYSTEM, OUTPUT_MODE_RESULT } = require('../../build/constants'); const DEBUG = cds.debug('cli'); 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 (err) { throw new Error(`Error parsing environment variable ${varName}`); } } } return result; } async deploy(options = {}) { let { model, serviceName: pServiceName, tunnelAddress, vcapFile, undeployWhitelist, hdiOptions = {}, dry, storeCredentials } = 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 (dry) { console.log('--- running in dry mode ---'); } 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.`); } } let buildResult if (cds.cli.options?.['no-build']) { console.log(`Provided flag '--no-build', skipping cds build`) } else { buildResult = await this._build(); } if (process.env.TARGET_CONTAINER) { vcapEnv.TARGET_CONTAINER = process.env.TARGET_CONTAINER; } if (dry) { if (!buildResult) return const sortedResult = Array.from(buildResult.hana).sort(); for (const file of sortedResult) { const srcPath = path.join(buildResult.dest, file); console.log(`-- ${path.relative(cds.root, srcPath)}`); console.log(await read(srcPath)); console.log(); } } else { // the primary database is always built and deployed const dest = path.join(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 if (cds.cli.options.on === 'k8s') { const cp = require('node:child_process') const appName = pServiceName ?? path.basename(cds.root) const instance = `apiVersion: services.cloud.sap.com/v1 kind: ServiceInstance metadata: name: ${appName}-db spec: serviceOfferingName: hana servicePlanName: hdi-shared\n` const binding = `apiVersion: services.cloud.sap.com/v1 kind: ServiceBinding metadata: name: ${appName}-db-binding spec: serviceInstanceName: ${appName}-db secretName: ${appName}-db-secret \n` try { cp.execSync('kubectl apply -f -', { input: instance }) cp.execSync('kubectl apply -f -', { input: binding }) cp.execSync(`kubectl wait --for=condition=Ready servicebinding/${appName}-db-binding --timeout=120s`) cp.execSync(`kubectl wait --for=condition=Ready serviceinstance/${appName}-db --timeout=120s`) } catch (error) { const descr = cp.execSync(`kubectl describe serviceinstance/${appName}-db`).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/${appName}-db-secret -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: `${appName}-db-binding`, 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) { vcapEnv.VCAP_SERVICES = this._getVCAPServicesEntry(cfServiceInstanceName, serviceKey); } if (storeCredentials) { await this._addInstanceToDefaultEnvJson([dest, cds.root], cfServiceInstanceName, serviceKey); } 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 (storeCredentials) { await gitUtil.ensureFileIsGitignored('default-env.json', cds.root); } if (!hasVCAPEnv) { 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'))); } return buildResult; } 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.`; } } } async _build() { const buildTaskOptions = { for: BUILD_TASK_HANA, src: cds.env.folders.db, [OUTPUT_MODE]: OUTPUT_MODE_FILESYSTEM | OUTPUT_MODE_RESULT } // cds build throws an error if the src folder does not exist const buildResult = await build(buildTaskOptions); return buildResult[0].result; } async _addInstanceToDefaultEnvJson(currentFolders, serviceInstanceName, serviceKey) { for (const currentFolder of currentFolders) { const defaultEnvJsonPath = path.join(currentFolder, 'default-env.json'); const defaultEnvJson = exists(defaultEnvJsonPath) ? await read(defaultEnvJsonPath) : {}; defaultEnvJson.VCAP_SERVICES ??= {}; for (const [serviceKey, services] of Object.entries(defaultEnvJson.VCAP_SERVICES)) { defaultEnvJson.VCAP_SERVICES[serviceKey] = services.filter(service => service.name !== serviceInstanceName); } const hanaEntry = this._getVCAPServicesEntry(serviceInstanceName, serviceKey) Object.assign(defaultEnvJson.VCAP_SERVICES, hanaEntry); console.log(`Writing ${defaultEnvJsonPath}`); await fs.mkdir(path.dirname(defaultEnvJsonPath), { recursive: true }) await fs.writeFile(defaultEnvJsonPath, JSON.stringify(defaultEnvJson, null, 2)); } } _getVCAPServicesEntry(serviceInstanceName, serviceKey) { return { hana: [ { name: serviceInstanceName, tags: ['hana'], credentials: serviceKey } ] }; } _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 } }