Skip to main content

liminal_server/services/
trial.rs

1//! Trial admission is an additional fence over the pass's own serve/call grants.
2
3use 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
10/// A reservation blocks completion while a registration is being installed.
11///
12/// Dropping an uncommitted reservation rolls back that admission. A successful
13/// owner commits it only after storing the registration in its own directory.
14pub 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    /// Makes the reservation live after the directory accepted the worker.
29    ///
30    /// # Errors
31    /// The connection closed or its reserved subject changed before commit.
32    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
74/// Compares every descriptor's schemas to the immutable promise for this subject.
75/// A raw/untyped registration cannot satisfy a contracts-first site.
76///
77/// # Errors
78/// Missing contract, missing descriptor, or a subject-named input/output mismatch.
79pub fn check_contract(
80    record: &ServiceRecord,
81    registration: &WorkerRegistration,
82) -> Result<(), SiteError> {
83    let subject = &registration.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 &registration.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
119/// Lys passes a builder's serve grant on the network, so the exchange keeps a bound builder to
120/// its own site's subjects: while bound it serves nothing else.
121fn 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    /// Validates schemas and signed builder identity, then reserves completion's
144    /// barrier before the directory stores anything. Both checks precede serving.
145    ///
146    /// # Errors
147    /// Foreign holder, schema mismatch, missing serve grant or another trial.
148    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(&registration.task_queue).is_some()
157            || crate::server::connection::services::is_site_route(&registration.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(&registration.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(&registration.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        // A serve right on the service is explicitly reached in the signed pass;
206        // the ordinary subject decision still runs in the registration owner.
207        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    /// Called by the directory owner for close and replacement, under the same
254    /// registration hold that removes the index entry. No timer infers closure.
255    ///
256    /// # Errors
257    /// A poisoned registry prevents an uncertain completion.
258    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    /// Fences every Building subject before routing and again before reply
269    /// release. Identity alone permits no call; the caller still needs its grant.
270    ///
271    /// # Errors
272    /// A third party, absent pass or the pass's own call refusal.
273    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        // The approvers of a drafted site, or the person answerable for a site Lys admitted outright.
298        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    /// Current registered or reserved trial connection, used by topology.
327    ///
328    /// # Errors
329    /// The registry lock is poisoned.
330    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;