use serde::{Deserialize, Serialize};
use std::{
fs::File,
io::BufReader,
path::Path,
};
use anyhow::Result;
use ron::de::from_reader;
use std::collections::HashMap;
use std::sync::Arc;
pub use traits::RepositoryBackendTrait;
use crate::exposed::ExposedEndpoint;
use crate::exposed::fast_routes::FastRoutes;
pub use crate::repository::Repository;
pub use crate::storage::StorageEndpoint;
pub mod audit;
pub mod auth;
pub mod exposed;
pub mod group;
pub mod grpc;
pub mod metrics;
pub mod object;
pub mod promote;
pub mod properties;
pub mod proxy;
pub mod raw;
pub mod replication;
pub mod repository;
pub mod retention;
pub mod sbom;
pub mod search;
pub mod security;
pub mod storage;
#[cfg(feature = "federation")]
pub mod upstream;
#[cfg(feature = "online")]
pub mod upstream_rust;
pub use crate::object::{LocalHolger, RemoteHolger};
pub use traits::HolgerObject;
pub use traits::{ArtifactEntry, ArtifactId};
fn sha256_hex(data: &[u8]) -> String {
nornir_hash::sha256_hex(data)
}
fn retention_artifact_label(id: &ArtifactId) -> String {
match &id.namespace {
Some(ns) => format!("{ns}/{}@{}", id.name, id.version),
None => format!("{}@{}", id.name, id.version),
}
}
pub fn wire_holger(holger: &mut Holger) -> Result<()> {
let mut repo_map = HashMap::new();
let mut exposed_map = HashMap::new();
let mut storage_map = HashMap::new();
for repo in &*holger.repositories {
repo_map.insert(repo.ron_name.clone(), repo as *const Repository);
}
for exp in &*holger.exposed_endpoints {
exposed_map.insert(exp.ron_name.clone(), exp as *const ExposedEndpoint);
}
for st in &*holger.storage_endpoints {
storage_map.insert(st.ron_name.clone(), st as *const StorageEndpoint);
}
for repo in &mut holger.repositories {
for name in &repo.ron_upstreams {
if let Some(ptr) = repo_map.get(name) {
repo.wired_upstreams.push(*ptr);
} else {
return Err(anyhow::anyhow!("Missing upstream repo: {}", name));
}
}
for name in &repo.ron_members {
if let Some(ptr) = repo_map.get(name) {
repo.wired_members.push(*ptr);
} else {
return Err(anyhow::anyhow!(
"Missing member repo '{}' for group '{}'",
name,
repo.ron_name
));
}
}
if let Some(io) = &mut repo.ron_in {
io.wired_storage = *storage_map
.get(&io.ron_storage_endpoint)
.ok_or_else(|| anyhow::anyhow!("Missing storage endpoint: {}", io.ron_storage_endpoint))?;
io.wired_exposed = *exposed_map
.get(&io.ron_exposed_endpoint)
.ok_or_else(|| anyhow::anyhow!("Missing exposed endpoint: {}", io.ron_exposed_endpoint))?;
}
if let Some(io) = &mut repo.ron_out {
io.wired_storage = *storage_map
.get(&io.ron_storage_endpoint)
.ok_or_else(|| anyhow::anyhow!("Missing storage endpoint: {}", io.ron_storage_endpoint))?;
io.wired_exposed = *exposed_map
.get(&io.ron_exposed_endpoint)
.ok_or_else(|| anyhow::anyhow!("Missing exposed endpoint: {}", io.ron_exposed_endpoint))?;
}
}
for exp in &mut holger.exposed_endpoints {
for repo in &holger.repositories {
if let Some(io) = &repo.ron_out {
if io.ron_exposed_endpoint == exp.ron_name {
exp.wired_out_repositories.push(repo as *const Repository);
}
}
}
}
fn effective_backend(repo: &Repository) -> Option<Arc<dyn RepositoryBackendTrait>> {
let backend_arc = repo.backend_repository.as_ref()?;
if repo.wired_upstreams.is_empty() {
return Some(backend_arc.clone());
}
let upstream_backends: Vec<Arc<dyn RepositoryBackendTrait>> = repo
.wired_upstreams
.iter()
.filter_map(|&ptr| {
if ptr.is_null() { return None; }
let upstream: &Repository = unsafe { &*ptr };
upstream.backend_repository.clone()
})
.collect();
let mut proxy = crate::proxy::ProxyBackend::new(backend_arc.clone(), upstream_backends);
if let Some(secs) = repo.ron_negative_cache_secs {
proxy = proxy.with_negative_cache_secs(secs);
}
Some(Arc::new(proxy))
}
for exp in &mut holger.exposed_endpoints {
let mut routes: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = Vec::new();
for &repo_ptr in &exp.wired_out_repositories {
if repo_ptr.is_null() { continue; }
let repo: &Repository = unsafe { &*repo_ptr };
if repo.ron_repo_type == "group" {
let members: Vec<Arc<dyn RepositoryBackendTrait>> = repo
.wired_members
.iter()
.filter_map(|&ptr| {
if ptr.is_null() { return None; }
let member: &Repository = unsafe { &*ptr };
effective_backend(member)
})
.collect();
routes.push((
repo.ron_name.clone(),
Arc::new(crate::group::GroupBackend::new(repo.ron_name.clone(), members)),
));
continue;
}
let Some(backend) = effective_backend(repo) else { continue };
routes.push((repo.ron_name.clone(), backend));
}
exp.aggregated_routes = if routes.is_empty() {
None
} else {
Some(FastRoutes::new(routes))
};
}
Ok(())
}
#[derive(Serialize, Deserialize)]
pub struct Holger {
pub repositories: Vec<Repository>,
pub exposed_endpoints: Vec<ExposedEndpoint>,
pub storage_endpoints: Vec<StorageEndpoint>,
#[serde(default)]
pub auth: Option<auth::AuthConfig>,
#[serde(default)]
pub audit: AuditSettings,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditSettings {
#[serde(default = "default_audit_dir")]
pub dir: String,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_audit_dir() -> String {
"holger-audit".to_string()
}
fn default_true() -> bool {
true
}
impl Default for AuditSettings {
fn default() -> Self {
Self {
dir: default_audit_dir(),
enabled: true,
}
}
}
pub fn read_ron_config<P: AsRef<Path>>(path: P) -> Result<Holger> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let holger: Holger = from_reader(reader)?;
Ok(holger)
}
fn bare_socket_addr(url: &str) -> &str {
let no_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
no_scheme.split(['/', '?', '#']).next().unwrap_or(no_scheme)
}
pub fn dev_pair_ron(data_dir: &Path, grpc_url: &str, http_addr: &str) -> String {
let d = data_dir.display();
let grpc_url = bare_socket_addr(grpc_url);
let http_addr = bare_socket_addr(http_addr);
format!(
r#"(
exposed_endpoints: [
( ron_name: "dev", ron_url: "{grpc_url}", ron_http_url: Some("{http_addr}") ),
],
storage_endpoints: [
( ron_name: "cache-store", ron_storage_type: "znippy", ron_path: "{d}/cache/" ),
( ron_name: "sparring-store", ron_storage_type: "znippy", ron_path: "{d}/sparring/" ),
],
repositories: [
( ron_name: "crates-io", ron_repo_type: "rust-remote", ron_upstreams: [], ron_in: None, ron_out: None ),
( ron_name: "cache", ron_repo_type: "rust", ron_writable_archive: Some("{d}/cache.znippy"), ron_base_url: Some("http://{http_addr}"), ron_upstreams: ["crates-io"], ron_in: None, ron_out: Some(( ron_storage_endpoint: "cache-store", ron_exposed_endpoint: "dev" )) ),
( ron_name: "sparring", ron_repo_type: "rust", ron_writable_archive: Some("{d}/sparring.znippy"), ron_base_url: Some("http://{http_addr}"), ron_upstreams: ["crates-io"], ron_in: None, ron_out: Some(( ron_storage_endpoint: "sparring-store", ron_exposed_endpoint: "dev" )) ),
],
)
"#
)
}
impl Holger {
pub fn fetch(&self, repository: &str, id: &traits::ArtifactId) -> Result<Option<Vec<u8>>> {
let repo = self.get_repo(repository)?;
repo.fetch(id)}
pub fn put(&self, repository: &str, id: &traits::ArtifactId, data: &[u8]) -> Result<()> {
let repo = self.get_repo(repository)?;
if !repo.is_writable() {
anyhow::bail!("Repository '{}' is read-only", repository);
}
repo.put(id, data)}
pub fn list_repositories(&self) -> Vec<&str> {
self.repositories.iter().map(|r| r.ron_name.as_str()).collect()
}
pub fn search_all(&self, query: &search::SearchQuery) -> search::SearchResults {
let repos = self.backend_pairs();
let props = self.property_store();
search::search_repos_with_properties(&repos, query, Some(&*props))
}
fn backend_pairs(&self) -> Vec<(String, Arc<dyn RepositoryBackendTrait>)> {
self.repositories
.iter()
.filter_map(|r| {
r.backend_repository
.as_ref()
.map(|b| (r.ron_name.clone(), b.clone()))
})
.collect()
}
pub fn replicate_into(
&self,
dest: &Holger,
only: Option<&str>,
opts: &replication::ReplicationOptions,
) -> replication::ReplicationReport {
let sources = self.backend_pairs();
let dests = dest.backend_pairs();
let mut pairs = Vec::new();
for (sname, sback) in &sources {
if let Some(want) = only {
if sname != want {
continue;
}
}
match dests.iter().find(|(dname, _)| dname == sname) {
Some((dname, dback)) => pairs.push(replication::RepoPair {
source_name: sname.clone(),
source: sback.clone(),
dest_name: dname.clone(),
dest: dback.clone(),
}),
None => log::warn!(
"replicate: source repo '{sname}' has no same-named destination repo — skipped"
),
}
}
replication::replicate_pairs(&pairs, opts)
}
fn data_root(&self) -> std::path::PathBuf {
std::path::Path::new(&self.audit.dir)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."))
}
pub fn sbom_store(&self) -> sbom::SharedSbomStore {
Arc::new(sbom::SbomStore::new(self.data_root().join("holger-sboms")))
}
pub fn property_store(&self) -> properties::SharedPropertyStore {
Arc::new(properties::PropertyStore::new(self.data_root().join("holger-props")))
}
pub fn set_artifact_property(
&self,
repo: &str,
id: &traits::ArtifactId,
key: &str,
values: Vec<String>,
) -> std::io::Result<properties::PropertyMap> {
self.property_store().set(repo, id, key, values)
}
pub fn get_artifact_properties(
&self,
repo: &str,
id: &traits::ArtifactId,
) -> properties::PropertyMap {
self.property_store().get(repo, id)
}
pub fn plan_repo_retention(
&self,
repository: &str,
policy: &retention::RetentionPolicy,
now: i64,
) -> Result<retention::RetentionPlan> {
let repo = self.get_repo(repository)?;
let prop_store = policy
.protect_property
.as_ref()
.map(|_| self.property_store());
let records: Vec<retention::ArtifactRecord> = repo
.list(None, usize::MAX)?
.into_iter()
.map(|entry| {
let record = retention::ArtifactRecord::from_entry(entry);
match (&policy.protect_property, &prop_store) {
(Some((key, value)), Some(store)) => {
let pinned = store.matches(repository, &record.id, key, value);
record.protected_by_property(pinned)
}
_ => record,
}
})
.collect();
Ok(retention::plan_retention(&records, policy, now))
}
pub fn execute_repo_retention(
&self,
repository: &str,
plan: &retention::RetentionPlan,
opts: &retention::GcOptions,
audit: &dyn audit::AuditLog,
jobs: Option<&Arc<jera::jobs::JobStore>>,
) -> Result<retention::GcReport> {
metrics::global().record_retention_run();
let job = jobs.map(|store| {
store.start(
"retention-gc",
repository,
repository,
serde_json::json!({
"dry_run": opts.dry_run,
"planned_delete": plan.delete_count,
"planned_reclaim_bytes": plan.reclaimed_bytes,
}),
)
});
let out = self.execute_repo_retention_inner(repository, plan, opts, audit);
if let Some(job) = job {
match &out {
Ok(report) => job.finish(
serde_json::json!({
"dry_run": report.dry_run,
"deleted": report.deleted_count,
"reclaimed_bytes": report.reclaimed_bytes,
"would_delete": report.would_delete_count,
"skipped": report.skipped_count,
"failed": report.failed_count,
}),
&format!("{} bytes reclaimed", report.reclaimed_bytes),
),
Err(e) => job.fail_with_detail(serde_json::json!({ "error": e.to_string() })),
}
}
out
}
fn execute_repo_retention_inner(
&self,
repository: &str,
plan: &retention::RetentionPlan,
opts: &retention::GcOptions,
audit: &dyn audit::AuditLog,
) -> Result<retention::GcReport> {
use retention::{GcAction, GcOutcome, RetentionReason};
let repo = self.get_repo(repository)?;
let mut actions: Vec<GcAction> = Vec::new();
let mut deleted_count = 0usize;
let mut reclaimed_bytes = 0u64;
let mut would_delete_count = 0usize;
let mut skipped_count = 0usize;
let mut failed_count = 0usize;
for d in &plan.decisions {
if !d.delete {
continue; }
let id = &d.record.id;
if d.reasons.iter().any(|r| {
matches!(
r,
RetentionReason::ProtectedLatest
| RetentionReason::MinPerNameFloor
| RetentionReason::ProtectedByProperty
)
}) {
skipped_count += 1;
actions.push(GcAction {
id: id.clone(),
size_bytes: d.record.size_bytes,
content_id: None,
outcome: GcOutcome::Skipped(
"protected generation carried a delete flag — refused".into(),
),
reasons: d.reasons.clone(),
});
continue;
}
let bytes = match repo.fetch(id) {
Ok(Some(b)) => b,
Ok(None) => {
skipped_count += 1;
actions.push(GcAction {
id: id.clone(),
size_bytes: d.record.size_bytes,
content_id: None,
outcome: GcOutcome::Skipped("already absent from the store".into()),
reasons: d.reasons.clone(),
});
continue;
}
Err(e) => {
failed_count += 1;
actions.push(GcAction {
id: id.clone(),
size_bytes: d.record.size_bytes,
content_id: None,
outcome: GcOutcome::Failed(format!("read failed: {e}")),
reasons: d.reasons.clone(),
});
continue;
}
};
let content_id = sha256_hex(&bytes);
if opts.dry_run {
would_delete_count += 1;
actions.push(GcAction {
id: id.clone(),
size_bytes: bytes.len() as u64,
content_id: Some(content_id),
outcome: GcOutcome::WouldDelete,
reasons: d.reasons.clone(),
});
continue;
}
match repo.delete_artifact(id, &content_id) {
Ok(()) => {
deleted_count += 1;
reclaimed_bytes += bytes.len() as u64;
let detail = format!(
"retention-gc reclaim: {}",
d.reasons
.iter()
.map(|r| r.as_str())
.collect::<Vec<_>>()
.join(",")
);
let _ = audit.record(
audit::AuditEvent::new(
"holger-retention-gc",
audit::AuditAction::Delete,
repository,
retention_artifact_label(id),
"",
200,
bytes.len() as u64,
)
.with_detail(detail),
);
actions.push(GcAction {
id: id.clone(),
size_bytes: bytes.len() as u64,
content_id: Some(content_id),
outcome: GcOutcome::Deleted,
reasons: d.reasons.clone(),
});
}
Err(e) => {
failed_count += 1;
actions.push(GcAction {
id: id.clone(),
size_bytes: bytes.len() as u64,
content_id: Some(content_id),
outcome: GcOutcome::Failed(e.to_string()),
reasons: d.reasons.clone(),
});
}
}
}
Ok(retention::GcReport {
repository: repository.to_string(),
dry_run: opts.dry_run,
actions,
deleted_count,
reclaimed_bytes,
would_delete_count,
skipped_count,
failed_count,
evaluated_at: plan.evaluated_at,
})
}
pub fn run_repo_retention(
&self,
repository: &str,
policy: &retention::RetentionPolicy,
now: i64,
opts: &retention::GcOptions,
audit: &dyn audit::AuditLog,
jobs: Option<&Arc<jera::jobs::JobStore>>,
) -> Result<retention::GcReport> {
let plan = self.plan_repo_retention(repository, policy, now)?;
self.execute_repo_retention(repository, &plan, opts, audit, jobs)
}
pub fn audit_log(&self) -> Arc<dyn audit::AuditLog> {
self.build_audit_log()
}
fn get_repo(&self, name: &str) -> Result<&Arc<dyn RepositoryBackendTrait>> {
for repo in &self.repositories {
if repo.ron_name == name {
return repo.backend_repository.as_ref()
.ok_or_else(|| anyhow::anyhow!("Repository '{}' has no backend", name));
}
}
anyhow::bail!("Repository '{}' not found", name)
}
pub fn start(&self) -> Result<()> {
crate::exposed::tls::ensure_crypto_provider();
let auth_config = Arc::new(self.auth.clone().unwrap_or_default());
auth_config.warn_unenforced_mtls_ca();
let audit = self.build_audit_log();
for ep in &self.exposed_endpoints {
if let Some(routes) = &ep.aggregated_routes {
self.start_grpc_endpoint(
&ep.ron_url,
routes.clone(),
auth_config.clone(),
audit.clone(),
ep.ron_tls.as_ref(),
ep.ron_max_body_bytes.unwrap_or(1024 * 1024 * 1024),
)?;
if let Some(http_url) = &ep.ron_http_url {
let addr: std::net::SocketAddr = http_url.parse()
.map_err(|e| anyhow::anyhow!("Invalid HTTP address '{}': {}", http_url, e))?;
let tls = match &ep.ron_tls {
Some(t) => Some(t.rustls_gateway_config()?),
None => None,
};
let settings = Arc::new(crate::exposed::http::GatewaySettings {
routes: routes.clone(),
auth_config: auth_config.clone(),
tls,
max_body_bytes: ep.ron_max_body_bytes.unwrap_or(1024 * 1024 * 1024),
audit: audit.clone(),
sboms: Some(self.sbom_store()),
properties: Some(self.property_store()),
quarantine: std::env::var("HOLGER_QUARANTINE_PROPERTY")
.ok()
.and_then(|raw| crate::properties::parse_predicate(&raw)),
});
crate::exposed::http::start_http_gateway(addr, settings)?;
}
}
}
Ok(())
}
fn build_audit_log(&self) -> Arc<dyn audit::AuditLog> {
if !self.audit.enabled {
log::info!("audit log disabled by config");
return Arc::new(audit::NoopAuditLog);
}
match audit::default_audit_log(&self.audit.dir) {
Ok(log) => {
log::info!(
"audit log: append-only Arrow IPC segments under {:?}",
self.audit.dir
);
log
}
Err(e) => {
log::error!(
"audit DISABLED — could not open audit dir {:?}: {e}",
self.audit.dir
);
Arc::new(audit::NoopAuditLog)
}
}
}
fn start_grpc_endpoint(
&self,
addr: &str,
routes: FastRoutes,
auth_config: Arc<auth::AuthConfig>,
audit: Arc<dyn audit::AuditLog>,
tls: Option<&crate::exposed::tls::TlsSettings>,
max_body_bytes: usize,
) -> Result<()> {
use crate::grpc::holger_proto::{
repository_service_server::RepositoryServiceServer,
archive_service_server::ArchiveServiceServer,
admin_service_server::AdminServiceServer,
promotion_service_server::PromotionServiceServer,
search_service_server::SearchServiceServer,
sbom_service_server::SbomServiceServer,
property_service_server::PropertyServiceServer,
replication_service_server::ReplicationServiceServer,
};
use crate::grpc::HolgerGrpc;
let data_root = {
let audit_dir = std::path::Path::new(&self.audit.dir);
audit_dir
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
};
let prod_root = match &data_root {
Some(p) => p.join("holger-prod-store"),
None => std::path::PathBuf::from("holger-prod-store"),
};
let mut engine = crate::promote::PromotionEngine::new(prod_root);
match crate::promote::PolicyGate::from_env()? {
Some(gate) => {
log::info!("promotion policy gate ENABLED (HOLGER_PROMOTE_ENFORCE)");
engine = engine.with_gate(gate);
}
None => log::info!(
"promotion policy gate off (set HOLGER_PROMOTE_ENFORCE=block|warn to enable)"
),
}
if let Ok(raw) = std::env::var("HOLGER_PROMOTE_REQUIRE_PROPERTY") {
if let Some((key, value)) = crate::promote::PromotionEngine::parse_property_requirement(&raw) {
log::info!("promotion property requirement ENABLED ('{key}={value}')");
engine = engine.with_required_property(key, value);
}
}
let jobs_root = match &data_root {
Some(p) => p.join("holger-jobs"),
None => std::path::PathBuf::from("holger-jobs"),
};
match std::fs::create_dir_all(&jobs_root)
.map_err(anyhow::Error::from)
.and_then(|()| jera::jobs::JobStore::open(&jobs_root))
{
Ok(store) => {
log::info!("promotion job ledger at {}/jobs.redb", jobs_root.display());
engine = engine.with_job_ledger(Arc::new(store));
}
Err(e) => log::warn!(
"promotion job ledger unavailable ({e}); promotions serve un-recorded"
),
}
let promotion = Arc::new(engine);
let sboms = self.sbom_store();
let props = self.property_store();
let quarantine = std::env::var("HOLGER_QUARANTINE_PROPERTY")
.ok()
.and_then(|raw| crate::properties::parse_predicate(&raw));
if let Some((k, v)) = &quarantine {
log::info!("serve-time quarantine gate ENABLED ('{k}={v}')");
}
let mut grpc_state_builder = HolgerGrpc::with_auth(routes, auth_config)
.with_audit(audit)
.with_max_body_bytes(max_body_bytes)
.with_promotion(promotion)
.with_sboms(sboms)
.with_properties(props);
if let Some((key, value)) = quarantine {
grpc_state_builder = grpc_state_builder.with_quarantine(key, value);
}
let grpc_state = Arc::new(grpc_state_builder);
let listen_addr: std::net::SocketAddr = addr.parse()
.map_err(|e| anyhow::anyhow!("Invalid gRPC address '{}': {}", addr, e))?;
let tls_was_set = tls.is_some();
let tls_config = match tls {
Some(t) => Some(
t.tonic_config()
.map_err(|e| anyhow::anyhow!("gRPC TLS config: {}", e))?,
),
None => {
log::warn!(
"SECURITY: gRPC server on {} runs WITHOUT TLS (cleartext h2c). \
Set ron_tls for any non-loopback use.",
listen_addr
);
None
}
};
let mut routes = tonic::service::Routes::builder();
routes.add_service(
RepositoryServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
routes.add_service(
ArchiveServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
routes.add_service(
AdminServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
routes.add_service(
PromotionServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
routes.add_service(
SearchServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
routes.add_service(
SbomServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
routes.add_service(
PropertyServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
routes.add_service(
ReplicationServiceServer::new(grpc_state.clone())
.max_decoding_message_size(max_body_bytes)
.max_encoding_message_size(max_body_bytes),
);
tokio::spawn(async move {
let scheme = if tls_was_set { "https/h2" } else { "h2c (cleartext)" };
println!("gRPC server listening on {} [{}]", listen_addr, scheme);
if let Err(e) =
nornir_service::serve_with_tls(routes.routes(), listen_addr, tls_config).await
{
eprintln!("gRPC server error: {}", e);
}
});
Ok(())
}
pub fn instantiate_backends(&mut self) -> Result<()> {
for se in &mut self.storage_endpoints {
se.backend_from_config()?;
}
let total = self.repositories.len();
let mut failed = 0usize;
for repo in &mut self.repositories {
if let Err(e) = repo.backend_from_config() {
failed += 1;
log::error!(
"repository '{}' DISABLED — backend init failed: {:#}",
repo.ron_name, e
);
}
}
if total > 0 && failed == total {
anyhow::bail!(
"all {} repositories failed to initialize — nothing to serve (see errors above)",
total
);
}
if failed > 0 {
log::warn!(
"{}/{} repositories disabled (backend init failed); serving the remaining {}",
failed, total, total - failed
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dev_pair_ron_parses_instantiates_and_wires() {
let dir = tempfile::tempdir().unwrap();
let ron = dev_pair_ron(dir.path(), "https://127.0.0.1:18443", "127.0.0.1:18444");
assert!(
ron.contains(r#"ron_url: "127.0.0.1:18443""#),
"ron_url must be a bare host:port with no scheme, got:\n{ron}"
);
let mut holger: Holger =
from_reader(std::io::Cursor::new(ron.as_bytes())).expect("dev-pair RON must parse");
assert_eq!(holger.list_repositories(), vec!["crates-io", "cache", "sparring"]);
holger.instantiate_backends().expect("every backend must build");
wire_holger(&mut holger).expect("upstreams + endpoints must all wire");
let id = traits::ArtifactId { namespace: None, name: "demo".into(), version: "0.1.0".into() };
holger.put("sparring", &id, b"crate-bytes").expect("sparring is writable");
assert_eq!(
holger.fetch("sparring", &id).unwrap().as_deref(),
Some(b"crate-bytes".as_ref()),
);
assert!(holger.put("crates-io", &id, b"x").is_err(), "crates-io upstream is read-only");
}
#[test]
fn group_repo_wires_and_resolves_across_members() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path().display();
let ron = format!(
r#"(
exposed_endpoints: [ ( ron_name: "dev", ron_url: "127.0.0.1:0", ron_http_url: None ) ],
storage_endpoints: [ ( ron_name: "inert", ron_storage_type: "znippy", ron_path: "{d}/inert/" ) ],
repositories: [
( ron_name: "r1", ron_repo_type: "rust", ron_data_dir: Some("{d}/r1"), ron_base_url: Some("http://x"), ron_upstreams: [], ron_in: None, ron_out: None ),
( ron_name: "r2", ron_repo_type: "rust", ron_data_dir: Some("{d}/r2"), ron_base_url: Some("http://x"), ron_upstreams: [], ron_in: None, ron_out: None ),
( ron_name: "all", ron_repo_type: "group", ron_members: ["r1", "r2"], ron_upstreams: [], ron_in: None, ron_out: Some(( ron_storage_endpoint: "inert", ron_exposed_endpoint: "dev" )) ),
],
)
"#
);
let mut holger: Holger =
from_reader(std::io::Cursor::new(ron.as_bytes())).expect("group RON must parse");
holger.instantiate_backends().expect("member + group backends build");
wire_holger(&mut holger).expect("group members + endpoint must wire");
let id = traits::ArtifactId { namespace: None, name: "widget".into(), version: "1.0.0".into() };
holger.put("r2", &id, b"from-r2").expect("r2 is writable");
let exp = &holger.exposed_endpoints[0];
let routes = exp.aggregated_routes.as_ref().expect("routes built");
let group = routes.lookup("all").expect("group route present");
assert!(!group.is_writable(), "group is a read-only view");
assert_eq!(
group.fetch(&id).unwrap().as_deref(),
Some(b"from-r2".as_ref()),
"group resolves an artifact held only by member r2",
);
let absent = traits::ArtifactId { namespace: None, name: "ghost".into(), version: "9.9.9".into() };
assert_eq!(group.fetch(&absent).unwrap(), None);
}
fn single_rust_store(dir: &std::path::Path) -> Holger {
let d = dir.display();
let ron = format!(
r#"(
exposed_endpoints: [ ( ron_name: "dev", ron_url: "127.0.0.1:0", ron_http_url: None ) ],
storage_endpoints: [],
repositories: [
( ron_name: "mirror", ron_repo_type: "rust", ron_data_dir: Some("{d}/mirror"), ron_base_url: Some("http://x"), ron_upstreams: [], ron_in: None, ron_out: None ),
],
)
"#
);
let mut holger: Holger =
from_reader(std::io::Cursor::new(ron.as_bytes())).expect("store RON must parse");
holger.instantiate_backends().expect("backend builds");
wire_holger(&mut holger).expect("wires");
holger
}
#[test]
fn replicate_between_two_file_backed_stores_is_complete_and_idempotent() {
use crate::replication::ReplicationOptions;
let src_dir = tempfile::tempdir().unwrap();
let dst_dir = tempfile::tempdir().unwrap();
let source = single_rust_store(src_dir.path());
let dest = single_rust_store(dst_dir.path());
let ids = [
traits::ArtifactId { namespace: None, name: "serde".into(), version: "1.0.0".into() },
traits::ArtifactId { namespace: None, name: "serde".into(), version: "1.0.1".into() },
traits::ArtifactId { namespace: None, name: "tokio".into(), version: "1.40.0".into() },
];
let bodies: [&[u8]; 3] = [b"serde-a", b"serde-b", b"tokio-c"];
for (id, body) in ids.iter().zip(bodies.iter()) {
source.put("mirror", id, body).expect("source is writable");
}
let preview = source.replicate_into(&dest, None, &ReplicationOptions::preview());
assert_eq!(preview.would_copy(), 3, "all three would copy");
assert_eq!(preview.copied(), 0);
for id in &ids {
assert!(dest.fetch("mirror", id).unwrap().is_none(), "dry run wrote nothing");
}
let run = source.replicate_into(&dest, None, &ReplicationOptions::executing());
assert_eq!(run.copied(), 3, "all three copied");
assert!(run.is_complete(), "no failures");
for (id, body) in ids.iter().zip(bodies.iter()) {
let got = dest
.fetch("mirror", id)
.unwrap()
.unwrap_or_else(|| panic!("destination MISSING {id:?} after replicate"));
assert_eq!(&got, body, "destination bytes must equal source bytes for {id:?}");
}
let again = source.replicate_into(&dest, None, &ReplicationOptions::executing());
assert_eq!(again.copied(), 0, "re-run copies nothing");
assert_eq!(again.skipped(), 3, "every artifact already present by content id");
}
}