|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// session-sentinel :: src/interface/ffi/src/verisimdb.zig |
| 5 | +// |
| 6 | +// VeriSimDB persistence client for health zone history and scan records. |
| 7 | +// |
| 8 | +// Persists session-sentinel health readings to VeriSimDB under the collection |
| 9 | +// `session-sentinel:health` so that Hypatia rules can detect degradation |
| 10 | +// trends across reboots and gitbot-fleet can act on Purple-zone breaches. |
| 11 | +// |
| 12 | +// ## Collection schema (session-sentinel:health) |
| 13 | +// |
| 14 | +// ```json |
| 15 | +// { |
| 16 | +// "reading_id": "ss:1740000000000:abc123", |
| 17 | +// "timestamp": "2026-01-30T12:00:00Z", |
| 18 | +// "bytes_used": 524288000, |
| 19 | +// "health_zone": "Yellow", |
| 20 | +// "trend": "degrading", |
| 21 | +// "flash_check": false, |
| 22 | +// "hostname": "hydra" |
| 23 | +// } |
| 24 | +// ``` |
| 25 | +// |
| 26 | +// ## Environment |
| 27 | +// |
| 28 | +// Set `VERISIMDB_URL` to override the default `http://localhost:8080`. |
| 29 | +// |
| 30 | +// ## Fail-open semantics |
| 31 | +// |
| 32 | +// VeriSimDB is best-effort. Health readings are logged locally first; |
| 33 | +// VeriSimDB persistence is additional durable storage for cross-reboot |
| 34 | +// analysis. If VeriSimDB is unreachable, `persistReading` returns an error |
| 35 | +// and the caller continues with local-only storage. |
| 36 | + |
| 37 | +const std = @import("std"); |
| 38 | + |
| 39 | +// --------------------------------------------------------------------------- |
| 40 | +// Constants |
| 41 | +// --------------------------------------------------------------------------- |
| 42 | + |
| 43 | +const DEFAULT_URL = "http://localhost:8080"; |
| 44 | +const COLLECTION = "session-sentinel:health"; |
| 45 | + |
| 46 | +// --------------------------------------------------------------------------- |
| 47 | +// Public types |
| 48 | +// --------------------------------------------------------------------------- |
| 49 | + |
| 50 | +/// Health zones matching the Green/Yellow/Red/Purple classification in health.as. |
| 51 | +pub const HealthZone = enum { |
| 52 | + Green, |
| 53 | + Yellow, |
| 54 | + Red, |
| 55 | + Purple, |
| 56 | +}; |
| 57 | + |
| 58 | +/// Trend direction from health.as trend analysis. |
| 59 | +pub const Trend = enum { |
| 60 | + improving, |
| 61 | + stable, |
| 62 | + degrading, |
| 63 | +}; |
| 64 | + |
| 65 | +/// A single health reading for VeriSimDB persistence. |
| 66 | +pub const HealthReading = struct { |
| 67 | + reading_id: []const u8, |
| 68 | + timestamp: []const u8, |
| 69 | + bytes_used: u64, |
| 70 | + zone: HealthZone, |
| 71 | + trend: Trend, |
| 72 | + flash_check: bool, |
| 73 | + hostname: []const u8, |
| 74 | +}; |
| 75 | + |
| 76 | +// --------------------------------------------------------------------------- |
| 77 | +// Public API |
| 78 | +// --------------------------------------------------------------------------- |
| 79 | + |
| 80 | +/// Persist a health reading to VeriSimDB (collection: session-sentinel:health). |
| 81 | +/// |
| 82 | +/// Uses HTTP PUT to `/v1/session-sentinel:health/<reading_id>`. |
| 83 | +/// Returns an error on connectivity failure — caller should log and continue. |
| 84 | +pub fn persistReading(allocator: std.mem.Allocator, reading: HealthReading) !void { |
| 85 | + const base_url = std.posix.getenv("VERISIMDB_URL") orelse DEFAULT_URL; |
| 86 | + |
| 87 | + const url = try std.fmt.allocPrint(allocator, "{s}/v1/{s}/{s}", .{ |
| 88 | + base_url, COLLECTION, reading.reading_id, |
| 89 | + }); |
| 90 | + defer allocator.free(url); |
| 91 | + |
| 92 | + const zone_str = @tagName(reading.zone); |
| 93 | + const trend_str = @tagName(reading.trend); |
| 94 | + const flash_str = if (reading.flash_check) "true" else "false"; |
| 95 | + |
| 96 | + const body = try std.fmt.allocPrint(allocator, |
| 97 | + \\{{ |
| 98 | + \\ "reading_id": "{s}", |
| 99 | + \\ "timestamp": "{s}", |
| 100 | + \\ "bytes_used": {d}, |
| 101 | + \\ "health_zone": "{s}", |
| 102 | + \\ "trend": "{s}", |
| 103 | + \\ "flash_check": {s}, |
| 104 | + \\ "hostname": "{s}" |
| 105 | + \\}} |
| 106 | + , .{ |
| 107 | + reading.reading_id, |
| 108 | + reading.timestamp, |
| 109 | + reading.bytes_used, |
| 110 | + zone_str, |
| 111 | + trend_str, |
| 112 | + flash_str, |
| 113 | + reading.hostname, |
| 114 | + }); |
| 115 | + defer allocator.free(body); |
| 116 | + |
| 117 | + try httpPut(allocator, url, body); |
| 118 | +} |
| 119 | + |
| 120 | +/// Generate a stable reading ID from hostname and timestamp milliseconds. |
| 121 | +/// |
| 122 | +/// Format: `ss:<ts_ms>:<first_8_of_hostname_sha256_hex>` |
| 123 | +pub fn makeReadingId(allocator: std.mem.Allocator, hostname: []const u8, ts_ms: u64) ![]u8 { |
| 124 | + var hash_buf: [32]u8 = undefined; |
| 125 | + std.crypto.hash.sha2.Sha256.hash(hostname, &hash_buf, .{}); |
| 126 | + const hex = try std.fmt.allocPrint(allocator, "{}", .{std.fmt.fmtSliceHexLower(hash_buf[0..4])}); |
| 127 | + defer allocator.free(hex); |
| 128 | + return std.fmt.allocPrint(allocator, "ss:{d}:{s}", .{ ts_ms, hex }); |
| 129 | +} |
| 130 | + |
| 131 | +// --------------------------------------------------------------------------- |
| 132 | +// Internal helpers |
| 133 | +// --------------------------------------------------------------------------- |
| 134 | + |
| 135 | +fn httpPut(allocator: std.mem.Allocator, url: []const u8, body: []const u8) !void { |
| 136 | + var client = std.http.Client{ .allocator = allocator }; |
| 137 | + defer client.deinit(); |
| 138 | + |
| 139 | + const uri = try std.Uri.parse(url); |
| 140 | + |
| 141 | + var header_buf: [4096]u8 = undefined; |
| 142 | + var req = try client.open(.PUT, uri, .{ |
| 143 | + .server_header_buffer = &header_buf, |
| 144 | + .extra_headers = &.{ |
| 145 | + .{ .name = "Content-Type", .value = "application/json" }, |
| 146 | + }, |
| 147 | + }); |
| 148 | + defer req.deinit(); |
| 149 | + |
| 150 | + req.transfer_encoding = .{ .content_length = body.len }; |
| 151 | + try req.send(); |
| 152 | + try req.writeAll(body); |
| 153 | + try req.finish(); |
| 154 | + try req.wait(); |
| 155 | + |
| 156 | + const status = req.response.status; |
| 157 | + if (@intFromEnum(status) < 200 or @intFromEnum(status) >= 300) { |
| 158 | + return error.VeriSimDbHttpError; |
| 159 | + } |
| 160 | +} |
0 commit comments