radicle-heartwood-lfs/crates/radicle-cli/src/ipfs.rs

150 lines
5.4 KiB
Rust

//! Pinning of Git-LFS-backed content in a local IPFS (Kubo) node.
//!
//! This is a prototype of "Git-LFS over IPFS": large files tracked via
//! Git LFS are stored in IPFS rather than as git blobs, and the mapping
//! from an LFS pointer file's git blob oid to the corresponding IPFS CID
//! is recorded as a git note on `refs/notes/rad-lfs` (note content
//! `cid=<cid>`).
//!
//! Since seeding a repository already means "keep a full local copy of
//! it", we extend that lifecycle to IPFS: seeding a repository pins all
//! of its known LFS objects locally, and unseeding unpins them.
use std::env;
use anyhow::anyhow;
use radicle::git::raw::Repository;
use crate::terminal as term;
/// Git notes ref under which oid -> CID mappings for LFS objects are stored.
pub const LFS_NOTES_REF: &str = "refs/notes/rad-lfs";
/// Default HTTP API address of a local Kubo (IPFS) daemon.
const DEFAULT_KUBO_API_URL: &str = "http://127.0.0.1:5001";
/// Returns the Kubo HTTP API base URL, respecting the `KUBO_API_URL`
/// environment variable if set.
pub(crate) fn kubo_api_url() -> String {
env::var("KUBO_API_URL").unwrap_or_else(|_| DEFAULT_KUBO_API_URL.to_string())
}
/// Check that a local IPFS (Kubo) daemon is reachable, failing with a
/// clear, actionable error otherwise. Used by `rad lfs init` to refuse to
/// proceed silently when there's no daemon to back LFS content with.
pub fn check_daemon() -> anyhow::Result<()> {
let base = kubo_api_url();
let url = format!("{base}/api/v0/id");
match ureq::post(&url).send_empty() {
Ok(_) => Ok(()),
Err(err) if is_daemon_unreachable(&err) => Err(anyhow!(
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
This repo's large-file (LFS) support is backed by your local IPFS node."
)),
Err(err) => Err(anyhow!("IPFS daemon at {base} responded unexpectedly: {err}")),
}
}
/// Collect the set of CIDs referenced by LFS notes on [`LFS_NOTES_REF`].
///
/// Returns an empty vector if the notes ref doesn't exist, i.e. the
/// repository has no LFS objects tracked yet. This is not an error.
#[must_use]
pub fn lfs_cids(repo: &Repository) -> Vec<String> {
let notes = match repo.notes(Some(LFS_NOTES_REF)) {
Ok(notes) => notes,
// The `refs/notes/rad-lfs` ref doesn't exist, which just means
// there are no LFS objects tracked for this repository yet.
Err(_) => return Vec::new(),
};
let mut cids = Vec::new();
for note in notes {
let Ok((_, annotated_id)) = note else {
continue;
};
let Ok(note) = repo.find_note(Some(LFS_NOTES_REF), annotated_id) else {
continue;
};
let Some(message) = note.message() else {
continue;
};
if let Some(cid) = message.trim().strip_prefix("cid=") {
cids.push(cid.to_string());
}
}
cids
}
/// Whether a `ureq` error indicates that the local IPFS daemon could
/// not be reached at all, as opposed to an HTTP-level error response.
fn is_daemon_unreachable(err: &ureq::Error) -> bool {
matches!(
err,
ureq::Error::ConnectionFailed
| ureq::Error::Io(_)
| ureq::Error::HostNotFound
| ureq::Error::Timeout(_)
)
}
/// Pin the given CIDs recursively in the local IPFS daemon.
///
/// Returns the number of CIDs successfully pinned. If the local daemon
/// is not reachable, a warning is printed and `0` is returned; this is
/// not treated as a hard failure, since seeding itself already succeeded.
#[must_use]
pub fn pin_all(cids: &[String]) -> usize {
let base = kubo_api_url();
let mut pinned = 0;
for cid in cids {
let url = format!("{base}/api/v0/pin/add?arg={cid}&recursive=true");
match ureq::post(&url).send_empty() {
Ok(_) => pinned += 1,
Err(err) if is_daemon_unreachable(&err) => {
term::warning(format!(
"Could not reach local IPFS daemon at {base}; LFS objects were not pinned"
));
return pinned;
}
Err(err) => {
term::warning(format!("Failed to pin LFS object {cid} in IPFS: {err}"));
}
}
}
pinned
}
/// Unpin the given CIDs from the local IPFS daemon.
///
/// If the local daemon is not reachable, a warning is printed and the
/// function returns without failing; this is not treated as a hard
/// failure, since unseeding itself already succeeded.
pub fn unpin_all(cids: &[String]) {
let base = kubo_api_url();
for cid in cids {
let url = format!("{base}/api/v0/pin/rm?arg={cid}");
// NOTE: this unconditionally unpins `cid`, even if it's still
// referenced by LFS objects of another repository that remains
// seeded. This prototype has no cross-repo pin refcounting, so
// a CID shared between repositories will be unpinned here
// regardless of whether other seeded repos still need it.
match ureq::post(&url).send_empty() {
Ok(_) => {}
Err(err) if is_daemon_unreachable(&err) => {
term::warning(format!(
"Could not reach local IPFS daemon at {base}; LFS objects were not unpinned"
));
return;
}
Err(err) => {
term::warning(format!("Failed to unpin LFS object {cid} in IPFS: {err}"));
}
}
}
}