diff --git a/docs/user-guide/config-commands.md b/docs/user-guide/config-commands.md index 7b0cd599..02d8e795 100644 --- a/docs/user-guide/config-commands.md +++ b/docs/user-guide/config-commands.md @@ -6,6 +6,7 @@ The `config` command group manages a package and its resources — importing a s - **Package & resource commands** — work with a package and its contents: - [`config package import`](#package-commands-config-package) — import a single package + - [`config package export`](#export-a-package) — export a single package - [`config package validate`](#validate-package-configurations-config-package-validate) — validate a package's staging version - [`config package list`](#list-packages-config-package-list) — list packages (staging by default) - [`config nodes …`](#finding-nodes) — work with individual nodes @@ -17,7 +18,7 @@ The `config` command group manages a package and its resources — importing a s ## Package vs. batch artifact format -`config package import` works with a plain **package zip** (a `package.json`, an optional `variables.json`, and a `nodes/` folder). The Team-to-Team Copy commands instead use a multi-package **batch artifact** (a top-level `manifest.json`, `variables.json`, `studio.json`, and a nested `_.zip` per package). The two formats are **not interchangeable**: +`config package import` and `config package export` work with a plain **package zip** (a `package.json`, an optional `variables.json`, and a `nodes/` folder). The Team-to-Team Copy commands instead use a multi-package **batch artifact** (a top-level `manifest.json`, `variables.json`, `studio.json`, and a nested `_.zip` per package). The two formats are **not interchangeable**: - An archive from `t2tc package export` can be imported with `t2tc package import` or inspected with `t2tc package diff` — but **not** with `config package import`. - A package zip used by `config package import` **cannot** be imported with `t2tc package import` or diffed with `t2tc package diff`. @@ -39,7 +40,7 @@ This applies to every command that reads or modifies a single package or its nod - `config nodes create`, `config nodes update`, `config nodes archive` - `config variables list` - `config versions get`, `config versions create` -- `config package validate`, `config package import`, `config metadata export` +- `config package validate`, `config package import`, `config package export`, `config metadata export` - `t2tc package export`, `t2tc package import`, `t2tc package diff` If the authenticated profile does not have the required permission, the command fails with `Access is Denied`. @@ -167,6 +168,49 @@ content-cli config package import -p -f --json info: File downloaded successfully. New filename: 9560f81f-f746-4117-83ee-dd1f614ad624.json ``` +### Export a Package + +`config package export` is the counterpart of [`config package import`](#import-a-package): it exports a single package's **staging (draft) version** in the package format. As with the `config nodes` commands, "staging" refers to the current draft state of the package — the unpublished working version, not a published package version. The output uses the same flat [package format](#package-zip-format) that `config package import` consumes, so a package can be exported from one team and imported into another without any conversion. Unlike [`t2tc package export`](./t2tc-commands.md#export-packages) — which produces a multi-package **batch artifact** — this command exports exactly one package on its own. + +> The artifact produced here is a **single package** in the package format, not a batch artifact. It cannot be imported with `t2tc package import` or diffed with `t2tc package diff`. Use [`config package import`](#import-a-package) to import it back. + +This command requires **edit permission** on the target package (see [Permissions](#permissions)). + +By default the package is exported **unzipped** into a `` directory in the current working directory (where you run the CLI) — handy for inspecting or version-controlling the package contents: + +```bash +content-cli config package export -p --packageKey +``` + +```bash +info: Successful export. Exported directory: my-package +``` + +Use `--zip` to export the package as a single `.zip` file in the current working directory instead: + +```bash +content-cli config package export -p --packageKey --zip +``` + +```bash +info: File downloaded successfully. New filename: my-package.zip +``` + +#### Package Layout + +Whether exported unzipped or as a zip, the package uses the same flat layout consumed by [`config package import`](#package-zip-format): + +```bash +my-package/ +├─ package.json # package metadata and configuration (key, name, type, flavor, configuration) +├─ variables.json # present only when the package has variable assignments +├─ nodes/ +│ ├─ .json # one file per node +│ ├─ ... +``` + +`variables.json` is omitted when the package has no variable assignments. + ## Validate Package Configurations (`config package validate`) > **Renamed.** This command moved from `config validate` to `config package validate`. The old `config validate` still works but prints a deprecation notice; switch to `config package validate`. diff --git a/docs/user-guide/t2tc-commands.md b/docs/user-guide/t2tc-commands.md index 115e4fe5..b31abe55 100644 --- a/docs/user-guide/t2tc-commands.md +++ b/docs/user-guide/t2tc-commands.md @@ -31,7 +31,7 @@ These commands are a **self-contained, batch-specific set**. `t2tc package expor - An archive from `t2tc package export` can be imported with `t2tc package import` or inspected with `t2tc package diff` — but **not** with `config package import`. - A package zip used by `config package import` **cannot** be imported with `t2tc package import` or diffed with `t2tc package diff`. -Reach for `t2tc package` only for its specific bulk use-case — moving a set of packages together (for example, a migration between teams). To work with a single package, use [`config package import`](./config-commands.md#package-commands-config-package). +Reach for `t2tc package` only for its specific bulk use-case — moving a set of packages together (for example, a migration between teams). To work with a single package, use [`config package import`](./config-commands.md#package-commands-config-package) or [`config package export`](./config-commands.md#export-a-package). ## List Packages diff --git a/src/commands/configuration-management/api/single-package-export-api.ts b/src/commands/configuration-management/api/single-package-export-api.ts new file mode 100644 index 00000000..45d4fdbd --- /dev/null +++ b/src/commands/configuration-management/api/single-package-export-api.ts @@ -0,0 +1,20 @@ +import { HttpClient } from "../../../core/http/http-client"; +import { Context } from "../../../core/command/cli-context"; +import { FatalError } from "../../../core/utils/logger"; + +export class SinglePackageExportApi { + + private readonly httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async exportPackage(packageKey: string): Promise { + return this.httpClient() + .getFile(`/pacman/api/core/staging/packages/${packageKey}/export-file`) + .catch(e => { + throw new FatalError(`Problem exporting package ${packageKey}: ${e}`); + }); + } +} diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index 2d599b29..4ffffa6c 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -16,6 +16,7 @@ import { NodeDependencyService } from "./node-dependency.service"; import { PackageVersionCommandService } from "./package-version-command.service"; import { PackageValidationService } from "./package-validation.service"; import { SinglePackageImportService } from "./single-package-import.service"; +import { SinglePackageExportService } from "./single-package-export.service"; class Module extends IModule { @@ -79,6 +80,12 @@ class Module extends IModule { .option("--json", "Return the response as a JSON file") .action(this.importSinglePackage); + packageCommand.command("export") + .description("Export a single package's staging (draft) version to an unzipped directory (or a single zip with --zip). Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.") + .requiredOption("--packageKey ", "Key of the package to export") + .option("--zip", "Export the package as a single .zip file instead of an unzipped directory", false) + .action(this.exportSinglePackage); + packageCommand.command("validate") .description("Validate package node configurations") .requiredOption("--packageKey ", "Key of the package to validate") @@ -303,6 +310,10 @@ class Module extends IModule { await new SinglePackageImportService(context).importPackage(options.file, options.directory, options.overwrite, options.json); } + private async exportSinglePackage(context: Context, command: Command, options: OptionValues): Promise { + await new SinglePackageExportService(context).exportPackage(options.packageKey, options.zip); + } + private async diffPackages(context: Context, command: Command, options: OptionValues): Promise { await new T2tcCommandService(context).diffPackages(options.file, options.hasChanges, options.baseVersion, options.json); } diff --git a/src/commands/configuration-management/single-package-export.service.ts b/src/commands/configuration-management/single-package-export.service.ts new file mode 100644 index 00000000..81e07b0c --- /dev/null +++ b/src/commands/configuration-management/single-package-export.service.ts @@ -0,0 +1,27 @@ +import { Context } from "../../core/command/cli-context"; +import { fileService, FileService } from "../../core/utils/file-service"; +import { logger } from "../../core/utils/logger"; +import { SinglePackageExportApi } from "./api/single-package-export-api"; + +export class SinglePackageExportService { + + private readonly singlePackageExportApi: SinglePackageExportApi; + + constructor(context: Context) { + this.singlePackageExportApi = new SinglePackageExportApi(context); + } + + public async exportPackage(packageKey: string, zip: boolean): Promise { + const packageData = await this.singlePackageExportApi.exportPackage(packageKey); + + if (zip) { + const fileName = `${packageKey}.zip`; + fileService.writeBufferToFileWithGivenName(packageData, fileName); + logger.info(FileService.fileDownloadedMessage + fileName); + return; + } + + fileService.extractZipBufferToDirectory(packageData, packageKey); + logger.info(`Successful export. Exported directory: ${packageKey}`); + } +} diff --git a/src/commands/t2tc/module.ts b/src/commands/t2tc/module.ts index fc3ede07..7da3374d 100644 --- a/src/commands/t2tc/module.ts +++ b/src/commands/t2tc/module.ts @@ -26,7 +26,7 @@ class Module extends IModule { .action(this.listPackages); t2tcPackageCommand.command("export") - .description("Export one or more packages into a Team-to-Team Copy archive. The archive is consumed by 't2tc package import' / 't2tc package diff'.") + .description("Export one or more packages into a Team-to-Team Copy archive. The archive is consumed by 't2tc package import' / 't2tc package diff'. To export a single standalone package, use 'config package export'.") .option("--packageKeys ", "Keys of packages to export. Exports the latest deployed version only") .option("--keysByVersion ", "Keys of packages to export by version") .option("--withDependencies", "Include variables and dependencies", "") diff --git a/src/core/utils/file-service.ts b/src/core/utils/file-service.ts index cbf232bb..5769572b 100644 --- a/src/core/utils/file-service.ts +++ b/src/core/utils/file-service.ts @@ -16,6 +16,19 @@ export class FileService { }); } + public writeBufferToFileWithGivenName(data: Buffer, filename: string): void { + fs.writeFileSync(path.resolve(process.cwd(), filename), data, { + mode: FileConstants.DEFAULT_FILE_PERMISSIONS, + }); + } + + public extractZipBufferToDirectory(data: Buffer, targetDir: string): void { + const targetPath = path.resolve(process.cwd(), targetDir); + fs.mkdirSync(targetPath, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + new AdmZip(data).extractAllTo(targetPath, true, true); + this.restrictFilePermissions(targetPath); + } + public async readFileToJson(fileName: string): Promise { const fileContent = this.readFile(fileName); diff --git a/tests/commands/configuration-management/single-package-export.spec.ts b/tests/commands/configuration-management/single-package-export.spec.ts new file mode 100644 index 00000000..304c0394 --- /dev/null +++ b/tests/commands/configuration-management/single-package-export.spec.ts @@ -0,0 +1,75 @@ +import * as path from "path"; +import AdmZip = require("adm-zip"); +import { mockAxiosGet, mockAxiosGetError, mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; + +const PACKAGE_KEY = "pkg-1"; +const EXPORT_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/export-file`; + +function buildSinglePackageZip(): Buffer { + const zip = new AdmZip(); + zip.addFile("package.json", Buffer.from(JSON.stringify({ + key: PACKAGE_KEY, + name: "My Package", + type: "PACKAGE", + flavor: "STUDIO", + schemaVersion: 1, + configuration: { variables: [] }, + }))); + zip.addFile("variables.json", Buffer.from(JSON.stringify([]))); + zip.addFile("nodes/", Buffer.alloc(0)); + zip.addFile("nodes/node-1.json", Buffer.from(JSON.stringify({ key: "node-1", type: "VIEW" }))); + return zip.toBuffer(); +} + +describe("Single package export", () => { + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("Should export the package unzipped into a directory by default", async () => { + const packageData = buildSinglePackageZip(); + mockAxiosGet(EXPORT_URL, packageData); + + const extractSpy = jest.spyOn(FileService.prototype, "extractZipBufferToDirectory").mockImplementation(() => undefined); + + await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything()); + expect(extractSpy).toHaveBeenCalledTimes(1); + const [extractedData, extractedDir] = extractSpy.mock.calls[0]; + expect((extractedData as Buffer).equals(packageData)).toBe(true); + expect(extractedDir).toEqual(PACKAGE_KEY); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(loggingTestTransport.logMessages[0].message).toContain(`Successful export. Exported directory: ${PACKAGE_KEY}`); + }); + + it("Should export the package as .zip when --zip is set", async () => { + const packageData = buildSinglePackageZip(); + mockAxiosGet(EXPORT_URL, packageData); + + await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true); + + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything()); + + const [writtenPath, writtenData, writtenOptions] = mockWriteFileSync.mock.calls[0]; + expect(writtenPath).toEqual(path.resolve(process.cwd(), `${PACKAGE_KEY}.zip`)); + expect((writtenData as Buffer).equals(packageData)).toBe(true); + expect(writtenOptions).toEqual({ mode: 0o600 }); + expect(loggingTestTransport.logMessages[0].message).toContain(`${FileService.fileDownloadedMessage}${PACKAGE_KEY}.zip`); + }); + + it("Should surface a fatal error when the export endpoint returns an error", async () => { + mockAxiosGetError(EXPORT_URL, 404, { errorCode: "PACKAGE_NOT_FOUND" }); + + await expect( + new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false) + ).rejects.toThrow(`Problem exporting package ${PACKAGE_KEY}`); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/utils/file.service.spec.ts b/tests/core/utils/file.service.spec.ts index ca11f68a..11c6ffe8 100644 --- a/tests/core/utils/file.service.spec.ts +++ b/tests/core/utils/file.service.spec.ts @@ -109,4 +109,32 @@ describe("FileService", () => { expect(entries.some(name => name.endsWith(".zip"))).toBe(false); }); }); + + describe("writeBufferToFileWithGivenName", () => { + test("Should write the raw buffer to disk byte-for-byte", () => { + const targetFile = path.join(tempDir, "export.zip"); + const data = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]); + + fileService.writeBufferToFileWithGivenName(data, targetFile); + + expect(fs.existsSync(targetFile)).toBe(true); + expect(fs.readFileSync(targetFile).equals(data)).toBe(true); + }); + }); + + describe("extractZipBufferToDirectory", () => { + test("Should extract the package zip buffer into the target directory", () => { + const zip = new AdmZip(); + zip.addFile("package.json", Buffer.from(JSON.stringify({ key: "pkg-1" }))); + zip.addFile("nodes/node-1.json", Buffer.from(JSON.stringify({ key: "node-1" }))); + const zipBuffer = zip.toBuffer(); + + const targetDir = path.join(tempDir, "extracted"); + fileService.extractZipBufferToDirectory(zipBuffer, targetDir); + + expect(fs.existsSync(path.join(targetDir, "package.json"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "nodes", "node-1.json"))).toBe(true); + expect(JSON.parse(fs.readFileSync(path.join(targetDir, "package.json"), "utf-8"))).toEqual({ key: "pkg-1" }); + }); + }); }); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index cdb6bbe2..c75b7f83 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -8,6 +8,7 @@ import { NodeDependencyService } from "../../../src/commands/configuration-manag import { PackageVersionCommandService } from "../../../src/commands/configuration-management/package-version-command.service"; import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; +import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; import { buildTestProgram } from "../../utls/cli-program"; import { loggingTestTransport } from "../../jest.setup"; @@ -19,6 +20,7 @@ jest.mock("../../../src/commands/configuration-management/node-dependency.servic jest.mock("../../../src/commands/configuration-management/node-diff.service"); jest.mock("../../../src/commands/configuration-management/package-version-command.service"); jest.mock("../../../src/commands/configuration-management/single-package-import.service"); +jest.mock("../../../src/commands/configuration-management/single-package-export.service"); describe("configuration-management command integration", () => { let program: Command; @@ -30,6 +32,7 @@ describe("configuration-management command integration", () => { let mockNodeDiffService: jest.Mocked; let mockPackageVersionCommandService: jest.Mocked; let mockSinglePackageImportService: jest.Mocked; + let mockSinglePackageExportService: jest.Mocked; beforeEach(() => { mockConfigCommandService = { @@ -69,6 +72,10 @@ describe("configuration-management command integration", () => { importPackage: jest.fn().mockResolvedValue(undefined), } as any; + mockSinglePackageExportService = { + exportPackage: jest.fn().mockResolvedValue(undefined), + } as any; + (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); (StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService); (MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService); @@ -77,6 +84,7 @@ describe("configuration-management command integration", () => { (NodeDiffService as jest.MockedClass).mockImplementation(() => mockNodeDiffService); (PackageVersionCommandService as jest.MockedClass).mockImplementation(() => mockPackageVersionCommandService); (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); + (SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService); program = buildTestProgram([Module]); }); @@ -520,6 +528,26 @@ describe("configuration-management command integration", () => { }); }); + describe("config package export (exportSinglePackage)", () => { + it("exports unzipped by default (zip defaults to false)", async () => { + await runCli(["config", "package", "export", "--packageKey", "my-package"]); + + expect(mockSinglePackageExportService.exportPackage).toHaveBeenCalledWith( + "my-package", + false + ); + }); + + it("forwards --zip", async () => { + await runCli(["config", "package", "export", "--packageKey", "my-package", "--zip"]); + + expect(mockSinglePackageExportService.exportPackage).toHaveBeenCalledWith( + "my-package", + true + ); + }); + }); + describe("config variables list (listVariables)", () => { it("rejects when --packageKeys and --keysByVersion are both provided", async () => { await runCli([