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.
- package/README.md +25 -10
- package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
- package/dist/s3db.cjs.map +1 -0
- package/dist/s3db.es.js +38653 -32291
- package/dist/s3db.es.js.map +1 -1
- package/package.json +218 -22
- package/src/concerns/id.js +90 -6
- package/src/concerns/index.js +2 -1
- package/src/concerns/password-hashing.js +150 -0
- package/src/database.class.js +6 -2
- package/src/plugins/api/auth/basic-auth.js +40 -10
- package/src/plugins/api/auth/index.js +49 -3
- package/src/plugins/api/auth/oauth2-auth.js +171 -0
- package/src/plugins/api/auth/oidc-auth.js +789 -0
- package/src/plugins/api/auth/oidc-client.js +462 -0
- package/src/plugins/api/auth/path-auth-matcher.js +284 -0
- package/src/plugins/api/concerns/event-emitter.js +134 -0
- package/src/plugins/api/concerns/failban-manager.js +651 -0
- package/src/plugins/api/concerns/guards-helpers.js +402 -0
- package/src/plugins/api/concerns/metrics-collector.js +346 -0
- package/src/plugins/api/index.js +510 -57
- package/src/plugins/api/middlewares/failban.js +305 -0
- package/src/plugins/api/middlewares/rate-limit.js +301 -0
- package/src/plugins/api/middlewares/request-id.js +74 -0
- package/src/plugins/api/middlewares/security-headers.js +120 -0
- package/src/plugins/api/middlewares/session-tracking.js +194 -0
- package/src/plugins/api/routes/auth-routes.js +119 -78
- package/src/plugins/api/routes/resource-routes.js +73 -30
- package/src/plugins/api/server.js +1139 -45
- package/src/plugins/api/utils/custom-routes.js +102 -0
- package/src/plugins/api/utils/guards.js +213 -0
- package/src/plugins/api/utils/mime-types.js +154 -0
- package/src/plugins/api/utils/openapi-generator.js +91 -12
- package/src/plugins/api/utils/path-matcher.js +173 -0
- package/src/plugins/api/utils/static-filesystem.js +262 -0
- package/src/plugins/api/utils/static-s3.js +231 -0
- package/src/plugins/api/utils/template-engine.js +188 -0
- package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
- package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
- package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
- package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
- package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
- package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
- package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
- package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
- package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
- package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
- package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
- package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
- package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
- package/src/plugins/cloud-inventory/index.js +20 -0
- package/src/plugins/cloud-inventory/registry.js +146 -0
- package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
- package/src/plugins/cloud-inventory.plugin.js +1333 -0
- package/src/plugins/concerns/plugin-dependencies.js +62 -2
- package/src/plugins/eventual-consistency/analytics.js +1 -0
- package/src/plugins/eventual-consistency/consolidation.js +2 -2
- package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
- package/src/plugins/eventual-consistency/install.js +2 -2
- package/src/plugins/identity/README.md +335 -0
- package/src/plugins/identity/concerns/mfa-manager.js +204 -0
- package/src/plugins/identity/concerns/password.js +138 -0
- package/src/plugins/identity/concerns/resource-schemas.js +273 -0
- package/src/plugins/identity/concerns/token-generator.js +172 -0
- package/src/plugins/identity/email-service.js +422 -0
- package/src/plugins/identity/index.js +1052 -0
- package/src/plugins/identity/oauth2-server.js +1033 -0
- package/src/plugins/identity/oidc-discovery.js +285 -0
- package/src/plugins/identity/rsa-keys.js +323 -0
- package/src/plugins/identity/server.js +500 -0
- package/src/plugins/identity/session-manager.js +453 -0
- package/src/plugins/identity/ui/layouts/base.js +251 -0
- package/src/plugins/identity/ui/middleware.js +135 -0
- package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
- package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
- package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
- package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
- package/src/plugins/identity/ui/pages/admin/users.js +263 -0
- package/src/plugins/identity/ui/pages/consent.js +262 -0
- package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
- package/src/plugins/identity/ui/pages/login.js +144 -0
- package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
- package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
- package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
- package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
- package/src/plugins/identity/ui/pages/profile.js +361 -0
- package/src/plugins/identity/ui/pages/register.js +226 -0
- package/src/plugins/identity/ui/pages/reset-password.js +128 -0
- package/src/plugins/identity/ui/pages/verify-email.js +172 -0
- package/src/plugins/identity/ui/routes.js +2541 -0
- package/src/plugins/identity/ui/styles/main.css +465 -0
- package/src/plugins/index.js +4 -1
- package/src/plugins/ml/base-model.class.js +65 -16
- package/src/plugins/ml/classification-model.class.js +1 -1
- package/src/plugins/ml/timeseries-model.class.js +3 -1
- package/src/plugins/ml.plugin.js +584 -31
- package/src/plugins/shared/error-handler.js +147 -0
- package/src/plugins/shared/index.js +9 -0
- package/src/plugins/shared/middlewares/compression.js +117 -0
- package/src/plugins/shared/middlewares/cors.js +49 -0
- package/src/plugins/shared/middlewares/index.js +11 -0
- package/src/plugins/shared/middlewares/logging.js +54 -0
- package/src/plugins/shared/middlewares/rate-limit.js +73 -0
- package/src/plugins/shared/middlewares/security.js +158 -0
- package/src/plugins/shared/response-formatter.js +264 -0
- package/src/plugins/state-machine.plugin.js +57 -2
- package/src/resource.class.js +140 -12
- package/src/schema.class.js +30 -1
- package/src/validator.class.js +57 -6
- 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;
|