use haematite::tree::Hash;
use liminal::durability::{DurableStore, RoutedBatchItem, bridge::block_on};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Read;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::super::{Lifecycle, LifecycleError, Registry, ServiceRecord, store};
use super::{Construction, SiteError, SiteLimits};
pub struct SiteBindings {
pub(in super::super) store: Arc<dyn DurableStore>,
pub(in super::super) limits: SiteLimits,
pub(in super::super) templates: Option<super::super::templates::TemplatePolicy>,
pub(in super::super) effects: Arc<dyn super::effects::SiteEffects>,
pub(in super::super) retained: Arc<crate::agents::retained::Retained>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(in super::super) struct Snapshot {
pub records: BTreeMap<String, ServiceRecord>,
#[serde(default)]
pub construction: Construction,
}
fn path(dir: &Path) -> PathBuf {
dir.join("construction").join("registry.json")
}
fn read_bounded(path: &Path, bound: usize) -> Result<Vec<u8>, LifecycleError> {
if !std::fs::symlink_metadata(path)
.map_err(|error| store(path, &error))?
.is_file()
{
return Err(LifecycleError::Store(
"construction_snapshot_not_regular".into(),
));
}
let canonical = path.canonicalize().map_err(|error| store(path, &error))?;
let file = super::super::templates::open_command(&canonical)
.map_err(|_| LifecycleError::Store("construction_snapshot_read_failed".into()))?;
if !file
.metadata()
.map_err(|error| store(path, &error))?
.is_file()
{
return Err(LifecycleError::Store(
"construction_snapshot_not_regular".into(),
));
}
let limit =
u64::try_from(bound).map_err(|_| LifecycleError::Store("snapshot_bytes_invalid".into()))?;
let mut bytes = Vec::new();
file.take(limit.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|error| store(path, &error))?;
if bytes.len() > bound {
return Err(LifecycleError::Store(
"snapshot_bytes_bound_exceeded".into(),
));
}
Ok(bytes)
}
pub(in super::super) fn load_bounded(
dir: &Path,
limits: &SiteLimits,
) -> Result<Snapshot, LifecycleError> {
let archive = path(dir);
match std::fs::symlink_metadata(&archive) {
Ok(metadata) => {
if !metadata.is_file() {
return Err(LifecycleError::Store(
"construction_snapshot_not_regular".into(),
));
}
return serde_json::from_slice(&read_bounded(&archive, limits.snapshot_bytes.get())?)
.map_err(|_| LifecycleError::Store("construction_snapshot_invalid".into()));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(store(&archive, &error)),
}
let mut snapshot = Snapshot::default();
let mut total = 0_usize;
for entry in std::fs::read_dir(dir).map_err(|error| store(dir, &error))? {
let path = entry.map_err(|error| store(dir, &error))?.path();
if path.extension().is_none_or(|extension| extension != "json") {
continue;
}
if snapshot.records.len() >= limits.sites.get() {
return Err(LifecycleError::Store(
"migration_records_bound_exceeded".into(),
));
}
let bytes = read_bounded(&path, limits.snapshot_bytes.get().saturating_sub(total))?;
total = total
.checked_add(bytes.len())
.ok_or_else(|| LifecycleError::Store("snapshot_bytes_bound_exceeded".into()))?;
let record: ServiceRecord = serde_json::from_slice(&bytes)
.map_err(|_| LifecycleError::Store("legacy_service_record_invalid".into()))?;
if record.id.is_empty() || snapshot.records.insert(record.id.clone(), record).is_some() {
return Err(LifecycleError::Store("legacy_service_id_repeated".into()));
}
}
write(dir, &snapshot, Some(limits.snapshot_bytes.get()))?;
Ok(snapshot)
}
pub(in super::super) fn load(dir: &Path) -> Result<Snapshot, LifecycleError> {
let snapshot_path = path(dir);
match std::fs::File::open(&snapshot_path) {
Ok(file) => {
return serde_json::from_reader(file)
.map_err(|_| LifecycleError::Store("construction_snapshot_invalid".into()));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(store(&snapshot_path, &error)),
}
let mut snapshot = Snapshot::default();
for entry in std::fs::read_dir(dir).map_err(|error| store(dir, &error))? {
let path = entry.map_err(|error| store(dir, &error))?.path();
if path.extension().is_none_or(|extension| extension != "json") {
continue;
}
let record: ServiceRecord = serde_json::from_reader(
std::fs::File::open(&path).map_err(|error| store(&path, &error))?,
)
.map_err(|_| LifecycleError::Store("legacy_service_record_invalid".into()))?;
if record.id.is_empty() || snapshot.records.insert(record.id.clone(), record).is_some() {
return Err(LifecycleError::Store("legacy_service_id_repeated".into()));
}
}
write(dir, &snapshot, None)?;
Ok(snapshot)
}
pub(in super::super) fn keep(
lifecycle: &Lifecycle,
registry: &mut Registry,
record: &ServiceRecord,
) -> Result<(), LifecycleError> {
let mut snapshot = Snapshot {
records: registry.records.clone(),
construction: registry.construction.clone(),
};
snapshot.records.insert(record.id.clone(), record.clone());
commit(lifecycle, registry, &snapshot)
}
pub(in super::super) fn commit(
lifecycle: &Lifecycle,
registry: &mut Registry,
snapshot: &Snapshot,
) -> Result<(), LifecycleError> {
let bound = lifecycle
.site_config
.get()
.map(|bindings| bindings.limits.snapshot_bytes.get());
let bytes = serde_json::to_vec(snapshot)
.map_err(|_| LifecycleError::Store("snapshot_encoding_failed".into()))?;
if bound.is_some_and(|limit| bytes.len() > limit) {
return Err(LifecycleError::Store(
"snapshot_bytes_bound_exceeded".into(),
));
}
if let Some(bindings) = lifecycle.site_config.get() {
if bindings
.store
.max_request_bytes()
.is_some_and(|limit| bytes.len() > limit)
{
return Err(LifecycleError::Store(
"store_request_bytes_bound_exceeded".into(),
));
}
let hash = Hash::of(&bytes);
let result = block_on(bindings.store.apply_routed_batch(
ROUTE,
vec![RoutedBatchItem {
key: KEY.into(),
expected: registry.construction_hash,
value: bytes,
ttl: None,
}],
))
.map_err(|_| LifecycleError::Store("construction_batch_failed".into()))
.and_then(|result| {
result.map_err(|_| LifecycleError::Store("construction_batch_failed".into()))
})
.and_then(|()| {
block_on(bindings.store.flush())
.map_err(|_| LifecycleError::Store("construction_flush_failed".into()))
.and_then(|result| {
result.map_err(|_| LifecycleError::Store("construction_flush_failed".into()))
})
});
if let Err(error) = result {
registry.poisoned = true;
return Err(error);
}
registry.construction_hash = Some(hash);
} else if let Err(error) = write(&lifecycle.dir, snapshot, None) {
registry.poisoned = true;
return Err(error);
}
registry.records = snapshot.records.clone();
registry.construction = snapshot.construction.clone();
lifecycle.changed.notify_all();
Ok(())
}
fn write(dir: &Path, snapshot: &Snapshot, bound: Option<usize>) -> Result<(), LifecycleError> {
let bytes = serde_json::to_vec(snapshot)
.map_err(|_| LifecycleError::Store("snapshot_encoding_failed".into()))?;
if bound.is_some_and(|limit| bytes.len() > limit) {
return Err(LifecycleError::Store(
"snapshot_bytes_bound_exceeded".into(),
));
}
let destination = path(dir);
let parent = destination
.parent()
.ok_or_else(|| LifecycleError::Store("snapshot_parent_absent".into()))?;
std::fs::create_dir_all(parent).map_err(|error| store(parent, &error))?;
let temporary = parent.join("registry.new");
let mut file = std::fs::File::create(&temporary).map_err(|error| store(&temporary, &error))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| store(&temporary, &error))?;
std::fs::rename(&temporary, &destination).map_err(|error| store(&destination, &error))?;
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| store(parent, &error))
}
impl Lifecycle {
pub(in super::super) fn snapshot(registry: &Registry) -> Snapshot {
Snapshot {
records: registry.records.clone(),
construction: registry.construction.clone(),
}
}
pub fn validate_site_storage(&self) -> Result<(), SiteError> {
let limits = &self.site_bindings()?.limits;
let registry = self.lock()?;
let snapshot = Self::snapshot(®istry);
if serde_json::to_vec(&snapshot)
.map_err(|_| SiteError::Store)?
.len()
> limits.snapshot_bytes.get()
{
return Err(SiteError::Bound("snapshot_bytes"));
}
if snapshot.construction.drafts.len() > limits.drafts.get()
|| snapshot.construction.outcomes.len() > limits.drafts.get()
{
return Err(SiteError::Bound("drafts"));
}
if registry
.records
.values()
.filter(|record| record.purpose.is_some())
.count()
> limits.sites.get()
{
return Err(SiteError::Bound("sites"));
}
for (id, record) in ®istry.records {
if id != &record.id || !crate::config::templates::is_segment(id) {
return Err(SiteError::Invalid("stored service id"));
}
if record.purpose.is_some() && record.transitions.len() > limits.transitions.get() {
return Err(SiteError::Bound("transitions"));
}
if record.plan.steps.len() > limits.steps.get()
|| record.plan.transitions.len() > limits.transitions.get()
{
return Err(SiteError::Bound("plan history"));
}
record.plan.validate(limits)?;
if record.builder_bound
&& (record.state != super::super::ServiceState::Building
|| record.builder.is_none()
|| record.builder_grants.is_none())
{
return Err(SiteError::Invalid("stored builder binding"));
}
if record.approvals.len() > limits.transitions.get()
|| record.approved_by.len() > limits.approvers.get()
{
return Err(SiteError::Bound("stored service approvals"));
}
}
validate_templates(®istry, self.site_bindings()?)?;
drop(registry);
Ok(())
}
}
fn validate_templates(registry: &Registry, bindings: &SiteBindings) -> Result<(), SiteError> {
if registry
.construction
.outcomes
.keys()
.any(|id| !registry.construction.drafts.contains_key(id))
{
return Err(SiteError::Invalid("stored outcome has no preparation"));
}
if registry.construction.templates.is_empty() {
return Ok(());
}
let policy = bindings
.templates
.as_ref()
.ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
if registry.construction.templates.len() > policy.records.get() {
return Err(SiteError::Bound("template records"));
}
for (name, template) in ®istry.construction.templates {
policy.validate(&template.proposal)?;
let service = registry
.records
.get(&template.proposal.service)
.ok_or(SiteError::Invalid("stored template site"))?;
if name != &template.proposal.name
|| template.proposal.subjects != service.subjects
|| !template.proposal.command.is_absolute()
|| template.proposal.digest.len() != 64
|| !template
.proposal
.digest
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
return Err(SiteError::Invalid("stored template contract or digest"));
}
if template.transitions.len() > bindings.limits.transitions.get()
|| template.approved_by.is_empty()
|| template.approved_by.len() > bindings.limits.approvers.get()
{
return Err(SiteError::Bound("stored template approvals"));
}
}
Ok(())
}
const ROUTE: &str = "liminal/construction/v1";
const KEY: &[u8] = b"construction";
pub(in super::super) fn restore(
lifecycle: &Lifecycle,
registry: &mut Registry,
) -> Result<(), SiteError> {
let bindings = lifecycle.site_bindings()?;
let bytes = block_on(bindings.store.get_routed(ROUTE, KEY))
.map_err(|_| SiteError::Store)?
.map_err(|_| SiteError::Store)?;
if let Some(bytes) = bytes {
if bytes.len() > bindings.limits.snapshot_bytes.get() {
return Err(SiteError::Bound("snapshot_bytes"));
}
let snapshot: Snapshot = serde_json::from_slice(&bytes).map_err(|_| SiteError::Store)?;
registry.construction_hash = Some(Hash::of(&bytes));
registry.records = snapshot.records;
registry.construction = snapshot.construction;
} else {
let snapshot = Lifecycle::snapshot(registry);
commit(lifecycle, registry, &snapshot)?;
}
Ok(())
}