liminal_server/services/site/
effects.rs1use super::{BuilderGrants, SiteError};
4use crate::services::lys_http::{LysHttp, segment};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use std::sync::Arc;
8
9#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct GrantSources {
13 pub plan: String,
15 pub serve: String,
17 #[serde(default)]
21 pub credential_path: Option<std::path::PathBuf>,
22 #[serde(skip)]
25 pub placement: Option<(std::path::PathBuf, String)>,
26}
27
28pub trait SiteEffects: Send + Sync {
30 fn agent_owner(&self, agent: &str) -> Result<String, SiteError>;
35 fn bind(
40 &self,
41 draft: &str,
42 site: &str,
43 agent: &str,
44 at_ms: u64,
45 ) -> Result<BuilderGrants, SiteError>;
46 fn revoke(&self, draft: &str, label: &str, grant: &str) -> Result<(), SiteError>;
51 fn place(&self, _kind: &str, _id: &str) -> Result<(), SiteError> {
56 Ok(())
57 }
58}
59
60pub struct LysSiteEffects {
62 http: Arc<dyn LysHttp>,
63 pass: Option<crate::link_pass::MachinePass>,
64 agents: Arc<crate::agents::runtime::Agents>,
65 sources: GrantSources,
66 metadata_bytes: std::num::NonZeroUsize,
68}
69
70impl LysSiteEffects {
71 #[must_use]
73 pub fn new(
74 http: Arc<dyn LysHttp>,
75 pass: Option<crate::link_pass::MachinePass>,
76 agents: Arc<crate::agents::runtime::Agents>,
77 sources: GrantSources,
78 metadata_bytes: std::num::NonZeroUsize,
79 ) -> Self {
80 Self {
81 http,
82 pass,
83 agents,
84 sources,
85 metadata_bytes,
86 }
87 }
88
89 fn delegate(&self, request: Delegation<'_>) -> Result<String, SiteError> {
90 let Delegation {
91 draft,
92 site,
93 agent,
94 responsible,
95 action,
96 source,
97 at_ms,
98 } = request;
99 let pass = self.bearer()?;
100 let source_grant = self
101 .http
102 .get(&pass, &format!("grants/{}", segment(source)))
103 .map_err(|_| SiteError::Invalid("Lys source grant read failed"))?;
104 if !source_grant.succeeded() {
105 return Err(SiteError::Pass(source_grant.refusal()));
106 }
107 if source_grant.body["standing"]["stands"] != true {
108 return Err(SiteError::Invalid("Lys source grant is not standing"));
109 }
110 let ends_at = source_grant
111 .body
112 .get("effective_ends_at")
113 .ok_or(SiteError::Invalid("Lys source grant window"))?;
114 if !ends_at.is_null() && !ends_at.is_u64() {
115 return Err(SiteError::Invalid("Lys source grant window"));
116 }
117 let resource = source_grant
121 .body
122 .get("resource")
123 .filter(|resource| {
124 resource["kind"] == "liminal.service" || resource["kind"] == "liminal.network"
125 })
126 .cloned()
127 .unwrap_or_else(|| serde_json::json!({"kind":"liminal.service","id":site}));
128 let answer = self.post(
129 "grants",
130 &serde_json::json!({
131 "operation": operation(draft, action), "route":"api", "source":source,
132 "recipient":agent, "responsible":responsible,
133 "resource":resource, "relation":relation_of(action),
134 "pass_on":{"kind":"use_only"}, "window":{"starts_at":at_ms / 1000,"ends_at":ends_at}
135 }),
136 )?;
137 let grant = answer
138 .get("grant")
139 .and_then(serde_json::Value::as_str)
140 .filter(|id| {
141 !id.is_empty()
142 && id.len() <= self.metadata_bytes.get()
143 && !id.chars().any(char::is_control)
144 })
145 .ok_or(SiteError::Invalid("Lys grant receipt"))?;
146 Ok(grant.into())
147 }
148
149 fn place_in_network(&self, kind: &str, id: &str) -> Result<(), SiteError> {
152 let Some((path, network)) = &self.sources.placement else {
153 return Ok(());
154 };
155 let credential = zeroize::Zeroizing::new(
156 std::fs::read_to_string(path)
157 .map_err(|_| SiteError::Invalid("app credential unreadable"))?
158 .trim()
159 .to_owned(),
160 );
161 let answer = self
162 .http
163 .post(
164 &credential,
165 "apps/liminal/placements",
166 &serde_json::json!({
167 "operation": operation(&format!("{kind}:{id}"), "placement"),
168 "child": {"kind": kind, "id": id},
169 "parent": {"kind": "liminal.network", "id": network},
170 }),
171 )
172 .map_err(|_| SiteError::Invalid("Lys placement transport failed"))?;
173 if answer.succeeded()
174 || answer.body["reason"]
175 == "placement_invalid: the resource is already placed in a parent"
176 {
177 return Ok(());
178 }
179 Err(SiteError::Pass(answer.refusal()))
180 }
181
182 fn bearer(&self) -> Result<zeroize::Zeroizing<String>, SiteError> {
184 if let Some(path) = &self.sources.credential_path {
185 let credential = std::fs::read_to_string(path)
186 .map_err(|_| SiteError::Invalid("construction grant credential unreadable"))?;
187 return Ok(zeroize::Zeroizing::new(credential.trim().to_owned()));
188 }
189 self.pass
190 .as_ref()
191 .ok_or(SiteError::Invalid("construction machine pass absent"))?
192 .current()
193 .map_err(|_| SiteError::Invalid("machine pass unavailable"))
194 }
195
196 fn post(&self, route: &str, body: &serde_json::Value) -> Result<serde_json::Value, SiteError> {
197 let pass = self.bearer()?;
198 let answer = self
199 .http
200 .post(&pass, route, body)
201 .map_err(|_| SiteError::Invalid("Lys grant transport failed"))?;
202 if !answer.succeeded() {
203 return Err(SiteError::Pass(answer.refusal()));
204 }
205 if answer.body.get("degraded").is_some() {
206 return Err(SiteError::Invalid("Lys grant projection degraded"));
207 }
208 Ok(answer.body)
209 }
210}
211
212#[must_use]
214pub fn operation(draft: &str, effect: &str) -> String {
215 let mut hash = Sha256::new();
216 for value in ["liminal-construction", draft, effect] {
217 hash.update(value.as_bytes());
218 hash.update([0]);
219 }
220 let bytes = hash.finalize();
221 format!("op-{}", hex::encode(&bytes[..16]))
222}
223
224impl SiteEffects for LysSiteEffects {
225 fn agent_owner(&self, agent: &str) -> Result<String, SiteError> {
226 let record = self
227 .agents
228 .registry()
229 .record(agent)
230 .map_err(|_| SiteError::Invalid("builder agent absent"))?;
231 if !record.active() {
232 return Err(SiteError::Refused(format!("agent {agent} is retired")));
233 }
234 Ok(record.owner)
235 }
236 fn bind(
237 &self,
238 draft: &str,
239 site: &str,
240 agent: &str,
241 at_ms: u64,
242 ) -> Result<BuilderGrants, SiteError> {
243 let owner = self.agent_owner(agent)?;
244 self.place_in_network("liminal.service", site)?;
246 let plan = self.delegate(Delegation {
247 draft,
248 site,
249 agent,
250 responsible: &owner,
251 action: "plan",
252 source: &self.sources.plan,
253 at_ms,
254 })?;
255 let serve = self.delegate(Delegation {
256 draft,
257 site,
258 agent,
259 responsible: &owner,
260 action: "serve",
261 source: &self.sources.serve,
262 at_ms,
263 })?;
264 Ok(BuilderGrants { plan, serve })
265 }
266 fn place(&self, kind: &str, id: &str) -> Result<(), SiteError> {
267 self.place_in_network(kind, id)
268 }
269 fn revoke(&self, draft: &str, label: &str, grant: &str) -> Result<(), SiteError> {
270 self.post(&format!("grants/{}/revoke", segment(grant)), &serde_json::json!({
271 "operation":operation(draft,label), "route":"api", "reason":"construction binding ended"
272 })).map(|_| ())
273 }
274}
275
276fn relation_of(action: &str) -> &str {
279 match action {
280 "plan" => "planner",
281 "serve" => "server",
282 other => other,
283 }
284}
285
286#[derive(Clone, Copy)]
287struct Delegation<'a> {
288 draft: &'a str,
289 site: &'a str,
290 agent: &'a str,
291 responsible: &'a str,
292 action: &'a str,
293 source: &'a str,
294 at_ms: u64,
295}