Skip to content
Open
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
14 changes: 8 additions & 6 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
fileignoreconfig:

- filename: pnpm-lock.yaml
checksum: 98236e2588d5cffcbdda3464179d91e9540fd8ddb4d4fb5251cf0f7379876f42
- filename: packages/contentstack-cli-tsgen/src/lib/helper.ts
checksum: cc2f88294ca026c29ca44ee7f9994ebc64e56d9a7a015c64070aaf271f1c3ba2
- filename: packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts
checksum: 083c6c40fa91a55a34e658b6f9b7229f304de306d8a3e7853664d32c4e34097c
- filename: packages/contentstack-bulk-operations/src/utils/backup-dir-asset-fetcher.ts
checksum: fbf1ef882bf4ea8f06a7a28b9248a07e01f5a5d2d4da98dbb9ee52bc16ce318a
- filename: packages/contentstack-bulk-operations/test/unit/utils/backup-dir-asset-fetcher.test.ts
checksum: b905524c952aff0f089fa5347e0db18479b75a438cd9ae77e2dfe1cb203d7e59
- filename: pnpm-lock.yaml
checksum: 98236e2588d5cffcbdda3464179d91e9540fd8ddb4d4fb5251cf0f7379876f42
- filename: packages/contentstack-import/src/import/modules/assets.ts
checksum: 014f612c3e8db21c891da0c17a1a3b4cd340c026c58034791d86cd5f73aa9f90
- filename: packages/contentstack-bulk-operations/test/unit/utils/revert-retry-handler.test.ts
checksum: 142e7b67a06bc13b6b4191b17f2ea8ae31a3bce20086ea35a92f3e3671eb603d
- filename: packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts
checksum: 9392511b337c361b86c36eb2431d8fea9129a61f6b7d70dcb4a633fea339ca61
- filename: packages/contentstack-bulk-operations/src/utils/batch-helper.ts
checksum: ddcd8ef7c56d1122df88075883a2768a28813147e8fdef5b8e72a1ec118c045a
- filename: packages/contentstack-bulk-operations/test/unit/utils/revert-retry-handler.test.ts
checksum: 142e7b67a06bc13b6b4191b17f2ea8ae31a3bce20086ea35a92f3e3671eb603d
version: ""
11 changes: 9 additions & 2 deletions packages/contentstack-bulk-operations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ USAGE
$ csdx cm:stacks:bulk-assets [-a <value>] [-k <value>] [--operation publish|unpublish|delete|move] [--source-alias
<value>] [--publish-mode bulk|single] [--branch <value>] [-c <value>] [-y] [--retry-failed <value>] [--revert
<value>] [--bulk-operation-file <value>] [--backup-dir <value> | --source-env <value> | --folder-uid <value> |
--environments <value>... | --locales <value>...] [--dry-run] [--space-uid <value>] [--org-uid <value>] [--workspace
<value>] [--asset-uids-file <value>] [--locale <value>] [--target-folder-uid <value>]
--environments <value>... | --locales <value>...] [--dry-run] [--retry-pending <value>] [--space-uid <value>]
[--org-uid <value>] [--workspace <value>] [--asset-uids-file <value>] [--locale <value>] [--target-folder-uid
<value>]

FLAGS
-a, --alias=<value> Uses the name of a saved Management Token to authenticate the command. The command
Expand Down Expand Up @@ -88,6 +89,10 @@ FLAGS
--retry-failed=<value> (optional) Use this option to retry publishing the failed entries/assets from the
logfile. Specify the name of the logfile that lists failed publish calls. If this
option is used, it will override all other flags.
--retry-pending=<value> (optional) Re-checks the malware scan status of assets a previous run skipped
because scanning was still in progress, and publishes the ones that are now clean.
Specify the bulk operation folder used by that run. Quarantined assets are never
retried.
--revert=<value> (optional) Revert publish operations from a log folder. Specify the folder path
containing success logs. Works similar to retry-failed.
--source-alias=<value> Alias name for source environment delivery token (required for cross-publish). Add
Expand Down Expand Up @@ -115,6 +120,8 @@ EXAMPLES

