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| super::site::declared::builds(*session))
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 site = |record: &&ServiceRecord| {
166 record.purpose.is_some() && record.subjects.contains(®istration.task_queue)
167 };
168 let Some(record) = registry
169 .records
170 .values()
171 .filter(site)
172 .find(|record| record.state != ServiceState::Retired)
173 .or_else(|| registry.records.values().find(site))
174 .cloned()
175 else {
176 refuse_builder_elsewhere(registry.records.values(), session)?;
177 return Ok(None);
178 };
179 check_contract(&record, registration)?;
180 if registration
181 .namespaces
182 .iter()
183 .any(|namespace| !record.subjects.contains(namespace))
184 {
185 return Err(SiteError::Invalid("registered namespaces differ from site"));
186 }
187 if record.state == ServiceState::Retired {
188 return Err(SiteError::Refused(format!(
189 "service {} is retired",
190 record.name
191 )));
192 }
193 if record.state != ServiceState::Building {
194 if process.is_some_and(|grant| {
195 grant.service == record.id
196 && grant.serves(®istration.task_queue)
197 && registry.running.contains_key(&record.id)
198 }) {
199 return Ok(None);
200 }
201 return Err(SiteError::Refused(format!(
202 "service {} requires its template process",
203 record.name
204 )));
205 }
206 let session = session.ok_or(SiteError::Invalid("trial needs an admitted Lys pass"))?;
207 if !super::site::declared::builds(session) {
208 return Err(SiteError::Invalid("trial holder is not an agent"));
209 }
210 self.check_plan_holder(&record, session.holder())?;
211 let binding = SubjectBinding::new(
214 self.network.clone(),
215 registration.task_queue.clone(),
216 registration.task_queue.clone(),
217 )
218 .map_err(|_| SiteError::Invalid("trial subject binding"))?;
219 session
220 .decide(&binding, HotAction::Serve, at_ms / 1000)
221 .map_err(|error| SiteError::Pass(error.to_string()))?;
222 if registry
223 .trials
224 .values()
225 .any(|trials| trials.contains_key(&connection))
226 {
227 return Err(SiteError::Refused(format!(
228 "trial still registered on connection {connection}"
229 )));
230 }
231 let trials = registry.trials.entry(record.id.clone()).or_default();
232 if let Some((registered, _)) = trials
233 .iter()
234 .find(|(_, trial)| trial.subject == registration.task_queue)
235 {
236 return Err(SiteError::Refused(format!(
237 "trial still registered on connection {registered}"
238 )));
239 }
240 if trials.len() >= self.site_bindings()?.limits.subjects.get() {
241 return Err(SiteError::Bound("trial registrations"));
242 }
243 trials.insert(
244 connection,
245 Registration {
246 subject: registration.task_queue.clone(),
247 live: false,
248 },
249 );
250 drop(registry);
251 Ok(Some(TrialPermit {
252 lifecycle: Arc::clone(self),
253 service: record.id,
254 connection,
255 committed: false,
256 }))
257 }
258
259 pub fn trial_closed(&self, connection: u64) -> Result<(), SiteError> {
265 let mut registry = self.lock()?;
266 for trials in registry.trials.values_mut() {
267 trials.remove(&connection);
268 }
269 registry.trials.retain(|_, trials| !trials.is_empty());
270 drop(registry);
271 Ok(())
272 }
273
274 pub fn admit_trial_call(
280 &self,
281 session: Option<&dyn LysSession>,
282 subject: &str,
283 at_ms: u64,
284 ) -> Result<bool, SiteError> {
285 let registry = self.lock()?;
286 let Some(record) = registry
287 .records
288 .values()
289 .find(|record| {
290 record.state == ServiceState::Building
291 && record.subjects.iter().any(|own| own == subject)
292 })
293 .cloned()
294 else {
295 return Ok(false);
296 };
297 drop(registry);
298 let session = session
299 .ok_or_else(|| SiteError::Refused(format!("service {} is a trial", record.name)))?;
300 let is_builder = record.builder_bound
301 && record.builder.as_deref() == Some(session.holder())
302 && super::site::declared::builds(session);
303 let is_approver = super::site::declared::answerable(session).is_some_and(|holder| {
306 record.approved_by.contains(&holder) || record.responsible.as_ref() == Some(&holder)
307 });
308 if !is_builder && !is_approver {
309 return Err(SiteError::Refused(format!(
310 "service {} is a trial",
311 record.name
312 )));
313 }
314 let binding = SubjectBinding::new(self.network.clone(), subject.into(), subject.into())
315 .map_err(|_| SiteError::Invalid("trial call binding"))?;
316 session
317 .decide(
318 &binding,
319 if is_builder {
320 HotAction::Serve
321 } else {
322 HotAction::Call
323 },
324 at_ms / 1000,
325 )
326 .map_err(|error| SiteError::Pass(error.to_string()))?;
327 Ok(true)
328 }
329
330 pub fn trial_connection(&self, service: &str) -> Result<Option<u64>, SiteError> {
335 Ok(self.lock()?.trials.get(service).and_then(|trials| {
336 trials
337 .iter()
338 .find(|(_, trial)| trial.live)
339 .map(|(connection, _)| *connection)
340 }))
341 }
342}
343
344#[cfg(test)]
345#[path = "trial_tests.rs"]
346mod tests;