1use std::os::unix::fs::PermissionsExt;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7
8use super::{Lifecycle, LifecycleError, ServiceRecord, ServiceState, Transition};
9use crate::config::templates::is_segment;
10
11#[path = "site/acts.rs"]
12mod acts;
13#[path = "site/declared.rs"]
14pub mod declared;
15#[path = "site/effects.rs"]
16pub mod effects;
17#[path = "site/executor.rs"]
18pub mod executor;
19#[path = "site/outright.rs"]
20mod outright;
21#[path = "site/preparation.rs"]
22pub mod preparation;
23#[path = "site/records.rs"]
24mod records;
25#[path = "site/storage.rs"]
26pub mod storage;
27#[path = "site/topology.rs"]
28mod topology;
29pub use records::{
30 Approval, BuilderGrants, ConstructionConfig, Contract, SiteAct, SiteChange, SiteLimits,
31 SiteSpec,
32};
33
34#[derive(Debug, thiserror::Error)]
36pub enum SiteError {
37 #[error("site_invalid: {0}")]
39 Invalid(&'static str),
40 #[error("site_bound_exceeded: {0}")]
42 Bound(&'static str),
43 #[error("{0}")]
45 Refused(String),
46 #[error("site_store_unavailable")]
48 Store,
49 #[error("site_pass_refused: {0}")]
51 Pass(String),
52 #[error("{0}")]
54 Lifecycle(#[from] LifecycleError),
55}
56
57pub fn validate_contracts(spec: &SiteSpec, limits: &SiteLimits) -> Result<(), SiteError> {
62 if !is_segment(&spec.name) || spec.name.len() > limits.text_bytes.get() {
63 return Err(SiteError::Invalid("name"));
64 }
65 if spec.purpose.trim().is_empty()
66 || spec.purpose.contains(['\n', '\r'])
67 || spec.purpose.len() > limits.text_bytes.get()
68 {
69 return Err(SiteError::Invalid("purpose: one sentence"));
70 }
71 if spec.subjects.is_empty() || spec.subjects.len() > limits.subjects.get() {
72 return Err(SiteError::Bound("subjects"));
73 }
74 let mut seen = std::collections::BTreeSet::new();
75 for subject in &spec.subjects {
76 if subject.len() > limits.text_bytes.get()
77 || !subject.split('.').all(is_segment)
78 || subject.starts_with("console.")
79 || subject.starts_with("agents.")
80 || subject.starts_with("services.")
81 || crate::server::connection::services::is_site_route(subject)
82 || !seen.insert(subject)
83 {
84 return Err(SiteError::Invalid("subject"));
85 }
86 let contract = spec
87 .contracts
88 .get(subject)
89 .ok_or_else(|| SiteError::Refused(format!("subject {subject} has no schema")))?;
90 if contract.input_schema_json.is_empty() || contract.output_schema_json.is_empty() {
91 return Err(SiteError::Refused(format!(
92 "subject {subject} has no schema"
93 )));
94 }
95 for (kind, bytes) in [
96 ("input", &contract.input_schema_json),
97 ("output", &contract.output_schema_json),
98 ] {
99 if bytes.len() > limits.schema_bytes.get() {
100 return Err(SiteError::Bound("schema_bytes"));
101 }
102 let value = serde_json::from_str(bytes).map_err(|_| {
103 SiteError::Refused(format!("subject {subject} has no valid {kind} schema"))
104 })?;
105 liminal::channel::Schema::new(value).map_err(|_| {
106 SiteError::Refused(format!("subject {subject} has no valid {kind} schema"))
107 })?;
108 }
109 }
110 if spec.contracts.len() != seen.len() {
111 return Err(SiteError::Invalid("contracts name undeclared subjects"));
112 }
113 Ok(())
114}
115
116pub(super) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
120 use std::os::unix::fs::DirBuilderExt;
121 if dir.try_exists()? {
122 return Ok(());
123 }
124 if let Some(parent) = dir.parent() {
125 std::fs::create_dir_all(parent)?;
126 }
127 std::fs::DirBuilder::new().mode(0o700).create(dir)
128}
129
130impl Lifecycle {
131 pub fn open_bounded(
137 templates: &[crate::config::types::ServiceTemplateConfig],
138 dir: std::path::PathBuf,
139 network: String,
140 placer: Option<Arc<dyn super::Placer>>,
141 limits: &SiteLimits,
142 ) -> Result<Arc<Self>, LifecycleError> {
143 create_private_dir(&dir).map_err(|error| super::store(&dir, &error))?;
144 if std::fs::metadata(&dir)
145 .map_err(|error| super::store(&dir, &error))?
146 .permissions()
147 .mode()
148 & 0o022
149 != 0
150 {
151 return Err(LifecycleError::Store(
152 "construction_registry_writable_by_others".into(),
153 ));
154 }
155 let snapshot = storage::load_bounded(&dir, limits)?;
156 Ok(Self::open_snapshot(
157 templates, dir, network, placer, snapshot,
158 ))
159 }
160
161 pub fn configure_sites(
167 &self,
168 limits: SiteLimits,
169 store: Arc<dyn liminal::durability::DurableStore>,
170 templates: Option<super::templates::TemplatePolicy>,
171 effects: Arc<dyn effects::SiteEffects>,
172 retained: Arc<crate::agents::retained::Retained>,
173 ) -> Result<(), SiteError> {
174 if std::fs::metadata(&self.dir)
175 .map_err(|_| SiteError::Store)?
176 .permissions()
177 .mode()
178 & 0o022
179 != 0
180 {
181 return Err(SiteError::Invalid(
182 "construction registry must not be writable by group or others",
183 ));
184 }
185 self.site_config
186 .set(storage::SiteBindings {
187 store,
188 limits,
189 templates,
190 effects,
191 retained,
192 })
193 .map_err(|_| SiteError::Invalid("construction already configured"))?;
194 let mut registry = self.lock()?;
195 storage::restore(self, &mut registry)?;
196 drop(registry);
197 self.validate_site_storage()?;
198 let registry = self.lock()?;
199 for record in registry
200 .records
201 .values()
202 .filter(|record| record.purpose.is_some())
203 {
204 validate_contracts(
205 &SiteSpec {
206 name: record.name.clone(),
207 purpose: record.purpose.clone().ok_or(SiteError::Store)?,
208 subjects: record.subjects.clone(),
209 contracts: record.contracts.clone(),
210 },
211 &self.site_bindings()?.limits,
212 )?;
213 self.publish_plan(record)?;
214 }
215 drop(registry);
216 Ok(())
217 }
218
219 pub(super) fn site_bindings(&self) -> Result<&storage::SiteBindings, SiteError> {
220 self.site_config.get().ok_or(SiteError::Invalid(
221 "[auth.lys.construction] is not configured",
222 ))
223 }
224
225 pub(super) fn building(&self, id: &str) -> Result<ServiceRecord, SiteError> {
226 let record = self.record(id)?;
227 if record.state != ServiceState::Building {
228 return Err(SiteError::Refused(format!(
229 "service {} is not building",
230 record.name
231 )));
232 }
233 Ok(record)
234 }
235
236 pub(super) fn publish_plan(&self, record: &ServiceRecord) -> Result<(), SiteError> {
237 let retained = &self.site_bindings()?.retained;
238 let subject = format!("services.{}.plan", record.name);
239 let value = super::plan::current_value(&record.plan);
240 if retained
241 .value(&subject)
242 .map_err(|_| SiteError::Store)?
243 .as_ref()
244 == Some(&value)
245 {
246 return Ok(());
247 }
248 retained
249 .publish(&[(subject, value)])
250 .map_err(|_| SiteError::Store)
251 }
252
253 pub(super) fn site_transition(
254 record: &mut ServiceRecord,
255 holder: &str,
256 at_ms: u64,
257 words: &str,
258 ) {
259 record.transitions.push(Transition {
260 from: record.state,
261 to: record.state,
262 holder: holder.into(),
263 at_ms,
264 note: Some(words.into()),
265 });
266 }
267}
268
269#[derive(Clone, Default, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub(super) struct Construction {
273 pub templates: std::collections::BTreeMap<String, super::templates::TemplateRecord>,
274 pub drafts: std::collections::BTreeMap<String, executor::Prepared>,
275 pub outcomes: std::collections::BTreeMap<String, executor::Outcome>,
276}
277
278#[cfg(test)]
279#[path = "site/preparation_tests.rs"]
280mod preparation_tests;
281#[cfg(test)]
282#[path = "site/test_support.rs"]
283pub(crate) mod test_support;
284#[cfg(test)]
285#[path = "site_tests.rs"]
286mod tests;
287
288impl Lifecycle {
289 #[must_use]
291 pub fn network_id(&self) -> &str {
292 &self.network
293 }
294 pub fn construction_limits(&self) -> Result<SiteLimits, SiteError> {
299 Ok(self.site_bindings()?.limits)
300 }
301 pub fn subscribe_plan(
306 &self,
307 id: u64,
308 subject: &str,
309 schemas: &[liminal::protocol::SchemaId],
310 install: Option<liminal::channel::InboxInstall>,
311 ) -> Result<crate::server::connection::services::ConnectionSubscription, SiteError> {
312 self.site_bindings()?
313 .retained
314 .subscribe(id, subject, schemas, install)
315 .map_err(|error| SiteError::Refused(error.to_string()))
316 }
317}
318
319impl Lifecycle {
320 pub fn bind_site_console(
325 &self,
326 console: &Arc<crate::console::ConsoleHub>,
327 ) -> Result<(), SiteError> {
328 self.site_console
329 .set(Arc::downgrade(console))
330 .map_err(|_| SiteError::Invalid("site console already bound"))
331 }
332 pub fn refresh_sites(&self) -> Result<(), SiteError> {
337 let registry = self.lock()?;
338 for record in registry
339 .records
340 .values()
341 .filter(|record| record.purpose.is_some())
342 {
343 self.publish_plan(record)?;
344 }
345 drop(registry);
346 self.notify_sites()
347 }
348 pub(super) fn notify_sites(&self) -> Result<(), SiteError> {
349 if let Some(console) = self.site_console.get().and_then(std::sync::Weak::upgrade) {
350 console
351 .agent_changed()
352 .map_err(|_| SiteError::Invalid("site topology signal failed"))?;
353 }
354 Ok(())
355 }
356}