@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
424 lines (355 loc) • 15.3 kB
JavaScript
const cp = require('child_process');
const fsp = require('fs').promises;
const os = require('os');
const path = require('path');
const util = require('util');
const axios = require('axios');
const IS_WIN = os.platform() === 'win32';
const execAsync = util.promisify(cp.exec);
const cds = require('../cds')
const LOG = cds.log ? cds.log('cli') : console;
const DEBUG = cds.debug('cli');
const { bold, warn } = require('./term');
const { URLS } = require('../init/constants');
const CF_COMMAND = 'cf';
const POLL_COUNTER = 40;
const POLL_DELAY = 2500; //ms
const OPERATION_STATE_INITIAL = 'initial';
const OPERATION_STATE_IN_PROGRESS = 'in progress';
const OPERATION_STATE_FAILED = 'failed';
const OPERATION_STATE_SUCCEEDED = 'succeeded';
const CF_CLIENT_MINIMUM_VERSION = 8;
const ENCODED_COMMA = encodeURIComponent(',');
module.exports = new class CfUtil {
async _sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async cfRun(...args) {
args = args.map(arg => arg.replace(/"/g, '\\"'));
const cmdLine = `${CF_COMMAND} "${args.join('" "')}"`;
DEBUG?.time(cmdLine);
DEBUG?.('>>>', cmdLine);
try {
const result = await execAsync(cmdLine, {
shell: IS_WIN,
stdio: ['inherit', 'pipe', 'inherit']
});
result.stdout = result.stdout?.trim();
result.stderr = result.stderr?.trim();
return result;
} catch (err) {
err.stdout = err.stdout?.trim();
err.stderr = err.stderr?.trim();
if (err.stderr.includes('command')) {
throw err.message + `\nPlease install the Cloud Foundry CLI from ${URLS.CF_CLI}`;
}
throw err;
} finally {
DEBUG?.timeEnd(cmdLine);
}
}
async curl(urlPath, queryObj, bodyObj) {
const url = this.#target.apiEndpoint + urlPath;
const options = {
headers: { 'Authorization': this.#target.token }
};
if (queryObj && Object.keys(queryObj).length > 0) {
const params = {};
for (const [k, v] of Object.entries(queryObj)) {
// cf rest API does not accept comma in query parameters
params[k] = v.replaceAll(',', ENCODED_COMMA);
}
options.params = params;
}
try {
let response;
if (bodyObj) {
response = (await axios.post(url, bodyObj, options));
} else {
response = (await axios.get(url, options));
}
if (LOG._debug) {
options.headers.Authorization = 'bearer <token>';
LOG.debug(`POST ${url}\n${JSON.stringify(options, null, 4)}\n${JSON.stringify(response.data, null, 4)}`);
}
return response.data;
} catch (err) {
if (err instanceof axios.AxiosError) {
if (err.response?.data?.errors[0]) {
throw JSON.stringify(err.response.data.errors[0], null, 4);
}
throw `${err.name} ${err.code} ${err.status||''}: ${err.message} while accessing ${url}`;
}
}
}
_extract(string, pattern, errorMsg) {
const match = string.match(pattern);
if (match?.[1]) {
return match[1];
}
throw errorMsg;
}
async _getCfTargetFromConfigFile() {
const cfHome = process.env.CF_HOME || process.env.cf_home || path.join(os.homedir(), '.cf');
try {
const fileContent = await fsp.readFile(path.join(cfHome, 'config.json'));
const config = JSON.parse(fileContent);
if (config) {
return {
apiEndpoint: this._extract(config.Target, /\s*(.+)\s*/, `CF API endpoint is missing. Use 'cf login' to login.`),
org: this._extract(config.OrganizationFields.Name, /\s*(.+)\s*/, `CF org is missing. Use 'cf target -o <ORG>' to specify.`),
space: this._extract(config.SpaceFields.Name, /\s*(.+)\s*/, `CF space is missing. Use 'cf target -s <SPACE>' to specify.`)
}
}
} catch (err) {
LOG.debug(`getCfTargetFromConfigFile: ${err}`);
}
}
#cfTargetPromise
async _getCfTargetFromCli() {
if (this.#cfTargetPromise) return this.#cfTargetPromise
this.#cfTargetPromise = (async () => {
const result = await this.cfRun('target')
if (result?.stdout) {
return {
apiEndpoint: this._extract(result.stdout, /api endpoint\s*:\s*([^\s]+)/i, `CF API endpoint is missing. Use 'cf login' to login.`),
user: this._extract(result.stdout, /user\s*:\s*(.+)/i, `CF user is missing. Use 'cf login' to login.`),
org: this._extract(result.stdout, /org\s*:\s*(.+)/i, `CF org is missing. Use 'cf target -o <ORG>' to specify.`),
space: this._extract(result.stdout, /space\s*:\s*(.+)/i, `CF space is missing. Use 'cf target -s <SPACE>' to specify.`),
}
}
})()
return this.#cfTargetPromise
}
#token
#tokenPromise
async _token() {
if (this.#token) return this.#token
if (!this.#tokenPromise) {
this.#tokenPromise = this.cfRun('oauth-token')
.then(result => {
this.#token = result.stdout
return this.#token
})
.catch(err => {
this.#tokenPromise = null
throw err.message
})
}
return this.#tokenPromise
}
#cfVersionPromise
async _checkCliVersion() {
if (this.#cfVersionPromise) return this.#cfVersionPromise
this.#cfVersionPromise = (async () => {
const result = await this.cfRun('-v');
const version = result?.stdout?.match(/version.*(\d+\.\d+\.\d+)/i);
if (parseInt(version?.[1]) < CF_CLIENT_MINIMUM_VERSION) {
console.log(warn(`
[Warning] You are using Cloud Foundry client version ${version[1]}. We recommend version ${CF_CLIENT_MINIMUM_VERSION} or higher.
Deployment will stop in the near future for Cloud Foundry client versions < ${CF_CLIENT_MINIMUM_VERSION}.
`))
}
})()
return this.#cfVersionPromise
}
#target
async target() {
if (!this.#target) {
await this._checkCliVersion();
const token = await this._token();
const target = await this._getCfTargetFromConfigFile() || await this._getCfTargetFromCli();
target.token = token;
this.#target = target;
const { org, space } = target;
const orgs = await this.curl('/v3/organizations', { names: org });
if (!orgs?.resources?.length) {
throw `CF org ${bold(org)} not found!`;
}
const orgGuid = orgs.resources[0].guid;
const spaces = await this.curl('/v3/spaces', { names: space, organization_guids: orgGuid });
if (!spaces?.resources?.length) {
throw `CF space ${bold(space)} not found in org ${bold(org)}!`;
}
const spaceGuid = spaces.resources[0].guid;
this.#target = {
...target,
orgGuid,
spaceGuid
}
}
return this.#target;
}
async services(app) {
const env = await this.env(app)
return Object.values(env.system_env_json.VCAP_SERVICES).flat();
}
async env(app) {
const { spaceGuid: space_guids, orgGuid: organization_guids } = await this.target();
const apps = await this.curl('/v3/apps', { space_guids, organization_guids, names: app });
if (apps.resources.length < 1) throw `cannot find app with name '${app}'`
const guid = apps.resources[0].guid;
return await this.curl('/v3/apps/' + guid + '/env', { space_guids, organization_guids, names: app });
}
async service(serviceName) {
const target = await this.target();
let counter = POLL_COUNTER;
while (counter > 0) {
counter--;
const serviceInstances = await this.curl('/v3/service_instances', {
names: serviceName,
space_guids: target.spaceGuid,
organization_guids: target.orgGuid
});
if (!serviceInstances?.resources?.length) {
return null;
}
const serviceInstance = serviceInstances.resources[0];
switch (serviceInstance?.last_operation?.state?.toLowerCase()) {
case OPERATION_STATE_INITIAL:
case OPERATION_STATE_IN_PROGRESS:
await this._sleep(POLL_DELAY);
break;
case OPERATION_STATE_SUCCEEDED:
return serviceInstance;
case OPERATION_STATE_FAILED:
if (!LOG._debug && serviceInstance?.last_operation?.description) {
throw serviceInstance.last_operation.description;
}
throw `the returned service reported state '${OPERATION_STATE_FAILED}'.\n${JSON.stringify(serviceInstance, null, 4)}`;
default:
console.error(`unsupported server response state '${serviceInstance?.last_operation?.state}' – waiting for next response`);
break;
}
}
throw `timeout occurred while getting service ${bold(serviceName)}`;
}
async getOrCreateService(serviceOfferingName, planName, serviceName, options) {
const probeService = await this.service(serviceName);
if (probeService) {
console.log(`getting service ${bold(serviceName)}`);
return probeService;
}
console.log(`creating service ${bold(serviceName)} - please be patient...`);
const target = await this.target();
const servicePlan = await this.curl(`/v3/service_plans`, {
names: planName,
space_guids: target.spaceGuid,
organization_guids: target.orgGuid,
service_offering_names: serviceOfferingName
});
if (!servicePlan?.resources?.length) {
throw `no service plans found for ${planName} in service offering ${serviceOfferingName}`;
}
const body = {
type: 'managed',
name: serviceName,
tags: [serviceOfferingName],
relationships: {
space: {
data: {
guid: target.spaceGuid
}
},
service_plan: {
data: {
guid: servicePlan.resources[0].guid
}
}
}
}
if (options) {
body.parameters = { ...options };
}
const postResult = await this.curl('/v3/service_instances', undefined, body);
if (postResult?.errors) {
throw postResult.errors[0].detail;
}
const newService = await this.service(serviceName);
if (newService) {
return newService;
}
throw `Could not create service ${bold(serviceName)}`;
}
async _getServiceKey(serviceInstance, serviceKeyName) {
let counter = POLL_COUNTER;
while (counter > 0) {
counter--;
const bindings = await this.curl(`/v3/service_credential_bindings`, { names: serviceKeyName, service_instance_guids: serviceInstance.guid });
if (!bindings?.resources?.length) {
return null;
}
const binding = bindings.resources[0];
switch (binding?.last_operation?.state?.toLowerCase()) {
case OPERATION_STATE_INITIAL:
case OPERATION_STATE_IN_PROGRESS:
await this._sleep(POLL_DELAY);
break;
case OPERATION_STATE_SUCCEEDED: {
const keyDetails = await this.curl(`/v3/service_credential_bindings/${encodeURIComponent(binding.guid)}/details`);
return keyDetails.credentials;
}
case OPERATION_STATE_FAILED:
if (!LOG._debug && binding?.last_operation?.description) {
throw binding.last_operation.description;
}
throw `The returned binding reported state '${OPERATION_STATE_FAILED}'.\n${JSON.stringify(binding, null, 4)}`;
default:
console.error(`unsupported server response state '${binding?.last_operation?.state}'. Waiting for next response.`);
break;
}
}
throw `Timeout occurred while getting service key ${bold(serviceKeyName)}`;
}
async getOrCreateServiceKey(serviceInstance, serviceKeyName, parameters, { silent = false } = {}) {
const serviceKey = await this._getServiceKey(serviceInstance, serviceKeyName);
if (serviceKey) {
DEBUG?.(`getting service key ${bold(serviceKeyName)}`)
return serviceKey;
}
if (!silent) console.log(`creating service key ${bold(serviceKeyName)} - please be patient...`);
const body = {
type: 'key',
name: serviceKeyName,
relationships: {
service_instance: {
data: {
guid: serviceInstance.guid
}
}
},
parameters
}
const postResult = await this.curl('/v3/service_credential_bindings', undefined, body);
if (postResult?.errors) {
throw postResult.errors[0].detail;
}
const newServiceKey = await this._getServiceKey(serviceInstance, serviceKeyName);
if (newServiceKey) {
return newServiceKey;
}
throw `could not create service key ${bold(serviceKeyName)}`;
}
async org(orgName) {
const result = await this.curl('/v3/organizations', { names: orgName })
return result?.resources?.[0]
}
async space(orgGuid, spaceName) {
const result = await this.curl('/v3/spaces', { names: spaceName, organization_guids: orgGuid })
return result?.resources?.find(r => r.relationships.organization.data.guid === orgGuid)
}
async instance(spaceGuid, instanceName) {
const result = await this.curl('/v3/service_instances', { names: instanceName, space_guids: spaceGuid })
return result?.resources?.find(r => !!r.relationships.space.data.guid)
}
async plan(planGuid) {
const result = await this.curl('/v3/service_plans', { guids: planGuid })
return result?.resources?.[0]
}
async offering(offeringGuid) {
const result = await this.curl('/v3/service_offerings', { guids: offeringGuid })
return result?.resources?.[0]
}
}