liminal_server/services/site/
storage.rs1use haematite::tree::Hash;
4use liminal::durability::{DurableStore, RoutedBatchItem, bridge::block_on};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::io::Read;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use super::super::{Lifecycle, LifecycleError, Registry, ServiceRecord, store};
13use super::{Construction, SiteError, SiteLimits};
14
15pub struct SiteBindings {
17 pub(in super::super) store: Arc<dyn DurableStore>,
18 pub(in super::super) limits: SiteLimits,
19 pub(in super::super) templates: Option<super::super::templates::TemplatePolicy>,
20 pub(in super::super) effects: Arc<dyn super::effects::SiteEffects>,
21 pub(in super::super) retained: Arc<crate::agents::retained::Retained>,
22}
23
24#[derive(Clone, Default, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub(in super::super) struct Snapshot {
27 pub records: BTreeMap<String, ServiceRecord>,
28 #[serde(default)]
29 pub construction: Construction,
30}
31
32fn path(dir: &Path) -> PathBuf {
33 dir.join("construction").join("registry.json")
34}
35
36fn read_bounded(path: &Path, bound: usize) -> Result<Vec<u8>, LifecycleError> {
37 if !std::fs::symlink_metadata(path)
38 .map_err(|error| store(path, &error))?
39 .is_file()
40 {
41 return Err(LifecycleError::Store(
42 "construction_snapshot_not_regular".into(),
43 ));
44 }
45 let canonical = path.canonicalize().map_err(|error| store(path, &error))?;
46 let file = super::super::templates::open_command(&canonical)
47 .map_err(|_| LifecycleError::Store("construction_snapshot_read_failed".into()))?;
48 if !file
49 .metadata()
50 .map_err(|error| store(path, &error))?
51 .is_file()
52 {
53 return Err(LifecycleError::Store(
54 "construction_snapshot_not_regular".into(),
55 ));
56 }
57 let limit =
58 u64::try_from(bound).map_err(|_| LifecycleError::Store("snapshot_bytes_invalid".into()))?;
59 let mut bytes = Vec::new();
60 file.take(limit.saturating_add(1))
61 .read_to_end(&mut bytes)
62 .map_err(|error| store(path, &error))?;
63 if bytes.len() > bound {
64 return Err(LifecycleError::Store(
65 "snapshot_bytes_bound_exceeded".into(),
66 ));
67 }
68 Ok(bytes)
69}
70
71pub(in super::super) fn load_bounded(
72 dir: &Path,
73 limits: &SiteLimits,
74) -> Result<Snapshot, LifecycleError> {
75 let archive = path(dir);
76 match std::fs::symlink_metadata(&archive) {
77 Ok(metadata) => {
78 if !metadata.is_file() {
79 return Err(LifecycleError::Store(
80 "construction_snapshot_not_regular".into(),
81 ));
82 }
83 return serde_json::from_slice(&read_bounded(&archive, limits.snapshot_bytes.get())?)
84 .map_err(|_| LifecycleError::Store("construction_snapshot_invalid".into()));
85 }
86 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
87 Err(error) => return Err(store(&archive, &error)),
88 }
89 let mut snapshot = Snapshot::default();
90 let mut total = 0_usize;
91 for entry in std::fs::read_dir(dir).map_err(|error| store(dir, &error))? {
92 let path = entry.map_err(|error| store(dir, &error))?.path();
93 if path.extension().is_none_or(|extension| extension != "json") {
94 continue;
95 }
96 if snapshot.records.len() >= limits.sites.get() {
97 return Err(LifecycleError::Store(
98 "migration_records_bound_exceeded".into(),
99 ));
100 }
101 let bytes = read_bounded(&path, limits.snapshot_bytes.get().saturating_sub(total))?;
102 total = total
103 .checked_add(bytes.len())
104 .ok_or_else(|| LifecycleError::Store("snapshot_bytes_bound_exceeded".into()))?;
105 let record: ServiceRecord = serde_json::from_slice(&bytes)
106 .map_err(|_| LifecycleError::Store("legacy_service_record_invalid".into()))?;
107 if record.id.is_empty() || snapshot.records.insert(record.id.clone(), record).is_some() {
108 return Err(LifecycleError::Store("legacy_service_id_repeated".into()));
109 }
110 }
111 write(dir, &snapshot, Some(limits.snapshot_bytes.get()))?;
112 Ok(snapshot)
113}
114
115pub(in super::super) fn load(dir: &Path) -> Result<Snapshot, LifecycleError> {
119 let snapshot_path = path(dir);
120 match std::fs::File::open(&snapshot_path) {
121 Ok(file) => {
122 return serde_json::from_reader(file)
123 .map_err(|_| LifecycleError::Store("construction_snapshot_invalid".into()));
124 }
125 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
126 Err(error) => return Err(store(&snapshot_path, &error)),
127 }
128 let mut snapshot = Snapshot::default();
129 for entry in std::fs::read_dir(dir).map_err(|error| store(dir, &error))? {
130 let path = entry.map_err(|error| store(dir, &error))?.path();
131 if path.extension().is_none_or(|extension| extension != "json") {
132 continue;
133 }
134 let record: ServiceRecord = serde_json::from_reader(
135 std::fs::File::open(&path).map_err(|error| store(&path, &error))?,
136 )
137 .map_err(|_| LifecycleError::Store("legacy_service_record_invalid".into()))?;
138 if record.id.is_empty() || snapshot.records.insert(record.id.clone(), record).is_some() {
139 return Err(LifecycleError::Store("legacy_service_id_repeated".into()));
140 }
141 }
142 write(dir, &snapshot, None)?;
143 Ok(snapshot)
144}
145
146pub(in super::super) fn keep(
147 lifecycle: &Lifecycle,
148 registry: &mut Registry,
149 record: &ServiceRecord,
150) -> Result<(), LifecycleError> {
151 let mut snapshot = Snapshot {
152 records: registry.records.clone(),
153 construction: registry.construction.clone(),
154 };
155 snapshot.records.insert(record.id.clone(), record.clone());
156 commit(lifecycle, registry, &snapshot)
157}
158
159pub(in super::super) fn commit(
160 lifecycle: &Lifecycle,
161 registry: &mut Registry,
162 snapshot: &Snapshot,
163) -> Result<(), LifecycleError> {
164 let bound = lifecycle
165 .site_config
166 .get()
167 .map(|bindings| bindings.limits.snapshot_bytes.get());
168 let bytes = serde_json::to_vec(snapshot)
169 .map_err(|_| LifecycleError::Store("snapshot_encoding_failed".into()))?;
170 if bound.is_some_and(|limit| bytes.len() > limit) {
171 return Err(LifecycleError::Store(
172 "snapshot_bytes_bound_exceeded".into(),
173 ));
174 }
175 if let Some(bindings) = lifecycle.site_config.get() {
176 if bindings
177 .store
178 .max_request_bytes()
179 .is_some_and(|limit| bytes.len() > limit)
180 {
181 return Err(LifecycleError::Store(
182 "store_request_bytes_bound_exceeded".into(),
183 ));
184 }
185 let hash = Hash::of(&bytes);
186 let result = block_on(bindings.store.apply_routed_batch(
187 ROUTE,
188 vec![RoutedBatchItem {
189 key: KEY.into(),
190 expected: registry.construction_hash,
191 value: bytes,
192 ttl: None,
193 }],
194 ))
195 .map_err(|_| LifecycleError::Store("construction_batch_failed".into()))
196 .and_then(|result| {
197 result.map_err(|_| LifecycleError::Store("construction_batch_failed".into()))
198 })
199 .and_then(|()| {
200 block_on(bindings.store.flush())
201 .map_err(|_| LifecycleError::Store("construction_flush_failed".into()))
202 .and_then(|result| {
203 result.map_err(|_| LifecycleError::Store("construction_flush_failed".into()))
204 })
205 });
206 if let Err(error) = result {
207 registry.poisoned = true;
208 return Err(error);
209 }
210 registry.construction_hash = Some(hash);
211 } else if let Err(error) = write(&lifecycle.dir, snapshot, None) {
212 registry.poisoned = true;
213 return Err(error);
214 }
215 registry.records = snapshot.records.clone();
216 registry.construction = snapshot.construction.clone();
217 lifecycle.changed.notify_all();
218 Ok(())
219}
220
221fn write(dir: &Path, snapshot: &Snapshot, bound: Option<usize>) -> Result<(), LifecycleError> {
222 let bytes = serde_json::to_vec(snapshot)
223 .map_err(|_| LifecycleError::Store("snapshot_encoding_failed".into()))?;
224 if bound.is_some_and(|limit| bytes.len() > limit) {
225 return Err(LifecycleError::Store(
226 "snapshot_bytes_bound_exceeded".into(),
227 ));
228 }
229 let destination = path(dir);
230 let parent = destination
231 .parent()
232 .ok_or_else(|| LifecycleError::Store("snapshot_parent_absent".into()))?;
233 std::fs::create_dir_all(parent).map_err(|error| store(parent, &error))?;
234 let temporary = parent.join("registry.new");
235 let mut file = std::fs::File::create(&temporary).map_err(|error| store(&temporary, &error))?;
236 file.write_all(&bytes)
237 .and_then(|()| file.sync_all())
238 .map_err(|error| store(&temporary, &error))?;
239 std::fs::rename(&temporary, &destination).map_err(|error| store(&destination, &error))?;
240 std::fs::File::open(parent)
241 .and_then(|directory| directory.sync_all())
242 .map_err(|error| store(parent, &error))
243}
244
245impl Lifecycle {
246 pub(in super::super) fn snapshot(registry: &Registry) -> Snapshot {
248 Snapshot {
249 records: registry.records.clone(),
250 construction: registry.construction.clone(),
251 }
252 }
253
254 pub fn validate_site_storage(&self) -> Result<(), SiteError> {
259 let limits = &self.site_bindings()?.limits;
260 let registry = self.lock()?;
261 let snapshot = Self::snapshot(®istry);
262 if serde_json::to_vec(&snapshot)
263 .map_err(|_| SiteError::Store)?
264 .len()
265 > limits.snapshot_bytes.get()
266 {
267 return Err(SiteError::Bound("snapshot_bytes"));
268 }
269 if snapshot.construction.drafts.len() > limits.drafts.get()
270 || snapshot.construction.outcomes.len() > limits.drafts.get()
271 {
272 return Err(SiteError::Bound("drafts"));
273 }
274 if registry
275 .records
276 .values()
277 .filter(|record| record.purpose.is_some())
278 .count()
279 > limits.sites.get()
280 {
281 return Err(SiteError::Bound("sites"));
282 }
283 for (id, record) in ®istry.records {
284 if id != &record.id || !crate::config::templates::is_segment(id) {
285 return Err(SiteError::Invalid("stored service id"));
286 }
287 if record.purpose.is_some() && record.transitions.len() > limits.transitions.get() {
288 return Err(SiteError::Bound("transitions"));
289 }
290 if record.plan.steps.len() > limits.steps.get()
291 || record.plan.transitions.len() > limits.transitions.get()
292 {
293 return Err(SiteError::Bound("plan history"));
294 }
295 record.plan.validate(limits)?;
296 if record.builder_bound
297 && (record.state != super::super::ServiceState::Building
298 || record.builder.is_none()
299 || record.builder_grants.is_none())
300 {
301 return Err(SiteError::Invalid("stored builder binding"));
302 }
303 if record.approvals.len() > limits.transitions.get()
304 || record.approved_by.len() > limits.approvers.get()
305 {
306 return Err(SiteError::Bound("stored service approvals"));
307 }
308 }
309 validate_templates(®istry, self.site_bindings()?)?;
310 drop(registry);
311 Ok(())
312 }
313}
314
315fn validate_templates(registry: &Registry, bindings: &SiteBindings) -> Result<(), SiteError> {
316 if registry
317 .construction
318 .outcomes
319 .keys()
320 .any(|id| !registry.construction.drafts.contains_key(id))
321 {
322 return Err(SiteError::Invalid("stored outcome has no preparation"));
323 }
324 if registry.construction.templates.is_empty() {
325 return Ok(());
326 }
327 let policy = bindings
328 .templates
329 .as_ref()
330 .ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
331 if registry.construction.templates.len() > policy.records.get() {
332 return Err(SiteError::Bound("template records"));
333 }
334 for (name, template) in ®istry.construction.templates {
335 policy.validate(&template.proposal)?;
336 let service = registry
337 .records
338 .get(&template.proposal.service)
339 .ok_or(SiteError::Invalid("stored template site"))?;
340 if name != &template.proposal.name
341 || template.proposal.subjects != service.subjects
342 || !template.proposal.command.is_absolute()
343 || template.proposal.digest.len() != 64
344 || !template
345 .proposal
346 .digest
347 .bytes()
348 .all(|byte| byte.is_ascii_hexdigit())
349 {
350 return Err(SiteError::Invalid("stored template contract or digest"));
351 }
352 if template.transitions.len() > bindings.limits.transitions.get()
353 || template.approved_by.is_empty()
354 || template.approved_by.len() > bindings.limits.approvers.get()
355 {
356 return Err(SiteError::Bound("stored template approvals"));
357 }
358 }
359 Ok(())
360}
361
362const ROUTE: &str = "liminal/construction/v1";
363const KEY: &[u8] = b"construction";
364
365pub(in super::super) fn restore(
368 lifecycle: &Lifecycle,
369 registry: &mut Registry,
370) -> Result<(), SiteError> {
371 let bindings = lifecycle.site_bindings()?;
372 let bytes = block_on(bindings.store.get_routed(ROUTE, KEY))
373 .map_err(|_| SiteError::Store)?
374 .map_err(|_| SiteError::Store)?;
375 if let Some(bytes) = bytes {
376 if bytes.len() > bindings.limits.snapshot_bytes.get() {
377 return Err(SiteError::Bound("snapshot_bytes"));
378 }
379 let snapshot: Snapshot = serde_json::from_slice(&bytes).map_err(|_| SiteError::Store)?;
380 registry.construction_hash = Some(Hash::of(&bytes));
381 registry.records = snapshot.records;
382 registry.construction = snapshot.construction;
383 } else {
384 let snapshot = Lifecycle::snapshot(registry);
385 commit(lifecycle, registry, &snapshot)?;
386 }
387 Ok(())
388}