diff --git a/README.md b/README.md index 6c5cee4..c472181 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ To integrate the CAP Operator Plugin into your project, follow these steps: ``` > During `cds build`, the plugin will copy the templates folder into the final chart. + > ### ⚠️ Experimental > To add a chart folder with the values.yaml prefilled with the design-time deployment details from the mta and mta extensions, use: >```sh @@ -39,6 +40,11 @@ To integrate the CAP Operator Plugin into your project, follow these steps: >``` > If you have multiple mta extensions, you can pass them as a comma-separated string to merge them. + > During `cds build`, the plugin will automatically inject the templates folder into the final chart similar to command `cds add cap-operator`. But If you want to add the templates folder as well during chart folder creation, then you can use the `--with-templates` option as shown below: + >```sh + > cds add cap-operator --with-mta --with-mta-extensions --with-templates + >``` + 2. Once executed, the chart folder or chart folder with templates will be added to your project directory. 3. The `values.yaml` requires two types of details: @@ -74,7 +80,38 @@ To integrate the CAP Operator Plugin into your project, follow these steps: 4. After filling all the design-time information in `values.yaml`, run `cds build`. The final chart will be generated in the `gen` folder within your project directory. -5. Now to deploy the application, you can pass the runtime values in a separate `runtime-values.yaml` file and deploy the chart using the following command: +5. To deploy the application, you need to create `runtime-values.yaml` with all the runtime values as mentioned above. For that you can make use of the plugin itself. The plugins provides two ways to generate the runtime values file - + + * **Interactive Mode** - This mode will ask you for all the runtime values one by one. To use this mode, run the following command: + + ```sh + npx cap-op-plugin generate-runtime-values + ``` + + * **File Mode** - Via this mode you can provide all the required runtime values in a yaml file. To use this mode, run the following command: + + ```sh + npx cap-op-plugin generate-runtime-values --with-input-yaml + ``` + + Sample input yaml - + + ```yaml + appName: incidentapp + capOperatorSubdomain: cap-op + clusterDomain: abc.com + globalAccountId: abcdef-abcd-4ef1-9263-1b6b7b6b7b6b + providerSubdomain: provider-subdomain-1234 + tenantId: da37c8e0-74d4-abcd-b5e2-sd8f7d8f7d8f + hanaInstanceId: 46e285d9-abcd-4c7d-8ebb-502sd8f7d8f7d + imagePullSecret: regcred + ``` + + Similar to the interactive mode, `appName`, `capOperatorSubdomain`, `clusterDomain`, `globalAccountId`, `providerSubdomain`, and `tenantId` are mandatory fields. The plugin will throw an error if they are not provided in the input YAML. + + The `runtime-values.yaml` file will be created in the chart folder of your project directory. + +5. Now you can finally deploy the application using the following command: ```sh helm upgrade -i -n /gen/chart -f @@ -84,8 +121,8 @@ To integrate the CAP Operator Plugin into your project, follow these steps: >```sh > helm upgrade -i -n /gen/chart --set-file serviceInstances.xsuaa.jsonParameters=/xs-security.json -f >``` - -As a reference, you can check out the [CAP Operator helm chart](https://github.com/cap-js/incidents-app/tree/cap-operator-plugin/chart) in the sample incident app. And also the corresponding [runtime-values.yaml](https://github.com/cap-js/incidents-app/blob/cap-operator-plugin/runtime-values.yaml) file. + +As a reference, you can check out the [CAP Operator helm chart](https://github.com/cap-js/incidents-app/tree/cap-operator-plugin/chart) in the sample incident app. And also the corresponding [runtime-values.yaml](https://github.com/cap-js/incidents-app/blob/cap-operator-plugin/chart/runtime-values.yaml) file. ## ❗Things to Note diff --git a/bin/cap-op-plugin.js b/bin/cap-op-plugin.js new file mode 100755 index 0000000..b603411 --- /dev/null +++ b/bin/cap-op-plugin.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +const cds = require('@sap/cds-dk') +const yaml = require('@sap/cds-foss').yaml +const Mustache = require('mustache') +const { ask, mergeObj, isCAPOperatorChart } = require('../lib/util') + +const isCli = require.main === module +const SUPPORTED = { 'generate-runtime-values': ['--with-input-yaml'] } + +async function capOperatorPlugin(cmd, option, inputYamlPath) { + + try { + if (!cmd) return _usage() + if (!Object.keys(SUPPORTED).includes(cmd)) return _usage(`Unknown command ${cmd}.`) + if (option && !SUPPORTED[cmd].includes(option)) return _usage(`Invalid option ${option}.`) + if (option === '--with-input-yaml' && !inputYamlPath) return _usage(`Input yaml path is missing.`) + + if (cmd === 'generate-runtime-values') await generateRuntimeValues(option, inputYamlPath) + } catch (e) { + if (isCli) { + console.error(e.message) + process.exit(1) + } else throw e + } +} + +async function _handleError(message) { + if (isCli) { + console.error(message) + process.exit(1) + } + throw new Error(message) +} + +async function _usage(message = '') { + return _handleError(message + ` + +USAGE + + cap-op-plugin [--with-input-yaml ] + +COMMANDS + + generate-runtime-values generate runtime-values.yaml file for the cap-operator chart + +EXAMPLES + + cap-op-plugin generate-runtime-values + cap-op-plugin generate-runtime-values --with-input-yaml /path/to/input.yaml +` + ) +} + +async function generateRuntimeValues(option, inputYamlPath) { + + if (!((cds.utils.exists('chart') && isCAPOperatorChart('chart')))) { + throw new Error("No CAP Operator chart found in the project. Please run 'cds add cap-operator --force' to add the CAP Operator chart folder.") + } + + let answerStruct = {} + const { appName, appDescription } = getAppDetails() + + if (option === '--with-input-yaml' && inputYamlPath) { + + answerStruct = yaml.parse(await cds.utils.read(cds.utils.path.join(cds.root, inputYamlPath))) + + if (!answerStruct['appName'] || !answerStruct['capOperatorSubdomain'] || !answerStruct['clusterDomain'] || !answerStruct['globalAccountId'] || !answerStruct['providerSubdomain'] || !answerStruct['tenantId']) + throw new Error(`'appName', 'capOperatorSubdomain', 'clusterDomain', 'globalAccountId', 'providerSubdomain' and 'tenantId' are mandatory fields in the input yaml file.`) + + } else { + const questions = [ + ['Enter app name for deployment: ', appName, true], + ['Enter CAP Operator subdomain (In kyma cluster it is "cap-op" by default): ', 'cap-op', true], + ['Enter your cluster shoot domain: ', '', true], + ['Enter your global account ID: ', '', true], + ['Enter your provider subdomain: ', '', true], + ['Enter your provider tenant ID: ', '', true], + ['Enter your HANA database instance ID: ', '', false], + ['Enter your image pull secrets: ', '', false] + ] + + const answerKeys = [ + 'appName', 'capOperatorSubdomain', 'clusterDomain', + 'globalAccountId', 'providerSubdomain', 'tenantId', + 'hanaInstanceId', 'imagePullSecret' + ] + + const answer = await ask(...questions) + answerStruct = Object.fromEntries(answerKeys.map((key, index) => [key, answer[index]])) + } + + answerStruct['appDescription'] = appDescription ?? answerStruct['appName'] + + const valuesYaml = yaml.parse(await cds.utils.read(cds.utils.path.join(cds.root, 'chart/values.yaml'))) + + let runtimeValuesYaml = yaml.parse(Mustache.render(await cds.utils.read(cds.utils.path.join(__dirname, '../files/runtime-values.yaml.hbs')), answerStruct)) + + if (!answerStruct['imagePullSecret']) + delete runtimeValuesYaml['imagePullSecrets'] + + runtimeValuesYaml['workloads'] = {} + for (const [workloadKey, workloadDetails] of Object.entries(valuesYaml.workloads)) { + + runtimeValuesYaml['workloads'][workloadKey] = workloadDetails.deploymentDefinition + ? { "deploymentDefinition": { "env": workloadDetails.deploymentDefinition.env ?? [] } } + : { "jobDefinition": { "env": workloadDetails.jobDefinition.env ?? [] } } + + const cdsConfigHana = Mustache.render('{"requires":{"cds.xt.DeploymentService":{"hdi":{"create":{"database_id":"{{hanaInstanceId}}"}}}}}', answerStruct) + + if (workloadDetails?.deploymentDefinition?.type === 'CAP' && answerStruct['hanaInstanceId']) + updateCdsConfigEnv(runtimeValuesYaml, workloadKey, 'deploymentDefinition', cdsConfigHana) + + if (workloadDetails?.jobDefinition?.type === 'TenantOperation' && answerStruct['hanaInstanceId']) { + updateCdsConfigEnv(runtimeValuesYaml, workloadKey, 'jobDefinition', cdsConfigHana) + } + + if (workloadDetails?.deploymentDefinition?.type === 'Router') { + const index = runtimeValuesYaml['workloads'][workloadKey]['deploymentDefinition']['env'].findIndex(e => e.name === 'TENANT_HOST_PATTERN') + if (index > -1) + runtimeValuesYaml['workloads'][workloadKey]['deploymentDefinition']['env'][index] = { name: 'TENANT_HOST_PATTERN', value: '^(.*).' + answerStruct["appName"] + '.' + answerStruct["clusterDomain"] } + else + runtimeValuesYaml['workloads'][workloadKey]['deploymentDefinition']['env'].push({ name: 'TENANT_HOST_PATTERN', value: '^(.*).' + answerStruct["appName"] + '.' + answerStruct["clusterDomain"] }) + } + } + + // remove workload definition where env is empty + for (const [workloadKey, workloadDetails] of Object.entries(runtimeValuesYaml.workloads)) { + if (workloadDetails?.deploymentDefinition?.env.length === 0) { + delete runtimeValuesYaml['workloads'][workloadKey] + } + + if (workloadDetails?.jobDefinition?.env.length === 0) { + delete runtimeValuesYaml['workloads'][workloadKey] + } + } + + await cds.utils.write(yaml.stringify(runtimeValuesYaml)).to(cds.utils.path.join(cds.root, 'chart/runtime-values.yaml')) + console.log("Generated 'runtime-values.yaml' file in the 'chart' folder.") +} + +function updateCdsConfigEnv(runtimeValuesYaml, workloadKey, workloadDefintion, cdsConfigHana) { + const index = runtimeValuesYaml['workloads'][workloadKey][workloadDefintion]['env'].findIndex(e => e.name === 'CDS_CONFIG') + if (index > -1) { + // Get existing CDS_CONFIG and merge with new CDS_CONFIG for HANA + const existingCdsConfigJson = JSON.parse(runtimeValuesYaml['workloads'][workloadKey][workloadDefintion]['env'][index].value) + const mergedCdsConfig = mergeObj(existingCdsConfigJson, JSON.parse(cdsConfigHana)) + + runtimeValuesYaml['workloads'][workloadKey][workloadDefintion]['env'][index] = { name: 'CDS_CONFIG', value: JSON.stringify(mergedCdsConfig) } + } else + runtimeValuesYaml['workloads'][workloadKey][workloadDefintion]['env'].push({ name: 'CDS_CONFIG', value: cdsConfigHana }) +} + +function getAppDetails() { + const { name, description } = JSON.parse(cds.utils.fs.readFileSync(cds.utils.path.join(cds.root, 'package.json'))) + const segments = (name ?? this.appName).trim().replace(/@/g, '').split('/').map(encodeURIComponent) + return { appName: segments[segments.length - 1], appDescription: description } +} + +if (isCli) { + const [, , cmd, option, inputYamlPath] = process.argv; + (async () => await capOperatorPlugin(cmd, option, inputYamlPath ?? undefined))() +} + +module.exports = { capOperatorPlugin } diff --git a/files/runtime-values.yaml.hbs b/files/runtime-values.yaml.hbs new file mode 100644 index 0000000..cce5d03 --- /dev/null +++ b/files/runtime-values.yaml.hbs @@ -0,0 +1,32 @@ +serviceInstances: + saas-registry: + parameters: + xsappname: {{appName}} + appName: {{appName}} + displayName: {{appName}} + description: {{appDescription}} + appUrls: + getDependencies: "https://{{providerSubdomain}}.{{appName}}.{{clusterDomain}}/callback/v1.0/dependencies" + onSubscription: "https://{{capOperatorSubdomain}}.{{clusterDomain}}/provision/tenants/{tenantId}" + xsuaa: + parameters: + xsappname: {{appName}} + oauth2-configuration: + redirect-uris: + - "https://*{{appName}}.{{clusterDomain}}/**" +app: + domains: + primary: {{appName}}.{{clusterDomain}} + secondary: [] + istioIngressGatewayLabels: + istio: ingressgateway + app: istio-ingressgateway + +btp: + globalAccountId: {{globalAccountId}} + provider: + subdomain: {{providerSubdomain}} + tenantId: {{tenantId}} + +imagePullSecrets: + - {{imagePullSecret}} diff --git a/lib/add.js b/lib/add.js index 326e22e..f298478 100644 --- a/lib/add.js +++ b/lib/add.js @@ -12,6 +12,8 @@ const md5 = data => require('crypto').createHash('md5').update(data).digest('hex const MtaTransformer = require('./mta-transformer') const { isCAPOperatorChart } = require('./util') +const Mustache = require('mustache') + module.exports = class CapOperatorAddPlugin extends cds.add.Plugin { options() { @@ -115,7 +117,6 @@ module.exports = class CapOperatorAddPlugin extends cds.add.Plugin { const project = cds.add.readProject() const { hasDestination, hasHtml5Repo, hasXsuaa, hasApprouter, hasMultitenancy } = project - const Mustache = require('mustache') const valuesYaml = yaml.parse(await read(join(cds.root, 'chart/values.yaml'))) if (hasDestination) { diff --git a/lib/util.js b/lib/util.js index 9365163..38e0bc4 100644 --- a/lib/util.js +++ b/lib/util.js @@ -4,7 +4,7 @@ SPDX-License-Identifier: Apache-2.0 */ const cds = require('@sap/cds-dk') -const fs = require('fs') +const readline = require('readline') function replacePlaceholders(obj, replacements) { if (typeof obj === "object") { @@ -31,24 +31,70 @@ function replacePlaceholders(obj, replacements) { return obj } -function mergeObj(obj1, obj2) { - const mergedObj = { ...obj1 } +function _isObject(item) { + return item && typeof item === 'object' && !Array.isArray(item) +} - for (const key in obj2) { - if (obj2.hasOwnProperty(key)) { - mergedObj[key] = obj2[key] +function mergeObj(source, target) { + const unique = array => [...new Set(array.map(JSON.stringify))].map(JSON.parse) + if (_isObject(target) && _isObject(source)) { + for (const key in source) { + if (_isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: source[key] }) + else mergeObj(source[key], target[key]) + } else if (Array.isArray(source[key]) && Array.isArray(target[key])) { + target[key] = unique([...source[key], ...target[key]]) + } else { + Object.assign(target, { [key]: target[key] || source[key] }) } + } + } else if (Array.isArray(target) && Array.isArray(source)) { + target = unique([...source, ...target]) } - return mergedObj + return target ?? source } function isCAPOperatorChart(chartFolderPath) { try { - const chartYaml = cds.parse.yaml(fs.readFileSync(chartFolderPath + "/Chart.yaml").toString()) + const chartYaml = cds.parse.yaml(cds.utils.fs.readFileSync(chartFolderPath + "/Chart.yaml").toString()) return chartYaml.annotations?.["app.kubernetes.io/managed-by"] === 'cap-operator-plugin' || false - } catch(err) { + } catch (err) { return false } } -module.exports = { replacePlaceholders, mergeObj, isCAPOperatorChart } +async function ask(...args) { + const answers = [] + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }) + + function askQuestion(question, suggestion, mandatory) { + return new Promise((resolve) => { + const prompt = suggestion ? `${question} [${suggestion}] ` : `${question} ` + console.log() + rl.question(prompt, (answer) => { + const trimmedAnswer = answer.trim() + if (mandatory && !trimmedAnswer && !suggestion) { + // If the question is mandatory and no answer is provided, re-ask the question + console.error('\nThis question is mandatory. Please provide an answer.') + resolve(askQuestion(question, suggestion, mandatory)) + } else { + answers.push(trimmedAnswer || suggestion || '') + resolve() + } + }) + }) + } + + for (const [question, suggestion, mandatory] of args) { + await askQuestion(question, suggestion, mandatory) + } + + rl.close() + return answers +} + + +module.exports = { replacePlaceholders, mergeObj, isCAPOperatorChart, ask } diff --git a/package.json b/package.json index 801def1..95776e5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ }, "author": "SAP SE (https://www.sap.com)", "license": "SEE LICENSE", + "bin": { + "cap-op-plugin": "bin/cap-op-plugin.js" + }, "main": "cds-plugin.js", "files": [ "cds-plugin.js", diff --git a/test/add.test.js b/test/add.test.js index d2370b1..5824f01 100644 --- a/test/add.test.js +++ b/test/add.test.js @@ -107,4 +107,18 @@ describe('cds add cap-operator', () => { expect(getFileHash(join(__dirname,'files/expectedChart/valuesWithDestination.yaml'))).to.equal(getFileHash(join(bookshop, 'chart/values.yaml'))) }) + it('Generate runtime-values file', async () => { + await cds.utils.copy(join('test/files', 'input_values.yaml'), join(bookshop, 'input_values.yaml')) + execSync(`cds add cap-operator`, { cwd: bookshop }) + execSync(`npx cap-op-plugin generate-runtime-values --with-input-yaml input_values.yaml`, { cwd: bookshop }) + + expect(getFileHash(join(__dirname,'files/expectedChart/runtime-values.yaml'))).to.equal(getFileHash(join(bookshop, 'chart/runtime-values.yaml'))) + }) + + it('Generate runtime-values file using wrong input_values.yaml', async () => { + await cds.utils.copy(join('test/files', 'input_values_wrong.yaml'), join(bookshop, 'input_values_wrong.yaml')) + execSync(`cds add cap-operator`, { cwd: bookshop }) + + expect(() => execSync(`npx cap-op-plugin generate-runtime-values --with-input-yaml input_values_wrong.yaml`, { cwd: bookshop })).to.throw(`'appName', 'capOperatorSubdomain', 'clusterDomain', 'globalAccountId', 'providerSubdomain' and 'tenantId' are mandatory fields in the input yaml file.`) + }) }) diff --git a/test/files/expectedChart/runtime-values.yaml b/test/files/expectedChart/runtime-values.yaml new file mode 100644 index 0000000..722a8fa --- /dev/null +++ b/test/files/expectedChart/runtime-values.yaml @@ -0,0 +1,46 @@ +serviceInstances: + saas-registry: + parameters: + xsappname: bkshop + appName: bkshop + displayName: bkshop + description: A simple CAP project. + appUrls: + getDependencies: https://bem-aad-sadad-123456789012.bkshop.c-abc.kyma.ondemand.com/callback/v1.0/dependencies + onSubscription: https://cap-op.c-abc.kyma.ondemand.com/provision/tenants/{tenantId} + xsuaa: + parameters: + xsappname: bkshop + oauth2-configuration: + redirect-uris: + - https://*bkshop.c-abc.kyma.ondemand.com/** +app: + domains: + primary: bkshop.c-abc.kyma.ondemand.com + secondary: [] + istioIngressGatewayLabels: + istio: ingressgateway + app: istio-ingressgateway +btp: + globalAccountId: dc94db56-asda-adssa-dada-123456789012 + provider: + subdomain: bem-aad-sadad-123456789012 + tenantId: dasdsd-1234-1234-1234-123456789012 +imagePullSecrets: + - regcred +workloads: + app-router: + deploymentDefinition: + env: + - name: TENANT_HOST_PATTERN + value: ^(.*).bkshop.c-abc.kyma.ondemand.com + server: + deploymentDefinition: + env: + - name: CDS_CONFIG + value: '{"requires":{"cds.xt.DeploymentService":{"hdi":{"create":{"database_id":"sdasd-4c4d-4d4d-4d4d-123456789012"}}}}}' + tenant-job: + jobDefinition: + env: + - name: CDS_CONFIG + value: '{"requires":{"cds.xt.DeploymentService":{"hdi":{"create":{"database_id":"sdasd-4c4d-4d4d-4d4d-123456789012"}}}}}' diff --git a/test/files/expectedChart/valuesWithMTA.yaml b/test/files/expectedChart/valuesWithMTA.yaml index 01ecce7..c3a76da 100644 --- a/test/files/expectedChart/valuesWithMTA.yaml +++ b/test/files/expectedChart/valuesWithMTA.yaml @@ -156,12 +156,12 @@ workloads: type: null image: null env: - - name: SUBSCRIPTION_URL - value: ${protocol}://\${tenant_subdomain}.${app-url}-srv.${domain} - name: OTLP_TRACE_URL value: http://telemetry-otlp-traces.kyma-system:4318 - name: IS_MTXS_ENABLED value: "true" + - name: SUBSCRIPTION_URL + value: ${protocol}://\${tenant_subdomain}.${app-url}-srv.${domain} author-readings-mtx-srv: name: author-readings-mtx-srv consumedBTPServices: diff --git a/test/files/input_values.yaml b/test/files/input_values.yaml new file mode 100644 index 0000000..eeae9af --- /dev/null +++ b/test/files/input_values.yaml @@ -0,0 +1,8 @@ +appName: bkshop +capOperatorSubdomain: cap-op +clusterDomain: c-abc.kyma.ondemand.com +globalAccountId: dc94db56-asda-adssa-dada-123456789012 +providerSubdomain: bem-aad-sadad-123456789012 +tenantId: dasdsd-1234-1234-1234-123456789012 +imagePullSecret: regcred +hanaInstanceId: sdasd-4c4d-4d4d-4d4d-123456789012 diff --git a/test/files/input_values_wrong.yaml b/test/files/input_values_wrong.yaml new file mode 100644 index 0000000..7c64ebe --- /dev/null +++ b/test/files/input_values_wrong.yaml @@ -0,0 +1,7 @@ +capOperatorSubdomain: cap-op +clusterDomain: c-abc.kyma.ondemand.com +globalAccountId: dc94db56-asda-adssa-dada-123456789012 +providerSubdomain: bem-aad-sadad-123456789012 +tenantId: dasdsd-1234-1234-1234-123456789012 +imagePullSecret: regcred +hanaInstanceId: sdasd-4c4d-4d4d-4d4d-123456789012