Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/snapshot/src/facade.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Supported headless snapshot assembly surface.
//!
//! This module gathers the profile's host-facing lifecycle without hiding the
//! narrower modules that implement it. A host can plan, encode, inspect,
//! verify and restore without importing a Dolos binary module or constructing
//! terminal feedback.

pub use crate::{
publisher::{Next, Publisher, RepositoryPublish},
registry::{Auth, Point, Repository, SnapshotRepository, Tuning},
restore::{execute as restore, Input as RestoreInput, RestoreOutcome, Restoring, Target},
source::{Selection, SnapshotSource, StoreSnapshot},
};
3 changes: 3 additions & 0 deletions crates/snapshot/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
//! to the protocol in the order above.
//! - [`restore`] — its inverse: the driver that reads a stele back into an
//! empty store set, in the order ADR-004 specifies.
//! - [`facade`] — supported headless assembly for planning, encoding,
//! inspection, verification and directory/OCI restoration.
//! - [`preflight`] — the free-space policy both drivers refuse under, so a run
//! that cannot fit its volume says so at minute zero.
//! - `registry` (feature `oci`) — publishing into an OCI repository: the
Expand All @@ -66,6 +68,7 @@
#[cfg(feature = "backfill")]
pub mod backfill;
pub mod export;
pub mod facade;
pub mod layers;
pub mod namespaces;
pub mod node;
Expand Down
39 changes: 30 additions & 9 deletions crates/snapshot/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ use stelae_driver::Standing;
use crate::{
export::Plan,
node,
registry::{self, Auth, Preview, Published, Publishing, Registry, Repository, Tuning},
registry::{
self, Auth, Preview, Published, Publishing, Registry, Repository, SnapshotRepository,
Tuning,
},
DolosProfile, Error,
};

Expand Down Expand Up @@ -140,22 +143,40 @@ impl Publisher {
) -> Result<Self, Error> {
let scratch = node::scratch_dir(&config.storage, publish.scratch_dir);

let registry = registry::open(
Self::open_explicit(
publish.repo,
publish.insecure,
auth,
scratch,
registry::record_path_in(&config.storage.path),
publish.rebuild,
publish.tuning,
)?;
)
}

/// Open a publisher from resolved host policy without a Dolos root
/// configuration.
///
/// The journal path is explicit because it is application state: an
/// external publisher may place it beside its own stores without adopting
/// the Dolos command's directory layout.
#[allow(clippy::too_many_arguments)]
pub fn open_explicit(
repository: &Repository,
insecure: bool,
auth: Auth,
scratch_dir: PathBuf,
record_path: PathBuf,
rebuild: bool,
tuning: Tuning,
) -> Result<Self, Error> {
let registry =
SnapshotRepository::open(repository, insecure, auth, scratch_dir, tuning)?.into_inner();

Ok(Self {
registry,
// The resumption record sits beside the stores, so an interrupted
// publish restarted against this repository carries forward the
// epoch layers it already uploaded instead of rebuilding them.
// `--rebuild` starts it over along with everything else.
record_path: registry::record_path_in(&config.storage.path),
rebuild: publish.rebuild,
record_path,
rebuild,
})
}

Expand Down
57 changes: 57 additions & 0 deletions crates/snapshot/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,63 @@ pub fn open(
)?)
}

/// An opened snapshot repository for inspection, verification and restore.
///
/// The host supplies transport policy explicitly: repository, plaintext opt-in,
/// credentials, staging directory and tuning. No configuration files,
/// environment variables or terminal components are consulted here.
pub struct SnapshotRepository {
registry: Registry,
}

impl SnapshotRepository {
/// Open a repository with fully resolved host inputs.
///
/// **Never call this from inside an async context.** The OCI transport owns
/// its runtime; see [`open`].
pub fn open(
repository: &Repository,
insecure: bool,
auth: Auth,
scratch_dir: PathBuf,
tuning: Tuning,
) -> Result<Self, Error> {
Ok(Self {
registry: open(repository, insecure, auth, scratch_dir, tuning)?,
})
}

/// Read the canonical document and manifest metadata without pulling
/// layers.
pub fn inspect(&self, point: Point) -> Result<Inspected, Error> {
inspect(&self.registry, point)
}

/// Stream and verify every layer named by the published document.
pub fn verify(&self, point: Point) -> Result<Verified, Error> {
verify(&self.registry, point)
}

/// Restore one published stele into the explicit target stores.
pub fn restore<A, S>(
&self,
point: Point,
node: Restoring<'_>,
target: Target<'_, A, S>,
observer: &Observer,
) -> Result<(crate::restore::Plan, Outlook, Summary), Error>
where
A: ArchiveStore,
S: StateStore,
{
restore_registry(&self.registry, point, node, target, observer)
}

pub(crate) fn into_inner(self) -> Registry {
self.registry
}
}

