liminal_server/services/
trial.rs1use super::site::SiteError;
4use super::{Lifecycle, ServiceRecord, ServiceState};
5use crate::auth_lys::session::LysSession;
6use crate::auth_lys::{HotAction, SubjectBinding};
7use liminal::protocol::WorkerRegistration;
8use std::sync::Arc;
9
10pub struct TrialPermit {
15 lifecycle: Arc<Lifecycle>,
16 service: String,
17 connection: u64,
18 committed: bool,
19}
20impl std::fmt::Debug for TrialPermit {
21 fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 out.debug_struct("TrialPermit")
23 .field("connection", &self.connection)
24 .finish_non_exhaustive()
25 }
26}
27impl TrialPermit {
28 pub fn commit(mut self) -> Result<(), SiteError> {
33 let mut registry = self.lifecycle.lock()?;
34 let trial = registry
35 .trials
36 .get_mut(&self.service)
37 .and_then(|trials| trials.get_mut(&self.connection))
38 .ok_or(SiteError::Invalid("trial reservation lost"))?;
39 trial.live = true;
40 self.committed = true;
41 drop(registry);
42 self.lifecycle.notify_sites()?;
43 Ok(())
44 }
45}
46
47pub(super) struct Registration {
48 pub subject: String,
49 pub live: bool,
50}
51impl Drop for TrialPermit {
52 fn drop(&mut self) {
53 if !self.committed {
54 if let Ok(mut registry) = self.lifecycle.lock() {
55 if let Some(trials) = registry.trials.get_mut(&self.service) {
56 trials.remove(&self.connection);
57 }
58 registry.trials.retain(|_, trials| !trials.is_empty());
59 } else {
60 tracing::error!("trial_reservation_release_failed");
61 }
62 }
63 }
64}
65
66fn schema_equal(expected: &str, actual: &str) -> Result<bool, SiteError> {
67 let expected: serde_json::Value =
68 serde_json::from_str(expected).map_err(|_| SiteError::Invalid("site schema"))?;
69 let actual: serde_json::Value =
70 serde_json::from_str(actual).map_err(|_| SiteError::Invalid("registered schema"))?;
71 Ok(expected == actual)
72}
73
74pub fn check_contract(
80 record: &ServiceRecord,
81 registration: &WorkerRegistration,
82) -> Result<(), SiteError> {
83 let subject = ®istration.task_queue;
84 let contract = record.contracts.get(subject).ok_or(SiteError::Invalid(
85 "registered subject has no site contract",
86 ))?;
87 if registration.activities.is_empty() {
88 return Err(SiteError::Refused(format!(
89 "subject {subject}: registered input schema differs from site"
90 )));
91 }
92 for activity in ®istration.activities {
93 for (kind, expected, actual) in [
94 (
95 "input",
96 &contract.input_schema_json,
97 &activity.input_schema_json,
98 ),
99 (
100 "output",
101 &contract.output_schema_json,
102 &activity.output_schema_json,
103 ),
104 ] {
105 if !schema_equal(expected, actual).map_err(|_| {
106 SiteError::Refused(format!(
107 "subject {subject}: registered {kind} schema differs from site"
108 ))
109 })? {
110 return Err(SiteError::Refused(format!(
111 "subject {subject}: registered {kind} schema differs from site"
112 )));
113 }
114 }
115 }
116 Ok(())
117}
118
119fn refuse_builder_elsewhere<'a>(
122 records: impl IntoIterator<Item = &'a ServiceRecord>,
123 session: Option<&dyn LysSession>,
124) -> Result<(), SiteError> {
125 let Some(holder) = session
126 .filter(|session| session.holder_kind() == Some("agent"))
127 .map(LysSession::holder)
128 else {
129 return Ok(());
130 };
131 records
132 .into_iter()
133 .find(|record| record.builder_bound && record.builder.as_deref() == Some(holder))
134 .map_or(Ok(()), |site| {
135 Err(SiteError::Refused(format!(
136 "builder of {} serves only its site's subjects",
137 site.name
138 )))
139 })
140}
141
142impl Lifecycle {
143 pub fn prepare_trial(
149 self: &Arc<Self>,
150 connection: u64,
151 session: Option<&dyn LysSession>,
152 registration: &WorkerRegistration,
153 process: Option<&super::ProcessGrant>,
154 at_ms: u64,
155 ) -> Result<Option<TrialPermit>, SiteError> {
156 if crate::server::connection::services::plan_subject(®istration.task_queue).is_some()
157 || crate::server::connection::services::is_site_route(®istration.task_queue)
158 {
159 return Err(SiteError::Invalid("service metadata is server-owned"));
160 }
161 let mut registry = self.lock()?;
162 let Some(record) = registry
163 .records
164 .values()
165 .find(|record| {
166 record.purpose.is_some() && record.subjects.contains(®istration.task_queue)
167 })
168 .cloned()
169 else {
170 refuse_builder_elsewhere(registry.records.values(), session)?;
171 return Ok(None);
172 };
173 check_contract(&record, registration)?;
174 if registration
175 .namespaces
176 .iter()
177 .any(|namespace| !record.subjects.contains(namespace))
178 {
179 return Err(SiteError::Invalid("registered namespaces differ from site"));
180 }
181 if record.state == ServiceState::Retired {
182 return Err(SiteError::Refused(format!(
183 "service {} is retired",
184 record.name
185 )));
186 }
187 if record.state != ServiceState::Building {
188 if process.is_some_and(|grant| {
189 grant.service == record.id
190 && grant.serves(®istration.task_queue)
191 && registry.running.contains_key(&record.id)
192 }) {
193 return Ok(None);
194 }
195 return Err(SiteError::Refused(format!(
196 "service {} requires its template process",
197 record.name
198 )));
199 }
200 let session = session.ok_or(SiteError::Invalid("trial needs an admitted Lys pass"))?;
201 if session.holder_kind() != Some("agent") {
202 return Err(SiteError::Invalid("trial holder is not an agent"));
203 }
204 self.check_plan_holder(&record, session.holder())?;
205 let binding = SubjectBinding::new(
208 self.network.clone(),
209 registration.task_queue.clone(),
210 registration.task_queue.clone(),
211 )
212 .map_err(|_| SiteError::Invalid("trial subject binding"))?;
213 session
214 .decide(&binding, HotAction::Serve, at_ms / 1000)
215 .map_err(|error| SiteError::Pass(error.to_string()))?;
216 if registry
217 .trials
218 .values()
219 .any(|trials| trials.contains_key(&connection))
220 {
221 return Err(SiteError::Refused(format!(
222 "trial still registered on connection {connection}"
223 )));
224 }
225 let trials = registry.trials.entry(record.id.clone()).or_default();
226 if let Some((registered, _)) = trials
227 .iter()
228 .find(|(_, trial)| trial.subject == registration.task_queue)
229 {
230 return Err(SiteError::Refused(format!(
231 "trial still registered on connection {registered}"
232 )));
233 }
234 if trials.len() >= self.site_bindings()?.limits.subjects.get() {
235 return Err(SiteError::Bound("trial registrations"));
236 }
237 trials.insert(
238 connection,
239 Registration {
240 subject: registration.task_queue.clone(),
241 live: false,
242 },
243 );
244 drop(registry);
245 Ok(Some(TrialPermit {
246 lifecycle: Arc::clone(self),
247 service: record.id,
248 connection,
249 committed: false,
250 }))
251 }
252
253 pub fn trial_closed(&self, connection: u64) -> Result<(), SiteError> {
259 let mut registry = self.lock()?;
260 for trials in registry.trials.values_mut() {
261 trials.remove(&connection);
262 }
263 registry.trials.retain(|_, trials| !trials.is_empty());
264 drop(registry);
265 Ok(())
266 }
267
268 pub fn admit_trial_call(
274 &self,
275 session: Option<&dyn LysSession>,
276 subject: &str,
277 at_ms: u64,
278 ) -> Result<bool, SiteError> {
279 let registry = self.lock()?;
280 let Some(record) = registry
281 .records
282 .values()
283 .find(|record| {
284 record.state == ServiceState::Building
285 && record.subjects.iter().any(|own| own == subject)
286 })
287 .cloned()
288 else {
289 return Ok(false);
290 };
291 drop(registry);
292 let session = session
293 .ok_or_else(|| SiteError::Refused(format!("service {} is a trial", record.name)))?;
294 let is_builder = record.builder_bound
295 && record.builder.as_deref() == Some(session.holder())
296 && session.holder_kind() == Some("agent");
297 let is_approver = session.holder_kind() == Some("person")
299 && (record
300 .approved_by
301 .iter()
302 .any(|person| person == session.holder())
303 || record.responsible.as_deref() == Some(session.holder()));
304 if !is_builder && !is_approver {
305 return Err(SiteError::Refused(format!(
306 "service {} is a trial",
307 record.name
308 )));
309 }
310 let binding = SubjectBinding::new(self.network.clone(), subject.into(), subject.into())
311 .map_err(|_| SiteError::Invalid("trial call binding"))?;
312 session
313 .decide(
314 &binding,
315 if is_builder {
316 HotAction::Serve
317 } else {
318 HotAction::Call
319 },
320 at_ms / 1000,
321 )
322 .map_err(|error| SiteError::Pass(error.to_string()))?;
323 Ok(true)
324 }
325
326 pub fn trial_connection(&self, service: &str) -> Result<Option<u64>, SiteError> {
331 Ok(self.lock()?.trials.get(service).and_then(|trials| {
332 trials
333 .iter()
334 .find(|(_, trial)| trial.live)
335 .map(|(connection, _)| *connection)
336 }))
337 }
338}
339
340#[cfg(test)]
341#[path = "trial_tests.rs"]
342mod tests;