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