@ui5/task-adaptation
Version:
Custom task for ui5-builder which allows building UI5 Flexibility Adaptation Projects for SAP BTP, Cloud Foundry environment
296 lines • 14.6 kB
JavaScript
import { cfCreateService, cfGetInstanceCredentials } from "@sap/cf-tools/out/src/cf-local.js";
import { getSpaceGuidThrowIfUndefined } from "@sap/cf-tools/out/src/utils.js";
import { Cli } from "@sap/cf-tools/out/src/cli.js";
import { eFilters } from "@sap/cf-tools/out/src/types.js";
import { getLogger } from "@ui5/logger";
const log = getLogger("@ui5/task-adaptation::CFUtil");
export default class CFUtil {
/**
* Get or create service keys for service instance found by query
* @static
* @param {IGetServiceInstanceParams} getServiceInstanceParams query parameters to find a service instance by
* @param {ICreateServiceInstanceParams} [createServiceInstanceParams] parameters to create a service instance
* @return {Promise<IServiceKeys>} promise with service keys
* @memberof CFUtil
*/
static async getServiceInstanceKeys(getServiceInstanceParams, createServiceInstanceParams) {
let serviceInstances = await this.getServiceInstance(getServiceInstanceParams);
if (!(serviceInstances?.length > 0) && createServiceInstanceParams) {
await this.createService(createServiceInstanceParams);
serviceInstances = await this.getServiceInstance(getServiceInstanceParams);
}
if (!(serviceInstances?.length > 0)) {
throw new Error(`Cannot find '${getServiceInstanceParams.names?.join(", ")}' service in current space: ${getServiceInstanceParams.spaceGuids?.join(", ")}`);
}
// we can use any instance in the list to connect to HTML5 Repo
log.verbose(`Use '${serviceInstances[0].name}' HTML5 Repo Runtime service instance`);
const serviceKeys = await this.getOrCreateServiceKeys(serviceInstances[0]);
if (!(serviceKeys?.length > 0)) {
throw new Error(`Cannot get service keys for '${getServiceInstanceParams.names?.join(", ")}' service in current space: ${getServiceInstanceParams.spaceGuids?.join(", ")}`);
}
return {
credentials: serviceKeys[0].credentials,
serviceInstance: serviceInstances[0]
};
}
static async createService(params) {
log.verbose(`Creating a service instance with parameters: ${JSON.stringify(params)}`);
const serviceOfferings = await this.requestCfApi(`/v3/service_offerings?names=${params.serviceName}`);
if (serviceOfferings.length === 0) {
throw new Error(`Cannot find a service offering by name '${params.serviceName}'`);
}
const plans = await this.requestCfApi(`/v3/service_plans?service_offering_guids=${serviceOfferings[0].guid}`);
const plan = plans.find(plan => plan.name === params.planName);
if (!plan) {
throw new Error(`Cannot find a plan by name '${params.planName}' for service '${params.serviceName}'`);
}
try {
await cfCreateService(plan.guid, params.serviceInstanceName, params.parameters, params.tags);
}
catch (error) {
throw new Error(`Cannot create a service instance '${params.serviceInstanceName}' in space '${params.spaceGuid}': ${error.message}`);
}
}
static async getOrCreateServiceKeys(serviceInstance) {
const credentials = await this.getServiceKeys(serviceInstance.guid);
if (credentials.length === 0) {
const serviceKeyName = serviceInstance.name + "_key";
log.info(`Creating service key '${serviceKeyName}' for service instance '${serviceInstance.name}'`);
await this.createServiceKey(serviceInstance.name, serviceKeyName);
}
else {
return credentials;
}
return this.getServiceKeys(serviceInstance.guid);
}
static getServiceKeys(serviceInstanceGuid) {
return cfGetInstanceCredentials({
filters: [{
value: serviceInstanceGuid,
key: eFilters.service_instance_guids
}]
}).catch((error) => {
throw new Error("Failed to get service credentials: " + error.message);
});
}
static async createServiceKey(serviceInstanceName, serviceKeyName) {
try {
return this.cfExecute(["create-service-key", serviceInstanceName, serviceKeyName]);
}
catch (error) {
throw new Error(`Couldn't create a service key for instance: ${serviceInstanceName}: ${error}`);
}
}
static deleteServiceKeyUnsafe(serviceInstanceName, serviceKeyName) {
// Fire and forget - async delete without waiting for result or handling errors
this.cfExecute(["delete-service-key", serviceInstanceName, serviceKeyName, "-f"]).catch(() => {
// Ignore any errors - this is intentionally unsafe
});
}
static async getServiceInstance(params) {
const PARAM_MAP = {
spaceGuids: "space_guids",
planNames: "service_plan_names",
names: "names"
};
const parameters = Object.entries(params)
.filter(([, value]) => value?.length && value?.length > 0)
.map(([key, value]) => `${PARAM_MAP[key]}=${value?.join(",")}`);
const uri = `/v3/service_instances` + (parameters.length > 0 ? `?${parameters.join("&")}` : "");
const resources = await this.requestCfApi(uri);
return resources.map((service) => ({
name: service.name,
guid: service.guid
}));
}
static processErrors(json) {
if (json?.errors?.length > 0) {
const message = JSON.stringify(json.errors);
if (json?.errors?.some((e) => e.title === "CF-NotAuthenticated" || e.code === 10002)) {
throw new Error(`Authentication error. Use 'cf login' to authenticate in Cloud Foundry: ${message}`);
}
throw new Error(`Failed sending request to Cloud Foundry: ${message}`);
}
}
static async requestCfApi(url) {
const response = await this.cfExecute(["curl", url]);
const json = this.parseJson(response);
this.processErrors(json);
const resources = json?.resources;
const totalPages = json?.pagination?.total_pages;
if (totalPages > 1) {
const pages = Array.from({ length: totalPages - 1 }, (_, i) => i + 2);
return resources.concat(await Promise.all(pages.map(async (page) => {
const uri = `${url}${url.includes("?") ? "&" : "?"}page=${page}`;
const response = await this.cfExecute(["curl", uri]);
return this.parseJson(response)?.resources || [];
})).then(resources => [].concat(...resources)));
}
return resources ?? [];
}
static getOAuthToken() {
return this.cfExecute(["oauth-token"]);
}
static async cfExecute(params) {
const MAX_ATTEMPTS = 3;
const errors = new Set();
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const response = await Cli.execute(params, { env: { "CF_COLOR": "false" } });
if (response.exitCode === 0) {
const errorValues = [...errors.values()];
if (errorValues?.length > 0) {
log.verbose(this.errorsToString(errorValues));
}
return response.stdout;
}
errors.add(response.error || response.stderr);
}
catch (error) {
errors.add(error.message);
}
}
throw new Error(`Failed to send request with parameters '${JSON.stringify(params)}': ${this.errorsToString([...errors.values()])}`);
}
static errorsToString(errors) {
return errors.length > 1
? errors.map((error, attempt) => `${attempt + 1} attempt: ${error}`).join("; ")
: errors.map(error => error);
}
static parseJson(jsonString) {
try {
return JSON.parse(jsonString);
}
catch (error) {
throw new Error(`Failed parse response from request CF API: ${error.message}`);
}
}
/**
* Get service keys and return the first one with valid endpoints
* @private
* @static
* @param {string} serviceInstanceGuid the service instance guid
* @return {Promise<any>} the first service key with valid endpoints, or null if none found
* @memberof CFUtil
*/
static async getServiceKeyWithValidEndpoints(serviceInstanceGuid) {
try {
const serviceKeys = await this.getServiceKeys(serviceInstanceGuid);
// Find and return the first key with valid endpoints
return serviceKeys.find((key) => key.credentials?.endpoints && this.hasValidEndpoints(key.credentials.endpoints));
}
catch (error) {
throw new Error("Failed to get service credentials: " + error.message);
}
}
/**
* Get service keys for a service instance by name. If the existing service key
* has endpoints as strings instead of objects, a new service key will be created.
* @static
* @param {string} serviceInstanceName name of the service instance
* @param {string} [spaceGuid] optional space guid, will use current space if not provided
* @return {Promise<any>} promise with service key credentials
* @memberof CFUtil
*/
static async getOrCreateServiceKeyWithEndpoints(serviceInstanceName, spaceGuid) {
const resolvedSpaceGuid = await this.getSpaceGuid(spaceGuid);
// Find service instance by name
const serviceInstances = await this.getServiceInstance({
names: [serviceInstanceName],
spaceGuids: [resolvedSpaceGuid]
});
if (!(serviceInstances?.length > 0)) {
throw new Error(`Cannot find service instance '${serviceInstanceName}' in space: ${resolvedSpaceGuid}`);
}
const serviceInstance = serviceInstances[0];
log.verbose(`Found service instance '${serviceInstance.name}' with guid: ${serviceInstance.guid}`);
// If no valid service key found, create a new one with unique name
const uniqueServiceKeyNamePromise = this.generateUniqueServiceKeyName(serviceInstance.name, serviceInstance.guid);
// Get service keys with credentials to find any with valid endpoints:
// object with url and destination instead of a single url string
const validKey = await this.getServiceKeyWithValidEndpoints(serviceInstance.guid);
if (validKey) {
log.verbose(`Using existing service key with valid endpoints structure`);
return validKey.credentials;
}
const uniqueServiceKeyName = await uniqueServiceKeyNamePromise;
log.verbose(`No valid service key found with proper endpoints structure. Creating new service key '${uniqueServiceKeyName}' for '${serviceInstance.name}'`);
await this.createServiceKey(serviceInstance.name, uniqueServiceKeyName);
// Get the newly created service key and validate its endpoints
const newValidKey = await this.getServiceKeyWithValidEndpoints(serviceInstance.guid);
if (newValidKey) {
log.verbose(`Using newly created service key with valid endpoints structure`);
return newValidKey.credentials;
}
// Clean up the created service key since it doesn't have valid
// endpoints. We don't throw an error here even if no valid endpoints
// found. We assume that there might be no coincidence between
// credential endpoints and xs-app.json destinations.
this.deleteServiceKeyUnsafe(serviceInstance.name, uniqueServiceKeyName);
log.verbose(`Created service key '${uniqueServiceKeyName}' does not have valid endpoints structure. Triggered deletion of invalid service key '${uniqueServiceKeyName}'`);
}
/**
* Check if endpoints object has at least one property that is an object
* @private
* @static
* @param {any} endpoints the endpoints object to validate
* @return {boolean} true if at least one property of endpoints is an object
* @memberof CFUtil
*/
static hasValidEndpoints(endpoints) {
if (!endpoints || typeof endpoints !== 'object' || Array.isArray(endpoints)) {
return false;
}
// Check if at least one property of endpoints is an object
return Object.values(endpoints).some(value => value && typeof value === 'object' && !Array.isArray(value));
}
/**
* Get all service key names for a service instance
* @private
* @static
* @param {string} serviceInstanceGuid the service instance guid
* @return {Promise<string[]>} promise with array of service key names
* @memberof CFUtil
*/
static async getAllServiceKeyNames(serviceInstanceGuid) {
try {
const serviceKeys = await this.requestCfApi(`/v3/service_credential_bindings?type=key&service_instance_guids=${serviceInstanceGuid}`);
return serviceKeys.map((key) => key.name);
}
catch (error) {
throw new Error(`Failed to get service key names: ${error.message}`);
}
}
/**
* Generate a unique service key name in format serviceInstanceName-key-N
* @static
* @param {string} serviceInstanceName the service instance name
* @param {string} serviceInstanceGuid the service instance guid
* @return {Promise<string>} promise with unique service key name
* @memberof CFUtil
*/
static async generateUniqueServiceKeyName(serviceInstanceName, serviceInstanceGuid) {
const existingKeyNames = await this.getAllServiceKeyNames(serviceInstanceGuid);
let counter = 0;
let keyName;
do {
keyName = `${serviceInstanceName}-key-${counter}`;
counter++;
} while (existingKeyNames.includes(keyName));
log.verbose(`Generated unique service key name: ${keyName}`);
return keyName;
}
/**
* Get space guid from configuration or local CF fodler
* @static
* @param {string} spaceGuid ui5.yaml options
* @return {Promise<string>} promise with space guid
* @memberof CFUtil
*/
static async getSpaceGuid(spaceGuid) {
return spaceGuid ?? getSpaceGuidThrowIfUndefined().catch((e) => {
throw new Error("Please specify space and org guids in ui5.yaml or login to Cloud Foundry with 'cf login' and try again: " + e.message);
});
}
}
//# sourceMappingURL=cfUtil.js.map