Skip to content

Commit fbe694a

Browse files
Merge pull request #191 from contentstack/feat/Asset-Scanning-bulk
feat: add asset scanning support
2 parents 8541efb + 0472661 commit fbe694a

13 files changed

Lines changed: 610 additions & 77 deletions

File tree

packages/contentstack-bulk-operations/src/base-bulk-command.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export abstract class BaseBulkCommand extends Command {
132132
protected rateLimiter!: AdaptiveRateLimiter;
133133
protected retryStrategy!: RetryStrategy;
134134
protected operationExecutor!: OperationExecutor;
135-
private batchResults: Map<string, BulkJobResult> = new Map();
135+
protected batchResults: Map<string, BulkJobResult> = new Map();
136136
protected parsedFlags: any;
137137

138138
/**
@@ -164,7 +164,7 @@ export abstract class BaseBulkCommand extends Command {
164164
}
165165

166166
// Fill missing required flags via interactive prompts
167-
flags = await fillMissingFlags(flags);
167+
flags = await this.resolveFlagsInteractively(flags);
168168
this.parsedFlags = flags;
169169

170170
await this.buildConfiguration(flags);
@@ -264,6 +264,13 @@ export abstract class BaseBulkCommand extends Command {
264264
}
265265
}
266266

267+
/**
268+
* Resolve flags interactively — subclasses can override to skip prompts for specific modes.
269+
*/
270+
protected async resolveFlagsInteractively(flags: any): Promise<any> {
271+
return await fillMissingFlags(flags);
272+
}
273+
267274
/**
268275
* Build operation configuration
269276
*/

packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts

Lines changed: 272 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { flags, handleAndLogError, FlagInput } from '@contentstack/cli-utilities';
1+
import * as fs from 'fs';
2+
import * as path from 'path';
23

3-
import { ResourceType } from '../../../interfaces';
4+
import { flags, handleAndLogError, log } from '@contentstack/cli-utilities';
5+
6+
import { AssetPublishData, BulkOperationResult, OperationType, ResourceType } from '../../../interfaces';
47
import { BaseBulkCommand } from '../../../base-bulk-command';
5-
import { $t, messages, fetchAssets } from '../../../utils';
8+
import { $t, messages, fetchAssets, scanDataDirStats, BATCH_CONSTANTS, categorizeByScanStatus } from '../../../utils';
9+
import type { DataDirScanStats } from '../../../utils';
10+
import { AssetService } from '../../../services';
611

712
/**
813
* Bulk operations command for assets
9-
* Supports publish, unpublish, and cross publish operations
14+
* Supports publish, unpublish, cross-publish, and data-dir publish operations
1015
*/
1116
export default class BulkAssets extends BaseBulkCommand {
1217
static description = messages.BULK_ASSETS_DESCRIPTION;
@@ -32,44 +37,87 @@ export default class BulkAssets extends BaseBulkCommand {
3237

3338
// Revert (unpublish) previously published assets using success log
3439
'<%= config.bin %> <%= command.id %> --revert ./bulk-operation -a myAlias',
40+
41+
// Publish assets from exported content folder (e.g. after asset scanning clears)
42+
'<%= config.bin %> <%= command.id %> --data-dir ./content --operation publish -k blt123',
3543
];
3644

37-
static flags: FlagInput = {
45+
static flags = {
3846
...BaseBulkCommand.baseFlags,
3947
'folder-uid': flags.string({
4048
description: messages.FOLDER_UID,
4149
}),
50+
'data-dir': flags.string({
51+
char: 'd',
52+
description: messages.DATA_DIR_FLAG_DESC,
53+
}),
54+
'dry-run': flags.boolean({
55+
description: messages.DRY_RUN_FLAG_DESC,
56+
default: false,
57+
}),
4258
};
4359

4460
protected resourceType: ResourceType = ResourceType.ASSET;
4561

62+
protected async resolveFlagsInteractively(flags: any): Promise<any> {
63+
if (flags['data-dir']) {
64+
return flags;
65+
}
66+
return super.resolveFlagsInteractively(flags);
67+
}
68+
4669
async run(): Promise<void> {
4770
try {
48-
// Handle cross-publish separately if source-env is specified
4971
if (this.bulkOperationConfig.sourceEnv) {
5072
await this.handleCrossPublish(this.parsedFlags);
5173
return;
5274
}
5375

76+
if (this.bulkOperationConfig.dataDir) {
77+
await this.runDataDirFlow();
78+
return;
79+
}
80+
5481
const assets = await this.fetchItems();
5582

5683
if (assets.length === 0) {
5784
this.logger.warn($t(messages.NO_ITEMS_FOUND, { resourceType: ResourceType.ASSET }));
5885
return;
5986
}
6087

61-
this.logger.info(
62-
$t(messages.FOUND_ASSETS_TO_OPERATE, { count: assets.length, operation: this.parsedFlags.operation || '' })
63-
);
88+
const { clean, pending, quarantined, noStatus } = categorizeByScanStatus(assets);
89+
const scanningEnabled = clean.length + pending.length + quarantined.length > 0;
90+
const publishable = scanningEnabled ? clean : [...clean, ...noStatus];
91+
92+
if (scanningEnabled) {
93+
// Log individual skipped assets
94+
pending.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: a.uid })));
95+
quarantined.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: a.uid })));
6496

