diff --git a/src/commands/engage/get.ts b/src/commands/engage/get.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/clients-external/apiserverclient.ts b/src/lib/clients-external/apiserverclient.ts new file mode 100644 index 00000000..63e37e95 --- /dev/null +++ b/src/lib/clients-external/apiserverclient.ts @@ -0,0 +1,1313 @@ +import chalk from "chalk"; +import { log } from "console"; +import { dataService } from "../request.js"; +import { + ApiServerClientApplyResult, + ApiServerClientBulkResult, + ApiServerClientListResult, + ApiServerClientSingleResult, + ApiServerError, + ApiServerSubResourceOperation, + ApiServerVersions, + GenericResource, + GenericResourceWithoutName, + LanguageTypes, + ProgressListener, + ResourceDefinition, + WAIT_TIMEOUT, +} from "../types.js"; +import { + buildGenericResource, + createLanguageSubresourceNames, + getLatestServedAPIVersion, + sanitizeMetadata, + ValueFromKey, +} from "../utils/utils.js"; +import pickBy from "lodash/pickBy.js"; +import isEmpty from "lodash/isEmpty.js"; +import assign from "lodash/assign.js"; + +export class ApiServerClient { + region?: string; + useCache: boolean; + account?: string; + team?: string | null; + forceGetAuthInfo?: boolean; + + /** + * Init temporary file if "data" is provided - write data to file (as YAML at the moment) + * @param {object} data optional data to write while creating file + */ + constructor({ + region, + account, + useCache, + team, + forceGetAuthInfo, + }: { + region?: string; + useCache?: boolean; + account?: string; + team?: string | null; + forceGetAuthInfo?: boolean; + } = {}) { + log( + `initializing client with params: region = ${region}, account = ${account}, useCache = ${useCache}, team = ${team}`, + ); + this.account = account; + this.region = region; + this.useCache = useCache === undefined ? true : useCache; // using cache by default + this.team = team; + this.forceGetAuthInfo = forceGetAuthInfo; + } + + /** + * Build resource url based on its ResourceDefinition and passed scope def and name. + * Note that for scope url part both name and def needed. + * The returned URL path is expected to be appended to the base URL. + */ + private buildResourceUrlPath({ + resourceDef, + resourceName, + scopeDef, + scopeName, + version = ApiServerVersions.v1alpha1, + forceDelete = false, + expand, + langDef, + fieldSet, + embed, + }: { + resourceDef: ResourceDefinition; + resourceName?: string; + scopeDef?: ResourceDefinition; + scopeName?: string; + version: string; + forceDelete?: boolean; + expand?: string; + langDef?: string; + fieldSet?: Set; + embed?: string; + }): string { + const groupUrl = `/${resourceDef.metadata.scope.name}/${version}`; + const scopeUrl = + scopeName && scopeDef + ? `/${scopeDef.spec.plural}/${encodeURIComponent(scopeName)}` + : ""; + const resourceUrl = `/${resourceDef.spec.plural}`; + const nameUrl = resourceName ? `/${encodeURIComponent(resourceName)}` : ""; + const embedSet = new Set(embed?.split(",")); + const expandSet = new Set(expand?.split(",")); + if (langDef) { + fieldSet ??= new Set(); + fieldSet + .add("languages") + .add("group") + .add("apiVersion") + .add("name") + .add("kind") + .add("metadata"); + expandSet.add("languages"); + let languageTypesArr: (string | undefined)[] = []; + Object.keys(LanguageTypes).forEach((key) => + languageTypesArr.push(ValueFromKey(LanguageTypes, key)), + ); + langDef.split(",").forEach((code) => { + if (languageTypesArr.includes(code)) { + embedSet.add(`languages-${code.trim()}.resource`); + expandSet.add(`languages-${code.trim()}`); + fieldSet!.add(`languages-${code.trim()}.values`); + } else if (code.trim().length > 0) { + console.log( + chalk.yellow( + `\n\'${code}\' language code is not supported. Allowed language codes: ${LanguageTypes.French} | ${LanguageTypes.German} | ${LanguageTypes.US} | ${LanguageTypes.Portugese}.'`, + ), + ); + } + }); + } + + let url = `${groupUrl}${scopeUrl}${resourceUrl}${nameUrl}`; + if (forceDelete || embedSet.size || expandSet.size || fieldSet) { + const queryParams: string[] = []; + if (forceDelete) { + queryParams.push("forceDelete=true"); + } + if (embedSet.size) { + queryParams.push("embed=" + [...embedSet].join(",")); + } + if (expandSet.size) { + queryParams.push("expand=" + [...expandSet].join(",")); + } + if (fieldSet) { + // If field set is empty, then return no fields. This is intentional. + queryParams.push("fields=" + [...fieldSet].join(",")); + } + url += "?" + queryParams.join("&"); + } + return url; + } + + /** + * Generates an array of PUT requests for sub-resources based on resource input + * + * @param {Object} args function expects arguments as an object + * @param {GenericResource} args.resource resource input (not the APIs response) + * @param {string} args.resourceName resource name + * @param {string} args.subResourceName subresource name + * @param {ResourceDefinition} args.resourceDef resource definition + * @param {string} [args.scopeName] scope name + * @param {ResourceDefinition} [args.scopeDef] scope definition + * @param {string} [args.version] api's version + * @returns {Promise Promise | null>} returns an array of "request creators" functions + * that will be used in {@link resolveSubResourcesRequests} to create sub-resources when needed + */ + public async generateSubResourcesRequests({ + resource, + resourceName, + subResourceName, + resourceDef, + scopeDef, + scopeName, + version, + createAction, + language, + }: { + resource: + | (GenericResource & { [subresource: string]: any }) + | (GenericResourceWithoutName & { [subresource: string]: any }); // file input, not the response + resourceName: string; + subResourceName?: string; + resourceDef: ResourceDefinition; + scopeDef?: ResourceDefinition; + scopeName?: string; + version: string; + createAction?: boolean; + language?: string; + }): Promise | null> { + const service = await dataService({ + account: this.account, + }); + const urlPath = this.buildResourceUrlPath({ + resourceDef, + resourceName, + scopeDef, + scopeName, + version, + }); + const knownSubResourcesNames = resourceDef.spec.subResources?.names ?? []; + const foundSubResources = pickBy(resource, (_, key) => { + if (key.startsWith("x-") || knownSubResourcesNames.includes(key)) { + return !subResourceName || subResourceName === key; + } + return false; + }); + if (language) { + const langSubResourcesNames = createLanguageSubresourceNames(language); + langSubResourcesNames.forEach((name) => { + if ( + !Object.keys(foundSubResources).includes(name) && + name !== "languages" + ) { + console.log( + chalk.yellow( + `\n\'${name}\' subresource definition not found, hence create/update cannot be performed on \'${name}\' subresource.`, + ), + ); + } + }); + Object.keys(foundSubResources).forEach((subRes) => { + if (!langSubResourcesNames.includes(subRes)) { + // For create, only delete the language subresources that are not passed in the 'language' argument. + if (createAction) { + if (subRes.includes("languages")) { + delete foundSubResources[subRes]; + } + } + // For update, delete all the subresources except the ones passed in the 'language' argument. + else { + delete foundSubResources[subRes]; + } + } + }); + } + return isEmpty(foundSubResources) + ? null + : Object.keys(foundSubResources).map((key) => { + return { + name: key, + operation: () => + service + .put(`${urlPath}/${key}?fields=${key}`, { + [key]: foundSubResources[key], + }) + .catch((err) => + Promise.reject({ name: key, requestError: err }), + ), + }; + }); + } + + /** + * Executes sub-resources requests generated by {@link generateSubResourcesRequests} + * + * @param {GenericResource} mainResourceResponse API response of the main resource update/create + * @param {Array<() => Promise> | null} pendingCalls an array of "request creators" functions for sub-resources + * @returns {ApiServerClientSingleResult} returns mainResourceResponse merged with successful sub-resources results + * and error details if encountered + */ + public async resolveSubResourcesRequests( + mainResourceResponse: GenericResource, + pendingCalls: Array | null, + ): Promise { + if (!pendingCalls) { + return { data: mainResourceResponse, error: null }; + } + log(`resolving sub-resources, pending calls = ${pendingCalls.length}.`); + // note: errors set to an empty array initially, will reset to null if no errors found + const result: ApiServerClientSingleResult = { + data: null, + updatedSubResourceNames: [], + error: [], + }; + + const subResourcesCombined = ( + await Promise.allSettled( + pendingCalls.map(async (next) => { + const opResult = await next.operation(); + result.updatedSubResourceNames?.push(next.name); + return opResult; + }), + ) + ).reduce((a, c) => { + if (c.status === "fulfilled") { + return { ...a, ...c.value }; + } + // expecting only a valid ApiServer error response here + // re-throw if something different, so it should be handled by command's catch block. + if ( + c.reason.requestError?.errors && + Array.isArray(c.reason.requestError.errors) + ) { + // note: if APIs are going to return more details this details override will not be needed, just push as in other methods + result.error?.push( + ...c.reason.requestError.errors.map((e: ApiServerError) => ({ + ...e, + detail: `sub-resource "${c.reason.name}" ${e.detail}`, + })), + ); + return a; + } + throw c.reason; + }, {}); + + result.data = assign(mainResourceResponse, subResourcesCombined); + if (!result.error?.length) result.error = null; // reset errors to null if none encountered + log( + `resolving sub-resources is complete, data received = ${!isEmpty(subResourcesCombined)}, errors = ${ + result.error?.length + }.`, + ); + return result; + } + + /** + * Check if resources are deleted by making a fetch call for the resources + */ + private checkForResources( + resources: GenericResource[], + sortedDefsArray: ResourceDefinition[], + ) { + return Promise.all( + resources.map((resource) => { + const resourceDef = sortedDefsArray.find( + (def) => + def.spec.kind === resource.kind && + def.spec.scope?.kind === resource.metadata?.scope?.kind, + ); + const scopeDef = !!resource.metadata?.scope + ? sortedDefsArray.find( + (def) => + def.spec.kind === resource.metadata!.scope!.kind && + !def.spec.scope, + ) + : undefined; + const scopeName = resource.metadata?.scope?.name; + if (resourceDef) { + return this.getResourceByName({ + resourceDef, + resourceName: resource.name, + scopeDef, + scopeName, + }); + } else return null; + }), + ); + } + + /** + * SINGLE RESOURCE CALLS + */ + + /** + * Create a single resource. + * @param resources resource to create + */ + async createResource({ + resourceDef, + resource, + scopeDef, + scopeName, + withSubResources = true, + language, + }: { + resource: GenericResource | GenericResourceWithoutName; + resourceDef: ResourceDefinition; + scopeName?: string; + scopeDef?: ResourceDefinition; + withSubResources?: boolean; + language?: string; + }): Promise { + log( + `createResource, spec.kind = ${resourceDef.spec.kind}, name = ${resource.name}`, + ); + const result: ApiServerClientSingleResult = { + data: null, + error: null, + pending: null, + warning: false, + }; + try { + const service = await dataService({ + account: this.account, + }); + const version = + resource.apiVersion === undefined + ? getLatestServedAPIVersion(resourceDef) + : resource.apiVersion; + const urlPath = this.buildResourceUrlPath({ + resourceDef, + scopeDef, + scopeName, + version, + }); + const response = await service.post(urlPath, sanitizeMetadata(resource)); + if (!resource.name) { + log("createResource, resource does not have a logical name"); + result.warning = true; + } + const pendingSubResources = await this.generateSubResourcesRequests({ + resource, + resourceName: response.name, + resourceDef, + scopeDef, + scopeName, + version, + createAction: true, + language, + }); + log( + `createResource, pendingSubResources = ${pendingSubResources?.length}`, + ); + if (withSubResources) { + const { data: subResData, error: subResError } = + await this.resolveSubResourcesRequests(response, pendingSubResources); + result.data = subResData; + result.error = subResError; + } else { + result.data = response; + result.pending = pendingSubResources; + } + } catch (e: any) { + log("createResource, error: ", e); + // expecting only a valid ApiServer error response here + // re-throw if something different, so it should be handled by command's catch block. + if (e.errors && Array.isArray(e.errors)) { + result.error = e.errors; + } else throw e; + } + if (!!result.data) { + result.data = sanitizeMetadata(result.data); + } + return result; + } + + /** + * Update a single resource. + * @param resources resource to create + */ + async updateResource({ + resourceDef, + resource, + scopeDef, + scopeName, + subResourceName, + language, + }: { + resource: GenericResource; + resourceDef: ResourceDefinition; + scopeName?: string; + scopeDef?: ResourceDefinition; + subResourceName?: string; + language?: string; + }): Promise { + log( + `updateResource, spec.kind = ${resourceDef.spec.kind}, name = ${resource.name}`, + ); + const result: ApiServerClientSingleResult = { + data: null, + error: null, + pending: null, + }; + const canUpdateMainResource = !language && !subResourceName; + const version = + resource.apiVersion === undefined + ? getLatestServedAPIVersion(resourceDef) + : resource.apiVersion; + if (canUpdateMainResource) { + try { + const service = await dataService({ + account: this.account, + }); + const urlPath = this.buildResourceUrlPath({ + resourceDef, + resourceName: resource.name, + scopeDef, + scopeName, + version, + }); + result.data = await service.put(urlPath, sanitizeMetadata(resource)); + } catch (e: any) { + log("updateResource, error", e); + // expecting only a valid ApiServer error response here + // re-throw if something different, so it should be handled by command's catch block. + if (e.errors && Array.isArray(e.errors)) { + result.error = e.errors; + } else { + throw e; + } + } + } + result.pending = await this.generateSubResourcesRequests({ + resource, + resourceName: resource.name, + subResourceName, + resourceDef, + scopeDef, + scopeName, + version, + createAction: false, + language, + }); + if (!result.data && !result.pending && subResourceName) { + result.error = [ + { + status: 0, + title: "", + detail: `sub-resource "${subResourceName}" not found.`, + meta: { + instanceId: "", + tenantId: "", + authenticatedUserId: "", + transactionId: "", + }, + }, + ]; + } + if (result.data) { + result.data = sanitizeMetadata(result.data); + } + return result; + } + + /** + * Update sub resource on the resource. + * @param resources resource to be updated + * @param subResourceName sub resource name to be updated + */ + async updateSubResource({ + resourceDef, + resource, + subResourceName, + scopeDef, + scopeName, + }: { + resource: GenericResource; + subResourceName: string; + resourceDef: ResourceDefinition; + scopeName?: string; + scopeDef?: ResourceDefinition; + withSubResources?: boolean; + }): Promise { + log( + `updateSubResource, spec.kind = ${resourceDef.spec.kind}, name = ${resource.name}`, + ); + const result: ApiServerClientSingleResult = { + data: null, + error: null, + pending: null, + }; + const version = getLatestServedAPIVersion(resourceDef); + try { + const service = await dataService({ + account: this.account, + }); + const knownSubResourcesNames = resourceDef.spec.subResources?.names ?? []; + const foundSubResources = pickBy( + resource, + (_, key) => + subResourceName == key && knownSubResourcesNames.includes(key), + ); + const resourceName = resource.name; + const urlPath = this.buildResourceUrlPath({ + resourceDef, + resourceName, + scopeDef, + scopeName, + version, + }); + + service.put(`${urlPath}/${subResourceName}?fields=${subResourceName}`, { + [subResourceName]: foundSubResources[subResourceName], + }); + } catch (e: any) { + log("updateSubResource, error", e); + // expecting only a valid ApiServer error response here + // re-throw if something different, so it should be handled by command's catch block. + if (e.errors && Array.isArray(e.errors)) { + result.error = e.errors; + } else throw e; + } + if (!!result.data) result.data = sanitizeMetadata(result.data); + return result; + } + + /** + * Delete a resources by name. + * @param opts = { + * resourceDef - required, resource definition + * resourceName - required + * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too + * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too + * version - apis version (using alpha1 by default currently) + * wait - if provided, a followup GET call will be executed to confirm if the resource removed. + * } + */ + async deleteResourceByName({ + resourceDef, + resourceName, + scopeDef, + scopeName, + wait, + forceDelete = false, + resourceAPIVersion, + }: { + resourceDef: ResourceDefinition; + resourceName: string; + scopeDef?: ResourceDefinition; + scopeName?: string; + wait?: boolean; + forceDelete?: boolean; + resourceAPIVersion?: string | undefined; + }): Promise { + log( + `deleteResourceByName, spec.kind = ${resourceDef.spec.kind}, name = ${resourceName}, scope.kind = ${scopeDef?.spec.kind}, scope.name = ${scopeName}`, + ); + const result: ApiServerClientSingleResult = { data: null, error: null }; + const version = + resourceAPIVersion === undefined + ? getLatestServedAPIVersion(resourceDef) + : resourceAPIVersion; + try { + const service = await dataService({ + account: this.account, + }); + const urlPath = this.buildResourceUrlPath({ + resourceDef, + resourceName, + scopeDef, + scopeName, + version, + forceDelete, + }); + const response = await service.delete(urlPath); + // note: delete "response" value from api-server is translated to an empty string currently. + // If its true, constructing a simple representation from provided data (definition, name, scope name) + // and manually set it as the "data" key. + result.data = + response === "" + ? buildGenericResource({ resourceDef, resourceName, scopeName }) + : response; + if (wait) { + await new Promise((resolve) => + setTimeout(async () => { + const res = await this.getResourceByName({ + resourceDef, + resourceName, + scopeDef, + scopeName, + }); + if (!!res.data) { + result.data = null; + result.error = [ + { + detail: "resource has not been deleted yet.", + status: 0, + } as ApiServerError, + ]; + } + resolve({}); + }, WAIT_TIMEOUT), + ); + } + } catch (e: any) { + log("deleteResourceByName, error: ", e); + // expecting only a valid ApiServer error response here + // re-throw if something different so it should be handled by command's catch block. + if (e.errors && Array.isArray(e.errors)) { + result.error = e.errors; + } else throw e; + } + return result; + } + + /** + * Get resources count. + * @param opts = { + * resourceDef - required, resource definition + * resourceName - optional, resource name + * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too + * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too + * query - Optional RSQL query filter + * } + */ + async getResourceCount({ + resourceDef, + resourceName, + scopeDef, + scopeName, + query, + }: { + resourceDef: ResourceDefinition; + resourceName?: string; + scopeDef?: ResourceDefinition; + scopeName?: string; + query?: string; + }): Promise { + const version = getLatestServedAPIVersion(resourceDef); + try { + const service = await dataService({ + account: this.account, + }); + const urlPath = this.buildResourceUrlPath({ + resourceDef, + resourceName, + scopeDef, + scopeName, + version, + }); + const response = await service.head(urlPath, { query }); + return response; + } catch (e: any) { + log("getResourceCount, error: ", e); + // re-throw + throw e; + } + } + + /** + * Get a resources list. + * @param opts = { + * resourceDef - required, resource definition + * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too + * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too + * version - apis version (using alpha1 by default currently) + * query - Optional RSQL query filter + * progressListener - Optional callback invoked multiple times with download progress + * } + */ + async getResourcesList({ + resourceDef, + scopeDef, + scopeName, + query, + progressListener, + expand, + langDef, + fieldSet, + }: { + resourceDef: ResourceDefinition; + scopeDef?: ResourceDefinition; + scopeName?: string; + query?: string; + progressListener?: ProgressListener; + expand?: string; + langDef?: string; + fieldSet?: Set; + }): Promise { + log(`getResourcesList, spec.kind = ${resourceDef.spec.kind}`); + const version = getLatestServedAPIVersion(resourceDef); + const result: ApiServerClientListResult = { data: null, error: null }; + try { + const service = await dataService({ + account: this.account, + }); + const urlPath = this.buildResourceUrlPath({ + resourceDef, + scopeDef, + scopeName, + version, + expand, + langDef, + fieldSet, + }); + const response = await service.getWithPagination( + urlPath, + { query }, + 50, + progressListener, + ); + result.data = response; + } catch (e: any) { + log("getResourcesList, error: ", e); + // expecting only a valid ApiServer error response here + // re-throw if something different so it should be handled by command's catch block. + if (e.errors && Array.isArray(e.errors)) { + result.error = e.errors; + } else throw e; + } + return result; + } + + /** + * Get a resources by name. + * @param opts = { + * resourceDef - required, resource definition + * resourceName - required + * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too + * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too + * version - apis version (using alpha1 by default currently) + * } + */ + async getResourceByName({ + resourceDef, + resourceName, + scopeDef, + scopeName, + expand, + langDef, + fieldSet, + resourceVersion, + embed, + }: { + resourceDef: ResourceDefinition; + resourceName: string; + scopeDef?: ResourceDefinition; + scopeName?: string; + expand?: string; + langDef?: string; + fieldSet?: Set; + resourceVersion?: string; + embed?: string; + }): Promise { + log( + `getResourceByName, spec.kind = ${resourceDef.spec.kind}, name = ${resourceName}`, + ); + const version = + resourceVersion === undefined + ? getLatestServedAPIVersion(resourceDef) + : resourceVersion; + const result: ApiServerClientSingleResult = { data: null, error: null }; + try { + const service = await dataService({ + account: this.account, + }); + const urlPath = this.buildResourceUrlPath({ + resourceDef, + resourceName, + scopeDef, + scopeName, + version, + expand, + langDef, + fieldSet, + embed: embed, + }); + const response = await service.get(urlPath); + result.data = response; + } catch (e: any) { + log("getResourceByName, error: ", e); + // expecting only a valid ApiServer error response here + // re-throw if something different so it should be handled by command's catch block. + if (e.errors && Array.isArray(e.errors)) { + result.error = e.errors; + } else throw e; + } + return result; + } + + // TODO: Implement this when Caching is done + + // /** + // * Fetch definition endpoints to get specs for available resources. + // * Note that only "management" group is used currently. + // * @returns { group1: { resources: Map, cli: Map }, group2: { ... }, groupN: { ... } } + // */ + // async getSpecs(version = ApiServerVersions.v1alpha1): Promise<{ + // [groupName: string]: { + // resources: Map; + // cli: Map; + // }; + // }> { + // log(`get specs`); + // try { + // const specs: { + // [groupName: string]: { + // resources: Map; + // cli: Map; + // }; + // } = {}; + + // const service = await dataService({ + // baseUrl: this.baseUrl, + // region: this.region, + // account: this.account, + // }); + // const groups = await service.getWithPagination( + // `/definitions/${version}/groups`, + // ); + // for (const group of groups) { + // let resources: ResourceDefinition[] = []; + // let cli: CommandLineInterface[] = []; + // const cachedGroup = CacheController.get( + // `groups-${group.name}-${version}`, + // ); + // let cacheUpdated = false; + // if ( + // this.useCache && + // cachedGroup && + // cachedGroup.resourceVersion === group.metadata.resourceVersion + // ) { + // log(`valid ${group.name}/${version} found in cache`); + // resources = cachedGroup.resources; + // cli = cachedGroup.cli; + // } else { + // log( + // `no valid ${group.name}/${version} found in cache or cache usage is not set`, + // ); + // [resources, cli] = await Promise.all([ + // service.getWithPagination( + // `/definitions/${version}/groups/${group.name}/resources`, + // ), + // service.getWithPagination( + // `/definitions/${version}/groups/${group.name}/commandlines`, + // ), + // ]); + // CacheController.set(`groups-${group.name}-${version}`, { + // resourceVersion: group.metadata.resourceVersion, + // resources, + // cli, + // }); + // cacheUpdated = true; + // } + // specs[group.name] = { + // resources: new Map(), + // cli: new Map(), + // }; + // for (const r of resources) { + // specs[group.name].resources.set(r.name, r); + // } + // for (const c of cli) { + // specs[group.name].cli.set(c.name, c); + // } + // if (cacheUpdated) CacheController.writeToFile(); + // } + // return specs; + // } catch (e: any) { + // log("get specs, error: ", e); + // throw e; + // } + // } + + /** + * BULK CALLS + */ + + /** + * Bulk creation of resources. + * There is no endpoint for bulk create so executing them one-by-one. Order of calls calculated by + * sorting of the array of resources with "compareResourcesByKindAsc". + * @param resources array of resources to create + */ + async bulkCreate( + resources: Array, + sortedDefsMap: Map, + exitOnError: boolean = false, + ): Promise { + log(`bulk create`); + const sortedDefsArray = Array.from(sortedDefsMap.values()); + const pendingSubResources: { + mainResult: GenericResource; + pendingCalls: Array; + withWarning: boolean; + }[] = []; + const bulkResult: ApiServerClientBulkResult = { + success: [], + error: [], + warning: [], + }; + + for (const resource of resources) { + const resourceDef = sortedDefsArray.find( + (def) => + def.spec.kind === resource.kind && + def.spec.scope?.kind === resource.metadata?.scope?.kind, + ); + if (!resourceDef) { + let errorMessage = `No resource definition found for "kind/${resource.kind}"`; + if (!!resource.metadata?.scope?.kind) { + errorMessage += ` in the scope "${resource.metadata?.scope?.kind}".`; + } else { + errorMessage += " with no scope."; + } + bulkResult.error.push({ + name: resource.name || "Unknown name", + kind: resource.kind, + error: new Error(errorMessage), + }); + continue; + } + + const scopeDef = !!resource.metadata?.scope + ? sortedDefsArray.find( + (def) => + def.spec.kind === resource.metadata!.scope!.kind && + !def.spec.scope, + ) + : undefined; + const scopeName = resource.metadata?.scope?.name; + + const res = await this.createResource({ + resource, + resourceDef, + scopeDef, + scopeName, + }); + if (res.data && !res.error) { + // note: bulk operation requires creation of sub-resources after all main resources created + // since a sub-resource might have a reference to another resource. + if (!!res.pending) { + pendingSubResources.push({ + mainResult: res.data, + pendingCalls: res.pending, + withWarning: res.warning ?? false, + }); + } else { + if (res.warning) bulkResult.warning?.push(res.data); + else bulkResult.success.push(res.data); + } + } else if (res.error) { + for (const nextError of res.error) { + bulkResult.error.push({ + name: resource.name || "Unknown name", + kind: resource.kind, + error: nextError, + }); + } + if (exitOnError) { + return bulkResult; + } + } + } + + // creating sub-resources + for (const p of pendingSubResources) { + const subResResult = await this.resolveSubResourcesRequests( + p.mainResult, + p.pendingCalls, + ); + if (subResResult.data && !subResResult.error) { + if (p.withWarning) bulkResult.warning?.push(subResResult.data); + else bulkResult.success.push(subResResult.data); + } else if (subResResult.error) { + for (const nextError of subResResult.error) { + bulkResult.error.push({ + name: p.mainResult.name, + kind: p.mainResult.kind, + error: nextError, + }); + } + } + } + + return bulkResult; + } + + /** + * Bulk creation of resources. + * There is no endpoint for bulk create so executing them one-by-one. Order of calls calculated by + * sorting of the array of resources with "compareResourcesByKindAsc". + * @param resources array of resources to create + */ + async bulkCreateOrUpdate( + resources: GenericResourceWithoutName[], + sortedDefsMap: Map, + language?: string, + subResourceName?: string, + ): Promise> { + log(`bulk create or update`); + const sortedDefsArray = Array.from(sortedDefsMap.values()); + const applyResults: Array = []; + + for (const resource of resources) { + const resourceDef = sortedDefsArray.find( + (def) => + def.spec.kind === resource.kind && + def.spec.scope?.kind === resource.metadata?.scope?.kind, + ); + // the check below is already happening when loading the specs but checking again just in case. + if (!resourceDef) { + let errorMessage = `No resource definition found for "kind/${resource.kind}"`; + if (!!resource.metadata?.scope?.kind) { + errorMessage += ` in the scope "${resource.metadata?.scope?.kind}".`; + } else { + errorMessage += " with no scope."; + } + applyResults.push({ + error: [ + { + name: resource.name ?? "Unknown name", + kind: resource.kind, + error: new Error(errorMessage), + }, + ], + }); + continue; + } + + const scopeDef = !!resource.metadata?.scope + ? sortedDefsArray.find( + (def) => + def.spec.kind === resource.metadata!.scope!.kind && + !def.spec.scope, + ) + : undefined; + const scopeName = resource.metadata?.scope?.name; + const resourceName = resource.name ?? "Unknown name"; + + // only making getResource call if resource has a name + let getResult: ApiServerClientSingleResult | null = resource.name + ? await this.getResourceByName({ + resourceDef, + resourceName: resource.name, + scopeDef, + scopeName, + resourceVersion: resource.apiVersion, + }) + : null; + + // Create new resources first + let singleResult: ApiServerClientSingleResult; + const shouldCreate = + !getResult || (!!getResult?.error && getResult.error[0].status === 404); + if (shouldCreate) { + // Resource not found. Create a new resource. + singleResult = await this.createResource({ + resource, + resourceDef, + scopeDef, + scopeName, + language, + }); + } else if (getResult!.data) { + // Resource found. Update the existing resource. + singleResult = await this.updateResource({ + resource: resource as GenericResource, + resourceDef, + scopeDef, + scopeName, + language, + subResourceName, + }); + } else { + // Something is going wrong - more than one error in api server response, re-throw in the same + // structure as ApiServerErrorResponse so renderer.anyError can pick this up. + throw { errors: getResult!.error }; + } + + // Store the results of the above create/update. + const applyResult: ApiServerClientApplyResult = { + data: singleResult.data, + wasCreated: shouldCreate && !!singleResult.data, + wasAutoNamed: shouldCreate && singleResult.warning, + wasMainResourceChanged: !!singleResult.data, + error: [], + }; + singleResult.error?.forEach((nextError) => + applyResult.error?.push({ + name: resourceName, + kind: resource.kind, + error: nextError, + }), + ); + applyResults.push(applyResult); + + // Create or update any pending subresources. + if (singleResult.pending) { + const pendingData = + singleResult.data ?? + sanitizeMetadata( + buildGenericResource({ + resourceName: resourceName, + resourceDef: resourceDef, + scopeName: scopeName, + }) as GenericResource, + ); + const subResResult = await this.resolveSubResourcesRequests( + pendingData, + singleResult.pending, + ); + if (subResResult.data) { + applyResult.data = subResResult.data; + } + applyResult.updatedSubResourceNames = + subResResult.updatedSubResourceNames; + subResResult.error?.forEach((error) => + applyResult.error?.push({ + name: resourceName, + kind: resource.kind, + error: error, + }), + ); + } + + // Delete the result's error array if it is empty. + if (!applyResult.error?.length) { + delete applyResult.error; + } + } + + return applyResults; + } + + /** + * Bulk deletion of resources. + * Order of calls calculated by sorting of the array of resources with "compareResourcesByKindDesc". + * @param resources array of resources to create + */ + async bulkDelete( + resources: GenericResource[], + sortedDefsMap: Map, + wait?: boolean, + forceDelete?: boolean, + ): Promise { + log(`bulk delete`); + const sortedDefsArray = Array.from(sortedDefsMap.values()); + const bulkResult: ApiServerClientBulkResult = { success: [], error: [] }; + for (const resource of resources) { + try { + const resourceDef = sortedDefsArray.find( + (def) => + def.spec.kind === resource.kind && + def.spec.scope?.kind === resource.metadata?.scope?.kind, + ); + const scopeDef = !!resource.metadata?.scope + ? sortedDefsArray.find( + (def) => + def.spec.kind === resource.metadata!.scope!.kind && + !def.spec.scope, + ) + : undefined; + const scopeName = resource.metadata?.scope?.name; + if (!resourceDef) { + let errorMessage = `No resource definition found for "kind/${resource.kind}"`; + if (!!resource.metadata?.scope?.kind) { + errorMessage += ` in the scope "${resource.metadata?.scope?.kind}".`; + } else { + errorMessage += " with no scope."; + } + bulkResult.error.push({ + name: resource.name || "Unknown name", + kind: resource.kind, + error: new Error(errorMessage), + }); + continue; + } + + const res = await this.deleteResourceByName({ + resourceName: resource.name, + resourceDef, + scopeDef, + scopeName, + forceDelete, + resourceAPIVersion: resource.apiVersion, + }); + if (res.error) { + for (const nextError of res.error) { + bulkResult.error.push({ + name: resource.name, + kind: resource.kind, + error: nextError, + }); + } + } else { + // deleteResourceByName is constructing a resource representation using buildGenericResource as res.data, + // but provided in a file resources might contain more data so using them currently + bulkResult.success.push(resource); + } + } catch (e: any) { + // expecting only a valid ApiServer error response here + // re-throw if something different so it should be handled by command's catch block. + if (e.errors && Array.isArray(e.errors)) { + for (const nextError of e.errors) { + bulkResult.error.push({ + name: resource.name, + kind: resource.kind, + error: nextError, + }); + } + } else { + throw e; + } + } + } + if (wait) { + let pendingResources: (ApiServerClientSingleResult | null)[] = []; + pendingResources = await this.checkForResources( + resources, + sortedDefsArray, + ); + const pendingDeletingResource = pendingResources.some((res) => res?.data); + if (pendingDeletingResource) { + setTimeout(async () => { + pendingResources = await this.checkForResources( + resources, + sortedDefsArray, + ); + }, WAIT_TIMEOUT); + const stillPending = pendingResources.some((res) => res?.data); + if (stillPending) { + const pendingResNames = pendingResources.map( + (res) => res?.data?.name, + ); + bulkResult.success.forEach( + (res, index) => + pendingResNames.includes(res.name) && + bulkResult.success.splice(index, 1), + ); + pendingResources.forEach((res) => { + if (res?.data) { + bulkResult.error.push({ + ...res.data, + error: { + detail: "Not deleted yet.", + }, + }); + } + }); + } else return bulkResult; + } else return bulkResult; + } + return bulkResult; + } +} diff --git a/src/lib/request.ts b/src/lib/request.ts index ebafde87..d99df76a 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,24 +1,26 @@ -import _ from 'lodash'; -import fs from 'fs'; -import chalk from 'chalk'; -import got from 'got'; -import httpProxyAgentPkg from 'http-proxy-agent'; -import httpsProxyAgentPkg from 'https-proxy-agent'; -import loadConfig, { Config } from './config.js'; -import path from 'path'; -import prettyBytes from 'pretty-bytes'; -import logger, { alert, highlight, ok, note } from './logger.js'; -import { fileURLToPath } from 'url'; -import { readJsonSync } from './fs.js'; +import _, { flatten } from "lodash"; +import fs from "fs"; +import chalk from "chalk"; +import got, { RequestError, TimeoutError } from "got"; +import httpProxyAgentPkg from "http-proxy-agent"; +import httpsProxyAgentPkg from "https-proxy-agent"; +import promiseLimit from "promise-limit"; +import loadConfig, { Config } from "./config.js"; +import path from "path"; +import prettyBytes from "pretty-bytes"; +import logger, { alert, highlight, ok, note } from "./logger.js"; +import { fileURLToPath } from "url"; +import { readJsonSync } from "./fs.js"; +import { ABORT_TIMEOUT, ProgressListener } from "./types.js"; const { HttpProxyAgent } = httpProxyAgentPkg; const { HttpsProxyAgent } = httpsProxyAgentPkg; -const { log } = logger('axway-cli:request'); +const { log } = logger("axway-cli:request"); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const { version } = readJsonSync(path.resolve(__dirname, '../../package.json')); +const { version } = readJsonSync(path.resolve(__dirname, "../../package.json")); /** * The user agent to use in outgoing requests. * IMPORTANT! Platform explicitly checks this user agent, so do NOT change the name or case. @@ -45,80 +47,97 @@ export { got }; * @returns {Promise} Resolves `got` options object. */ export function options(opts: any = {}) { - if (!opts || typeof opts !== 'object') { - throw new TypeError('Expected options to be an object'); - } - - opts = { ...opts }; - - const { defaults } = opts; - const { - ca = defaults?.ca, - caFile = defaults?.caFile, - cert = defaults?.cert, - certFile = defaults?.certFile, - key = defaults?.key, - keyFile = defaults?.keyFile, - proxy = defaults?.proxy, - strictSSL = defaults?.strictSSL - } = opts; - - delete opts.ca; - delete opts.caFile; - delete opts.cert; - delete opts.certFile; - delete opts.defaults; - delete opts.key; - delete opts.keyFile; - delete opts.proxy; - delete opts.strictSSL; - - // Default all requests to use the custom CLI user agent - opts.headers = { - 'User-Agent': userAgent - }; - - const load = it => (Buffer.isBuffer(it) ? it : typeof it === 'string' ? fs.readFileSync(it) : undefined); - - opts.hooks = _.merge(opts.hooks, { - afterResponse: [ - response => { - const { headers, request, statusCode, url } = response; - log([ - request.options.method, - highlight(url), - proxy && note(`[proxy ${proxy}]`), - Object.prototype.hasOwnProperty.call(headers, 'content-length') && chalk.magenta(`(${prettyBytes(Number(headers['content-length']))})`), - statusCode < 400 ? ok(statusCode) : alert(statusCode) - ].filter(Boolean).join(' ')); - return response; // note: this must return response - } - ] - }); - - opts.https = { - ...opts.https || {}, - certificate: load(opts.https?.certificate || cert || certFile), - certificateAuthority: load(opts.https?.certificateAuthority || ca || caFile), - key: load(opts.https?.key || key || keyFile), - rejectUnauthorized: opts.https?.rejectUnauthorized !== undefined ? opts.https.rejectUnauthorized : !!strictSSL !== false - }; - - if (proxy) { - const agentOpts = { - ca: opts.https.certificateAuthority, - cert: opts.https.certificate, - key: opts.https.key, - rejectUnauthorized: opts.https.rejectUnauthorized - }; - opts.agent ||= {}; - // @ts-expect-error - For some reason the typings for HttpProxyAgent is reporting the agentOpts arg as `never`. - opts.agent.http ||= new HttpProxyAgent(proxy, agentOpts); - // @ts-expect-error - For some reason the typings for HttpsProxyAgent is reporting the agentOpts arg as `never`. - opts.agent.https ||= new HttpsProxyAgent(proxy, agentOpts); - } - - return opts; + if (!opts || typeof opts !== "object") { + throw new TypeError("Expected options to be an object"); + } + + opts = { ...opts }; + + const { defaults } = opts; + const { + ca = defaults?.ca, + caFile = defaults?.caFile, + cert = defaults?.cert, + certFile = defaults?.certFile, + key = defaults?.key, + keyFile = defaults?.keyFile, + proxy = defaults?.proxy, + strictSSL = defaults?.strictSSL, + } = opts; + + delete opts.ca; + delete opts.caFile; + delete opts.cert; + delete opts.certFile; + delete opts.defaults; + delete opts.key; + delete opts.keyFile; + delete opts.proxy; + delete opts.strictSSL; + + // Default all requests to use the custom CLI user agent + opts.headers = { + "User-Agent": userAgent, + }; + + const load = (it) => + Buffer.isBuffer(it) + ? it + : typeof it === "string" + ? fs.readFileSync(it) + : undefined; + + opts.hooks = _.merge(opts.hooks, { + afterResponse: [ + (response) => { + const { headers, request, statusCode, url } = response; + log( + [ + request.options.method, + highlight(url), + proxy && note(`[proxy ${proxy}]`), + Object.prototype.hasOwnProperty.call(headers, "content-length") && + chalk.magenta( + `(${prettyBytes(Number(headers["content-length"]))})` + ), + statusCode < 400 ? ok(statusCode) : alert(statusCode), + ] + .filter(Boolean) + .join(" ") + ); + return response; // note: this must return response + }, + ], + }); + + opts.https = { + ...(opts.https || {}), + certificate: load(opts.https?.certificate || cert || certFile), + certificateAuthority: load( + opts.https?.certificateAuthority || ca || caFile + ), + key: load(opts.https?.key || key || keyFile), + rejectUnauthorized: + opts.https?.rejectUnauthorized !== undefined + ? opts.https.rejectUnauthorized + : !!strictSSL !== false, + }; + + if (proxy) { + const agentOpts = { + ca: opts.https.certificateAuthority, + cert: opts.https.certificate, + key: opts.https.key, + rejectUnauthorized: opts.https.rejectUnauthorized, + }; + opts.agent ||= {}; + // @ts-expect-error - For some reason the typings for HttpProxyAgent is reporting the agentOpts arg as `never`. + opts.agent.http ||= new HttpProxyAgent(proxy, agentOpts); + // @ts-expect-error - For some reason the typings for HttpsProxyAgent is reporting the agentOpts arg as `never`. + opts.agent.https ||= new HttpsProxyAgent(proxy, agentOpts); + } + + return opts; } /** @@ -138,7 +157,7 @@ export function options(opts: any = {}) { * @returns {Function} A `got` instance. */ export function init(opts = {}) { - return got.extend(options(opts)); + return got.extend(options(opts)); } export default init; @@ -154,46 +173,285 @@ export default init; * @returns {Object} */ export function createRequestOptions(opts = {}, config?): any { - if (opts instanceof Config) { - config = opts; - opts = {}; - } else if (!opts && typeof opts !== 'object') { - throw new TypeError('Expected options to be an object'); - } else { - opts = { ...opts }; - } - - if (config && !(config instanceof Config)) { - throw new TypeError('Expected config to be an Amplify Config instance'); - } - - const load = async (src, dest) => { - if (opts[dest] !== undefined) { - return; - } - if (!config) { - config = await loadConfig(); - } - const value = await config.get(src); - if (value === undefined) { - return; - } - if (dest === 'proxy') { - opts[dest] = value; - } else if (dest === 'strictSSL') { - opts[dest] = !!value !== false; - } else { - opts[dest] = fs.readFileSync(value); - } - }; - - load('network.caFile', 'ca'); - load('network.certFile', 'cert'); - load('network.keyFile', 'key'); - load('network.proxy', 'proxy'); - load('network.httpsProxy', 'proxy'); - load('network.httpProxy', 'proxy'); - load('network.strictSSL', 'strictSSL'); - - return opts; + if (opts instanceof Config) { + config = opts; + opts = {}; + } else if (!opts && typeof opts !== "object") { + throw new TypeError("Expected options to be an object"); + } else { + opts = { ...opts }; + } + + if (config && !(config instanceof Config)) { + throw new TypeError("Expected config to be an Amplify Config instance"); + } + + const load = async (src, dest) => { + if (opts[dest] !== undefined) { + return; + } + if (!config) { + config = await loadConfig(); + } + const value = await config.get(src); + if (value === undefined) { + return; + } + if (dest === "proxy") { + opts[dest] = value; + } else if (dest === "strictSSL") { + opts[dest] = !!value !== false; + } else { + opts[dest] = fs.readFileSync(value); + } + }; + + load("network.caFile", "ca"); + load("network.certFile", "cert"); + load("network.keyFile", "key"); + load("network.proxy", "proxy"); + load("network.httpsProxy", "proxy"); + load("network.httpProxy", "proxy"); + load("network.strictSSL", "strictSSL"); + + return opts; } + +// ____ ENGAGE _______ + +type DataServiceMethods = { + post: ( + url: string, + body: object, + headers?: object, + params?: object + ) => Promise; + put: ( + route: string, + body: object, + headers?: object, + params?: object + ) => Promise; + get: (url: string, params?: object) => Promise; + head: (url: string, params?: object) => Promise; + getWithPagination: ( + url: string, + params?: object, + pageSize?: number, + progressListener?: ProgressListener + ) => Promise; + delete: (url: string, params?: object) => Promise; + download: (url: string) => Promise; +}; + +const handleResponse = (response: any) => { + return /application\/json/.test(response.headers["content-type"]) + ? JSON.parse(response.body) + : response.body; +}; + +const updateRequestError = (err: Error) => { + // Do not change given object if it's a timeout error. + if (err instanceof TimeoutError) { + return; + } + + // If we have a JSON HTTP body, then turn it into a dictionary. + let jsonBody = null; + if (err instanceof RequestError && err.response?.body) { + jsonBody = handleResponse(err.response); + } + if (!jsonBody) { + return; + } + + // Turn given Error object into an "ApiServerError" or "ApiServerErrorResponse" object. + if ( + typeof jsonBody.code === "number" && + typeof jsonBody.description === "string" + ) { + // We received a "Platform" server error response. + (err as any).status = jsonBody.code; + (err as any).detail = jsonBody.description; + } else { + // Assume we received a "Central" server error response which should already conform to "ApiServerError". + Object.assign(err, jsonBody); + } +}; + +/** + * Creates an object with various functions communicating with the API Server. + * @param {String} clientId Client id to use. + * @param {String} [team] The preferred team to use. This value overrides the default from the Axway CLI config. + * @param {String} [region] The preferred region to use. + * @returns Object containing data retrieval functions. + */ +export const dataService = async ({ + account, +}: { + account?: any; +}): Promise => { + // TODO: add 'X-Axway-Tenant-Id' header is added to the request and it is fetched from the account. + const token = account.auth?.tokens?.access_token; + if (!token) { + throw new Error("Invalid/expired account"); + } + const headers: any = { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "X-Axway-Tenant-Id": account.org.org_id, + }; + const got = init(createRequestOptions({ headers })); + const fetch = async ( + method: string, + url: string, + params = {} + ): Promise => { + try { + // add the team guid - TODO: add this team validtion part of the command. + // if (teamGuid !== undefined) { + // const parsed = new URL(url); + // parsed.searchParams.set( + // "query", + // teamGuid + // ? `owner.id==${teamGuid},(owner.id==null;metadata.scope.owner.id==${teamGuid})` + // : "owner.id==null" + // ); + // url = parsed.toString(); + // } + + const response = await got[method](url, { + followRedirect: false, + retry: 0, + timeout: ABORT_TIMEOUT, + ...params, + }); + + return response; + } catch (err: any) { + updateRequestError(err); + throw err; + } + }; + + return { + post: (url: string, data: object, headers = {}) => { + log(`POST: ${url}`); + log(data); + return fetch("post", url, { + headers: headers, + json: data, + }).then(handleResponse); + }, + put: (url: string, data: object, headers = {}) => { + log(`PUT: ${url}`); + return fetch("put", url, { + headers: headers, + json: data, + }).then(handleResponse); + }, + get: (url: string, params = {}) => { + log(`GET: ${url}`); + return fetch("get", url, params).then(handleResponse); + }, + head: (url: string, params?: object) => { + log(`HEAD: ${url}`); + return fetch("head", url, params).then((response) => { + return response.headers["x-axway-total-count"]; + }); + }, + /** + * Get the entire list using pagination. Method is trying to define total number of items based on response header + * and makes additional calls if needed to retrieve additional pages. + * Note: currently this only present correct results if response is an array (see the "allPages" var spread logic) + * @param route route to hit + * @param queryParams specific query params + * @param pageSize page size to use, by default = 50 + * @param headers headers to add + * @param progressListener invoked multiple times where argument is assigned progress value 0-100 + */ + getWithPagination: async function ( + url: string, + params: any = {}, + pageSize: number = 50, + progressListener?: ProgressListener + ) { + params.searchParams.pageSize = pageSize; + log(`GET (with auto-pagination): ${url}`); + const response = await fetch("get", url, params); + const totalCountHeader = response.headers["x-axway-total-count"]; + if (totalCountHeader === null || totalCountHeader === undefined) { + log( + `GET (with auto-pagination), warning: cannot figure out 'total count' header, resolving response as-is` + ); + return handleResponse(response); + } + + log( + `GET (with auto-pagination), 'total count' header found, count = ${totalCountHeader}, will fire additional GET calls if needed` + ); + const totalPages = Math.max( + Math.ceil(Number(totalCountHeader) / pageSize), + 1 + ); + const allPages = new Array(totalPages); + allPages[0] = handleResponse(response); + if (totalPages > 1) { + const limit = promiseLimit(8); // Limits number of concurrrent HTTP requests. + const otherPagesCalls = []; + let pageDownloadCount = 1; + const updateProgress = () => { + if (progressListener && totalPages > 4) { + progressListener( + Math.floor((pageDownloadCount / totalPages) * 100) + ); + } + }; + updateProgress(); + for (let pageIndex = 1; pageIndex < totalPages; pageIndex++) { + const thisPageIndex = pageIndex; + params.searchParams.page = `${thisPageIndex + 1}`; + + otherPagesCalls.push( + limit(async () => { + allPages[thisPageIndex] = await (this as DataServiceMethods).get( + url, + params + ); + pageDownloadCount++; + updateProgress(); + }) + ); + } + await Promise.all(otherPagesCalls); + } + return flatten(allPages); + }, + delete: (url: string, params = {}) => { + log(`DELETE: ${url}`); + return fetch("delete", url, params).then(handleResponse); + }, + download: async (url: string) => { + try { + return await new Promise((resolve, reject) => { + log(`DOWNLOAD: ${url}`); + const stream = got.stream(url, { + retry: { limit: 0 }, + timeout: { request: ABORT_TIMEOUT }, + }); + stream.on("response", (response: any) => { + if (response.statusCode < 300) { + resolve({ response, stream }); + } else { + reject(new Error()); + } + }); + stream.on("error", reject); + }); + } catch (err: any) { + updateRequestError(err); + throw err; + } + }, + }; +}; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 00000000..8ae5ab53 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,254 @@ +export const ABORT_TIMEOUT = + process.env.NODE_ENV === "test" + ? 1e3 + : process.env.DEBUG || process.env.SNOOPLOGG + ? 1e9 + : 30e3; + +export const MAX_TABLE_STRING_LENGTH = 50; +export const MAX_FILE_SIZE = + process.env.NODE_ENV === "test" ? 1e5 : 20 * 1024 * 1024; +export const MAX_CACHE_FILE_SIZE = 5 * 1024 * 1024; + +// 12 hours +export const CACHE_FILE_TTL_MILLISECONDS = + process.env.NODE_ENV === "test" ? 100 : 60000 * 60 * 12; +export const WAIT_TIMEOUT = process.env.NODE_ENV === "test" ? 1e3 : 1e4; + +/** + * Invoked multiple times to indicate progress on something, such as download progress. + * @param progress Value ranging from 0 to 100. + */ +export type ProgressListener = (progress: number) => void; + +/** + * ApiServer backend types + */ +export enum ApiServerVersions { + v1alpha1 = "v1alpha1", +} + +export enum LanguageTypes { + French = "fr-fr", + US = "en-us", + German = "de-de", + Portugese = "pt-br", +} + +export type ApiServerError = { + status: number; + title: string; + detail: string; + source?: object; + meta: { + regexp?: string; + instanceId: string; + tenantId: string; + authenticatedUserId: string; + transactionId: string; + }; +}; + +export type ApiServerErrorResponse = { + errors: ApiServerError[]; +}; + +export interface ResourceDefinition { + apiVersion: ApiServerVersions; + kind: "ResourceDefinition"; + name: string; // "environment" + group: string; //"management" + metadata: { + id: string; //'e4e08f487156b7c8017156b9eef60002'; + audit: { + createTimestamp: string; //'2020-04-07T22:19:18.141+0000'; + modifyTimestamp: string; //'2020-04-07T22:19:18.141+0000' + }; + scope: { + id: string; //'e4e08f487156b7c8017156b9ed930000'; + kind: string; //'ResourceGroup'; + name: string; //'management' + }; + resourceVersion: string; //'1609'; + references: any[]; //[]; + }; + spec: { + kind: string; // "Environment", + plural: string; //"environments", + scope?: { + kind: string; //'Environment' + }; + apiVersions?: { + name: string; + served: boolean; + deprecated: boolean; + }[]; + // note: making it optional for backward-compatible logic. + subResources?: { + names: string[]; + }; + references: { + toResources: { + kind: string; + group: string; + types: ("soft" | "hard")[]; + scopeKind?: string; + from?: { + subResourceName: string; + }; + }[]; + fromResources: { + kind: string; + types: ("soft" | "hard")[]; + scopeKind?: string; + from?: { + subResourceName: string; + }; + }[]; + }; + }; +} + +export interface CommandLineInterfaceColumns { + name: string; //'Name'; + type: string; //'string'; + jsonPath: string; //'.name'; + description: string; //'The name of the environment.'; + hidden: boolean; //false +} + +export interface CommandLineInterface { + apiVersion: ApiServerVersions; + kind: "CommandLineInterface"; + name: string; // "environment" + spec: { + names: { + plural: string; // 'environments'; + // 10/2022 note: "singular" value is not always equal to the "name" value anymore + singular: string; // 'environment'; + shortNames: string[]; // ['env', 'envs']; + shortNamesAlias?: string[]; // ['env'] + }; + columns: CommandLineInterfaceColumns[]; + resourceDefinition: string; //'environment'; + }; + metadata: { + scope: { + name: string; // 'management' + }; + }; +} + +export interface AuditMetadata { + createTimestamp: string; // '2020-08-04T21:05:32.106Z'; + createUserId: string; // '07e6b449-3a31-4a96-8920-e87dd504cb87'; + modifyTimestamp: string; // '2020-08-04T21:05:32.106Z'; + modifyUserId: string; // '07e6b449-3a31-4a96-8920-e87dd504cb87'; +} + +interface Scope { + id: string; + kind: Kind; + name: string; +} + +export enum Kind { + Environment = "Environment", + APIService = "APIService", + APIServiceRevision = "APIServiceRevision", + APIServiceInstance = "APIServiceInstance", + Asset = "Asset", + AssetMapping = "AssetMapping", + Product = "Product", + ReleaseTag = "ReleaseTag", + Secret = "Secret", + Webhook = "Webhook", + ConsumerSubscriptionDefinition = "ConsumerSubscriptionDefinition", + ConsumerInstance = "ConsumerInstance", +} + +export interface Metadata { + audit: AuditMetadata; + resourceVersion?: string; + id: string; + scope?: Scope; + references: { + id: string; // e4e0900570caf70701713be3e36a076e + kind: string; // "Secret" + name: string; // secret1 + types: ["soft", "hard"]; + }[]; +} + +export interface GenericResource { + apiVersion: string; + group: string; + title: string; + name: string; + kind: string; + attributes: object; + tags: string[]; + // note: metadata is not an optional when received from the api-server but + // might be missing in some of our castings and in the resources from a file + metadata?: Metadata; + spec: any; + // note: have to include "any" indexed type for allowing sub-resources + [subresource: string]: any; +} + +export type GenericResourceWithoutName = Omit & { + name?: string; +}; + +/** + * Client's types + */ +export type ApiServerClientListResult = { + data: null | GenericResource[]; + error: null | ApiServerError[]; +}; + +export type ApiServerSubResourceOperation = { + name: string; + operation: () => Promise; +}; + +export type ApiServerClientSingleResult = { + data: null | GenericResource; + updatedSubResourceNames?: string[]; + warning?: boolean; + error: null | ApiServerError[]; + pending?: null | Array; +}; + +export type ApiServerClientApplyResult = { + data?: null | GenericResource; + wasAutoNamed?: boolean; + wasCreated?: boolean; + wasMainResourceChanged?: boolean; + updatedSubResourceNames?: string[]; + error?: { + name: string; + kind: string; + error: ApiServerError | Error | { detail: string; title?: string }; + }[]; +}; + +export type ApiServerClientBulkResult = { + success: GenericResource[]; + error: ApiServerClientError[]; + warning?: GenericResource[]; +}; + +export type ApiServerClientError = { + name: string; + kind: string; + error: ApiServerError | Error | { detail: string; title?: string }; +}; + +export type GetSpecsResult = { + [groupName: string]: { + resources: Map; + cli: Map; + }; +}; diff --git a/src/lib/utils/utils.ts b/src/lib/utils/utils.ts new file mode 100644 index 00000000..45e6c5bf --- /dev/null +++ b/src/lib/utils/utils.ts @@ -0,0 +1,140 @@ +import chalk from "chalk"; +import { + ApiServerVersions, + GenericResource, + GenericResourceWithoutName, + LanguageTypes, + Metadata, + ResourceDefinition, +} from "../types.js"; + +export function ValueFromKey( + stringEnum: { [key: string]: string }, + key: string, +): string | undefined { + for (const k of Object.values(stringEnum)) { + if (k === stringEnum[key]) return k; + } + return undefined; +} + +export const createLanguageSubresourceNames = (langCode: string) => { + const langCodeArr = langCode.split(","); + let langSubresourceNamesArr = ["languages"]; + let languageTypesArr: (string | undefined)[] = []; + Object.keys(LanguageTypes).forEach((key) => + languageTypesArr.push(ValueFromKey(LanguageTypes, key)), + ); + langCodeArr.forEach((langCode) => { + if (langCode.trim() != "") { + if (!languageTypesArr.includes(langCode)) { + console.log( + chalk.yellow( + `\n\'${langCode}\' language code is not supported, hence create/update cannot be performed on \'languages-${langCode}\. Allowed language codes: ${LanguageTypes.French} | ${LanguageTypes.German} | ${LanguageTypes.US} | ${LanguageTypes.Portugese}.'`, + ), + ); + } else { + langSubresourceNamesArr.push(`languages-${langCode.trim()}`); + } + } + }); + + return langSubresourceNamesArr; +}; + +export const getLatestServedAPIVersion = ( + resourceDef: ResourceDefinition, +): string => { + let apiVersions = resourceDef.spec.apiVersions; + if (apiVersions && apiVersions.length > 0) { + for (const version of apiVersions) { + if (version.served && !version.deprecated) { + return version.name; + } + } + return ApiServerVersions.v1alpha1; + } + // if the apiVersions are not set on the resource definition, fallback to v1alpha1 version + return ApiServerVersions.v1alpha1; +}; + +/** + * Api-server returns the "resourceVersion" in metadata object as a counter for resource updates. + * If a user will send this key in the payload it will throw an error so using this helper to sanitizing metadata on + * the updates. + * @param doc resource data + * @returns {GenericResource} resource data without metadata.resourceVersion key + */ +export function sanitizeMetadata(doc: GenericResource): GenericResource; +export function sanitizeMetadata( + doc: GenericResourceWithoutName, +): GenericResourceWithoutName; +export function sanitizeMetadata( + doc: GenericResource | GenericResourceWithoutName, +): GenericResource | GenericResourceWithoutName { + if (doc?.metadata?.resourceVersion) { + delete doc.metadata.resourceVersion; + } + return doc; +} + +/** + * Generate a GenericResource instance from resource definition, resource name, and scope name. Used + * in some rendering logic for the "delete" command. + * Note that generated metadata includes only scope info. + * @param {ResourceDefinition} resourceDef resource definition + * @param {string} resourceName resource name + * @param {string} scopeName optional scope name + * @returns {GenericResource} generic resource representation + */ +export const buildGenericResource = ({ + resourceDef, + resourceName, + scopeName, +}: { + resourceDef: ResourceDefinition; + resourceName?: string; + scopeName?: string; +}): GenericResource | GenericResourceWithoutName => { + if (resourceName) { + return { + apiVersion: resourceDef?.apiVersion, + group: resourceDef?.group, + title: resourceName, + name: resourceName, + kind: resourceDef?.spec.kind, + attributes: {}, + tags: [], + metadata: + resourceDef?.spec?.scope && scopeName + ? ({ + scope: { + kind: resourceDef?.spec?.scope?.kind, + name: scopeName, + }, + // note: forced conversion here only because using generated resources for rendering simple text + } as unknown as Metadata) + : undefined, + spec: {}, + }; + } else { + return { + apiVersion: resourceDef?.apiVersion, + group: resourceDef?.group, + kind: resourceDef?.spec.kind, + attributes: {}, + tags: [], + metadata: + resourceDef?.spec?.scope && scopeName + ? ({ + scope: { + kind: resourceDef?.spec?.scope?.kind, + name: scopeName, + }, + // note: forced conversion here only because using generated resources for rendering simple text + } as unknown as Metadata) + : undefined, + spec: {}, + }; + } +};