From abd2870b6648187c87451980b3c04ead225729dc Mon Sep 17 00:00:00 2001 From: Deepak Kasu Date: Wed, 18 Mar 2026 10:08:55 -0700 Subject: [PATCH] Engage CLI - Migrate & Refactor Results Rendering Functionality --- package-lock.json | 55 + package.json | 1 + src/lib/cache/CacheController.ts | 8 +- src/lib/clients-external/apiserverclient.ts | 2340 +++++++++---------- src/lib/command.ts | 62 +- src/lib/request.ts | 710 +++--- src/lib/results/cliconfigmanager.ts | 86 + src/lib/results/compositeerror.ts | 93 + src/lib/results/coreconfigcontroller.ts | 262 +++ src/lib/results/renderer.ts | 434 ++++ src/lib/results/resultsrenderer.ts | 167 ++ src/lib/types.ts | 361 +-- src/lib/utils/utils.ts | 30 +- 13 files changed, 2873 insertions(+), 1736 deletions(-) create mode 100644 src/lib/results/cliconfigmanager.ts create mode 100644 src/lib/results/compositeerror.ts create mode 100644 src/lib/results/coreconfigcontroller.ts create mode 100644 src/lib/results/renderer.ts create mode 100644 src/lib/results/resultsrenderer.ts diff --git a/package-lock.json b/package-lock.json index 1778aa0c..657ea9ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "cli-table3": "^0.6.5", "dayjs": "^1.11.19", "debug": "^4.4.3", + "easy-table": "^1.2.0", "fastest-levenshtein": "^1.0.16", "got": "^14.6.2", "http-proxy-agent": "^7.0.2", @@ -2570,6 +2571,29 @@ "dev": true, "license": "MIT" }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -2697,6 +2721,27 @@ "dev": true, "license": "MIT" }, + "node_modules/easy-table": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.2.0.tgz", + "integrity": "sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "optionalDependencies": { + "wcwidth": "^1.0.1" + } + }, + "node_modules/easy-table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7167,6 +7212,16 @@ "node": ">= 0.8" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 4c4b2190..0fe853f0 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "cli-table3": "^0.6.5", "dayjs": "^1.11.19", "debug": "^4.4.3", + "easy-table": "^1.2.0", "fastest-levenshtein": "^1.0.16", "got": "^14.6.2", "http-proxy-agent": "^7.0.2", diff --git a/src/lib/cache/CacheController.ts b/src/lib/cache/CacheController.ts index ebb7fe01..4f4ec9bd 100644 --- a/src/lib/cache/CacheController.ts +++ b/src/lib/cache/CacheController.ts @@ -126,7 +126,7 @@ class CacheControllerClass implements Cache { < CACHE_FILE_TTL_MILLISECONDS ) { for (const [ key, val ] of Object.entries(storedCache.data)) { - if (storedCache.data.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(storedCache.data, key)) { this.cache.set(key, val); } } @@ -138,9 +138,8 @@ class CacheControllerClass implements Cache { } } catch (e) { log('cannot read cache from the file', e); - } finally { - return this; } + return this; } /** @@ -169,9 +168,8 @@ class CacheControllerClass implements Cache { writeToFile(this.cacheFilePath, JSON.stringify(dataToStore)); } catch (e) { log('cannot write cache to the file', e); - } finally { - return this; } + return this; } } diff --git a/src/lib/clients-external/apiserverclient.ts b/src/lib/clients-external/apiserverclient.ts index 63e37e95..a1a1a6fb 100644 --- a/src/lib/clients-external/apiserverclient.ts +++ b/src/lib/clients-external/apiserverclient.ts @@ -1,154 +1,154 @@ -import chalk from "chalk"; -import { log } from "console"; -import { dataService } from "../request.js"; +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"; + 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"; + 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; + 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; - } + 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}.'`, - ), - ); - } - }); - } + 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'); + const 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; - } + 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 @@ -162,93 +162,93 @@ export class ApiServerClient { * @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: + 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 }), - ), - }; - }); - } + 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 @@ -256,333 +256,333 @@ export class ApiServerClient { * @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: [], - }; + 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; - }, {}); + 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; - } + 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; - }), - ); - } + 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; - } + 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; - } + 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, - }); + 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; - } + 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 @@ -593,85 +593,85 @@ export class ApiServerClient { * 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; - } + 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 @@ -681,41 +681,41 @@ export class ApiServerClient { * 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; - } - } + 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 @@ -726,60 +726,60 @@ export class ApiServerClient { * 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; - } + 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 @@ -789,525 +789,521 @@ export class ApiServerClient { * 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; - } + 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 + // 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; - // }; - // } = {}; + // /** + // * 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; - // } - // } + // 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: [], - }; + 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; - } + 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 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; - } - } - } + 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, - }); - } - } - } + // 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; - } + 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 = []; + 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; - } + 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"; + 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; + // only making getResource call if resource has a name + const 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 }; - } + // 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); + // 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, + // 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, - }), - ); - } + 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; - } - } + // Delete the result's error array if it is empty. + if (!applyResult.error?.length) { + delete applyResult.error; + } + } - return applyResults; - } + 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; - } + 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; - } + 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/command.ts b/src/lib/command.ts index bc532907..7f69b864 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -1,8 +1,13 @@ -import { Command, type Config as OclifConfig, Flags, loadHelpClass } from '@oclif/core'; +import { + Command, + type Config as OclifConfig, + Flags, + loadHelpClass, +} from '@oclif/core'; import loadConfig, { type Config } from './config.js'; -import { initSDK } from './amplify-sdk/index.js'; - import type AmplifySDK from './amplify-sdk/index.js'; +// eslint-disable-next-line no-duplicate-imports +import { initSDK } from './amplify-sdk/index.js'; import type { FlagInput, ParserOutput } from '@oclif/core/interfaces'; @@ -24,8 +29,8 @@ interface AxwayParserOutput extends ParserOutput { export const authenticatedFlags = { account: Flags.string({ description: 'The account to use within the active profile.', - required: false - }) + required: false, + }), }; export default abstract class AxwayCommand extends Command { @@ -36,30 +41,30 @@ export default abstract class AxwayCommand extends Command { 'no-banner': Flags.boolean({ description: 'Disable the banner.', default: false, - helpGroup: 'GLOBAL' + helpGroup: 'GLOBAL', }), 'no-color': Flags.boolean({ description: 'Disable color output.', default: false, - helpGroup: 'GLOBAL' + helpGroup: 'GLOBAL', }), profile: Flags.string({ description: 'Specify the configuration profile to use.', - helpGroup: 'GLOBAL' - }) + helpGroup: 'GLOBAL', + }), }; /** - * Whether authentication is required to run this command. - * Defaults to true. - */ + * Whether authentication is required to run this command. + * Defaults to true. + */ static readonly authenticated: boolean = true; /** - * Whether this command should exclude the profile flag. Should only be set - * to true for commands that manage profiles themselves. - * Defaults to false. - */ + * Whether this command should exclude the profile flag. Should only be set + * to true for commands that manage profiles themselves. + * Defaults to false. + */ static readonly enableProfileFlag: boolean = true; override async parse(options = this.ctor as any): Promise { @@ -69,7 +74,7 @@ export default abstract class AxwayCommand extends Command { const data: AxwayParserOutput = { ...parsed, - config: await loadConfig() + config: await loadConfig(), }; // Load the config, applying the profile if specified. @@ -80,23 +85,34 @@ export default abstract class AxwayCommand extends Command { if (options.authenticated) { data.sdk = await initSDK(); - data.account = await data.sdk.auth.find(parsed.args?.accountName || parsed.flags?.account || data.config.get('auth.defaultAccount')); + data.account = await data.sdk.auth.find( + parsed.args?.accountName + || parsed.flags?.account + || data.config.get('auth.defaultAccount'), + ); if (!data.account) { if (parsed.args?.accountName || parsed.flags?.account) { - return this.error(`The account "${parsed.args?.accountName || parsed.flags?.account}" is not logged in.\n\nTo login, run: ${this.config.bin} auth login`); + return this.error( + `The account "${parsed.args?.accountName || parsed.flags?.account}" is not logged in.\n\nTo login, run: ${this.config.bin} auth login`, + ); } - return this.error(`You must be authenticated\n\nTo login, run: ${this.config.bin} auth login`); + return this.error( + `You must be authenticated\n\nTo login, run: ${this.config.bin} auth login`, + ); } - data.org = await data.sdk.org.find(data.account, parsed.args?.org || parsed.flags?.org); + data.org = await data.sdk.org.find( + data.account, + parsed.args?.org || parsed.flags?.org, + ); } return data; } /** - * Log command help output to stdout - */ + * Log command help output to stdout + */ async help() { const Help = await loadHelpClass(this.config); const help = new Help(this.config); diff --git a/src/lib/request.ts b/src/lib/request.ts index d99df76a..091021d2 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,26 +1,26 @@ -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"; +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. @@ -47,97 +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"]))})` + 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: + 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; + ? 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; } /** @@ -157,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; @@ -173,110 +173,110 @@ 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; + 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; + 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); - } + // 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); + } }; /** @@ -287,80 +287,79 @@ const updateRequestError = (err: Error) => { * @returns Object containing data retrieval functions. */ export const dataService = async ({ - account, + account, }: { - account?: any; + 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"]; - }); - }, - /** + 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) @@ -370,88 +369,89 @@ export const dataService = async ({ * @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; - } - }, - }; + 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( + // eslint-disable-next-line no-loop-func + 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/results/cliconfigmanager.ts b/src/lib/results/cliconfigmanager.ts new file mode 100644 index 00000000..cabe4652 --- /dev/null +++ b/src/lib/results/cliconfigmanager.ts @@ -0,0 +1,86 @@ +import { existsSync, outputJsonSync, readJsonSync } from 'fs-extra'; +import _ from 'lodash'; +import { homedir } from 'os'; +import { join } from 'path'; + +export enum CliConfigKeys { + APIC_DEPLOYMENT = 'apic-deployment', + BASE_URL = 'base-url', + ACCOUNT = 'account', + REGION = 'region', + PLATFORM = 'platform', + /* + note: "extensions" key is an object, current list of possible keys: + extensions.apigee + extensions.azure + extensions.bitbucket + extensions.github + extensions.layer7 + extensions.mulesoft + extensions.swaggerhub + */ + EXTENSIONS = 'extensions', + + // deprecated, keeping it here just for "unset" command, remove when all related functionality is gone + CLIENT_ID = 'client-id', +} + +type ConfigObject = { [k in CliConfigKeys]?: string }; + +export class CliConfigManager { + static configFilePath = join(homedir(), '.axway', 'central.json'); + + private saveToFile(values: ConfigObject) { + outputJsonSync(CliConfigManager.configFilePath, values, { spaces: '\t' }); + } + + /** + * Temporary validator for config file content. Needed only to cleanup some values from config files for a couple of + * versions, remove it after some time. + */ + validateSavedConfigKeys() { + const deprecatedKeys = [ + // TODO: a few other configs might be getting deprecated: https://jira.axway.com/browse/APIGOV-19737 + // CliConfigKeys.PLATFORM, + CliConfigKeys.CLIENT_ID, + ]; + const keysToRemove = Object.keys(this.getAll()).filter((key) => deprecatedKeys.includes(key as CliConfigKeys)); + if (keysToRemove.length) { + throw Error( + `Following Axway Central CLI config keys has been deprecated and no longer needed: ${keysToRemove.join(', ')} +Please unset by running: +${keysToRemove.map((key) => `axway central config unset ${key}`).join('\n')} + ` + ); + } + } + + // Note: current validation is good for "unset" but for "set" its needed to validate the value for "extensions" (should be non-empty) + validate(key: string) { + // validate 'extensions' keys - should alway have dot in the mid: extensions.abc + if (key.startsWith(`${CliConfigKeys.EXTENSIONS}`)) { + if (!key.includes('.')) { + throw Error(`invalid "${CliConfigKeys.EXTENSIONS}" key format.`); + } + } else if (!Object.values(CliConfigKeys).includes(key as CliConfigKeys)) { + throw Error(`central configuration doesn't support the "${key}" key.`); + } + } + + getAll(): ConfigObject { + return existsSync(CliConfigManager.configFilePath) ? readJsonSync(CliConfigManager.configFilePath) : {}; + } + + get(key: CliConfigKeys): string | undefined { + return this.getAll()[key]; + } + + // TODO + // set(key: CentralConfigKeys) {} + + unset(key: string) { + const config = this.getAll(); + _.unset(config, key); + this.saveToFile(config); + } +} diff --git a/src/lib/results/compositeerror.ts b/src/lib/results/compositeerror.ts new file mode 100644 index 00000000..06c099af --- /dev/null +++ b/src/lib/results/compositeerror.ts @@ -0,0 +1,93 @@ +/** This type is returnd by the CompositeError.toDictionary() method. Intended to be outputted to JSON or YAML. */ +export interface CompositeErrorDictionary { + /** Name of the error type such as "CompositeError", "TypeError", "RangeError", etc. */ + name: string; + + /** The error object's message. */ + message: string; + + /** Provides array of errors if this is a CompositeError type. Will be undefined for other error types. */ + nestedErrors?: CompositeErrorDictionary[]; +} + +/** Error object which can provide nested errors to indicated if multiple errors have occurred. */ +export class CompositeError extends Error { + /** Array of errors wrapped by this error object. */ + #nestedErrors: Error[]; + + /** + * Creates a new error wrapping the given array of errors. + * @param errors Array of errors to be owned by this composite error. Can contain CompositeError instances. + * @param message Optional error message to be displayed by this root error object. + */ + constructor(errors: Error[], message?: string) { + super(message); + this.#nestedErrors = errors; + } + + /** Gets the name of this error type. */ + get name(): string { + return 'CompositeError'; + } + + /** Gets an array of error objects owned by this composite error. */ + get nestedErrors(): Error[] { + return this.#nestedErrors; + } + + /** + * Creates a dictionary providing the name, message, and array of all errors nested under this error object. + * Intended to be outputted to JSON or YAML. + * @returns Returns a dictionary of this error object and all nested error objects. + */ + toDictionary(): CompositeErrorDictionary { + const dictionary: CompositeErrorDictionary = { + name: this.name, + message: this.message, + nestedErrors: [], + }; + for (const nextError of this.#nestedErrors) { + if (nextError instanceof CompositeError) { + dictionary.nestedErrors!.push(nextError.toDictionary()); + } else { + dictionary.nestedErrors!.push({ + name: nextError.name, + message: nextError.message, + }); + } + } + return dictionary; + } + + /** + * Creates an array of strings providing this object's error message and all of its nested error messages. + * Each error object's message is added to its own array entry and will be indented according to nested position. + * @returns Returns an array of all error messages that are indented according to their nested position. + */ + toNestedMessageArray(): string[] { + const messageLines: string[] = []; + if (this.message) { + messageLines.push(this.message); + } + for (const nextError of this.#nestedErrors) { + if (nextError instanceof CompositeError) { + for (const nextLine of nextError.toNestedMessageArray()) { + const prefix = nextLine.startsWith('-') ? ' ' : '* '; + messageLines.push(prefix + nextLine); + } + } else { + messageLines.push(`- ${nextError.message || 'Unknown error'}`); + } + } + return messageLines; + } + + /** + * Creates an error message containing this object's message and all nested error messages separated by newlines + * and indented according to their nested position. + * @returns Returns a single string containing all nested error messages separated by newlines. + */ + toNestedMessageString(): string { + return this.toNestedMessageArray().join('\n'); + } +} diff --git a/src/lib/results/coreconfigcontroller.ts b/src/lib/results/coreconfigcontroller.ts new file mode 100644 index 00000000..d7a76649 --- /dev/null +++ b/src/lib/results/coreconfigcontroller.ts @@ -0,0 +1,262 @@ +import { initSDK, loadConfig } from '@axway/amplify-cli-utils'; +import snooplogg from 'snooplogg'; +import { CliConfigKeys, CliConfigManager } from './cliconfigmanager.js'; +import { AuthUrls, Platforms, PlatformTeam, PreprodBaseUrls, ProdBaseUrls } from './types'; + +const { log } = snooplogg('central: CoreConfigController'); + +// TODO: https://jira.axway.com/browse/APIGOV-20520 +// interface AuthenticationError extends Error { +// errors?: Array; +// } + +type DosaUserInfo = { + axwayId: null; + organization: null; +}; + +type RegularUserInfo = { + email: string; // 'agrakhov@axway.com'; + firstName: string; // 'Alexey'; + guid: string; // '07e6b449-3a31-4a96-8920-e87dd504cb87'; + lastName: string; // 'Grakhov'; +}; + +type RegularUserOrgInfoV4 = { + id: number; // 576227026211882; + // note: entitlements, name, region, guid are optional only because of the v2-v4 mapper, + // not optional on v4 account object itself. + entitlements?: object; // entitlements": { "partners": ["api_central"] } + guid?: string; // '3bcf145c-9f77-48fe-9479-68b094febabc'; + name?: string; // 'Vertex'; + region?: string; // 'US' + teams: PlatformTeam[]; +}; + +type DosaOrgInfoV4 = { + org_id: string; // "576227026211882", + // note: guid is optional only because of the v2-v4 mapper + guid?: string; // "3bcf145c-9f77-48fe-9479-68b094febabc" + name: string; + region?: string; // 'US' + teams: PlatformTeam[]; +}; + +export enum AccountRole { + AnalyticsSpecialist = 'analytics_specialist', + ApiCentralAdmin = 'api_central_admin', + FileTransferServicesAdmin = 'fts_admin', + FlowCentralAccessManager = 'fc_access_manager', + FlowCentralIntegration = 'fc_integration', + FlowCentralITAdmin = 'fc_it_admin', + FlowCentralProductsAdmin = 'fc_products_admin', + FlowCentralSpecOps = 'fc_spec_ops', + FlowCentralSubscriptionApprover = 'fc_subscriptionapprover', + FlowCentralSubscriptionSpecialist = 'fc_subscriptionspecialist', + FlowCentralTemplatePublisher = 'fc_templatepublisher', + FlowCentralCftAdmin = 'fc_cft_admin', + PlatformAdmin = 'administrator', + PlatformAuditor = 'auditor', + PlatformCollaborator = 'collaborator', + PlatformConsumer = 'consumer', + PlatformDeveloper = 'developer', + PlatformReadOnly = 'read_only', + RuntimeServicesAdmin = 'ars_admin', +} + +export interface AccountV4 { + auth: { + authenticator: string; // 'PKCE'; + baseUrl: string; // 'https://login.axwaytest.net'; + clientId: string; // 'amplify-cli'; + env: string; // 'staging'; + expires: { + access: number; // 1602703437986; + refresh: number; // 1602723237986; + }; + realm: 'Broker'; + tokens: { + access_token: string; // 'eyJhb...ReBMg'; + expires_in: number; // 1800; + refresh_expires_in: number; // 21600; + refresh_token: string; // 'eyJhbG...p5To'; + token_type: 'bearer'; + id_token: string; // 'eyJh...Njg'; + 'not-before-policy': number; // 1552677851; + session_state: string; // '35733295-1631-4b33-adcc-bebb50caed55'; + scope: string; // 'openid'; + }; + }; + default: boolean; + hash: string; // 'amplify-cli:fd0b1f4328d48b7700878f62f8f23afb'; + name: string; // 'amplify-cli:agrakhov@axway.com'; + org: RegularUserOrgInfoV4 | DosaOrgInfoV4; + orgs: (Pick | DosaOrgInfoV4)[]; + role: AccountRole; + roles: AccountRole[]; + user: RegularUserInfo | DosaUserInfo; + // isPlatform = false for service accounts + isPlatform: boolean; + // sid is not available for DOSA accounts. + sid?: string; // 's:e8eKeurfiOarqfWcOMOSItsz-EE8nMP2.yCZKRkPu7zuAZE0aDJUoNbExnqR2Uwt+wnz6KcVSeaA'; + team: PlatformTeam; +} + +export interface AmplifySDK { + auth: { + find: Function; + list: Function; + }; + team: { + list: Function; + }; +} + +interface AuthInfoResult { + orgId?: string; + orgRegion?: string; + teamGuid?: string | null; + token: string; +} + +export class CoreConfigController { + static devOpsAccount: AccountV4 | null = null; + + /** + * Get authentication info + * @param {String} [account] The account name to use, otherwise fallsback to the default from + * the Axway CLI config. + * @param {String} [team] The team name or guid to use, otherwise fallsback to the default from + * the Axway CLI config. + * @returns object containing token and orgId. For service accounts orgId is undefined. + * @throws 401 if no authenticated account found. + */ + async getAuthInfo({ + account, + team, + forceGetAuthInfo, + }: { + account?: string; + team?: string | null; + forceGetAuthInfo?: boolean; + } = {}): Promise { + const configCtrl = new CliConfigManager(); + const config = loadConfig(); + + // note: remove this validator after couple of versions + configCtrl.validateSavedConfigKeys(); + + log(`getAuthInfo, received account = ${account}, team = ${team}`); + + const baseUrl = process.env.AXWAY_CENTRAL_BASE_URL || configCtrl.get(CliConfigKeys.BASE_URL); + + // environment defined by using central cli "base-url" or axway "env" configs if set, + // otherwise its undefined (equals to prod) + const environment + = !baseUrl + || baseUrl === ProdBaseUrls.US + || baseUrl === ProdBaseUrls.EU + || baseUrl === ProdBaseUrls.AP + || baseUrl === PreprodBaseUrls.US + || baseUrl === PreprodBaseUrls.EU + ? config.get('env') + : 'staging'; + log(`getAuthInfo, baseUrl = ${baseUrl}, environment = ${environment}`); + + const { sdk } = initSDK({ env: environment }, config); + let { devOpsAccount } = CoreConfigController; + if (forceGetAuthInfo) { + devOpsAccount = null; + } + + if (!devOpsAccount || (account && devOpsAccount.name !== account)) { + log('getAuthInfo, no cached devOpsAccount found, or account name does not match'); + + if (account) { + // ELSE IF: account name passed - ignoring defaultAccount and other accounts + log('getAuthInfo, account value passed, trying to find a matching account'); + devOpsAccount = await sdk.auth.find(account); + } else { + // ELSE: trying to get any authenticated account + log('getAuthInfo, account value not passed, trying to find default/any match'); + const list: AccountV4[] = await sdk.auth.list({ validate: true }); + log(`getAuthInfo, authenticated accounts found: ${list.length}`); + + if (list.length === 1) { + log(`getAuthInfo, using a single account found with name: ${list[0].name}`); + devOpsAccount = list[0]; + } else if (list.length > 1) { + // try to find the default account + devOpsAccount + = list.find((a) => a.name === config.get('auth.defaultAccount')) || list.find((a) => a.default) || list[0]; + } + } + + if (!devOpsAccount) { + // TODO: piece of old logic here, move throwing out of the method? + // temporary commenting out the new functionality and reverting back to the old one, will be fixed with: + // https://jira.axway.com/browse/APIGOV-20520 + log('getAuthInfo, no devOpsAccount set after all, throwing 401'); + // const title: string = accountName + // ? `Account "${accountName}" cannot be found` + // : 'No authenticated accounts found.'; + // const err: AuthenticationError = new Error(title); + // err.errors = [{ status: 401, title }]; + // throw err; + throw { + errors: [ + { + status: 401, + title: account ? `Account "${account}" cannot be found` : 'No authenticated accounts found.', + }, + ], + }; + } + + CoreConfigController.devOpsAccount = devOpsAccount; + } + + const result: AuthInfoResult = { + orgId: (devOpsAccount?.org as RegularUserOrgInfoV4)?.id?.toString(), + orgRegion: devOpsAccount.org?.region, + token: + process.env.AXWAY_CENTRAL_AUTH_TOKEN || config.get('central.authToken', devOpsAccount.auth.tokens.access_token), + }; + + // now that we have resolved the account, we can validate the team + if (team) { + const { teams } = await sdk.team.list(devOpsAccount); + const teamObj = teams.find((t: PlatformTeam) => { + return t.guid.toLowerCase() === team.toLowerCase() || t.name.toLowerCase() === team.toLowerCase(); + }); + + if (!teamObj) { + throw new Error(`Unable to find team "${team}" in the "${devOpsAccount.org.name}" organization`); + } + + result.teamGuid = teamObj.guid; + } else if (team === null) { + result.teamGuid = null; + } + + log(`getAuthInfo, returning account = ${devOpsAccount.name}`); + log( + `getAuthInfo, returning token = ${result.token.substring(0, 5)}*****${result.token.substring( + result.token.length - 5 + )}` + ); + log(`getAuthInfo, returning orgId = ${result.orgId}`); + log(`getAuthInfo, returning orgRegion = ${result.orgRegion}`); + log(`getAuthInfo, returning teamGuid = ${result.teamGuid}`); + + return result; + } + + static getEnv(): Platforms { + return (CoreConfigController.devOpsAccount?.auth?.env as Platforms) || Platforms.prod; + } + + static getAuthUrl(): AuthUrls { + return (CoreConfigController.devOpsAccount?.auth?.baseUrl as AuthUrls) || AuthUrls.Prod; + } +} diff --git a/src/lib/results/renderer.ts b/src/lib/results/renderer.ts new file mode 100644 index 00000000..88b74389 --- /dev/null +++ b/src/lib/results/renderer.ts @@ -0,0 +1,434 @@ +import { chalk } from 'cli-kit'; +import ora from 'ora'; +import { + ApiServerClientApplyResult, + ApiServerClientBulkResult, + ApiServerClientListResult, + ApiServerClientSingleResult, + ApiServerError, + ApiServerErrorResponse, + CommandLineInterfaceColumns, + GenericResource, + OutputTypes, +} from '../types.js'; +import { CompositeError } from './compositeerror.js'; +import { renderResponse } from './resultsrenderer.js'; +import { isApiServerErrorResponseType, isApiServerErrorType } from '../utils/utils.js'; + +export interface RenderGetResultsInput { + response: ApiServerClientSingleResult | ApiServerClientListResult; + columns: CommandLineInterfaceColumns[]; +} + +export default class Renderer { + private spinner: ora.Ora | null; + private _console: Console; + private output: OutputTypes | undefined; + + constructor(console: Console, output?: OutputTypes) { + this.spinner = process.env['DEBUG'] || !!output ? null : ora({ spinner: 'dots3' }); + this._console = console; + this.output = output; + } + + /** + * Start the spinner and return self + * @param text text to display near the spinner + */ + startSpin(text: string): Renderer { + this.spinner && this.spinner.start(text); + return this; + } + + /** + * Stop the spinner + */ + stopSpin(): any { + this.spinner && this.spinner.stop(); + return this; + } + + /** + * Replaces the text shown by startSpin(). Intended to show progress. + * @param text The text to displayed next to the spinner. + */ + updateSpinText(text: string): void { + if (this.spinner) { + this.spinner.text = text; + } + } + + /** + * Print simple text to console + * @param text text to render + */ + console(text: string): void { + this._console.log(text); + } + + /** + * Render success message. If output param has been provided use simple console + * otherwise use spinner. + * @param text text to render + * @param spinnerOnly optional, if its true message will be rendered + * only when spinner is in use (which mean no output param has been provided) + */ + success(text: string, spinnerOnly: boolean = false): void { + this.output && !spinnerOnly + ? this.console(text) + : this.spinner && this.spinner.succeed(chalk`{greenBright ${text}}`); + } + + /** + * Render warning message. If output param has been provided use simple console + * otherwise use spinner. + * @param text text to render. + * @param spinnerOnly optional, if its true message will be rendered + * only when spinner is in use (which mean no output param has been provided) + */ + warning(text: string, spinnerOnly: boolean = false): void { + this.output && !spinnerOnly ? this.console(text) : this.spinner && this.spinner.warn(chalk`{yellow ${text}}`); + } + + /** + * Render error message. If output param has been provided use simple console + * otherwise use spinner. + * @param text text to render + * @param spinnerOnly optional, if true message will be rendered + * only when spinner is in use (which mean no output param has been provided) + */ + error(text: string, spinnerOnly: boolean = false): void { + this.output && !spinnerOnly ? this.console(text) : this.spinner && this.spinner.fail(chalk`{red ${text}}`); + } + + /** + * A helper returning `"/" in the scope "/"` + * string used to print bulk results (in "bulkResult" or "bulkCreateOrUpdateResult") or individually (see the "delete" cmd) + * @param {GenericResource} resource resource for witch the string should be created + * @returns {string} + */ + resourceAndScopeKinds(resource: GenericResource): string { + // prettier-ignore + return `"${resource.kind}/${resource.name}"${resource.metadata?.scope ? ` in the scope "${resource.metadata.scope.kind}/${resource.metadata.scope.name}"` : ''}`; + } + + /** + * A helper returning `"/" subresource "" in the scope "/"` + * string used to print bulk results (in "bulkResult" or "bulkCreateOrUpdateResult") or individually (see the "delete" cmd) + * @param {GenericResource} resource resource for witch the string should be created + * @param {string} subResourceName of the subresource that was updated + * @returns {string} + */ + subResourceAndScopeKinds(resource: GenericResource, subResourceName: string): string { + let message = `"${resource.kind}/${resource.name}" subresource "${subResourceName}"`; + if (resource.metadata?.scope) { + message += ` in the scope "${resource.metadata.scope.kind}/${resource.metadata.scope.name}"`; + } + return message; + } + + /** + * Render bulk call result. + * If error is happening - render as simple output (even if "output" param has been provided) + * @param bulkResult bulk response from ApiServerClient + * @param simpleSuccessMsg message to display for each created "kind/name" + */ + bulkResult(bulkResult: ApiServerClientBulkResult, simpleSuccessMsg: string): void { + if (bulkResult.error.length) { + bulkResult.warning?.forEach((r) => + this.warning(`${this.resourceAndScopeKinds(r)} was created with an autogenerated logical name.`) + ); + bulkResult.success.forEach((r) => this.success(`${this.resourceAndScopeKinds(r)} ${simpleSuccessMsg}`)); + bulkResult.error.forEach((r) => this.anyError(r.error as ApiServerError, `"${r.kind}/${r.name}" `, true)); + } else if (this.output) { + let results = bulkResult.success; + if (bulkResult.warning?.length) { + results = bulkResult.warning.concat(results); + } + renderResponse(this._console, results, this.output); + } else { + bulkResult.warning?.forEach((r) => + this.warning(`${this.resourceAndScopeKinds(r)} was created with an autogenerated logical name.`) + ); + bulkResult.success.forEach((r) => this.success(`${this.resourceAndScopeKinds(r)} ${simpleSuccessMsg}`)); + } + } + + /** + * Render bulk call result. + * If error is happening - render as simple output (even if "output" param has been provided) + * @param bulkResult bulk response from ApiServerClient + * @param simpleSuccessMsg message to display for each created "kind/name" + */ + productizationResult(bulkResultMap: Map): void { + bulkResultMap.forEach((value, key) => { + console.log('\n\n' + 'API Service: ' + key); + if (value.warning && value.warning.length > 0) { + value.warning?.forEach((r) => + this.success(`${this.resourceAndScopeKinds(r)} was created successfully with an autogenerated logical name.`) + ); + } + if (value.error.length > 0) { + value.error.forEach((r) => this.anyError(r.error as ApiServerError, `"${r.kind}/${r.name}" `, true)); + if (key) { + this.warning(`Unable to productize API Service '${key}' for the above errors.`); + } + } else { + console.log('API Service ' + '\'' + key + '\' has been successfully productized.'); + } + }); + } + + /** + * Render bulk "apply" result (with different success messages). + * If error is happening - render as simple output (even if "output" param has been provided) + * @param results array of responses from createOrUpdate ApiServerClient + */ + bulkCreateOrUpdateResult(results: Array): void { + if (results.every((r) => (r.error?.length ?? 0) === 0) && this.output) { + // Output responses as JSON/YAML, but only if no error responses were received. + const dataArray = results.map((r) => r.data).filter((r) => r !== null); + renderResponse(this._console, dataArray, this.output); + } else { + // Log results. + for (const result of results) { + if (result.data) { + if (result.wasAutoNamed) { + this.warning(this.resourceAndScopeKinds(result.data) + ' was created with an autogenerated logical name.'); + } else if (result.wasCreated) { + this.success(this.resourceAndScopeKinds(result.data) + ' has successfully been created.'); + } else if (result.wasMainResourceChanged) { + this.success(this.resourceAndScopeKinds(result.data) + ' has successfully been updated.'); + } + } + result.error?.forEach((r) => this.anyError(r.error as ApiServerError, `"${r.kind}/${r.name}" `, true)); + if (!result.wasMainResourceChanged && result.data) { + result.updatedSubResourceNames?.forEach((name) => + this.success(this.subResourceAndScopeKinds(result.data!, name) + ' has successfully been updated.') + ); + } + } + } + } + + renderGetResults(bulkResultsArray: RenderGetResultsInput[], successMsg: string, langDef?: string): void { + // sort all results by success / error + // IMPORTANT: mind the response.data! non-null assertion later on, should be covered by this loop + const sortedResults = bulkResultsArray.reduce<{ + success: RenderGetResultsInput[]; + error: RenderGetResultsInput[]; + }>( + (a, c) => { + c.response.error ? a.error.push(c) : a.success.push(c); + return a; + }, + { success: [], error: [] } + ); + + if (this.output) { + /** + * IF bulkResultsArray.length === 1 this means user query only for a single resource (entire list or by name, + * no comma separated: "axway central get env" or "axway central get env abc") so result should + * represent list or a single resource based on the received result from the server. In this case setting the + * dataToRender to first "success" element (sortedResults will also include only one element in this case). + */ + let dataToRender: object | object[] = []; + if (bulkResultsArray.length === 1) { + dataToRender = sortedResults.success[0]?.response?.data || {}; + } else { + /** + * ELSE user query for multiple resources (ie: "wh,secret"), which means all result should be presented as + * an array even if its the only one, so flatten the responses data and create a single array for rendering + */ + dataToRender = sortedResults.success.reduce((a, v) => { + Array.isArray(v.response.data) ? a.push(...v.response.data) : a.push(v.response.data!); + return a; + }, []); + } + if (langDef) { + dataToRender = this.formatLanguageDefinitionGetResults(dataToRender); + } + renderResponse(this._console, dataToRender, this.output); + } else { + // stop spinner and render a table for each successful request + if (sortedResults.success.length) { + this.success(successMsg, true); + } + const sortedSuccess = sortedResults.success.reduce<{ + empty: RenderGetResultsInput[]; + notEmpty: RenderGetResultsInput[]; + }>( + (a, c) => { + Array.isArray(c.response.data) && !c.response.data.length ? a.empty.push(c) : a.notEmpty.push(c); + return a; + }, + { empty: [], notEmpty: [] } + ); + // if all results are empty, render just once, otherwise render only successful results + if (!sortedSuccess.notEmpty.length && sortedSuccess.empty.length) { + renderResponse( + this._console, + sortedSuccess.empty[0].response.data!, + this.output, + sortedSuccess.empty[0].columns + ); + } else { + sortedSuccess.notEmpty.forEach((v) => renderResponse(this._console, v.response.data!, this.output, v.columns)); + } + } + // rendering errors only if there are zero successful results, + // also assuming only first (and only) error will be in api-server error response in case of 404 + if (!sortedResults.success.length) { + sortedResults.error.forEach( + (v) => v.response.error && this.error(`Error: ${v.response.error[0].detail || v.response.error[0].title}`) + ); + } + } + + /** + * Render any kind of error + * @param error error or ApiServer error to render + * @param prefixMsg a string to put before the error details (works only for api-server / known errors) + * @param ignoreOutputParam if provided as true will ignore the output param and always render + * the error message as set of strings. Currently used in bulk result renderers in case of any errors. + */ + formatLanguageDefinitionGetResults(data: any) { + let dataArr = []; + if (!Array.isArray(data)) { + dataArr.push(data); + } else { + dataArr = data; + } + + dataArr.forEach((data) => { + delete data?.metadata?.audit; + delete data?.metadata?.acl; + delete data?.metadata?.accessRights; + delete data?.metadata?.references; + delete data?.metadata?.scope?.title; + delete data?.languages?.metadata; + }); + + return dataArr; + } + + /** + * Render any kind of error + * @param error error or ApiServer error to render + * @param prefixMsg a string to put before the error details (works only for api-server / known errors) + * @param ignoreOutputParam if provided as true will ignore the output param and always render + * the error message as set of strings. Currently used in bulk result renderers in case of any errors. + */ + anyError(error: ApiServerError | ApiServerErrorResponse | Error, prefixMsg = '', ignoreOutputParam = false): void { + if ((this.output === OutputTypes.json || this.output === OutputTypes.yaml) && !ignoreOutputParam) { + // Output given error to a JSON or YAML format. + let response; + if (isApiServerErrorType(error) || isApiServerErrorResponseType(error)) { + if (error instanceof Error && error.name === 'HTTPError' && (error as unknown as ApiServerErrorResponse).errors) { + // The HTTP response has an errors array. Only log that part. + response = (error as unknown as ApiServerErrorResponse).errors; + } else { + response = error; + } + } else if (error instanceof CompositeError) { + response = error.toDictionary(); + } else { + response = { + name: error?.name, + message: error?.message, + }; + } + renderResponse(this._console, response, this.output); + } else if (error instanceof CompositeError) { + // A hierarchy of nested errors was provided. Log it with appropriate indentation. + const logCompositeError = (compositeError: CompositeError) => { + const indentation = this.output ? '' : ' '; + this.error(compositeError.toNestedMessageArray().join(`\n${indentation}`)); + }; + if (error.message) { + // Root error has a message. Log entire error hierarchy as a single error. + logCompositeError(error); + } else { + // Root error does not have a message. Log child errors separately as a flat error message list. + for (const nestedError of error.nestedErrors) { + if (nestedError instanceof CompositeError) { + logCompositeError(nestedError); + } else { + this.error(nestedError.message); + } + } + } + } else if (error instanceof Error && error.name !== 'HTTPError') { + // ELSE IF some generic error is happening (and its not an instance of "got" HTTPError) + const message + = error.name === 'AbortError' ? 'Error: couldn\'t connect to Amplify Central' : `${error.name}: ${error.message}`; + this.error(message); + } else if (isApiServerErrorResponseType(error) && error.errors?.length) { + // We were given an array of ApiServerError types. Log them separately. + for (const nextError of error.errors) { + this.anyError(nextError, prefixMsg, ignoreOutputParam); + } + } else { + // ELSE this is ApiServer error or ApiServer error response + // using just first error from api response since all bulk operations executed one-by-one for now + const err = error as ApiServerError; + switch (err.status) { + // TODO: some pasta here: 401 thrown manually on data service, fix it? + // https://jira.axway.com/browse/APIGOV-20818 + case 401: + this.error(this.#createApiServerErrorMessage(prefixMsg, err.title || 'Looks like you\'re not authenticated!')); + this.console('\nTry running:'); + this.console(chalk`{cyan axway auth login}`); + this.console('Or if using a service account:'); + this.console( + chalk`{cyan axway auth login --client-id --secret-file }` + ); + break; + case 400: + this.error(this.#createApiServerErrorMessage(prefixMsg, err.title, err.detail)); + if (err.source) { + this.console(chalk.gray(`Caused by: ${JSON.stringify(err.source)}`)); + } + break; + case 403: + case 404: + case 409: + case 500: + case 0: // status 0 used for internal errors (see ApiServerClient.deleteSingleResource wait logic) + this.error(this.#createApiServerErrorMessage(prefixMsg, err.title, err.detail)); + break; + default: + // rare case, should almost never happen. + if (error instanceof Error) { + this.error(error.toString()); + } else { + this.error( + `An unknown error occurred, try different output formats (for ex.: "-o ${OutputTypes.json}") to find out more details.` + ); + } + } + } + } + + /** + * Creates an error message for the given ApiResponseError title and detail strings. + * @param prefix String to be prefixed to the message. + * @param title Optional title such as "Validation error", "Forbidden error", etc. + * @param detail Optional error detail explaining exactly what went wrong. + * @returns Returns an error message to be outputted to the console. + */ + #createApiServerErrorMessage(prefix: string, title?: string, detail?: string) { + let message = prefix; + if (message.length > 0 && !message.endsWith(' ')) { + message += ' '; + } + message += title || 'Error'; + if (detail) { + if (!message.endsWith(':') && !message.endsWith('.') && !message.endsWith('!')) { + message += ':'; + } + message += ' ' + detail; + } + return message; + } +} diff --git a/src/lib/results/resultsrenderer.ts b/src/lib/results/resultsrenderer.ts new file mode 100644 index 00000000..35f4f6c0 --- /dev/null +++ b/src/lib/results/resultsrenderer.ts @@ -0,0 +1,167 @@ +import chalk from 'chalk'; +import dayjs from 'dayjs'; +import Table from 'easy-table'; +import { dump } from 'js-yaml'; +import _ from 'lodash'; +import { CommandLineInterfaceColumns, GenericResource, MAX_TABLE_STRING_LENGTH, OutputTypes } from '../types.js'; +import { CoreConfigController } from './coreconfigcontroller.js'; +import { initSDK } from '../amplify-sdk/index.js'; + +/** + * HACK: removing "---" delimiter printing from the lib. + * Currently this is not supported in library itself so have to override prototype methods. + */ +Table.prototype.pushDelimeter = function () { + return this; +}; + +/** + * Parse JSON object | array of objects as YAML + * @param response request response payload + * @param console current console + * @returns parsed string with YAML objects representation + */ +export const parseAsYaml = (response: object | object[]): string => { + let result = ''; + if (Array.isArray(response)) { + for (const i of response) { + result += `\n---\n${dump(i)}`; + } + } else { + result = dump(response); + } + return result; +}; + +/** + * Parse JSON object | array of objects as simple text table, + * NOTE: currently can build table only for "Environment" type. + * @param response request response payload + * @param console current console + * @param columns columns config from CommandLineInterface resource definition + * @returns parsed string with table objects representation + */ +const parseAsTable = ( + response: GenericResource | GenericResource[], + columns: CommandLineInterfaceColumns[] +): string => { + const data = Array.isArray(response) ? response : [response]; + const t = new Table(); + for (const i of data) { + for (const col of columns) { + // jsonPath starts with '.' so using the substring + let value: string | undefined = _.get(i, col.jsonPath.substring(1)); + const deletingState: boolean = _.get(i, 'metadata.state'); + if (col.type === 'date') { + value = dayjs(value).fromNow(); + } else if (col.type === 'teamGuid' && !value) { + value = chalk.gray('---'); + } else if (value && value.length > MAX_TABLE_STRING_LENGTH + 3) { + value = + value.substring(0, MAX_TABLE_STRING_LENGTH / 2) + + '...' + + value.substring(value.length - MAX_TABLE_STRING_LENGTH / 2); + } + if (deletingState) { + t.cell(col.name.toUpperCase(), chalk.yellow(value)); + } else { + t.cell(col.name.toUpperCase(), value); + } + } + t.newRow(); + } + return data.length ? `\n${t.toString()}` : '\nNo resources found.'; +}; + +/** + * Parse JSON object | array of objects as is but without any replacing like [object Object]. + * @param response request response payload + * @param console current console + * @returns parsed string with JSON objects representation + */ +export const parseAsJson = (response: object | object[]): string => JSON.stringify(response, null, 4); + +/** + * Util function to render JSON object | array of objects based on output type provided + * @param response request response payload + * @param output type of output to render (table (default) / yaml / json) + * @param console current console + */ +export const renderResponse = ( + console: Console, + response: object | object[], + output?: OutputTypes, + columns?: CommandLineInterfaceColumns[] +): void => { + switch (output) { + case OutputTypes.yaml: + console.log(parseAsYaml(response)); + break; + case OutputTypes.json: + console.log(parseAsJson(response)); + break; + default: + // @ts-expect-error TODO: fix types error once more types are used + console.log(parseAsTable(response, columns)); + } +}; + +interface TeamNameLookup { + [guid: string]: string; +} + +interface Result { + data: object[]; +} + +/** + * If a team guid column is being rendered, it resolves the team name and injects it into + * the response payload. + * @param columns an array of columns being rendered + * @param response request response payload + */ +export async function resolveTeamNames({ + columns, + response, +}: { + columns?: CommandLineInterfaceColumns[]; + response: object | object[]; +}) { + // check that we even have a team guid column + const column = columns?.find((col) => col.type === 'teamGuid'); + if (!column || !CoreConfigController.devOpsAccount) { + return; + } + + const jsonPath = column.jsonPath.substring(1); + const results = Array.isArray(response) ? response : [response]; + const teamNames: TeamNameLookup = {}; + const { devOpsAccount } = CoreConfigController; + const sdk = await initSDK({ env: devOpsAccount.auth.env }); + + // build the team name lookup + const { teams } = await sdk!.team.list(devOpsAccount, devOpsAccount.org.guid); + for (const team of teams) { + teamNames[team.guid] = team.name; + } + + // create the new jsonPath and update the column + const targetJsonPath = jsonPath.split('.').slice(0, -1).join('.') + '.teamName'; + column.jsonPath = `.${targetJsonPath}`; + + // next loop over data and set the team name + for (let { data } of results as Result[]) { + if (!data || typeof data !== 'object') { + continue; + } + if (!Array.isArray(data)) { + data = [data]; + } + for (const obj of data) { + const value = _.get(obj, jsonPath, null); + if (value !== null) { + _.set(obj, targetJsonPath, (value && teamNames[value]) || value || ''); + } + } + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 8ae5ab53..6f51f92c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,19 +1,19 @@ -export const ABORT_TIMEOUT = - process.env.NODE_ENV === "test" - ? 1e3 - : process.env.DEBUG || process.env.SNOOPLOGG - ? 1e9 - : 30e3; +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_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; +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. @@ -25,230 +25,235 @@ export type ProgressListener = (progress: number) => void; * ApiServer backend types */ export enum ApiServerVersions { - v1alpha1 = "v1alpha1", + v1alpha1 = 'v1alpha1', +} + +export enum OutputTypes { + yaml = 'yaml', + json = 'json', } export enum LanguageTypes { - French = "fr-fr", - US = "en-us", - German = "de-de", - Portugese = "pt-br", + 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; - }; + status: number; + title: string; + detail: string; + source?: object; + meta: { + regexp?: string; + instanceId: string; + tenantId: string; + authenticatedUserId: string; + transactionId: string; + }; }; export type ApiServerErrorResponse = { - errors: ApiServerError[]; + 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; - }; - }[]; - }; - }; + 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 + 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' - }; - }; + 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'; + 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; + 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", + 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"]; - }[]; + 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; + 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; +export type GenericResourceWithoutName = Omit & { + name?: string; }; /** * Client's types */ export type ApiServerClientListResult = { - data: null | GenericResource[]; - error: null | ApiServerError[]; + data: null | GenericResource[]; + error: null | ApiServerError[]; }; export type ApiServerSubResourceOperation = { - name: string; - operation: () => Promise; + name: string; + operation: () => Promise; }; export type ApiServerClientSingleResult = { - data: null | GenericResource; - updatedSubResourceNames?: string[]; - warning?: boolean; - error: null | ApiServerError[]; - pending?: null | Array; + 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 }; - }[]; + 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[]; + success: GenericResource[]; + error: ApiServerClientError[]; + warning?: GenericResource[]; }; export type ApiServerClientError = { - name: string; - kind: string; - error: ApiServerError | Error | { detail: string; title?: string }; + name: string; + kind: string; + error: ApiServerError | Error | { detail: string; title?: string }; }; export type GetSpecsResult = { - [groupName: string]: { - resources: Map; - cli: Map; - }; + [groupName: string]: { + resources: Map; + cli: Map; + }; }; diff --git a/src/lib/utils/utils.ts b/src/lib/utils/utils.ts index d3ba37c2..5ecf38f8 100644 --- a/src/lib/utils/utils.ts +++ b/src/lib/utils/utils.ts @@ -25,6 +25,8 @@ export const isValidJson = (item: any) => { }; import chalk from 'chalk'; import { + ApiServerError, + ApiServerErrorResponse, ApiServerVersions, GenericResource, GenericResourceWithoutName, @@ -38,7 +40,9 @@ export function ValueFromKey( key: string, ): string | undefined { for (const k of Object.values(stringEnum)) { - if (k === stringEnum[key]) { return k; } + if (k === stringEnum[key]) { + return k; + } } return undefined; } @@ -51,11 +55,11 @@ export const createLanguageSubresourceNames = (langCode: string) => { languageTypesArr.push(ValueFromKey(LanguageTypes, key)), ); langCodeArr.forEach((langCode) => { - if (langCode.trim() != '') { + 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}.'`, + `\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 { @@ -163,3 +167,23 @@ export const buildGenericResource = ({ }; } }; + +/** + * Returns true if error object is of type ApiServerError + * @param err error object to check + */ +export const isApiServerErrorType = (err: ApiServerError | ApiServerErrorResponse | Error): err is ApiServerError => { + const cast = err as ApiServerError; + return !!cast.status && !!cast.title && !!cast.detail; +}; + +/** + * Returns true if error object is of type ApiServerErrorResponse + * @param err error object to check + */ +export const isApiServerErrorResponseType = ( + err: ApiServerError | ApiServerErrorResponse | Error +): err is ApiServerErrorResponse => { + const cast = err as ApiServerErrorResponse; + return !!cast.errors && Array.isArray(cast.errors); +};