use std::path::Path;
use std::path::PathBuf;
use tracing::error;
use tracing::info;
use crate::Res;
use crate::checksum::refresh_hash;
use crate::error::PackageOpError;
use crate::flow;
use crate::flow::PullOutcome;
use crate::flow::apply_latest_update;
use crate::flow::classify_pull;
use crate::flow::remote_delta;
use crate::io::manifest::resolve_tag;
use crate::io::remote::HostConfig;
use crate::io::remote::Remote;
use crate::io::storage::Storage;
use crate::lineage::InstalledPackageStatus;
use crate::lineage::PackageLineage;
use crate::manifest::Manifest;
use crate::paths::DomainPaths;
use quilt_uri::ManifestUri;
use quilt_uri::Namespace;
use quilt_uri::Tag;
#[derive(Debug)]
pub struct PullSnapshot {
pub status: InstalledPackageStatus,
pub latest: ManifestUri,
pub latest_manifest: Manifest,
}
pub async fn snapshot_for_pull(
mut lineage: PackageLineage,
base_manifest: &Manifest,
paths: &DomainPaths,
storage: &(impl Storage + Sync),
remote: &impl Remote,
package_home: impl AsRef<Path>,
host_config: HostConfig,
) -> Res<(PackageLineage, PullSnapshot)> {
let remote_uri = lineage.remote()?.clone();
let origin = remote_uri.origin.clone();
let latest = resolve_tag(remote, origin.as_ref(), remote_uri, Tag::Latest).await?;
lineage.latest_hash.clone_from(&latest.hash);
if latest.hash == lineage.base_hash {
return Err(PackageOpError::AlreadyUpToDate.into());
}
let latest_manifest = flow::cache_remote_manifest(paths, storage, remote, &latest).await?;
let (lineage, status) =
flow::status(lineage, storage, base_manifest, package_home, host_config).await?;
Ok((
lineage,
PullSnapshot {
status,
latest,
latest_manifest,
},
))
}
#[allow(clippy::too_many_arguments)]
pub async fn pull_package(
lineage: PackageLineage,
manifest: &mut Manifest,
paths: &DomainPaths,
storage: &(impl Storage + Sync),
remote: &impl Remote,
working_dir: PathBuf,
snapshot: PullSnapshot,
namespace: Namespace,
) -> Res<PackageLineage> {
info!("⏳ Starting pull for package {}", namespace);
if lineage.commit.is_some() {
error!("❌ Found pending commits, cannot pull");
return Err(PackageOpError::Package("package has pending commits".to_string()).into());
}
let remote_uri = lineage.remote()?.clone();
if remote_uri.hash != lineage.base_hash {
error!("❌ Package has diverged from remote");
return Err(PackageOpError::Package("package has diverged".to_string()).into());
}
if lineage.base_hash == lineage.latest_hash {
error!("❌ Package is already up-to-date");
return Err(PackageOpError::AlreadyUpToDate.into());
}
let outcome = classify_pull(&snapshot.status, manifest, &snapshot.latest_manifest);
match &outcome {
PullOutcome::UpToDate => {
return Err(PackageOpError::AlreadyUpToDate.into());
}
PullOutcome::Blocked { conflicts } => {
error!("❌ Pull blocked by conflicts: {conflicts:?}");
return Err(PackageOpError::PullConflict(conflicts.clone()).into());
}
PullOutcome::CleanUpdate | PullOutcome::KeepsLocalChanges { .. } => {}
}
let touched: Vec<PathBuf> = remote_delta(manifest, &snapshot.latest_manifest)
.into_keys()
.filter(|p| lineage.paths.contains_key(p))
.filter(|p| !snapshot.status.changes.contains_key(p))
.collect();
let mut drifted = Vec::new();
for path in &touched {
let Some(base_row) = manifest.get_record(path) else {
continue;
};
match refresh_hash(storage, &working_dir.join(path), base_row.clone()).await {
Ok(None) => {}
Ok(Some(_)) => drifted.push(path.clone()),
Err(err) if err.is_not_found() => drifted.push(path.clone()),
Err(err) => return Err(err),
}
}
if !drifted.is_empty() {
error!("❌ Working-tree drift on touched paths since the walk: {drifted:?}");
return Err(PackageOpError::PullConflict(drifted).into());
}
let lineage = apply_latest_update(
lineage,
manifest,
paths,
storage,
remote,
working_dir,
namespace,
snapshot.latest,
&touched,
)
.await?;
info!("✔️ Successfully pulled (surgical), outcome={outcome:?}");
Ok(lineage)
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use std::collections::BTreeMap;
use aws_sdk_s3::primitives::ByteStream;
use multihash::Multihash;
use crate::io::remote::HostConfig;
use crate::io::remote::mocks::MockRemote;
use crate::io::storage::StorageExt;
use crate::io::storage::mocks::MockStorage;
use crate::lineage::Change;
use crate::lineage::CommitState;
use crate::lineage::PathState;
use crate::manifest::ManifestRow;
use crate::object_hash::Hash;
use crate::object_hash::Sha256Hash;
use quilt_uri::ManifestUri;
use quilt_uri::S3Uri;
fn row(key: &str, hash_seed: &[u8]) -> ManifestRow {
ManifestRow {
logical_key: PathBuf::from(key),
physical_key: format!("s3://b/{key}"),
hash: Multihash::<256>::wrap(0x12, hash_seed)
.unwrap()
.try_into()
.unwrap(),
size: hash_seed.len() as u64,
meta: None,
}
}
fn manifest_of(rows: Vec<ManifestRow>) -> Manifest {
Manifest {
rows,
..Manifest::default()
}
}
fn snapshot_with(status: InstalledPackageStatus, latest_manifest: Manifest) -> PullSnapshot {
PullSnapshot {
status,
latest: ManifestUri::default(),
latest_manifest,
}
}
#[test(tokio::test)]
async fn added_file_does_not_block_the_guard() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "b".to_string(),
..PackageLineage::default()
};
let status = InstalledPackageStatus {
changes: BTreeMap::from([(
PathBuf::from("new"),
Change::Added(ManifestRow::default()),
)]),
..InstalledPackageStatus::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(status, Manifest::default()),
Namespace::default(),
)
.await;
assert!(matches!(
error.unwrap_err(),
crate::Error::PackageOp(PackageOpError::AlreadyUpToDate)
));
}
#[test(tokio::test)]
async fn snapshot_short_circuits_when_latest_equals_base() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let bucket = "bkt";
let base = "base";
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
bucket: bucket.to_string(),
namespace: ("f", "b").into(),
hash: base.to_string(),
origin: None,
}),
base_hash: base.to_string(),
latest_hash: base.to_string(),
..PackageLineage::default()
};
let tag_uri =
S3Uri::try_from(format!("s3://{bucket}/.quilt/named_packages/f/b/latest").as_str())
.unwrap();
remote
.put_object(None, &tag_uri, base.as_bytes().to_vec())
.await
.unwrap();
let result = snapshot_for_pull(
lineage,
&Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
HostConfig::default(),
)
.await;
assert!(matches!(
result.unwrap_err(),
crate::Error::PackageOp(PackageOpError::AlreadyUpToDate)
));
let manifest_uri = format!("s3://{bucket}/.quilt/packages/{base}");
assert_eq!(remote.get_object_count(&manifest_uri), 0);
}
#[test(tokio::test)]
async fn test_no_pull_if_commit() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
commit: Some(CommitState::default()),
..PackageLineage::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(InstalledPackageStatus::default(), Manifest::default()),
Namespace::default(),
)
.await;
assert_eq!(
error.unwrap_err().to_string(),
"General error regarding package: package has pending commits".to_string()
);
}
#[test(tokio::test)]
async fn test_no_pull_if_diverged() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "b".to_string(),
..PackageLineage::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(InstalledPackageStatus::default(), Manifest::default()),
Namespace::default(),
)
.await;
assert_eq!(
error.unwrap_err().to_string(),
"General error regarding package: package has diverged".to_string()
);
}
#[test(tokio::test)]
async fn test_no_pull_if_up_to_date() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "a".to_string(),
..PackageLineage::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(InstalledPackageStatus::default(), Manifest::default()),
Namespace::default(),
)
.await;
assert!(matches!(
error.unwrap_err(),
crate::Error::PackageOp(PackageOpError::AlreadyUpToDate)
));
}
#[test(tokio::test)]
async fn racing_edit_on_touched_path_aborts_as_conflict() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let working_dir = PathBuf::from("/wd");
let path = PathBuf::from("a");
let edited = b"raced edit after the walk";
storage
.write_byte_stream(working_dir.join(&path), ByteStream::from_static(edited))
.await
.unwrap();
let base = manifest_of(vec![row("a", b"v1")]);
let latest = manifest_of(vec![row("a", b"v2")]);
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "b".to_string(),
paths: BTreeMap::from([(path.clone(), PathState::default())]),
..PackageLineage::default()
};
let status = InstalledPackageStatus::default();
let mut base = base;
let error = pull_package(
lineage,
&mut base,
&DomainPaths::default(),
&storage,
&remote,
working_dir.clone(),
snapshot_with(status, latest),
Namespace::default(),
)
.await;
assert!(
matches!(
error.as_ref().unwrap_err(),
crate::Error::PackageOp(PackageOpError::PullConflict(paths)) if paths == &vec![path.clone()]
),
"expected PullConflict naming `a`, got: {error:?}"
);
assert_eq!(
storage.read_bytes(&working_dir.join(&path)).await.unwrap(),
edited
);
}
#[test(tokio::test)]
async fn missing_touched_path_aborts_as_conflict() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let working_dir = PathBuf::from("/wd");
let path = PathBuf::from("a");
let base = manifest_of(vec![row("a", b"v1")]);
let latest = manifest_of(vec![row("a", b"v2")]);
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "b".to_string(),
paths: BTreeMap::from([(path.clone(), PathState::default())]),
..PackageLineage::default()
};
let status = InstalledPackageStatus::default();
let mut base = base;
let error = pull_package(
lineage,
&mut base,
&DomainPaths::default(),
&storage,
&remote,
working_dir.clone(),
snapshot_with(status, latest),
Namespace::default(),
)
.await;
assert!(
matches!(
error.as_ref().unwrap_err(),
crate::Error::PackageOp(PackageOpError::PullConflict(paths)) if paths == &vec![path.clone()]
),
"expected PullConflict naming `a`, got: {error:?}"
);
}
#[test(tokio::test)]
async fn touched_path_matching_base_passes_verify_and_pulls() -> crate::Res {
let storage = MockStorage::default();
let remote = MockRemote::default();
let bucket = "bkt";
let namespace: Namespace = ("f", "b").into();
let base_hash = "OLD";
let latest_hash = "NEW";
let working_dir = PathBuf::from("/wd");
let path = PathBuf::from("a");
let paths = DomainPaths::default();
paths.scaffold_for_caching(&storage, bucket).await?;
let content = b"the base content of a";
storage
.write_byte_stream(working_dir.join(&path), ByteStream::from_static(content))
.await?;
let file = storage.open_file(&working_dir.join(&path)).await?;
let hash: Multihash<256> = Sha256Hash::from_reader(file, content.len() as u64)
.await?
.into();
let base_row = ManifestRow {
logical_key: path.clone(),
hash: hash.try_into()?,
size: content.len() as u64,
..ManifestRow::default()
};
let base = manifest_of(vec![base_row]);
let latest_manifest = manifest_of(vec![]);
let latest_uri = ManifestUri {
bucket: bucket.to_string(),
namespace: namespace.clone(),
hash: latest_hash.to_string(),
origin: None,
};
remote
.put_object(
None,
&S3Uri::try_from(format!("s3://{bucket}/.quilt/packages/{latest_hash}").as_str())?,
br#"{"version": "v0"}"#.to_vec(),
)
.await?;
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
bucket: bucket.to_string(),
namespace: namespace.clone(),
hash: base_hash.to_string(),
origin: None,
}),
base_hash: base_hash.to_string(),
latest_hash: latest_hash.to_string(),
paths: BTreeMap::from([(path.clone(), PathState::default())]),
..PackageLineage::default()
};
let snapshot = PullSnapshot {
status: InstalledPackageStatus::default(),
latest: latest_uri,
latest_manifest,
};
let mut base = base;
let lineage = pull_package(
lineage,
&mut base,
&paths,
&storage,
&remote,
working_dir.clone(),
snapshot,
namespace,
)
.await?;
assert_eq!(lineage.base_hash, latest_hash);
assert!(!lineage.paths.contains_key(&path));
Ok(())
}
}