@calltelemetry/ct-lab-mcp 0.1.2 → 0.1.3
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/dist/src/appliance/commands.d.ts +4 -0
- package/dist/src/appliance/commands.d.ts.map +1 -1
- package/dist/src/appliance/commands.js +32 -0
- package/dist/src/appliance/commands.js.map +1 -1
- package/dist/src/appliance/mock.d.ts.map +1 -1
- package/dist/src/appliance/mock.js +42 -0
- package/dist/src/appliance/mock.js.map +1 -1
- package/dist/src/appliance/types.d.ts +8 -1
- package/dist/src/appliance/types.d.ts.map +1 -1
- package/dist/src/appliance/types.js.map +1 -1
- package/dist/src/server.d.ts +3 -1
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +14 -4
- package/dist/src/server.js.map +1 -1
- package/dist/src/slot/gc.d.ts +11 -2
- package/dist/src/slot/gc.d.ts.map +1 -1
- package/dist/src/slot/gc.js +20 -2
- package/dist/src/slot/gc.js.map +1 -1
- package/dist/src/slot/manager.d.ts +34 -3
- package/dist/src/slot/manager.d.ts.map +1 -1
- package/dist/src/slot/manager.js +572 -3
- package/dist/src/slot/manager.js.map +1 -1
- package/dist/src/slot/types.d.ts +96 -1
- package/dist/src/slot/types.d.ts.map +1 -1
- package/dist/src/slot/types.js.map +1 -1
- package/dist/src/telemetry/index.d.ts +3 -0
- package/dist/src/telemetry/index.d.ts.map +1 -0
- package/dist/src/telemetry/index.js +3 -0
- package/dist/src/telemetry/index.js.map +1 -0
- package/dist/src/telemetry/registry.d.ts +43 -0
- package/dist/src/telemetry/registry.d.ts.map +1 -0
- package/dist/src/telemetry/registry.js +146 -0
- package/dist/src/telemetry/registry.js.map +1 -0
- package/dist/src/telemetry/types.d.ts +37 -0
- package/dist/src/telemetry/types.d.ts.map +1 -0
- package/dist/src/telemetry/types.js +6 -0
- package/dist/src/telemetry/types.js.map +1 -0
- package/dist/src/tools/appliance-exec.js +1 -1
- package/dist/src/tools/appliance-exec.js.map +1 -1
- package/dist/src/tools/index.d.ts +3 -1
- package/dist/src/tools/index.d.ts.map +1 -1
- package/dist/src/tools/index.js +19 -8
- package/dist/src/tools/index.js.map +1 -1
- package/dist/src/tools/list-hosts.d.ts +2 -2
- package/dist/src/tools/list-inventory.d.ts +3 -3
- package/dist/src/tools/provision-tools.d.ts +122 -21
- package/dist/src/tools/provision-tools.d.ts.map +1 -1
- package/dist/src/tools/provision-tools.js +384 -1
- package/dist/src/tools/provision-tools.js.map +1 -1
- package/dist/src/tools/purge-slots.d.ts +52 -0
- package/dist/src/tools/purge-slots.d.ts.map +1 -0
- package/dist/src/tools/purge-slots.js +107 -0
- package/dist/src/tools/purge-slots.js.map +1 -0
- package/dist/src/tools/slot-tools.d.ts +92 -30
- package/dist/src/tools/slot-tools.d.ts.map +1 -1
- package/dist/src/tools/slot-tools.js +67 -5
- package/dist/src/tools/slot-tools.js.map +1 -1
- package/dist/src/tools/vm-action.d.ts +22 -22
- package/dist/src/utils/formatters.d.ts +38 -4
- package/dist/src/utils/formatters.d.ts.map +1 -1
- package/dist/src/utils/formatters.js +179 -23
- package/dist/src/utils/formatters.js.map +1 -1
- package/package.json +1 -1
package/dist/src/slot/manager.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global Slot Allocation & Capacity Partitioning Engine
|
|
3
3
|
* Manages 8GB appliance slots across Proxmox VE and VMware ESXi hosts with hard partition limits,
|
|
4
|
-
* atomic concurrency locking (AsyncMutex), TTL lease management,
|
|
4
|
+
* atomic concurrency locking (AsyncMutex), TTL lease management, candidate slot ranking,
|
|
5
|
+
* structured metadata labeling/indexing, and automated expired slot purge & reclamation.
|
|
5
6
|
*/
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
7
8
|
import { CapacityExceededError, GlobalCapacityExceededError, SlotNotFoundError, LeaseCeilingExceededError, InvalidSlotOperationError, DatastoreCapacityExceededError } from "./types.js";
|
|
8
9
|
import { AsyncMutex } from "../utils/mutex.js";
|
|
10
|
+
import { defaultSafetyGuardrailEngine } from "../guardrails/safety.js";
|
|
11
|
+
import { telemetry } from "../telemetry/index.js";
|
|
9
12
|
export * from "./types.js";
|
|
10
13
|
const DEFAULT_HOST_CONFIGS = [
|
|
11
14
|
{
|
|
@@ -49,9 +52,15 @@ export class SlotManager {
|
|
|
49
52
|
mutex = new AsyncMutex();
|
|
50
53
|
defaultDurationMinutes;
|
|
51
54
|
proxmoxManager;
|
|
55
|
+
hypervisorManager;
|
|
56
|
+
guardrailEngine;
|
|
57
|
+
telemetry;
|
|
52
58
|
constructor(options = {}) {
|
|
53
59
|
this.defaultDurationMinutes = options.defaultDurationMinutes ?? 60;
|
|
54
60
|
this.proxmoxManager = options.proxmoxManager;
|
|
61
|
+
this.hypervisorManager = options.hypervisorManager;
|
|
62
|
+
this.guardrailEngine = options.guardrailEngine || defaultSafetyGuardrailEngine;
|
|
63
|
+
this.telemetry = options.telemetryRegistry || telemetry;
|
|
55
64
|
const hostConfigs = options.hosts && options.hosts.length > 0
|
|
56
65
|
? options.hosts
|
|
57
66
|
: [...DEFAULT_HOST_CONFIGS];
|
|
@@ -62,6 +71,18 @@ export class SlotManager {
|
|
|
62
71
|
this.registerHost(host);
|
|
63
72
|
}
|
|
64
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Set or update hypervisor manager reference.
|
|
76
|
+
*/
|
|
77
|
+
setHypervisorManager(hypervisorManager) {
|
|
78
|
+
this.hypervisorManager = hypervisorManager;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Set or update safety guardrail engine reference.
|
|
82
|
+
*/
|
|
83
|
+
setGuardrailEngine(guardrailEngine) {
|
|
84
|
+
this.guardrailEngine = guardrailEngine;
|
|
85
|
+
}
|
|
65
86
|
/**
|
|
66
87
|
* Register a host and initialize its slot definitions.
|
|
67
88
|
*/
|
|
@@ -93,6 +114,11 @@ export class SlotManager {
|
|
|
93
114
|
ttlMinutes: null,
|
|
94
115
|
ttlSeconds: null,
|
|
95
116
|
tag: null,
|
|
117
|
+
label: null,
|
|
118
|
+
workloadType: null,
|
|
119
|
+
targetVersion: null,
|
|
120
|
+
branch: null,
|
|
121
|
+
customTags: [],
|
|
96
122
|
requester: null,
|
|
97
123
|
linearIssue: null,
|
|
98
124
|
sessionId: null,
|
|
@@ -112,6 +138,19 @@ export class SlotManager {
|
|
|
112
138
|
return "proxmox-mini";
|
|
113
139
|
return hostId;
|
|
114
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Normalizes tag inputs into array of strings.
|
|
143
|
+
*/
|
|
144
|
+
normalizeTagsArray(tags) {
|
|
145
|
+
if (!tags)
|
|
146
|
+
return [];
|
|
147
|
+
if (Array.isArray(tags))
|
|
148
|
+
return tags.map((t) => String(t).trim()).filter(Boolean);
|
|
149
|
+
if (typeof tags === "string") {
|
|
150
|
+
return tags.split(/[;, ]+/).map((t) => t.trim()).filter(Boolean);
|
|
151
|
+
}
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
115
154
|
/**
|
|
116
155
|
* Internal lazy expiration sweep (must be called inside mutex or synchronous method).
|
|
117
156
|
*/
|
|
@@ -121,6 +160,25 @@ export class SlotManager {
|
|
|
121
160
|
if ((slot.status === "CLAIMED" || slot.status === "active") && slot.expiresAt) {
|
|
122
161
|
const expiresTime = new Date(slot.expiresAt).getTime();
|
|
123
162
|
if (expiresTime <= now) {
|
|
163
|
+
// Emit expired telemetry event
|
|
164
|
+
this.telemetry.emit({
|
|
165
|
+
event: "slot.expired",
|
|
166
|
+
actor: slot.requester || slot.sessionId || "system",
|
|
167
|
+
status: "success",
|
|
168
|
+
linearIssue: slot.linearIssue || undefined,
|
|
169
|
+
slotId: slot.slotId,
|
|
170
|
+
claimId: slot.claimId || undefined,
|
|
171
|
+
vmId: slot.vmId || undefined,
|
|
172
|
+
vmName: slot.vmName || undefined,
|
|
173
|
+
hostId: slot.hostId,
|
|
174
|
+
hypervisorType: slot.hypervisorType,
|
|
175
|
+
metadata: {
|
|
176
|
+
expiredAt: new Date(now).toISOString(),
|
|
177
|
+
scheduledExpiresAt: slot.expiresAt,
|
|
178
|
+
label: slot.label,
|
|
179
|
+
workloadType: slot.workloadType
|
|
180
|
+
}
|
|
181
|
+
});
|
|
124
182
|
slot.status = "AVAILABLE";
|
|
125
183
|
slot.claimId = null;
|
|
126
184
|
slot.claimedAt = null;
|
|
@@ -129,6 +187,11 @@ export class SlotManager {
|
|
|
129
187
|
slot.ttlMinutes = null;
|
|
130
188
|
slot.ttlSeconds = null;
|
|
131
189
|
slot.tag = null;
|
|
190
|
+
slot.label = null;
|
|
191
|
+
slot.workloadType = null;
|
|
192
|
+
slot.targetVersion = null;
|
|
193
|
+
slot.branch = null;
|
|
194
|
+
slot.customTags = [];
|
|
132
195
|
slot.requester = null;
|
|
133
196
|
slot.linearIssue = null;
|
|
134
197
|
slot.sessionId = null;
|
|
@@ -259,6 +322,20 @@ export class SlotManager {
|
|
|
259
322
|
const now = new Date();
|
|
260
323
|
const claimedAt = now.toISOString();
|
|
261
324
|
const expiresAt = new Date(now.getTime() + durationMinutes * 60 * 1000).toISOString();
|
|
325
|
+
const linearIssue = request.linearIssue || (request.tag && request.tag.startsWith("CTUAT-") ? request.tag : null);
|
|
326
|
+
// Normalize custom tags
|
|
327
|
+
const customTags = [
|
|
328
|
+
...this.normalizeTagsArray(request.customTags),
|
|
329
|
+
...this.normalizeTagsArray(request.tags)
|
|
330
|
+
];
|
|
331
|
+
// Deduplicate tags
|
|
332
|
+
const uniqueTags = Array.from(new Set(customTags));
|
|
333
|
+
// Auto-assign human readable label if not provided
|
|
334
|
+
const label = request.label || (linearIssue
|
|
335
|
+
? `[${linearIssue}] ${request.workloadType || "workload"}: Slot ${targetSlot.slotIndex}`
|
|
336
|
+
: request.purpose
|
|
337
|
+
? `${request.purpose} (Slot ${targetSlot.slotIndex})`
|
|
338
|
+
: `${request.tag || tagInput} Lease`);
|
|
262
339
|
targetSlot.status = "CLAIMED";
|
|
263
340
|
targetSlot.claimId = claimId;
|
|
264
341
|
targetSlot.claimedAt = claimedAt;
|
|
@@ -267,11 +344,35 @@ export class SlotManager {
|
|
|
267
344
|
targetSlot.ttlMinutes = durationMinutes;
|
|
268
345
|
targetSlot.ttlSeconds = durationMinutes * 60;
|
|
269
346
|
targetSlot.tag = (request.tag || tagInput).trim();
|
|
347
|
+
targetSlot.label = label;
|
|
348
|
+
targetSlot.workloadType = request.workloadType || null;
|
|
349
|
+
targetSlot.targetVersion = request.targetVersion || null;
|
|
350
|
+
targetSlot.branch = request.branch || null;
|
|
351
|
+
targetSlot.customTags = uniqueTags;
|
|
270
352
|
targetSlot.requester = request.requester || request.tag || null;
|
|
271
|
-
targetSlot.linearIssue =
|
|
353
|
+
targetSlot.linearIssue = linearIssue;
|
|
272
354
|
targetSlot.sessionId = request.sessionId || null;
|
|
273
355
|
targetSlot.purpose = request.purpose || null;
|
|
274
356
|
targetSlot.metadata = request.metadata || {};
|
|
357
|
+
// Emit audit telemetry
|
|
358
|
+
this.telemetry.emit({
|
|
359
|
+
event: "slot.claim",
|
|
360
|
+
actor: targetSlot.requester || targetSlot.sessionId || "unknown",
|
|
361
|
+
status: "success",
|
|
362
|
+
linearIssue: linearIssue || undefined,
|
|
363
|
+
slotId: targetSlot.slotId,
|
|
364
|
+
claimId,
|
|
365
|
+
hostId: targetSlot.hostId,
|
|
366
|
+
hypervisorType: targetSlot.hypervisorType,
|
|
367
|
+
metadata: {
|
|
368
|
+
label: targetSlot.label,
|
|
369
|
+
workloadType: targetSlot.workloadType,
|
|
370
|
+
targetVersion: targetSlot.targetVersion,
|
|
371
|
+
branch: targetSlot.branch,
|
|
372
|
+
durationMinutes,
|
|
373
|
+
expiresAt
|
|
374
|
+
}
|
|
375
|
+
});
|
|
275
376
|
return {
|
|
276
377
|
claimId,
|
|
277
378
|
slotId: targetSlot.slotId,
|
|
@@ -283,6 +384,11 @@ export class SlotManager {
|
|
|
283
384
|
allocatedMemoryMb: targetSlot.memoryMb,
|
|
284
385
|
allocatedMemoryBytes: targetSlot.memoryMb * 1024 * 1024,
|
|
285
386
|
tag: targetSlot.tag,
|
|
387
|
+
label: targetSlot.label || undefined,
|
|
388
|
+
workloadType: targetSlot.workloadType || undefined,
|
|
389
|
+
targetVersion: targetSlot.targetVersion || undefined,
|
|
390
|
+
branch: targetSlot.branch || undefined,
|
|
391
|
+
customTags: targetSlot.customTags.length > 0 ? targetSlot.customTags : undefined,
|
|
286
392
|
requester: targetSlot.requester || undefined,
|
|
287
393
|
linearIssue: targetSlot.linearIssue || undefined,
|
|
288
394
|
claimedAt,
|
|
@@ -380,6 +486,23 @@ export class SlotManager {
|
|
|
380
486
|
if (destroyVm && slot.vmId !== null && slot.vmId !== undefined) {
|
|
381
487
|
destroyedVms.push(slot.vmId);
|
|
382
488
|
}
|
|
489
|
+
// Emit telemetry for released slot
|
|
490
|
+
this.telemetry.emit({
|
|
491
|
+
event: "slot.release",
|
|
492
|
+
actor: requester || sessionId || slot.requester || "unknown",
|
|
493
|
+
status: "success",
|
|
494
|
+
linearIssue: slot.linearIssue || undefined,
|
|
495
|
+
slotId: slot.slotId,
|
|
496
|
+
claimId: slot.claimId || undefined,
|
|
497
|
+
vmId: slot.vmId || undefined,
|
|
498
|
+
vmName: slot.vmName || undefined,
|
|
499
|
+
hostId: slot.hostId,
|
|
500
|
+
hypervisorType: slot.hypervisorType,
|
|
501
|
+
metadata: {
|
|
502
|
+
freedMemoryMb: slot.memoryMb,
|
|
503
|
+
destroyVmRequested: Boolean(destroyVm)
|
|
504
|
+
}
|
|
505
|
+
});
|
|
383
506
|
slot.status = "AVAILABLE";
|
|
384
507
|
slot.claimId = null;
|
|
385
508
|
slot.claimedAt = null;
|
|
@@ -388,6 +511,11 @@ export class SlotManager {
|
|
|
388
511
|
slot.ttlMinutes = null;
|
|
389
512
|
slot.ttlSeconds = null;
|
|
390
513
|
slot.tag = null;
|
|
514
|
+
slot.label = null;
|
|
515
|
+
slot.workloadType = null;
|
|
516
|
+
slot.targetVersion = null;
|
|
517
|
+
slot.branch = null;
|
|
518
|
+
slot.customTags = [];
|
|
391
519
|
slot.requester = null;
|
|
392
520
|
slot.linearIssue = null;
|
|
393
521
|
slot.sessionId = null;
|
|
@@ -438,6 +566,23 @@ export class SlotManager {
|
|
|
438
566
|
slot.durationMinutes = totalDurationMinutes;
|
|
439
567
|
slot.ttlMinutes = totalDurationMinutes;
|
|
440
568
|
slot.ttlSeconds = totalDurationMinutes * 60;
|
|
569
|
+
// Emit telemetry for extension
|
|
570
|
+
this.telemetry.emit({
|
|
571
|
+
event: "slot.extend",
|
|
572
|
+
actor: sessionId || slot.requester || "unknown",
|
|
573
|
+
status: "success",
|
|
574
|
+
linearIssue: slot.linearIssue || undefined,
|
|
575
|
+
slotId: slot.slotId,
|
|
576
|
+
claimId,
|
|
577
|
+
hostId: slot.hostId,
|
|
578
|
+
hypervisorType: slot.hypervisorType,
|
|
579
|
+
metadata: {
|
|
580
|
+
extendedByMinutes: additionalMinutes,
|
|
581
|
+
previousExpiresAt,
|
|
582
|
+
newExpiresAt,
|
|
583
|
+
totalDurationMinutes
|
|
584
|
+
}
|
|
585
|
+
});
|
|
441
586
|
return {
|
|
442
587
|
claimId,
|
|
443
588
|
slotId: slot.slotId,
|
|
@@ -451,7 +596,81 @@ export class SlotManager {
|
|
|
451
596
|
});
|
|
452
597
|
}
|
|
453
598
|
/**
|
|
454
|
-
*
|
|
599
|
+
* Update metadata, label, workload type, or tags on an active slot lease.
|
|
600
|
+
*/
|
|
601
|
+
async updateSlotMetadata(options) {
|
|
602
|
+
return this.mutex.runExclusive(async () => {
|
|
603
|
+
this.sweepExpiredSlotsInternal();
|
|
604
|
+
const { claimId, slotId } = options;
|
|
605
|
+
if (!claimId && !slotId) {
|
|
606
|
+
throw new InvalidSlotOperationError("Must provide 'claimId' or 'slotId' to update slot metadata.");
|
|
607
|
+
}
|
|
608
|
+
const slot = Array.from(this.slots.values()).find((s) => (claimId && s.claimId === claimId) || (slotId && s.slotId === slotId));
|
|
609
|
+
if (!slot || (slot.status !== "CLAIMED" && slot.status !== "active")) {
|
|
610
|
+
throw new SlotNotFoundError(`Active slot not found matching ${claimId ? `claimId '${claimId}'` : `slotId '${slotId}'`}.`);
|
|
611
|
+
}
|
|
612
|
+
const updatedFields = [];
|
|
613
|
+
if (options.label !== undefined) {
|
|
614
|
+
slot.label = options.label;
|
|
615
|
+
updatedFields.push("label");
|
|
616
|
+
}
|
|
617
|
+
if (options.workloadType !== undefined) {
|
|
618
|
+
slot.workloadType = options.workloadType;
|
|
619
|
+
updatedFields.push("workloadType");
|
|
620
|
+
}
|
|
621
|
+
if (options.targetVersion !== undefined) {
|
|
622
|
+
slot.targetVersion = options.targetVersion;
|
|
623
|
+
updatedFields.push("targetVersion");
|
|
624
|
+
}
|
|
625
|
+
if (options.branch !== undefined) {
|
|
626
|
+
slot.branch = options.branch;
|
|
627
|
+
updatedFields.push("branch");
|
|
628
|
+
}
|
|
629
|
+
if (options.customTags !== undefined) {
|
|
630
|
+
slot.customTags = this.normalizeTagsArray(options.customTags);
|
|
631
|
+
updatedFields.push("customTags");
|
|
632
|
+
}
|
|
633
|
+
if (options.linearIssue !== undefined) {
|
|
634
|
+
slot.linearIssue = options.linearIssue;
|
|
635
|
+
updatedFields.push("linearIssue");
|
|
636
|
+
}
|
|
637
|
+
if (options.purpose !== undefined) {
|
|
638
|
+
slot.purpose = options.purpose;
|
|
639
|
+
updatedFields.push("purpose");
|
|
640
|
+
}
|
|
641
|
+
if (options.metadata !== undefined) {
|
|
642
|
+
slot.metadata = { ...(slot.metadata || {}), ...options.metadata };
|
|
643
|
+
updatedFields.push("metadata");
|
|
644
|
+
}
|
|
645
|
+
// Emit telemetry for metadata update
|
|
646
|
+
this.telemetry.emit({
|
|
647
|
+
event: "slot.update_metadata",
|
|
648
|
+
actor: slot.requester || slot.sessionId || "unknown",
|
|
649
|
+
status: "success",
|
|
650
|
+
linearIssue: slot.linearIssue || undefined,
|
|
651
|
+
slotId: slot.slotId,
|
|
652
|
+
claimId: slot.claimId || undefined,
|
|
653
|
+
hostId: slot.hostId,
|
|
654
|
+
hypervisorType: slot.hypervisorType,
|
|
655
|
+
metadata: {
|
|
656
|
+
updatedFields,
|
|
657
|
+
label: slot.label,
|
|
658
|
+
workloadType: slot.workloadType,
|
|
659
|
+
targetVersion: slot.targetVersion,
|
|
660
|
+
branch: slot.branch,
|
|
661
|
+
customTags: slot.customTags
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
success: true,
|
|
666
|
+
slot: { ...slot },
|
|
667
|
+
updatedFields,
|
|
668
|
+
message: `Successfully updated ${updatedFields.length} metadata field(s) on slot ${slot.slotId}.`
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* List slots with comprehensive multi-field filtering and computed remaining TTL.
|
|
455
674
|
*/
|
|
456
675
|
async listSlots(options = {}) {
|
|
457
676
|
return this.mutex.runExclusive(async () => {
|
|
@@ -460,6 +679,10 @@ export class SlotManager {
|
|
|
460
679
|
const results = [];
|
|
461
680
|
const filterHost = options.host || options.targetHost;
|
|
462
681
|
const normalizedFilterHost = filterHost ? this.normalizeHostId(filterHost) : undefined;
|
|
682
|
+
const parsedTags = [
|
|
683
|
+
...this.normalizeTagsArray(options.tags),
|
|
684
|
+
...this.normalizeTagsArray(options.customTags)
|
|
685
|
+
];
|
|
463
686
|
for (const slot of this.slots.values()) {
|
|
464
687
|
if (normalizedFilterHost && slot.hostId !== normalizedFilterHost)
|
|
465
688
|
continue;
|
|
@@ -477,6 +700,51 @@ export class SlotManager {
|
|
|
477
700
|
slot.hypervisorType !== options.hypervisorType) {
|
|
478
701
|
continue;
|
|
479
702
|
}
|
|
703
|
+
// Structured metadata filters
|
|
704
|
+
if (options.workloadType && slot.workloadType !== options.workloadType)
|
|
705
|
+
continue;
|
|
706
|
+
if (options.targetVersion && slot.targetVersion !== options.targetVersion)
|
|
707
|
+
continue;
|
|
708
|
+
if (options.branch && slot.branch !== options.branch)
|
|
709
|
+
continue;
|
|
710
|
+
if (options.label && (!slot.label || !slot.label.toLowerCase().includes(options.label.toLowerCase()))) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
// Custom tags matching (must match at least one if specified)
|
|
714
|
+
if (parsedTags.length > 0) {
|
|
715
|
+
const slotAllTags = [
|
|
716
|
+
...(slot.customTags || []),
|
|
717
|
+
slot.tag,
|
|
718
|
+
slot.linearIssue
|
|
719
|
+
].filter(Boolean).map((t) => String(t).toLowerCase());
|
|
720
|
+
const matchesTag = parsedTags.some((pt) => slotAllTags.some((st) => st.includes(pt.toLowerCase())));
|
|
721
|
+
if (!matchesTag)
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
// Free-text filter search across all string metadata fields
|
|
725
|
+
if (options.filter) {
|
|
726
|
+
const q = options.filter.toLowerCase().trim();
|
|
727
|
+
const searchable = [
|
|
728
|
+
slot.slotId,
|
|
729
|
+
slot.hostId,
|
|
730
|
+
slot.tag,
|
|
731
|
+
slot.label,
|
|
732
|
+
slot.workloadType,
|
|
733
|
+
slot.targetVersion,
|
|
734
|
+
slot.branch,
|
|
735
|
+
slot.linearIssue,
|
|
736
|
+
slot.purpose,
|
|
737
|
+
slot.requester,
|
|
738
|
+
slot.vmName,
|
|
739
|
+
slot.vmId ? String(slot.vmId) : null,
|
|
740
|
+
...(slot.customTags || [])
|
|
741
|
+
]
|
|
742
|
+
.filter(Boolean)
|
|
743
|
+
.join(" ")
|
|
744
|
+
.toLowerCase();
|
|
745
|
+
if (!searchable.includes(q))
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
480
748
|
let remainingTtlSeconds = null;
|
|
481
749
|
if ((slot.status === "CLAIMED" || slot.status === "active") && slot.expiresAt) {
|
|
482
750
|
const diffMs = new Date(slot.expiresAt).getTime() - now;
|
|
@@ -586,6 +854,302 @@ export class SlotManager {
|
|
|
586
854
|
return this.sweepExpiredSlotsInternal();
|
|
587
855
|
});
|
|
588
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* Purge expired, threshold-exceeded, or orphaned slots and reclaim hypervisor VM capacity.
|
|
859
|
+
* Gated by SafetyGuardrailEngine non-bypassable protections.
|
|
860
|
+
*/
|
|
861
|
+
async purgeSlots(options = {}) {
|
|
862
|
+
return this.mutex.runExclusive(async () => {
|
|
863
|
+
const now = Date.now();
|
|
864
|
+
const dryRun = options.dryRun ?? false;
|
|
865
|
+
const destroyVms = options.destroyVms ?? true;
|
|
866
|
+
const force = options.force ?? false;
|
|
867
|
+
const includeOrphans = options.includeOrphans ?? false;
|
|
868
|
+
const targetHost = options.host && options.host !== "all" ? this.normalizeHostId(options.host) : undefined;
|
|
869
|
+
const targetHypervisor = options.hypervisorType && options.hypervisorType !== "all" && options.hypervisorType !== "any"
|
|
870
|
+
? options.hypervisorType
|
|
871
|
+
: undefined;
|
|
872
|
+
const matchingSlots = [];
|
|
873
|
+
const reasons = new Map();
|
|
874
|
+
for (const slot of this.slots.values()) {
|
|
875
|
+
if (slot.status !== "CLAIMED" && slot.status !== "active")
|
|
876
|
+
continue;
|
|
877
|
+
if (targetHost && slot.hostId !== targetHost)
|
|
878
|
+
continue;
|
|
879
|
+
if (targetHypervisor && slot.hypervisorType !== targetHypervisor)
|
|
880
|
+
continue;
|
|
881
|
+
let shouldPurge = false;
|
|
882
|
+
let reason = "manual_purge";
|
|
883
|
+
const expiresTime = slot.expiresAt ? new Date(slot.expiresAt).getTime() : 0;
|
|
884
|
+
const claimedTime = slot.claimedAt ? new Date(slot.claimedAt).getTime() : 0;
|
|
885
|
+
const isExpired = expiresTime > 0 && expiresTime <= now;
|
|
886
|
+
if (options.linearIssue) {
|
|
887
|
+
const matchesLinear = slot.linearIssue === options.linearIssue || slot.tag === options.linearIssue;
|
|
888
|
+
if (matchesLinear) {
|
|
889
|
+
shouldPurge = true;
|
|
890
|
+
reason = isExpired ? "expired" : "linear_match";
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
else if (options.olderThanMinutes !== undefined) {
|
|
894
|
+
const ageMinutes = (now - claimedTime) / (60 * 1000);
|
|
895
|
+
if (ageMinutes >= options.olderThanMinutes || isExpired) {
|
|
896
|
+
shouldPurge = true;
|
|
897
|
+
reason = isExpired ? "expired" : "older_than_threshold";
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
else if (isExpired) {
|
|
901
|
+
shouldPurge = true;
|
|
902
|
+
reason = "expired";
|
|
903
|
+
}
|
|
904
|
+
else if (force) {
|
|
905
|
+
shouldPurge = true;
|
|
906
|
+
reason = "forced";
|
|
907
|
+
}
|
|
908
|
+
if (shouldPurge) {
|
|
909
|
+
matchingSlots.push(slot);
|
|
910
|
+
reasons.set(slot.slotId, reason);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const purgedSlots = [];
|
|
914
|
+
const destroyedVms = [];
|
|
915
|
+
let skippedProtectedCount = 0;
|
|
916
|
+
let reclaimedMemoryMb = 0;
|
|
917
|
+
for (const slot of matchingSlots) {
|
|
918
|
+
const reason = reasons.get(slot.slotId) || "manual_purge";
|
|
919
|
+
purgedSlots.push({
|
|
920
|
+
slotId: slot.slotId,
|
|
921
|
+
hostId: slot.hostId,
|
|
922
|
+
claimId: slot.claimId,
|
|
923
|
+
claimedAt: slot.claimedAt,
|
|
924
|
+
expiresAt: slot.expiresAt,
|
|
925
|
+
linearIssue: slot.linearIssue,
|
|
926
|
+
label: slot.label,
|
|
927
|
+
workloadType: slot.workloadType,
|
|
928
|
+
vmId: slot.vmId,
|
|
929
|
+
vmName: slot.vmName,
|
|
930
|
+
reason
|
|
931
|
+
});
|
|
932
|
+
reclaimedMemoryMb += slot.memoryMb;
|
|
933
|
+
// Process attached VM cleanup if present
|
|
934
|
+
if (slot.vmId !== null && slot.vmId !== undefined) {
|
|
935
|
+
const vmTarget = {
|
|
936
|
+
vmid: slot.vmId,
|
|
937
|
+
name: slot.vmName || undefined,
|
|
938
|
+
isProtected: false
|
|
939
|
+
};
|
|
940
|
+
// Guardrail safety check
|
|
941
|
+
const protCheck = this.guardrailEngine.isProtected(vmTarget);
|
|
942
|
+
if (protCheck.protected) {
|
|
943
|
+
skippedProtectedCount++;
|
|
944
|
+
destroyedVms.push({
|
|
945
|
+
vmId: slot.vmId,
|
|
946
|
+
vmName: slot.vmName || undefined,
|
|
947
|
+
hostId: slot.hostId,
|
|
948
|
+
hypervisorType: slot.hypervisorType,
|
|
949
|
+
status: "skipped_protected",
|
|
950
|
+
reason: `Protected infrastructure preserved: ${protCheck.reason}`
|
|
951
|
+
});
|
|
952
|
+
this.telemetry.emit({
|
|
953
|
+
event: "vm.guardrail_blocked",
|
|
954
|
+
actor: slot.requester || "system",
|
|
955
|
+
status: "blocked",
|
|
956
|
+
linearIssue: slot.linearIssue || undefined,
|
|
957
|
+
vmId: slot.vmId,
|
|
958
|
+
vmName: slot.vmName || undefined,
|
|
959
|
+
hostId: slot.hostId,
|
|
960
|
+
hypervisorType: slot.hypervisorType,
|
|
961
|
+
error: `Guardrail blocked purge destruction: ${protCheck.reason}`
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
if (dryRun) {
|
|
966
|
+
destroyedVms.push({
|
|
967
|
+
vmId: slot.vmId,
|
|
968
|
+
vmName: slot.vmName || undefined,
|
|
969
|
+
hostId: slot.hostId,
|
|
970
|
+
hypervisorType: slot.hypervisorType,
|
|
971
|
+
status: "dry_run",
|
|
972
|
+
reason: `Would be destroyed during purge (${reason})`
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
else if (destroyVms && this.hypervisorManager) {
|
|
976
|
+
try {
|
|
977
|
+
await this.hypervisorManager.vmAction({
|
|
978
|
+
vmIdOrName: String(slot.vmId),
|
|
979
|
+
targetHost: slot.hostId,
|
|
980
|
+
action: "destroy",
|
|
981
|
+
force: true
|
|
982
|
+
});
|
|
983
|
+
destroyedVms.push({
|
|
984
|
+
vmId: slot.vmId,
|
|
985
|
+
vmName: slot.vmName || undefined,
|
|
986
|
+
hostId: slot.hostId,
|
|
987
|
+
hypervisorType: slot.hypervisorType,
|
|
988
|
+
status: "destroyed",
|
|
989
|
+
reason: `Purged and destroyed attached VM (${reason})`
|
|
990
|
+
});
|
|
991
|
+
this.telemetry.emit({
|
|
992
|
+
event: "vm.purged",
|
|
993
|
+
actor: slot.requester || "system",
|
|
994
|
+
status: "success",
|
|
995
|
+
linearIssue: slot.linearIssue || undefined,
|
|
996
|
+
vmId: slot.vmId,
|
|
997
|
+
vmName: slot.vmName || undefined,
|
|
998
|
+
hostId: slot.hostId,
|
|
999
|
+
hypervisorType: slot.hypervisorType,
|
|
1000
|
+
metadata: { slotId: slot.slotId, reason }
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
destroyedVms.push({
|
|
1005
|
+
vmId: slot.vmId,
|
|
1006
|
+
vmName: slot.vmName || undefined,
|
|
1007
|
+
hostId: slot.hostId,
|
|
1008
|
+
hypervisorType: slot.hypervisorType,
|
|
1009
|
+
status: "failed",
|
|
1010
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// If not a dry run, reset slot state
|
|
1017
|
+
if (!dryRun) {
|
|
1018
|
+
this.telemetry.emit({
|
|
1019
|
+
event: "slot.purge",
|
|
1020
|
+
actor: slot.requester || "system",
|
|
1021
|
+
status: "success",
|
|
1022
|
+
linearIssue: slot.linearIssue || undefined,
|
|
1023
|
+
slotId: slot.slotId,
|
|
1024
|
+
claimId: slot.claimId || undefined,
|
|
1025
|
+
vmId: slot.vmId || undefined,
|
|
1026
|
+
vmName: slot.vmName || undefined,
|
|
1027
|
+
hostId: slot.hostId,
|
|
1028
|
+
hypervisorType: slot.hypervisorType,
|
|
1029
|
+
metadata: {
|
|
1030
|
+
reason,
|
|
1031
|
+
freedMemoryMb: slot.memoryMb
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
slot.status = "AVAILABLE";
|
|
1035
|
+
slot.claimId = null;
|
|
1036
|
+
slot.claimedAt = null;
|
|
1037
|
+
slot.expiresAt = null;
|
|
1038
|
+
slot.durationMinutes = null;
|
|
1039
|
+
slot.ttlMinutes = null;
|
|
1040
|
+
slot.ttlSeconds = null;
|
|
1041
|
+
slot.tag = null;
|
|
1042
|
+
slot.label = null;
|
|
1043
|
+
slot.workloadType = null;
|
|
1044
|
+
slot.targetVersion = null;
|
|
1045
|
+
slot.branch = null;
|
|
1046
|
+
slot.customTags = [];
|
|
1047
|
+
slot.requester = null;
|
|
1048
|
+
slot.linearIssue = null;
|
|
1049
|
+
slot.sessionId = null;
|
|
1050
|
+
slot.purpose = null;
|
|
1051
|
+
slot.vmId = null;
|
|
1052
|
+
slot.vmName = null;
|
|
1053
|
+
slot.metadata = {};
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Check orphan VMs if requested
|
|
1057
|
+
if (includeOrphans && this.hypervisorManager) {
|
|
1058
|
+
try {
|
|
1059
|
+
const allVms = await this.hypervisorManager.listUnifiedInventory({
|
|
1060
|
+
includeTemplates: false,
|
|
1061
|
+
host: targetHost,
|
|
1062
|
+
hypervisor: targetHypervisor
|
|
1063
|
+
});
|
|
1064
|
+
const activeVmIds = new Set(Array.from(this.slots.values())
|
|
1065
|
+
.map((s) => s.vmId)
|
|
1066
|
+
.filter((id) => id !== null && id !== undefined)
|
|
1067
|
+
.map((id) => String(id)));
|
|
1068
|
+
for (const vm of allVms) {
|
|
1069
|
+
// Check if VM is an untracked disposable VM without active lease
|
|
1070
|
+
if (!vm.isTemplate && !vm.isProtected && !activeVmIds.has(String(vm.id))) {
|
|
1071
|
+
const protCheck = this.guardrailEngine.isProtected({
|
|
1072
|
+
vmid: vm.id,
|
|
1073
|
+
name: vm.name,
|
|
1074
|
+
isProtected: vm.isProtected,
|
|
1075
|
+
tags: vm.tags
|
|
1076
|
+
});
|
|
1077
|
+
if (protCheck.protected) {
|
|
1078
|
+
skippedProtectedCount++;
|
|
1079
|
+
destroyedVms.push({
|
|
1080
|
+
vmId: vm.id,
|
|
1081
|
+
vmName: vm.name,
|
|
1082
|
+
hostId: vm.hostName,
|
|
1083
|
+
hypervisorType: vm.hypervisor,
|
|
1084
|
+
status: "skipped_protected",
|
|
1085
|
+
reason: `Protected orphan preserved: ${protCheck.reason}`
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
if (dryRun) {
|
|
1090
|
+
destroyedVms.push({
|
|
1091
|
+
vmId: vm.id,
|
|
1092
|
+
vmName: vm.name,
|
|
1093
|
+
hostId: vm.hostName,
|
|
1094
|
+
hypervisorType: vm.hypervisor,
|
|
1095
|
+
status: "dry_run",
|
|
1096
|
+
reason: "Orphaned disposable VM with no active slot lease"
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
else if (destroyVms) {
|
|
1100
|
+
try {
|
|
1101
|
+
await this.hypervisorManager.vmAction({
|
|
1102
|
+
vmIdOrName: String(vm.id),
|
|
1103
|
+
targetHost: vm.hostName,
|
|
1104
|
+
action: "destroy",
|
|
1105
|
+
force: true
|
|
1106
|
+
});
|
|
1107
|
+
destroyedVms.push({
|
|
1108
|
+
vmId: vm.id,
|
|
1109
|
+
vmName: vm.name,
|
|
1110
|
+
hostId: vm.hostName,
|
|
1111
|
+
hypervisorType: vm.hypervisor,
|
|
1112
|
+
status: "destroyed",
|
|
1113
|
+
reason: "Destroyed orphaned disposable VM with no active slot lease"
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
catch (orphanErr) {
|
|
1117
|
+
destroyedVms.push({
|
|
1118
|
+
vmId: vm.id,
|
|
1119
|
+
vmName: vm.name,
|
|
1120
|
+
hostId: vm.hostName,
|
|
1121
|
+
hypervisorType: vm.hypervisor,
|
|
1122
|
+
status: "failed",
|
|
1123
|
+
error: orphanErr instanceof Error ? orphanErr.message : String(orphanErr)
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
catch (invErr) {
|
|
1132
|
+
console.error("[purgeSlots] Orphan inventory inspection error:", invErr);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
const destroyedCount = destroyedVms.filter((v) => v.status === "destroyed").length;
|
|
1136
|
+
const reclaimedMemoryBytes = reclaimedMemoryMb * 1024 * 1024;
|
|
1137
|
+
const prefix = dryRun ? "[DRY RUN] " : "";
|
|
1138
|
+
const message = `${prefix}Purged ${purgedSlots.length} slot lease(s), reclaimed ${reclaimedMemoryMb / 1024} GB RAM, processed ${destroyedVms.length} VM(s) (${destroyedCount} destroyed, ${skippedProtectedCount} protected skipped).`;
|
|
1139
|
+
return {
|
|
1140
|
+
purgedSlotsCount: purgedSlots.length,
|
|
1141
|
+
purgedSlots,
|
|
1142
|
+
destroyedVmsCount: destroyedCount,
|
|
1143
|
+
destroyedVms,
|
|
1144
|
+
skippedProtectedVmsCount: skippedProtectedCount,
|
|
1145
|
+
reclaimedMemoryBytes,
|
|
1146
|
+
reclaimedMemoryMb,
|
|
1147
|
+
dryRun,
|
|
1148
|
+
timestamp: new Date(now).toISOString(),
|
|
1149
|
+
message
|
|
1150
|
+
};
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
589
1153
|
/**
|
|
590
1154
|
* Attach a created VMID or VM name to a claimed slot.
|
|
591
1155
|
*/
|
|
@@ -636,6 +1200,11 @@ export class SlotManager {
|
|
|
636
1200
|
slot.ttlMinutes = null;
|
|
637
1201
|
slot.ttlSeconds = null;
|
|
638
1202
|
slot.tag = null;
|
|
1203
|
+
slot.label = null;
|
|
1204
|
+
slot.workloadType = null;
|
|
1205
|
+
slot.targetVersion = null;
|
|
1206
|
+
slot.branch = null;
|
|
1207
|
+
slot.customTags = [];
|
|
639
1208
|
slot.requester = null;
|
|
640
1209
|
slot.linearIssue = null;
|
|
641
1210
|
slot.sessionId = null;
|