use super::site::SiteError;
use super::{Lifecycle, ServiceRecord, ServiceState};
use crate::auth_lys::session::LysSession;
use crate::auth_lys::{HotAction, SubjectBinding};
use liminal::protocol::WorkerRegistration;
use std::sync::Arc;
pub struct TrialPermit {
lifecycle: Arc<Lifecycle>,
service: String,
connection: u64,
committed: bool,
}
impl std::fmt::Debug for TrialPermit {
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
out.debug_struct("TrialPermit")
.field("connection", &self.connection)
.finish_non_exhaustive()
}
}
impl TrialPermit {
pub fn commit(mut self) -> Result<(), SiteError> {
let mut registry = self.lifecycle.lock()?;
let trial = registry
.trials
.get_mut(&self.service)
.and_then(|trials| trials.get_mut(&self.connection))
.ok_or(SiteError::Invalid("trial reservation lost"))?;
trial.live = true;
self.committed = true;
drop(registry);
self.lifecycle.notify_sites()?;
Ok(())
}
}
pub(super) struct Registration {
pub subject: String,
pub live: bool,
}
impl Drop for TrialPermit {
fn drop(&mut self) {
if !self.committed {
if let Ok(mut registry) = self.lifecycle.lock() {
if let Some(trials) = registry.trials.get_mut(&self.service) {
trials.remove(&self.connection);
}
registry.trials.retain(|_, trials| !trials.is_empty());
} else {
tracing::error!("trial_reservation_release_failed");
}
}
}
}
fn schema_equal(expected: &str, actual: &str) -> Result<bool, SiteError> {
let expected: serde_json::Value =
serde_json::from_str(expected).map_err(|_| SiteError::Invalid("site schema"))?;
let actual: serde_json::Value =
serde_json::from_str(actual).map_err(|_| SiteError::Invalid("registered schema"))?;
Ok(expected == actual)
}
pub fn check_contract(
record: &ServiceRecord,
registration: &WorkerRegistration,
) -> Result<(), SiteError> {
let subject = ®istration.task_queue;
let contract = record.contracts.get(subject).ok_or(SiteError::Invalid(
"registered subject has no site contract",
))?;
if registration.activities.is_empty() {
return Err(SiteError::Refused(format!(
"subject {subject}: registered input schema differs from site"
)));
}
for activity in ®istration.activities {
for (kind, expected, actual) in [
(
"input",
&contract.input_schema_json,
&activity.input_schema_json,
),
(
"output",
&contract.output_schema_json,
&activity.output_schema_json,
),
] {
if !schema_equal(expected, actual).map_err(|_| {
SiteError::Refused(format!(
"subject {subject}: registered {kind} schema differs from site"
))
})? {
return Err(SiteError::Refused(format!(
"subject {subject}: registered {kind} schema differs from site"
)));
}
}
}
Ok(())
}
fn refuse_builder_elsewhere<'a>(
records: impl IntoIterator<Item = &'a ServiceRecord>,
session: Option<&dyn LysSession>,
) -> Result<(), SiteError> {
let Some(holder) = session
.filter(|session| session.holder_kind() == Some("agent"))
.map(LysSession::holder)
else {
return Ok(());
};
records
.into_iter()
.find(|record| record.builder_bound && record.builder.as_deref() == Some(holder))
.map_or(Ok(()), |site| {
Err(SiteError::Refused(format!(
"builder of {} serves only its site's subjects",
site.name
)))
})
}
impl Lifecycle {
pub fn prepare_trial(
self: &Arc<Self>,
connection: u64,
session: Option<&dyn LysSession>,
registration: &WorkerRegistration,
process: Option<&super::ProcessGrant>,
at_ms: u64,
) -> Result<Option<TrialPermit>, SiteError> {
if crate::server::connection::services::plan_subject(®istration.task_queue).is_some()
|| crate::server::connection::services::is_site_route(®istration.task_queue)
{
return Err(SiteError::Invalid("service metadata is server-owned"));
}
let mut registry = self.lock()?;
let Some(record) = registry
.records
.values()
.find(|record| {
record.purpose.is_some() && record.subjects.contains(®istration.task_queue)
})
.cloned()
else {
refuse_builder_elsewhere(registry.records.values(), session)?;
return Ok(None);
};
check_contract(&record, registration)?;
if registration
.namespaces
.iter()
.any(|namespace| !record.subjects.contains(namespace))
{
return Err(SiteError::Invalid("registered namespaces differ from site"));
}
if record.state == ServiceState::Retired {
return Err(SiteError::Refused(format!(
"service {} is retired",
record.name
)));
}
if record.state != ServiceState::Building {
if process.is_some_and(|grant| {
grant.service == record.id
&& grant.serves(®istration.task_queue)
&& registry.running.contains_key(&record.id)
}) {
return Ok(None);
}
return Err(SiteError::Refused(format!(
"service {} requires its template process",
record.name
)));
}
let session = session.ok_or(SiteError::Invalid("trial needs an admitted Lys pass"))?;
if session.holder_kind() != Some("agent") {
return Err(SiteError::Invalid("trial holder is not an agent"));
}
self.check_plan_holder(&record, session.holder())?;
let binding = SubjectBinding::new(
self.network.clone(),
registration.task_queue.clone(),
registration.task_queue.clone(),
)
.map_err(|_| SiteError::Invalid("trial subject binding"))?;
session
.decide(&binding, HotAction::Serve, at_ms / 1000)
.map_err(|error| SiteError::Pass(error.to_string()))?;
if registry
.trials
.values()
.any(|trials| trials.contains_key(&connection))
{
return Err(SiteError::Refused(format!(
"trial still registered on connection {connection}"
)));
}
let trials = registry.trials.entry(record.id.clone()).or_default();
if let Some((registered, _)) = trials
.iter()
.find(|(_, trial)| trial.subject == registration.task_queue)
{
return Err(SiteError::Refused(format!(
"trial still registered on connection {registered}"
)));
}
if trials.len() >= self.site_bindings()?.limits.subjects.get() {
return Err(SiteError::Bound("trial registrations"));
}
trials.insert(
connection,
Registration {
subject: registration.task_queue.clone(),
live: false,
},
);
drop(registry);
Ok(Some(TrialPermit {
lifecycle: Arc::clone(self),
service: record.id,
connection,
committed: false,
}))
}
pub fn trial_closed(&self, connection: u64) -> Result<(), SiteError> {
let mut registry = self.lock()?;
for trials in registry.trials.values_mut() {
trials.remove(&connection);
}
registry.trials.retain(|_, trials| !trials.is_empty());
drop(registry);
Ok(())
}
pub fn admit_trial_call(
&self,
session: Option<&dyn LysSession>,
subject: &str,
at_ms: u64,
) -> Result<bool, SiteError> {
let registry = self.lock()?;
let Some(record) = registry
.records
.values()
.find(|record| {
record.state == ServiceState::Building
&& record.subjects.iter().any(|own| own == subject)
})
.cloned()
else {
return Ok(false);
};
drop(registry);
let session = session
.ok_or_else(|| SiteError::Refused(format!("service {} is a trial", record.name)))?;
let is_builder = record.builder_bound
&& record.builder.as_deref() == Some(session.holder())
&& session.holder_kind() == Some("agent");
let is_approver = session.holder_kind() == Some("person")
&& (record
.approved_by
.iter()
.any(|person| person == session.holder())
|| record.responsible.as_deref() == Some(session.holder()));
if !is_builder && !is_approver {
return Err(SiteError::Refused(format!(
"service {} is a trial",
record.name
)));
}
let binding = SubjectBinding::new(self.network.clone(), subject.into(), subject.into())
.map_err(|_| SiteError::Invalid("trial call binding"))?;
session
.decide(
&binding,
if is_builder {
HotAction::Serve
} else {
HotAction::Call
},
at_ms / 1000,
)
.map_err(|error| SiteError::Pass(error.to_string()))?;
Ok(true)
}
pub fn trial_connection(&self, service: &str) -> Result<Option<u64>, SiteError> {
Ok(self.lock()?.trials.get(service).and_then(|trials| {
trials
.iter()
.find(|(_, trial)| trial.live)
.map(|(connection, _)| *connection)
}))
}
}
#[cfg(test)]
#[path = "trial_tests.rs"]
mod tests;