Skip to main content

liminal_server/services/
budgets.rs

1//! The production [`BudgetSource`]: budgets and stops read from Lys, never
2//! decided in liminal (ACCESS-003 R1).
3//!
4//! `budgets.<name>` names an agent, the seat of that name. Its budget is
5//! Lys's `GET /budgets/agent/{name}` answer and its stops Lys's
6//! `GET /agents/{name}/stops` answer, both carried whole in a
7//! [`BudgetPolicy`]. Lys answers a budget never set with version 0; with no
8//! budget set and no stop kept, Lys holds no policy for the seat, which is
9//! `Ok(None)` and so a refusal upstream, never a local default. Lys reads
10//! budgets for an administrator or the person responsible for the agent
11//! only, so the reads are made with this liminal's own Lys pass and a Lys
12//! refusal comes back by its name.
13
14use super::lys_http::{LysHttp, segment};
15use super::{BudgetPolicy, BudgetSource, ServiceEffectError};
16use std::sync::Arc;
17
18/// Budgets and stops read from Lys over [`LysHttp`].
19pub struct LysBudgets {
20    http: Arc<dyn LysHttp>,
21    bearer: String,
22}
23
24impl std::fmt::Debug for LysBudgets {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        formatter
27            .debug_struct("LysBudgets")
28            .field("http", &self.http)
29            .finish_non_exhaustive()
30    }
31}
32
33impl LysBudgets {
34    /// Reads through `http` with `bearer`, this liminal's own Lys pass. The
35    /// pass is never shown by `Debug` (ACCESS-004 R5).
36    #[must_use]
37    pub const fn new(http: Arc<dyn LysHttp>, bearer: String) -> Self {
38        Self { http, bearer }
39    }
40}
41
42/// Whether Lys's budget answer holds a budget: a version above 0.
43fn budget_set(budgets: &serde_json::Value) -> Result<bool, ServiceEffectError> {
44    budgets
45        .get("version")
46        .and_then(serde_json::Value::as_u64)
47        .map(|version| version > 0)
48        .ok_or_else(|| {
49            ServiceEffectError(
50                "lys_could_not_be_asked: the budget answer carries no version".to_owned(),
51            )
52        })
53}
54
55/// Whether Lys's stops answer keeps any stop.
56fn stops_kept(stops: &serde_json::Value) -> Result<bool, ServiceEffectError> {
57    stops
58        .get("stops")
59        .and_then(serde_json::Value::as_array)
60        .map(|kept| !kept.is_empty())
61        .ok_or_else(|| {
62            ServiceEffectError(
63                "lys_could_not_be_asked: the stops answer carries no stops".to_owned(),
64            )
65        })
66}
67
68impl BudgetSource for LysBudgets {
69    fn read(&self, name: &str) -> Result<Option<BudgetPolicy>, ServiceEffectError> {
70        let id = segment(name);
71        let budget_route = format!("budgets/agent/{id}");
72        let budgets = self.http.get(&self.bearer, &budget_route)?;
73        if !budgets.succeeded() {
74            return Err(budgets.refused(&budget_route));
75        }
76        let stop_route = format!("agents/{id}/stops");
77        let stops = self.http.get(&self.bearer, &stop_route)?;
78        if !stops.succeeded() {
79            return Err(stops.refused(&stop_route));
80        }
81        if !budget_set(&budgets.body)? && !stops_kept(&stops.body)? {
82            return Ok(None);
83        }
84        Ok(Some(BudgetPolicy {
85            holder: format!("agent:{name}"),
86            budgets: budgets.body,
87            stops: stops.body,
88        }))
89    }
90}