65-
// Confirm operation
66-
const confirmed = await this.confirmOperation(assets);
97+
this.printScanningDashboard({
98+
total: assets.length,
99+
clean: clean.length,
100+
pending: pending.length,
101+
quarantined: quarantined.length,
102+
});
103+
104+
if (publishable.length === 0) {
105+
this.logger.warn($t(messages.NO_PUBLISHABLE_ASSETS));
106+
return;
107+
}
108+
} else {
109+
log.info(
110+
$t(messages.FOUND_ASSETS_TO_OPERATE, { count: assets.length, operation: this.parsedFlags.operation || '' })
111+
);
112+
}
113+
114+
const confirmed = await this.confirmOperation(publishable);
67115
if (!confirmed) {
68116
this.logger.warn($t(messages.OPERATION_CANCELLED));
69117
return;
70118
}
71119

72-
const result = await this.executeBulkOperation(assets);
120+
const result = await this.executeBulkOperation(publishable);
73121
this.printOperationSummary(result);
74122
} catch (error) {
75123
handleAndLogError(error);
@@ -78,6 +126,218 @@ export default class BulkAssets extends BaseBulkCommand {
78126
}
79127
}
80128

129+
private async runDataDirFlow(): Promise<void> {
130+
const { dataDir, dryRun } = this.bulkOperationConfig;
131+
132+
// Capture original CLI locales/envs before pass 1 overwrites them on the config.
133+
const cliLocales = [...(this.bulkOperationConfig.locales || [])];
134+
const cliEnvs = [...(this.bulkOperationConfig.environments || [])];
135+
136+
// Pass 1 — count-only scan: no AssetPublishData objects built, one chunk in memory at a time.
137+
let stats: DataDirScanStats;
138+
try {
139+
stats = await scanDataDirStats(dataDir!, cliEnvs, cliLocales, this.logger);
140+
} catch (err: any) {
141+
this.logger.error($t(messages.DATA_DIR_READ_ERROR, { path: dataDir!, error: err.message || String(err) }));
142+
return;
143+
}
144+
145+
this.bulkOperationConfig.environments = stats.environments;
146+
this.bulkOperationConfig.locales = stats.locales;
147+
148+
// Pass 1.5 — fetch scan status for all target UIDs (post-import UIDs on the destination stack).
149+
const targetUids = Object.values(stats.assetUidMapper);
150+
const assetService = new AssetService(this.managementStack, this.deliveryStack, this.logger);
151+
const scanStatusMap = await assetService.fetchScanStatusByUIDs(targetUids);
152+
153+
let cleanCount = 0;
154+
let pendingCount = 0;
155+
let quarantinedCount = 0;
156+
for (const uid of targetUids) {
157+
const status = scanStatusMap.get(uid);
158+
if (status === 'pending') pendingCount++;
159+
else if (status === 'quarantined') quarantinedCount++;
160+
else cleanCount++; // clean or undefined (scanning disabled) — both are publishable
161+
}
162+
163+
this.printScanningDashboard({
164+
total: stats.eligible + stats.skipped + stats.unmapped,
165+
localSkipped: stats.skipped,
166+
unmapped: stats.unmapped,
167+
clean: cleanCount,
168+
pending: pendingCount,
169+
quarantined: quarantinedCount,
170+
});
171+
172+
if (cleanCount === 0) {
173+
this.logger.warn($t(messages.NO_PUBLISHABLE_ASSETS));
174+
return;
175+
}
176+
177+
// new Array(n) has .length === n but allocates no elements — just for the count.
178+
const confirmed = await this.confirmOperation(new Array(cleanCount));
179+
if (!confirmed) {
180+
this.logger.warn($t(messages.OPERATION_CANCELLED));
181+
return;
182+
}
183+
184+
if (dryRun) {
185+
log.info($t(messages.DATA_DIR_DRY_RUN));
186+
return;
187+
}
188+
189+
// Pass 2 — stream and publish: one chunk at a time, batches of ≤50 items enqueued directly.
190+
// stats.assetUidMapper and stats.assetsIndex are reused from pass 1 — no second disk read.
191+
const result = await this.streamAndPublish(
192+
dataDir!,
193+
cliLocales,
194+
stats.totalItems,
195+
stats.assetUidMapper,
196+
stats.assetsIndex,
197+
scanStatusMap
198+
);
199+
this.printOperationSummary(result);
200+
}
201+
202+
/**
203+
* Pass 2 of the data-dir flow.
204+
* Reads chunk files one at a time, fills a working batch of ≤50 AssetPublishData items,
205+
* and enqueues each batch directly into the queue manager without ever holding the full
206+
* asset list in memory. Peak memory: one chunk file + one batch of ≤50 items.
207+
*
208+
* assetUidMapper and assetsIndex are passed in from pass 1 to avoid re-reading those files.
209+
* scanStatusMap filters out non-clean assets before enqueueing.
210+
*/
211+
private async streamAndPublish(
212+
dataDir: string,
213+
cliLocales: string[],
214+
totalItemCount: number,
215+
assetUidMapper: Record<string, string>,
216+
assetsIndex: Record<string, string>,
217+
scanStatusMap: Map<string, string | undefined>
218+
): Promise<BulkOperationResult> {
219+
// Snapshot both arrays so in-flight mutations to bulkOperationConfig can't corrupt payloads.
220+
const environments = [...this.bulkOperationConfig.environments!];
221+
const locales = [...this.bulkOperationConfig.locales!];
222+
const operation = this.bulkOperationConfig.operation as OperationType;
223+
const startTime = Date.now();
224+
225+
// Warn early if the mapper is empty — all assets will be skipped and the user needs to know why.
226+
if (Object.keys(assetUidMapper).length === 0) {
227+
this.logger.warn(
228+
'Asset UID mapper is empty — all assets will be skipped. Ensure the import completed successfully.'
229+
);
230+
}
231+
232+
const useOverrideLocales = cliLocales.length > 0;
233+
const BATCH_SIZE = BATCH_CONSTANTS.maxItems;
234+
// totalItemCount comes from pass 1 using identical counting logic — used as upper bound for totalBatches.
235+
// Scan status filtering may reduce the actual count; the invariant check below will log any mismatch.
236+
const totalBatches = Math.ceil(totalItemCount / BATCH_SIZE);
237+
238+
let workingBatch: AssetPublishData[] = [];
239+
let batchNumber = 0;
240+
let totalSubmitted = 0;
241+
242+
this.batchResults.clear();
243+
244+
const flushBatch = (): void => {
245+
if (workingBatch.length === 0) return;
246+
batchNumber++;
247+
this.queueManager.enqueue(ResourceType.ASSET, operation, {
248+
items: [...workingBatch],
249+
environments,
250+
locales,
251+
batchNumber,
252+
totalBatches,
253+
operation,
254+
});
255+
totalSubmitted += workingBatch.length;
256+
workingBatch = [];
257+
};
258+
259+
for (const chunkFilename of Object.values(assetsIndex)) {
260+
const chunkPath = path.join(dataDir, 'assets', chunkFilename);
261+
const chunkData: Record<string, any> = JSON.parse(fs.readFileSync(chunkPath, 'utf-8'));
262+
263+
for (const asset of Object.values(chunkData)) {
264+
if (!asset.publish_details || asset.publish_details.length === 0) continue;
265+
const targetUid = assetUidMapper[asset.uid as string];
266+
if (!targetUid) continue;
267+
268+
// Skip assets that did not pass scanning.
269+
const scanStatus = scanStatusMap.get(targetUid);
270+
if (scanStatus === 'quarantined') {
271+
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: targetUid }));
272+
continue;
273+
}
274+
if (scanStatus === 'pending') {
275+
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: targetUid }));
276+
continue;
277+
}
278+
279+
const assetLocales: string[] = useOverrideLocales
280+
? cliLocales
281+
: [...new Set<string>(asset.publish_details.map((pd: any) => pd.locale as string))];
282+
283+
for (const locale of assetLocales) {
284+
workingBatch.push({ type: 'asset', uid: targetUid, locale, version: asset._version });
285+
if (workingBatch.length >= BATCH_SIZE) {
286+
flushBatch();
287+
}
288+
}
289+
}
290+
// chunkData falls out of scope here — GC can reclaim it before the next chunk is read.
291+
}
292+
293+
flushBatch();
294+
295+
// Invariant: pass 1 and pass 2 use identical counting logic (excluding scan status filtering).
296+
// If batchNumber < totalBatches, scan status filtering reduced the published count — expected.
297+
if (batchNumber !== totalBatches) {
298+
this.logger.debug(
299+
`Batch count: predicted ${totalBatches}, actual ${batchNumber}. Difference is expected when assets are skipped due to scan status.`
300+
);
301+
}
302+
303+
await this.queueManager.waitForCompletion();
304+
305+
const duration = Date.now() - startTime;
306+
const jobIds = [...this.batchResults.values()].map((r) => r.jobId).filter((id): id is string => !!id);
307+
308+
return { success: 0, failed: 0, total: totalSubmitted, duration, jobIds };
309+
}
310+
311+
private printScanningDashboard(opts: {
312+
total: number;
313+
clean: number;
314+
pending: number;
315+
quarantined: number;
316+
localSkipped?: number;
317+
unmapped?: number;
318+
}): void {
319+
const { total, clean, pending, quarantined, localSkipped, unmapped } = opts;
320+
const SEP = '─'.repeat(42);
321+
322+
log.info('');
323+
log.info(` ${messages.DATA_DIR_ASSET_SCANNING_HEADER}`);
324+
log.info(' ' + SEP);
325+
log.info(` ${messages.DATA_DIR_TOTAL.padEnd(38)} ${total}`);
326+
if (localSkipped !== undefined) {
327+
log.warn(` ${messages.DATA_DIR_NO_PUBLISH_DETAILS.padEnd(38)} ${localSkipped}`);
328+
}
329+
if (unmapped !== undefined) {
330+
log.warn(` ${messages.DATA_DIR_UNMAPPED.padEnd(38)} ${unmapped}`);
331+
}
332+
log.info(' ' + SEP);
333+
log.info(` ${messages.SCAN_STATUS_CLEAN.padEnd(38)} ${clean}`);
334+
if (pending > 0) log.warn(` ${messages.SCAN_STATUS_PENDING.padEnd(38)} ${pending}`);
335+
if (quarantined > 0) log.warn(` ${messages.SCAN_STATUS_QUARANTINED.padEnd(38)} ${quarantined}`);
336+
log.info(' ' + SEP);
337+
log.info(` ${messages.DATA_DIR_WILL_PUBLISH.padEnd(38)} ${clean}`);
338+
log.info('');
339+
}
340+
81341
protected async fetchItems(): Promise<any[]> {
82342
return await fetchAssets(this.bulkOperationConfig, this.managementStack, this.deliveryStack, this.logger);
83343
}

0 commit comments

Comments
 (0)