$ csdx cm:stacks:bulk-assets --retry-failed ./bulk-operation -a myAlias

$ csdx cm:stacks:bulk-assets --retry-pending ./bulk-operation -a myAlias

$ csdx cm:stacks:bulk-assets --revert ./bulk-operation -a myAlias

$ csdx cm:stacks:bulk-assets --backup-dir ./content --operation publish -k blt123
Expand Down
18 changes: 18 additions & 0 deletions packages/contentstack-bulk-operations/src/base-bulk-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ export abstract class BaseBulkCommand extends Command {
return false;
}

/**
* Hook for a resource-specific retry flow that loads its config from a log file
* rather than the usual flags — like --retry-failed/--revert, but with its own
* pre-publish checks (see BulkAssets and --retry-pending).
*
* Return true when the flow handled the run: init() then returns immediately,
* skipping interactive prompts, the normal pipeline, and clearLogs().
*/
protected async handleResourceSpecificRetryFlow(_flags: any): Promise<boolean> {
return false;
}

/**
* Initialize common components
*/
Expand All @@ -181,6 +193,12 @@ export abstract class BaseBulkCommand extends Command {
this.logger = log;
this.loggerContext = { module: this.id };

// Resource-specific retry flows (e.g. asset scan-status retry) run their own
// init and must bypass clearLogs() for the same reason revert/retry does.
if (await this.handleResourceSpecificRetryFlow(flags)) {
return;
}

// Check for revert/retry EARLY - all config comes from log file
const isRevertOrRetry = flags.revert || flags['retry-failed'];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import * as path from 'path';

import { flags, handleAndLogError, log, FlagInput } from '@contentstack/cli-utilities';

import { BulkOperationResult, ResourceType, OperationType, CsAssetsFlags } from '../../../interfaces';
import {
AssetPublishData,
BulkOperationResult,
ResourceType,
OperationType,
CsAssetsFlags,
PendingScanLogEntry,
} from '../../../interfaces';
import { BaseBulkCommand } from '../../../base-bulk-command';
import {
$t,
Expand All @@ -21,6 +28,12 @@ import {
OperationFlagMatrixError,
RETRY_REVERT_CONTEXT,
} from '../../../utils';
import {
appendPendingScanLog,
getLogPaths,
readPendingScanLog,
writePendingScanLog,
} from '../../../utils/bulk-operation-log-handler';
import type { BackupDirScanStats } from '../../../utils';
import { AssetService } from '../../../services';

Expand Down Expand Up @@ -66,6 +79,9 @@ export default class BulkAssets extends BaseBulkCommand {
// Retry failed assets from a log file
'<%= config.bin %> <%= command.id %> --retry-failed ./bulk-operation -a myAlias',

// Re-check scan status and publish assets a previous run skipped as still scanning
'<%= config.bin %> <%= command.id %> --retry-pending ./bulk-operation -a myAlias',

// Revert (unpublish) previously published assets using success log
'<%= config.bin %> <%= command.id %> --revert ./bulk-operation -a myAlias',

Expand Down Expand Up @@ -98,6 +114,9 @@ export default class BulkAssets extends BaseBulkCommand {
description: messages.DRY_RUN_FLAG_DESC,
default: false,
}),
'retry-pending': flags.string({
description: messages.RETRY_PENDING,
}),

// CS Assets delete/move flags
'space-uid': flags.string({
Expand Down Expand Up @@ -142,7 +161,9 @@ export default class BulkAssets extends BaseBulkCommand {
token === '--retry-failed' ||
token.startsWith('--retry-failed=') ||
token === '--revert' ||
token.startsWith('--revert=')
token.startsWith('--revert=') ||
token === '--retry-pending' ||
token.startsWith('--retry-pending=')
);

if (!operation && !isRevertOrRetry) {
Expand Down Expand Up @@ -219,6 +240,20 @@ export default class BulkAssets extends BaseBulkCommand {
pending.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: a.uid })));
quarantined.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: a.uid })));

