s3db.js 13.4.0 → 13.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +25 -10
  2. package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
  3. package/dist/s3db.cjs.map +1 -0
  4. package/dist/s3db.es.js +38653 -32291
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +218 -22
  7. package/src/concerns/id.js +90 -6
  8. package/src/concerns/index.js +2 -1
  9. package/src/concerns/password-hashing.js +150 -0
  10. package/src/database.class.js +6 -2
  11. package/src/plugins/api/auth/basic-auth.js +40 -10
  12. package/src/plugins/api/auth/index.js +49 -3
  13. package/src/plugins/api/auth/oauth2-auth.js +171 -0
  14. package/src/plugins/api/auth/oidc-auth.js +789 -0
  15. package/src/plugins/api/auth/oidc-client.js +462 -0
  16. package/src/plugins/api/auth/path-auth-matcher.js +284 -0
  17. package/src/plugins/api/concerns/event-emitter.js +134 -0
  18. package/src/plugins/api/concerns/failban-manager.js +651 -0
  19. package/src/plugins/api/concerns/guards-helpers.js +402 -0
  20. package/src/plugins/api/concerns/metrics-collector.js +346 -0
  21. package/src/plugins/api/index.js +510 -57
  22. package/src/plugins/api/middlewares/failban.js +305 -0
  23. package/src/plugins/api/middlewares/rate-limit.js +301 -0
  24. package/src/plugins/api/middlewares/request-id.js +74 -0
  25. package/src/plugins/api/middlewares/security-headers.js +120 -0
  26. package/src/plugins/api/middlewares/session-tracking.js +194 -0
  27. package/src/plugins/api/routes/auth-routes.js +119 -78
  28. package/src/plugins/api/routes/resource-routes.js +73 -30
  29. package/src/plugins/api/server.js +1139 -45
  30. package/src/plugins/api/utils/custom-routes.js +102 -0
  31. package/src/plugins/api/utils/guards.js +213 -0
  32. package/src/plugins/api/utils/mime-types.js +154 -0
  33. package/src/plugins/api/utils/openapi-generator.js +91 -12
  34. package/src/plugins/api/utils/path-matcher.js +173 -0
  35. package/src/plugins/api/utils/static-filesystem.js +262 -0
  36. package/src/plugins/api/utils/static-s3.js +231 -0
  37. package/src/plugins/api/utils/template-engine.js +188 -0
  38. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
  39. package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
  40. package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
  41. package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
  42. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
  43. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
  44. package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
  45. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
  46. package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
  47. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
  48. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
  49. package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
  50. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
  51. package/src/plugins/cloud-inventory/index.js +20 -0
  52. package/src/plugins/cloud-inventory/registry.js +146 -0
  53. package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
  54. package/src/plugins/cloud-inventory.plugin.js +1333 -0
  55. package/src/plugins/concerns/plugin-dependencies.js +62 -2
  56. package/src/plugins/eventual-consistency/analytics.js +1 -0
  57. package/src/plugins/eventual-consistency/consolidation.js +2 -2
  58. package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
  59. package/src/plugins/eventual-consistency/install.js +2 -2
  60. package/src/plugins/identity/README.md +335 -0
  61. package/src/plugins/identity/concerns/mfa-manager.js +204 -0
  62. package/src/plugins/identity/concerns/password.js +138 -0
  63. package/src/plugins/identity/concerns/resource-schemas.js +273 -0
  64. package/src/plugins/identity/concerns/token-generator.js +172 -0
  65. package/src/plugins/identity/email-service.js +422 -0
  66. package/src/plugins/identity/index.js +1052 -0
  67. package/src/plugins/identity/oauth2-server.js +1033 -0
  68. package/src/plugins/identity/oidc-discovery.js +285 -0
  69. package/src/plugins/identity/rsa-keys.js +323 -0
  70. package/src/plugins/identity/server.js +500 -0
  71. package/src/plugins/identity/session-manager.js +453 -0
  72. package/src/plugins/identity/ui/layouts/base.js +251 -0
  73. package/src/plugins/identity/ui/middleware.js +135 -0
  74. package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
  75. package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
  76. package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
  77. package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
  78. package/src/plugins/identity/ui/pages/admin/users.js +263 -0
  79. package/src/plugins/identity/ui/pages/consent.js +262 -0
  80. package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
  81. package/src/plugins/identity/ui/pages/login.js +144 -0
  82. package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
  83. package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
  84. package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
  85. package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
  86. package/src/plugins/identity/ui/pages/profile.js +361 -0
  87. package/src/plugins/identity/ui/pages/register.js +226 -0
  88. package/src/plugins/identity/ui/pages/reset-password.js +128 -0
  89. package/src/plugins/identity/ui/pages/verify-email.js +172 -0
  90. package/src/plugins/identity/ui/routes.js +2541 -0
  91. package/src/plugins/identity/ui/styles/main.css +465 -0
  92. package/src/plugins/index.js +4 -1
  93. package/src/plugins/ml/base-model.class.js +65 -16
  94. package/src/plugins/ml/classification-model.class.js +1 -1
  95. package/src/plugins/ml/timeseries-model.class.js +3 -1
  96. package/src/plugins/ml.plugin.js +584 -31
  97. package/src/plugins/shared/error-handler.js +147 -0
  98. package/src/plugins/shared/index.js +9 -0
  99. package/src/plugins/shared/middlewares/compression.js +117 -0
  100. package/src/plugins/shared/middlewares/cors.js +49 -0
  101. package/src/plugins/shared/middlewares/index.js +11 -0
  102. package/src/plugins/shared/middlewares/logging.js +54 -0
  103. package/src/plugins/shared/middlewares/rate-limit.js +73 -0
  104. package/src/plugins/shared/middlewares/security.js +158 -0
  105. package/src/plugins/shared/response-formatter.js +264 -0
  106. package/src/plugins/state-machine.plugin.js +57 -2
  107. package/src/resource.class.js +140 -12
  108. package/src/schema.class.js +30 -1
  109. package/src/validator.class.js +57 -6
  110. package/dist/s3db.cjs.js.map +0 -1
