Milestone 1: workspace skeleton, wire protocol, socket round-trip
Three-crate workspace per SPECS.md. varde-proto defines the full JSON Lines protocol (requests, envelopes, structured errors, events) with string-typed hashes so the crate carries no iroh dependency. The daemon binds its unix socket, loads config with flags > env > file > defaults precedence, and answers status/list; everything else returns a structured "unimplemented" error. varde-ctl maps subcommands 1:1 onto requests and round-trips status against a real daemon in the tests. Dependencies: serde/serde_json (wire format), tokio (async runtime and unix sockets), tracing/tracing-subscriber (structured logging), toml (config file), anyhow (binary-edge errors), thiserror (reserved for library errors), clap (ctl flag parsing, per spec), tempfile (dev-only, ephemeral test dirs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "varde-ctl"
|
||||
description = "CLI client for the varde daemon socket API"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "varde-ctl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
varde-proto = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
varde-daemon = { path = "../varde-daemon" }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,295 @@
|
||||
//! varde-ctl: CLI client for the varde daemon.
|
||||
//!
|
||||
//! Doubles as the reference implementation of the socket protocol: every
|
||||
//! subcommand maps 1:1 onto a [`varde_proto::Request`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use varde_proto::{Envelope, MaterializeMode, PinPolicy, Request, ResponseData};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "varde-ctl", version, about = "Control the varde daemon")]
|
||||
struct Cli {
|
||||
/// Socket path (default: /run/varde/varde.sock as root,
|
||||
/// $XDG_RUNTIME_DIR/varde.sock otherwise; env: VARDE_SOCKET)
|
||||
#[arg(long, global = true, env = "VARDE_SOCKET")]
|
||||
socket: Option<PathBuf>,
|
||||
|
||||
/// Print raw JSON responses instead of human-readable output
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Import a file or directory into the store
|
||||
Add {
|
||||
/// Path to import (made absolute before sending)
|
||||
path: PathBuf,
|
||||
/// Recurse into directories
|
||||
#[arg(short, long)]
|
||||
recursive: bool,
|
||||
},
|
||||
/// Pin a hash: keep it, fetch it if absent, serve it per policy
|
||||
Pin {
|
||||
/// BLAKE3 hash (hex)
|
||||
hash: String,
|
||||
/// Serve to any LAN peer, not just trusted ones
|
||||
#[arg(long)]
|
||||
open_lan: bool,
|
||||
},
|
||||
/// Remove a pin (data stays until `gc`)
|
||||
Unpin {
|
||||
/// BLAKE3 hash (hex)
|
||||
hash: String,
|
||||
},
|
||||
/// Export content from the store to a path
|
||||
Materialize {
|
||||
/// BLAKE3 hash (hex)
|
||||
hash: String,
|
||||
/// Destination path (made absolute before sending)
|
||||
dest: PathBuf,
|
||||
/// Export mode
|
||||
#[arg(long, value_enum, default_value_t = CliMode::ReflinkOrCopy)]
|
||||
mode: CliMode,
|
||||
},
|
||||
/// Show daemon status, or status of one hash
|
||||
Status {
|
||||
/// BLAKE3 hash (hex)
|
||||
hash: Option<String>,
|
||||
},
|
||||
/// List pins
|
||||
List,
|
||||
/// Ticket operations (out-of-band sharing)
|
||||
#[command(subcommand)]
|
||||
Ticket(TicketCommand),
|
||||
/// Peer trust operations
|
||||
#[command(subcommand)]
|
||||
Peer(PeerCommand),
|
||||
/// Drop all unpinned blobs
|
||||
Gc,
|
||||
/// Stream daemon events to stdout (one JSON object per line)
|
||||
Subscribe,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TicketCommand {
|
||||
/// Export a ticket for a hash
|
||||
Export {
|
||||
/// BLAKE3 hash (hex)
|
||||
hash: String,
|
||||
},
|
||||
/// Import a ticket: pin and fetch its content
|
||||
Import {
|
||||
/// Ticket string from `ticket export`
|
||||
ticket: String,
|
||||
/// Serve to any LAN peer, not just trusted ones
|
||||
#[arg(long)]
|
||||
open_lan: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum PeerCommand {
|
||||
/// Trust a peer NodeId
|
||||
Trust {
|
||||
/// iroh NodeId (z-base-32)
|
||||
node_id: String,
|
||||
},
|
||||
/// Revoke trust in a peer NodeId
|
||||
Untrust {
|
||||
/// iroh NodeId (z-base-32)
|
||||
node_id: String,
|
||||
},
|
||||
/// List trusted peers
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
enum CliMode {
|
||||
ReflinkOrCopy,
|
||||
Copy,
|
||||
}
|
||||
|
||||
impl From<CliMode> for MaterializeMode {
|
||||
fn from(m: CliMode) -> Self {
|
||||
match m {
|
||||
CliMode::ReflinkOrCopy => MaterializeMode::ReflinkOrCopy,
|
||||
CliMode::Copy => MaterializeMode::Copy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_socket() -> PathBuf {
|
||||
// Mirrors the daemon's mode detection: root talks to the system
|
||||
// daemon, everyone else to their user daemon.
|
||||
if is_root() {
|
||||
PathBuf::from("/run/varde/varde.sock")
|
||||
} else if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
PathBuf::from(dir).join("varde.sock")
|
||||
} else {
|
||||
PathBuf::from("/run/varde/varde.sock")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_root() -> bool {
|
||||
// SAFETY: geteuid has no preconditions and cannot fail.
|
||||
unsafe { geteuid() == 0 }
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn geteuid() -> u32;
|
||||
}
|
||||
|
||||
fn absolute(path: PathBuf) -> Result<String> {
|
||||
let abs =
|
||||
std::path::absolute(&path).with_context(|| format!("resolving path {}", path.display()))?;
|
||||
Ok(abs.display().to_string())
|
||||
}
|
||||
|
||||
fn to_request(command: Command) -> Result<Request> {
|
||||
Ok(match command {
|
||||
Command::Add { path, recursive } => Request::Add {
|
||||
path: absolute(path)?,
|
||||
recursive,
|
||||
},
|
||||
Command::Pin { hash, open_lan } => Request::Pin {
|
||||
hash,
|
||||
policy: PinPolicy { open_lan },
|
||||
},
|
||||
Command::Unpin { hash } => Request::Unpin { hash },
|
||||
Command::Materialize { hash, dest, mode } => Request::Materialize {
|
||||
hash,
|
||||
dest: absolute(dest)?,
|
||||
mode: mode.into(),
|
||||
},
|
||||
Command::Status { hash } => Request::Status { hash },
|
||||
Command::List => Request::List {},
|
||||
Command::Ticket(TicketCommand::Export { hash }) => Request::TicketExport { hash },
|
||||
Command::Ticket(TicketCommand::Import { ticket, open_lan }) => Request::TicketImport {
|
||||
ticket,
|
||||
pin_policy: PinPolicy { open_lan },
|
||||
},
|
||||
Command::Peer(PeerCommand::Trust { node_id }) => Request::PeerTrust { node_id },
|
||||
Command::Peer(PeerCommand::Untrust { node_id }) => Request::PeerUntrust { node_id },
|
||||
Command::Peer(PeerCommand::List) => Request::PeerList {},
|
||||
Command::Gc => Request::Gc {},
|
||||
Command::Subscribe => Request::Subscribe {},
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let socket = cli.socket.clone().unwrap_or_else(default_socket);
|
||||
let subscribe = matches!(cli.command, Command::Subscribe);
|
||||
let request = to_request(cli.command)?;
|
||||
|
||||
let stream = UnixStream::connect(&socket)
|
||||
.await
|
||||
.with_context(|| format!("connecting to daemon at {}", socket.display()))?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
|
||||
let mut line = serde_json::to_string(&request)?;
|
||||
line.push('\n');
|
||||
write_half.write_all(line.as_bytes()).await?;
|
||||
|
||||
let reply = lines
|
||||
.next_line()
|
||||
.await?
|
||||
.context("daemon closed the connection without replying")?;
|
||||
let envelope: Envelope =
|
||||
serde_json::from_str(&reply).context("daemon sent an unparseable reply")?;
|
||||
|
||||
if !envelope.ok {
|
||||
let err = envelope.error.context("error reply without error body")?;
|
||||
bail!("{:?}: {}", err.code, err.message);
|
||||
}
|
||||
|
||||
if cli.json {
|
||||
println!("{reply}");
|
||||
} else {
|
||||
print_human(envelope.data.as_ref());
|
||||
}
|
||||
|
||||
if subscribe {
|
||||
// Connection is now an event stream; relay lines until it closes.
|
||||
while let Some(event) = lines.next_line().await? {
|
||||
println!("{event}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_human(data: Option<&ResponseData>) {
|
||||
let Some(data) = data else {
|
||||
println!("ok");
|
||||
return;
|
||||
};
|
||||
match data {
|
||||
ResponseData::Added { hash, bytes } => println!("added {hash} ({bytes} bytes)"),
|
||||
ResponseData::Pinned { hash } => println!("pinned {hash}"),
|
||||
ResponseData::Done {} => println!("ok"),
|
||||
ResponseData::Materialized { bytes, reflinked } => {
|
||||
let how = if *reflinked { "reflinked" } else { "copied" };
|
||||
println!("materialized {bytes} bytes ({how})");
|
||||
}
|
||||
ResponseData::Status(s) => {
|
||||
println!("varde-daemon {} (protocol {})", s.version, s.protocol);
|
||||
if let Some(node_id) = &s.node_id {
|
||||
println!("node id: {node_id}");
|
||||
}
|
||||
println!("store: {}", s.store_path);
|
||||
println!("pins: {}", s.pins);
|
||||
println!("peers: {} connected", s.connected_peers);
|
||||
}
|
||||
ResponseData::HashStatus(h) => {
|
||||
println!("hash: {}", h.hash);
|
||||
let total = h
|
||||
.total_bytes
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
println!("bytes: {}/{total}", h.have_bytes);
|
||||
println!("complete: {}", h.complete);
|
||||
println!("pinned: {}", h.pinned);
|
||||
if !h.active_peers.is_empty() {
|
||||
println!("active: {}", h.active_peers.join(", "));
|
||||
}
|
||||
}
|
||||
ResponseData::Pins { pins } => {
|
||||
if pins.is_empty() {
|
||||
println!("no pins");
|
||||
}
|
||||
for pin in pins {
|
||||
let state = if pin.complete { "complete" } else { "partial" };
|
||||
let lan = if pin.policy.open_lan { " open-lan" } else { "" };
|
||||
println!("{} {state}{lan}", pin.hash);
|
||||
}
|
||||
}
|
||||
ResponseData::Ticket { ticket } => println!("{ticket}"),
|
||||
ResponseData::Peers { peers } => {
|
||||
if peers.is_empty() {
|
||||
println!("no trusted peers");
|
||||
}
|
||||
for peer in peers {
|
||||
let state = if peer.connected {
|
||||
"connected"
|
||||
} else {
|
||||
"offline"
|
||||
};
|
||||
println!("{} {state}", peer.node_id);
|
||||
}
|
||||
}
|
||||
ResponseData::GcDone { blobs_removed } => {
|
||||
println!("gc: removed {blobs_removed} blobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Milestone 1: `varde-ctl status` round-trips against a real daemon.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use varde_daemon::config::Config;
|
||||
|
||||
/// Start an in-process daemon on an ephemeral socket, then run the real
|
||||
/// varde-ctl binary against it.
|
||||
#[test]
|
||||
fn ctl_status_round_trips() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let socket_path = dir.path().join("varde.sock");
|
||||
let config = Config {
|
||||
store_dir: dir.path().join("store"),
|
||||
socket_path: socket_path.clone(),
|
||||
max_upload_bytes_per_sec: 0,
|
||||
max_download_bytes_per_sec: 0,
|
||||
discovery: false,
|
||||
wan_upload: false,
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
let _daemon = rt.spawn(varde_daemon::run(config));
|
||||
|
||||
// run() binds the socket synchronously at first poll; give it a beat.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !socket_path.exists() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_varde-ctl"))
|
||||
.args(["--socket", socket_path.to_str().unwrap(), "status"])
|
||||
.output()
|
||||
.expect("running varde-ctl");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "varde-ctl failed: {stderr}");
|
||||
assert!(
|
||||
stdout.contains("varde-daemon"),
|
||||
"unexpected output: {stdout}"
|
||||
);
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_varde-ctl"))
|
||||
.args([
|
||||
"--socket",
|
||||
socket_path.to_str().unwrap(),
|
||||
"--json",
|
||||
"status",
|
||||
])
|
||||
.output()
|
||||
.expect("running varde-ctl --json");
|
||||
assert!(output.status.success());
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).expect("--json output is JSON");
|
||||
assert_eq!(json["ok"], true);
|
||||
}
|
||||
Reference in New Issue
Block a user