Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions docs/user-guide/config-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<packageKey>_<version>.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 `<packageKey>_<version>.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`.
Expand All @@ -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`.
Expand Down Expand Up @@ -167,6 +168,49 @@ content-cli config package import -p <sourceProfile> -f <file path> --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 `<packageKey>` 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 <sourceProfile> --packageKey <packageKey>
```

```bash
info: Successful export. Exported directory: my-package
```

Use `--zip` to export the package as a single `<packageKey>.zip` file in the current working directory instead:

```bash
content-cli config package export -p <sourceProfile> --packageKey <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/
│ ├─ <nodeKey>.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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/t2tc-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Buffer> {
return this.httpClient()
.getFile(`/pacman/api/core/staging/packages/${packageKey}/export-file`)
.catch(e => {
throw new FatalError(`Problem exporting package ${packageKey}: ${e}`);
});
}
}
11 changes: 11 additions & 0 deletions src/commands/configuration-management/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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 <packageKey>", "Key of the package to export")
.option("--zip", "Export the package as a single <packageKey>.zip file instead of an unzipped <packageKey> directory", false)
.action(this.exportSinglePackage);

packageCommand.command("validate")
.description("Validate package node configurations")
.requiredOption("--packageKey <packageKey>", "Key of the package to validate")
Expand Down Expand Up @@ -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<void> {
await new SinglePackageExportService(context).exportPackage(options.packageKey, options.zip);
}

private async diffPackages(context: Context, command: Command, options: OptionValues): Promise<void> {
await new T2tcCommandService(context).diffPackages(options.file, options.hasChanges, options.baseVersion, options.json);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`);
}
}
2 changes: 1 addition & 1 deletion src/commands/t2tc/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <packageKeys...>", "Keys of packages to export. Exports the latest deployed version only")
.option("--keysByVersion <keysByVersion...>", "Keys of packages to export by version")
.option("--withDependencies", "Include variables and dependencies", "")
Expand Down
13 changes: 13 additions & 0 deletions src/core/utils/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(fileName: string): Promise<T> {
const fileContent = this.readFile(fileName);

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <packageKey> 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 <packageKey>.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();
});
});
28 changes: 28 additions & 0 deletions tests/core/utils/file.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
});
});
Loading
Loading