@thinremote/thinr-cli 1.1.0 → 1.2.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/CHANGELOG.md +71 -0
- package/README.md +230 -16
- package/bin/thinr.js +23 -24
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +30 -17
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +199 -0
- package/lib/concurrency.js +52 -0
- package/lib/config.js +5 -3
- package/lib/console.js +12 -4
- package/lib/constants.js +76 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +109 -147
- package/lib/devices.js +18 -21
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1343 -10
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +15 -3
- package/commands/product.js +0 -167
package/lib/product.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeStorageFile,
|
|
12
12
|
deleteStorageFile,
|
|
13
13
|
} from './storage.js';
|
|
14
|
+
import { parsePlaybook } from './playbook/loader.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Helpers for ThinRemote products and the product-script workflow.
|
|
@@ -76,6 +77,18 @@ export async function createProduct(
|
|
|
76
77
|
user,
|
|
77
78
|
{ icon = 'fab fa-linux', name, description } = {},
|
|
78
79
|
) {
|
|
80
|
+
// Icons accept either a Font Awesome class (string, legacy short form)
|
|
81
|
+
// or a structured object { type: 'fa'|'svg', source, color, background }.
|
|
82
|
+
// The structured form lets callers ship inline SVG markup directly.
|
|
83
|
+
const iconSpec =
|
|
84
|
+
typeof icon === 'object' && icon !== null
|
|
85
|
+
? {
|
|
86
|
+
type: icon.type === 'svg' ? 'svg' : 'fa',
|
|
87
|
+
source: icon.source,
|
|
88
|
+
color: icon.color || '#000000',
|
|
89
|
+
background: icon.background || '#ffffff',
|
|
90
|
+
}
|
|
91
|
+
: { type: 'fa', source: icon, color: '#000000', background: '#ffffff' };
|
|
79
92
|
try {
|
|
80
93
|
await api.post(`${v1(user)}/products`, {
|
|
81
94
|
product: productId,
|
|
@@ -87,7 +100,7 @@ export async function createProduct(
|
|
|
87
100
|
icons: [
|
|
88
101
|
{
|
|
89
102
|
conditions: [],
|
|
90
|
-
icon:
|
|
103
|
+
icon: iconSpec,
|
|
91
104
|
},
|
|
92
105
|
],
|
|
93
106
|
},
|
|
@@ -188,6 +201,58 @@ export async function setProductCode(productId, user, payload) {
|
|
|
188
201
|
}
|
|
189
202
|
}
|
|
190
203
|
|
|
204
|
+
// ─── Low-level: product properties (/v1/.../products/<id>/properties) ─
|
|
205
|
+
//
|
|
206
|
+
// Product-level properties are structured JSON values attached to the
|
|
207
|
+
// product itself (not to any single device). Typical uses: dashboard
|
|
208
|
+
// configuration, shared feature flags, a list of metrics to stream.
|
|
209
|
+
// Endpoint mirrors device properties but lives under the v1 product
|
|
210
|
+
// namespace on the server.
|
|
211
|
+
|
|
212
|
+
export async function getProductProperties(productId, user) {
|
|
213
|
+
try {
|
|
214
|
+
const res = await api.get(`${v1(user)}/products/${productId}/properties`);
|
|
215
|
+
return res.data || [];
|
|
216
|
+
} catch (e) {
|
|
217
|
+
throw apiError(e, { notFound: `Product not found: ${productId}` });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function getProductProperty(productId, propertyId, user) {
|
|
222
|
+
try {
|
|
223
|
+
const res = await api.get(
|
|
224
|
+
`${v1(user)}/products/${productId}/properties/${propertyId}`,
|
|
225
|
+
);
|
|
226
|
+
return res.data?.value;
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw apiError(e, {
|
|
229
|
+
notFound: `Property not found: ${propertyId} on product ${productId}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function setProductProperty(productId, propertyId, value, user) {
|
|
235
|
+
try {
|
|
236
|
+
const res = await api.put(
|
|
237
|
+
`${v1(user)}/products/${productId}/properties/${propertyId}`,
|
|
238
|
+
{ value },
|
|
239
|
+
);
|
|
240
|
+
return res.data;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
throw apiError(e, { notFound: `Product not found: ${productId}` });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function deleteProductProperty(productId, propertyId, user) {
|
|
247
|
+
try {
|
|
248
|
+
await api.delete(`${v1(user)}/products/${productId}/properties/${propertyId}`);
|
|
249
|
+
return true;
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (e.response?.status === 404) return false;
|
|
252
|
+
throw apiError(e);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
191
256
|
// ─── Low-level: device ↔ product binding ─────────────────────────────
|
|
192
257
|
|
|
193
258
|
export async function setDeviceProduct(deviceId, productId, user) {
|
|
@@ -214,12 +279,36 @@ const PRODUCT_SCRIPT_INDEX_JS = `function script(ev) {
|
|
|
214
279
|
);
|
|
215
280
|
if (!match) throw new Error("script not found: " + ev.resource);
|
|
216
281
|
const body = fs.readFileSync(path.join(dir, match), "utf8");
|
|
282
|
+
// Input can arrive in three shapes depending on payload_type:
|
|
283
|
+
// - template_payload: ev.input (envelope from buildInputTemplate)
|
|
284
|
+
// - source_event: ev.payload.input or ev.payload (free-form)
|
|
285
|
+
// - source_payload: ev (already the payload)
|
|
286
|
+
const inp = ev.input !== undefined ? ev.input
|
|
287
|
+
: (ev.payload && ev.payload.input !== undefined) ? ev.payload.input
|
|
288
|
+
: ev.payload;
|
|
217
289
|
return {
|
|
218
290
|
cmd: body, mode: "api", timeout: 30,
|
|
219
|
-
stdin:
|
|
220
|
-
? JSON.stringify(ev.payload.input) : undefined
|
|
291
|
+
stdin: inp !== undefined && inp !== null ? JSON.stringify(inp) : undefined
|
|
221
292
|
};
|
|
222
293
|
}
|
|
294
|
+
|
|
295
|
+
function parse_stdout(ev) {
|
|
296
|
+
const p = (ev && ev.payload) || {};
|
|
297
|
+
if (p.retcode !== 0) {
|
|
298
|
+
const msg = (p.stderr && String(p.stderr).trim()) ||
|
|
299
|
+
("script exited with code " + p.retcode);
|
|
300
|
+
return { error: msg, retcode: p.retcode };
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(p.stdout);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return {
|
|
306
|
+
error: "invalid JSON in stdout",
|
|
307
|
+
detail: e.message,
|
|
308
|
+
stdout: p.stdout
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
223
312
|
`;
|
|
224
313
|
|
|
225
314
|
/** Drop a product (and, by default, its companion file storage). */
|
|
@@ -243,6 +332,29 @@ export async function deleteProductWithStorage(productId, user, { keepStorage =
|
|
|
243
332
|
return { steps };
|
|
244
333
|
}
|
|
245
334
|
|
|
335
|
+
function buildInputTemplate(input) {
|
|
336
|
+
const pairs = Object.entries(input).map(([k, v]) => {
|
|
337
|
+
if (typeof v === 'string') {
|
|
338
|
+
return ` "${k}": "{{payload.${k}="${v}"}}"`;
|
|
339
|
+
}
|
|
340
|
+
if (typeof v === 'number' || typeof v === 'boolean') {
|
|
341
|
+
return ` "${k}": {{payload.${k}=${v}}}`;
|
|
342
|
+
}
|
|
343
|
+
return ` "${k}": ${JSON.stringify(v)}`;
|
|
344
|
+
});
|
|
345
|
+
// Keep {{resource}} in the envelope so the wrapper can still discover
|
|
346
|
+
// which script to dispatch — template_payload strips the event context
|
|
347
|
+
// away and only hands the rendered object to the payload_function.
|
|
348
|
+
return [
|
|
349
|
+
'{',
|
|
350
|
+
' "resource": "{{resource}}",',
|
|
351
|
+
' "input": {',
|
|
352
|
+
pairs.join(',\n'),
|
|
353
|
+
' }',
|
|
354
|
+
'}',
|
|
355
|
+
].join('\n');
|
|
356
|
+
}
|
|
357
|
+
|
|
246
358
|
/**
|
|
247
359
|
* Install (create or update) a product script. Ensures the product
|
|
248
360
|
* exists, the storage exists, the index.js wrapper is present, the
|
|
@@ -250,7 +362,7 @@ export async function deleteProductWithStorage(productId, user, { keepStorage =
|
|
|
250
362
|
* storage, and the API resource named after the script's stem is
|
|
251
363
|
* registered. Idempotent.
|
|
252
364
|
*/
|
|
253
|
-
export async function installProductScript({ product, name, content, user,
|
|
365
|
+
export async function installProductScript({ product, name, content, user, input, output } = {}) {
|
|
254
366
|
if (!product) throw inputError('product is required');
|
|
255
367
|
if (!name) throw inputError('name is required');
|
|
256
368
|
if (typeof content !== 'string') throw inputError('content is required (string)');
|
|
@@ -259,16 +371,32 @@ export async function installProductScript({ product, name, content, user, icon
|
|
|
259
371
|
`Invalid script name "${name}" (allowed: letters, digits, dot, dash, underscore)`,
|
|
260
372
|
);
|
|
261
373
|
}
|
|
374
|
+
if (input !== undefined && input !== null) {
|
|
375
|
+
if (typeof input !== 'object' || Array.isArray(input)) {
|
|
376
|
+
throw inputError('input must be a plain object mapping field names to default values');
|
|
377
|
+
}
|
|
378
|
+
for (const k of Object.keys(input)) {
|
|
379
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
|
|
380
|
+
throw inputError(`Invalid input field name "${k}"`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
262
384
|
|
|
263
385
|
const storage = product;
|
|
264
386
|
const stem = name.replace(/\.[^.]+$/, '');
|
|
265
387
|
const steps = [];
|
|
266
388
|
|
|
267
|
-
// 1.
|
|
389
|
+
// 1. Refuse to silently create the product. The agent flow is
|
|
390
|
+
// list → create → write-script: if the product is missing, the
|
|
391
|
+
// caller has to create it explicitly with `thinr_product_create`
|
|
392
|
+
// so its name, description and icon are set deliberately rather
|
|
393
|
+
// than picking up defaults from a side effect.
|
|
268
394
|
if (!(await productExists(product, user))) {
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
395
|
+
const err = new Error(
|
|
396
|
+
`Product "${product}" does not exist. Create it first with thinr_product_create.`,
|
|
397
|
+
);
|
|
398
|
+
err.code = 'not_found';
|
|
399
|
+
throw err;
|
|
272
400
|
}
|
|
273
401
|
|
|
274
402
|
// 2. Ensure storage exists
|
|
@@ -277,8 +405,19 @@ export async function installProductScript({ product, name, content, user, icon
|
|
|
277
405
|
steps.push(`created storage "${storage}"`);
|
|
278
406
|
}
|
|
279
407
|
|
|
280
|
-
// 3. Ensure the generic index.js wrapper is in place
|
|
281
|
-
|
|
408
|
+
// 3. Ensure the generic index.js wrapper is in place and up to date.
|
|
409
|
+
// Rewrite only when the contents differ, so hand-edited wrappers are
|
|
410
|
+
// preserved if the user tweaked theirs and still match.
|
|
411
|
+
let needsWrapper = true;
|
|
412
|
+
if (await storageFileExists(storage, 'index.js', user)) {
|
|
413
|
+
try {
|
|
414
|
+
const current = await readStorageFile(storage, 'index.js', user);
|
|
415
|
+
if (current === PRODUCT_SCRIPT_INDEX_JS) needsWrapper = false;
|
|
416
|
+
} catch {
|
|
417
|
+
// unreadable → just overwrite.
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (needsWrapper) {
|
|
282
421
|
await writeStorageFile(storage, 'index.js', PRODUCT_SCRIPT_INDEX_JS, user);
|
|
283
422
|
steps.push('wrote index.js wrapper');
|
|
284
423
|
}
|
|
@@ -301,18 +440,39 @@ export async function installProductScript({ product, name, content, user, icon
|
|
|
301
440
|
steps.push('enabled product');
|
|
302
441
|
}
|
|
303
442
|
|
|
304
|
-
// 7. Create/update the API resource bound to the script stem
|
|
443
|
+
// 7. Create/update the API resource bound to the script stem.
|
|
444
|
+
// With an input schema, register a template_payload so the UI can
|
|
445
|
+
// render typed fields; without one, fall back to source_event so any
|
|
446
|
+
// object the caller sends flows straight through.
|
|
447
|
+
const requestData = {
|
|
448
|
+
target: 'resource',
|
|
449
|
+
resource: 'cmd',
|
|
450
|
+
payload_function: 'script',
|
|
451
|
+
};
|
|
452
|
+
if (input && Object.keys(input).length > 0) {
|
|
453
|
+
// The frontend encodes "Template Payload" as an empty payload_type
|
|
454
|
+
// string (see profile-source-configurator.html), and the backend
|
|
455
|
+
// defaults to template_payload when the value is unrecognised.
|
|
456
|
+
requestData.payload_type = '';
|
|
457
|
+
requestData.payload = buildInputTemplate(input);
|
|
458
|
+
} else {
|
|
459
|
+
requestData.payload_type = 'source_event';
|
|
460
|
+
}
|
|
461
|
+
// When the caller explicitly opts out of the response (output: false)
|
|
462
|
+
// the resource becomes fire-and-forget: no parse_stdout, no body back.
|
|
463
|
+
// Combined with no input this classifies as a "run" resource.
|
|
464
|
+
const wantOutput = output !== false;
|
|
465
|
+
const responseData = wantOutput
|
|
466
|
+
? {
|
|
467
|
+
source: 'request_response',
|
|
468
|
+
payload_type: 'source_event',
|
|
469
|
+
payload_function: 'parse_stdout',
|
|
470
|
+
}
|
|
471
|
+
: { payload_type: 'none' };
|
|
305
472
|
await setProductApiResource(product, stem, user, {
|
|
306
473
|
enabled: true,
|
|
307
|
-
request: {
|
|
308
|
-
|
|
309
|
-
target: 'resource',
|
|
310
|
-
resource: 'cmd',
|
|
311
|
-
payload_type: 'source_event',
|
|
312
|
-
payload_function: 'script',
|
|
313
|
-
},
|
|
314
|
-
},
|
|
315
|
-
response: { data: { source: 'request_response', payload_type: 'source_payload' } },
|
|
474
|
+
request: { data: requestData },
|
|
475
|
+
response: { data: responseData },
|
|
316
476
|
});
|
|
317
477
|
steps.push(`upserted API resource "${stem}"`);
|
|
318
478
|
|
|
@@ -371,3 +531,346 @@ export async function readProductScript(productId, name, user) {
|
|
|
371
531
|
}
|
|
372
532
|
return readStorageFile(productId, `scripts/${name}`, user);
|
|
373
533
|
}
|
|
534
|
+
|
|
535
|
+
// ─── Orchestrators: dashboard metrics ────────────────────────────────
|
|
536
|
+
//
|
|
537
|
+
// The dashboard reads its metric panels from a single product property
|
|
538
|
+
// (`dashboard_metrics`) whose value is an array of metric entries. These
|
|
539
|
+
// helpers read/modify/write that list so callers don't have to care
|
|
540
|
+
// about the encoding — each metric is addressed by its stable `name`
|
|
541
|
+
// key, idempotently upserted or removed.
|
|
542
|
+
|
|
543
|
+
export const DASHBOARD_METRICS_PROPERTY = 'dashboard_metrics';
|
|
544
|
+
|
|
545
|
+
const METRIC_VISUALIZATIONS = new Set(['kpi', 'bar', 'sparkline', 'list']);
|
|
546
|
+
const METRIC_AGGREGATIONS = new Set([
|
|
547
|
+
'avg',
|
|
548
|
+
'count',
|
|
549
|
+
'distribution',
|
|
550
|
+
'max',
|
|
551
|
+
'min',
|
|
552
|
+
'none',
|
|
553
|
+
'sum',
|
|
554
|
+
'top',
|
|
555
|
+
]);
|
|
556
|
+
|
|
557
|
+
function validateMetric(m) {
|
|
558
|
+
if (!m || typeof m !== 'object') throw inputError('metric must be an object');
|
|
559
|
+
if (!m.name || typeof m.name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(m.name)) {
|
|
560
|
+
throw inputError('metric.name is required (letters, digits, underscore, dash)');
|
|
561
|
+
}
|
|
562
|
+
if (!m.resource || typeof m.resource !== 'string') {
|
|
563
|
+
throw inputError('metric.resource is required (product API resource name)');
|
|
564
|
+
}
|
|
565
|
+
if (m.aggregation && !METRIC_AGGREGATIONS.has(m.aggregation)) {
|
|
566
|
+
throw inputError(
|
|
567
|
+
`metric.aggregation must be one of: ${[...METRIC_AGGREGATIONS].join(', ')}`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (m.visualization && !METRIC_VISUALIZATIONS.has(m.visualization)) {
|
|
571
|
+
throw inputError(
|
|
572
|
+
`metric.visualization must be one of: ${[...METRIC_VISUALIZATIONS].join(', ')}`,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
if (m.interval != null) {
|
|
576
|
+
const n = Number(m.interval);
|
|
577
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
578
|
+
throw inputError('metric.interval must be a positive number of seconds');
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function loadMetricList(productId, user) {
|
|
584
|
+
try {
|
|
585
|
+
const value = await getProductProperty(productId, DASHBOARD_METRICS_PROPERTY, user);
|
|
586
|
+
if (Array.isArray(value)) return value;
|
|
587
|
+
if (Array.isArray(value?.metrics)) return value.metrics;
|
|
588
|
+
return [];
|
|
589
|
+
} catch (e) {
|
|
590
|
+
if (/Property not found/.test(e.message || '')) return [];
|
|
591
|
+
throw e;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export async function listDashboardMetrics(productId, user) {
|
|
596
|
+
return loadMetricList(productId, user);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function upsertDashboardMetric(productId, metric, user) {
|
|
600
|
+
validateMetric(metric);
|
|
601
|
+
const list = await loadMetricList(productId, user);
|
|
602
|
+
const idx = list.findIndex((m) => m.name === metric.name);
|
|
603
|
+
const next = [...list];
|
|
604
|
+
const action = idx >= 0 ? 'updated' : 'added';
|
|
605
|
+
if (idx >= 0) next[idx] = { ...list[idx], ...metric };
|
|
606
|
+
else next.push(metric);
|
|
607
|
+
await setProductProperty(productId, DASHBOARD_METRICS_PROPERTY, next, user);
|
|
608
|
+
return { action, metric, count: next.length };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function removeDashboardMetric(productId, name, user) {
|
|
612
|
+
if (!name || typeof name !== 'string') throw inputError('metric name is required');
|
|
613
|
+
const list = await loadMetricList(productId, user);
|
|
614
|
+
const next = list.filter((m) => m.name !== name);
|
|
615
|
+
if (next.length === list.length) return { action: 'noop', count: next.length };
|
|
616
|
+
if (next.length === 0) {
|
|
617
|
+
await deleteProductProperty(productId, DASHBOARD_METRICS_PROPERTY, user);
|
|
618
|
+
} else {
|
|
619
|
+
await setProductProperty(productId, DASHBOARD_METRICS_PROPERTY, next, user);
|
|
620
|
+
}
|
|
621
|
+
return { action: 'removed', count: next.length };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ─── Orchestrators: product playbooks ────────────────────────────────
|
|
625
|
+
//
|
|
626
|
+
// Playbooks live as YAML files inside the product's file storage under
|
|
627
|
+
// a reserved `playbooks/` folder. A lightweight index kept in the
|
|
628
|
+
// product property `playbooks` mirrors the available entries with just
|
|
629
|
+
// name, description and storage path, so listing is a single property
|
|
630
|
+
// read instead of a storage scan. Defaults for playbook variables
|
|
631
|
+
// travel inside the YAML itself (see lib/playbook/loader.js) — the
|
|
632
|
+
// execution tasks consume them without a separate metadata file.
|
|
633
|
+
|
|
634
|
+
export const PLAYBOOK_INDEX_PROPERTY = 'playbooks';
|
|
635
|
+
export const PLAYBOOK_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
636
|
+
|
|
637
|
+
export function playbookStoragePath(name) {
|
|
638
|
+
return `playbooks/${name}.yaml`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function assertPlaybookName(name) {
|
|
642
|
+
if (!name || typeof name !== 'string') {
|
|
643
|
+
throw inputError('playbook name is required');
|
|
644
|
+
}
|
|
645
|
+
if (!PLAYBOOK_NAME_RE.test(name)) {
|
|
646
|
+
throw inputError(
|
|
647
|
+
`Invalid playbook name "${name}" (allowed: letters, digits, underscore, dash)`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function loadPlaybookIndex(productId, user) {
|
|
653
|
+
try {
|
|
654
|
+
const value = await getProductProperty(productId, PLAYBOOK_INDEX_PROPERTY, user);
|
|
655
|
+
if (Array.isArray(value)) return value;
|
|
656
|
+
if (Array.isArray(value?.playbooks)) return value.playbooks;
|
|
657
|
+
return [];
|
|
658
|
+
} catch (e) {
|
|
659
|
+
if (/Property not found/.test(e.message || '')) return [];
|
|
660
|
+
throw e;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function savePlaybookIndex(productId, list, user) {
|
|
665
|
+
if (!list || list.length === 0) {
|
|
666
|
+
await deleteProductProperty(productId, PLAYBOOK_INDEX_PROPERTY, user);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
await setProductProperty(productId, PLAYBOOK_INDEX_PROPERTY, list, user);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export async function listProductPlaybooks(productId, user) {
|
|
673
|
+
const list = await loadPlaybookIndex(productId, user);
|
|
674
|
+
return list.map((e) => ({
|
|
675
|
+
name: e.name,
|
|
676
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
677
|
+
path: e.path || playbookStoragePath(e.name),
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export async function findProductPlaybook(productId, name, user) {
|
|
682
|
+
assertPlaybookName(name);
|
|
683
|
+
const list = await loadPlaybookIndex(productId, user);
|
|
684
|
+
const entry = list.find((e) => e.name === name);
|
|
685
|
+
if (!entry) return null;
|
|
686
|
+
return {
|
|
687
|
+
name: entry.name,
|
|
688
|
+
description: typeof entry.description === 'string' ? entry.description : '',
|
|
689
|
+
path: entry.path || playbookStoragePath(entry.name),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export async function readProductPlaybook(productId, name, user) {
|
|
694
|
+
assertPlaybookName(name);
|
|
695
|
+
const entry = await findProductPlaybook(productId, name, user);
|
|
696
|
+
const path = entry?.path || playbookStoragePath(name);
|
|
697
|
+
return readStorageFile(productId, path, user);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Upload a playbook YAML to a product. Validates the name, optionally
|
|
702
|
+
* validates the YAML against the playbook schema, ensures the storage
|
|
703
|
+
* exists, writes the file, and only then registers the entry in the
|
|
704
|
+
* index property. If the index update fails the file is rolled back
|
|
705
|
+
* so no orphans are left behind. Returns `{ action, entry, replaced }`.
|
|
706
|
+
*
|
|
707
|
+
* @param {{
|
|
708
|
+
* product?: string,
|
|
709
|
+
* name?: string,
|
|
710
|
+
* content?: unknown,
|
|
711
|
+
* description?: string,
|
|
712
|
+
* user?: string,
|
|
713
|
+
* skipValidation?: boolean,
|
|
714
|
+
* }} [opts]
|
|
715
|
+
*/
|
|
716
|
+
export async function uploadProductPlaybook({
|
|
717
|
+
product,
|
|
718
|
+
name,
|
|
719
|
+
content,
|
|
720
|
+
description,
|
|
721
|
+
user,
|
|
722
|
+
skipValidation = false,
|
|
723
|
+
} = {}) {
|
|
724
|
+
if (!product) throw inputError('product is required');
|
|
725
|
+
assertPlaybookName(name);
|
|
726
|
+
if (typeof content !== 'string') throw inputError('content is required (string)');
|
|
727
|
+
|
|
728
|
+
let parsedDescription = null;
|
|
729
|
+
if (!skipValidation) {
|
|
730
|
+
const pb = parsePlaybook(content, { sourcePath: `${name}.yaml` });
|
|
731
|
+
parsedDescription = pb.description;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const storage = product;
|
|
735
|
+
const path = playbookStoragePath(name);
|
|
736
|
+
const steps = [];
|
|
737
|
+
|
|
738
|
+
if (!(await storageExists(storage, user))) {
|
|
739
|
+
await createStorage(storage, user, { description: `Product storage for ${product}` });
|
|
740
|
+
steps.push(`created storage "${storage}"`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const existing = await loadPlaybookIndex(product, user);
|
|
744
|
+
const priorIdx = existing.findIndex((e) => e.name === name);
|
|
745
|
+
const replaced = priorIdx >= 0;
|
|
746
|
+
|
|
747
|
+
await writeStorageFile(storage, path, content, user);
|
|
748
|
+
steps.push(`${replaced ? 'overwrote' : 'wrote'} ${path} (${content.length} bytes)`);
|
|
749
|
+
|
|
750
|
+
const entry = {
|
|
751
|
+
name,
|
|
752
|
+
description:
|
|
753
|
+
typeof description === 'string'
|
|
754
|
+
? description
|
|
755
|
+
: typeof parsedDescription === 'string'
|
|
756
|
+
? parsedDescription
|
|
757
|
+
: '',
|
|
758
|
+
path,
|
|
759
|
+
};
|
|
760
|
+
const next = [...existing];
|
|
761
|
+
if (replaced) next[priorIdx] = entry;
|
|
762
|
+
else next.push(entry);
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
await savePlaybookIndex(product, next, user);
|
|
766
|
+
steps.push(`${replaced ? 'updated' : 'registered'} index entry "${name}"`);
|
|
767
|
+
} catch (indexErr) {
|
|
768
|
+
// Rollback the file so the user doesn't end up with an orphan
|
|
769
|
+
// that `list` can't see. If rollback itself fails, surface that
|
|
770
|
+
// alongside the original error so the user can clean up.
|
|
771
|
+
let rollbackNote;
|
|
772
|
+
try {
|
|
773
|
+
if (replaced) {
|
|
774
|
+
// The file existed before — we overwrote it. Leaving it
|
|
775
|
+
// in its new shape is the least destructive option.
|
|
776
|
+
rollbackNote = ' (overwritten file kept, previous content lost)';
|
|
777
|
+
} else {
|
|
778
|
+
await deleteStorageFile(storage, path, user);
|
|
779
|
+
rollbackNote = ' (uploaded file rolled back)';
|
|
780
|
+
}
|
|
781
|
+
} catch (rollbackErr) {
|
|
782
|
+
rollbackNote = ` (rollback failed: ${rollbackErr.message || rollbackErr})`;
|
|
783
|
+
}
|
|
784
|
+
const err = new Error(
|
|
785
|
+
`Failed to update playbook index for "${name}"${rollbackNote}: ${indexErr.message || indexErr}`,
|
|
786
|
+
);
|
|
787
|
+
err.code = indexErr.code || 'error';
|
|
788
|
+
throw err;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return { action: replaced ? 'updated' : 'created', entry, replaced, steps };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Persist a fleet-run report in the product's file storage under
|
|
796
|
+
* `playbooks/runs/`. Returns `{ ok, path, reason }`. Best-effort: the
|
|
797
|
+
* caller decides whether to surface failures as warnings instead of
|
|
798
|
+
* aborting, since the rollout has already run by the time this is
|
|
799
|
+
* called.
|
|
800
|
+
*
|
|
801
|
+
* @param {{
|
|
802
|
+
* product: string,
|
|
803
|
+
* report: object,
|
|
804
|
+
* user?: string | null,
|
|
805
|
+
* filename?: string,
|
|
806
|
+
* }} opts
|
|
807
|
+
*/
|
|
808
|
+
export async function uploadFleetRunReport({ product, report, user, filename } = {}) {
|
|
809
|
+
if (!product) throw inputError('product is required');
|
|
810
|
+
if (!report || typeof report !== 'object') throw inputError('report is required');
|
|
811
|
+
|
|
812
|
+
if (!(await storageExists(product, user))) {
|
|
813
|
+
return { ok: false, reason: 'storage-missing', path: null };
|
|
814
|
+
}
|
|
815
|
+
const path = `playbooks/runs/${filename || defaultRunReportFilename(report)}`;
|
|
816
|
+
try {
|
|
817
|
+
await writeStorageFile(product, path, JSON.stringify(report, null, 2), user);
|
|
818
|
+
return { ok: true, path, reason: null };
|
|
819
|
+
} catch (err) {
|
|
820
|
+
return {
|
|
821
|
+
ok: false,
|
|
822
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
823
|
+
path: null,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function defaultRunReportFilename(report) {
|
|
829
|
+
const name = typeof report.name === 'string' && PLAYBOOK_NAME_RE.test(report.name)
|
|
830
|
+
? report.name
|
|
831
|
+
: 'run';
|
|
832
|
+
const user = typeof report.user === 'string' && /^[A-Za-z0-9_.-]+$/.test(report.user)
|
|
833
|
+
? report.user
|
|
834
|
+
: 'unknown';
|
|
835
|
+
const ts = typeof report.startedAt === 'string' ? report.startedAt : new Date().toISOString();
|
|
836
|
+
const stamp = ts.replace(/[:.]/g, '').replace(/[^0-9TZ]/g, '');
|
|
837
|
+
return `${stamp}-${name}-${user}.json`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export async function deleteProductPlaybook({ product, name, user } = {}) {
|
|
841
|
+
if (!product) throw inputError('product is required');
|
|
842
|
+
assertPlaybookName(name);
|
|
843
|
+
|
|
844
|
+
const steps = [];
|
|
845
|
+
const list = await loadPlaybookIndex(product, user);
|
|
846
|
+
const idx = list.findIndex((e) => e.name === name);
|
|
847
|
+
const entry = idx >= 0 ? list[idx] : null;
|
|
848
|
+
const path = entry?.path || playbookStoragePath(name);
|
|
849
|
+
|
|
850
|
+
if (idx >= 0) {
|
|
851
|
+
const next = list.filter((_, i) => i !== idx);
|
|
852
|
+
await savePlaybookIndex(product, next, user);
|
|
853
|
+
steps.push(`removed index entry "${name}"`);
|
|
854
|
+
} else {
|
|
855
|
+
steps.push(`index entry "${name}" already absent`);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
let fileRemoved = false;
|
|
859
|
+
try {
|
|
860
|
+
fileRemoved = await deleteStorageFile(product, path, user);
|
|
861
|
+
steps.push(fileRemoved ? `deleted ${path}` : `${path} already absent`);
|
|
862
|
+
} catch (e) {
|
|
863
|
+
// Index is already clean at this point. Surface the file error
|
|
864
|
+
// as a warning step rather than failing the whole op, since the
|
|
865
|
+
// user now just has an orphan to clean up manually.
|
|
866
|
+
steps.push(`warning: failed to delete ${path}: ${e.message || e}`);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
removed: idx >= 0 || fileRemoved,
|
|
871
|
+
indexRemoved: idx >= 0,
|
|
872
|
+
fileRemoved,
|
|
873
|
+
entry,
|
|
874
|
+
steps,
|
|
875
|
+
};
|
|
876
|
+
}
|