Skip to content

Commit

Permalink
CLI to generate runtime values file (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
anirudhprasad-sap authored Jun 27, 2024
1 parent 40a287c commit 7057d3d
Show file tree
Hide file tree
Showing 11 changed files with 376 additions and 16 deletions.
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ 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
> cds add cap-operator --with-mta <mta-yaml-file-path> --with-mta-extensions <mta-ext-yaml-file-path>
>```
> 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 <mta-yaml-file-path> --with-mta-extensions <mta-ext-yaml-file-path> --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:
Expand Down Expand Up @@ -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 <input-yaml-path>
```

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 <namespace> <release-name> <project-path>/gen/chart -f <runtime-values.yaml-path>
Expand All @@ -84,8 +121,8 @@ To integrate the CAP Operator Plugin into your project, follow these steps:
>```sh
> helm upgrade -i -n <namespace> <release-name> <project-path>/gen/chart --set-file serviceInstances.xsuaa.jsonParameters=<project-path>/xs-security.json -f <runtime-values.yaml-path>
>```
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

Expand Down
166 changes: 166 additions & 0 deletions bin/cap-op-plugin.js
Original file line number Diff line number Diff line change
@@ -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 <command> [--with-input-yaml <input-yaml-path>]
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 }
32 changes: 32 additions & 0 deletions files/runtime-values.yaml.hbs
Original file line number Diff line number Diff line change
@@ -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}}
3 changes: 2 additions & 1 deletion lib/add.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
66 changes: 56 additions & 10 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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 }
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 7057d3d

Please sign in to comment.