pub use stelae_driver::publish::Tuning;

/// Where a publish is going and what the host running it knows about itself,
Expand Down
63 changes: 63 additions & 0 deletions crates/snapshot/src/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@
//! and any earlier chunk that was already committed is harmless for the same
//! reason `set_cursor` is last: a restore that fails leaves no cursor, so what
//! it wrote is not a node.
//!
//! ## External-host initialization
//!
//! Restoration is deliberately not node initialization. An embedding
//! application should open isolated scratch stores, call [`execute`], close
//! those stores, recover the replay checkpoint/WAL through its engine facade,
//! and only then open a replay session. A failed restore never selects genesis,
//! writes an application initialization marker or removes a directory. Genesis
//! fallback, marker ownership and destructive cleanup are host policy and must
//! remain explicit at that boundary.

use std::{
collections::BTreeMap,
Expand Down Expand Up @@ -1136,6 +1146,59 @@ where
restore_stele(&stele, node, None, target, observer)
}

/// A fully resolved restore input.
///
/// The repository arm holds an already-opened client, so credentials, TLS
/// policy, staging and transport tuning have been decided before destination
/// stores are touched.
pub enum Input<'a> {
Directory(&'a Path),
Repository {
repository: &'a crate::registry::SnapshotRepository,
point: crate::registry::Point,
},
}

/// The structured result of a completed restore.
#[derive(Debug)]
pub struct RestoreOutcome {
pub plan: Plan,
pub outlook: Outlook,
pub summary: Summary,
}

/// Restore from either supported transport through one headless entry point.
///
/// `node` contains the explicit resume and space-check policy. Passing no
/// observer selects the protocol's silent observer and never constructs a
/// terminal renderer.
pub fn execute<A, S>(
input: Input<'_>,
node: Restoring<'_>,
target: Target<'_, A, S>,
observer: Option<&Observer>,
) -> Result<RestoreOutcome, Error>
where
A: ArchiveStore,
S: StateStore,
{
let silent = Observer::silent();
let observer = observer.unwrap_or(&silent);

let (plan, outlook, summary) = match input {
Input::Directory(path) => restore_dir(path, node, target, observer)?,
Input::Repository { repository, point } => {
repository.restore(point, node, target, observer)?
}
};

Ok(RestoreOutcome {
plan,
outlook,
summary,
})
}

/// The stele a restore is reading, and the terms it reads under.
///
/// Carried as one value because every layer is read the same way, and because
Expand Down
84 changes: 82 additions & 2 deletions crates/snapshot/src/source.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,79 @@
//! Read-only access to the Dolos snapshot profile.
//! Headless access to the Dolos snapshot profile.
//!
//! A host owns storage lifecycle and application policy. This module gives it
//! one read-only view for planning, directory publication, digest reproduction
//! and verification without exposing the underlying store handles.

use std::{num::NonZeroUsize, path::Path};

use dolos_core::{ArchiveStore, ChainPoint, StateStore};
use stelae::progress::Observer;

use crate::{
export::{self, Plan},
export::{self, Document, Plan, Predecessor},
inscription::Inscription,
planning::{self, EpochRange},
publisher::Publisher,
registry::{Preview, Published},
Error, RetainedEpochs,
};

/// The selection shared by publication, digest and reproduction.
///
/// All fields are optional so [`Selection::default`] preserves the profile's
/// measured defaults and selects every epoch available at the current cursor.
#[derive(Debug, Clone, Copy, Default)]
pub struct Selection {
pub epochs: Option<EpochRange>,
pub index_band: Option<NonZeroUsize>,
pub producers: Option<NonZeroUsize>,
}

