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| 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    /// 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        // A retired site's subjects can pass to a new site: the site still
163        // standing owns the subject, and a retired one only answers when
164        // no other site holds it.
165        let site = |record: &&ServiceRecord| {
166            record.purpose.is_some() && record.subjects.contains(&registration.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(&registration.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        // A serve right on the service is explicitly reached in the signed pass;
212        // the ordinary subject decision still runs in the registration owner.
213        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    /// Called by the directory owner for close and replacement, under the same
260    /// registration hold that removes the index entry. No timer infers closure.
261    ///
262    /// # Errors
263    /// A poisoned registry prevents an uncertain completion.
264    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    /// Fences every Building subject before routing and again before reply
275    /// release. Identity alone permits no call; the caller still needs its grant.
276    ///
277    /// # Errors
278    /// A third party, absent pass or the pass's own call refusal.
279    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        // The approvers of a drafted site, or the holder answerable for a site Lys admitted
304        // outright or an open seat built (ACCESS-015 R3).
305        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    /// Current registered or reserved trial connection, used by topology.
331    ///
332    /// # Errors
333    /// The registry lock is poisoned.
334    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;