mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-08-13 07:00:35 +00:00
- Add ene-node, ene-rds-chat, ene-rds-ephemeral, ene-rds-wiki, ene-storage, ene-sync crates to the ene-rds workspace - Extend ene-rds-core with shared types and cluster messaging - Add review doc and wiki page for the RDS Rust workspace - Fix devcontainer: custom /etc/passwd with researcher user, add gcc/glibc/binutils for VS Code server compatibility, include util-linux, gnutar, gzip; add LD_LIBRARY_PATH - Ignore Nix build output (`result`) and Rust artifacts Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
404 lines
13 KiB
Rust
404 lines
13 KiB
Rust
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
// ── constants ──────────────────────────────────────────────────────────────
|
|
|
|
pub const SCHEMA: &str = "storage_agent_receipt_v1";
|
|
pub const VERSION: &str = "1.0.0";
|
|
pub const GARAGE_ENDPOINT: &str = "http://localhost:3900";
|
|
pub const GARAGE_BUCKET: &str = "research-stack";
|
|
pub const RECEIPT_PREFIX: &str = "agent-receipts";
|
|
pub const Q16_ONE: u32 = 0x0001_0000;
|
|
pub const Q16_DEDUP_LOW: u32 = 19_661; // 0.3
|
|
|
|
// ── config / env loading ───────────────────────────────────────────────────
|
|
|
|
pub fn load_garage_env() -> HashMap<String, String> {
|
|
let mut env: HashMap<String, String> = HashMap::new();
|
|
let garage_env = PathBuf::from("/etc/garage/garage.env");
|
|
if garage_env.exists() {
|
|
if let Ok(text) = std::fs::read_to_string(&garage_env) {
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
env.insert(k.trim().to_string(), v.trim().to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut creds = HashMap::new();
|
|
creds.insert(
|
|
"AWS_ACCESS_KEY_ID".into(),
|
|
env.get("GARAGE_ACCESS_KEY_ID")
|
|
.cloned()
|
|
.or_else(|| std::env::var("AWS_ACCESS_KEY_ID").ok())
|
|
.unwrap_or_default(),
|
|
);
|
|
creds.insert(
|
|
"AWS_SECRET_ACCESS_KEY".into(),
|
|
env.get("GARAGE_SECRET_ACCESS_KEY")
|
|
.cloned()
|
|
.or_else(|| std::env::var("AWS_SECRET_ACCESS_KEY").ok())
|
|
.unwrap_or_default(),
|
|
);
|
|
creds.insert(
|
|
"AWS_DEFAULT_REGION".into(),
|
|
env.get("AWS_DEFAULT_REGION")
|
|
.cloned()
|
|
.or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
|
|
.unwrap_or_else(|| "garage".into()),
|
|
);
|
|
creds.insert(
|
|
"AWS_ENDPOINT_URL".into(),
|
|
env.get("AWS_ENDPOINT_URL")
|
|
.cloned()
|
|
.or_else(|| std::env::var("AWS_ENDPOINT_URL").ok())
|
|
.unwrap_or_else(|| GARAGE_ENDPOINT.into()),
|
|
);
|
|
creds.insert(
|
|
"RESTIC_REPOSITORY".into(),
|
|
std::env::var("RESTIC_REPOSITORY")
|
|
.ok()
|
|
.unwrap_or_else(|| "s3:http://localhost:3900/research-stack".into()),
|
|
);
|
|
creds.insert(
|
|
"RESTIC_PASSWORD_FILE".into(),
|
|
std::env::var("RESTIC_PASSWORD_FILE")
|
|
.ok()
|
|
.unwrap_or_else(|| "/etc/garage/restic-password".into()),
|
|
);
|
|
creds
|
|
}
|
|
|
|
// ── Observation ────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
pub struct GarageObs {
|
|
pub up: bool,
|
|
pub nodes_total: i64,
|
|
pub nodes_ok: i64,
|
|
pub buckets: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
pub struct ResticObs {
|
|
pub snapshot_count: i64,
|
|
pub latest_ts: Option<String>,
|
|
pub latest_size_bytes: i64,
|
|
pub stored_bytes: i64,
|
|
pub dedup_ratio_q16: u32,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
pub struct BackupLogObs {
|
|
pub last_ok: bool,
|
|
pub last_ts: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
pub struct Observation {
|
|
pub ts: String,
|
|
pub garage: GarageObs,
|
|
pub restic: ResticObs,
|
|
pub backup_log: BackupLogObs,
|
|
pub cold_copy_needed: bool,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
impl Observation {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
ts: Utc::now().to_rfc3339(),
|
|
garage: GarageObs::default(),
|
|
restic: ResticObs::default(),
|
|
backup_log: BackupLogObs::default(),
|
|
cold_copy_needed: false,
|
|
errors: vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Decision ───────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
pub struct Decision {
|
|
pub trigger_snap: bool,
|
|
pub trigger_cold_copy: bool,
|
|
pub trigger_verify: bool,
|
|
pub trigger_forget: bool,
|
|
pub trigger_offload: bool,
|
|
pub trigger_garage_restart: bool,
|
|
pub alerts: Vec<String>,
|
|
pub rationale: Vec<String>,
|
|
}
|
|
|
|
pub fn decide(obs: &Observation) -> Decision {
|
|
let mut d = Decision::default();
|
|
|
|
if !obs.garage.up {
|
|
d.alerts.push("ALERT: Garage S3 is unreachable".into());
|
|
d.trigger_garage_restart = true;
|
|
d.rationale.push("garage_up=false → trigger_garage_restart".into());
|
|
}
|
|
|
|
if obs.restic.snapshot_count == 0 && obs.garage.up {
|
|
d.alerts.push("ALERT: restic repo has zero snapshots — initial backup needed".into());
|
|
d.trigger_snap = true;
|
|
d.rationale.push("snapshot_count=0 → trigger_snap".into());
|
|
}
|
|
|
|
if !obs.backup_log.last_ok && obs.garage.up {
|
|
d.alerts.push("WARN: No successful restic snapshot found in backup log".into());
|
|
d.trigger_snap = true;
|
|
d.rationale.push("backup_log_last_ok=false → trigger_snap".into());
|
|
}
|
|
|
|
if obs.restic.dedup_ratio_q16 > 0
|
|
&& obs.restic.dedup_ratio_q16 < Q16_DEDUP_LOW
|
|
&& obs.restic.snapshot_count > 5
|
|
{
|
|
d.trigger_verify = true;
|
|
d.rationale.push(format!(
|
|
"dedup_ratio_q16={} < Q16_DEDUP_LOW={} and snapshot_count={} > 5 → trigger_verify",
|
|
obs.restic.dedup_ratio_q16, Q16_DEDUP_LOW, obs.restic.snapshot_count
|
|
));
|
|
}
|
|
|
|
if obs.restic.snapshot_count > 30 {
|
|
d.trigger_forget = true;
|
|
d.rationale.push(format!(
|
|
"snapshot_count={} > 30 → trigger_forget (prune)",
|
|
obs.restic.snapshot_count
|
|
));
|
|
}
|
|
|
|
if obs.cold_copy_needed {
|
|
d.alerts.push("WARN: Newest restic snapshot is >26 h old — cold copy to gdrive appears stale".into());
|
|
d.trigger_cold_copy = true;
|
|
d.rationale.push("cold_copy_needed=true → trigger_cold_copy".into());
|
|
}
|
|
|
|
if obs.garage.up {
|
|
d.trigger_offload = true;
|
|
d.rationale.push("garage_up=true → trigger_offload (idempotent)".into());
|
|
}
|
|
|
|
d
|
|
}
|
|
|
|
// ── Action Result ────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
pub struct ActionResult {
|
|
pub actions_attempted: Vec<String>,
|
|
pub actions_succeeded: Vec<String>,
|
|
pub actions_failed: Vec<String>,
|
|
pub details: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
// ── Receipt ────────────────────────────────────────────────────────────────
|
|
|
|
pub fn build_receipt(
|
|
tick: i64,
|
|
parent_hash: &str,
|
|
obs: &Observation,
|
|
dec: &Decision,
|
|
ar: &ActionResult,
|
|
) -> serde_json::Value {
|
|
let mut receipt = serde_json::json!({
|
|
"schema": SCHEMA,
|
|
"version": VERSION,
|
|
"generated_at_utc": Utc::now().to_rfc3339(),
|
|
"tick": tick,
|
|
"parent_hash": parent_hash,
|
|
"observation": obs,
|
|
"decision": dec,
|
|
"action_result": ar,
|
|
"claim_boundary": "storage-agent-observe-decide-act-only",
|
|
});
|
|
|
|
// Hash the preimage (excluding generated_at_utc and receipt_hash)
|
|
let preimage = serde_json::json!({
|
|
"schema": SCHEMA,
|
|
"version": VERSION,
|
|
"tick": tick,
|
|
"parent_hash": parent_hash,
|
|
"observation": obs,
|
|
"decision": dec,
|
|
"action_result": ar,
|
|
"claim_boundary": "storage-agent-observe-decide-act-only",
|
|
});
|
|
let hash = sha256_text(&serde_json::to_string(&preimage).unwrap_or_default());
|
|
receipt["receipt_hash"] = serde_json::json!(hash);
|
|
receipt
|
|
}
|
|
|
|
pub fn sha256_text(data: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(data.as_bytes());
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
pub fn resume_chain() -> (i64, String) {
|
|
let log_path = dirs::cache_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join("ene-storage/receipts.jsonl");
|
|
if !log_path.exists() {
|
|
return (0, String::new());
|
|
}
|
|
let text = match std::fs::read_to_string(&log_path) {
|
|
Ok(t) => t,
|
|
Err(_) => return (0, String::new()),
|
|
};
|
|
let mut last_tick = 0i64;
|
|
let mut last_hash = String::new();
|
|
for line in text.lines() {
|
|
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
|
|
last_tick = entry.get("tick").and_then(|v| v.as_i64()).unwrap_or(last_tick);
|
|
last_hash = entry
|
|
.get("receipt_hash")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&last_hash)
|
|
.to_string();
|
|
}
|
|
}
|
|
(last_tick, last_hash)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn decide_garage_down_triggers_restart() {
|
|
let mut obs = Observation::new();
|
|
obs.garage.up = false;
|
|
let d = decide(&obs);
|
|
assert!(d.trigger_garage_restart);
|
|
assert!(d.alerts.iter().any(|a| a.contains("unreachable")));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_zero_snapshots_triggers_snap() {
|
|
let mut obs = Observation::new();
|
|
obs.garage.up = true;
|
|
obs.restic.snapshot_count = 0;
|
|
let d = decide(&obs);
|
|
assert!(d.trigger_snap);
|
|
assert!(d.alerts.iter().any(|a| a.contains("zero snapshots")));
|
|
}
|
|
|
|
#[test]
|
|
fn decide_poor_dedup_triggers_verify() {
|
|
let mut obs = Observation::new();
|
|
obs.garage.up = true;
|
|
obs.restic.snapshot_count = 10;
|
|
obs.restic.dedup_ratio_q16 = 10_000; // well below 19_661
|
|
let d = decide(&obs);
|
|
assert!(d.trigger_verify);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_good_dedup_no_verify() {
|
|
let mut obs = Observation::new();
|
|
obs.garage.up = true;
|
|
obs.restic.snapshot_count = 10;
|
|
obs.restic.dedup_ratio_q16 = 50_000; // above 19_661
|
|
let d = decide(&obs);
|
|
assert!(!d.trigger_verify);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_too_many_snapshots_triggers_forget() {
|
|
let mut obs = Observation::new();
|
|
obs.garage.up = true;
|
|
obs.restic.snapshot_count = 31;
|
|
let d = decide(&obs);
|
|
assert!(d.trigger_forget);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_cold_copy_stale() {
|
|
let mut obs = Observation::new();
|
|
obs.garage.up = true;
|
|
obs.cold_copy_needed = true;
|
|
let d = decide(&obs);
|
|
assert!(d.trigger_cold_copy);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_garage_up_triggers_offload() {
|
|
let mut obs = Observation::new();
|
|
obs.garage.up = true;
|
|
let d = decide(&obs);
|
|
assert!(d.trigger_offload);
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_hash_is_stable() {
|
|
let obs = Observation::new();
|
|
let dec = decide(&obs);
|
|
let ar = ActionResult::default();
|
|
let r1 = build_receipt(1, "", &obs, &dec, &ar);
|
|
let r2 = build_receipt(1, "", &obs, &dec, &ar);
|
|
assert_eq!(r1["receipt_hash"], r2["receipt_hash"]);
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_hash_changes_with_tick() {
|
|
let obs = Observation::new();
|
|
let dec = decide(&obs);
|
|
let ar = ActionResult::default();
|
|
let r1 = build_receipt(1, "", &obs, &dec, &ar);
|
|
let r2 = build_receipt(2, "", &obs, &dec, &ar);
|
|
assert_ne!(r1["receipt_hash"], r2["receipt_hash"]);
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_hash_changes_with_parent() {
|
|
let obs = Observation::new();
|
|
let dec = decide(&obs);
|
|
let ar = ActionResult::default();
|
|
let r1 = build_receipt(1, "abc", &obs, &dec, &ar);
|
|
let r2 = build_receipt(1, "def", &obs, &dec, &ar);
|
|
assert_ne!(r1["receipt_hash"], r2["receipt_hash"]);
|
|
}
|
|
|
|
#[test]
|
|
fn sha256_text_is_deterministic() {
|
|
let h1 = sha256_text("hello");
|
|
let h2 = sha256_text("hello");
|
|
assert_eq!(h1, h2);
|
|
assert_eq!(h1.len(), 64);
|
|
}
|
|
|
|
#[test]
|
|
fn sha256_text_changes_with_input() {
|
|
let h1 = sha256_text("a");
|
|
let h2 = sha256_text("b");
|
|
assert_ne!(h1, h2);
|
|
}
|
|
|
|
#[test]
|
|
fn observation_new_sets_timestamp() {
|
|
let obs = Observation::new();
|
|
assert!(!obs.ts.is_empty());
|
|
assert!(!obs.garage.up);
|
|
assert_eq!(obs.restic.snapshot_count, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn q16_constants_sane() {
|
|
assert_eq!(Q16_ONE, 65536);
|
|
assert!(Q16_DEDUP_LOW < Q16_ONE);
|
|
// 0.3 * 65536 = 19660.8 ≈ 19661
|
|
assert_eq!(Q16_DEDUP_LOW, 19661);
|
|
}
|
|
}
|