diff --git a/package-lock.json b/package-lock.json index 657ea9ec..f2e6f268 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "debug": "^4.4.3", "easy-table": "^1.2.0", "fastest-levenshtein": "^1.0.16", + "fs-extra": "^11.3.0", "got": "^14.6.2", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", @@ -28,15 +29,17 @@ "keytar": "7.9.0", "lodash": "^4.17.21", "node-cache": "^5.1.2", + "ora": "^5.4.1", "pluralize": "^8.0.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", + "promise-limit": "^2.7.0", "semver": "^7.7.3", "serialize-error": "^12.0.0", "uuid": "^13.0.0" }, "bin": { - "axway": "bin/run" + "axway": "bin/run.js" }, "devDependencies": { "@koa/router": "^14.0.0", @@ -2159,6 +2162,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -2576,7 +2591,6 @@ "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" }, @@ -2589,7 +2603,6 @@ "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" } @@ -3765,6 +3778,20 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4078,7 +4105,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -4634,6 +4660,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4819,7 +4854,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4996,6 +5030,18 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -5189,7 +5235,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -5206,7 +5251,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -5223,7 +5267,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -5301,6 +5344,15 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-response": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", @@ -5621,6 +5673,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5639,6 +5706,78 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/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/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -5912,6 +6051,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/propagate": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", @@ -6187,6 +6332,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -7156,6 +7320,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7217,7 +7390,6 @@ "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" } diff --git a/package.json b/package.json index 0fe853f0..9c6838dc 100644 --- a/package.json +++ b/package.json @@ -13,17 +13,29 @@ "bugs": "https://github.com/appcelerator/amplify-tooling/issues", "repository": "https://github.com/appcelerator/amplify-tooling", "bin": { - "axway": "./bin/run" + "axway": "./bin/run.js" }, "scripts": { "debug": "node --inspect=9254 --enable-source-maps dist/index.js", - "start": "./bin/run", + "start": "./bin/run.js", "build": "tsc", "test": "mocha --exit --recursive -r test/helpers/setup.js --slow 15000 --timeout 40000 test", "lint": "eslint . --ignore-pattern 'dist/**'", "watch": "tsc --watch", "postinstall": "node dist/scripts/postinstall.js || node -e true" }, + "oclif": { + "bin": "axway", + "dirname": "axway", + "commands": { + "strategy": "pattern", + "target": "./dist/commands" + }, + "plugins": [ + "@oclif/plugin-autocomplete" + ], + "topicSeparator": " " + }, "dependencies": { "@inquirer/prompts": "^7.9.0", "@oclif/core": "^4.8.0", @@ -36,6 +48,7 @@ "debug": "^4.4.3", "easy-table": "^1.2.0", "fastest-levenshtein": "^1.0.16", + "fs-extra": "^11.3.0", "got": "^14.6.2", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", @@ -43,9 +56,11 @@ "keytar": "7.9.0", "lodash": "^4.17.21", "node-cache": "^5.1.2", + "ora": "^5.4.1", "pluralize": "^8.0.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", + "promise-limit": "^2.7.0", "semver": "^7.7.3", "serialize-error": "^12.0.0", "uuid": "^13.0.0" diff --git a/src/commands/engage/get.ts b/src/commands/engage/get.ts index e69de29b..70e453e3 100644 --- a/src/commands/engage/get.ts +++ b/src/commands/engage/get.ts @@ -0,0 +1,289 @@ +import Command from '../../lib/command.js'; +import { Args, Flags } from '@oclif/core'; +import { ApiServerClientListResult, ApiServerClientSingleResult, CommandLineInterfaceColumns, LanguageTypes, OutputTypes, PlatformTeam } from '../../lib/types.js'; +import { commonFlags } from '../../lib/engage/flags.js'; +import logger, { highlight } from '../../lib/logger.js'; +import Renderer from '../../lib/results/renderer.js'; +import { ApiServerClient } from '../../lib/clients-external/apiserverclient.js'; +import { DefinitionsManager, FindDefsByWordResult } from '../../lib/results/DefinitionsManager.js'; +import { getFieldSetFromDefinitionColumns, parseScopeParam, transformSimpleFilters, verifyScopeParam } from '../../lib/utils/utils.js'; +import chalk from 'chalk'; +import { resolveTeamNames } from '../../lib/results/resultsrenderer.js'; + +export default class EngageGet extends Command { + static override summary = 'List one or more resources.'; + + static override description = `You must be authenticated to list one or more resources. + Run ${highlight('"axway auth login"')} to authenticate.`; + + static override examples = [ + { + description: 'Get a list of resources', + command: '<%= config.bin %> <%= command.id %> ', + }, + { + description: 'Get a list of multiple resources', + command: '<%= config.bin %> <%= command.id %> ,,...,', + }, + { + description: 'Get a list of resources in a specific scope', + command: '<%= config.bin %> <%= command.id %> --scope /', + }, + { + description: 'Get a list of resources matching a specific RSQL query', + command: '<%= config.bin %> <%= command.id %> --query ""', + }, + { + description: 'Get a specific resource by name across all scopes', + command: '<%= config.bin %> <%= command.id %> --scope ', + }, + { + description: 'Get a specific resource by name in a specific scope', + command: '<%= config.bin %> <%= command.id %> --scope /', + }, + ]; + + static override args = { + resource: Args.string({ + description: 'Resource type to get. Supports comma-separated values for multiple resources.', + required: false, + }), + name: Args.string({ + description: 'Name of the specific resource to get.', + required: false, + }), + }; + + static override flags = { + ...commonFlags, + output: Flags.string({ + char: 'o', + description: `Additional output formats. One of: ${OutputTypes.yaml} | ${OutputTypes.json}`, + }), + scope: Flags.string({ + char: 's', + description: 'Scope name or kind/name for scoped resources', + }), + query: Flags.string({ + char: 'q', + description: + 'RSQL-formatted query to search for filters that match specific parameters', + }), + title: Flags.string({ + description: 'Title of resource(s) to fetch', + }), + attribute: Flags.string({ + description: 'Attribute in key=value pair format to filter by', + }), + tag: Flags.string({ + description: 'Tag of resource(s) to fetch', + }), + team: Flags.string({ + description: 'The team name or guid to use', + }), + 'no-owner': Flags.boolean({ + description: 'Returns resources which do not have an owner', + }), + language: Flags.string({ + description: `Show the language detail of the returned object. One of: * | Comma Separated values of ${LanguageTypes.French} | ${LanguageTypes.US} | ${LanguageTypes.German} | ${LanguageTypes.Portugese}`, + }), + languageDefinition: Flags.string({ + description: `Show the language definition constraint of the returned object. One of: Comma Separated values of ${LanguageTypes.French} | ${LanguageTypes.US} | ${LanguageTypes.German} | ${LanguageTypes.Portugese}`, + }), + }; + + async run(): Promise { + const log = logger('EngageGet'); + const { args, flags, account, teams } = await this.parse(EngageGet); + + if (!flags.team && flags.owner) { + // no --team set and --no-owner was set, so we set the team to `null` which will know + // to get teams that do not have an owner + flags.team = null; + } + + // will be set to true and exit 1 if any get result contains an error or args invalid + let isCmdError = true; + // name can be provided or not (args[0] is the resource type) + const resourceName: string | undefined = args.name; + // verify output argument + if (!!flags.output && !(flags.output in OutputTypes)) { + throw Error(`invalid "output" (-o,--output) value provided, allowed: ${OutputTypes.yaml} | ${OutputTypes.json}`); + } + + const renderer = new Renderer(console, flags.output); + const getResults: { + columns: CommandLineInterfaceColumns[]; + response: ApiServerClientSingleResult | ApiServerClientListResult; + }[] = []; + try { + // get specs and allowed words + const client = new ApiServerClient({ region: flags.region, account: account, useCache: flags.cache, team: flags.team }); + const defsManager = await new DefinitionsManager(client).init(); + const scope = parseScopeParam(flags.scope); + let languageExpand = flags.language; + const languageDefinition = flags.languageDefinition; + let teamGuid: string | undefined; + if (flags.team) { + const team = teams?.teams?.find((t: PlatformTeam) => { + return t.guid.toLowerCase() === flags.team.toLowerCase() || t.name.toLowerCase() === flags.team.toLowerCase(); + }); + if (!team) { + throw new Error(`Unable to find team "${flags.team}" in the "${account.org.name}" organization`); + } + teamGuid = team.metadata.guid; + } + const formattedFilter = transformSimpleFilters(flags.title, flags.attribute, flags.tag, teamGuid); + const query = flags.query ? flags.query : formattedFilter; + // verify either "--language" or "--languageDefinition" argument is passed and error when both are passed + if (languageExpand && languageDefinition) { + throw Error('You must specify either of the "--language" or "--languageDefinition" argument and not both.'); + } + if (languageDefinition && !flags.output) { + throw Error('The "--languageDefinition" argument can only be used with output(-o,--output) argument'); + } + if (languageExpand) { + // when "*" is provided, expand all supported languages + let lang = ''; + let i = 0; + if (languageExpand.trim() === '*') { + lang = 'languages-*'; + } else { + const langCodeArr = languageExpand.split(','); + langCodeArr.forEach(v => { + if (i < langCodeArr.length - 1) { + lang = lang + 'languages-' + v.trim() + ','; + } else { + lang = lang + 'languages-' + v.trim(); + } + i++; + }); + } + languageExpand = 'languages,' + lang; + } + if (flags.query && formattedFilter) { + console.log( + `${chalk.yellow( + 'Both simple queries and advanced query parameters have been provided. Only the advanced query parameter will be applied.' + )}` + ); + } + // verify passed args + if (!args.resource) { + renderer.error('Error: You must specify the type of resource to get.'); + this.log('\nThe server supports the following resources:\n'); + this.log(defsManager.getDefsTableForHelpMsg()); + process.exit(1); + } + + // Start showing download progress. + const downloadMessage = 'Retrieving resource(s)'; + renderer.startSpin(downloadMessage); + const progressListener = (percent: number) => { + renderer.updateSpinText(`${downloadMessage} - ${percent}%`); + }; + + // parse passed resources types (if passed comma-separated) + for (const typedResource of args.resource.split(',')) { + const defs = defsManager.findDefsByWord(typedResource); + // is typed resource known? + if (!defs) { + throw Error(`the server doesn't have a resource type "${typedResource}"`); + } + // check if a user is trying to get a scoped-only resource by name without providing a scope name + if (defs.every((defs) => !!defs.scope) && resourceName && !scope) { + throw Error( + `scope name param (-s/--scope) is required for the scoped "${defs[0].resource.spec.kind}" resource.` + ); + } + // verify passed scope param kind + verifyScopeParam(defsManager.getAllKindsList(), defs, scope); + + /** + 1) If "scope" param provided: execute getByName or getList calls for every definition that match this scope name/kind. + 2) If "scope" param is not provided: execute list (get all) api calls for scoped resources without providing the scope in + the path so api-server returns the entire list in all scopes. For example, using "Document" kind and calling + https://apicentral.axway.com/apis/catalog/v1alpha1/documents returns a list of documents in Asset and AssetRelease + scopes in a single api call. So getting unique list of groups and finding first matching definitions to do a call + Note: this logic might have some edge cases if same kind can be used for "scoped" and "scope" resources and api-server is + not handling this case correctly anymore. + */ + if (scope) { + const results = await Promise.all( + defs + .filter((defs) => !scope.kind || !defs.scope || defs.scope.spec.kind === scope.kind) + .map(async (defs) => ({ + response: await client.getListOrByName( + { + resourceDef: defs.resource, + scopeName: scope?.name, + resourceName, + scopeDef: defs.scope, + query, + progressListener, + expand: languageExpand, + langDef: languageDefinition, + fieldSet: flags.output ? undefined : getFieldSetFromDefinitionColumns(defs), + } + ), + cli: defs.cli, + })) + ); + results.forEach(({ response, cli }) => { + getResults.push({ + columns: cli.spec.columns, + response, + }); + }); + } else { + const defsMatchingGroup: { [groupName: string]: FindDefsByWordResult } = {}; + defs.forEach((def) => { + if (!defsMatchingGroup[def.resource.metadata.scope?.name]) { + defsMatchingGroup[def.resource.metadata.scope?.name] = def; + } + }); + const results = await Promise.all( + Object.values(defsMatchingGroup).map(async (defs) => ({ + response: await client.getListOrByName({ + resourceDef: defs.resource, + scopeName: scope?.name, + resourceName, + scopeDef: undefined, + query, + progressListener, + expand: languageExpand, + langDef: languageDefinition, + fieldSet: flags.output ? undefined : getFieldSetFromDefinitionColumns(defs), + }), + cli: defs.cli, + })) + ); + results.forEach(({ response, cli }) => { + getResults.push({ + columns: cli.spec.columns, + response, + }); + }); + } + } + // resolve team guids + for (const obj of getResults) { + await resolveTeamNames({ ...obj, account }); + } + + // considering the command successful if at least 1 response found + isCmdError = !getResults.filter((res) => res.response.data !== null).length; + renderer.renderGetResults(getResults, 'Resource(s) successfully retrieved', languageDefinition); + } catch (e: any) { + log('command error', e); + isCmdError = true; + renderer.anyError(e); + } finally { + log('command complete'); + renderer.stopSpin(); + if (isCmdError) { + process.exit(1); + } + } + } +} diff --git a/src/commands/engage/index.ts b/src/commands/engage/index.ts new file mode 100644 index 00000000..2b3b7637 --- /dev/null +++ b/src/commands/engage/index.ts @@ -0,0 +1,56 @@ +import Command from '../../lib/command.js'; +import { highlight } from '../../lib/logger.js'; + +export default class EngageCommand extends Command { + static override hidden = true; + + static override summary = 'Manage APIs, services and publish to the Amplify Marketplace.'; + + static override description = `You must be authenticated to manage Engage Operations. +Run ${highlight('"<%= config.bin %> auth login"')} to authenticate.`; + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %> apply', + description: 'Update resources from a file' + }, + { + command: '<%= config.bin %> <%= command.id %> completion', + description: 'Output shell completion code' + }, + { + command: '<%= config.bin %> <%= command.id %> config', + description: 'Configure Engage CLI settings' + }, + { + command: '<%= config.bin %> <%= command.id %> create', + description: 'Create one or more resources from a file or stdin' + }, + { + command: '<%= config.bin %> <%= command.id %> delete', + description: 'Delete resources' + }, + { + command: '<%= config.bin %> <%= command.id %> edit', + description: 'Edit and update resources by using the default editor' + }, + { + command: '<%= config.bin %> <%= command.id %> get', + description: 'List one or more resources' + }, + { + command: '<%= config.bin %> <%= command.id %> install', + description: 'Install additional platform resources' + }, + { + command: '<%= config.bin %> <%= command.id %> productize', + description: 'Productize one or more API Services from a file' + } + ]; + + static override authenticated = false; + + async run() { + return this.help(); + } +} diff --git a/src/lib/cache/CacheController.ts b/src/lib/cache/CacheController.ts index 4f4ec9bd..f18f7a64 100644 --- a/src/lib/cache/CacheController.ts +++ b/src/lib/cache/CacheController.ts @@ -1,10 +1,6 @@ import dayjs from 'dayjs'; -import { - lstatSync, - outputJsonSync, - pathExistsSync, - readFileSync, -} from 'fs-extra'; +import { lstatSync, readFileSync } from 'fs'; +import fse from 'fs-extra'; import pkg from 'lodash'; import NodeCache from 'node-cache'; import { homedir } from 'os'; @@ -57,7 +53,7 @@ class CacheControllerClass implements Cache { */ initCacheFile() { try { - if (pathExistsSync(this.cacheFilePath)) { + if (fse.pathExistsSync(this.cacheFilePath)) { log(`init, cache file found at ${this.cacheFilePath}`); const stats = lstatSync(this.cacheFilePath); log(`init, cache file size: ${Math.round(stats.size / 1000)} kb`); @@ -68,17 +64,17 @@ class CacheControllerClass implements Cache { MAX_CACHE_FILE_SIZE / 1000, )} kb, resetting the file`, ); - outputJsonSync(this.cacheFilePath, {}); + fse.outputJsonSync(this.cacheFilePath, {}); } else if (!isValidJson(readFileSync(this.cacheFilePath, 'utf8'))) { // validating the content log('init, cache content is invalid, resetting the file '); - outputJsonSync(this.cacheFilePath, {}); + fse.outputJsonSync(this.cacheFilePath, {}); } } else { log( `init, cache file not found, creating an empty one at ${this.cacheFilePath}`, ); - outputJsonSync(this.cacheFilePath, {}); + fse.outputJsonSync(this.cacheFilePath, {}); } } catch (e) { log('cannot initialize cache file', e); @@ -134,7 +130,7 @@ class CacheControllerClass implements Cache { log( 'timestamp or content is not valid and file is not empty, resetting the cache file', ); - outputJsonSync(this.cacheFilePath, {}); + fse.outputJsonSync(this.cacheFilePath, {}); } } catch (e) { log('cannot read cache from the file', e); diff --git a/src/lib/clients-external/apiserverclient.ts b/src/lib/clients-external/apiserverclient.ts index a1a1a6fb..61e5fa4d 100644 --- a/src/lib/clients-external/apiserverclient.ts +++ b/src/lib/clients-external/apiserverclient.ts @@ -1,5 +1,4 @@ import chalk from 'chalk'; -import { log } from 'console'; import { dataService } from '../request.js'; import { ApiServerClientApplyResult, @@ -9,10 +8,14 @@ import { ApiServerError, ApiServerSubResourceOperation, ApiServerVersions, + BasePaths, + CommandLineInterface, GenericResource, GenericResourceWithoutName, LanguageTypes, + ProdBaseUrls, ProgressListener, + Regions, ResourceDefinition, WAIT_TIMEOUT, } from '../types.js'; @@ -26,46 +29,64 @@ import { import pickBy from 'lodash/pickBy.js'; import isEmpty from 'lodash/isEmpty.js'; import assign from 'lodash/assign.js'; +import { CacheController } from '../cache/CacheController.js'; +import logger from '../logger.js'; +import { loadConfig } from '../config.js'; export class ApiServerClient { region?: string; useCache: boolean; - account?: string; + account: Account; team?: string | null; forceGetAuthInfo?: boolean; + private _baseUrl?: string; - /** - * 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, + region, useCache, team, forceGetAuthInfo, }: { + account: Account; region?: string; useCache?: boolean; - account?: string; team?: string | null; forceGetAuthInfo?: boolean; - } = {}) { - log( - `initializing client with params: region = ${region}, account = ${account}, useCache = ${useCache}, team = ${team}`, + }) { + const log = logger('ApiServerClient.constructor'); + log.info( + 'initializing client with params:', ); this.account = account; this.region = region; - this.useCache = useCache === undefined ? true : useCache; // using cache by default + this.useCache = useCache === undefined ? true : useCache; 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 async initializeDataService() { + if (this._baseUrl === undefined) { + const config = await loadConfig(); + const envBaseUrl = process.env.AXWAY_CENTRAL_BASE_URL || config.get('engage.baseUrl'); + if (envBaseUrl) { + this._baseUrl = envBaseUrl + BasePaths.ApiServer; + } else { + const regionKey = String( + this.region || this.account?.org?.region || Regions.US + ).toUpperCase() as Regions; + const prodBaseUrl = ProdBaseUrls[regionKey]; + if (!prodBaseUrl) { + throw new Error( + 'Unknown region provided, check your region config, should be one of: ' + Object.keys(ProdBaseUrls).join(', ') + ); + } + this._baseUrl = prodBaseUrl + BasePaths.ApiServer; + } + } + return dataService({ account: this.account, baseUrl: this._baseUrl }); + } + private buildResourceUrlPath({ resourceDef, resourceName, @@ -89,6 +110,7 @@ export class ApiServerClient { fieldSet?: Set; embed?: string; }): string { + const log = logger('ApiServerClient.buildResourceUrlPath'); const groupUrl = `/${resourceDef.metadata.scope.name}/${version}`; const scopeUrl = scopeName && scopeDef @@ -120,7 +142,7 @@ export class ApiServerClient { } 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}.'`, + `\n'${code}' language code is not supported. Allowed language codes: ${LanguageTypes.French} | ${LanguageTypes.German} | ${LanguageTypes.US} | ${LanguageTypes.Portugese}.'`, ), ); } @@ -140,28 +162,14 @@ export class ApiServerClient { 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('&'); } + log.info(`built url path: ${url}`); return url; } - /** - * Generates an array of PUT requests for sub-resources based on resource input - * - * @param {Object} args function expects arguments as an object - * @param {GenericResource} args.resource resource input (not the APIs response) - * @param {string} args.resourceName resource name - * @param {string} args.subResourceName subresource name - * @param {ResourceDefinition} args.resourceDef resource definition - * @param {string} [args.scopeName] scope name - * @param {ResourceDefinition} [args.scopeDef] scope definition - * @param {string} [args.version] api's version - * @returns {Promise Promise | null>} returns an array of "request creators" functions - * that will be used in {@link resolveSubResourcesRequests} to create sub-resources when needed - */ public async generateSubResourcesRequests({ resource, resourceName, @@ -174,8 +182,8 @@ export class ApiServerClient { language, }: { resource: - | (GenericResource & { [subresource: string]: any }) - | (GenericResourceWithoutName & { [subresource: string]: any }); // file input, not the response + | (GenericResource & { [subresource: string]: any }) + | (GenericResourceWithoutName & { [subresource: string]: any }); resourceName: string; subResourceName?: string; resourceDef: ResourceDefinition; @@ -185,9 +193,11 @@ export class ApiServerClient { createAction?: boolean; language?: string; }): Promise | null> { - const service = await dataService({ - account: this.account, - }); + const log = logger('ApiServerClient.generateSubResourcesRequests'); + log.info( + `generateSubResourcesRequests, spec.kind = ${resourceDef.spec.kind}, resourceName = ${resourceName}`, + ); + const service = await this.initializeDataService(); const urlPath = this.buildResourceUrlPath({ resourceDef, resourceName, @@ -207,31 +217,28 @@ export class ApiServerClient { langSubResourcesNames.forEach((name) => { if ( !Object.keys(foundSubResources).includes(name) - && name !== 'languages' + && name !== 'languages' ) { console.log( chalk.yellow( - `\n\'${name}\' subresource definition not found, hence create/update cannot be performed on \'${name}\' subresource.`, + `\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 { + } else { delete foundSubResources[subRes]; } } }); } - return isEmpty(foundSubResources) + const result = isEmpty(foundSubResources) ? null : Object.keys(foundSubResources).map((key) => { return { @@ -242,29 +249,24 @@ export class ApiServerClient { [key]: foundSubResources[key], }) .catch((err) => + // eslint-disable-next-line promise/no-return-wrap Promise.reject({ name: key, requestError: err }), ), }; }); + log.info(`generateSubResourcesRequests, found sub-resources = ${result?.length ?? 0}`); + return result; } - /** - * Executes sub-resources requests generated by {@link generateSubResourcesRequests} - * - * @param {GenericResource} mainResourceResponse API response of the main resource update/create - * @param {Array<() => Promise> | null} pendingCalls an array of "request creators" functions for sub-resources - * @returns {ApiServerClientSingleResult} returns mainResourceResponse merged with successful sub-resources results - * and error details if encountered - */ public async resolveSubResourcesRequests( mainResourceResponse: GenericResource, pendingCalls: Array | null, ): Promise { + const log = logger('ApiServerClient.resolveSubResourcesRequests'); 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 + log.info(`resolving sub-resources, pending calls = ${pendingCalls.length}.`); const result: ApiServerClientSingleResult = { data: null, updatedSubResourceNames: [], @@ -283,13 +285,10 @@ export class ApiServerClient { 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) + && 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, @@ -302,34 +301,33 @@ export class ApiServerClient { }, {}); 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 - }.`, + if (!result.error?.length) { + result.error = null; + } + log.info( + `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[], ) { + const log = logger('ApiServerClient.checkForResources'); + log.info(`checkForResources, resources count = ${resources.length}`); return Promise.all( resources.map((resource) => { const resourceDef = sortedDefsArray.find( (def) => def.spec.kind === resource.kind - && def.spec.scope?.kind === resource.metadata?.scope?.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, + && !def.spec.scope, ) : undefined; const scopeName = resource.metadata?.scope?.name; @@ -340,19 +338,13 @@ export class ApiServerClient { scopeDef, scopeName, }); - } else { return null; } + } else { + return null; + } }), ); } - /** - * SINGLE RESOURCE CALLS - */ - - /** - * Create a single resource. - * @param resources resource to create - */ async createResource({ resourceDef, resource, @@ -368,7 +360,8 @@ export class ApiServerClient { withSubResources?: boolean; language?: string; }): Promise { - log( + const log = logger('ApiServerClient.createResource'); + log.info( `createResource, spec.kind = ${resourceDef.spec.kind}, name = ${resource.name}`, ); const result: ApiServerClientSingleResult = { @@ -378,9 +371,7 @@ export class ApiServerClient { warning: false, }; try { - const service = await dataService({ - account: this.account, - }); + const service = await this.initializeDataService(); const version = resource.apiVersion === undefined ? getLatestServedAPIVersion(resourceDef) @@ -393,7 +384,7 @@ export class ApiServerClient { }); const response = await service.post(urlPath, sanitizeMetadata(resource)); if (!resource.name) { - log('createResource, resource does not have a logical name'); + log.info('createResource, resource does not have a logical name'); result.warning = true; } const pendingSubResources = await this.generateSubResourcesRequests({ @@ -406,7 +397,7 @@ export class ApiServerClient { createAction: true, language, }); - log( + log.info( `createResource, pendingSubResources = ${pendingSubResources?.length}`, ); if (withSubResources) { @@ -419,9 +410,7 @@ export class ApiServerClient { 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. + log.error('createResource, error: ', e); if (e.errors && Array.isArray(e.errors)) { result.error = e.errors; } else { throw e; } @@ -432,10 +421,6 @@ export class ApiServerClient { return result; } - /** - * Update a single resource. - * @param resources resource to create - */ async updateResource({ resourceDef, resource, @@ -451,7 +436,8 @@ export class ApiServerClient { subResourceName?: string; language?: string; }): Promise { - log( + const log = logger('ApiServerClient.updateResource'); + log.info( `updateResource, spec.kind = ${resourceDef.spec.kind}, name = ${resource.name}`, ); const result: ApiServerClientSingleResult = { @@ -466,9 +452,7 @@ export class ApiServerClient { : resource.apiVersion; if (canUpdateMainResource) { try { - const service = await dataService({ - account: this.account, - }); + const service = await this.initializeDataService(); const urlPath = this.buildResourceUrlPath({ resourceDef, resourceName: resource.name, @@ -478,9 +462,7 @@ export class ApiServerClient { }); 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. + log.error('updateResource, error', e); if (e.errors && Array.isArray(e.errors)) { result.error = e.errors; } else { @@ -520,11 +502,6 @@ export class ApiServerClient { 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, @@ -539,7 +516,8 @@ export class ApiServerClient { scopeDef?: ResourceDefinition; withSubResources?: boolean; }): Promise { - log( + const log = logger('ApiServerClient.updateSubResource'); + log.info( `updateSubResource, spec.kind = ${resourceDef.spec.kind}, name = ${resource.name}`, ); const result: ApiServerClientSingleResult = { @@ -549,14 +527,12 @@ export class ApiServerClient { }; const version = getLatestServedAPIVersion(resourceDef); try { - const service = await dataService({ - account: this.account, - }); + const service = await this.initializeDataService(); const knownSubResourcesNames = resourceDef.spec.subResources?.names ?? []; const foundSubResources = pickBy( resource, (_, key) => - subResourceName == key && knownSubResourcesNames.includes(key), + subResourceName === key && knownSubResourcesNames.includes(key), ); const resourceName = resource.name; const urlPath = this.buildResourceUrlPath({ @@ -566,33 +542,21 @@ export class ApiServerClient { 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. + log.error('updateSubResource, error', e); if (e.errors && Array.isArray(e.errors)) { result.error = e.errors; } else { throw e; } } - if (result.data) { result.data = sanitizeMetadata(result.data); } + if (result.data) { + result.data = sanitizeMetadata(result.data); + } return result; } - /** - * Delete a resources by name. - * @param opts = { - * resourceDef - required, resource definition - * resourceName - required - * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too - * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too - * version - apis version (using alpha1 by default currently) - * wait - if provided, a followup GET call will be executed to confirm if the resource removed. - * } - */ async deleteResourceByName({ resourceDef, resourceName, @@ -610,7 +574,8 @@ export class ApiServerClient { forceDelete?: boolean; resourceAPIVersion?: string | undefined; }): Promise { - log( + const log = logger('ApiServerClient.deleteResourceByName'); + log.info( `deleteResourceByName, spec.kind = ${resourceDef.spec.kind}, name = ${resourceName}, scope.kind = ${scopeDef?.spec.kind}, scope.name = ${scopeName}`, ); const result: ApiServerClientSingleResult = { data: null, error: null }; @@ -619,9 +584,7 @@ export class ApiServerClient { ? getLatestServedAPIVersion(resourceDef) : resourceAPIVersion; try { - const service = await dataService({ - account: this.account, - }); + const service = await this.initializeDataService(); const urlPath = this.buildResourceUrlPath({ resourceDef, resourceName, @@ -631,9 +594,6 @@ export class ApiServerClient { 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 }) @@ -661,9 +621,7 @@ export class ApiServerClient { ); } } 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. + log.error('deleteResourceByName, error: ', e); if (e.errors && Array.isArray(e.errors)) { result.error = e.errors; } else { throw e; } @@ -671,16 +629,6 @@ export class ApiServerClient { return result; } - /** - * Get resources count. - * @param opts = { - * resourceDef - required, resource definition - * resourceName - optional, resource name - * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too - * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too - * query - Optional RSQL query filter - * } - */ async getResourceCount({ resourceDef, resourceName, @@ -694,11 +642,11 @@ export class ApiServerClient { scopeName?: string; query?: string; }): Promise { + const log = logger('ApiServerClient.getResourceCount'); + log.info(`getResourceCount, spec.kind = ${resourceDef.spec.kind}`); const version = getLatestServedAPIVersion(resourceDef); try { - const service = await dataService({ - account: this.account, - }); + const service = await this.initializeDataService(); const urlPath = this.buildResourceUrlPath({ resourceDef, resourceName, @@ -706,26 +654,55 @@ export class ApiServerClient { scopeName, version, }); - const response = await service.head(urlPath, { query }); + const response = await service.head(urlPath, query ? { searchParams: { query } } : {}); return response; } catch (e: any) { - log('getResourceCount, error: ', e); - // re-throw + log.error('getResourceCount, error: ', e); throw e; } } - /** - * Get a resources list. - * @param opts = { - * resourceDef - required, resource definition - * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too - * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too - * version - apis version (using alpha1 by default currently) - * query - Optional RSQL query filter - * progressListener - Optional callback invoked multiple times with download progress - * } - */ + async getListOrByName({ resourceDef, + scopeName, + resourceName, + scopeDef, + query, + progressListener, + expand, + langDef, + fieldSet }: { + resourceDef: ResourceDefinition, + scopeName?: string, + resourceName?: string, + scopeDef?: ResourceDefinition, + query?: string, + progressListener?: ProgressListener, + expand?: string, + langDef?: string, + fieldSet?: Set, + }): Promise { + return resourceName + ? await this.getResourceByName({ + resourceDef, + resourceName, + scopeDef, + scopeName, + expand, + langDef, + fieldSet, + }) + : await this.getResourcesList({ + resourceDef, + scopeDef, + scopeName, + query, + progressListener, + expand, + langDef, + fieldSet, + }); + }; + async getResourcesList({ resourceDef, scopeDef, @@ -745,13 +722,12 @@ export class ApiServerClient { langDef?: string; fieldSet?: Set; }): Promise { - log(`getResourcesList, spec.kind = ${resourceDef.spec.kind}`); + const log = logger('ApiServerClient.getResourcesList'); + log.info(`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 service = await this.initializeDataService(); const urlPath = this.buildResourceUrlPath({ resourceDef, scopeDef, @@ -763,15 +739,13 @@ export class ApiServerClient { }); const response = await service.getWithPagination( urlPath, - { query }, + query ? { searchParams: { 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. + log.error('getResourcesList, error: ', e); if (e.errors && Array.isArray(e.errors)) { result.error = e.errors; } else { throw e; } @@ -779,16 +753,6 @@ export class ApiServerClient { return result; } - /** - * Get a resources by name. - * @param opts = { - * resourceDef - required, resource definition - * resourceName - required - * scopeDef - optional scope resource definition, used only if @param opts.scopeName provided too - * scopeName - optional name of the scope, used only if scoped @param opts.scopeDef provided too - * version - apis version (using alpha1 by default currently) - * } - */ async getResourceByName({ resourceDef, resourceName, @@ -810,7 +774,8 @@ export class ApiServerClient { resourceVersion?: string; embed?: string; }): Promise { - log( + const log = logger('ApiServerClient.getResourceByName'); + log.info( `getResourceByName, spec.kind = ${resourceDef.spec.kind}, name = ${resourceName}`, ); const version @@ -819,9 +784,7 @@ export class ApiServerClient { : resourceVersion; const result: ApiServerClientSingleResult = { data: null, error: null }; try { - const service = await dataService({ - account: this.account, - }); + const service = await this.initializeDataService(); const urlPath = this.buildResourceUrlPath({ resourceDef, resourceName, @@ -836,9 +799,7 @@ export class ApiServerClient { 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. + log.error('getResourceByName, error: ', e); if (e.errors && Array.isArray(e.errors)) { result.error = e.errors; } else { throw e; } @@ -846,105 +807,86 @@ export class ApiServerClient { return result; } - // TODO: Implement this when Caching is done - - // /** - // * Fetch definition endpoints to get specs for available resources. - // * Note that only "management" group is used currently. - // * @returns { group1: { resources: Map, cli: Map }, group2: { ... }, groupN: { ... } } - // */ - // async getSpecs(version = ApiServerVersions.v1alpha1): Promise<{ - // [groupName: string]: { - // resources: Map; - // cli: Map; - // }; - // }> { - // log(`get specs`); - // try { - // const specs: { - // [groupName: string]: { - // resources: Map; - // cli: Map; - // }; - // } = {}; - - // const service = await dataService({ - // baseUrl: this.baseUrl, - // region: this.region, - // account: this.account, - // }); - // const groups = await service.getWithPagination( - // `/definitions/${version}/groups`, - // ); - // for (const group of groups) { - // let resources: ResourceDefinition[] = []; - // let cli: CommandLineInterface[] = []; - // const cachedGroup = CacheController.get( - // `groups-${group.name}-${version}`, - // ); - // let cacheUpdated = false; - // if ( - // this.useCache && - // cachedGroup && - // cachedGroup.resourceVersion === group.metadata.resourceVersion - // ) { - // log(`valid ${group.name}/${version} found in cache`); - // resources = cachedGroup.resources; - // cli = cachedGroup.cli; - // } else { - // log( - // `no valid ${group.name}/${version} found in cache or cache usage is not set`, - // ); - // [resources, cli] = await Promise.all([ - // service.getWithPagination( - // `/definitions/${version}/groups/${group.name}/resources`, - // ), - // service.getWithPagination( - // `/definitions/${version}/groups/${group.name}/commandlines`, - // ), - // ]); - // CacheController.set(`groups-${group.name}-${version}`, { - // resourceVersion: group.metadata.resourceVersion, - // resources, - // cli, - // }); - // cacheUpdated = true; - // } - // specs[group.name] = { - // resources: new Map(), - // cli: new Map(), - // }; - // for (const r of resources) { - // specs[group.name].resources.set(r.name, r); - // } - // for (const c of cli) { - // specs[group.name].cli.set(c.name, c); - // } - // if (cacheUpdated) CacheController.writeToFile(); - // } - // return specs; - // } catch (e: any) { - // log("get specs, error: ", e); - // throw e; - // } - // } + async getSpecs(version = ApiServerVersions.v1alpha1): Promise<{ + [groupName: string]: { + resources: Map; + cli: Map; + }; + }> { + const log = logger('ApiServerClient.getSpecs'); + log.info('get specs'); + try { + const specs: { + [groupName: string]: { + resources: Map; + cli: Map; + }; + } = {}; - /** - * BULK CALLS - */ + const service = await this.initializeDataService(); + 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.info(`valid ${group.name}/${version} found in cache`); + resources = cachedGroup.resources; + cli = cachedGroup.cli; + } else { + log.info( + `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.error('get specs, error: ', e); + throw e; + } + } - /** - * 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 log = logger('ApiServerClient.bulkCreate'); + log.info('bulk create'); const sortedDefsArray = Array.from(sortedDefsMap.values()); const pendingSubResources: { mainResult: GenericResource; @@ -961,7 +903,7 @@ export class ApiServerClient { const resourceDef = sortedDefsArray.find( (def) => def.spec.kind === resource.kind - && def.spec.scope?.kind === resource.metadata?.scope?.kind, + && def.spec.scope?.kind === resource.metadata?.scope?.kind, ); if (!resourceDef) { let errorMessage = `No resource definition found for "kind/${resource.kind}"`; @@ -982,7 +924,7 @@ export class ApiServerClient { ? sortedDefsArray.find( (def) => def.spec.kind === resource.metadata!.scope!.kind - && !def.spec.scope, + && !def.spec.scope, ) : undefined; const scopeName = resource.metadata?.scope?.name; @@ -994,15 +936,17 @@ export class ApiServerClient { 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.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({ @@ -1017,14 +961,17 @@ export class ApiServerClient { } } - // 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); } + 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({ @@ -1039,19 +986,14 @@ export class ApiServerClient { 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 log = logger('ApiServerClient.bulkCreateOrUpdate'); + log.info('bulk create or update'); const sortedDefsArray = Array.from(sortedDefsMap.values()); const applyResults: Array = []; @@ -1059,9 +1001,8 @@ export class ApiServerClient { const resourceDef = sortedDefsArray.find( (def) => def.spec.kind === resource.kind - && def.spec.scope?.kind === resource.metadata?.scope?.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) { @@ -1085,13 +1026,12 @@ export class ApiServerClient { ? sortedDefsArray.find( (def) => def.spec.kind === resource.metadata!.scope!.kind - && !def.spec.scope, + && !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 const getResult: ApiServerClientSingleResult | null = resource.name ? await this.getResourceByName({ resourceDef, @@ -1102,12 +1042,10 @@ export class ApiServerClient { }) : 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, @@ -1116,7 +1054,6 @@ export class ApiServerClient { language, }); } else if (getResult!.data) { - // Resource found. Update the existing resource. singleResult = await this.updateResource({ resource: resource as GenericResource, resourceDef, @@ -1126,12 +1063,11 @@ export class ApiServerClient { 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 }; + throw new Error( + `ApiServer error(s): ${JSON.stringify(getResult!.error)}` + ); } - // Store the results of the above create/update. const applyResult: ApiServerClientApplyResult = { data: singleResult.data, wasCreated: shouldCreate && !!singleResult.data, @@ -1148,17 +1084,16 @@ export class ApiServerClient { ); 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, - ); + ?? sanitizeMetadata( + buildGenericResource({ + resourceName: resourceName, + resourceDef: resourceDef, + scopeName: scopeName, + }) as GenericResource, + ); const subResResult = await this.resolveSubResourcesRequests( pendingData, singleResult.pending, @@ -1166,8 +1101,7 @@ export class ApiServerClient { if (subResResult.data) { applyResult.data = subResResult.data; } - applyResult.updatedSubResourceNames - = subResResult.updatedSubResourceNames; + applyResult.updatedSubResourceNames = subResResult.updatedSubResourceNames; subResResult.error?.forEach((error) => applyResult.error?.push({ name: resourceName, @@ -1177,7 +1111,6 @@ export class ApiServerClient { ); } - // Delete the result's error array if it is empty. if (!applyResult.error?.length) { delete applyResult.error; } @@ -1186,18 +1119,14 @@ export class ApiServerClient { 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 log = logger('ApiServerClient.bulkDelete'); + log.info('bulk delete'); const sortedDefsArray = Array.from(sortedDefsMap.values()); const bulkResult: ApiServerClientBulkResult = { success: [], error: [] }; for (const resource of resources) { @@ -1205,13 +1134,13 @@ export class ApiServerClient { const resourceDef = sortedDefsArray.find( (def) => def.spec.kind === resource.kind - && def.spec.scope?.kind === resource.metadata?.scope?.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, + && !def.spec.scope, ) : undefined; const scopeName = resource.metadata?.scope?.name; @@ -1247,13 +1176,9 @@ export class ApiServerClient { }); } } 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({ @@ -1269,35 +1194,25 @@ export class ApiServerClient { } if (wait) { let pendingResources: (ApiServerClientSingleResult | null)[] = []; - pendingResources = await this.checkForResources( - resources, - sortedDefsArray, - ); + pendingResources = await this.checkForResources(resources, sortedDefsArray); const pendingDeletingResource = pendingResources.some((res) => res?.data); if (pendingDeletingResource) { setTimeout(async () => { - pendingResources = await this.checkForResources( - resources, - sortedDefsArray, - ); + 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, - ); + const pendingResNames = pendingResources.map((res) => res?.data?.name); bulkResult.success.forEach( (res, index) => pendingResNames.includes(res.name) - && bulkResult.success.splice(index, 1), + && bulkResult.success.splice(index, 1), ); pendingResources.forEach((res) => { if (res?.data) { bulkResult.error.push({ ...res.data, - error: { - detail: 'Not deleted yet.', - }, + error: { detail: 'Not deleted yet.' }, }); } }); @@ -1306,4 +1221,5 @@ export class ApiServerClient { } return bulkResult; } + } diff --git a/src/lib/command.ts b/src/lib/command.ts index 7f69b864..eb7d167c 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -16,6 +16,7 @@ interface AxwayParserOutput extends ParserOutput { sdk?: AmplifySDK; account?: Awaited>; org?: Awaited>; + teams?: Awaited>; } /** @@ -105,6 +106,8 @@ export default abstract class AxwayCommand extends Command { data.account, parsed.args?.org || parsed.flags?.org, ); + + data.teams = await data.sdk.team.list(data.account, data.org); } return data; diff --git a/src/lib/engage/flags.ts b/src/lib/engage/flags.ts new file mode 100644 index 00000000..dffa50ad --- /dev/null +++ b/src/lib/engage/flags.ts @@ -0,0 +1,29 @@ +import { Flags } from '@oclif/core'; + +/** + * Common flags shared across all Engage commands. + * Spread into a command's `flags` definition to include them all: + * static override flags = { ...commonFlags, ... } + */ +export const commonFlags = { + account: Flags.string({ + description: 'Override your default account config', + }), + region: Flags.string({ + description: 'Override your region config', + }), + cache: Flags.boolean({ + description: 'Use cache when communicating with the server', + allowNo: true, + default: true, + }), + baseUrl: Flags.string({ + hidden: true, + }), + apicDeployment: Flags.string({ + hidden: true, + }), + axwayManaged: Flags.boolean({ + hidden: true, + }), +}; diff --git a/src/lib/request.ts b/src/lib/request.ts index 091021d2..7450ffef 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,5 +1,4 @@ -import _, { flatten } from 'lodash'; -import fs from 'fs'; +import _ from 'lodash'; import chalk from 'chalk'; import got, { RequestError, TimeoutError } from 'got'; import httpProxyAgentPkg from 'http-proxy-agent'; @@ -12,6 +11,7 @@ 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 { readFileSync } from 'fs'; const { HttpProxyAgent } = httpProxyAgentPkg; const { HttpsProxyAgent } = httpsProxyAgentPkg; @@ -77,6 +77,7 @@ export function options(opts: any = {}) { // Default all requests to use the custom CLI user agent opts.headers = { + ...opts.headers, 'User-Agent': userAgent, }; @@ -84,7 +85,7 @@ export function options(opts: any = {}) { (Buffer.isBuffer(it) ? it : typeof it === 'string' - ? fs.readFileSync(it) + ? readFileSync(it) : undefined); opts.hooks = _.merge(opts.hooks, { @@ -202,7 +203,7 @@ export function createRequestOptions(opts = {}, config?): any { } else if (dest === 'strictSSL') { opts[dest] = !!value !== false; } else { - opts[dest] = fs.readFileSync(value); + opts[dest] = readFileSync(value); } }; @@ -288,8 +289,10 @@ const updateRequestError = (err: Error) => { */ export const dataService = async ({ account, + baseUrl = '', }: { - account?: any; + account?: Account; + baseUrl?: string; }): Promise => { const token = account.auth?.tokens?.access_token; if (!token) { @@ -301,31 +304,19 @@ export const dataService = async ({ 'X-Axway-Tenant-Id': account.org.org_id, }; const got = init(createRequestOptions({ headers })); + const prependBase = (url: string) => baseUrl + url; 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, + retry: { limit: 0 }, + timeout: { request: ABORT_TIMEOUT }, ...params, }); - return response; } catch (err: any) { updateRequestError(err); @@ -335,27 +326,31 @@ export const dataService = async ({ return { post: (url: string, data: object, headers = {}) => { - log(`POST: ${url}`); + const fullUrl = prependBase(url); + log(`POST: ${fullUrl}`); log(data); - return fetch('post', url, { + return fetch('post', fullUrl, { headers: headers, json: data, }).then(handleResponse); }, put: (url: string, data: object, headers = {}) => { - log(`PUT: ${url}`); - return fetch('put', url, { + const fullUrl = prependBase(url); + log(`PUT: ${fullUrl}`); + return fetch('put', fullUrl, { headers: headers, json: data, }).then(handleResponse); }, get: (url: string, params = {}) => { - log(`GET: ${url}`); - return fetch('get', url, params).then(handleResponse); + const fullUrl = prependBase(url); + log(`GET: ${fullUrl}`); + return fetch('get', fullUrl, params).then(handleResponse); }, head: (url: string, params?: object) => { - log(`HEAD: ${url}`); - return fetch('head', url, params).then((response) => { + const fullUrl = prependBase(url); + log(`HEAD: ${fullUrl}`); + return fetch('head', fullUrl, params).then((response) => { return response.headers['x-axway-total-count']; }); }, @@ -375,9 +370,11 @@ export const dataService = async ({ pageSize: number = 50, progressListener?: ProgressListener ) { + const fullUrl = prependBase(url); + params.searchParams = params.searchParams ?? {}; params.searchParams.pageSize = pageSize; - log(`GET (with auto-pagination): ${url}`); - const response = await fetch('get', url, params); + log(`GET (with auto-pagination): ${fullUrl}`); + const response = await fetch('get', fullUrl, params); const totalCountHeader = response.headers['x-axway-total-count']; if (totalCountHeader === null || totalCountHeader === undefined) { log( @@ -415,7 +412,7 @@ export const dataService = async ({ // eslint-disable-next-line no-loop-func limit(async () => { allPages[thisPageIndex] = await (this as DataServiceMethods).get( - url, + fullUrl, params ); pageDownloadCount++; @@ -425,11 +422,12 @@ export const dataService = async ({ } await Promise.all(otherPagesCalls); } - return flatten(allPages); + return _.flatten(allPages); }, delete: (url: string, params = {}) => { - log(`DELETE: ${url}`); - return fetch('delete', url, params).then(handleResponse); + const fullUrl = prependBase(url); + log(`DELETE: ${fullUrl}`); + return fetch('delete', fullUrl, params).then(handleResponse); }, download: async (url: string) => { try { diff --git a/src/lib/results/DefinitionsManager.ts b/src/lib/results/DefinitionsManager.ts new file mode 100644 index 00000000..36940cc8 --- /dev/null +++ b/src/lib/results/DefinitionsManager.ts @@ -0,0 +1,345 @@ +import logger from '../logger.js'; +import Table from 'easy-table'; +import loadash from 'lodash'; +import { ApiServerClient } from '../clients-external/apiserverclient.js'; +import { ResourceDefinition, CommandLineInterface, GetSpecsResult } from '../types.js'; +import chalk from 'chalk'; + +const { log } = logger('engage:class.DefinitionsManager'); + +export interface FindDefsByWordResult { + resource: ResourceDefinition; + cli: CommandLineInterface; + scope?: ResourceDefinition; +} + +/** + * Get / fetch / set specs. + */ +export class DefinitionsManager { + apiServerClient: ApiServerClient; + specs?: GetSpecsResult; + cli = new Map(); + resources = new Map(); + + constructor(apiServerClient: ApiServerClient) { + this.apiServerClient = apiServerClient; + } + /** + * Private + */ + + /** + * A reducer for sorting ResourceDefinition by references in the sortByReferences's "fromTo" list. + * Function utilize current "fromTo" list which is expected to be passed in { sorted, unsorted } form + * and "from" list which is used to find references for entities in the "fromTo" list. + * IF resource's "to" references still in "unsorted" list - put it back to unsorted also + * (its too early for the resource to be sorted). + * ELSE resource's "to" links are in the "from" list or/and current "sorted" list - find max index + * and append to "right-most" position of the "sorted" list + * @param curr + * @param defsFrom + */ + private reduceByReferenceLinks( + curr: { sorted: ResourceDefinition[]; unsorted: ResourceDefinition[] }, + defsFrom: ResourceDefinition[] + ): { sorted: ResourceDefinition[]; unsorted: ResourceDefinition[] } { + return curr.unsorted.reduce<{ sorted: ResourceDefinition[]; unsorted: ResourceDefinition[] }>( + (a, c, _, arr) => { + // IF any unsorted reference found, push current definition to unsorted too and skip + const unsortedRefs = c.spec.references.toResources.find((ref) => { + return ( + loadash.findLastIndex(arr, (def) => def.spec.kind === ref.kind && def.spec.scope?.kind === ref.scopeKind) !== -1 + ); + }); + if (unsortedRefs) { + a.unsorted.push(c); + } else { + // ELSE all refs are in pre populated or in sorted lists, calculate + const startIndex + = loadash.max( + c.spec.references.toResources.map((ref) => { + // find index in current sorted array + const sortedListIndex = loadash.findLastIndex( + a.sorted, + (def) => def.spec.kind === ref.kind && def.spec.scope?.kind === ref.scopeKind + ); + // find index in "from-only" array + const fromListIndex = loadash.findLastIndex( + defsFrom, + (def) => def.spec.kind === ref.kind && def.spec.scope?.kind === ref.scopeKind + ); + // this should never happen only if the api-server is missing some corresponding + // references so nothing found + if (sortedListIndex === -1 && fromListIndex === -1) { + log('reduceByReferenceLinks, startIndex error, def: ', JSON.stringify(c, null, 2), '\nref: ', ref); + throw Error( + `References calculation error, startIndex for kind: ${c.spec.kind} in scope: ${c.spec.scope?.kind}` + ); + } else { // if nothing found in sorted and pre return null so it will put it back to unsorted + return sortedListIndex === -1 ? 0 : sortedListIndex; + } + }) + ) ?? null; + const stopIndex + = loadash.min( + c.spec.references.fromResources.map((ref) => { + const i = loadash.findIndex( + a.sorted, + (def) => def.spec.kind === ref.kind && def.spec.scope?.kind === ref.scopeKind + ); + return i === -1 ? null : i; + }) + ) ?? null; + + if ((startIndex && stopIndex && startIndex >= stopIndex) || startIndex === null) { + log('reduceByReferenceLinks, indexes error, definition: ', JSON.stringify(c, null, 2)); + throw Error( + `References calculation error, indexes for kind: ${c.spec.kind} in scope: ${c.spec.scope?.kind}` + ); + } else { + a.sorted.splice(startIndex + 1, 0, c); + } + } + + return a; + }, + { sorted: curr.sorted, unsorted: [] } + ); + } + + /** + * Utility for sorting ResourceDefinition by refs. Its grouping resources into 4 arrays and + * iterates over the "fromTo" list with "reduceByReferenceLinks" reducer until its completely sorted. + * @param defs list of resources to sort + */ + private sortByReferences(defs: ResourceDefinition[]): ResourceDefinition[] { + // 1. Sort by references into 4 arrays + const groupedDefs = defs.reduce<{ + noRefs: ResourceDefinition[]; // defs without any reference + from: ResourceDefinition[]; // defs with only "from" references + fromTo: ResourceDefinition[]; // defs with "from" and "to" references + to: ResourceDefinition[]; // defs with only "to" references + }>( + (a, c) => { + const fromRefsNum = c.spec.references.fromResources.length; + const toRefsNum = c.spec.references.toResources.length; + if (fromRefsNum && toRefsNum) { + a.fromTo.push(c); + } else if (!fromRefsNum && !toRefsNum) { + a.noRefs.push(c); + } else if (!toRefsNum) { + a.from.push(c); + } else if (!fromRefsNum) { + a.to.push(c); + } + return a; + }, + { + noRefs: [], + from: [], + fromTo: [], + to: [], + } + ); + + // 2. Iterate over "fromTo" defs until its completely sorted + let result = this.reduceByReferenceLinks({ sorted: [], unsorted: groupedDefs.fromTo }, groupedDefs.from); + let loopCount = 0; // just in case, circuit breaker; + while (result.unsorted.length > 0 && loopCount <= 1000) { + result = this.reduceByReferenceLinks(result, groupedDefs.from); + loopCount += 1; + } + // On average function should not take more than 5 loops currently. + // Lets signal that something is wrong here. + if (loopCount === 1000) { + throw Error('Definition references calculation error, max loop count reached'); + } + return [ ...groupedDefs.noRefs, ...groupedDefs.from, ...result.sorted, ...groupedDefs.to ]; + } + + /** + * Public + * Constructs two maps per resource group(e.g. management, catalog) + * First map is for the nested 'cli' object and the other is for the nested 'resource' object + * Created by stripping the 'definitions' group from the specs object, leaving only the 'management' and 'catalog' groups as of 6/7 + * (this will dynamically update in case new groups are added on api-server) + * Then iterating over that specs object and pushing the cli and resource objects for each group into arrays, which are used to initialize the final maps + */ + async init(): Promise { + log('init'); + this.specs = await this.apiServerClient.getSpecs(); + const filteredSpecs = loadash.omit(this.specs, 'definitions'); + const cliArray = []; + const resourcesArray = []; + for (const [ key ] of Object.entries(filteredSpecs)) { + resourcesArray.push(...filteredSpecs[key].resources); + cliArray.push(...filteredSpecs[key].cli); + } + this.cli = new Map(cliArray); + this.resources = new Map(resourcesArray); + return this; + } + + getAllWordsList(): string[] { + if (!this.specs) { + return []; + } + const result: string[] = []; + this.cli.forEach((v) => { + result.push(v.spec.names.plural, v.spec.names.singular, ...v.spec.names.shortNames); + }); + return result; + } + + getAllKindsList(): Set { + if (!this.specs) { + throw Error('DefinitionManager.specs is not initialized.'); + } + const result: Set = new Set([]); + this.resources.forEach((v) => { + result.add(v.spec.kind); + }); + return result; + } + + /** + * Get the map with resources definitions sorted by references. + * Used to identify the correct order in which resources should be created / removed. + * @returns {Map} map where key is ResourceDefinition.name, value is ResourceDefinition + */ + getSortedKindsMap(): Map { + if (!this.specs) { + throw Error('DefinitionManager.specs is not initialized.'); + } + // For each spec modify the references: + // 1. since cli do not support creating or updating sub-resources we are ignoring references and removing them atm. + // 2. if it is a scoped resource, add a manual "to" reference to the scope resource and a corresponding "from" + // reference in the scope resource + this.resources.forEach((definition) => { + // 1. remove the references from the sub-resources and circular references (to self) + // TODO: circular references support: https://jira.axway.com/browse/APIGOV-20808 + definition.spec.references.toResources = definition.spec.references.toResources.filter( + (ref) => !ref.from && !(ref.kind === definition.spec.kind && ref.scopeKind === definition.spec.scope?.kind) + ); + definition.spec.references.fromResources = definition.spec.references.fromResources.filter( + (ref) => !ref.from && !(ref.kind === definition.spec.kind && ref.scopeKind === definition.spec.scope?.kind) + ); + // 2. add references between scope and scoped resources + if (definition.spec.scope) { + const scopeDef = [ ...this.resources.values() ].find((res) => res.spec.kind === definition.spec.scope!.kind)!; // mind the non-null assertion here + // modify current definition by adding "toResources" link to scopeDef + if (!definition.spec.references.toResources.find((ref) => ref.kind === scopeDef.spec.kind)) { + definition.spec.references.toResources.push({ + kind: scopeDef.spec.kind, + // NOTE: not used value, adding just to indicate it's manual nature. + // @ts-ignore + types: [ 'CALCULATED' ], + }); + } + // modify related "scope" definition by adding "fromResources" link to current definition + if (!scopeDef.spec.references.fromResources.find((ref) => ref.kind === definition.spec.kind)) { + scopeDef.spec.references.fromResources.push({ + kind: definition.spec.kind, + // @ts-ignore + // NOTE: not used value, adding just to indicate it's manual nature. + types: [ 'CALCULATED' ], + scopeKind: definition.spec.scope.kind, + }); + } + } + }); + // execute the sorting, note that the returning map is using the "name" field as keys. + const res = this.sortByReferences([ ...this.resources.values() ]); + return new Map(res.map((v) => [ v.name, v ])); + } + + getDefsTableForHelpMsg(): string { + if (!this.specs) { + return 'No resources found.'; + } + const t = new Table(); + + // create the 'axway central get' table + this.cli.forEach((v) => { + // grab the resource group + const group = v.metadata.scope.name; + t.cell('RESOURCE', `${v.spec.names.plural}`, () => chalk.cyan(v.spec.names.plural)); + t.cell('SHORT NAMES', [ ...(v.spec.names.shortNamesAlias || v.spec.names.shortNames) ].join(',')); + t.cell('RESOURCE KIND', this.resources.get(v.spec.resourceDefinition)?.spec.kind); + t.cell('SCOPED', this.resources.get(v.spec.resourceDefinition)?.spec.scope ? 'true' : 'false'); + t.cell('SCOPE KIND', this.resources?.get(v.spec.resourceDefinition)?.spec.scope?.kind); + t.cell('RESOURCE GROUP', group); + t.newRow(); + }); + return t.sort([ 'RESOURCE' ]).toString(); + } + + findDefsByKind(kind: string): null | FindDefsByWordResult[] { + log('findDefsByKind: ', kind); + + const res = [ ...this.resources ].reduce< + { resource: ResourceDefinition; cli: CommandLineInterface; scope?: ResourceDefinition }[] + >((a, [ _, def ]) => { + if (def.spec.kind === kind) { + a.push({ + resource: def, + cli: [ ...this.cli ].find(([ _, cliDef ]) => cliDef.spec.resourceDefinition === def.name)![1], + scope: def.spec.scope + ? [ ...this.resources ].find(([ _, resDef ]) => resDef.spec.kind === def.spec.scope!.kind)![1] + : undefined, + }); + } + return a; + }, []); + return res.length ? res : null; + } + + /** + * Returns set of related definitions if word is known. + * @param word word to search for + * @returns {object | null} { + * resource: definition of the resource + * cli: cli definition of the resource + * scope: scope resource definition, can support multiple scopes (only for scoped resources, otherwise it is undefined) + * } or null if no definitions found for this word. + */ + findDefsByWord(word: string): null | FindDefsByWordResult[] { + log('findDefsByWord: ', word); + if (!this.specs) { + return null; + } + const cliKv = [ ...this.cli ].filter( + ([ _, v ]) => + v.spec.names.plural === word + || v.spec.names.singular === word + || v.spec.names.shortNames.includes(word) + || v.spec.names.shortNamesAlias?.includes(word) + ); + // no match found returning null + if (!cliKv.length) { + return null; + } + const result = [ ...this.cli ].reduce< + { resource: ResourceDefinition; cli: CommandLineInterface; scope?: ResourceDefinition }[] + >((a, [ _, cliDef ]) => { + if ( + cliDef.spec.names.plural === word + || cliDef.spec.names.singular === word + || cliDef.spec.names.shortNames.includes(word) + || cliDef.spec.names.shortNamesAlias?.includes(word) + ) { + // note: mind non-null assertion + const resource = this.resources.get(cliDef.spec.resourceDefinition)!; + const scope = resource.spec.scope ? this.findDefsByKind(resource.spec.scope.kind)?.[0].resource : null; + a.push({ + resource, + cli: cliDef, + scope: scope ? scope : undefined, + }); + } + return a; + }, []); + return result; + } +} diff --git a/src/lib/results/cliconfigmanager.ts b/src/lib/results/cliconfigmanager.ts deleted file mode 100644 index cabe4652..00000000 --- a/src/lib/results/cliconfigmanager.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 index 06c099af..4c2a5d56 100644 --- a/src/lib/results/compositeerror.ts +++ b/src/lib/results/compositeerror.ts @@ -26,7 +26,7 @@ export class CompositeError extends Error { } /** Gets the name of this error type. */ - get name(): string { + override get name(): string { return 'CompositeError'; } diff --git a/src/lib/results/coreconfigcontroller.ts b/src/lib/results/coreconfigcontroller.ts deleted file mode 100644 index d7a76649..00000000 --- a/src/lib/results/coreconfigcontroller.ts +++ /dev/null @@ -1,262 +0,0 @@ -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 index 88b74389..cae46c83 100644 --- a/src/lib/results/renderer.ts +++ b/src/lib/results/renderer.ts @@ -1,4 +1,4 @@ -import { chalk } from 'cli-kit'; +import chalk from 'chalk'; import ora from 'ora'; import { ApiServerClientApplyResult, diff --git a/src/lib/results/resultsrenderer.ts b/src/lib/results/resultsrenderer.ts index 35f4f6c0..7568943c 100644 --- a/src/lib/results/resultsrenderer.ts +++ b/src/lib/results/resultsrenderer.ts @@ -1,10 +1,12 @@ import chalk from 'chalk'; import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime.js'; import Table from 'easy-table'; + +dayjs.extend(relativeTime); 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'; /** @@ -123,24 +125,25 @@ interface Result { export async function resolveTeamNames({ columns, response, + account }: { columns?: CommandLineInterfaceColumns[]; response: object | object[]; + account?: Account; }) { // check that we even have a team guid column const column = columns?.find((col) => col.type === 'teamGuid'); - if (!column || !CoreConfigController.devOpsAccount) { + if (!column || !account) { 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 }); + const sdk = await initSDK({ env: account?.auth.env }); // build the team name lookup - const { teams } = await sdk!.team.list(devOpsAccount, devOpsAccount.org.guid); + const { teams } = await sdk!.team.list(account, account?.org.guid); for (const team of teams) { teamNames[team.guid] = team.name; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 6f51f92c..8beb998a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -257,3 +257,89 @@ export type GetSpecsResult = { cli: Map; }; }; + +export const commonCmdArgsDescription = { + '--account [value]': 'Override your default account config', + '--region [value]': 'Override your region config', + '--no-cache': 'Do not use cache when communicating with the server', + '--base-url [value]': { hidden: true }, + '--apic-deployment [value]': { hidden: true }, + '--axway-managed': { hidden: true }, +}; + +export interface ParsedScopeParam { + name: string; + kind?: string; +} + +export enum AuthUrls { + Staging = 'https://login.axwaytest.net', + Prod = 'https://login.axway.com', + Preprod = 'https://login.na-us.axwaypreprod.net', +} + +export enum Regions { + US = 'US', + EU = 'EU', + AP = 'AP', +} + +export enum PreprodRegions { + US = 'US', + EU = 'EU', +} + +export enum Platforms { + prod = 'prod', + staging = 'staging', + preprod = 'preprod', +} + +export const ProdBaseUrls: { [k in Regions]: string } = { + US: 'https://apicentral.axway.com', + EU: 'https://central.eu-fr.axway.com', + AP: 'https://central.ap-sg.axway.com', +}; + +export const PreprodBaseUrls: { [k in PreprodRegions]: string } = { + US: 'https://engage.na-us.axwaypreprod.net', + EU: 'https://engage.eu-fr.axwaypreprod.net', +}; + +export const APICDeployments = { + EU: 'prod-eu', + EUStaging: 'staging-eu', + QA: 'qa', + US: 'prod', + USStaging: 'staging', + TEAMS: 'teams', + AP: 'prod-ap', + APStaging: 'preprod', + USPreprod: 'preprod', + EUPreprod: 'preprod', + APPreprod: 'preprod', +}; + +/** Provides information for a platform team. */ +export interface PlatformTeam { + apps: any[]; + created: string; // "2019-10-10T17:19:43.721Z" + default: boolean; // false + desc: null | string; // null + guid: string; // '6b2b5192-6599-48a9-997d-9af61b7d5f2a'; + name: string; // 'Avengers'; + org_guid: string; // '4a8a4a98-befd-4062-bb36-b4567f47eb87'; + tags: string[]; // []; + updated: string; // '2020-04-24T17:49:14.600Z'; +} + +export enum BasePaths { + ApiServer = '/apis', + ApiCentral = '/api/v1', + Platform = '/platform/api/v1', + V7Agents = '/artifactory/ampc-public-generic-release/v7-agents', + AWSAgents = '/artifactory/ampc-public-generic-release/aws-agents', + DockerAgentPublicRepo = '/agent', + DockerAgentAPIRepoPath = '/artifactory/api/docker/ampc-public-docker-release/v2/agent', +} + diff --git a/src/lib/utils/utils.ts b/src/lib/utils/utils.ts index 5ecf38f8..27013af7 100644 --- a/src/lib/utils/utils.ts +++ b/src/lib/utils/utils.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from 'fs-extra'; +import { writeFileSync } from '../fs.js'; export const writeToFile = (path: string, data: any): void => { try { @@ -28,12 +28,15 @@ import { ApiServerError, ApiServerErrorResponse, ApiServerVersions, + CommandLineInterface, GenericResource, GenericResourceWithoutName, LanguageTypes, Metadata, + ParsedScopeParam, ResourceDefinition, } from '../types.js'; +import { FindDefsByWordResult } from '../results/DefinitionsManager.js'; export function ValueFromKey( stringEnum: { [key: string]: string }, @@ -187,3 +190,106 @@ export const isApiServerErrorResponseType = ( const cast = err as ApiServerErrorResponse; return !!cast.errors && Array.isArray(cast.errors); }; + +/** + * Parse and verify scope param, returns undefined if param is undefined. Throws an error if "Kind" is unknown. + * @param scopeParam raw scope param value + * @returns {ParsedScopeParam | undefined} + */ +export const parseScopeParam = (scopeParam?: string): ParsedScopeParam | undefined => { + if (!scopeParam) { + return undefined; + } + + const sp = scopeParam.toString(); + if (sp.indexOf('/') === -1) { + return { name: scopeParam }; + } else { + const name = sp.substring(scopeParam.indexOf('/') + 1); + const kind = sp.substring(0, scopeParam.indexOf('/')); + if (!name.length || !kind.length) { + throw Error( + 'invalid scope (-s/--scope) parameter value.' + + '\nPlease use "--scope /" or "--scope " formats.' + ); + } + return { name, kind }; + } +}; + +/** + * Transforms simple filters(title, attribute, tag) into an RSQL-formatted query string the GET API supports. + * @param {string} title The title the user wants to filter the resource list by. + * @param {string} attribute The attribute(key=value) the user wants to filter the resource list by. + * @param {string} tag The tag the user wants to filter the resource list by. + * @returns {string} transformedFilter, the RSQL formatted query string + */ +export const transformSimpleFilters = (title?: string, attribute?: string, tag?: string, teamGuid?: string) => { + const titleFilter = title ? `title=='*${title}*'` : ''; + const attributeKey = attribute && attribute.split('=')[0]; + const attributeValue = attribute && attribute.split('=')[1]; + const attributeFilter = attributeKey && attributeValue ? `attributes.${attributeKey}==${attributeValue}` : ''; + const tagFilter = tag ? `tags==${tag}` : ''; + const teamGuidFilter = teamGuid + ? `owner.id==${teamGuid},(owner.id==null;metadata.scope.owner.id==${teamGuid})` + : 'owner.id==null'; + const formattedFilter = `${titleFilter && `${titleFilter};`}${attributeFilter && `${attributeFilter};`}${tagFilter && `${tagFilter};`}${teamGuidFilter}`; + const transformedFilter + = formattedFilter.charAt(formattedFilter.length - 1) === ';' ? formattedFilter.slice(0, -1) : formattedFilter; + return transformedFilter; +}; + +/** + * Verify parsed scope param: + * 1. scope kind should be known. + * 2. scope kind should match at least one in a resource "scoped" definitions ("non-scoped" definition will be ignored). + * @param allKinds all available kinds. + * @param defs resource definitions where at least one should match the scope kind if some "scoped" resources are there. + * @param scopeParam parsed scope param. + */ +export const verifyScopeParam = ( + allKinds: Set, + defs: { + resource: ResourceDefinition; + cli: CommandLineInterface; + scope?: ResourceDefinition; + }[], + scopeParam?: ParsedScopeParam +): void => { + const allowedScopeKinds = new Set(); + defs.forEach((defs) => !!defs.scope && allowedScopeKinds.add(defs.scope.spec.kind)); + if (scopeParam?.kind) { + if (!allKinds.has(scopeParam.kind)) { + throw new Error( + `unsupported kind value "${scopeParam.kind}" in the "--scope" param.` + + `\nCurrently supported values are (case sensitive): ${[ ...allKinds.values() ].join(', ')}` + ); + } + if (allowedScopeKinds.size > 0 && !allowedScopeKinds.has(scopeParam.kind)) { + throw Error( + `scope kind "${scopeParam.kind}" is invalid.` + + `\n"${defs[0].resource.spec.kind}" resource might exist in the following scopes: ${[ + ...allowedScopeKinds.values(), + ].join(', ')}` + ); + } + } +}; + +/** + * Gets the resource field names to be shown when outputting the resource as a table. + * These names are to be assigned to the api-server HTTP GET request's "fields" query param. + * @param def The resource definition providing the columns to be shown in the outputed table. + * @returns Returns a set set of field names. + */ +export function getFieldSetFromDefinitionColumns(def: FindDefsByWordResult): Set { + const fieldSet = new Set(); + def.cli?.spec?.columns?.forEach(column => { + let fieldName = column.jsonPath; + if (fieldName.startsWith('.')) { + fieldName = fieldName.substring(1); + } + fieldSet.add(fieldName); + }); + return fieldSet; +}