// Persist the pending ones so --retry-pending can publish them once the scan clears.
// fetchAssets() builds one row per uid x locale, so each carries its own locale/version.
appendPendingScanLog(
pending.map((a) =>
this.buildPendingScanEntry({
uid: a.uid,
locale: a.locale,
version: a.version ?? a._version,
environments: (a.publish_details || []).map((pd: any) => pd.environment),
})
),
this.bulkOperationConfig.bulkOperationFolder
);

this.printScanningDashboard({
total: assets.length,
clean: clean.length,
Expand Down Expand Up @@ -340,6 +375,9 @@ export default class BulkAssets extends BaseBulkCommand {
}

let totalSubmitted = 0;
// Collected across the whole stream and written once at the end — a per-asset
// write would defeat the one-chunk-at-a-time design of this pass.
const pendingScanEntries: PendingScanLogEntry[] = [];

this.batchResults.clear();

Expand Down Expand Up @@ -372,13 +410,20 @@ export default class BulkAssets extends BaseBulkCommand {
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: targetUid }));
continue;
}

const targets = assetPublishTargets(asset, environmentsMap);

if (scanStatus === 'pending') {
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: targetUid }));
// Record per target so --retry-pending republishes exactly the rows this run would have.
for (const [locale, environments] of targets) {
pendingScanEntries.push(
this.buildPendingScanEntry({ uid: targetUid, locale, version: asset._version, environments })
);
}
continue;
}

const targets = assetPublishTargets(asset, environmentsMap);

