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