mind-body: the operator's brake and burn-rate dial, the reader and the mind-control CLI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1686cc5491
commit
4b3e850fef
9 changed files with 971 additions and 4 deletions
166
Cargo.lock
generated
166
Cargo.lock
generated
|
|
@ -63,6 +63,12 @@ version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.92"
|
version = "0.1.92"
|
||||||
|
|
@ -113,6 +119,30 @@ dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "borsh"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||||
|
dependencies = [
|
||||||
|
"borsh-derive",
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "borsh-derive"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
|
|
@ -141,6 +171,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
|
|
@ -246,7 +282,7 @@ dependencies = [
|
||||||
"cultcache-rs",
|
"cultcache-rs",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"hmac",
|
"hmac",
|
||||||
"rand",
|
"rand 0.9.5",
|
||||||
"reed-solomon-erasure",
|
"reed-solomon-erasure",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
"rmpv",
|
"rmpv",
|
||||||
|
|
@ -372,6 +408,12 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
|
|
@ -592,6 +634,12 @@ dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac"
|
name = "hmac"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -683,6 +731,16 @@ version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
|
@ -757,7 +815,7 @@ version = "0.7.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a"
|
checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown",
|
"hashbrown 0.12.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -766,6 +824,19 @@ version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mind-body"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"cultcache-rs",
|
||||||
|
"eureka-pipeline",
|
||||||
|
"rust_decimal",
|
||||||
|
"serde",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|
@ -861,6 +932,15 @@ dependencies = [
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-crate"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
|
dependencies = [
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
|
|
@ -891,16 +971,37 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha 0.3.1",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha",
|
"rand_chacha 0.9.0",
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -1044,6 +1145,23 @@ dependencies = [
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust_decimal"
|
||||||
|
version = "1.43.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"borsh",
|
||||||
|
"bytes",
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.8.8",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
@ -1299,7 +1417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|
@ -1361,6 +1479,36 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.25.17+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|
@ -1486,6 +1634,7 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
|
"serde",
|
||||||
"wasm-bindgen-macro",
|
"wasm-bindgen-macro",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
@ -1694,6 +1843,15 @@ version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ members = [
|
||||||
"crates/huginn-mind",
|
"crates/huginn-mind",
|
||||||
"crates/huginn-daemon",
|
"crates/huginn-daemon",
|
||||||
"crates/eureka-state",
|
"crates/eureka-state",
|
||||||
|
"crates/mind-body",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
|
|
|
||||||
34
crates/mind-body/Cargo.toml
Normal file
34
crates/mind-body/Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[package]
|
||||||
|
name = "mind-body"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
# One crate for her organs (rationale "One crate for her organs"). It holds the
|
||||||
|
# operator's brake and burn-rate dial today: a root-owned per-instance CultCache
|
||||||
|
# store (`control`), the operator's CLI over it (`cli`, the `mind-control`
|
||||||
|
# binary) and the one fail-closed reader every acting organ calls
|
||||||
|
# (`control::read_effective`). The waker joins this crate in cut mind-wake.
|
||||||
|
#
|
||||||
|
# `rust_decimal` is the dial's number type: heat, cadence and the run cap are
|
||||||
|
# money-adjacent and the tests pin exact products, which a float would blur. It
|
||||||
|
# serialises as a string, so the stored document carries no float.
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
|
cultcache-rs.workspace = true
|
||||||
|
eureka-pipeline = { path = "../eureka-pipeline" }
|
||||||
|
rust_decimal = { version = "1", default-features = false, features = ["std", "serde"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "mind_body"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mind-control"
|
||||||
|
path = "src/main.rs"
|
||||||
117
crates/mind-body/src/cli.rs
Normal file
117
crates/mind-body/src/cli.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
//! The `mind-control` command line: the operator's hand on one instance's
|
||||||
|
//! control store. It performs no permission check of its own: the file is
|
||||||
|
//! root-owned and the filesystem is the authority.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! mind-control --instance <slug> show
|
||||||
|
//! mind-control --instance <slug> brake hold
|
||||||
|
//! mind-control --instance <slug> brake release
|
||||||
|
//! mind-control --instance <slug> dial set --heat H --base-cooldown-s S --base-run-usd U
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use eureka_pipeline::Slug;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
use crate::control::{BurnRate, ControlWriter, FileSource, control_path, load_state, read_effective};
|
||||||
|
|
||||||
|
const USAGE: &str =
|
||||||
|
"usage: mind-control --instance <slug> show | brake hold | brake release | dial set --heat H --base-cooldown-s S --base-run-usd U";
|
||||||
|
|
||||||
|
/// Who made the change: `SUDO_USER`, else `USER`, else `unknown`. An empty
|
||||||
|
/// variable counts as unset.
|
||||||
|
pub fn operator_name(sudo_user: Option<String>, user: Option<String>) -> String {
|
||||||
|
[sudo_user, user]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.find(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one command against `<root>/<instance>/control.cc`. The root, the
|
||||||
|
/// operator's name, the clock and the output are parameters so a test drives
|
||||||
|
/// the whole command without touching `/etc`.
|
||||||
|
pub fn run(args: &[String], root: &Path, set_by: &str, now: DateTime<Utc>, out: &mut impl Write) -> Result<()> {
|
||||||
|
let (instance, command) = match args {
|
||||||
|
[flag, instance, command @ ..] if flag == "--instance" => (Slug(instance.clone()), command),
|
||||||
|
_ => bail!("{USAGE}"),
|
||||||
|
};
|
||||||
|
let path = control_path(root, &instance)?;
|
||||||
|
let words: Vec<&str> = command.iter().map(String::as_str).collect();
|
||||||
|
match words.as_slice() {
|
||||||
|
["show"] => show(&path, &instance, out),
|
||||||
|
["brake", "hold"] => ControlWriter::open(&path)?.set_brake(false, now, set_by),
|
||||||
|
["brake", "release"] => ControlWriter::open(&path)?.set_brake(true, now, set_by),
|
||||||
|
["dial", "set", flags @ ..] => {
|
||||||
|
let dial = parse_dial(flags, now, set_by)?;
|
||||||
|
ControlWriter::open(&path)?.set_dial(dial)
|
||||||
|
}
|
||||||
|
_ => bail!("{USAGE}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dial(flags: &[&str], now: DateTime<Utc>, set_by: &str) -> Result<BurnRate> {
|
||||||
|
let (mut heat, mut cooldown, mut run_usd) = (None, None, None);
|
||||||
|
let mut rest = flags.iter();
|
||||||
|
while let Some(flag) = rest.next() {
|
||||||
|
let value = rest.next().ok_or_else(|| anyhow!("{flag} needs a value"))?;
|
||||||
|
let slot = match *flag {
|
||||||
|
"--heat" => &mut heat,
|
||||||
|
"--base-cooldown-s" => &mut cooldown,
|
||||||
|
"--base-run-usd" => &mut run_usd,
|
||||||
|
other => bail!("unknown flag {other:?}"),
|
||||||
|
};
|
||||||
|
if slot.replace(*value).is_some() {
|
||||||
|
bail!("{flag} given twice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let need = |value: Option<&str>, name: &str| value.ok_or_else(|| anyhow!("{name} is required"));
|
||||||
|
Ok(BurnRate {
|
||||||
|
heat: Decimal::from_str(need(heat, "--heat")?).context("--heat is not a decimal")?,
|
||||||
|
base_cooldown_s: need(cooldown, "--base-cooldown-s")?
|
||||||
|
.parse()
|
||||||
|
.context("--base-cooldown-s is not a whole number of seconds")?,
|
||||||
|
base_run_usd: Decimal::from_str(need(run_usd, "--base-run-usd")?).context("--base-run-usd is not a decimal")?,
|
||||||
|
set_at: now,
|
||||||
|
set_by: set_by.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show(path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> {
|
||||||
|
let source = FileSource::new(path);
|
||||||
|
writeln!(out, "instance: {}", instance.0)?;
|
||||||
|
match load_state(&source) {
|
||||||
|
Ok(state) => {
|
||||||
|
match &state.brake {
|
||||||
|
Some(brake) => writeln!(
|
||||||
|
out,
|
||||||
|
"brake: {} (set_at {}, set_by {})",
|
||||||
|
if brake.released { "released" } else { "held" },
|
||||||
|
brake.set_at.to_rfc3339(),
|
||||||
|
brake.set_by
|
||||||
|
)?,
|
||||||
|
None => writeln!(out, "brake: absent")?,
|
||||||
|
}
|
||||||
|
match &state.dial {
|
||||||
|
Some(dial) => writeln!(
|
||||||
|
out,
|
||||||
|
"dial: heat {} base_cooldown_s {} base_run_usd {} (set_at {}, set_by {})",
|
||||||
|
dial.heat,
|
||||||
|
dial.base_cooldown_s,
|
||||||
|
dial.base_run_usd,
|
||||||
|
dial.set_at.to_rfc3339(),
|
||||||
|
dial.set_by
|
||||||
|
)?,
|
||||||
|
None => writeln!(out, "dial: absent")?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => writeln!(out, "store: undecodable ({error:#})")?,
|
||||||
|
}
|
||||||
|
writeln!(out, "effective: {:?}", read_effective(&source))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
231
crates/mind-body/src/control.rs
Normal file
231
crates/mind-body/src/control.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
//! The operator's brake and burn-rate dial, and the one reader of them.
|
||||||
|
//!
|
||||||
|
//! State is a CultCache single-file store at
|
||||||
|
//! `/etc/gamecult/minds/<instance>/control.cc`, root-owned and world-readable.
|
||||||
|
//! The operator's CLI replaces a document whole; every organ that acts calls
|
||||||
|
//! `read_effective` before the consequence and takes its answer as given. The
|
||||||
|
//! cadence and the run cap are derived here and nowhere else.
|
||||||
|
//!
|
||||||
|
//! Fail closed: the reader's only route to `Released` is a decodable store
|
||||||
|
//! holding a released brake and an in-bounds dial. Anything else (no file, no
|
||||||
|
//! brake, a held brake, no dial, a dial out of bounds, bytes that do not
|
||||||
|
//! decode, a file that cannot be read) is `Held`, with the reason.
|
||||||
|
//!
|
||||||
|
//! The reader opens the file read-only through CultCache's read-only snapshot
|
||||||
|
//! door: it takes no lock and creates no sibling file, so the units she runs as
|
||||||
|
//! need no write authority anywhere under `/etc`. Writing is the operator's;
|
||||||
|
//! the filesystem is the authority and nothing here checks permission.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||||
|
use eureka_pipeline::Slug;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use rust_decimal::prelude::ToPrimitive;
|
||||||
|
|
||||||
|
/// Where every instance's control directory lives. Tests and the library never
|
||||||
|
/// touch it: the root is a parameter.
|
||||||
|
pub const CONTROL_ROOT: &str = "/etc/gamecult/minds";
|
||||||
|
|
||||||
|
/// The one key each control document has in the store.
|
||||||
|
pub const BRAKE_KEY: &str = "brake";
|
||||||
|
pub const DIAL_KEY: &str = "dial";
|
||||||
|
|
||||||
|
/// `<root>/<instance>/control.cc`, after the leaf's slug grammar has passed, so
|
||||||
|
/// an instance name cannot walk out of the root.
|
||||||
|
pub fn control_path(root: &Path, instance: &Slug) -> Result<PathBuf> {
|
||||||
|
instance
|
||||||
|
.validate_slug()
|
||||||
|
.map_err(|refusal| anyhow::anyhow!("instance {:?} is not a slug: {refusal:?}", instance.0))?;
|
||||||
|
Ok(root.join(&instance.0).join("control.cc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The brake: released means the operator allows her to act.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||||
|
#[cultcache(type = "eureka.control.brake.v1", schema = "ControlBrake")]
|
||||||
|
pub struct Brake {
|
||||||
|
#[cultcache(key = 0)]
|
||||||
|
pub released: bool,
|
||||||
|
#[cultcache(key = 1)]
|
||||||
|
pub set_at: DateTime<Utc>,
|
||||||
|
#[cultcache(key = 2)]
|
||||||
|
pub set_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The burn-rate dial: `heat` scales how active she is; the base values say
|
||||||
|
/// what heat 1.0 means. Cadence and run cap are derived from it, never stored.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||||
|
#[cultcache(type = "eureka.control.burn_rate.v1", schema = "ControlBurnRate")]
|
||||||
|
pub struct BurnRate {
|
||||||
|
#[cultcache(key = 0)]
|
||||||
|
pub heat: Decimal,
|
||||||
|
#[cultcache(key = 1)]
|
||||||
|
pub base_cooldown_s: u32,
|
||||||
|
#[cultcache(key = 2)]
|
||||||
|
pub base_run_usd: Decimal,
|
||||||
|
#[cultcache(key = 3)]
|
||||||
|
pub set_at: DateTime<Utc>,
|
||||||
|
#[cultcache(key = 4)]
|
||||||
|
pub set_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heat's inclusive bounds.
|
||||||
|
pub fn heat_min() -> Decimal {
|
||||||
|
Decimal::new(5, 2)
|
||||||
|
}
|
||||||
|
pub fn heat_max() -> Decimal {
|
||||||
|
Decimal::new(20, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BurnRate {
|
||||||
|
/// The one bounds rule: the reader holds on it and the CLI refuses on it.
|
||||||
|
pub fn in_bounds(&self) -> bool {
|
||||||
|
self.heat >= heat_min() && self.heat <= heat_max() && self.base_cooldown_s > 0 && self.base_run_usd > Decimal::ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake cadence: `base_cooldown_s / heat`, to the nanosecond.
|
||||||
|
fn cadence(&self) -> Option<Duration> {
|
||||||
|
let seconds = Decimal::from(self.base_cooldown_s).checked_div(self.heat)?;
|
||||||
|
let whole = seconds.trunc();
|
||||||
|
let nanos = ((seconds - whole) * Decimal::from(1_000_000_000u32)).round();
|
||||||
|
Some(Duration::new(whole.to_u64()?, nanos.to_u32()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-run cap: `base_run_usd x heat`.
|
||||||
|
fn run_cap_usd(&self) -> Option<Decimal> {
|
||||||
|
self.base_run_usd.checked_mul(self.heat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the reader said held.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum HeldReason {
|
||||||
|
BrakeAbsent,
|
||||||
|
BrakeHeld,
|
||||||
|
DialAbsent,
|
||||||
|
DialOutOfBounds,
|
||||||
|
Undecodable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the operator's store allows right now.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Effective {
|
||||||
|
Held { reason: HeldReason },
|
||||||
|
Released { cadence: Duration, run_cap_usd: Decimal },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store's contents, decoded and not yet judged.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub struct ControlState {
|
||||||
|
pub brake: Option<Brake>,
|
||||||
|
pub dial: Option<BurnRate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlState {
|
||||||
|
/// The one derivation. Order: brake first, then dial; each failing rung is
|
||||||
|
/// its own reason.
|
||||||
|
pub fn effective(&self) -> Effective {
|
||||||
|
let held = |reason| Effective::Held { reason };
|
||||||
|
let Some(brake) = &self.brake else { return held(HeldReason::BrakeAbsent) };
|
||||||
|
if !brake.released {
|
||||||
|
return held(HeldReason::BrakeHeld);
|
||||||
|
}
|
||||||
|
let Some(dial) = &self.dial else { return held(HeldReason::DialAbsent) };
|
||||||
|
if !dial.in_bounds() {
|
||||||
|
return held(HeldReason::DialOutOfBounds);
|
||||||
|
}
|
||||||
|
match (dial.cadence(), dial.run_cap_usd()) {
|
||||||
|
(Some(cadence), Some(run_cap_usd)) => Effective::Released { cadence, run_cap_usd },
|
||||||
|
_ => held(HeldReason::DialOutOfBounds),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reader's port: the store's envelopes as one snapshot. An absent store is
|
||||||
|
/// an empty snapshot; an unreadable one is an error.
|
||||||
|
pub trait ControlSource {
|
||||||
|
fn snapshot(&self) -> Result<Vec<CultCacheEnvelope>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real source: the control file, read without locks and without writes.
|
||||||
|
pub struct FileSource {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSource {
|
||||||
|
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { path: path.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlSource for FileSource {
|
||||||
|
fn snapshot(&self) -> Result<Vec<CultCacheEnvelope>> {
|
||||||
|
SingleFileMessagePackBackingStore::new(&self.path).pull_all_read_only_snapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registered_cache() -> Result<CultCache> {
|
||||||
|
let mut cache = CultCache::new();
|
||||||
|
cache.register_entry_type::<Brake>()?;
|
||||||
|
cache.register_entry_type::<BurnRate>()?;
|
||||||
|
Ok(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the store into its two documents. Any envelope the cache does not
|
||||||
|
/// know, or any payload that does not decode, is an error: a store the reader
|
||||||
|
/// cannot fully read is not one it may act on.
|
||||||
|
pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
|
||||||
|
let mut cache = registered_cache()?;
|
||||||
|
for envelope in source.snapshot().context("control store cannot be read")? {
|
||||||
|
cache.put_raw_envelope(envelope)?;
|
||||||
|
}
|
||||||
|
Ok(ControlState { brake: cache.get::<Brake>(BRAKE_KEY)?, dial: cache.get::<BurnRate>(DIAL_KEY)? })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only reader every organ uses (the waker now; the permit issuer and the
|
||||||
|
/// Persona organ later).
|
||||||
|
pub fn read_effective(source: &impl ControlSource) -> Effective {
|
||||||
|
match load_state(source) {
|
||||||
|
Ok(state) => state.effective(),
|
||||||
|
Err(_) => Effective::Held { reason: HeldReason::Undecodable },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operator's writer over one control file. Each setter replaces its
|
||||||
|
/// document whole; the other document is untouched. The file is created on the
|
||||||
|
/// first write.
|
||||||
|
pub struct ControlWriter {
|
||||||
|
cache: CultCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlWriter {
|
||||||
|
pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
|
||||||
|
let mut cache = registered_cache()?;
|
||||||
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
|
||||||
|
cache.pull_all_backing_stores()?;
|
||||||
|
Ok(Self { cache })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_brake(&mut self, released: bool, set_at: DateTime<Utc>, set_by: &str) -> Result<()> {
|
||||||
|
let brake = Brake { released, set_at, set_by: set_by.to_string() };
|
||||||
|
self.cache.put(BRAKE_KEY, &brake)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses an out-of-bounds dial before anything is written, so the store
|
||||||
|
/// never holds one that the CLI put there.
|
||||||
|
pub fn set_dial(&mut self, dial: BurnRate) -> Result<()> {
|
||||||
|
if !dial.in_bounds() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"dial refused: heat must be within {}..={}, base_cooldown_s above 0 and base_run_usd above 0",
|
||||||
|
heat_min(),
|
||||||
|
heat_max()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.cache.put(DIAL_KEY, &dial)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/mind-body/src/lib.rs
Normal file
9
crates/mind-body/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//! `mind-body`: the organs of one Mind's body that live in Huginn's workspace.
|
||||||
|
//!
|
||||||
|
//! Today that is `control`, the operator's brake and burn-rate dial, and `cli`,
|
||||||
|
//! the operator's hand on it. The brake and the dial are the operator's consent
|
||||||
|
//! (ruling `eureka-body:ruling:brake-dial-root-store-now`), not her body: the
|
||||||
|
//! store is root-owned and no organ of hers writes it.
|
||||||
|
|
||||||
|
pub mod cli;
|
||||||
|
pub mod control;
|
||||||
21
crates/mind-body/src/main.rs
Normal file
21
crates/mind-body/src/main.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
//! `mind-control`: the operator's hand on a Mind's brake and burn-rate dial.
|
||||||
|
//! All behaviour is `mind_body::cli`; this reads the process's environment and
|
||||||
|
//! clock, the two things the library never reads.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let set_by = mind_body::cli::operator_name(std::env::var("SUDO_USER").ok(), std::env::var("USER").ok());
|
||||||
|
let result = mind_body::cli::run(
|
||||||
|
&args,
|
||||||
|
Path::new(mind_body::control::CONTROL_ROOT),
|
||||||
|
&set_by,
|
||||||
|
chrono::Utc::now(),
|
||||||
|
&mut std::io::stdout(),
|
||||||
|
);
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("mind-control: {error:#}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/mind-body/tests/cli.rs
Normal file
101
crates/mind-body/tests/cli.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
//! The `mind-control` command driven end to end through `mind_body::cli::run`
|
||||||
|
//! against a temporary root, and the binary once as a process for its exit
|
||||||
|
//! status. Nothing touches `/etc`.
|
||||||
|
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use mind_body::cli::{operator_name, run};
|
||||||
|
|
||||||
|
fn go(root: &std::path::Path, args: &[&str], set_by: &str, secs: i64) -> (anyhow::Result<()>, String) {
|
||||||
|
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let now = Utc.timestamp_opt(1_800_000_000 + secs, 0).unwrap();
|
||||||
|
let result = run(&args, root, set_by, now, &mut out);
|
||||||
|
(result, String::from_utf8(out).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_reports_every_rung_from_an_absent_store_to_released() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let (r, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 0);
|
||||||
|
r.unwrap();
|
||||||
|
assert!(out.contains("brake: absent") && out.contains("dial: absent") && out.contains("BrakeAbsent"), "{out}");
|
||||||
|
|
||||||
|
go(root.path(), &["--instance", "eureka", "brake", "release"], "alice", 1).0.unwrap();
|
||||||
|
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 2);
|
||||||
|
assert!(out.contains("brake: released") && out.contains("set_by alice") && out.contains("DialAbsent"), "{out}");
|
||||||
|
|
||||||
|
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "bob", 3)
|
||||||
|
.0
|
||||||
|
.unwrap();
|
||||||
|
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 4);
|
||||||
|
assert!(out.contains("heat 0.5") && out.contains("base_cooldown_s 3600") && out.contains("set_by bob"), "{out}");
|
||||||
|
assert!(out.contains("Released") && out.contains("7200s"), "{out}");
|
||||||
|
|
||||||
|
go(root.path(), &["--instance", "eureka", "brake", "hold"], "alice", 5).0.unwrap();
|
||||||
|
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 6);
|
||||||
|
assert!(out.contains("brake: held") && out.contains("BrakeHeld"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instances_do_not_share_a_store() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
go(root.path(), &["--instance", "one", "brake", "release"], "op", 0).0.unwrap();
|
||||||
|
let (_, out) = go(root.path(), &["--instance", "two", "show"], "op", 1);
|
||||||
|
assert!(out.contains("brake: absent"), "{out}");
|
||||||
|
assert!(root.path().join("one").join("control.cc").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dial_set_outside_bounds_refuses_and_leaves_the_store_unchanged() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap();
|
||||||
|
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 1)
|
||||||
|
.0
|
||||||
|
.unwrap();
|
||||||
|
let path = root.path().join("eureka").join("control.cc");
|
||||||
|
let before = std::fs::read(&path).unwrap();
|
||||||
|
for heat in ["2.0001", "0.0499", "0", "-1"] {
|
||||||
|
let (r, _) = go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", heat, "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 9);
|
||||||
|
assert!(r.is_err(), "heat {heat} must be refused");
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), before, "heat {heat} changed the store");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal on a store that does not exist yet must not create it.
|
||||||
|
let fresh = tempfile::tempdir().unwrap();
|
||||||
|
let (r, _) = go(fresh.path(), &["--instance", "eureka", "dial", "set", "--heat", "5", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 0);
|
||||||
|
assert!(r.is_err());
|
||||||
|
assert!(!fresh.path().join("eureka").join("control.cc").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_commands_are_refused_and_write_nothing() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let bad: &[&[&str]] = &[
|
||||||
|
&[],
|
||||||
|
&["show"],
|
||||||
|
&["--instance", "eureka"],
|
||||||
|
&["--instance", "eureka", "brake"],
|
||||||
|
&["--instance", "eureka", "brake", "toggle"],
|
||||||
|
&["--instance", "eureka", "dial", "set", "--heat", "1"],
|
||||||
|
&["--instance", "eureka", "dial", "set", "--heat", "1", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"],
|
||||||
|
&["--instance", "eureka", "dial", "set", "--heat", "x", "--base-cooldown-s", "60", "--base-run-usd", "1"],
|
||||||
|
&["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "1.5", "--base-run-usd", "1"],
|
||||||
|
&["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1", "--extra", "1"],
|
||||||
|
&["--instance", "eureka", "dial", "set", "--heat"],
|
||||||
|
&["--instance", "../evil", "brake", "release"],
|
||||||
|
];
|
||||||
|
for args in bad {
|
||||||
|
assert!(go(root.path(), args, "op", 0).0.is_err(), "{args:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0, "a refused command wrote something");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_operator_is_sudo_user_then_user_then_unknown() {
|
||||||
|
let s = |v: &str| Some(v.to_string());
|
||||||
|
assert_eq!(operator_name(s("alice"), s("root")), "alice");
|
||||||
|
assert_eq!(operator_name(None, s("bob")), "bob");
|
||||||
|
assert_eq!(operator_name(s(""), s("bob")), "bob");
|
||||||
|
assert_eq!(operator_name(None, s("")), "unknown");
|
||||||
|
assert_eq!(operator_name(None, None), "unknown");
|
||||||
|
}
|
||||||
295
crates/mind-body/tests/control.rs
Normal file
295
crates/mind-body/tests/control.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
//! Behavioural tests for the brake, the dial and the one reader. Every test
|
||||||
|
//! works in a temporary directory through the same file door the units use;
|
||||||
|
//! nothing touches `/etc`.
|
||||||
|
//!
|
||||||
|
//! The permission boundary (no user she runs as can write the file) belongs to
|
||||||
|
//! the filesystem, not to this crate, and is not tested here: the verify
|
||||||
|
//! container runs as root, where a permission failure cannot be provoked.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
|
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||||
|
use mind_body::control::{
|
||||||
|
BRAKE_KEY, BurnRate, ControlSource, ControlWriter, Effective, FileSource, HeldReason, control_path, load_state, read_effective,
|
||||||
|
};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn dec(text: &str) -> Decimal {
|
||||||
|
Decimal::from_str(text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(seconds: i64) -> DateTime<Utc> {
|
||||||
|
Utc.timestamp_opt(1_800_000_000 + seconds, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dial(heat: &str, cooldown: u32, run_usd: &str) -> BurnRate {
|
||||||
|
BurnRate { heat: dec(heat), base_cooldown_s: cooldown, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_path(dir: &Path) -> PathBuf {
|
||||||
|
dir.join("control.cc")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn held(reason: HeldReason) -> Effective {
|
||||||
|
Effective::Held { reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A written store: brake released and the given dial, through the real writer.
|
||||||
|
fn released_store(dir: &Path, dial: BurnRate) -> PathBuf {
|
||||||
|
let path = store_path(dir);
|
||||||
|
let mut writer = ControlWriter::open(&path).unwrap();
|
||||||
|
writer.set_brake(true, at(0), "op").unwrap();
|
||||||
|
writer.set_dial(dial).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective(path: &Path) -> Effective {
|
||||||
|
read_effective(&FileSource::new(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dial written around the writer's bounds check, so the reader's own check
|
||||||
|
/// is what is under test.
|
||||||
|
fn write_unchecked_dial(path: &Path, dial: &BurnRate) {
|
||||||
|
let mut cache = CultCache::new();
|
||||||
|
cache.register_entry_type::<BurnRate>().unwrap();
|
||||||
|
cache.register_entry_type::<mind_body::control::Brake>().unwrap();
|
||||||
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||||
|
cache.pull_all_backing_stores().unwrap();
|
||||||
|
cache.put(mind_body::control::DIAL_KEY, dial).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absent_store_reads_as_held() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(effective(&store_path(dir.path())), held(HeldReason::BrakeAbsent));
|
||||||
|
assert!(!store_path(dir.path()).exists(), "reading must not create the store");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absent_brake_reads_as_held_even_with_a_dial() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = store_path(dir.path());
|
||||||
|
ControlWriter::open(&path).unwrap().set_dial(dial("1", 3600, "1")).unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::BrakeAbsent));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_undecodable_store_reads_as_held() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||||
|
let good = std::fs::read(&path).unwrap();
|
||||||
|
|
||||||
|
// Garbage in place of the store.
|
||||||
|
std::fs::write(&path, b"this is not a cultcache store").unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::Undecodable));
|
||||||
|
|
||||||
|
// A store cut off mid-document.
|
||||||
|
std::fs::write(&path, &good[..good.len() / 2]).unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::Undecodable));
|
||||||
|
|
||||||
|
// The untouched store is still released, so the two reads above were the damage.
|
||||||
|
std::fs::write(&path, &good).unwrap();
|
||||||
|
assert!(matches!(effective(&path), Effective::Released { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_that_cannot_be_read_as_a_file_reads_as_held() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// A directory sits where the store should be: it exists and cannot be read as a file.
|
||||||
|
let path = store_path(dir.path());
|
||||||
|
std::fs::create_dir(&path).unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::Undecodable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_that_fails_reads_as_held() {
|
||||||
|
struct Failing;
|
||||||
|
impl ControlSource for Failing {
|
||||||
|
fn snapshot(&self) -> Result<Vec<CultCacheEnvelope>> {
|
||||||
|
Err(anyhow!("disk gone"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(read_effective(&Failing), held(HeldReason::Undecodable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_document_type_makes_the_store_undecodable() {
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||||
|
#[cultcache(type = "eureka.control.something_else.v1", schema = "Other")]
|
||||||
|
struct Other {
|
||||||
|
#[cultcache(key = 0)]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||||
|
let mut cache = CultCache::new();
|
||||||
|
cache.register_entry_type::<Other>().unwrap();
|
||||||
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||||
|
cache.put("x", &Other { value: 1 }).unwrap();
|
||||||
|
// The store now holds a released brake, a good dial and a foreign document.
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::Undecodable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_brake_payload_that_does_not_decode_makes_the_store_undecodable() {
|
||||||
|
// Same type id as the brake, a different shape: the payload cannot decode as a Brake.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||||
|
#[cultcache(type = "eureka.control.brake.v1", schema = "ControlBrake")]
|
||||||
|
struct WrongBrake {
|
||||||
|
#[cultcache(key = 0)]
|
||||||
|
released: String,
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = store_path(dir.path());
|
||||||
|
let mut cache = CultCache::new();
|
||||||
|
cache.register_entry_type::<WrongBrake>().unwrap();
|
||||||
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||||
|
cache.put(BRAKE_KEY, &WrongBrake { released: "yes".into() }).unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::Undecodable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decode_failure_outranks_a_held_brake() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||||
|
ControlWriter::open(&path).unwrap().set_brake(false, at(5), "op").unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::BrakeHeld));
|
||||||
|
std::fs::write(&path, b"junk").unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::Undecodable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_released_brake_with_no_dial_reads_held_dial_absent() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = store_path(dir.path());
|
||||||
|
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::DialAbsent));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dial_bounds_are_inclusive_at_both_ends() {
|
||||||
|
for (heat, expected_in) in [("0.05", true), ("2.0", true), ("0.0499", false), ("2.0001", false), ("0", false), ("-1", false), ("3", false)] {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store_unchecked(dir.path(), dial(heat, 3600, "1"));
|
||||||
|
let got = effective(&path);
|
||||||
|
if expected_in {
|
||||||
|
assert!(matches!(got, Effective::Released { .. }), "heat {heat}: {got:?}");
|
||||||
|
} else {
|
||||||
|
assert_eq!(got, held(HeldReason::DialOutOfBounds), "heat {heat}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_base_cooldown_or_a_non_positive_run_cost_is_out_of_bounds() {
|
||||||
|
for bad in [dial("1", 0, "1"), dial("1", 3600, "0"), dial("1", 3600, "-0.5")] {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store_unchecked(dir.path(), bad.clone());
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::DialOutOfBounds), "{bad:?}");
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let smallest = released_store_unchecked(dir.path(), dial("1", 1, "0.000001"));
|
||||||
|
assert!(matches!(effective(&smallest), Effective::Released { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn released_store_unchecked(dir: &Path, bad: BurnRate) -> PathBuf {
|
||||||
|
let path = store_path(dir);
|
||||||
|
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||||
|
write_unchecked_dial(&path, &bad);
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_and_run_cap_are_the_dial_divided_and_multiplied_by_heat() {
|
||||||
|
for (heat, cooldown, run_usd, cadence, cap) in [
|
||||||
|
("0.5", 3600u32, "5", Duration::from_secs(7200), dec("2.5")),
|
||||||
|
("2.0", 3600, "5", Duration::from_secs(1800), dec("10.0")),
|
||||||
|
("1", 600, "3", Duration::from_secs(600), dec("3")),
|
||||||
|
("0.05", 90, "4", Duration::from_secs(1800), dec("0.20")),
|
||||||
|
("1.5", 10, "2", Duration::new(6, 666_666_667), dec("3.0")),
|
||||||
|
("2.0", 1, "0.25", Duration::from_millis(500), dec("0.500")),
|
||||||
|
] {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial(heat, cooldown, run_usd));
|
||||||
|
assert_eq!(effective(&path), Effective::Released { cadence, run_cap_usd: cap }, "heat {heat} cooldown {cooldown} run {run_usd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_operators_last_write_is_what_is_in_force_and_provenance_round_trips() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||||
|
assert!(matches!(effective(&path), Effective::Released { .. }));
|
||||||
|
|
||||||
|
ControlWriter::open(&path).unwrap().set_brake(false, at(60), "alice").unwrap();
|
||||||
|
assert_eq!(effective(&path), held(HeldReason::BrakeHeld));
|
||||||
|
let state = load_state(&FileSource::new(&path)).unwrap();
|
||||||
|
let brake = state.brake.unwrap();
|
||||||
|
assert_eq!((brake.released, brake.set_at, brake.set_by.as_str()), (false, at(60), "alice"));
|
||||||
|
// The dial survives the brake write untouched.
|
||||||
|
assert_eq!(state.dial.unwrap().set_by, "op");
|
||||||
|
|
||||||
|
ControlWriter::open(&path).unwrap().set_brake(true, at(120), "bob").unwrap();
|
||||||
|
assert!(matches!(effective(&path), Effective::Released { .. }));
|
||||||
|
let brake = load_state(&FileSource::new(&path)).unwrap().brake.unwrap();
|
||||||
|
assert_eq!((brake.released, brake.set_at, brake.set_by.as_str()), (true, at(120), "bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setting_the_dial_replaces_it_whole_and_keeps_the_brake() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||||
|
let mut next = dial("0.5", 100, "8");
|
||||||
|
next.set_at = at(30);
|
||||||
|
next.set_by = "carol".into();
|
||||||
|
ControlWriter::open(&path).unwrap().set_dial(next.clone()).unwrap();
|
||||||
|
let state = load_state(&FileSource::new(&path)).unwrap();
|
||||||
|
assert_eq!(state.dial, Some(next));
|
||||||
|
assert!(state.brake.unwrap().released);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_writer_refuses_an_out_of_bounds_dial_and_leaves_the_store_unchanged() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||||
|
let before = std::fs::read(&path).unwrap();
|
||||||
|
for bad in [dial("2.0001", 3600, "1"), dial("0.0499", 3600, "1"), dial("1", 0, "1"), dial("1", 3600, "0")] {
|
||||||
|
assert!(ControlWriter::open(&path).unwrap().set_dial(bad).is_err());
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_writes_nothing_and_takes_no_lock_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||||
|
let listing = |dir: &Path| {
|
||||||
|
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
};
|
||||||
|
// The writer left its sibling lock file; remove it, so a reader that wants one has to create it.
|
||||||
|
for entry in std::fs::read_dir(dir.path()).unwrap() {
|
||||||
|
let entry = entry.unwrap().path();
|
||||||
|
if entry != path {
|
||||||
|
std::fs::remove_file(entry).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let before = (listing(dir.path()), std::fs::read(&path).unwrap());
|
||||||
|
assert!(matches!(effective(&path), Effective::Released { .. }));
|
||||||
|
assert_eq!((listing(dir.path()), std::fs::read(&path).unwrap()), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_control_path_is_derived_from_a_valid_instance_only() {
|
||||||
|
use eureka_pipeline::Slug;
|
||||||
|
let root = Path::new("/etc/gamecult/minds");
|
||||||
|
assert_eq!(control_path(root, &Slug("eureka".into())).unwrap(), root.join("eureka").join("control.cc"));
|
||||||
|
for bad in ["", "../etc", "a/b", "A b", "eureka/.."] {
|
||||||
|
assert!(control_path(root, &Slug(bad.into())).is_err(), "{bad:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue