Add configurable history and storage retention policies

This commit is contained in:
2026-10-05 22:48:04 -04:00
parent 7c98df4fb0
commit 3952c4c938
37 changed files with 963 additions and 82 deletions
+10 -22
View File
@@ -1,3 +1,4 @@
import { pruneHistory } from '../retention.js';
import { recordAgentReceipt } from '../provisioning/agent-events.js';
import { EventEmitter } from 'node:events';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
@@ -357,8 +358,8 @@ function recordResources(hostId: string, inventory: HostInventory, timestamp: st
(host_id,sampled_at,cpu_count,cpu_usage_percent,memory_total,memory_available) VALUES(?,?,?,?,?,?)`)
.run(hostId, timestamp, inventory.cpuCount ?? null, inventory.cpuUsagePercent ?? null,
inventory.memoryTotal ?? null, inventory.memoryAvailable ?? null);
database().prepare('DELETE FROM ec_host_resource_samples WHERE host_id=? AND sampled_at<?')
.run(hostId, new Date(Date.parse(timestamp) - 7 * 86400_000).toISOString());
if (fleetSettings().hostResourceKeepDays) database().prepare('DELETE FROM ec_host_resource_samples WHERE host_id=? AND sampled_at<?')
.run(hostId, new Date(Date.parse(timestamp) - fleetSettings().hostResourceKeepDays * 86400_000).toISOString());
}
export function requireReadyHost(hostId: string, options: { libraryId?: string; requiredBytes?: number; image?: string; port?: number; bindAddress?: string; protocol?: 'udp' | 'tcp' } = {}): Host {
const host = requireHost(hostId); const reasons = [...host.readinessReasons];
@@ -391,7 +392,7 @@ export function getHostOperationStatus(id: string): HostOperationStatus | null {
export function listHostOperations(hostId: string, limit = 100, summary = false): HostOperation[] {
const columns = summary ? `o.id,o.host_id,o.kind,o.instance_id,o.generation,o.state,o.error,o.created_at,o.updated_at,'{}' AS payload,NULL AS result`
: `${operationStatusColumns},${operationPayloadSql} AS payload`;
return (database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE o.host_id=? ORDER BY o.created_at DESC,o.rowid DESC LIMIT ?`)
return (database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE o.host_id=? AND o.history_expired_at IS NULL ORDER BY o.created_at DESC,o.rowid DESC LIMIT ?`)
.all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow);
}
/** Browse retained history without loading command payloads or credential-bearing results. */
@@ -399,6 +400,7 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
const handle = database();
const pageSize = 25;
const conditions = filter === 'all' ? [] : [filter === 'routine' ? 'o.routine=1' : `(${importantOperationSql})`];
conditions.push('o.history_expired_at IS NULL');
const parameters: string[] = [];
if (scope.host) { conditions.push('o.host_id=?'); parameters.push(scope.host); }
if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); }
@@ -423,22 +425,7 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
}
/** Bound each cleanup pass so retained history cannot stall monitoring or receipts. */
export function pruneHostObservations(): number {
const days = fleetSettings().observationKeepDays;
if (!Number.isSafeInteger(days) || days <= 0 || days > 36500) return 0;
const handle = database();
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
return handle.transaction(() => {
const rows = handle.prepare(`SELECT id FROM ec_host_operations WHERE observation_success=1 AND state='completed'
AND updated_at<? ORDER BY updated_at LIMIT 500`).all(cutoff) as { id: string }[];
if (!rows.length) return 0;
// Fixed SQL text prevents each batch size from adding three cached statements.
const ids = JSON.stringify(rows.map(row => row.id));
handle.prepare('DELETE FROM ec_host_operation_events WHERE operation_id IN (SELECT value FROM json_each(?))').run(ids);
handle.prepare('DELETE FROM ec_host_operation_payloads WHERE operation_id IN (SELECT value FROM json_each(?))').run(ids);
return handle.prepare('DELETE FROM ec_host_operations WHERE id IN (SELECT value FROM json_each(?))').run(ids).changes;
})();
}
export function pruneHostObservations(): number { return pruneHistory('observation'); }
/** Heartbeats can tell an executing create to stop at its next safe command boundary. */
export function pendingCreationCancellations(hostId: string): string[] {
return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations
@@ -464,11 +451,12 @@ function enqueueHostOperation(hostId: string, kind: string, payload: string | Bu
if (options.generation !== undefined && (!Number.isSafeInteger(options.generation) || options.generation < 1)) throw new Error('Instance generation must be a positive integer.');
const operation = database().transaction(() => {
if (options.idempotencyKey) {
const existing = database().prepare(`SELECT o.id,o.kind,o.instance_id,o.generation,${operationPayloadSql}=CAST(? AS TEXT) AS same_payload
const existing = database().prepare(`SELECT o.id,o.kind,o.instance_id,o.generation,o.history_expired_at,o.request_hash,${operationPayloadSql}=CAST(? AS TEXT) AS same_payload
FROM ec_host_operations o WHERE o.host_id=? AND o.idempotency_key=?`).get(payload, hostId, options.idempotencyKey) as
{ id: string; kind: string; instance_id: string | null; generation: number | null; same_payload: number } | undefined;
{ id: string; kind: string; instance_id: string | null; generation: number | null; history_expired_at: string | null; request_hash: string | null; same_payload: number } | undefined;
if (existing) {
if (existing.kind !== kind || existing.instance_id !== (options.instanceId ?? null) || existing.generation !== (options.generation ?? null) || !existing.same_payload) throw new Error('Operation idempotency key is already associated with a different request.');
const samePayload = existing.history_expired_at ? existing.request_hash === createHash('sha256').update(payload).digest('hex') : !!existing.same_payload;
if (existing.kind !== kind || existing.instance_id !== (options.instanceId ?? null) || existing.generation !== (options.generation ?? null) || !samePayload) throw new Error('Operation idempotency key is already associated with a different request.');
return getHostOperationStatus(existing.id)!;
}
}