use std::sync::Arc;
use serde::{Deserialize, Serialize};
use traits::{ArtifactId, RepositoryBackendTrait};
fn content_id(data: &[u8]) -> String {
nornir_hash::sha256_hex(data)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReplicationOptions {
pub execute: bool,
pub limit: usize,
}
impl Default for ReplicationOptions {
fn default() -> Self {
Self { execute: false, limit: 0 }
}
}
impl ReplicationOptions {
pub fn preview() -> Self {
Self::default()
}
pub fn executing() -> Self {
Self { execute: true, limit: 0 }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Copied,
Skipped,
WouldCopy,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReplicatedArtifact {
pub source_repo: String,
pub dest_repo: String,
pub id: ArtifactId,
pub content_id: String,
pub size_bytes: i64,
pub outcome: Outcome,
pub detail: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReplicationReport {
pub items: Vec<ReplicatedArtifact>,
pub truncated: bool,
}
impl ReplicationReport {
fn count(&self, o: Outcome) -> usize {
self.items.iter().filter(|i| i.outcome == o).count()
}
pub fn copied(&self) -> usize {
self.count(Outcome::Copied)
}
pub fn skipped(&self) -> usize {
self.count(Outcome::Skipped)
}
pub fn would_copy(&self) -> usize {
self.count(Outcome::WouldCopy)
}
pub fn failed(&self) -> usize {
self.count(Outcome::Failed)
}
pub fn examined(&self) -> usize {
self.items.len()
}
pub fn is_complete(&self) -> bool {
self.failed() == 0
}
pub fn copied_bytes(&self) -> i64 {
self.items
.iter()
.filter(|i| i.outcome == Outcome::Copied)
.map(|i| i.size_bytes)
.sum()
}
}
pub struct RepoPair {
pub source_name: String,
pub source: Arc<dyn RepositoryBackendTrait>,
pub dest_name: String,
pub dest: Arc<dyn RepositoryBackendTrait>,
}
pub fn replicate_pairs(pairs: &[RepoPair], opts: &ReplicationOptions) -> ReplicationReport {
let mut report = ReplicationReport::default();
for pair in pairs {
replicate_one_pair(pair, opts, &mut report);
}
report
}
fn replicate_one_pair(
pair: &RepoPair,
opts: &ReplicationOptions,
report: &mut ReplicationReport,
) {
let entries = match pair.source.list(None, 0) {
Ok(e) => e,
Err(e) => {
log::warn!(
"replicate: list failed in source repo '{}': {e}",
pair.source_name
);
return;
}
};
let cap = if opts.limit == 0 { usize::MAX } else { opts.limit };
let dest_writable = pair.dest.is_writable();
for (i, entry) in entries.into_iter().enumerate() {
if i >= cap {
report.truncated = true;
break;
}
let id = entry.id;
let bytes = match pair.source.fetch(&id) {
Ok(Some(b)) => b,
Ok(None) => {
report.items.push(row(
pair,
&id,
"",
0,
Outcome::Failed,
"source listed the artifact but fetch returned no bytes",
));
continue;
}
Err(e) => {
report.items.push(row(
pair,
&id,
"",
0,
Outcome::Failed,
&format!("source fetch failed: {e}"),
));
continue;
}
};
let cid = content_id(&bytes);
let size = bytes.len() as i64;
let already_present = match pair.dest.fetch(&id) {
Ok(Some(existing)) => content_id(&existing) == cid,
Ok(None) => false,
Err(e) => {
log::warn!(
"replicate: dest presence probe failed in '{}' for {}@{}: {e}",
pair.dest_name,
id.name,
id.version
);
false
}
};
if already_present {
report
.items
.push(row(pair, &id, &cid, size, Outcome::Skipped, ""));
continue;
}
if !dest_writable {
report.items.push(row(
pair,
&id,
&cid,
size,
Outcome::Failed,
&format!("destination repo '{}' is read-only", pair.dest_name),
));
continue;
}
if !opts.execute {
report
.items
.push(row(pair, &id, &cid, size, Outcome::WouldCopy, ""));
continue;
}
match pair.dest.put(&id, &bytes) {
Ok(()) => report
.items
.push(row(pair, &id, &cid, size, Outcome::Copied, "")),
Err(e) => report.items.push(row(
pair,
&id,
&cid,
size,
Outcome::Failed,
&format!("destination put failed: {e}"),
)),
}
}
}
fn row(
pair: &RepoPair,
id: &ArtifactId,
cid: &str,
size: i64,
outcome: Outcome,
detail: &str,
) -> ReplicatedArtifact {
ReplicatedArtifact {
source_repo: pair.source_name.clone(),
dest_repo: pair.dest_name.clone(),
id: id.clone(),
content_id: cid.to_string(),
size_bytes: size,
outcome,
detail: detail.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::Mutex;
use traits::{ArchiveInfo, ArtifactEntry, ArtifactFormat};
struct MemRepo {
name: String,
writable: bool,
store: Mutex<Vec<(ArtifactId, Vec<u8>)>>,
}
impl MemRepo {
fn new(name: &str, writable: bool) -> Self {
Self { name: name.into(), writable, store: Mutex::new(Vec::new()) }
}
fn seeded(name: &str, entries: Vec<(ArtifactId, Vec<u8>)>) -> Self {
Self { name: name.into(), writable: false, store: Mutex::new(entries) }
}
fn arc(self) -> Arc<dyn RepositoryBackendTrait> {
Arc::new(self)
}
}
#[async_trait]
impl RepositoryBackendTrait for MemRepo {
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Rust
}
fn is_writable(&self) -> bool {
self.writable
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.find(|(e, _)| e == id)
.map(|(_, b)| b.clone()))
}
fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
if !self.writable {
anyhow::bail!("read-only");
}
let mut s = self.store.lock().unwrap();
if let Some(slot) = s.iter_mut().find(|(e, _)| e == id) {
slot.1 = data.to_vec(); } else {
s.push((id.clone(), data.to_vec()));
}
Ok(())
}
fn list(&self, _name: Option<&str>, _limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.map(|(id, b)| ArtifactEntry {
id: id.clone(),
size_bytes: b.len() as i64,
content_type: "application/octet-stream".into(),
})
.collect())
}
fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
Ok(ArchiveInfo::default())
}
fn handle_http2_request(
&self,
_m: &str,
_s: &str,
_b: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, vec![], Vec::new()))
}
}
fn id(name: &str, ver: &str) -> ArtifactId {
ArtifactId { namespace: None, name: name.into(), version: ver.into() }
}
fn pair(dest_writable: bool) -> (Arc<dyn RepositoryBackendTrait>, Arc<dyn RepositoryBackendTrait>) {
let src = MemRepo::seeded(
"rust-dev",
vec![
(id("serde", "1.0.0"), b"serde-bytes".to_vec()),
(id("serde", "1.0.1"), b"serde-newer".to_vec()),
(id("tokio", "1.40.0"), b"tokio-bytes".to_vec()),
],
)
.arc();
let dst = MemRepo::new("rust-mirror", dest_writable).arc();
(src, dst)
}
fn one_pair(
src: Arc<dyn RepositoryBackendTrait>,
dst: Arc<dyn RepositoryBackendTrait>,
) -> Vec<RepoPair> {
vec![RepoPair {
source_name: "rust-dev".into(),
source: src,
dest_name: "rust-mirror".into(),
dest: dst,
}]
}
#[test]
fn dry_run_reports_every_artifact_and_writes_nothing() {
let (src, dst) = pair(true);
let pairs = one_pair(src, dst.clone());
let r = replicate_pairs(&pairs, &ReplicationOptions::preview());
assert_eq!(r.would_copy(), 3, "all 3 source artifacts would copy");
assert_eq!(r.copied(), 0, "a dry run copies nothing");
assert!(
dst.list(None, 0).unwrap().is_empty(),
"dry run must NOT write to the destination"
);
}
#[test]
fn execute_copies_every_source_artifact_byte_identical() {
let (src, dst) = pair(true);
let pairs = one_pair(src.clone(), dst.clone());
let r = replicate_pairs(&pairs, &ReplicationOptions::executing());
assert_eq!(r.copied(), 3, "all 3 copied");
assert!(r.is_complete(), "no failures");
for entry in src.list(None, 0).unwrap() {
let want = src.fetch(&entry.id).unwrap().expect("source has it");
let got = dst
.fetch(&entry.id)
.unwrap()
.unwrap_or_else(|| panic!("destination MISSING {:?} after replicate", entry.id));
assert_eq!(got, want, "destination bytes must equal source bytes for {:?}", entry.id);
}
}
#[test]
fn rerun_is_a_no_op_everything_already_present_by_content_id() {
let (src, dst) = pair(true);
let first = replicate_pairs(&one_pair(src.clone(), dst.clone()), &ReplicationOptions::executing());
assert_eq!(first.copied(), 3);
let second = replicate_pairs(&one_pair(src, dst), &ReplicationOptions::executing());
assert_eq!(second.copied(), 0, "re-run must copy nothing (idempotent)");
assert_eq!(second.skipped(), 3, "every artifact already present by content id");
assert!(second.is_complete());
}
#[test]
fn changed_content_is_recopied_not_skipped() {
let (src, dst) = pair(true);
dst.put(&id("serde", "1.0.0"), b"stale-different-bytes").unwrap();
let r = replicate_pairs(&one_pair(src.clone(), dst.clone()), &ReplicationOptions::executing());
assert_eq!(r.copied(), 3, "the divergent coordinate is re-copied, not skipped");
assert_eq!(r.skipped(), 0);
assert_eq!(
dst.fetch(&id("serde", "1.0.0")).unwrap().unwrap(),
b"serde-bytes",
"destination now matches source bytes"
);
}
#[test]
fn read_only_destination_fails_closed_never_silently_drops() {
let (src, dst) = pair(false); let r = replicate_pairs(&one_pair(src, dst.clone()), &ReplicationOptions::executing());
assert_eq!(r.failed(), 3, "a read-only destination fails every copy closed");
assert_eq!(r.copied(), 0);
assert!(!r.is_complete(), "failures ⇒ not complete");
assert!(dst.list(None, 0).unwrap().is_empty(), "nothing written to a read-only dest");
}
#[test]
fn partial_destination_copies_only_the_missing_remainder() {
let (src, dst) = pair(true);
dst.put(&id("serde", "1.0.0"), b"serde-bytes").unwrap();
let r = replicate_pairs(&one_pair(src.clone(), dst.clone()), &ReplicationOptions::executing());
assert_eq!(r.skipped(), 1, "the already-present artifact is skipped");
assert_eq!(r.copied(), 2, "only the two missing artifacts copy");
for entry in src.list(None, 0).unwrap() {
assert!(dst.fetch(&entry.id).unwrap().is_some(), "dest complete after replicate");
}
}
#[test]
fn limit_caps_examined_artifacts_and_flags_truncation() {
let (src, dst) = pair(true);
let r = replicate_pairs(
&one_pair(src, dst),
&ReplicationOptions { execute: true, limit: 2 },
);
assert_eq!(r.examined(), 2, "only two artifacts examined under the cap");
assert!(r.truncated, "more source artifacts remained ⇒ truncated");
}
}