use super::super::{Lifecycle, ServiceRecord, ServiceState};
use super::storage::Snapshot;
use super::{SiteAct, SiteChange, SiteError, validate_contracts};
use crate::auth_lys::live::drafts::ApprovalTransition;
impl Lifecycle {
pub(super) fn apply_site_act(
&self,
snapshot: &mut Snapshot,
act: &SiteAct,
transition: &ApprovalTransition,
prepared_at_ms: u64,
trials: &std::collections::BTreeMap<
String,
std::collections::BTreeMap<u64, super::super::trial::Registration>,
>,
) -> Result<(), SiteError> {
let bindings = self.site_bindings()?;
let limits = &bindings.limits;
if let SiteChange::CreateSite(spec) = &act.change {
return self.create_site(snapshot, act, spec, transition);
}
if matches!(
act.change,
SiteChange::CreateTemplate(_)
| SiteChange::UpdateTemplate(_)
| SiteChange::RetireTemplate
) {
return self.apply_template(snapshot, act, transition);
}
let record = snapshot
.records
.get_mut(&act.target)
.ok_or_else(|| SiteError::Refused(format!("unknown_service: {}", act.target)))?;
if record.state != ServiceState::Building {
return Err(SiteError::Refused(format!(
"service {} is not building",
record.name
)));
}
if record.transitions.len() >= limits.transitions.get() {
return Err(SiteError::Bound("transitions"));
}
match &act.change {
SiteChange::BindBuilder { agent } => {
self.bind_site_builder(record, agent, transition, prepared_at_ms, trials)?;
}
SiteChange::UnbindBuilder => {
if !record.builder_bound {
return Err(SiteError::Invalid("site has no bound builder"));
}
let grants = record.builder_grants.as_ref().ok_or(SiteError::Store)?;
bindings
.effects
.revoke(&transition.draft, "unbind-plan", &grants.plan)?;
bindings
.effects
.revoke(&transition.draft, "unbind-serve", &grants.serve)?;
record.builder_bound = false;
record.builder = None;
}
SiteChange::Complete => {
self.complete_site(record, &snapshot.construction, transition, trials)?;
}
_ => return Err(SiteError::Invalid("construction act")),
}
record.approvals.push(transition.clone().into());
let before = if matches!(act.change, SiteChange::Complete) {
ServiceState::Building
} else {
record.state
};
record.transitions.push(super::super::Transition {
from: before,
to: record.state,
holder: act.holder.clone(),
at_ms: transition.executed_at_ms,
note: Some(act.change.action().into()),
});
Ok(())
}
fn complete_site(
&self,
record: &mut ServiceRecord,
construction: &super::Construction,
transition: &ApprovalTransition,
trials: &std::collections::BTreeMap<
String,
std::collections::BTreeMap<u64, super::super::trial::Registration>,
>,
) -> Result<(), SiteError> {
let bindings = self.site_bindings()?;
if let Some(connection) = trials
.get(&record.id)
.and_then(|trials| trials.keys().next())
{
return Err(SiteError::Refused(format!(
"trial still registered on connection {connection}"
)));
}
super::super::plan::refuse_unfinished(&record.plan)?;
let template = record
.template
.as_ref()
.and_then(|name| construction.templates.get(name))
.filter(|template| !template.retired)
.ok_or(SiteError::Invalid("site has no approved template"))?;
if template.proposal.subjects != record.subjects {
return Err(SiteError::Invalid("template subjects differ from site"));
}
self.verify_template(template)?;
let grants = record
.builder_grants
.as_ref()
.ok_or(SiteError::Invalid("site has no bound builder"))?;
bindings
.effects
.revoke(&transition.draft, "complete-serve", &grants.serve)?;
record.state = ServiceState::Asleep;
record.builder_bound = false;
Ok(())
}
fn bind_site_builder(
&self,
record: &mut ServiceRecord,
agent: &str,
transition: &ApprovalTransition,
prepared_at_ms: u64,
trials: &std::collections::BTreeMap<
String,
std::collections::BTreeMap<u64, super::super::trial::Registration>,
>,
) -> Result<(), SiteError> {
let bindings = self.site_bindings()?;
let limits = &bindings.limits;
if !crate::config::templates::is_segment(agent) || agent.len() > limits.text_bytes.get() {
return Err(SiteError::Invalid("builder"));
}
if record.builder_bound {
return Err(SiteError::Refused(format!(
"site {} has builder {}",
record.name,
record.builder.as_deref().ok_or(SiteError::Store)?
)));
}
if let Some(connection) = trials
.get(&record.id)
.and_then(|trials| trials.keys().next())
{
return Err(SiteError::Refused(format!(
"trial still registered on connection {connection}"
)));
}
let grants = bindings
.effects
.bind(&transition.draft, &record.id, agent, prepared_at_ms)?;
record.builder = Some(agent.into());
record.builder_grants = Some(grants);
record.builder_bound = true;
Ok(())
}
fn create_site(
&self,
snapshot: &mut Snapshot,
act: &SiteAct,
spec: &super::SiteSpec,
transition: &ApprovalTransition,
) -> Result<(), SiteError> {
let limits = &self.site_bindings()?.limits;
validate_contracts(spec, limits)?;
if act.target != spec.name {
return Err(SiteError::Invalid("site target"));
}
if snapshot.records.contains_key(&act.target)
|| snapshot
.records
.values()
.any(|record| record.name == spec.name)
{
return Err(SiteError::Refused(format!("name_taken: {}", spec.name)));
}
if snapshot
.records
.values()
.filter(|record| record.purpose.is_some())
.count()
>= limits.sites.get()
{
return Err(SiteError::Bound("sites"));
}
for subject in &spec.subjects {
if let Some(owner) = snapshot
.records
.values()
.find(|record| record.subjects.contains(subject))
{
return Err(SiteError::Refused(format!(
"subject {subject} belongs to service {}",
owner.name
)));
}
}
let placer = self
.placer
.as_ref()
.ok_or(super::super::LifecycleError::NoPlacer)?;
placer
.place(
&super::effects::operation(&transition.draft, "site"),
("liminal.service", &act.target),
("liminal.network", &self.network),
)
.map_err(SiteError::Pass)?;
for subject in &spec.subjects {
placer
.place(
&super::effects::operation(&transition.draft, &format!("subject:{subject}")),
("liminal.subject", subject),
("liminal.service", &act.target),
)
.map_err(SiteError::Pass)?;
}
let plan_subject = format!("services.{}.plan", spec.name);
placer
.place(
&super::effects::operation(&transition.draft, "plan-subject"),
("liminal.subject", &plan_subject),
("liminal.network", &self.network),
)
.map_err(SiteError::Pass)?;
let mut record = ServiceRecord {
id: act.target.clone(),
name: spec.name.clone(),
template: None,
subjects: spec.subjects.clone(),
purpose: Some(spec.purpose.clone()),
contracts: spec.contracts.clone(),
builder: None,
builder_grants: None,
builder_bound: false,
approved_by: transition.approved_by.clone(),
responsible: Some(act.responsible.clone()),
plan: super::super::plan::Plan::default(),
approvals: vec![transition.clone().into()],
state: ServiceState::Building,
created_by: act.holder.clone(),
transitions: Vec::new(),
};
Self::site_transition(
&mut record,
&act.holder,
transition.executed_at_ms,
"site created",
);
snapshot.records.insert(act.target.clone(), record);
Ok(())
}
fn apply_template(
&self,
snapshot: &mut Snapshot,
act: &SiteAct,
transition: &ApprovalTransition,
) -> Result<(), SiteError> {
let bindings = self.site_bindings()?;
let policy = bindings
.templates
.as_ref()
.ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
if self.templates.contains_key(&act.target) {
return Err(SiteError::Refused(format!(
"template {} is read-only configuration",
act.target
)));
}
if matches!(act.change, SiteChange::RetireTemplate) {
let record = snapshot
.construction
.templates
.get_mut(&act.target)
.ok_or(SiteError::Invalid("template absent"))?;
if record.retired {
return Err(SiteError::Invalid("template retired"));
}
if record.transitions.len() >= bindings.limits.transitions.get() {
return Err(SiteError::Bound("transitions"));
}
record.retired = true;
record.transitions.push(transition.clone().into());
return Ok(());
}
let (proposal, creating) = match &act.change {
SiteChange::CreateTemplate(proposal) => (proposal, true),
SiteChange::UpdateTemplate(proposal) => (proposal, false),
_ => return Err(SiteError::Invalid("template act")),
};
let old = snapshot.construction.templates.get(&act.target);
self.check_template_act(snapshot, act, proposal, creating, old)?;
let mut transitions = old.map_or_else(Vec::new, |template| template.transitions.clone());
if transitions.len() >= bindings.limits.transitions.get() {
return Err(SiteError::Bound("transitions"));
}
let (_, actual) = policy.digest(&proposal.name, &proposal.command)?;
if actual != proposal.digest {
return Err(SiteError::Refused(format!(
"template {}: command changed since proposal (digest {} != {actual})",
proposal.name, proposal.digest
)));
}
transitions.push(transition.clone().into());
let template = super::super::templates::TemplateRecord {
proposal: proposal.clone(),
approved_by: transition.approved_by.clone(),
approved_at_ms: transition.executed_at_ms,
retired: false,
transitions,
};
let placer = self
.placer
.as_ref()
.ok_or(super::super::LifecycleError::NoPlacer)?;
if creating {
placer
.place(
&super::effects::operation(&transition.draft, "template"),
("liminal.template", &act.target),
("liminal.network", &self.network),
)
.map_err(SiteError::Pass)?;
}
snapshot
.construction
.templates
.insert(act.target.clone(), template);
snapshot
.records
.get_mut(&proposal.service)
.ok_or(SiteError::Store)?
.template = Some(act.target.clone());
Ok(())
}
fn check_template_act(
&self,
snapshot: &Snapshot,
act: &SiteAct,
proposal: &super::super::templates::TemplateProposal,
creating: bool,
old: Option<&super::super::templates::TemplateRecord>,
) -> Result<(), SiteError> {
let bindings = self.site_bindings()?;
let policy = bindings
.templates
.as_ref()
.ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
policy.validate(proposal)?;
if proposal.name != act.target {
return Err(SiteError::Invalid("template target"));
}
let site = snapshot
.records
.get(&proposal.service)
.ok_or(SiteError::Invalid("template site absent"))?;
if (creating && site.state != ServiceState::Building)
|| site.state == ServiceState::Retired
|| proposal.subjects != site.subjects
{
return Err(SiteError::Invalid(
"template subjects differ from building site",
));
}
if creating && old.is_some() {
return Err(SiteError::Refused(format!(
"template {} already exists",
act.target
)));
}
if !creating && old.is_none_or(|template| template.retired) {
return Err(SiteError::Invalid("template absent or retired"));
}
if creating && snapshot.construction.templates.len() >= policy.records.get() {
return Err(SiteError::Bound("template records"));
}
if old.is_some_and(|template| template.proposal.service != proposal.service) {
return Err(SiteError::Invalid("template belongs to another site"));
}
Ok(())
}
}