UNPKG

@sap/cds-dk

Version:

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

327 lines (283 loc) 12.4 kB
module.exports = Object.assign(up, { options: ['--to', '--namespace', '--overlay'], shortcuts: ['-2', '-n'], flags: [], help: ` # SYNOPSIS *cds up* Builds and deploys the application to Cloud Foundry or Kubernetes. # OPTIONS *-2* | *--to* cf|k8s The platform to deploy to. Defaults to *k8s* when Helm charts are present and no *mta.yaml* exists, otherwise *cf*. *--overlay* <file> (Cloud Foundry only) Use a deployment overlay. Path to an MTA extension descriptor (*.mtaext*). Optionally omit the *.mtaext* suffix. *-n* | *--namespace* <name> (Kubernetes only) Target namespace. If it does not yet exist it will be created automatically via *helm --create-namespace*. # EXAMPLES *cds up* *cds up --to k8s* *cds up --to k8s --namespace e2e-tests* *cds up --to cf --overlay .deploy/mtaext/eu10.mtaext* `}) const { execSync } = require('node:child_process') const { tmpdir } = require('node:os') const crypto = require('node:crypto') const cds = require('../lib/cds') const { highlight, warn, dim, info, bold, link } = require('../lib/util/term') const { exists, fs, path, path: { join }, read, write } = cds.utils const { ask4 } = require('../lib/util/question') const { readProject } = require('../lib/init/projectReader') const { mkdirp, copy } = cds.utils const { containerize } = require('../bin/util/containerize') const { calcDirChecksum } = require('../lib/util/checksum') const { URLS } = require('../lib/init/constants') const DEBUG = /\b(y|all|cli|up)\b/.test(process.env.DEBUG) ? console.debug : undefined const run = (cmd, { continueOnFail = false, silent = false } = {}) => { const redactedCmd = cmd.replace(/(--docker-password=)(['"]?)[^'"\s]+(['"]?)/, '$1[REDACTED]$3') console.log(highlight(redactedCmd)) try { return execSync(cmd, { stdio: silent ? 'pipe' : 'inherit', env: { ...process.env, FORCE_COLOR: cds.utils.colors.enabled } }) } catch (error) { if (continueOnFail) { if (!silent) console.error(error.message) return } throw error.message } } /** * Get or create checksum file path in temp directory for UI5 apps * @param {string} appName - The name of the app * @returns {string} - Path to checksum file in temp directory */ function getChecksumFilePath(appName) { const projectHash = crypto.createHash('md5').update(cds.root).digest('hex').substring(0, 8) return join(tmpdir(), `cds-ui5-checksum-${projectHash}-${appName}.txt`) } /** * Read stored checksum for an app from temp directory * @param {string} appName - The name of the app * @returns {string|null} - The stored checksum or null if not found */ function readStoredChecksum(appName) { const checksumFile = getChecksumFilePath(appName) if (exists(checksumFile)) { try { return fs.readFileSync(checksumFile, 'utf-8').trim() } catch { // Ignore read errors and assume no checksum is stored } } return null } /** * Write checksum for an app to temp directory * @param {string} appName - The name of the app * @param {string} checksum - The checksum to store */ function writeStoredChecksum(appName, checksum) { const checksumFile = getChecksumFilePath(appName) fs.writeFileSync(checksumFile, checksum, 'utf-8') } const _mtaextForOverlay = overlay => { if (!overlay) return if (exists(overlay)) return overlay if (!overlay.endsWith('.mtaext')) { const file = `${overlay}.mtaext` if (exists(file)) return file } } async function up() { const project = readProject() const { apps, appName, hasApprouter, hasXsuaa, isMonorepoMicroservice, hasKyma, hasMta, k8s } = project const toKyma = cds.cli.options.to === 'k8s' || hasKyma && !hasMta, toCf = !toKyma if (cds.cli.options.overlay && toKyma) { console.log(warn(`Ignoring --overlay ${cds.cli.options.overlay} (Cloud Foundry only)`)) } if (toCf) { if (!hasCommand('mbt')) { throw `Missing 'mbt' command. Run ${bold('npm install -g mbt')} to install the Cloud MTA Build Tool.` } if (!hasCommand('cf')) { throw `Missing 'cf' command. Refer to ${link(URLS.CF_INSTALL)} to install the Cloud Foundry CLI.` } } else if (toKyma) { if (!hasCommand('docker')) { throw `Missing 'docker' command. Refer to ${link(URLS.DOCKER_INSTALL)} to install Docker.` } if (!hasCommand('helm')) { throw `Missing 'helm' command. Refer to ${link(URLS.HELM_INSTALL)} to install Helm.` } if (!hasCommand('kubectl')) { throw `Missing 'kubectl' command. Refer to ${link(URLS.KUBECTL_INSTALL)} to install kubectl.` } } for (const { app } of apps) { const appPath = path.join(cds.root, cds.env.folders.app, app) if (exists(path.join(appPath, 'package.json'))) { if (!exists(path.join(appPath, 'package-lock.json'))) { run(`npm i --prefix ${appPath}`) } else if (process.env.CI) { run(`npm ci --prefix ${appPath}`) } } } if (exists('mtx/sidecar') && !exists('mtx/sidecar/package-lock.json')) { run('npm i --package-lock-only --prefix mtx/sidecar') } const app = cds.env.folders.app const legacyToPreferred = { [join(app, 'router')]: '.deploy/app-router', [join(app, 'portal')]: '.deploy/portal', [join(app, 'html5-deployer')]: '.deploy/html5-deployer' } for (const [legacy, preferred] of Object.entries(legacyToPreferred)) { const root = exists(preferred) ? preferred : legacy if (exists(join(root, 'package.json')) && !exists(join(root, 'package-lock.json'))) { run(`npm i --package-lock-only --prefix ${root}`) } } if (toKyma) { if (!exists('chart') || !exists('containerize.yaml')) run('cds add kyma') if (project.hasHtml5Repo) { const html5DeployerPath = join(cds.root, cds.env.folders.app, 'html5-deployer', 'resources') if (apps.length > 0) { await mkdirp(html5DeployerPath) } for (const { app, archiveName } of apps) { if (!archiveName) continue const appPath = path.join(cds.root, cds.env.folders.app, app) const zipPath = path.join(appPath, `dist/${archiveName}`) // Calculate checksum for the app source folder (excluding node_modules and dist) const currentChecksum = await calcDirChecksum( appPath, (dir) => !dir.includes('node_modules') && !dir.includes('dist') ) const storedChecksum = readStoredChecksum(app) const needsBuild = currentChecksum !== storedChecksum || !exists(zipPath) if (needsBuild || process.env.SKIP_CHECK == 'true') { DEBUG?.(dim(`build required for '${app}' – checksums`, { old: storedChecksum, new: currentChecksum })) run(`npm run build --prefix ${appPath}`) await copy(zipPath).to(html5DeployerPath, `${app}.zip`) writeStoredChecksum(app, currentChecksum) } else { console.log(info(`Skipping build for UI5 app '${app}' - no changes detected (checksum: ${currentChecksum.substring(0, 8)}...)`)) } } } } else { if (!exists('mta.yaml')) { run('cds add mta') } if (!exists('package-lock.json')) { run('npm i --package-lock-only') } } if (isMonorepoMicroservice) { const { npmWorkspaceRoot } = project const source = path.join(npmWorkspaceRoot, 'package-lock.json') const target = path.join(process.cwd(), 'package-lock.json') if (exists(target) && fs.lstatSync(target).isSymbolicLink()) fs.unlinkSync(target) const relative = path.relative(process.cwd(), source) if (!exists(target)) fs.symlinkSync(relative, target, 'junction') } if (toKyma) { const ns = cds.cli.options.namespace const nsHelm = ns ? `--namespace ${ns}` : '' const nsKubectl = ns ? `-n ${ns}` : '' run('npm i --package-lock') run('cds build --production') if (ns) { const k8sNamespaceResult = run(`kubectl get namespace ${ns} -o jsonpath="{.metadata.name}"`, { continueOnFail: true, silent: true }) if (!k8sNamespaceResult) { run(`kubectl create namespace ${ns}`) } } const isValidDomain = str => /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(str) const { image, imagePullSecret } = k8s.values?.global ?? {} let registry = image?.registry if (!process.env.CI) { if (!registry || !isValidDomain(registry)) { const valuesPath = 'chart/values.yaml' const [yn] = await ask4([warn(`invalid registry server '${registry}' detected, override in ${valuesPath}? (Y/n)`)]) if (yn?.toLowerCase() !== 'y' || process.env.CI) { throw `registry parameter is invalid, please specify it in ${valuesPath}` } else { registry = await ask4(['registry server (e.g. index.docker.io): ']) if (!k8s.values.global.image) k8s.values.global.image = {} k8s.values.global.image.registry = registry const values = (await read(valuesPath)).replace(/(registry:\s*).*/, `$1${registry}`) await write(values).to(valuesPath) const containerize = (await read('containerize.yaml')).replace(/(repository:\s*).*/, `$1${registry}`) await write(containerize).to('containerize.yaml') } } if (imagePullSecret?.name) { const imagePullSecretResult = run(`kubectl get secret ${imagePullSecret.name} ${nsKubectl} -o jsonpath="{.metadata.name}"`, { continueOnFail: true, silent: true }) if (!imagePullSecretResult) { const yn = await ask4([`image pull secret '${imagePullSecret.name}' not found in namespace '${ns || 'default'}' – do you want to create it? (Y/n) `]) if (yn?.toLowerCase() !== 'y' && yn !== '') { throw `image pull secret '${imagePullSecret.name}' is required to pull images from the registry` } else { const [username, email] = await ask4([ 'registry username: ', 'registry email: ' ]) const password = await ask4(['registry password: '], { redacted: true }) run(`kubectl create secret docker-registry ${imagePullSecret.name} ${nsKubectl} --docker-server=${registry} --docker-username=${username} --docker-password='${password}' --docker-email='${email}'`) } } } } const modules = await containerize() const helmArgs = [] modules?.forEach(m => { if (m.path === 'gen/srv') { helmArgs.push(`--set srv.annotations.deployment.deployment-checksum=${m.checksum}`) } if (m.path === 'app/router') { helmArgs.push(`--set approuter.annotations.deployment.deployment-checksum=${m.checksum}`) } if (m.path === 'app/html5-deployer') { helmArgs.push(`--set html5-apps-deployer.annotations.job.job-checksum=${m.checksum}`) } if (m.path === 'gen/db') { helmArgs.push(`--set hana-deployer.annotations.job.job-checksum=${m.checksum}`) } if (m.path === 'gen/mtx/sidecar') { helmArgs.push(`--set sidecar.annotations.deployment.deployment-checksum=${m.checksum}`) } }) let helmUpgrade = `helm upgrade --install ${appName} ./gen/chart ${nsHelm} --wait --wait-for-jobs --timeout=10m` if (hasXsuaa) helmArgs.push(' --set-file xsuaa.jsonParameters=xs-security.json') if (helmArgs.length > 0) helmUpgrade += ` ${helmArgs.join(' ')}` run(helmUpgrade) run(`kubectl rollout status deployment ${appName}-srv ${nsKubectl} --timeout=8m`) if (hasApprouter) run(`kubectl rollout status deployment ${appName}-approuter ${nsKubectl} --timeout=8m`) if (exists('mtx/sidecar')) run(`kubectl rollout status deployment ${appName}-sidecar ${nsKubectl} --timeout=8m`) } else { run('mbt build -t gen --mtar mta.tar') const overlayOption = cds.cli.options.overlay const overlay = overlayOption && _mtaextForOverlay(overlayOption) if (overlayOption && !overlay) console.log(`MTA extension descriptor not found at '${overlayOption}' - continuing with base mta.yaml`) const ext = overlay ? `-e ${overlay}` : '' const retries = process.env.CI ? '' : '--retries 0' run(`cf deploy gen/mta.tar ${ext} -f ${retries}`) } } const hasCommand = cmd => { try { execSync(process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`, { stdio: 'ignore' }) return true } catch (e) { DEBUG?.(e) return false } }