UNPKG

@sap/cds-dk

Version:

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

345 lines (289 loc) 12.5 kB
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 } }