Skip to main content

liminal_server/services/site/
storage.rs

1//! One snapshot operation keeps the target, approval, receipt and outcome.
2
3use 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
15/// Operator policy and runtime effects, never serialized or included in Debug.
16pub 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
115/// Migrates old installs by reading each service once, then committing the
116/// complete new snapshot before returning. Old files are kept as evidence,
117/// and are never replayed after the snapshot exists.
118pub(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    /// Returns one bounded current snapshot for an approval owner's atomic commit.
247    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    /// Validates every restored construction count before admission.
255    ///
256    /// # Errors
257    /// Oversized or inconsistent restored history.
258    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(&registry);
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 &registry.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(&registry, 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 &registry.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
365/// Imports old file records only when the new durable key is absent. Once kept,
366/// the conditional key is authoritative and a stale archive is never replayed.
367pub(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}