attachmentMigration.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. import { Meteor } from 'meteor/meteor';
  2. import { ReactiveCache } from '/imports/reactiveCache';
  3. import { Attachments, fileStoreStrategyFactory } from '/models/attachments';
  4. import { moveToStorage } from '/models/lib/fileStoreStrategy';
  5. import os from 'os';
  6. import { createHash } from 'crypto';
  7. // Migration state management
  8. const migrationState = {
  9. isRunning: false,
  10. isPaused: false,
  11. targetStorage: null,
  12. batchSize: 10,
  13. delayMs: 1000,
  14. cpuThreshold: 70,
  15. progress: 0,
  16. totalAttachments: 0,
  17. migratedAttachments: 0,
  18. currentBatch: [],
  19. migrationQueue: [],
  20. log: [],
  21. startTime: null,
  22. lastCpuCheck: 0
  23. };
  24. // CPU monitoring
  25. function getCpuUsage() {
  26. const cpus = os.cpus();
  27. let totalIdle = 0;
  28. let totalTick = 0;
  29. cpus.forEach(cpu => {
  30. for (const type in cpu.times) {
  31. totalTick += cpu.times[type];
  32. }
  33. totalIdle += cpu.times.idle;
  34. });
  35. const idle = totalIdle / cpus.length;
  36. const total = totalTick / cpus.length;
  37. const usage = 100 - Math.floor(100 * idle / total);
  38. return usage;
  39. }
  40. // Logging function
  41. function addToLog(message) {
  42. const timestamp = new Date().toISOString();
  43. const logEntry = `[${timestamp}] ${message}`;
  44. migrationState.log.unshift(logEntry);
  45. // Keep only last 100 log entries
  46. if (migrationState.log.length > 100) {
  47. migrationState.log = migrationState.log.slice(0, 100);
  48. }
  49. console.log(logEntry);
  50. }
  51. // Get migration status
  52. function getMigrationStatus() {
  53. return {
  54. isRunning: migrationState.isRunning,
  55. isPaused: migrationState.isPaused,
  56. targetStorage: migrationState.targetStorage,
  57. progress: migrationState.progress,
  58. totalAttachments: migrationState.totalAttachments,
  59. migratedAttachments: migrationState.migratedAttachments,
  60. remainingAttachments: migrationState.totalAttachments - migrationState.migratedAttachments,
  61. status: migrationState.isRunning ? (migrationState.isPaused ? 'paused' : 'running') : 'idle',
  62. log: migrationState.log.slice(0, 10).join('\n'), // Return last 10 log entries
  63. startTime: migrationState.startTime,
  64. estimatedTimeRemaining: calculateEstimatedTimeRemaining()
  65. };
  66. }
  67. // Calculate estimated time remaining
  68. function calculateEstimatedTimeRemaining() {
  69. if (!migrationState.isRunning || migrationState.migratedAttachments === 0) {
  70. return null;
  71. }
  72. const elapsed = Date.now() - migrationState.startTime;
  73. const rate = migrationState.migratedAttachments / elapsed; // attachments per ms
  74. const remaining = migrationState.totalAttachments - migrationState.migratedAttachments;
  75. return Math.round(remaining / rate);
  76. }
  77. // Process a single attachment migration
  78. function migrateAttachment(attachmentId) {
  79. try {
  80. const attachment = ReactiveCache.getAttachment(attachmentId);
  81. if (!attachment) {
  82. addToLog(`Warning: Attachment ${attachmentId} not found`);
  83. return false;
  84. }
  85. // Check if already in target storage
  86. const currentStorage = fileStoreStrategyFactory.getFileStrategy(attachment, 'original').getStorageName();
  87. if (currentStorage === migrationState.targetStorage) {
  88. addToLog(`Attachment ${attachmentId} already in target storage ${migrationState.targetStorage}`);
  89. return true;
  90. }
  91. // Perform migration
  92. moveToStorage(attachment, migrationState.targetStorage, fileStoreStrategyFactory);
  93. addToLog(`Migrated attachment ${attachmentId} from ${currentStorage} to ${migrationState.targetStorage}`);
  94. return true;
  95. } catch (error) {
  96. addToLog(`Error migrating attachment ${attachmentId}: ${error.message}`);
  97. return false;
  98. }
  99. }
  100. // Process a batch of attachments
  101. function processBatch() {
  102. if (!migrationState.isRunning || migrationState.isPaused) {
  103. return;
  104. }
  105. const batch = migrationState.migrationQueue.splice(0, migrationState.batchSize);
  106. if (batch.length === 0) {
  107. // Migration complete
  108. migrationState.isRunning = false;
  109. migrationState.progress = 100;
  110. addToLog(`Migration completed. Migrated ${migrationState.migratedAttachments} attachments.`);
  111. return;
  112. }
  113. let successCount = 0;
  114. batch.forEach(attachmentId => {
  115. if (migrateAttachment(attachmentId)) {
  116. successCount++;
  117. migrationState.migratedAttachments++;
  118. }
  119. });
  120. // Update progress
  121. migrationState.progress = Math.round((migrationState.migratedAttachments / migrationState.totalAttachments) * 100);
  122. addToLog(`Processed batch: ${successCount}/${batch.length} successful. Progress: ${migrationState.progress}%`);
  123. // Check CPU usage
  124. const currentTime = Date.now();
  125. if (currentTime - migrationState.lastCpuCheck > 5000) { // Check every 5 seconds
  126. const cpuUsage = getCpuUsage();
  127. migrationState.lastCpuCheck = currentTime;
  128. if (cpuUsage > migrationState.cpuThreshold) {
  129. addToLog(`CPU usage ${cpuUsage}% exceeds threshold ${migrationState.cpuThreshold}%. Pausing migration.`);
  130. migrationState.isPaused = true;
  131. return;
  132. }
  133. }
  134. // Schedule next batch
  135. if (migrationState.isRunning && !migrationState.isPaused) {
  136. Meteor.setTimeout(() => {
  137. processBatch();
  138. }, migrationState.delayMs);
  139. }
  140. }
  141. // Initialize migration queue
  142. function initializeMigrationQueue() {
  143. const allAttachments = ReactiveCache.getAttachments();
  144. migrationState.totalAttachments = allAttachments.length;
  145. migrationState.migrationQueue = allAttachments.map(attachment => attachment._id);
  146. migrationState.migratedAttachments = 0;
  147. migrationState.progress = 0;
  148. addToLog(`Initialized migration queue with ${migrationState.totalAttachments} attachments`);
  149. }
  150. // Start migration
  151. function startMigration(targetStorage, batchSize, delayMs, cpuThreshold) {
  152. if (migrationState.isRunning) {
  153. throw new Meteor.Error('migration-already-running', 'Migration is already running');
  154. }
  155. migrationState.isRunning = true;
  156. migrationState.isPaused = false;
  157. migrationState.targetStorage = targetStorage;
  158. migrationState.batchSize = batchSize;
  159. migrationState.delayMs = delayMs;
  160. migrationState.cpuThreshold = cpuThreshold;
  161. migrationState.startTime = Date.now();
  162. migrationState.lastCpuCheck = 0;
  163. initializeMigrationQueue();
  164. addToLog(`Started migration to ${targetStorage} with batch size ${batchSize}, delay ${delayMs}ms, CPU threshold ${cpuThreshold}%`);
  165. // Start processing
  166. processBatch();
  167. }
  168. // Pause migration
  169. function pauseMigration() {
  170. if (!migrationState.isRunning) {
  171. throw new Meteor.Error('migration-not-running', 'No migration is currently running');
  172. }
  173. migrationState.isPaused = true;
  174. addToLog('Migration paused');
  175. }
  176. // Resume migration
  177. function resumeMigration() {
  178. if (!migrationState.isRunning) {
  179. throw new Meteor.Error('migration-not-running', 'No migration is currently running');
  180. }
  181. if (!migrationState.isPaused) {
  182. throw new Meteor.Error('migration-not-paused', 'Migration is not paused');
  183. }
  184. migrationState.isPaused = false;
  185. addToLog('Migration resumed');
  186. // Continue processing
  187. processBatch();
  188. }
  189. // Stop migration
  190. function stopMigration() {
  191. if (!migrationState.isRunning) {
  192. throw new Meteor.Error('migration-not-running', 'No migration is currently running');
  193. }
  194. migrationState.isRunning = false;
  195. migrationState.isPaused = false;
  196. migrationState.migrationQueue = [];
  197. addToLog('Migration stopped');
  198. }
  199. // Get attachment storage configuration
  200. function getAttachmentStorageConfiguration() {
  201. const config = {
  202. filesystemPath: process.env.WRITABLE_PATH || '/data',
  203. attachmentsPath: `${process.env.WRITABLE_PATH || '/data'}/attachments`,
  204. avatarsPath: `${process.env.WRITABLE_PATH || '/data'}/avatars`,
  205. gridfsEnabled: true, // Always available
  206. s3Enabled: false,
  207. s3Endpoint: '',
  208. s3Bucket: '',
  209. s3Region: '',
  210. s3SslEnabled: false,
  211. s3Port: 443
  212. };
  213. // Check S3 configuration
  214. if (process.env.S3) {
  215. try {
  216. const s3Config = JSON.parse(process.env.S3).s3;
  217. if (s3Config && s3Config.key && s3Config.secret && s3Config.bucket) {
  218. config.s3Enabled = true;
  219. config.s3Endpoint = s3Config.endPoint || '';
  220. config.s3Bucket = s3Config.bucket || '';
  221. config.s3Region = s3Config.region || '';
  222. config.s3SslEnabled = s3Config.sslEnabled || false;
  223. config.s3Port = s3Config.port || 443;
  224. }
  225. } catch (error) {
  226. console.error('Error parsing S3 configuration:', error);
  227. }
  228. }
  229. return config;
  230. }
  231. // Get attachment monitoring data
  232. function getAttachmentMonitoringData() {
  233. const attachments = ReactiveCache.getAttachments();
  234. const stats = {
  235. totalAttachments: attachments.length,
  236. filesystemAttachments: 0,
  237. gridfsAttachments: 0,
  238. s3Attachments: 0,
  239. totalSize: 0,
  240. filesystemSize: 0,
  241. gridfsSize: 0,
  242. s3Size: 0
  243. };
  244. attachments.forEach(attachment => {
  245. const storage = fileStoreStrategyFactory.getFileStrategy(attachment, 'original').getStorageName();
  246. const size = attachment.size || 0;
  247. stats.totalSize += size;
  248. switch (storage) {
  249. case 'fs':
  250. stats.filesystemAttachments++;
  251. stats.filesystemSize += size;
  252. break;
  253. case 'gridfs':
  254. stats.gridfsAttachments++;
  255. stats.gridfsSize += size;
  256. break;
  257. case 's3':
  258. stats.s3Attachments++;
  259. stats.s3Size += size;
  260. break;
  261. }
  262. });
  263. return stats;
  264. }
  265. // Test S3 connection
  266. function testS3Connection(s3Config) {
  267. // This would implement actual S3 connection testing
  268. // For now, we'll just validate the configuration
  269. if (!s3Config.secretKey) {
  270. throw new Meteor.Error('s3-secret-key-required', 'S3 secret key is required');
  271. }
  272. // In a real implementation, you would test the connection here
  273. // For now, we'll just return success
  274. return { success: true, message: 'S3 connection test successful' };
  275. }
  276. // Save S3 settings
  277. function saveS3Settings(s3Config) {
  278. if (!s3Config.secretKey) {
  279. throw new Meteor.Error('s3-secret-key-required', 'S3 secret key is required');
  280. }
  281. // In a real implementation, you would save the S3 configuration
  282. // For now, we'll just return success
  283. return { success: true, message: 'S3 settings saved successfully' };
  284. }
  285. // Meteor methods
  286. if (Meteor.isServer) {
  287. Meteor.methods({
  288. // Migration methods
  289. 'startAttachmentMigration'(config) {
  290. if (!this.userId) {
  291. throw new Meteor.Error('not-authorized', 'Must be logged in');
  292. }
  293. const user = ReactiveCache.getUser(this.userId);
  294. if (!user || !user.isAdmin) {
  295. throw new Meteor.Error('not-authorized', 'Admin access required');
  296. }
  297. startMigration(config.targetStorage, config.batchSize, config.delayMs, config.cpuThreshold);
  298. return { success: true, message: 'Migration started' };
  299. },
  300. 'pauseAttachmentMigration'() {
  301. if (!this.userId) {
  302. throw new Meteor.Error('not-authorized', 'Must be logged in');
  303. }
  304. const user = ReactiveCache.getUser(this.userId);
  305. if (!user || !user.isAdmin) {
  306. throw new Meteor.Error('not-authorized', 'Admin access required');
  307. }
  308. pauseMigration();
  309. return { success: true, message: 'Migration paused' };
  310. },
  311. 'resumeAttachmentMigration'() {
  312. if (!this.userId) {
  313. throw new Meteor.Error('not-authorized', 'Must be logged in');
  314. }
  315. const user = ReactiveCache.getUser(this.userId);
  316. if (!user || !user.isAdmin) {
  317. throw new Meteor.Error('not-authorized', 'Admin access required');
  318. }
  319. resumeMigration();
  320. return { success: true, message: 'Migration resumed' };
  321. },
  322. 'stopAttachmentMigration'() {
  323. if (!this.userId) {
  324. throw new Meteor.Error('not-authorized', 'Must be logged in');
  325. }
  326. const user = ReactiveCache.getUser(this.userId);
  327. if (!user || !user.isAdmin) {
  328. throw new Meteor.Error('not-authorized', 'Admin access required');
  329. }
  330. stopMigration();
  331. return { success: true, message: 'Migration stopped' };
  332. },
  333. 'getAttachmentMigrationSettings'() {
  334. if (!this.userId) {
  335. throw new Meteor.Error('not-authorized', 'Must be logged in');
  336. }
  337. const user = ReactiveCache.getUser(this.userId);
  338. if (!user || !user.isAdmin) {
  339. throw new Meteor.Error('not-authorized', 'Admin access required');
  340. }
  341. return {
  342. batchSize: migrationState.batchSize,
  343. delayMs: migrationState.delayMs,
  344. cpuThreshold: migrationState.cpuThreshold,
  345. status: migrationState.isRunning ? (migrationState.isPaused ? 'paused' : 'running') : 'idle',
  346. progress: migrationState.progress
  347. };
  348. },
  349. // Configuration methods
  350. 'getAttachmentStorageConfiguration'() {
  351. if (!this.userId) {
  352. throw new Meteor.Error('not-authorized', 'Must be logged in');
  353. }
  354. const user = ReactiveCache.getUser(this.userId);
  355. if (!user || !user.isAdmin) {
  356. throw new Meteor.Error('not-authorized', 'Admin access required');
  357. }
  358. return getAttachmentStorageConfiguration();
  359. },
  360. 'testS3Connection'(s3Config) {
  361. if (!this.userId) {
  362. throw new Meteor.Error('not-authorized', 'Must be logged in');
  363. }
  364. const user = ReactiveCache.getUser(this.userId);
  365. if (!user || !user.isAdmin) {
  366. throw new Meteor.Error('not-authorized', 'Admin access required');
  367. }
  368. return testS3Connection(s3Config);
  369. },
  370. 'saveS3Settings'(s3Config) {
  371. if (!this.userId) {
  372. throw new Meteor.Error('not-authorized', 'Must be logged in');
  373. }
  374. const user = ReactiveCache.getUser(this.userId);
  375. if (!user || !user.isAdmin) {
  376. throw new Meteor.Error('not-authorized', 'Admin access required');
  377. }
  378. return saveS3Settings(s3Config);
  379. },
  380. // Monitoring methods
  381. 'getAttachmentMonitoringData'() {
  382. if (!this.userId) {
  383. throw new Meteor.Error('not-authorized', 'Must be logged in');
  384. }
  385. const user = ReactiveCache.getUser(this.userId);
  386. if (!user || !user.isAdmin) {
  387. throw new Meteor.Error('not-authorized', 'Admin access required');
  388. }
  389. return getAttachmentMonitoringData();
  390. },
  391. 'refreshAttachmentMonitoringData'() {
  392. if (!this.userId) {
  393. throw new Meteor.Error('not-authorized', 'Must be logged in');
  394. }
  395. const user = ReactiveCache.getUser(this.userId);
  396. if (!user || !user.isAdmin) {
  397. throw new Meteor.Error('not-authorized', 'Admin access required');
  398. }
  399. return getAttachmentMonitoringData();
  400. },
  401. 'exportAttachmentMonitoringData'() {
  402. if (!this.userId) {
  403. throw new Meteor.Error('not-authorized', 'Must be logged in');
  404. }
  405. const user = ReactiveCache.getUser(this.userId);
  406. if (!user || !user.isAdmin) {
  407. throw new Meteor.Error('not-authorized', 'Admin access required');
  408. }
  409. const monitoringData = getAttachmentMonitoringData();
  410. const migrationStatus = getMigrationStatus();
  411. return {
  412. timestamp: new Date().toISOString(),
  413. monitoring: monitoringData,
  414. migration: migrationStatus,
  415. system: {
  416. cpuUsage: getCpuUsage(),
  417. memoryUsage: process.memoryUsage(),
  418. uptime: process.uptime()
  419. }
  420. };
  421. }
  422. });
  423. // Publications
  424. Meteor.publish('attachmentMigrationStatus', function() {
  425. if (!this.userId) {
  426. return this.ready();
  427. }
  428. const user = ReactiveCache.getUser(this.userId);
  429. if (!user || !user.isAdmin) {
  430. return this.ready();
  431. }
  432. const self = this;
  433. let handle;
  434. function updateStatus() {
  435. const status = getMigrationStatus();
  436. self.changed('attachmentMigrationStatus', 'status', status);
  437. }
  438. self.added('attachmentMigrationStatus', 'status', getMigrationStatus());
  439. // Update every 2 seconds
  440. handle = Meteor.setInterval(updateStatus, 2000);
  441. self.ready();
  442. self.onStop(() => {
  443. if (handle) {
  444. Meteor.clearInterval(handle);
  445. }
  446. });
  447. });
  448. Meteor.publish('attachmentMonitoringData', function() {
  449. if (!this.userId) {
  450. return this.ready();
  451. }
  452. const user = ReactiveCache.getUser(this.userId);
  453. if (!user || !user.isAdmin) {
  454. return this.ready();
  455. }
  456. const self = this;
  457. let handle;
  458. function updateMonitoring() {
  459. const data = getAttachmentMonitoringData();
  460. self.changed('attachmentMonitoringData', 'data', data);
  461. }
  462. self.added('attachmentMonitoringData', 'data', getAttachmentMonitoringData());
  463. // Update every 10 seconds
  464. handle = Meteor.setInterval(updateMonitoring, 10000);
  465. self.ready();
  466. self.onStop(() => {
  467. if (handle) {
  468. Meteor.clearInterval(handle);
  469. }
  470. });
  471. });
  472. }