@@ -0,0 +1,1333 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import jsonStableStringify from 'json-stable-stringify';
3
+ import { flatten } from 'flat';
4
+ import isEqual from 'lodash-es/isEqual.js';
5
+
6
+ import { Plugin } from './plugin.class.js';
7
+ import tryFn from '../concerns/try-fn.js';
8
+ import { requirePluginDependency } from './concerns/plugin-dependencies.js';
9
+
10
+ import {
11
+ createCloudDriver,
12
+ validateCloudDefinition,
13
+ listCloudDrivers,
14
+ registerCloudDriver,
15
+ BaseCloudDriver
16
+ } from './cloud-inventory/index.js';
17
+
18
+ const DEFAULT_RESOURCES = {
19
+ snapshots: 'plg_cloud_inventory_snapshots',
20
+ versions: 'plg_cloud_inventory_versions',
21
+ changes: 'plg_cloud_inventory_changes',
22
+ clouds: 'plg_cloud_inventory_clouds'
23
+ };
24
+
25
+ const DEFAULT_DISCOVERY = {
26
+ concurrency: 3,
27
+ include: null,
28
+ exclude: [],
29
+ runOnInstall: true,
30
+ dryRun: false
31
+ };
32
+
33
+ const DEFAULT_LOCK = {
34
+ ttl: 300,
35
+ timeout: 0
36
+ };
37
+
38
+ const BASE_SCHEDULE = {
39
+ enabled: false,
40
+ cron: null,
41
+ timezone: undefined,
42
+ runOnStart: false
43
+ };
44
+
45
+ const DEFAULT_TERRAFORM = {
46
+ enabled: false,
47
+ autoExport: false,
48
+ output: null,
49
+ outputType: 'file', // 'file', 's3', or 'custom'
50
+ filters: {
51
+ providers: [],
52
+ resourceTypes: [],
53
+ cloudId: null
54
+ },
55
+ terraformVersion: '1.5.0',
56
+ serial: 1
57
+ };
58
+
59
+ const INLINE_DRIVER_NAMES = new Map();
60
+
61
+ /**
62
+ * CloudInventoryPlugin
63
+ *
64
+ * Centralizes configuration snapshots collected from multiple cloud vendors.
65
+ * For each discovered asset we store:
66
+ * - A canonical record with the latest configuration digest
67
+ * - Frozen configuration revisions (immutable history)
68
+ * - Structured diffs between revisions
69
+ */
70
+ export class CloudInventoryPlugin extends Plugin {
71
+ constructor(options = {}) {
72
+ super(options);
73
+
74
+ const pendingLogs = [];
75
+ const normalizedClouds = normalizeCloudDefinitions(
76
+ Array.isArray(options.clouds) ? options.clouds : [],
77
+ (level, message, meta) => pendingLogs.push({ level, message, meta })
78
+ );
79
+
80
+ this.config = {
81
+ clouds: normalizedClouds,
82
+ discovery: {
83
+ ...DEFAULT_DISCOVERY,
84
+ ...(options.discovery || {})
85
+ },
86
+ resources: {
87
+ ...DEFAULT_RESOURCES,
88
+ ...(options.resources || {})
89
+ },
90
+ logger: typeof options.logger === 'function' ? options.logger : null,
91
+ verbose: options.verbose === true,
92
+ scheduled: normalizeSchedule(options.scheduled),
93
+ lock: {
94
+ ttl: options.lock?.ttl ?? DEFAULT_LOCK.ttl,
95
+ timeout: options.lock?.timeout ?? DEFAULT_LOCK.timeout
96
+ },
97
+ terraform: {
98
+ ...DEFAULT_TERRAFORM,
99
+ ...(options.terraform || {}),
100
+ filters: {
101
+ ...DEFAULT_TERRAFORM.filters,
102
+ ...(options.terraform?.filters || {})
103
+ }
104
+ }
105
+ };
106
+
107
+ this.cloudDrivers = new Map();
108
+ this._resourceHandles = {};
109
+ this._scheduledJobs = [];
110
+ this._cron = null;
111
+
112
+ for (const entry of pendingLogs) {
113
+ this._log(entry.level, entry.message, entry.meta);
114
+ }
115
+ }
116
+
117
+ async onInstall() {
118
+ this._validateConfiguration();
119
+ await this._ensureResources();
120
+ await this._initializeDrivers();
121
+
122
+ if (this.config.discovery.runOnInstall) {
123
+ await this.syncAll();
124
+ }
125
+ }
126
+
127
+ async onStart() {
128
+ await this._setupSchedules();
129
+ }
130
+
131
+ async onStop() {
132
+ await this._teardownSchedules();
133
+ await this._destroyDrivers();
134
+ }
135
+
136
+ async onUninstall() {
137
+ await this._teardownSchedules();
138
+ await this._destroyDrivers();
139
+ }
140
+
141
+ async syncAll(options = {}) {
142
+ const results = [];
143
+ for (const cloud of this.config.clouds) {
144
+ const result = await this.syncCloud(cloud.id, options);
145
+ results.push(result);
146
+ }
147
+
148
+ // Auto-export to Terraform after all clouds sync (if configured for global export)
149
+ if (this.config.terraform.enabled && this.config.terraform.autoExport && !this.config.terraform.filters.cloudId) {
150
+ await this._autoExportTerraform(null); // null = all clouds
151
+ }
152
+
153
+ return results;
154
+ }
155
+
156
+ async syncCloud(cloudId, options = {}) {
157
+ const driverEntry = this.cloudDrivers.get(cloudId);
158
+ if (!driverEntry) {
159
+ throw new Error(`Cloud "${cloudId}" is not registered. Available clouds: ${[...this.cloudDrivers.keys()].join(', ') || 'none'}`);
160
+ }
161
+
162
+ const { driver, definition } = driverEntry;
163
+ const summaryResource = this._resourceHandles.clouds;
164
+
165
+ const summaryBefore = (await summaryResource.getOrNull(cloudId))
166
+ ?? await this._ensureCloudSummaryRecord(cloudId, definition, definition.scheduled);
167
+
168
+ const storage = this.getStorage();
169
+ const lockKey = `cloud-inventory-sync-${cloudId}`;
170
+ const lock = await storage.acquireLock(lockKey, {
171
+ ttl: this.config.lock.ttl,
172
+ timeout: this.config.lock.timeout
173
+ });
174
+
175
+ if (!lock) {
176
+ this._log('info', 'Cloud sync already running on another worker, skipping', { cloudId });
177
+ return {
178
+ cloudId,
179
+ driver: definition.driver,
180
+ skipped: true,
181
+ reason: 'lock-not-acquired'
182
+ };
183
+ }
184
+
185
+ const runId = createRunIdentifier();
186
+ const startedAt = new Date().toISOString();
187
+
188
+ await this._updateCloudSummary(cloudId, {
189
+ status: 'running',
190
+ lastRunAt: startedAt,
191
+ lastRunId: runId,
192
+ lastError: null,
193
+ progress: null
194
+ });
195
+
196
+ let pendingCheckpoint = summaryBefore?.checkpoint ?? null;
197
+ let pendingRateLimit = summaryBefore?.rateLimit ?? null;
198
+ let pendingState = summaryBefore?.state ?? null;
199
+
200
+ const runtimeContext = {
201
+ checkpoint: summaryBefore?.checkpoint ?? null,
202
+ state: summaryBefore?.state ?? null,
203
+ emitCheckpoint: (value) => {
204
+ if (value === undefined) return;
205
+ pendingCheckpoint = value;
206
+ this._updateCloudSummary(cloudId, {
207
+ checkpoint: value,
208
+ checkpointUpdatedAt: new Date().toISOString()
209
+ }).catch(err => this._log('warn', 'Failed to persist checkpoint', { cloudId, error: err.message }));
210
+ },
211
+ emitRateLimit: (value) => {
212
+ pendingRateLimit = value;
213
+ this._updateCloudSummary(cloudId, {
214
+ rateLimit: value,
215
+ rateLimitUpdatedAt: new Date().toISOString()
216
+ }).catch(err => this._log('warn', 'Failed to persist rate-limit metadata', { cloudId, error: err.message }));
217
+ },
218
+ emitState: (value) => {
219
+ pendingState = value;
220
+ this._updateCloudSummary(cloudId, {
221
+ state: value,
222
+ stateUpdatedAt: new Date().toISOString()
223
+ }).catch(err => this._log('warn', 'Failed to persist driver state', { cloudId, error: err.message }));
224
+ },
225
+ emitProgress: (value) => {
226
+ this._updateCloudSummary(cloudId, { progress: value })
227
+ .catch(err => this._log('warn', 'Failed to persist progress', { cloudId, error: err.message }));
228
+ }
229
+ };
230
+
231
+ let items;
232
+ try {
233
+ items = await driver.listResources({
234
+ discovery: this.config.discovery,
235
+ checkpoint: runtimeContext.checkpoint,
236
+ state: runtimeContext.state,
237
+ runtime: runtimeContext,
238
+ ...options
239
+ });
240
+ } catch (err) {
241
+ await storage.releaseLock(lockKey).catch(() => {});
242
+ await this._updateCloudSummary(cloudId, {
243
+ status: 'error',
244
+ lastErrorAt: new Date().toISOString(),
245
+ lastError: err.message || 'Driver failure during listResources'
246
+ });
247
+ throw err;
248
+ }
249
+
250
+ let countCreated = 0;
251
+ let countUpdated = 0;
252
+ let countUnchanged = 0;
253
+ let processed = 0;
254
+ let errorDuringRun = null;
255
+ const startMs = Date.now();
256
+
257
+ const processItem = async (rawItem) => {
258
+ const normalized = this._normalizeResource(definition, rawItem);
259
+ if (!normalized) return;
260
+
261
+ const persisted = await this._persistSnapshot(normalized, rawItem);
262
+ processed += 1;
263
+ if (persisted?.status === 'created') countCreated += 1;
264
+ else if (persisted?.status === 'updated') countUpdated += 1;
265
+ else countUnchanged += 1;
266
+ };
267
+
268
+ try {
269
+ if (isAsyncIterable(items)) {
270
+ for await (const item of items) {
271
+ await processItem(item);
272
+ }
273
+ } else if (Array.isArray(items)) {
274
+ for (const item of items) {
275
+ await processItem(item);
276
+ }
277
+ } else if (items) {
278
+ await processItem(items);
279
+ }
280
+ } catch (err) {
281
+ errorDuringRun = err;
282
+ }
283
+
284
+ const finishedAt = new Date().toISOString();
285
+ const durationMs = Date.now() - startMs;
286
+
287
+ const summaryPatch = {
288
+ status: errorDuringRun ? 'error' : 'idle',
289
+ lastRunAt: startedAt,
290
+ lastRunId: runId,
291
+ lastResult: {
292
+ runId,
293
+ startedAt,
294
+ finishedAt,
295
+ durationMs,
296
+ counts: {
297
+ created: countCreated,
298
+ updated: countUpdated,
299
+ unchanged: countUnchanged
300
+ },
301
+ processed,
302
+ checkpoint: pendingCheckpoint
303
+ },
304
+ totalResources: Math.max(0, (summaryBefore?.totalResources ?? 0) + countCreated),
305
+ totalVersions: Math.max(0, (summaryBefore?.totalVersions ?? 0) + countCreated + countUpdated),
306
+ checkpoint: pendingCheckpoint,
307
+ checkpointUpdatedAt: pendingCheckpoint !== summaryBefore?.checkpoint ? finishedAt : summaryBefore?.checkpointUpdatedAt,
308
+ rateLimit: pendingRateLimit,
309
+ rateLimitUpdatedAt: pendingRateLimit !== summaryBefore?.rateLimit ? finishedAt : summaryBefore?.rateLimitUpdatedAt,
310
+ state: pendingState,
311
+ stateUpdatedAt: pendingState !== summaryBefore?.state ? finishedAt : summaryBefore?.stateUpdatedAt,
312
+ progress: null
313
+ };
314
+
315
+ if (errorDuringRun) {
316
+ summaryPatch.lastError = errorDuringRun.message;
317
+ summaryPatch.lastErrorAt = finishedAt;
318
+ } else {
319
+ summaryPatch.lastError = null;
320
+ summaryPatch.lastSuccessAt = finishedAt;
321
+ }
322
+
323
+ await this._updateCloudSummary(cloudId, summaryPatch);
324
+
325
+ try {
326
+ await storage.releaseLock(lockKey);
327
+ } catch (releaseErr) {
328
+ this._log('warn', 'Failed to release sync lock', { cloudId, error: releaseErr.message });
329
+ }
330
+
331
+ if (errorDuringRun) {
332
+ throw errorDuringRun;
333
+ }
334
+
335
+ const summary = {
336
+ cloudId,
337
+ driver: definition.driver,
338
+ created: countCreated,
339
+ updated: countUpdated,
340
+ unchanged: countUnchanged,
341
+ processed,
342
+ durationMs
343
+ };
344
+
345
+ this._log('info', 'Cloud sync finished', summary);
346
+
347
+ // Auto-export to Terraform if configured
348
+ if (this.config.terraform.enabled && this.config.terraform.autoExport) {
349
+ await this._autoExportTerraform(cloudId);
350
+ }
351
+
352
+ return summary;
353
+ }
354
+
355
+ _validateConfiguration() {
356
+ if (!Array.isArray(this.config.clouds) || this.config.clouds.length === 0) {
357
+ throw new Error(
358
+ 'CloudInventoryPlugin requires a "clouds" array in the configuration. ' +
359
+ `Registered drivers: ${listCloudDrivers().join(', ') || 'none'}`
360
+ );
361
+ }
362
+
363
+ for (const cloud of this.config.clouds) {
364
+ validateCloudDefinition(cloud);
365
+
366
+ try {
367
+ normalizeSchedule(cloud.scheduled);
368
+ } catch (err) {
369
+ throw new Error(`Cloud "${cloud.id}" has an invalid scheduled configuration: ${err.message}`);
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Export discovered cloud resources to Terraform/OpenTofu state format
376
+ * @param {Object} options - Export options
377
+ * @param {Array<string>} options.resourceTypes - Filter by cloud resource types (e.g., ['aws.ec2.instance'])
378
+ * @param {Array<string>} options.providers - Filter by provider (e.g., ['aws', 'gcp'])
379
+ * @param {string} options.cloudId - Filter by specific cloud ID
380
+ * @param {string} options.terraformVersion - Terraform version (default: '1.5.0')
381
+ * @param {string} options.lineage - State lineage UUID (default: auto-generated)
382
+ * @param {number} options.serial - State serial number (default: 1)
383
+ * @param {Object} options.outputs - Terraform outputs (default: {})
384
+ * @returns {Promise<Object>} - { state, stats }
385
+ *
386
+ * @example
387
+ * // Export all resources
388
+ * const result = await plugin.exportToTerraformState();
389
+ * console.log(result.state); // Terraform state object
390
+ * console.log(result.stats); // { total, converted, skipped }
391
+ *
392
+ * // Export specific provider
393
+ * const awsOnly = await plugin.exportToTerraformState({ providers: ['aws'] });
394
+ *
395
+ * // Export specific resource types
396
+ * const ec2Only = await plugin.exportToTerraformState({
397
+ * resourceTypes: ['aws.ec2.instance', 'aws.rds.instance']
398
+ * });
399
+ */
400
+ async exportToTerraformState(options = {}) {
401
+ const { exportToTerraformState: exportFn } = await import('./cloud-inventory/terraform-exporter.js');
402
+
403
+ const {
404
+ resourceTypes = [],
405
+ providers = [],
406
+ cloudId = null,
407
+ ...exportOptions
408
+ } = options;
409
+
410
+ // Get snapshots resource
411
+ const snapshotsResource = this._resourceHandles.snapshots;
412
+ if (!snapshotsResource) {
413
+ throw new Error('Snapshots resource not initialized. Ensure plugin is installed.');
414
+ }
415
+
416
+ // Build query filter
417
+ const queryOptions = {};
418
+
419
+ if (cloudId) {
420
+ queryOptions.cloudId = cloudId;
421
+ }
422
+
423
+ // Fetch all snapshots (or filtered)
424
+ const snapshots = await snapshotsResource.query(queryOptions);
425
+
426
+ this._log('info', 'Exporting cloud inventory to Terraform state', {
427
+ totalSnapshots: snapshots.length,
428
+ resourceTypes: resourceTypes.length > 0 ? resourceTypes : 'all',
429
+ providers: providers.length > 0 ? providers : 'all'
430
+ });
431
+
432
+ // Export to Terraform format
433
+ const result = exportFn(snapshots, {
434
+ ...exportOptions,
435
+ resourceTypes,
436
+ providers
437
+ });
438
+
439
+ this._log('info', 'Export complete', result.stats);
440
+
441
+ return result;
442
+ }
443
+
444
+ /**
445
+ * Export cloud inventory to Terraform state file
446
+ * @param {string} filePath - Output file path
447
+ * @param {Object} options - Export options (see exportToTerraformState)
448
+ * @returns {Promise<Object>} - { filePath, stats }
449
+ *
450
+ * @example
451
+ * // Export to file
452
+ * await plugin.exportToTerraformStateFile('./terraform.tfstate');
453
+ *
454
+ * // Export AWS resources only
455
+ * await plugin.exportToTerraformStateFile('./aws-resources.tfstate', {
456
+ * providers: ['aws']
457
+ * });
458
+ */
459
+ async exportToTerraformStateFile(filePath, options = {}) {
460
+ const { promises: fs } = await import('fs');
461
+ const path = await import('path');
462
+
463
+ const result = await this.exportToTerraformState(options);
464
+
465
+ // Write to file
466
+ const dir = path.dirname(filePath);
467
+ await fs.mkdir(dir, { recursive: true });
468
+ await fs.writeFile(filePath, JSON.stringify(result.state, null, 2), 'utf8');
469
+
470
+ this._log('info', `Terraform state exported to: ${filePath}`, result.stats);
471
+
472
+ return {
473
+ filePath,
474
+ ...result
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Export cloud inventory to Terraform state in S3
480
+ * @param {string} bucket - S3 bucket name
481
+ * @param {string} key - S3 object key
482
+ * @param {Object} options - Export options (see exportToTerraformState)
483
+ * @returns {Promise<Object>} - { bucket, key, stats }
484
+ *
485
+ * @example
486
+ * // Export to S3
487
+ * await plugin.exportToTerraformStateToS3('my-bucket', 'terraform/state.tfstate');
488
+ *
489
+ * // Export GCP resources to S3
490
+ * await plugin.exportToTerraformStateToS3('my-bucket', 'terraform/gcp.tfstate', {
491
+ * providers: ['gcp']
492
+ * });
493
+ */
494
+ async exportToTerraformStateToS3(bucket, key, options = {}) {
495
+ const result = await this.exportToTerraformState(options);
496
+
497
+ // Get S3 client from database
498
+ const s3Client = this.database.client;
499
+ if (!s3Client || typeof s3Client.putObject !== 'function') {
500
+ throw new Error('S3 client not available. Database must use S3-compatible storage.');
501
+ }
502
+
503
+ // Upload to S3
504
+ await s3Client.putObject({
505
+ Bucket: bucket,
506
+ Key: key,
507
+ Body: JSON.stringify(result.state, null, 2),
508
+ ContentType: 'application/json'
509
+ });
510
+
511
+ this._log('info', `Terraform state exported to S3: s3://${bucket}/${key}`, result.stats);
512
+
513
+ return {
514
+ bucket,
515
+ key,
516
+ ...result
517
+ };
518
+ }
519
+
520
+ /**
521
+ * Auto-export Terraform state after discovery (internal)
522
+ * @private
523
+ */
524
+ async _autoExportTerraform(cloudId = null) {
525
+ try {
526
+ const { terraform } = this.config;
527
+ const exportOptions = {
528
+ ...terraform.filters,
529
+ terraformVersion: terraform.terraformVersion,
530
+ serial: terraform.serial
531
+ };
532
+
533
+ // If cloudId specified, override filter
534
+ if (cloudId) {
535
+ exportOptions.cloudId = cloudId;
536
+ }
537
+
538
+ this._log('info', 'Auto-exporting Terraform state', {
539
+ output: terraform.output,
540
+ outputType: terraform.outputType,
541
+ cloudId: cloudId || 'all'
542
+ });
543
+
544
+ let result;
545
+
546
+ // Determine output type and call appropriate export method
547
+ if (terraform.outputType === 's3') {
548
+ // Parse S3 URL: s3://bucket/path/to/file.tfstate
549
+ const s3Match = terraform.output?.match(/^s3:\/\/([^/]+)\/(.+)$/);
550
+ if (!s3Match) {
551
+ throw new Error(`Invalid S3 URL format: ${terraform.output}. Expected: s3://bucket/path/file.tfstate`);
552
+ }
553
+ const [, bucket, key] = s3Match;
554
+ result = await this.exportToTerraformStateToS3(bucket, key, exportOptions);
555
+ } else if (terraform.outputType === 'file') {
556
+ // File path
557
+ if (!terraform.output) {
558
+ throw new Error('Terraform output path not configured');
559
+ }
560
+ result = await this.exportToTerraformStateFile(terraform.output, exportOptions);
561
+ } else {
562
+ // Custom function (user-provided)
563
+ if (typeof terraform.output === 'function') {
564
+ const stateData = await this.exportToTerraformState(exportOptions);
565
+ result = await terraform.output(stateData);
566
+ } else {
567
+ throw new Error(`Unknown terraform.outputType: ${terraform.outputType}`);
568
+ }
569
+ }
570
+
571
+ this._log('info', 'Terraform state auto-export completed', result.stats);
572
+ } catch (err) {
573
+ this._log('error', 'Failed to auto-export Terraform state', {
574
+ error: err.message,
575
+ stack: err.stack
576
+ });
577
+ // Don't throw - auto-export is best-effort
578
+ }
579
+ }
580
+
581
+ async _ensureResources() {
582
+ const {
583
+ snapshots,
584
+ versions,
585
+ changes,
586
+ clouds
587
+ } = this.config.resources;
588
+
589
+ const resourceDefinitions = [
590
+ {
591
+ name: snapshots,
592
+ attributes: {
593
+ id: 'string|required',
594
+ cloudId: 'string|required',
595
+ driver: 'string|required',
596
+ accountId: 'string|optional',
597
+ subscriptionId: 'string|optional',
598
+ organizationId: 'string|optional',
599
+ projectId: 'string|optional',
600
+ region: 'string|optional',
601
+ service: 'string|optional',
602
+ resourceType: 'string|required',
603
+ resourceId: 'string|required',
604
+ name: 'string|optional',
605
+ tags: 'json|optional',
606
+ labels: 'json|optional',
607
+ latestDigest: 'string|required',
608
+ latestVersion: 'number|required',
609
+ latestSnapshotId: 'string|required',
610
+ lastSeenAt: 'string|required',
611
+ firstSeenAt: 'string|required',
612
+ changelogSize: 'number|default:0',
613
+ metadata: 'json|optional'
614
+ },
615
+ behavior: 'body-overflow',
616
+ timestamps: true,
617
+ partitions: {
618
+ byCloudId: {
619
+ fields: {
620
+ cloudId: 'string|required'
621
+ }
622
+ },
623
+ byResourceType: {
624
+ fields: {
625
+ resourceType: 'string|required'
626
+ }
627
+ },
628
+ byCloudAndType: {
629
+ fields: {
630
+ cloudId: 'string|required',
631
+ resourceType: 'string|required'
632
+ }
633
+ },
634
+ byRegion: {
635
+ fields: {
636
+ region: 'string|optional'
637
+ }
638
+ }
639
+ }
640
+ },
641
+ {
642
+ name: versions,
643
+ attributes: {
644
+ id: 'string|required',
645
+ resourceKey: 'string|required',
646
+ cloudId: 'string|required',
647
+ driver: 'string|required',
648
+ version: 'number|required',
649
+ digest: 'string|required',
650
+ capturedAt: 'string|required',
651
+ configuration: 'json|required',
652
+ summary: 'json|optional',
653
+ raw: 'json|optional'
654
+ },
655
+ behavior: 'body-overflow',
656
+ timestamps: true,
657
+ partitions: {
658
+ byResourceKey: {
659
+ fields: {
660
+ resourceKey: 'string|required'
661
+ }
662
+ },
663
+ byCloudId: {
664
+ fields: {
665
+ cloudId: 'string|required'
666
+ }
667
+ }
668
+ }
669
+ },
670
+ {
671
+ name: changes,
672
+ attributes: {
673
+ id: 'string|required',
674
+ resourceKey: 'string|required',
675
+ cloudId: 'string|required',
676
+ driver: 'string|required',
677
+ fromVersion: 'number|required',
678
+ toVersion: 'number|required',
679
+ fromDigest: 'string|required',
680
+ toDigest: 'string|required',
681
+ diff: 'json|required',
682
+ summary: 'json|optional',
683
+ capturedAt: 'string|required'
684
+ },
685
+ behavior: 'body-overflow',
686
+ timestamps: true,
687
+ partitions: {
688
+ byResourceKey: {
689
+ fields: {
690
+ resourceKey: 'string|required'
691
+ }
692
+ },
693
+ byCloudId: {
694
+ fields: {
695
+ cloudId: 'string|required'
696
+ }
697
+ }
698
+ }
699
+ },
700
+ {
701
+ name: clouds,
702
+ attributes: {
703
+ id: 'string|required',
704
+ driver: 'string|required',
705
+ status: 'string|default:idle',
706
+ lastRunAt: 'string|optional',
707
+ lastRunId: 'string|optional',
708
+ lastSuccessAt: 'string|optional',
709
+ lastErrorAt: 'string|optional',
710
+ lastError: 'string|optional',
711
+ totalResources: 'number|default:0',
712
+ totalVersions: 'number|default:0',
713
+ lastResult: 'json|optional',
714
+ tags: 'json|optional',
715
+ metadata: 'json|optional',
716
+ schedule: 'json|optional',
717
+ checkpoint: 'json|optional',
718
+ checkpointUpdatedAt: 'string|optional',
719
+ rateLimit: 'json|optional',
720
+ rateLimitUpdatedAt: 'string|optional',
721
+ state: 'json|optional',
722
+ stateUpdatedAt: 'string|optional',
723
+ progress: 'json|optional'
724
+ },
725
+ behavior: 'body-overflow',
726
+ timestamps: true
727
+ }
728
+ ];
729
+
730
+ for (const definition of resourceDefinitions) {
731
+ const [ok, err] = await tryFn(() => this.database.createResource(definition));
732
+ if (!ok && err?.message?.includes('already exists')) {
733
+ this._log('debug', 'Resource already exists, skipping creation', { resource: definition.name });
734
+ } else if (!ok) {
735
+ throw err;
736
+ }
737
+ }
738
+
739
+ this._resourceHandles.snapshots = this.database.resources[snapshots];
740
+ this._resourceHandles.versions = this.database.resources[versions];
741
+ this._resourceHandles.changes = this.database.resources[changes];
742
+ this._resourceHandles.clouds = this.database.resources[clouds];
743
+ }
744
+
745
+ async _initializeDrivers() {
746
+ for (const cloudDef of this.config.clouds) {
747
+ const driverId = cloudDef.id;
748
+ if (this.cloudDrivers.has(driverId)) continue;
749
+
750
+ const schedule = normalizeSchedule(cloudDef.scheduled);
751
+ const summary = await this._ensureCloudSummaryRecord(driverId, cloudDef, schedule);
752
+
753
+ const driver = createCloudDriver(cloudDef.driver, {
754
+ ...cloudDef,
755
+ globals: this.config,
756
+ schedule,
757
+ logger: (level, message, meta = {}) => {
758
+ this._log(level, message, { cloudId: driverId, driver: cloudDef.driver, ...meta });
759
+ }
760
+ });
761
+
762
+ await driver.initialize();
763
+ this.cloudDrivers.set(driverId, {
764
+ driver,
765
+ definition: { ...cloudDef, scheduled: schedule },
766
+ summary
767
+ });
768
+ this._log('info', 'Cloud driver initialized', { cloudId: driverId, driver: cloudDef.driver });
769
+ }
770
+ }
771
+
772
+ async _destroyDrivers() {
773
+ for (const [cloudId, { driver }] of this.cloudDrivers.entries()) {
774
+ try {
775
+ await driver.destroy?.();
776
+ } catch (err) {
777
+ this._log('warn', 'Failed to destroy cloud driver', { cloudId, error: err.message });
778
+ }
779
+ }
780
+ this.cloudDrivers.clear();
781
+ }
782
+
783
+ async _setupSchedules() {
784
+ await this._teardownSchedules();
785
+
786
+ const globalSchedule = this.config.scheduled;
787
+ const cloudsWithSchedule = [...this.cloudDrivers.values()]
788
+ .filter(entry => entry.definition.scheduled?.enabled);
789
+
790
+ const needsCron = globalSchedule.enabled || cloudsWithSchedule.length > 0;
791
+ if (!needsCron) return;
792
+
793
+ await requirePluginDependency('cloud-inventory-plugin');
794
+
795
+ if (!this._cron) {
796
+ const cronModule = await import('node-cron');
797
+ this._cron = cronModule.default || cronModule;
798
+ }
799
+
800
+ if (globalSchedule.enabled) {
801
+ this._scheduleJob(globalSchedule, async () => {
802
+ try {
803
+ await this.syncAll({ reason: 'scheduled-global' });
804
+ } catch (err) {
805
+ this._log('error', 'Scheduled global sync failed', { error: err.message });
806
+ }
807
+ });
808
+
809
+ if (globalSchedule.runOnStart) {
810
+ this.syncAll({ reason: 'scheduled-global-runOnStart' }).catch(err => {
811
+ this._log('error', 'Initial global scheduled sync failed', { error: err.message });
812
+ });
813
+ }
814
+ }
815
+
816
+ for (const { definition } of this.cloudDrivers.values()) {
817
+ const schedule = definition.scheduled;
818
+ if (!schedule?.enabled) continue;
819
+
820
+ const cloudId = definition.id;
821
+ this._scheduleJob(schedule, async () => {
822
+ try {
823
+ await this.syncCloud(cloudId, { reason: 'scheduled-cloud' });
824
+ } catch (err) {
825
+ this._log('error', 'Scheduled cloud sync failed', { cloudId, error: err.message });
826
+ }
827
+ });
828
+
829
+ if (schedule.runOnStart) {
830
+ this.syncCloud(cloudId, { reason: 'scheduled-cloud-runOnStart' }).catch(err => {
831
+ this._log('error', 'Initial cloud scheduled sync failed', { cloudId, error: err.message });
832
+ });
833
+ }
834
+ }
835
+ }
836
+
837
+ _scheduleJob(schedule, handler) {
838
+ if (!this._cron) return;
839
+ const job = this._cron.schedule(
840
+ schedule.cron,
841
+ handler,
842
+ { timezone: schedule.timezone }
843
+ );
844
+ if (job?.start) {
845
+ job.start();
846
+ }
847
+ this._scheduledJobs.push(job);
848
+ }
849
+
850
+ async _teardownSchedules() {
851
+ if (!this._scheduledJobs.length) return;
852
+ for (const job of this._scheduledJobs) {
853
+ try {
854
+ job?.stop?.();
855
+ job?.destroy?.();
856
+ } catch (err) {
857
+ this._log('warn', 'Failed to teardown scheduled job', { error: err.message });
858
+ }
859
+ }
860
+ this._scheduledJobs = [];
861
+ }
862
+
863
+ _normalizeResource(cloudDefinition, entry) {
864
+ if (!entry || typeof entry !== 'object') {
865
+ this._log('warn', 'Skipping invalid resource entry', { cloudId: cloudDefinition.id });
866
+ return null;
867
+ }
868
+
869
+ const configuration = ensureObject(
870
+ entry.configuration ??
871
+ entry.state ??
872
+ entry.attributes ??
873
+ entry
874
+ );
875
+
876
+ const normalized = {
877
+ cloudId: cloudDefinition.id,
878
+ driver: cloudDefinition.driver,
879
+ accountId: entry.accountId || cloudDefinition.config?.accountId || null,
880
+ subscriptionId: entry.subscriptionId || null,
881
+ organizationId: entry.organizationId || null,
882
+ projectId: entry.projectId || cloudDefinition.config?.projectId || null,
883
+ region: entry.region || entry.location || null,
884
+ service: entry.service || entry.product || null,
885
+ resourceType: entry.resourceType || entry.type || 'unknown',
886
+ resourceId: entry.resourceId || entry.id || configuration.id || configuration.arn || configuration.name,
887
+ name: entry.name || configuration.name || configuration.displayName || null,
888
+ tags: entry.tags || configuration.tags || null,
889
+ labels: entry.labels || configuration.labels || null,
890
+ metadata: entry.metadata || {},
891
+ configuration
892
+ };
893
+
894
+ if (!normalized.resourceId) {
895
+ this._log('warn', 'Entry missing resource identifier, skipping', {
896
+ cloudId: normalized.cloudId,
897
+ driver: normalized.driver,
898
+ resourceType: normalized.resourceType
899
+ });
900
+ return null;
901
+ }
902
+
903
+ normalized.resourceKey = [
904
+ normalized.cloudId,
905
+ normalized.resourceType,
906
+ normalized.resourceId
907
+ ].filter(Boolean).join(':');
908
+
909
+ return normalized;
910
+ }
911
+
912
+ async _persistSnapshot(normalized, rawItem) {
913
+ const now = new Date().toISOString();
914
+ const digest = computeDigest(normalized.configuration);
915
+ const resourceKey = normalized.resourceKey;
916
+
917
+ const snapshots = this._resourceHandles.snapshots;
918
+ const versions = this._resourceHandles.versions;
919
+ const changes = this._resourceHandles.changes;
920
+
921
+ const existing = await snapshots.getOrNull(resourceKey);
922
+
923
+ if (!existing) {
924
+ const versionNumber = 1;
925
+ const versionId = buildVersionId(resourceKey, versionNumber);
926
+
927
+ await versions.insert({
928
+ id: versionId,
929
+ resourceKey,
930
+ cloudId: normalized.cloudId,
931
+ driver: normalized.driver,
932
+ version: versionNumber,
933
+ digest,
934
+ capturedAt: now,
935
+ configuration: normalized.configuration,
936
+ summary: buildSummary(normalized),
937
+ raw: rawItem
938
+ });
939
+
940
+ await snapshots.insert({
941
+ id: resourceKey,
942
+ cloudId: normalized.cloudId,
943
+ driver: normalized.driver,
944
+ accountId: normalized.accountId,
945
+ subscriptionId: normalized.subscriptionId,
946
+ organizationId: normalized.organizationId,
947
+ projectId: normalized.projectId,
948
+ region: normalized.region,
949
+ service: normalized.service,
950
+ resourceType: normalized.resourceType,
951
+ resourceId: normalized.resourceId,
952
+ name: normalized.name,
953
+ tags: normalized.tags,
954
+ labels: normalized.labels,
955
+ metadata: normalized.metadata,
956
+ latestDigest: digest,
957
+ latestVersion: versionNumber,
958
+ latestSnapshotId: versionId,
959
+ firstSeenAt: now,
960
+ lastSeenAt: now,
961
+ changelogSize: 0
962
+ });
963
+
964
+ return { status: 'created', resourceKey, version: versionNumber };
965
+ }
966
+
967
+ if (existing.latestDigest === digest) {
968
+ await snapshots.update(resourceKey, { lastSeenAt: now });
969
+ return { status: 'unchanged', resourceKey, version: existing.latestVersion };
970
+ }
971
+
972
+ const previousVersionId = existing.latestSnapshotId;
973
+ const previousVersion = await versions.getOrNull(previousVersionId);
974
+ const nextVersionNumber = existing.latestVersion + 1;
975
+ const nextVersionId = buildVersionId(resourceKey, nextVersionNumber);
976
+
977
+ await versions.insert({
978
+ id: nextVersionId,
979
+ resourceKey,
980
+ cloudId: normalized.cloudId,
981
+ driver: normalized.driver,
982
+ version: nextVersionNumber,
983
+ digest,
984
+ capturedAt: now,
985
+ configuration: normalized.configuration,
986
+ summary: buildSummary(normalized),
987
+ raw: rawItem
988
+ });
989
+
990
+ const diff = computeDiff(previousVersion?.configuration, normalized.configuration);
991
+ await changes.insert({
992
+ id: `${resourceKey}:${existing.latestVersion}->${nextVersionNumber}`,
993
+ resourceKey,
994
+ cloudId: normalized.cloudId,
995
+ driver: normalized.driver,
996
+ fromVersion: existing.latestVersion,
997
+ toVersion: nextVersionNumber,
998
+ fromDigest: existing.latestDigest,
999
+ toDigest: digest,
1000
+ diff,
1001
+ summary: {
1002
+ added: Object.keys(diff.added || {}).length,
1003
+ removed: Object.keys(diff.removed || {}).length,
1004
+ updated: Object.keys(diff.updated || {}).length
1005
+ },
1006
+ capturedAt: now
1007
+ });
1008
+
1009
+ await snapshots.update(resourceKey, {
1010
+ latestDigest: digest,
1011
+ latestVersion: nextVersionNumber,
1012
+ latestSnapshotId: nextVersionId,
1013
+ lastSeenAt: now,
1014
+ changelogSize: (existing.changelogSize || 0) + 1,
1015
+ metadata: normalized.metadata,
1016
+ tags: normalized.tags,
1017
+ labels: normalized.labels,
1018
+ region: normalized.region,
1019
+ service: normalized.service,
1020
+ name: normalized.name
1021
+ });
1022
+
1023
+ return { status: 'updated', resourceKey, version: nextVersionNumber };
1024
+ }
1025
+
1026
+ async _ensureCloudSummaryRecord(cloudId, cloudDef, schedule) {
1027
+ const clouds = this._resourceHandles.clouds;
1028
+ const existing = await clouds.getOrNull(cloudId);
1029
+
1030
+ const payload = {
1031
+ driver: cloudDef.driver,
1032
+ schedule: schedule.enabled ? schedule : null,
1033
+ tags: cloudDef.tags ?? existing?.tags ?? null,
1034
+ metadata: cloudDef.metadata ?? existing?.metadata ?? null
1035
+ };
1036
+
1037
+ if (!existing) {
1038
+ await clouds.insert({
1039
+ id: cloudId,
1040
+ status: 'idle',
1041
+ totalResources: 0,
1042
+ totalVersions: 0,
1043
+ lastResult: null,
1044
+ checkpoint: null,
1045
+ rateLimit: null,
1046
+ ...payload
1047
+ });
1048
+ return await clouds.get(cloudId);
1049
+ }
1050
+
1051
+ await clouds.update(cloudId, payload);
1052
+ return await clouds.get(cloudId);
1053
+ }
1054
+
1055
+ async _updateCloudSummary(cloudId, patch) {
1056
+ const clouds = this._resourceHandles.clouds;
1057
+ if (!clouds) return;
1058
+
1059
+ const [ok, err] = await tryFn(() => clouds.update(cloudId, patch));
1060
+ if (ok) return;
1061
+
1062
+ if (err?.message?.includes('does not exist')) {
1063
+ await tryFn(() => clouds.insert({
1064
+ id: cloudId,
1065
+ status: 'idle',
1066
+ totalResources: 0,
1067
+ totalVersions: 0,
1068
+ ...patch
1069
+ }));
1070
+ } else {
1071
+ this._log('warn', 'Failed to update cloud summary', { cloudId, error: err?.message });
1072
+ }
1073
+ }
1074
+
1075
+ _log(level, message, meta = {}) {
1076
+ if (this.config.logger) {
1077
+ this.config.logger(level, message, meta);
1078
+ return;
1079
+ }
1080
+
1081
+ const shouldLog = this.config.verbose || level === 'error' || level === 'warn';
1082
+ if (shouldLog && typeof console[level] === 'function') {
1083
+ console[level](`[CloudInventoryPlugin] ${message}`, meta);
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ function ensureObject(value) {
1089
+ if (value && typeof value === 'object') return value;
1090
+ return {};
1091
+ }
1092
+
1093
+ function computeDigest(payload) {
1094
+ const canonical = jsonStableStringify(payload ?? {});
1095
+ return createHash('sha256').update(canonical).digest('hex');
1096
+ }
1097
+
1098
+ function buildVersionId(resourceKey, version) {
1099
+ return `${resourceKey}:${String(version).padStart(6, '0')}`;
1100
+ }
1101
+
1102
+ function buildSummary(normalized) {
1103
+ return {
1104
+ name: normalized.name,
1105
+ region: normalized.region,
1106
+ service: normalized.service,
1107
+ resourceType: normalized.resourceType,
1108
+ tags: normalized.tags,
1109
+ labels: normalized.labels,
1110
+ metadata: normalized.metadata
1111
+ };
1112
+ }
1113
+
1114
+ function computeDiff(previousConfig = {}, nextConfig = {}) {
1115
+ const prevFlat = flatten(previousConfig, { safe: true }) || {};
1116
+ const nextFlat = flatten(nextConfig, { safe: true }) || {};
1117
+
1118
+ const diff = {
1119
+ added: {},
1120
+ removed: {},
1121
+ updated: {}
1122
+ };
1123
+
1124
+ for (const key of Object.keys(nextFlat)) {
1125
+ if (!(key in prevFlat)) {
1126
+ diff.added[key] = nextFlat[key];
1127
+ } else if (!isEqual(prevFlat[key], nextFlat[key])) {
1128
+ diff.updated[key] = {
1129
+ before: prevFlat[key],
1130
+ after: nextFlat[key]
1131
+ };
1132
+ }
1133
+ }
1134
+
1135
+ for (const key of Object.keys(prevFlat)) {
1136
+ if (!(key in nextFlat)) {
1137
+ diff.removed[key] = prevFlat[key];
1138
+ }
1139
+ }
1140
+
1141
+ if (!Object.keys(diff.added).length) delete diff.added;
1142
+ if (!Object.keys(diff.removed).length) delete diff.removed;
1143
+ if (!Object.keys(diff.updated).length) delete diff.updated;
1144
+
1145
+ return diff;
1146
+ }
1147
+
1148
+ function isAsyncIterable(obj) {
1149
+ return obj?.[Symbol.asyncIterator];
1150
+ }
1151
+
1152
+ function createRunIdentifier() {
1153
+ try {
1154
+ return randomUUID();
1155
+ } catch {
1156
+ return `run-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
1157
+ }
1158
+ }
1159
+
1160
+ function normalizeSchedule(input) {
1161
+ const schedule = {
1162
+ ...BASE_SCHEDULE,
1163
+ ...(typeof input === 'object' && input !== null ? input : {})
1164
+ };
1165
+
1166
+ schedule.enabled = Boolean(schedule.enabled);
1167
+ schedule.cron = typeof schedule.cron === 'string' && schedule.cron.trim().length > 0
1168
+ ? schedule.cron.trim()
1169
+ : null;
1170
+ schedule.timezone = typeof schedule.timezone === 'string' && schedule.timezone.trim().length > 0
1171
+ ? schedule.timezone.trim()
1172
+ : undefined;
1173
+ schedule.runOnStart = Boolean(schedule.runOnStart);
1174
+
1175
+ if (schedule.enabled && !schedule.cron) {
1176
+ throw new Error('Scheduled configuration requires a valid cron expression when enabled is true');
1177
+ }
1178
+
1179
+ return schedule;
1180
+ }
1181
+
1182
+ function resolveDriverReference(driverInput, logFn) {
1183
+ if (typeof driverInput === 'string') {
1184
+ return driverInput;
1185
+ }
1186
+
1187
+ if (typeof driverInput === 'function') {
1188
+ if (INLINE_DRIVER_NAMES.has(driverInput)) {
1189
+ return INLINE_DRIVER_NAMES.get(driverInput);
1190
+ }
1191
+
1192
+ const baseName = sanitizeId(driverInput.name || 'inline-driver');
1193
+ let candidate = `inline-${baseName}`;
1194
+ const existing = new Set(listCloudDrivers().concat([...INLINE_DRIVER_NAMES.values()]));
1195
+
1196
+ let attempt = 1;
1197
+ while (existing.has(candidate)) {
1198
+ attempt += 1;
1199
+ candidate = `inline-${baseName}-${attempt}`;
1200
+ }
1201
+
1202
+ registerCloudDriver(candidate, (options) => instantiateInlineDriver(driverInput, options));
1203
+ INLINE_DRIVER_NAMES.set(driverInput, candidate);
1204
+ if (typeof logFn === 'function') {
1205
+ logFn('info', `Registered inline cloud driver "${candidate}"`, { driver: driverInput.name || 'anonymous' });
1206
+ }
1207
+ return candidate;
1208
+ }
1209
+
1210
+ throw new Error('Cloud driver must be a string identifier or a class/factory that produces a BaseCloudDriver instance');
1211
+ }
1212
+
1213
+ function instantiateInlineDriver(driverInput, options) {
1214
+ if (isSubclassOfBase(driverInput)) {
1215
+ return new driverInput(options);
1216
+ }
1217
+
1218
+ const result = driverInput(options);
1219
+ if (result instanceof BaseCloudDriver) {
1220
+ return result;
1221
+ }
1222
+
1223
+ if (result && typeof result === 'object' && typeof result.listResources === 'function') {
1224
+ return result;
1225
+ }
1226
+
1227
+ throw new Error('Inline driver factory must return an instance of BaseCloudDriver');
1228
+ }
1229
+
1230
+ function isSubclassOfBase(fn) {
1231
+ return typeof fn === 'function' && (fn === BaseCloudDriver || fn.prototype instanceof BaseCloudDriver);
1232
+ }
1233
+
1234
+ function normalizeCloudDefinitions(rawClouds, logFn) {
1235
+ const usedIds = new Set();
1236
+ const results = [];
1237
+
1238
+ const emitLog = (level, message, meta = {}) => {
1239
+ if (typeof logFn === 'function') {
1240
+ logFn(level, message, meta);
1241
+ }
1242
+ };
1243
+
1244
+ for (const cloud of rawClouds) {
1245
+ if (!cloud || typeof cloud !== 'object') {
1246
+ continue;
1247
+ }
1248
+
1249
+ const driverName = resolveDriverReference(cloud.driver, emitLog);
1250
+ const cloudWithDriver = { ...cloud, driver: driverName };
1251
+
1252
+ let id = typeof cloudWithDriver.id === 'string' && cloudWithDriver.id.trim().length > 0
1253
+ ? cloudWithDriver.id.trim()
1254
+ : null;
1255
+
1256
+ if (!id) {
1257
+ const derived = deriveCloudId(cloudWithDriver);
1258
+ let candidate = derived;
1259
+ let attempt = 1;
1260
+ while (usedIds.has(candidate)) {
1261
+ attempt += 1;
1262
+ candidate = `${derived}-${attempt}`;
1263
+ }
1264
+ id = candidate;
1265
+ emitLog('info', `Cloud id not provided for driver "${driverName}", using derived id "${id}"`, { driver: driverName });
1266
+ } else if (usedIds.has(id)) {
1267
+ let candidate = id;
1268
+ let attempt = 1;
1269
+ while (usedIds.has(candidate)) {
1270
+ attempt += 1;
1271
+ candidate = `${id}-${attempt}`;
1272
+ }
1273
+ emitLog('warn', `Duplicated cloud id "${id}" detected, using "${candidate}" instead`, { driver: driverName });
1274
+ id = candidate;
1275
+ }
1276
+
1277
+ usedIds.add(id);
1278
+ results.push({ ...cloudWithDriver, id });
1279
+ }
1280
+
1281
+ return results;
1282
+ }
1283
+
1284
+ function deriveCloudId(cloud) {
1285
+ const driver = (cloud.driver || 'cloud').toString().toLowerCase();
1286
+ const hints = extractIdentityHints(cloud);
1287
+ const base = hints.length > 0 ? `${driver}-${sanitizeId(hints[0])}` : driver;
1288
+ return base || driver;
1289
+ }
1290
+
1291
+ function extractIdentityHints(cloud) {
1292
+ const values = [];
1293
+ const candidatePaths = [
1294
+ ['config', 'accountId'],
1295
+ ['config', 'projectId'],
1296
+ ['config', 'subscriptionId'],
1297
+ ['credentials', 'accountId'],
1298
+ ['credentials', 'accountNumber'],
1299
+ ['credentials', 'subscriptionId'],
1300
+ ['credentials', 'tenantId'],
1301
+ ['credentials', 'email'],
1302
+ ['credentials', 'user'],
1303
+ ['credentials', 'profile'],
1304
+ ['credentials', 'organizationId']
1305
+ ];
1306
+
1307
+ for (const path of candidatePaths) {
1308
+ let ref = cloud;
1309
+ for (const segment of path) {
1310
+ if (ref && typeof ref === 'object' && segment in ref) {
1311
+ ref = ref[segment];
1312
+ } else {
1313
+ ref = null;
1314
+ break;
1315
+ }
1316
+ }
1317
+ if (typeof ref === 'string' && ref.trim().length > 0) {
1318
+ values.push(ref.trim());
1319
+ }
1320
+ }
1321
+
1322
+ return values;
1323
+ }
1324
+
1325
+ function sanitizeId(value) {
1326
+ return value
1327
+ .toLowerCase()
1328
+ .replace(/[^a-z0-9._-]+/g, '-')
1329
+ .replace(/^-+|-+$/g, '')
1330
+ .slice(0, 80) || 'cloud';
1331
+ }
1332
+
1333
+ export default CloudInventoryPlugin;