From c5c38dff91422ba7448759f625a59c7c72ad35f6 Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Fri, 28 Jun 2024 11:32:23 +0200 Subject: [PATCH 01/12] feat: add import/export/analyze action-flows functionality [EMA-4088] --- src/api/action-flow-api.ts | 31 +++++++++ src/commands/action-flow.command.ts | 16 +++++ src/content-cli-analyze.ts | 43 ++++++++++++ src/content-cli-export.ts | 17 +++++ src/content-cli-import.ts | 19 ++++++ src/content-cli.ts | 4 +- .../action-flow/action-flow-service.ts | 67 +++++++++++++++++++ 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/api/action-flow-api.ts create mode 100644 src/commands/action-flow.command.ts create mode 100644 src/content-cli-analyze.ts create mode 100644 src/services/action-flow/action-flow-service.ts diff --git a/src/api/action-flow-api.ts b/src/api/action-flow-api.ts new file mode 100644 index 0000000..63a99de --- /dev/null +++ b/src/api/action-flow-api.ts @@ -0,0 +1,31 @@ +import { httpClientV2 } from "../services/http-client-service.v2"; +import { FatalError } from "../util/logger"; +import * as FormData from "form-data"; + +class ActionFlowApi { + public static readonly INSTANCE = new ActionFlowApi(); + + public async exportRawAssets(packageId: string): Promise { + return httpClientV2.getFile(`/ems-automation/api/root/${packageId}/export/assets`).catch(e => { + throw new FatalError(`Problem getting action-flow assets: ${e}`); + }); + } + + public async analyzeAssets(packageId: string): Promise { + return httpClientV2.get(`/ems-automation/api/root/${packageId}/export/assets/analyze`).catch(e => { + throw new FatalError(`Problem analyzing action-flow assets: ${e}`); + }); + } + + public async importAssets(packageId: string, data: FormData, dryRun: boolean): Promise { + const params = { + dryRun: dryRun, + }; + + return httpClientV2.postFile(`/ems-automation/api/root/${packageId}/import/assets`, data, params).catch(e => { + throw new FatalError(`Problem importing action-flow assets: ${e}`); + }); + } +} + +export const actionFlowApi = ActionFlowApi.INSTANCE; \ No newline at end of file diff --git a/src/commands/action-flow.command.ts b/src/commands/action-flow.command.ts new file mode 100644 index 0000000..6d0b431 --- /dev/null +++ b/src/commands/action-flow.command.ts @@ -0,0 +1,16 @@ +import { actionFlowService } from "../services/action-flow/action-flow-service"; + +export class ActionFlowCommand { + + public async exportActionFlows(packageId: string, metadataFile: string): Promise { + await actionFlowService.exportActionFlows(packageId, metadataFile); + } + + public async analyzeActionFlows(packageId: string, outputToJsonFile: boolean): Promise { + await actionFlowService.analyzeActionFlows(packageId, outputToJsonFile); + } + + public async importActionFlows(packageId: string, filePath: string, dryRun: boolean, outputToJsonFile: boolean): Promise { + await actionFlowService.importActionFlows(packageId, filePath, dryRun, outputToJsonFile); + } +} \ No newline at end of file diff --git a/src/content-cli-analyze.ts b/src/content-cli-analyze.ts new file mode 100644 index 0000000..88f5be1 --- /dev/null +++ b/src/content-cli-analyze.ts @@ -0,0 +1,43 @@ +import { ContextInitializer } from "./util/context-initializer"; +import { logger } from "./util/logger"; +import { ActionFlowCommand } from "./commands/action-flow.command"; +import commander = require("commander"); + +type CommanderStatic = commander.CommanderStatic; + +class Analyze { + public static actionFlows(program: CommanderStatic): CommanderStatic { + program + .command("action-flows") + .description("Analyze action-flows dependencies for a certain package") + .option("-p, --profile ", "Profile which you want to use to analyze action-flows") + .requiredOption("-i, --packageId ", "ID of the package from which you want to export action-flows") + .option("-o, --outputToJsonFile ", "Output the analyze result in a JSON file") + .action(async cmd => { + await new ActionFlowCommand().analyzeActionFlows(cmd.packageId, cmd.outputToJsonFile); + process.exit(); + }); + return program; + } +} + +const loadCommands = () => { + getAllCommands(); +}; + +ContextInitializer.initContext() + .then(loadCommands, loadCommands) + .catch(e => { + logger.error(e); + }); + +if (!process.argv.slice(2).length) { + commander.outputHelp(); + process.exit(1); +} + +function getAllCommands(): void { + Analyze.actionFlows(commander); + + commander.parse(process.argv); +} diff --git a/src/content-cli-export.ts b/src/content-cli-export.ts index d6b6c42..bccc607 100644 --- a/src/content-cli-export.ts +++ b/src/content-cli-export.ts @@ -4,6 +4,7 @@ import { PackageCommand } from "./commands/package.command"; import { logger } from "./util/logger"; import { DataPoolCommand } from "./commands/data-pool.command"; import { ContextInitializer } from "./util/context-initializer"; +import { ActionFlowCommand } from "./commands/action-flow.command"; export class Export { public static packages(program: CommanderStatic): CommanderStatic { @@ -36,6 +37,21 @@ export class Export { return program; } + + public static actionFlows(program: CommanderStatic): CommanderStatic { + program + .command("action-flows") + .description("Command to export all action-flows in a package with their objects and dependencies") + .option("-p, --profile ", "Profile which you want to use to export action-flows") + .requiredOption("-i, --packageId ", "ID of the package from which you want to export action-flows") + .option("-f, --file ", "Action flows metadata file (relative path)") + .action(async cmd => { + await new ActionFlowCommand().exportActionFlows(cmd.packageId, cmd.file); + process.exit(); + }); + + return program; + } } const loadCommands = () => { @@ -56,6 +72,7 @@ if (!process.argv.slice(2).length) { function getAllCommands(): void { Export.packages(commander); Export.dataPool(commander); + Export.actionFlows(commander); commander.parse(process.argv); } diff --git a/src/content-cli-import.ts b/src/content-cli-import.ts index 5c326eb..19993ab 100644 --- a/src/content-cli-import.ts +++ b/src/content-cli-import.ts @@ -4,6 +4,7 @@ import { PackageCommand } from "./commands/package.command"; import { DataPoolCommand } from "./commands/data-pool.command"; import { ContextInitializer } from "./util/context-initializer"; import { logger } from "./util/logger"; +import { ActionFlowCommand } from "./commands/action-flow.command"; export class Import { public static packages(program: CommanderStatic): CommanderStatic { @@ -41,6 +42,23 @@ export class Import { return program; } + + public static actionFlows(program: CommanderStatic): CommanderStatic { + program + .command("action-flows") + .description("Command to import all action-flows in a package with their objects and dependencies") + .option("-p, --profile ", "Profile which you want to use to import action-flows") + .requiredOption("-i, --packageId ", "ID of the package to which you want to export action-flows") + .requiredOption("-f, --file ", "Exported action-flows file (relative path)") + .requiredOption("-d, --dryRun ", "Execute the import on dry run mode") + .option("-o, --outputToJsonFile ", "Output the import result in a JSON file") + .action(async cmd => { + await new ActionFlowCommand().importActionFlows(cmd.packageId, cmd.file, cmd.dryRun, cmd.outputToJsonFile); + process.exit(); + }); + + return program; + } } const loadCommands = () => { @@ -61,6 +79,7 @@ if (!process.argv.slice(2).length) { function getAllCommands(): void { Import.packages(commander); Import.dataPools(commander); + Import.actionFlows(commander); commander.parse(process.argv); } diff --git a/src/content-cli.ts b/src/content-cli.ts index 21fa9e6..e83d272 100644 --- a/src/content-cli.ts +++ b/src/content-cli.ts @@ -18,7 +18,7 @@ program.command("profile", "Commands related to profiles."); program.command("pull", "Commands to pull content."); program.command("export", "Commands to export content.") -program.command("import", "Commands to export content.") +program.command("import", "Commands to import content.") program.command("push", "Commands to push content."); @@ -32,6 +32,8 @@ program.command("set", "Commands to set configuration properties."); program.command("config", "Commands related to config management.") +program.command("analyze", "Commands to analyze assets dependencies."); + program.version(VersionUtils.getCurrentCliVersion()); program.parse(process.argv); diff --git a/src/services/action-flow/action-flow-service.ts b/src/services/action-flow/action-flow-service.ts new file mode 100644 index 0000000..1479b70 --- /dev/null +++ b/src/services/action-flow/action-flow-service.ts @@ -0,0 +1,67 @@ +import { logger } from "../../util/logger"; +import { v4 as uuidv4 } from "uuid"; +import { FileService, fileService } from "../file-service"; +import { actionFlowApi } from "../../api/action-flow-api"; +import * as AdmZip from "adm-zip"; +import * as FormData from "form-data"; +import * as fs from "fs"; + +class ActionFlowService { + public async exportActionFlows(packageId: string, metadataFilePath: string): Promise { + const exportedActionFlowsData = await actionFlowApi.exportRawAssets(packageId); + const exportedActionFlowsZip: AdmZip = new AdmZip(exportedActionFlowsData); + + if (metadataFilePath) { + this.attachMetadataFile(metadataFilePath, exportedActionFlowsZip); + } + + const fileName = "export_action-flows_" + uuidv4() + ".zip"; + exportedActionFlowsZip.writeZip(fileName); + logger.info(FileService.fileDownloadedMessage + fileName); + } + + public async analyzeActionFlows(packageId: string, outputToJsonFile: boolean): Promise { + const actionFlowsMetadata = await actionFlowApi.analyzeAssets(packageId); + const actionFlowsMetadataString = JSON.stringify(actionFlowsMetadata, null, 4); + + if (outputToJsonFile) { + const metadataFileName = "action-flows_metadata_" + uuidv4() + ".json"; + fileService.writeToFileWithGivenName(actionFlowsMetadataString, metadataFileName); + logger.info(FileService.fileDownloadedMessage + metadataFileName); + } else { + logger.info("Action flows analyze metadata: \n" + actionFlowsMetadataString); + } + } + + public async importActionFlows(packageId: string, filePath: string, dryRun: boolean, outputToJsonFile: boolean): Promise { + const actionFlowsZip = this.createBodyForImport(filePath); + const eventLog = await actionFlowApi.importAssets(packageId, actionFlowsZip, dryRun); + const eventLogString = JSON.stringify(eventLog, null, 4); + + if (outputToJsonFile) { + const eventLogFileName = "action-flows_import_event_log_" + uuidv4() + ".json"; + fileService.writeToFileWithGivenName(eventLogString, eventLogFileName); + logger.info(FileService.fileDownloadedMessage + eventLogFileName); + } else { + logger.info("Action flows import event log: \n" + eventLogString); + } + } + + private createBodyForImport(fileName: string): FormData { + fileName = fileName + (fileName.endsWith(".zip") ? "" : ".zip"); + + const formData = new FormData(); + formData.append("file", fs.createReadStream(fileName, { encoding: null }), { filename: fileName }); + + return formData; + } + + private attachMetadataFile(fileName: string, zip: AdmZip): void { + fileName = fileName + (fileName.endsWith(".json") ? "" : ".json"); + const metadata = fileService.readFile(fileName); + + zip.addFile("./metadata.json", Buffer.from(metadata)); + } +} + +export const actionFlowService = new ActionFlowService(); \ No newline at end of file From 4329f958b9d217fd194052c9eae023d915c3dd2d Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Fri, 28 Jun 2024 12:18:54 +0200 Subject: [PATCH 02/12] chore: update the `DOCUMENTATION.md` for action flows commands [EMA-4088] --- DOCUMENTATION.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 43271d6..0a3958c 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -15,6 +15,10 @@ - [List all packages in Studio](#list-all-packages-in-studio) - [List assignments](#list-assignments) - [Asset options for Analysis](#asset-options-for-analysis) + - [Action Flows commands](#action-flows-commands) + - [Analyze Action Flows](#analyze-action-flows) + - [Export Action Flows](#export-action-flows) + - [Import Action Flows](#import-action-flows) - [Data Pool export / import commands](#data-pool-export--import-commands) - [Export Data Pool](#export-data-pool) - [Batch Import multiple Data Pools](#batch-import-multiple-data-pools) @@ -440,6 +444,59 @@ the same command as with pushing other assets to Studio: content-cli push bookmarks -p my-profile-name --id 73d39112-73ae-4bbe-8051-3c0f14e065ec --file studio_analysis_bookmarks_39c5bb7b-b486-4230-ab01-854a17ddbff2.json ``` +### Action Flows commands + +#### Analyze Action Flows + +The analyze operation returns the metadata of Action Flows for one package together with their dependencies. Dependencies +could be webhooks, data structures, variables and other. + +In order to analyze Action Flows you can execute the following command: + +``` +content-cli analyze action-flows -p my-profile-name -i --outputToJsonFile +``` + +The ```--outputToJsonFile``` is optional. If specified, the analyze result is saved in a JSON file (```metadata.json```). The +command output will give you all the details. + +#### Export Action Flows + +The export operation allows export of Action Flows of one package together with their dependencies. Dependencies +could be webhooks, data structures, variables and other. + +In order to pull Action Flows you can execute the following command: + +``` +content-cli export action-flows -p my-profile-name -i -f +``` + +_Note_: The ```-f``` is optional. If specified, it will attach the metadata file to the exported zip. +This file is expected to be received by the ```action-flows analyze``` command, and manually be populated with the mappings source to target package. + +#### Import Action Flows + +The import operation allows import of Action Flows of one package together with their dependencies. Dependencies +could be webhooks, data structures, variables and other. + +In order to push Action Flows use the following command: + +``` +content-cli import action-flows -p my-profile-name -i -f --dryRun --outputToJsonFile +``` + +##### Input + +* The zip file is the one that you receive from the ```action-flows export``` command +* The ```--outputToJsonFile``` is optional. If specified, the import will be executed on dry run mode (nothing will be created) + * This is expected to be used to test the import by looking at report that is received on the response +* The ```--outputToJsonFile``` is optional. If specified, the import result is saved in a JSON file. The + command output will give you all the details. + +#### Output + +The command outputs an import report (event log). + ### Data Pool export / import commands #### Export Data Pool From 71ef0cf026f7a3e67f71ffe89f249f529897ae5d Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Fri, 28 Jun 2024 12:29:10 +0200 Subject: [PATCH 03/12] feat: support default values for boolean inputs previously `... import --dryRun true` `... --dryRun false` now `... import --dryRyn` `... import` So in case you specify it, the command will get it as true and in case you do not specify it, the command will get it as false (Same thing is applied to outputToJsonFile input field) [EMA-4088] --- src/content-cli-analyze.ts | 2 +- src/content-cli-import.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/content-cli-analyze.ts b/src/content-cli-analyze.ts index 88f5be1..83f2f80 100644 --- a/src/content-cli-analyze.ts +++ b/src/content-cli-analyze.ts @@ -12,7 +12,7 @@ class Analyze { .description("Analyze action-flows dependencies for a certain package") .option("-p, --profile ", "Profile which you want to use to analyze action-flows") .requiredOption("-i, --packageId ", "ID of the package from which you want to export action-flows") - .option("-o, --outputToJsonFile ", "Output the analyze result in a JSON file") + .option("-o, --outputToJsonFile", "Output the analyze result in a JSON file") .action(async cmd => { await new ActionFlowCommand().analyzeActionFlows(cmd.packageId, cmd.outputToJsonFile); process.exit(); diff --git a/src/content-cli-import.ts b/src/content-cli-import.ts index 19993ab..d1b7f17 100644 --- a/src/content-cli-import.ts +++ b/src/content-cli-import.ts @@ -50,8 +50,8 @@ export class Import { .option("-p, --profile ", "Profile which you want to use to import action-flows") .requiredOption("-i, --packageId ", "ID of the package to which you want to export action-flows") .requiredOption("-f, --file ", "Exported action-flows file (relative path)") - .requiredOption("-d, --dryRun ", "Execute the import on dry run mode") - .option("-o, --outputToJsonFile ", "Output the import result in a JSON file") + .requiredOption("-d, --dryRun", "Execute the import on dry run mode") + .option("-o, --outputToJsonFile", "Output the import result in a JSON file") .action(async cmd => { await new ActionFlowCommand().importActionFlows(cmd.packageId, cmd.file, cmd.dryRun, cmd.outputToJsonFile); process.exit(); From 4a60c8bcb78beff4d4ac9a3b5cf82e345a97acdb Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Thu, 11 Jul 2024 09:21:58 +0200 Subject: [PATCH 04/12] fix: zip size not being calculated correctly somehow adding a zip with entries using the `adm-zip` constructor differs from adding the zip entries using the `addFile` method If I use the constructor to create the zip, and then send that zip to the server I was receiving the error `invalid entry size (expected 2187395080 but got 158 bytes)` [EMA-4088] --- src/services/action-flow/action-flow-service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/services/action-flow/action-flow-service.ts b/src/services/action-flow/action-flow-service.ts index 1479b70..2a9dd6e 100644 --- a/src/services/action-flow/action-flow-service.ts +++ b/src/services/action-flow/action-flow-service.ts @@ -9,14 +9,19 @@ import * as fs from "fs"; class ActionFlowService { public async exportActionFlows(packageId: string, metadataFilePath: string): Promise { const exportedActionFlowsData = await actionFlowApi.exportRawAssets(packageId); - const exportedActionFlowsZip: AdmZip = new AdmZip(exportedActionFlowsData); + const tmpZip: AdmZip = new AdmZip(exportedActionFlowsData); + + const zip = new AdmZip(); + tmpZip.getEntries().forEach(entry => { + zip.addFile(entry.entryName, entry.getData()); + }); if (metadataFilePath) { - this.attachMetadataFile(metadataFilePath, exportedActionFlowsZip); + this.attachMetadataFile(metadataFilePath, zip); } const fileName = "export_action-flows_" + uuidv4() + ".zip"; - exportedActionFlowsZip.writeZip(fileName); + zip.writeZip(fileName); logger.info(FileService.fileDownloadedMessage + fileName); } From 38d90b1f32038c635d09f981d3cca81098041bfb Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Thu, 11 Jul 2024 09:58:04 +0200 Subject: [PATCH 05/12] fix: remove the `./` from metadata file path [EMA-4088] --- src/services/action-flow/action-flow-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/action-flow/action-flow-service.ts b/src/services/action-flow/action-flow-service.ts index 2a9dd6e..7f8d1e3 100644 --- a/src/services/action-flow/action-flow-service.ts +++ b/src/services/action-flow/action-flow-service.ts @@ -65,7 +65,7 @@ class ActionFlowService { fileName = fileName + (fileName.endsWith(".json") ? "" : ".json"); const metadata = fileService.readFile(fileName); - zip.addFile("./metadata.json", Buffer.from(metadata)); + zip.addFile("metadata.json", Buffer.from(metadata)); } } From 17cfdc3f438640d29b790deb035871bc95c1f050 Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Thu, 11 Jul 2024 10:01:26 +0200 Subject: [PATCH 06/12] chore: rename info/error messages [EMA-4088] --- src/api/action-flow-api.ts | 6 +++--- src/content-cli-analyze.ts | 6 +++--- src/content-cli-export.ts | 6 +++--- src/content-cli-import.ts | 8 ++++---- src/services/action-flow/action-flow-service.ts | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/api/action-flow-api.ts b/src/api/action-flow-api.ts index 63a99de..ad60200 100644 --- a/src/api/action-flow-api.ts +++ b/src/api/action-flow-api.ts @@ -7,13 +7,13 @@ class ActionFlowApi { public async exportRawAssets(packageId: string): Promise { return httpClientV2.getFile(`/ems-automation/api/root/${packageId}/export/assets`).catch(e => { - throw new FatalError(`Problem getting action-flow assets: ${e}`); + throw new FatalError(`Problem getting Action Flow assets: ${e}`); }); } public async analyzeAssets(packageId: string): Promise { return httpClientV2.get(`/ems-automation/api/root/${packageId}/export/assets/analyze`).catch(e => { - throw new FatalError(`Problem analyzing action-flow assets: ${e}`); + throw new FatalError(`Problem analyzing Action Flow assets: ${e}`); }); } @@ -23,7 +23,7 @@ class ActionFlowApi { }; return httpClientV2.postFile(`/ems-automation/api/root/${packageId}/import/assets`, data, params).catch(e => { - throw new FatalError(`Problem importing action-flow assets: ${e}`); + throw new FatalError(`Problem importing Action Flow assets: ${e}`); }); } } diff --git a/src/content-cli-analyze.ts b/src/content-cli-analyze.ts index 83f2f80..7873588 100644 --- a/src/content-cli-analyze.ts +++ b/src/content-cli-analyze.ts @@ -9,9 +9,9 @@ class Analyze { public static actionFlows(program: CommanderStatic): CommanderStatic { program .command("action-flows") - .description("Analyze action-flows dependencies for a certain package") - .option("-p, --profile ", "Profile which you want to use to analyze action-flows") - .requiredOption("-i, --packageId ", "ID of the package from which you want to export action-flows") + .description("Analyze Action Flows dependencies for a certain package") + .option("-p, --profile ", "Profile which you want to use to analyze Action Flows") + .requiredOption("-i, --packageId ", "ID of the package from which you want to export Action Flows") .option("-o, --outputToJsonFile", "Output the analyze result in a JSON file") .action(async cmd => { await new ActionFlowCommand().analyzeActionFlows(cmd.packageId, cmd.outputToJsonFile); diff --git a/src/content-cli-export.ts b/src/content-cli-export.ts index bccc607..34b00ef 100644 --- a/src/content-cli-export.ts +++ b/src/content-cli-export.ts @@ -41,9 +41,9 @@ export class Export { public static actionFlows(program: CommanderStatic): CommanderStatic { program .command("action-flows") - .description("Command to export all action-flows in a package with their objects and dependencies") - .option("-p, --profile ", "Profile which you want to use to export action-flows") - .requiredOption("-i, --packageId ", "ID of the package from which you want to export action-flows") + .description("Command to export all Action Flows in a package with their objects and dependencies") + .option("-p, --profile ", "Profile which you want to use to export Action Flows") + .requiredOption("-i, --packageId ", "ID of the package from which you want to export Action Flows") .option("-f, --file ", "Action flows metadata file (relative path)") .action(async cmd => { await new ActionFlowCommand().exportActionFlows(cmd.packageId, cmd.file); diff --git a/src/content-cli-import.ts b/src/content-cli-import.ts index d1b7f17..76e84a5 100644 --- a/src/content-cli-import.ts +++ b/src/content-cli-import.ts @@ -46,10 +46,10 @@ export class Import { public static actionFlows(program: CommanderStatic): CommanderStatic { program .command("action-flows") - .description("Command to import all action-flows in a package with their objects and dependencies") - .option("-p, --profile ", "Profile which you want to use to import action-flows") - .requiredOption("-i, --packageId ", "ID of the package to which you want to export action-flows") - .requiredOption("-f, --file ", "Exported action-flows file (relative path)") + .description("Command to import all Action Flows in a package with their objects and dependencies") + .option("-p, --profile ", "Profile which you want to use to import Action Flows") + .requiredOption("-i, --packageId ", "ID of the package to which you want to export Action Flows") + .requiredOption("-f, --file ", "Exported Action Flows file (relative path)") .requiredOption("-d, --dryRun", "Execute the import on dry run mode") .option("-o, --outputToJsonFile", "Output the import result in a JSON file") .action(async cmd => { diff --git a/src/services/action-flow/action-flow-service.ts b/src/services/action-flow/action-flow-service.ts index 7f8d1e3..5abbaa5 100644 --- a/src/services/action-flow/action-flow-service.ts +++ b/src/services/action-flow/action-flow-service.ts @@ -20,7 +20,7 @@ class ActionFlowService { this.attachMetadataFile(metadataFilePath, zip); } - const fileName = "export_action-flows_" + uuidv4() + ".zip"; + const fileName = "action-flows_export_" + uuidv4() + ".zip"; zip.writeZip(fileName); logger.info(FileService.fileDownloadedMessage + fileName); } From 4a63c9363c8b90486ac5428dcb9dd4cc63b38075 Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Tue, 23 Jul 2024 16:27:43 +0200 Subject: [PATCH 07/12] fix: use the `dryRun` value currently we were passing dryRun as true even if it was explicitly set as false [EMA-4088] --- src/content-cli-import.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content-cli-import.ts b/src/content-cli-import.ts index 76e84a5..476d5aa 100644 --- a/src/content-cli-import.ts +++ b/src/content-cli-import.ts @@ -50,7 +50,7 @@ export class Import { .option("-p, --profile ", "Profile which you want to use to import Action Flows") .requiredOption("-i, --packageId ", "ID of the package to which you want to export Action Flows") .requiredOption("-f, --file ", "Exported Action Flows file (relative path)") - .requiredOption("-d, --dryRun", "Execute the import on dry run mode") + .requiredOption("-d, --dryRun ", "Execute the import on dry run mode") .option("-o, --outputToJsonFile", "Output the import result in a JSON file") .action(async cmd => { await new ActionFlowCommand().importActionFlows(cmd.packageId, cmd.file, cmd.dryRun, cmd.outputToJsonFile); From 179f174f5f59fbcf2817febdf0311a02e2974229 Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Fri, 23 Aug 2024 10:10:05 +0200 Subject: [PATCH 08/12] chore: refactor commands and move `metadata.jon` as a static variable [EMA-4345] --- src/content-cli-analyze.ts | 2 +- src/content-cli-export.ts | 2 +- src/content-cli-import.ts | 2 +- src/services/action-flow/action-flow-service.ts | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/content-cli-analyze.ts b/src/content-cli-analyze.ts index 7873588..ccbf822 100644 --- a/src/content-cli-analyze.ts +++ b/src/content-cli-analyze.ts @@ -11,7 +11,7 @@ class Analyze { .command("action-flows") .description("Analyze Action Flows dependencies for a certain package") .option("-p, --profile ", "Profile which you want to use to analyze Action Flows") - .requiredOption("-i, --packageId ", "ID of the package from which you want to export Action Flows") + .requiredOption("--packageId ", "ID of the package from which you want to export Action Flows") .option("-o, --outputToJsonFile", "Output the analyze result in a JSON file") .action(async cmd => { await new ActionFlowCommand().analyzeActionFlows(cmd.packageId, cmd.outputToJsonFile); diff --git a/src/content-cli-export.ts b/src/content-cli-export.ts index 34b00ef..d0032b2 100644 --- a/src/content-cli-export.ts +++ b/src/content-cli-export.ts @@ -43,7 +43,7 @@ export class Export { .command("action-flows") .description("Command to export all Action Flows in a package with their objects and dependencies") .option("-p, --profile ", "Profile which you want to use to export Action Flows") - .requiredOption("-i, --packageId ", "ID of the package from which you want to export Action Flows") + .requiredOption("--packageId ", "ID of the package from which you want to export Action Flows") .option("-f, --file ", "Action flows metadata file (relative path)") .action(async cmd => { await new ActionFlowCommand().exportActionFlows(cmd.packageId, cmd.file); diff --git a/src/content-cli-import.ts b/src/content-cli-import.ts index 476d5aa..df66889 100644 --- a/src/content-cli-import.ts +++ b/src/content-cli-import.ts @@ -48,7 +48,7 @@ export class Import { .command("action-flows") .description("Command to import all Action Flows in a package with their objects and dependencies") .option("-p, --profile ", "Profile which you want to use to import Action Flows") - .requiredOption("-i, --packageId ", "ID of the package to which you want to export Action Flows") + .requiredOption("--packageId ", "ID of the package to which you want to export Action Flows") .requiredOption("-f, --file ", "Exported Action Flows file (relative path)") .requiredOption("-d, --dryRun ", "Execute the import on dry run mode") .option("-o, --outputToJsonFile", "Output the import result in a JSON file") diff --git a/src/services/action-flow/action-flow-service.ts b/src/services/action-flow/action-flow-service.ts index 5abbaa5..f7b1f80 100644 --- a/src/services/action-flow/action-flow-service.ts +++ b/src/services/action-flow/action-flow-service.ts @@ -7,6 +7,8 @@ import * as FormData from "form-data"; import * as fs from "fs"; class ActionFlowService { + private static readonly METADATA_FILE_NAME = "metadata.json"; + public async exportActionFlows(packageId: string, metadataFilePath: string): Promise { const exportedActionFlowsData = await actionFlowApi.exportRawAssets(packageId); const tmpZip: AdmZip = new AdmZip(exportedActionFlowsData); @@ -65,7 +67,7 @@ class ActionFlowService { fileName = fileName + (fileName.endsWith(".json") ? "" : ".json"); const metadata = fileService.readFile(fileName); - zip.addFile("metadata.json", Buffer.from(metadata)); + zip.addFile(ActionFlowService.METADATA_FILE_NAME, Buffer.from(metadata)); } } From 5a19c77a2184b6a41731f0898fe80bad0259706d Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Fri, 23 Aug 2024 14:01:45 +0200 Subject: [PATCH 09/12] chore: update `DOCUMENTATION` [EMA-4345] --- DOCUMENTATION.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 0a3958c..acb0d11 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -454,7 +454,7 @@ could be webhooks, data structures, variables and other. In order to analyze Action Flows you can execute the following command: ``` -content-cli analyze action-flows -p my-profile-name -i --outputToJsonFile +content-cli analyze action-flows -p my-profile-name --packageId --outputToJsonFile ``` The ```--outputToJsonFile``` is optional. If specified, the analyze result is saved in a JSON file (```metadata.json```). The @@ -468,7 +468,7 @@ could be webhooks, data structures, variables and other. In order to pull Action Flows you can execute the following command: ``` -content-cli export action-flows -p my-profile-name -i -f +content-cli export action-flows -p my-profile-name --packageId -f ``` _Note_: The ```-f``` is optional. If specified, it will attach the metadata file to the exported zip. @@ -482,7 +482,7 @@ could be webhooks, data structures, variables and other. In order to push Action Flows use the following command: ``` -content-cli import action-flows -p my-profile-name -i -f --dryRun --outputToJsonFile +content-cli import action-flows -p my-profile-name --packageId -f --dryRun --outputToJsonFile ``` ##### Input From c39fc0e77802f321bac14903ba637ecc492302f8 Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Mon, 26 Aug 2024 13:48:52 +0200 Subject: [PATCH 10/12] tests: add tests for action-flow commands [EMA-4088] --- .../action-flow/action-flow-service.ts | 5 +- tests/analyze/action-flows.spec.ts | 69 +++++++ tests/export/action-flows.spec.ts | 181 ++++++++++++++++++ tests/import/action-flows.spec.ts | 55 ++++++ tests/utls/fs-mock-utils.ts | 4 + 5 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 tests/analyze/action-flows.spec.ts create mode 100644 tests/export/action-flows.spec.ts create mode 100644 tests/import/action-flows.spec.ts diff --git a/src/services/action-flow/action-flow-service.ts b/src/services/action-flow/action-flow-service.ts index f7b1f80..c3e1c16 100644 --- a/src/services/action-flow/action-flow-service.ts +++ b/src/services/action-flow/action-flow-service.ts @@ -7,7 +7,7 @@ import * as FormData from "form-data"; import * as fs from "fs"; class ActionFlowService { - private static readonly METADATA_FILE_NAME = "metadata.json"; + public static readonly METADATA_FILE_NAME = "metadata.json"; public async exportActionFlows(packageId: string, metadataFilePath: string): Promise { const exportedActionFlowsData = await actionFlowApi.exportRawAssets(packageId); @@ -71,4 +71,5 @@ class ActionFlowService { } } -export const actionFlowService = new ActionFlowService(); \ No newline at end of file +export const actionFlowService = new ActionFlowService(); +export const metadataFileName = ActionFlowService.METADATA_FILE_NAME; \ No newline at end of file diff --git a/tests/analyze/action-flows.spec.ts b/tests/analyze/action-flows.spec.ts new file mode 100644 index 0000000..8cb1f7a --- /dev/null +++ b/tests/analyze/action-flows.spec.ts @@ -0,0 +1,69 @@ +import { FileService } from "../../src/services/file-service"; +import * as path from "path"; +import { mockWriteFileSync, testTransport } from "../jest.setup"; +import { mockedAxiosInstance } from "../utls/http-requests-mock"; +import { ActionFlowCommand } from "../../src/commands/action-flow.command"; + +describe("Analyze action-flows", () => { + + const packageId = "123-456-789"; + const mockAnalyzeResponse = { + "actionFlows": [ + { + "key": "987_asset_key", + "rootNodeKey": "123_root_key_node", + "parentNodeKey": "555_parent_node_key", + "name": "T2T - simple package Automation", + "scenarioId": "321", + "webHookUrl": null, + "version": "10", + "sensorType": null, + "schedule": { + "type": "indefinitely", + "interval": 900, + }, + "teamSpecific": { + "connections": [], + "variables": [], + "celonisApps": [], + "callingOtherAf": [], + "datastructures": [], + }, + }, + ], + "connections": [], + "dataPools": [], + "dataModels": [], + "skills": [], + "analyses": [], + "datastructures": [], + "mappings": [], + "actionFlowsTeamId": "1234", + }; + + it("Should call import API and return non-json response", async () => { + const resp = { data: mockAnalyzeResponse }; + (mockedAxiosInstance.get as jest.Mock).mockResolvedValue(resp); + + await new ActionFlowCommand().analyzeActionFlows(packageId, false); + + expect(testTransport.logMessages.length).toBe(1); + expect(testTransport.logMessages[0].message).toContain(JSON.stringify(mockAnalyzeResponse, null, 4)); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets/analyze`, expect.anything()); + }); + + it("Should call import API and return json response", async () => { + const resp = { data: mockAnalyzeResponse }; + (mockedAxiosInstance.get as jest.Mock).mockResolvedValue(resp); + + await new ActionFlowCommand().analyzeActionFlows(packageId, true); + + expect(testTransport.logMessages.length).toBe(1); + expect(testTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + const expectedFileName = testTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(mockAnalyzeResponse, null, 4), { encoding: "utf-8" }); + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets/analyze`, expect.anything()); + }); +}); \ No newline at end of file diff --git a/tests/export/action-flows.spec.ts b/tests/export/action-flows.spec.ts new file mode 100644 index 0000000..f050391 --- /dev/null +++ b/tests/export/action-flows.spec.ts @@ -0,0 +1,181 @@ +import { FileService } from "../../src/services/file-service"; +import { mockWriteSync, testTransport } from "../jest.setup"; +import { mockAxiosGet } from "../utls/http-requests-mock"; +import { ActionFlowCommand } from "../../src/commands/action-flow.command"; +import * as AdmZip from "adm-zip"; +import { mockExistsSyncOnce, mockReadFileSync } from "../utls/fs-mock-utils"; +import { stringify } from "../../src/util/yaml"; +import * as fs from "fs"; +import { parse } from "yaml"; +import { metadataFileName } from "../../src/services/action-flow/action-flow-service"; + +describe("Export action-flows", () => { + + const packageId = "123-456-789"; + const actionFlowFileName = "20240711-scenario-1234.json"; + const actionFlowConfig = { + "name": "T2T - simple package Automation", + "flow": [ + { + "id": 6, + "module": "util:FunctionSleep", + "version": 1, + "parameters": {}, + "metadata": { + "expect": [ + { + "name": "duration", + "type": "uinteger", + "label": "Delay", + "required": true, + "validate": { + "max": 300, + "min": 1, + }, + }, + ], + "restore": {}, + "designer": { + "x": 300, + "y": 0, + }, + }, + "mapper": { + "duration": "1", + }, + }, + ], + "metadata": { + "instant": false, + "version": 1, + "designer": { + "orphans": [], + }, + "scenario": { + "dlq": false, + "dataloss": false, + "maxErrors": 3, + "autoCommit": true, + "roundtrips": 1, + "sequential": false, + "confidential": false, + "freshVariables": false, + "autoCommitTriggerLast": true, + }, + }, + "io": { + "output_spec": [], + "input_spec": [], + }, + }; + + beforeEach(() => { + (fs.openSync as jest.Mock).mockReturnValue(100); + }); + + it("Should call export API and return the zip response", async () => { + const zipExport = new AdmZip(); + zipExport.addFile(actionFlowFileName, Buffer.from(stringify(actionFlowConfig))); + + mockAxiosGet(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets`, zipExport.toBuffer()); + + await new ActionFlowCommand().exportActionFlows(packageId, null); + + expect(testTransport.logMessages.length).toBe(1); + const expectedZipFileName = testTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedZipFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const receivedZip = new AdmZip(fileBuffer); + + expect(receivedZip.getEntries().length).toBe(1); + const receivedZipEntry = receivedZip.getEntries()[0]; + const receivedActionFlowConfig = parse(receivedZipEntry.getData().toString()); + expect(receivedZipEntry.name).toEqual(actionFlowFileName); + expect(receivedActionFlowConfig).toEqual(actionFlowConfig); + }); + + it("Should call export API and attach the provided file to the zip response", async () => { + const metadata = { + "actionFlows": [ + { + "key": "123_asset_key", + "rootNodeKey": "543_root_key", + "parentNodeKey": "9099_parent_key", + "name": "T2T - simple package Automation", + "scenarioId": "1234", + "webHookUrl": null, + "version": "10", + "sensorType": null, + "schedule": { + "type": "indefinitely", + "interval": 900, + }, + "teamSpecific": { + "connections": [], + "variables": [], + "celonisApps": [], + "callingOtherAf": [], + "datastructures": [], + }, + }, + ], + "connections": [], + "dataPools": [], + "dataModels": [], + "skills": [], + "analyses": [], + "datastructures": [], + "mappings": [], + "actionFlowsTeamId": "555", + }; + + mockExistsSyncOnce(); + mockReadFileSync(stringify(metadata)); + const zipExport = new AdmZip(); + zipExport.addFile(actionFlowFileName, Buffer.from(stringify(actionFlowConfig))); + + mockAxiosGet(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets`, zipExport.toBuffer()); + await new ActionFlowCommand().exportActionFlows(packageId, metadataFileName); + + expect(testTransport.logMessages.length).toBe(1); + const expectedZipFileName = testTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(fs.openSync).toHaveBeenCalledWith(expectedZipFileName, expect.anything(), expect.anything()); + expect(mockWriteSync).toHaveBeenCalled(); + + const fileBuffer = mockWriteSync.mock.calls[0][1]; + const receivedZip = new AdmZip(fileBuffer); + + expect(receivedZip.getEntries().length).toBe(2); + expect(receivedZip.getEntries().filter(entry => entry.name === metadataFileName).length).toBe(1); + + const receivedMetadataZipEntry = receivedZip.getEntries().filter(entry => entry.name === metadataFileName)[0]; + const receivedActionFlowZipEntry = receivedZip.getEntries().filter(entry => entry.name !== metadataFileName)[0]; + const receivedMetadataFile = parse(receivedMetadataZipEntry.getData().toString()); + const receivedActionFlowFile = parse(receivedActionFlowZipEntry.getData().toString()); + + expect(receivedActionFlowZipEntry.name).toEqual(actionFlowFileName); + expect(receivedActionFlowFile).toEqual(actionFlowConfig); + expect(receivedMetadataFile).toEqual(metadata); + }); + + it("Should throw error if metadata files does not exist", async () => { + const zipExport = new AdmZip(); + zipExport.addFile(actionFlowFileName, Buffer.from(stringify(actionFlowConfig))); + + mockAxiosGet(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/export/assets`, zipExport.toBuffer()); + + const error = new Error("mock exit"); + jest.spyOn(process, "exit").mockImplementation(() => { + throw error; + }); + + try { + await new ActionFlowCommand().exportActionFlows(packageId, metadataFileName); + } catch (e) { + expect(e).toBe(error); + expect(process.exit).toHaveBeenCalledWith(1); + } + }); +}); \ No newline at end of file diff --git a/tests/import/action-flows.spec.ts b/tests/import/action-flows.spec.ts new file mode 100644 index 0000000..3dea247 --- /dev/null +++ b/tests/import/action-flows.spec.ts @@ -0,0 +1,55 @@ +import { FileService } from "../../src/services/file-service"; +import * as path from "path"; +import { mockWriteFileSync, testTransport } from "../jest.setup"; +import { mockedAxiosInstance } from "../utls/http-requests-mock"; +import { ActionFlowCommand } from "../../src/commands/action-flow.command"; +import * as AdmZip from "adm-zip"; +import { mockCreateReadStream } from "../utls/fs-mock-utils"; + +describe("Import action-flows", () => { + + const packageId = "123-456-789"; + const mockImportResponse = { + "status": "SUCCESS", + "eventLog": [ + { + "status": "SUCCESS", + "assetType": "SCENARIO", + "assetId": "asset-id-automation", + "eventType": "IMPORT", + "log": "updated action flow with key [asset-id-automation]", + "mapping": null, + }, + ], + }; + + it("Should call import API and return non-json response", async () => { + const resp = { data: mockImportResponse }; + (mockedAxiosInstance.post as jest.Mock).mockResolvedValue(resp); + const zip = new AdmZip(); + mockCreateReadStream(zip.toBuffer()); + + await new ActionFlowCommand().importActionFlows(packageId, "tmp", true, false); + + expect(testTransport.logMessages.length).toBe(1); + expect(testTransport.logMessages[0].message).toContain(JSON.stringify(mockImportResponse, null, 4)); + + expect(mockedAxiosInstance.post).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/import/assets`, expect.anything(), expect.anything()); + }); + + it("Should call import API and return json response", async () => { + const resp = { data: mockImportResponse }; + (mockedAxiosInstance.post as jest.Mock).mockResolvedValue(resp); + const zip = new AdmZip(); + mockCreateReadStream(zip.toBuffer()); + + await new ActionFlowCommand().importActionFlows(packageId, "tmp", true, true); + + expect(testTransport.logMessages.length).toBe(1); + expect(testTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + const expectedFileName = testTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + + expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), JSON.stringify(mockImportResponse, null, 4), { encoding: "utf-8" }); + expect(mockedAxiosInstance.post).toHaveBeenCalledWith(`https://myTeam.celonis.cloud/ems-automation/api/root/${packageId}/import/assets`, expect.anything(), expect.anything()); + }); +}); \ No newline at end of file diff --git a/tests/utls/fs-mock-utils.ts b/tests/utls/fs-mock-utils.ts index add8872..2c01cea 100644 --- a/tests/utls/fs-mock-utils.ts +++ b/tests/utls/fs-mock-utils.ts @@ -14,4 +14,8 @@ export function mockCreateReadStream(data: any): void { export function mockExistsSync(): void { (fs.existsSync as jest.Mock).mockReturnValue(true); +} + +export function mockExistsSyncOnce(): void { + (fs.existsSync as jest.Mock).mockReturnValueOnce(true); } \ No newline at end of file From 54164d610690d4149f1e123f35b3e52943328e54 Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Mon, 26 Aug 2024 14:32:36 +0200 Subject: [PATCH 11/12] chore: remove import commands from `DOCUMENTATION` [EMA-4088] --- DOCUMENTATION.md | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index acb0d11..83bfd0d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -18,7 +18,6 @@ - [Action Flows commands](#action-flows-commands) - [Analyze Action Flows](#analyze-action-flows) - [Export Action Flows](#export-action-flows) - - [Import Action Flows](#import-action-flows) - [Data Pool export / import commands](#data-pool-export--import-commands) - [Export Data Pool](#export-data-pool) - [Batch Import multiple Data Pools](#batch-import-multiple-data-pools) @@ -474,29 +473,6 @@ content-cli export action-flows -p my-profile-name --packageId -f --dryRun --outputToJsonFile -``` - -##### Input - -* The zip file is the one that you receive from the ```action-flows export``` command -* The ```--outputToJsonFile``` is optional. If specified, the import will be executed on dry run mode (nothing will be created) - * This is expected to be used to test the import by looking at report that is received on the response -* The ```--outputToJsonFile``` is optional. If specified, the import result is saved in a JSON file. The - command output will give you all the details. - -#### Output - -The command outputs an import report (event log). - ### Data Pool export / import commands #### Export Data Pool From 62feaa5576077e0310f116cd742f3343608495dc Mon Sep 17 00:00:00 2001 From: jetmirhalili11 Date: Mon, 26 Aug 2024 14:43:55 +0200 Subject: [PATCH 12/12] chore: bump patch version [EMA-4088] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 607d138..37095b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@celonis/content-cli", - "version": "0.10.0", + "version": "0.10.1", "description": "CLI Tool to help manage content in Celonis EMS", "main": "content-cli.js", "bin": {