diff --git a/src/commands/engage/delete.ts b/src/commands/engage/delete.ts new file mode 100644 index 00000000..e580440c --- /dev/null +++ b/src/commands/engage/delete.ts @@ -0,0 +1,172 @@ +import Command from '../../lib/command.js'; +import { Args, Flags } from '@oclif/core'; +import { commonFlags } from '../../lib/engage/flags.js'; +import logger, { highlight } from '../../lib/logger.js'; +import { YesNo, YesNoChoices } from '../../lib/engage/types.js'; +import Renderer from '../../lib/engage/results/renderer.js'; +import { deleteResources } from '../../lib/engage/services/delete-service.js'; +import { askList } from '../../lib/engage/utils/basic-prompts.js'; + +export default class EngageDelete extends Command { + static override summary = 'Delete resources.'; + + static override aliases = [ 'central:delete' ]; + + static override description = `You must be authenticated to delete one or more resources. + Run ${highlight('"axway auth login"')} to authenticate.`; + + static override examples = [ + { + description: 'Delete resources from a file', + command: '<%= config.bin %> <%= command.id %> --file ', + }, + { + description: 'Delete a single resource', + command: '<%= config.bin %> <%= command.id %> ', + }, + { + description: 'Delete a scoped resource in all scopes with a specific name', + command: '<%= config.bin %> <%= command.id %> --scope --yes', + }, + { + description: 'Delete a scoped resource in a specific scope', + command: '<%= config.bin %> <%= command.id %> --scope /', + }, + ]; + + static override args = { + resource: Args.string({ + description: 'Resource type to delete.', + required: false, + }), + name: Args.string({ + description: 'Name of the resource to delete.', + required: false, + }), + }; + + static override flags = { + ...commonFlags, + file: Flags.string({ + char: 'f', + description: 'Filename to use to delete the resource.', + }), + scope: Flags.string({ + char: 's', + description: 'Scope name or kind/name for scoped resources.', + }), + yes: Flags.boolean({ + char: 'y', + description: 'Automatically reply "yes" to any command prompts.', + }), + wait: Flags.boolean({ + description: 'Wait for the resources to be completely deleted.', + }), + forceDelete: Flags.boolean({ + description: 'Force delete a resource (Warning: Ignores finalizers on the resource and the resources scoped under it)', + }), + }; + + private printInvalidArgsUsage() { + this.log(`\nUSAGE: +To delete resources by filenames:\t"axway engage delete -f/--file +To delete a single resource:\t\t"axway engage delete "`); + } + + async run(): Promise { + const log = logger('EngageDelete'); + const { args, flags, account } = await this.parse(EngageDelete); + + const typedResource = args.resource; + const typedName = args.name; + const render = new Renderer(console); + + if (flags.file && typedResource) { + render.error('Error: Invalid command arguments, please provide a file path or resource type and name.'); + this.printInvalidArgsUsage(); + process.exit(1); + } + + let isCmdError = true; + try { + render.startSpin(`Deleting resources${flags.wait ? ' and waiting for them to be deleted' : ''}`); + + const result = await deleteResources({ + account, + region: flags.region, + useCache: flags.cache, + resourceType: typedResource, + resourceName: typedName, + filePath: flags.file, + scopeParam: flags.scope, + wait: flags.wait, + forceDelete: flags.forceDelete, + skipConfirmation: flags.yes, + onConfirmSingleDelete: async (scopeProvided, matchingDefsLength) => { + render.stopSpin(); + let answer = YesNo.Yes; + if (!scopeProvided) { + answer = await askList({ + msg: 'Deleting this will delete all resources under its scope. Are you sure you want to do this?', + choices: YesNoChoices, + default: YesNo.No, + }) as YesNo; + } else if (matchingDefsLength > 1) { + answer = await askList({ + msg: 'The resource may exist in many scopes, and multiple entities might be deleted. Do you want to continue?', + choices: YesNoChoices, + default: YesNo.No, + }) as YesNo; + } + render.startSpin(`Deleting resources${flags.wait ? ' and waiting for them to be deleted' : ''}`); + return answer !== YesNo.No; + }, + onConfirmForceDelete: async () => { + render.stopSpin(); + const answer = await askList({ + msg: 'Are you sure you want to force delete this resource?', + choices: YesNoChoices, + default: YesNo.No, + }) as YesNo; + render.startSpin(`Deleting resources${flags.wait ? ' and waiting for them to be deleted' : ''}`); + return answer !== YesNo.No; + }, + }); + + if (result.missingArgs) { + render.stopSpin(); + render.error('Error: You must specify the type and name of the resource to delete or a file path.'); + if (result.defsHelpTable) { + this.log(`\nThe server supports the following resources:\n\n${result.defsHelpTable}`); + } + process.exit(1); + } + + isCmdError = result.hasErrors; + + if (result.singleResults) { + result.singleResults.forEach((res) => { + if (isCmdError && res.error?.length) { + render.anyError(res.error[0]); + } else if (!res.error?.length && res.data) { + render.success(`${render.resourceAndScopeKinds(res.data)} has successfully been deleted.`); + } + }); + } + + if (result.bulkResults) { + render.bulkResult(result.bulkResults, 'has successfully been deleted.'); + } + } catch (e: any) { + log('command error', e); + isCmdError = true; + render.anyError(e); + } finally { + log(`command finished, success = ${!isCmdError}`); + render.stopSpin(); + if (isCmdError) { + process.exit(1); + } + } + } +} diff --git a/src/lib/engage/services/delete-service.ts b/src/lib/engage/services/delete-service.ts new file mode 100644 index 00000000..dd64e17c --- /dev/null +++ b/src/lib/engage/services/delete-service.ts @@ -0,0 +1,170 @@ +import logger from '../../logger.js'; +import { ApiServerClient } from '../clients-external/apiserverclient.js'; +import { DefinitionsManager } from '../results/DefinitionsManager.js'; +import { loadAndVerifySpecs, parseScopeParam, verifyFile, verifyScopeParam } from '../utils/utils.js'; +import { + ApiServerClientSingleResult, + DeleteCommandParams, + DeleteCommandResult, + GenericResource, +} from '../types.js'; + +const log = logger('engage:delete-service'); + +export async function deleteResources(params: DeleteCommandParams): Promise { + const { + account, + region, + useCache, + resourceType, + resourceName, + filePath, + scopeParam, + wait, + forceDelete, + skipConfirmation, + onConfirmSingleDelete, + onConfirmForceDelete, + } = params; + + const client = new ApiServerClient({ account, region, useCache }); + const defsManager = await new DefinitionsManager(client).init(); + + if (!filePath && !resourceType) { + return { + hasErrors: true, + missingArgs: true, + defsHelpTable: defsManager.getDefsTableForHelpMsg(), + }; + } + + const scope = parseScopeParam(scopeParam); + + if (resourceType) { + log('executing api calls in single delete mode'); + return runSingleDelete({ + resourceType, + resourceName, + scope, + wait, + forceDelete, + skipConfirmation, + defsManager, + client, + onConfirmSingleDelete, + onConfirmForceDelete, + }); + } + + if (!filePath) { + throw new Error('file path is required for bulk delete.'); + } + + log('executing api calls in bulk delete mode'); + return runBulkDelete({ filePath, defsManager, client, wait, forceDelete }); +} + +async function runSingleDelete({ + resourceType, + resourceName, + scope, + wait, + forceDelete, + skipConfirmation, + defsManager, + client, + onConfirmSingleDelete, + onConfirmForceDelete, +}: { + resourceType: string; + resourceName?: string; + scope: ReturnType; + wait?: boolean; + forceDelete?: boolean; + skipConfirmation?: boolean; + defsManager: DefinitionsManager; + client: ApiServerClient; + onConfirmSingleDelete?: (scopeProvided: boolean, matchingDefsLength: number) => Promise; + onConfirmForceDelete?: () => Promise; +}): Promise { + const defs = defsManager.findDefsByWord(resourceType); + if (!defs) { + throw new Error(`the server doesn't have a resource type "${resourceType}"`); + } + if (!resourceName) { + throw new Error('resource name is required.'); + } + if (defs.every((def) => !!def.scope) && !scope) { + throw new Error( + `scope name param (-s/--scope) is required for the scoped "${defs[0].resource.spec.kind}" resource.` + ); + } + verifyScopeParam(defsManager.getAllKindsList(), defs, scope); + + const matchingDefs = defs.filter( + (def) => + (scope && ((scope.kind && scope.kind === def.scope?.spec.kind) || (!scope.kind && !!def.scope))) || + (!scope && !def.scope) + ); + + if (!matchingDefs.length) { + throw new Error('can\'t find matching resource definitions.'); + } + + if (!skipConfirmation && onConfirmSingleDelete) { + const shouldContinue = await onConfirmSingleDelete(!!scope, matchingDefs.length); + if (!shouldContinue) { + return { hasErrors: true }; + } + } + + if (!skipConfirmation && forceDelete && onConfirmForceDelete) { + const shouldContinue = await onConfirmForceDelete(); + if (!shouldContinue) { + return { hasErrors: true }; + } + } + + const singleResults: ApiServerClientSingleResult[] = await Promise.all( + matchingDefs.map(async (def) => + client.deleteResourceByName({ + resourceDef: def.resource, + resourceName: resourceName, + scopeDef: def.scope, + scopeName: scope?.name, + wait, + forceDelete, + }) + ) + ); + + const hasErrors = !singleResults.filter((res) => res.data !== null).length; + log('single delete complete, hasErrors:', hasErrors); + return { hasErrors, singleResults }; +} + +async function runBulkDelete({ + filePath, + defsManager, + client, + wait, + forceDelete, +}: { + filePath: string; + defsManager: DefinitionsManager; + client: ApiServerClient; + wait?: boolean; + forceDelete?: boolean; +}): Promise { + verifyFile(filePath); + const { docs } = await loadAndVerifySpecs(filePath, defsManager.getAllKindsList()); + const bulkResults = await client.bulkDelete( + docs as GenericResource[], + defsManager.getSortedKindsMap(), + wait, + forceDelete + ); + const hasErrors = !!bulkResults.error.length; + log('bulk delete complete, hasErrors:', hasErrors); + return { hasErrors, bulkResults }; +} diff --git a/src/lib/engage/types.ts b/src/lib/engage/types.ts index a101e243..07a5ecce 100644 --- a/src/lib/engage/types.ts +++ b/src/lib/engage/types.ts @@ -419,6 +419,29 @@ export interface ApplyCommandResult { hasErrors: boolean; } +export interface DeleteCommandParams extends EngageCommandParams { + resourceType?: string; + resourceName?: string; + filePath?: string; + scopeParam?: string; + wait?: boolean; + forceDelete?: boolean; + skipConfirmation?: boolean; + /** Called to confirm a single delete (scope or multi-scope). Return false to abort. */ + onConfirmSingleDelete?: (scopeProvided: boolean, matchingDefsLength: number) => Promise; + /** Called to confirm a force delete. Return false to abort. */ + onConfirmForceDelete?: () => Promise; +} + +export interface DeleteCommandResult { + hasErrors: boolean; + singleResults?: ApiServerClientSingleResult[]; + bulkResults?: ApiServerClientBulkResult; + /** True when neither resource type nor file was provided. */ + missingArgs?: boolean; + defsHelpTable?: string; +} + export interface CreateCommandParams extends EngageCommandParams { filePath: string; /** Called when file contains resources with missing names. Return false to abort. */