impl Selection {
/// Apply every selection knob through the profile's canonical planning
/// functions. The order is shared by all hosts and commands.
pub fn apply(self, plan: Plan) -> Plan {
let plan = planning::restrict(plan, self.epochs);
let plan = planning::banded(plan, self.index_band);
planning::produced(plan, self.producers)
}
}

/// Profile operations available without exposing mutable storage handles.
pub trait SnapshotSource {
fn committed_position(&self) -> Result<Option<ChainPoint>, Error>;
fn epoch(&self) -> Result<Option<u64>, Error>;
fn plan(&self, network_magic: u64, retained: RetainedEpochs) -> Result<Plan, Error>;

/// Build and select a plan in the same way for every snapshot operation.
fn selected_plan(
&self,
network_magic: u64,
retained: RetainedEpochs,
selection: Selection,
) -> Result<Plan, Error> {
Ok(selection.apply(self.plan(network_magic, retained)?))
}

/// Write a directory stele through the same profile encoder publication
/// and registry publication use.
fn publish_directory(
&self,
destination: &Path,
plan: &Plan,
observer: &Observer,
) -> Result<Inscription, Error>;

/// Reproduce a canonical inscription without writing it.
fn digest_document(&self, plan: &Plan, previous: &dyn Predecessor) -> Result<Document, Error>;

/// Rebuild every layer and compare it with a published inscription.
fn verify_reproduction(
&self,
published: &Inscription,
plan: &Plan,
) -> Result<Inscription, Error>;

fn preview(&self, publisher: &Publisher, plan: &Plan) -> Result<Preview, Error>;
fn publish(
&self,
Expand Down Expand Up @@ -59,6 +118,27 @@ impl<A: ArchiveStore, S: StateStore> SnapshotSource for StoreSnapshot<'_, A, S>
export::plan(self.state, network_magic, retained)
}

fn publish_directory(
&self,
destination: &Path,
plan: &Plan,
observer: &Observer,
) -> Result<Inscription, Error> {
export::publish(destination, plan, self.archive, self.state, None, observer)
}

fn digest_document(&self, plan: &Plan, previous: &dyn Predecessor) -> Result<Document, Error> {
export::digest_document(plan, self.archive, self.state, previous)
}

fn verify_reproduction(
&self,
published: &Inscription,
plan: &Plan,
) -> Result<Inscription, Error> {
export::verify_reproduction(published, plan, self.archive, self.state, None)
}

fn preview(&self, publisher: &Publisher, plan: &Plan) -> Result<Preview, Error> {
publisher.preview(plan, self.archive)
}
Expand Down
27 changes: 27 additions & 0 deletions crates/snapshot/tests/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ use dolos_core::{
};
use dolos_snapshot::{
export::{self, EpochWindow, Plan},
facade::{Selection, SnapshotSource as _, StoreSnapshot},
layers::{blocks, indexes, logs, state},
state_layer_count, state_ns_for, DolosProfile, Network, RetainedEpochs, BLOCKS, INDEXES,
LOG_KINDS, LOG_NAMESPACES, NAMESPACES, STATE_KINDS, UTXOS,
Expand Down Expand Up @@ -1729,3 +1730,29 @@ fn banding_moves_no_bytes() {
);
}
}

/// The supported facade keeps planning, directory encoding, digest and
/// reproduction on one store view and one plan.
#[test]
fn the_headless_facade_plans_encodes_and_reproduces_one_document() {
let domain: ToyDomain = harness();
let source = StoreSnapshot::new(domain.archive(), domain.state());
let plan = source
.selected_plan(
u64::from(domain.genesis().network_magic()),
RetainedEpochs::default(),
Selection::default(),
)
.unwrap();

let destination = tempfile::tempdir().unwrap();
let stele = destination.path().join("stele");
let inscription = source
.publish_directory(&stele, &plan, &Observer::silent())
.unwrap();
let reproduced = source.verify_reproduction(&inscription, &plan).unwrap();
let document = source.digest_document(&plan, &export::First).unwrap();

assert_eq!(reproduced.canonicalize().unwrap(), document.canonical);
assert_eq!(inscription.digest().unwrap(), document.identity);
}
Loading
Loading