@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
327 lines (283 loc) • 12.4 kB
JavaScript
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
}
}