UNPKG

@sap/cds-dk

Version:

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

424 lines (355 loc) 15.3 kB
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] } }