for (const [locale, environments] of targets) {
batcher.add({
type: 'asset',
Expand All @@ -394,6 +439,9 @@ export default class BulkAssets extends BaseBulkCommand {

batcher.end();

// One write for the whole streamed run.
appendPendingScanLog(pendingScanEntries, this.bulkOperationConfig.bulkOperationFolder);

if (batcher.skippedCount > 0) {
this.logger.warn(`Skipped ${batcher.skippedCount} asset item(s) with no resolvable publish target.`);
}
Expand Down Expand Up @@ -447,4 +495,132 @@ export default class BulkAssets extends BaseBulkCommand {
protected async fetchItems(): Promise<any[]> {
return await fetchAssets(this.bulkOperationConfig, this.managementStack, this.deliveryStack, this.logger);
}

protected async handleResourceSpecificRetryFlow(flags: any): Promise<boolean> {
if (!flags['retry-pending']) {
return false;
}
await this.initForRetryPendingScan(flags);
return true;
}

/**
* Initialize the --retry-pending run. Mirrors initForRevertOrRetry: everything
* needed comes from the pending-scan log, with any CLI flag overriding it.
*/
private async initForRetryPendingScan(flags: any): Promise<void> {
const logPath = flags['retry-pending'];
const pendingEntries = readPendingScanLog(logPath);

if (pendingEntries.length === 0) {
log.warn($t(messages.NO_PENDING_SCAN_ITEMS_IN_LOG, { path: getLogPaths(logPath).pendingScan }));
this.finalizeProgressSummary();
process.exit(0);
}

const [first] = pendingEntries;
const mergedFlags = {
...flags,
'stack-api-key': flags['stack-api-key'] || first.apiKey,
environments:
flags.environments?.length > 0
? flags.environments
: [...new Set(pendingEntries.flatMap((entry) => entry.environments))],
locales: flags.locales?.length > 0 ? flags.locales : [...new Set(pendingEntries.map((entry) => entry.locale))],
branch: flags.branch !== 'main' ? flags.branch : first.branch || 'main',
// Scan status only ever gates publish.
operation: OperationType.PUBLISH,
'publish-mode': flags['publish-mode'] || 'bulk',
};

this.parsedFlags = mergedFlags;
await this.buildConfiguration(mergedFlags);
await this.setupStack();
await this.initializeComponents();

await this.retryPendingScan(logPath, pendingEntries);

// Mirrors initForRevertOrRetry: this early exit bypasses finally().
this.finalizeProgressSummary();
process.exit(0);
}

/**
* Stamp a pending-scan skip with the run metadata --retry-pending needs to
* rebuild the publish call later without re-fetching the asset.
*/
private buildPendingScanEntry(item: {
uid: string;
locale: string;
version?: number;
environments: string[];
}): PendingScanLogEntry {
return {
...item,
operation: 'publish',
timestamp: new Date().toISOString(),
apiKey: this.bulkOperationConfig.apiKey || this.bulkOperationConfig.stackApiKey || '',
branch: this.bulkOperationConfig.branch,
};
}

/**
* Re-check the scan status of assets a previous run skipped as still scanning,
* publish the ones now clean, and prune the log down to those still pending.
*/
private async retryPendingScan(logPath: string, pendingEntries: PendingScanLogEntry[]): Promise<void> {
const uids = [...new Set(pendingEntries.map((entry) => entry.uid))];
const assetService = new AssetService(this.managementStack, this.deliveryStack, this.logger);
const scanStatusMap = await assetService.fetchScanStatusByUIDs(uids);

const nowClean: PendingScanLogEntry[] = [];
const stillPending: PendingScanLogEntry[] = [];
const nowQuarantined: PendingScanLogEntry[] = [];

for (const entry of pendingEntries) {
const status = scanStatusMap.get(entry.uid);
if (status === 'pending') stillPending.push(entry);
else if (status === 'quarantined') nowQuarantined.push(entry);
else nowClean.push(entry); // clean, or undefined when scanning is disabled
}

log.info(
$t(messages.SCAN_RECHECK_SUMMARY, {
total: pendingEntries.length,
clean: nowClean.length,
pending: stillPending.length,
quarantined: nowQuarantined.length,
})
);
nowQuarantined.forEach((entry) =>
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: entry.uid }))
);

if (nowClean.length === 0) {
this.logger.warn($t(messages.NO_PUBLISHABLE_ASSETS));
// Still prune: the quarantined ones will never become publishable.
writePendingScanLog(stillPending, logPath);
return;
}

const items: AssetPublishData[] = nowClean.map((entry) => ({
type: 'asset',
uid: entry.uid,
locale: entry.locale,
version: entry.version,
publish_details: entry.environments.map((environment) => ({ environment, locale: entry.locale })),
}));

const confirmed = await this.confirmOperation(items);
if (!confirmed) {
this.logger.warn($t(messages.OPERATION_CANCELLED));
// Leave the log untouched so the next run sees the same set.
return;
}

const result = await this.executeBulkOperation(items);
this.printOperationSummary(result);

writePendingScanLog(stillPending, logPath);
}
}
21 changes: 21 additions & 0 deletions packages/contentstack-bulk-operations/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,25 @@ export interface SingleModeLogEntry {
*/
export type LogEntry = BulkModeLogEntry | SingleModeLogEntry;

/**
* Log entry for an asset skipped because its malware scan was still pending.
*
* Flat (no bulk/single split) — the skip happens before the publish mode matters.
* Self-sufficient by design: a later --retry-pending run rebuilds the publish
* payload straight from this record without re-fetching the asset.
*/
export interface PendingScanLogEntry {
uid: string;
locale: string;
version?: number;
environments: string[];
operation: 'publish'; // scan status only gates publish
timestamp: string;
// Metadata
apiKey: string;
branch?: string;
}

export interface LogPaths {
folder: string;
// Bulk mode logs
Expand All @@ -408,6 +427,8 @@ export interface LogPaths {
// Single mode logs
singleSuccess: string;
singleFailed: string;
// Assets skipped for a pending scan, retried via --retry-pending
pendingScan: string;
}

/**
Expand Down
Loading
Loading