Skip to content

Commit 7b00a06

Browse files
flokliclbot
authored andcommitted
feat(tvix/nar-bridge): init
This adds an implementation of nar-bridge in Rust. Currently, only the GET parts are implemented. Contrary to the Go variant, this doesn't try to keep a mapping from nar hashes to root node in memory, it simply encodes the root node itself (stripped by its basename) into the URL. This pulls in a more recent version of axum than what we use in tonic, causing two versions of http and hyper, however dealing with `Body::from_stream` in axum 0.6 is much more annoying, and hyperium/tonic#1740 suggests this will be fixed soon. Change-Id: Ia4c2dbda7cd3fdbe47a75f3e33544d19eac6e44e Reviewed-on: https://cl.tvl.fyi/c/depot/+/11898 Autosubmit: flokli <[email protected]> Reviewed-by: Brian Olsen <[email protected]> Tested-by: BuildkiteCI
1 parent de6882a commit 7b00a06

File tree

8 files changed

+1475
-113
lines changed

8 files changed

+1475
-113
lines changed

Cargo.lock

Lines changed: 231 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.nix

Lines changed: 861 additions & 76 deletions
Large diffs are not rendered by default.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ members = [
2525
"eval",
2626
"eval/builtin-macros",
2727
"glue",
28+
"nar-bridge",
2829
"nix-compat",
2930
"serde",
3031
"store",

nar-bridge/Cargo.toml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
[package]
2+
name = "nar-bridge"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
axum = { version = "0.7.5", features = ["http2"] }
8+
bytes = "1.4.0"
9+
clap = { version = "4.0", features = ["derive", "env"] }
10+
data-encoding = "2.3.3"
11+
itertools = "0.12.0"
12+
prost = "0.12.1"
13+
nix-compat = { path = "../nix-compat", features = ["async"] }
14+
thiserror = "1.0.56"
15+
tokio = { version = "1.32.0" }
16+
tokio-listener = { version = "0.4.2", features = [ "axum07", "clap", "multi-listener", "sd_listen" ] }
17+
tokio-util = { version = "0.7.9", features = ["io", "io-util", "compat"] }
18+
tonic = { version = "0.11.0", features = ["tls", "tls-roots"] }
19+
tvix-castore = { path = "../castore" }
20+
tvix-store = { path = "../store" }
21+
tvix-tracing = { path = "../tracing", features = ["tonic"] }
22+
tracing = "0.1.37"
23+
tracing-subscriber = "0.3.16"
24+
url = "2.4.0"
25+
serde = { version = "1.0.204", features = ["derive"] }
26+
27+
[build-dependencies]
28+
prost-build = "0.12.1"
29+
tonic-build = "0.11.0"
30+
31+
[features]
32+
default = ["otlp"]
33+
otlp = ["tvix-tracing/otlp"]
34+
35+
[dev-dependencies]
36+
hex-literal = "0.4.1"
37+
rstest = "0.19.0"
38+
39+
[lints]
40+
workspace = true

nar-bridge/src/bin/nar-bridge.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
use clap::Parser;
2+
use nar_bridge::AppState;
3+
use tracing::info;
4+
5+
/// Expose the Nix HTTP Binary Cache protocol for a tvix-store.
6+
#[derive(Parser)]
7+
#[command(author, version, about, long_about = None)]
8+
struct Cli {
9+
#[arg(long, env, default_value = "grpc+http://[::1]:8000")]
10+
blob_service_addr: String,
11+
12+
#[arg(long, env, default_value = "grpc+http://[::1]:8000")]
13+
directory_service_addr: String,
14+
15+
#[arg(long, env, default_value = "grpc+http://[::1]:8000")]
16+
path_info_service_addr: String,
17+
18+
/// The priority to announce at the `nix-cache-info` endpoint.
19+
/// A lower number means it's *more preferred.
20+
#[arg(long, env, default_value_t = 39)]
21+
priority: u64,
22+
23+
/// The address to listen on.
24+
#[clap(flatten)]
25+
listen_args: tokio_listener::ListenerAddressLFlag,
26+
27+
#[cfg(feature = "otlp")]
28+
/// Whether to configure OTLP. Set --otlp=false to disable.
29+
#[arg(long, default_missing_value = "true", default_value = "true", num_args(0..=1), require_equals(true), action(clap::ArgAction::Set))]
30+
otlp: bool,
31+
}
32+
33+
#[tokio::main]
34+
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
35+
let cli = Cli::parse();
36+
37+
let _tracing_handle = {
38+
#[allow(unused_mut)]
39+
let mut builder = tvix_tracing::TracingBuilder::default();
40+
#[cfg(feature = "otlp")]
41+
{
42+
if cli.otlp {
43+
builder = builder.enable_otlp("tvix.store");
44+
}
45+
}
46+
builder.build()?
47+
};
48+
49+
// initialize stores
50+
let (blob_service, directory_service, path_info_service, _nar_calculation_service) =
51+
tvix_store::utils::construct_services(
52+
cli.blob_service_addr,
53+
cli.directory_service_addr,
54+
cli.path_info_service_addr,
55+
)
56+
.await?;
57+
58+
let state = AppState::new(blob_service, directory_service, path_info_service.into());
59+
60+
let app = nar_bridge::gen_router(cli.priority).with_state(state);
61+
62+
let listen_address = &cli.listen_args.listen_address.unwrap_or_else(|| {
63+
"[::]:8000"
64+
.parse()
65+
.expect("invalid fallback listen address")
66+
});
67+
68+
let listener = tokio_listener::Listener::bind(
69+
listen_address,
70+
&Default::default(),
71+
&cli.listen_args.listener_options,
72+
)
73+
.await?;
74+
75+
info!(listen_address=%listen_address, "starting daemon");
76+
77+
tokio_listener::axum07::serve(
78+
listener,
79+
app.into_make_service_with_connect_info::<tokio_listener::SomeSocketAddrClonable>(),
80+
)
81+
.await?;
82+
83+
Ok(())
84+
}

nar-bridge/src/lib.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use axum::routing::head;
2+
use axum::{routing::get, Router};
3+
use std::sync::Arc;
4+
use tvix_castore::blobservice::BlobService;
5+
use tvix_castore::directoryservice::DirectoryService;
6+
use tvix_store::pathinfoservice::PathInfoService;
7+
8+
mod nar;
9+
mod narinfo;
10+
11+
#[derive(Clone)]
12+
pub struct AppState {
13+
blob_service: Arc<dyn BlobService>,
14+
directory_service: Arc<dyn DirectoryService>,
15+
path_info_service: Arc<dyn PathInfoService>,
16+
}
17+
18+
impl AppState {
19+
pub fn new(
20+
blob_service: Arc<dyn BlobService>,
21+
directory_service: Arc<dyn DirectoryService>,
22+
path_info_service: Arc<dyn PathInfoService>,
23+
) -> Self {
24+
Self {
25+
blob_service,
26+
directory_service,
27+
path_info_service,
28+
}
29+
}
30+
}
31+
32+
pub fn gen_router(priority: u64) -> Router<AppState> {
33+
Router::new()
34+
.route("/", get(root))
35+
.route("/nar/tvix-castore/:root_node_enc", get(nar::get))
36+
.route("/:narinfo_str", get(narinfo::get))
37+
.route("/:narinfo_str", head(narinfo::head))
38+
.route("/nix-cache-info", get(move || nix_cache_info(priority)))
39+
}
40+
41+
async fn root() -> &'static str {
42+
"Hello from nar-bridge"
43+
}
44+
45+
async fn nix_cache_info(priority: u64) -> String {
46+
format!(
47+
"StoreDir: /nix/store\nWantMassQuery: 1\nPriority: {}\n",
48+
priority
49+
)
50+
}

nar-bridge/src/nar.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
use axum::body::Body;
2+
use axum::extract::Query;
3+
use axum::http::StatusCode;
4+
use axum::response::Response;
5+
use bytes::Bytes;
6+
use data_encoding::BASE64URL_NOPAD;
7+
use serde::Deserialize;
8+
use tokio_util::io::ReaderStream;
9+
use tracing::{instrument, warn};
10+
11+
use crate::AppState;
12+
13+
#[derive(Debug, Deserialize)]
14+
pub(crate) struct GetNARParams {
15+
#[serde(rename = "narsize")]
16+
nar_size: u64,
17+
}
18+
19+
#[instrument(skip(blob_service, directory_service))]
20+
pub async fn get(
21+
axum::extract::Path(root_node_enc): axum::extract::Path<String>,
22+
axum::extract::Query(GetNARParams { nar_size }): Query<GetNARParams>,
23+
axum::extract::State(AppState {
24+
blob_service,
25+
directory_service,
26+
..
27+
}): axum::extract::State<AppState>,
28+
) -> Result<Response, StatusCode> {
29+
use prost::Message;
30+
// b64decode the root node passed *by the user*
31+
let root_node_proto = BASE64URL_NOPAD
32+
.decode(root_node_enc.as_bytes())
33+
.map_err(|e| {
34+
warn!(err=%e, "unable to decode root node b64");
35+
StatusCode::NOT_FOUND
36+
})?;
37+
38+
// check the proto size to be somewhat reasonable before parsing it.
39+
if root_node_proto.len() > 4096 {
40+
warn!("rejected too large root node");
41+
return Err(StatusCode::BAD_REQUEST);
42+
}
43+
44+
// parse the proto
45+
let root_node: tvix_castore::proto::Node = Message::decode(Bytes::from(root_node_enc))
46+
.map_err(|e| {
47+
warn!(err=%e, "unable to decode root node proto");
48+
StatusCode::NOT_FOUND
49+
})?;
50+
51+
// validate it.
52+
let root_node = root_node
53+
.validate()
54+
.map_err(|e| {
55+
warn!(err=%e, "root node validation failed");
56+
StatusCode::BAD_REQUEST
57+
})?
58+
.to_owned();
59+
60+
let (w, r) = tokio::io::duplex(1024 * 8);
61+
62+
// spawn a task rendering the NAR to the client
63+
tokio::spawn(async move {
64+
if let Err(e) =
65+
tvix_store::nar::write_nar(w, &root_node, blob_service, directory_service).await
66+
{
67+
warn!(err=%e, "failed to write out NAR");
68+
}
69+
});
70+
71+
Ok(Response::builder()
72+
.status(StatusCode::OK)
73+
.header("cache-control", "max-age=31536000, immutable")
74+
.header("content-length", nar_size)
75+
.body(Body::from_stream(ReaderStream::new(r)))
76+
.unwrap())
77+
}

nar-bridge/src/narinfo.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use axum::http::StatusCode;
2+
use nix_compat::nixbase32;
3+
use tracing::{instrument, warn, Span};
4+
use tvix_castore::proto::node::Node;
5+
6+
use crate::AppState;
7+
8+
#[instrument(skip(path_info_service))]
9+
pub async fn head(
10+
axum::extract::Path(narinfo_str): axum::extract::Path<String>,
11+
axum::extract::State(AppState {
12+
path_info_service, ..
13+
}): axum::extract::State<AppState>,
14+
) -> Result<&'static str, StatusCode> {
15+
let digest = parse_narinfo_str(&narinfo_str)?;
16+
Span::current().record("path_info.digest", &narinfo_str[0..32]);
17+
18+
if path_info_service
19+
.get(digest)
20+
.await
21+
.map_err(|e| {
22+
warn!(err=%e, "failed to get PathInfo");
23+
StatusCode::INTERNAL_SERVER_ERROR
24+
})?
25+
.is_some()
26+
{
27+
Ok("")
28+
} else {
29+
warn!("PathInfo not found");
30+
Err(StatusCode::NOT_FOUND)
31+
}
32+
}
33+
34+
#[instrument(skip(path_info_service))]
35+
pub async fn get(
36+
axum::extract::Path(narinfo_str): axum::extract::Path<String>,
37+
axum::extract::State(AppState {
38+
path_info_service, ..
39+
}): axum::extract::State<AppState>,
40+
) -> Result<String, StatusCode> {
41+
let digest = parse_narinfo_str(&narinfo_str)?;
42+
Span::current().record("path_info.digest", &narinfo_str[0..32]);
43+
44+
// fetch the PathInfo
45+
let path_info = path_info_service
46+
.get(digest)
47+
.await
48+
.map_err(|e| {
49+
warn!(err=%e, "failed to get PathInfo");
50+
StatusCode::INTERNAL_SERVER_ERROR
51+
})?
52+
.ok_or(StatusCode::NOT_FOUND)?;
53+
54+
let store_path = path_info.validate().map_err(|e| {
55+
warn!(err=%e, "invalid PathInfo");
56+
StatusCode::INTERNAL_SERVER_ERROR
57+
})?;
58+
59+
let mut narinfo = path_info.to_narinfo(store_path).ok_or_else(|| {
60+
warn!(path_info=?path_info, "PathInfo contained no NAR data");
61+
StatusCode::INTERNAL_SERVER_ERROR
62+
})?;
63+
64+
// encode the (unnamed) root node in the NAR url itself.
65+
let root_node = path_info
66+
.node
67+
.as_ref()
68+
.and_then(|n| n.node.as_ref())
69+
.expect("root node must not be none")
70+
.clone()
71+
.rename("".into());
72+
73+
let mut buf = Vec::new();
74+
Node::encode(&root_node, &mut buf);
75+
76+
let url = format!(
77+
"nar/tvix-castore/{}?narsize={}",
78+
data_encoding::BASE64URL_NOPAD.encode(&buf),
79+
narinfo.nar_size,
80+
);
81+
82+
narinfo.url = &url;
83+
84+
Ok(narinfo.to_string())
85+
}
86+
87+
/// Parses a `3mzh8lvgbynm9daj7c82k2sfsfhrsfsy.narinfo` string and returns the
88+
/// nixbase32-decoded digest.
89+
fn parse_narinfo_str(s: &str) -> Result<[u8; 20], StatusCode> {
90+
if !s.is_char_boundary(32) {
91+
warn!("invalid string, no char boundary at 32");
92+
return Err(StatusCode::NOT_FOUND);
93+
}
94+
95+
Ok(match s.split_at(32) {
96+
(hash_str, ".narinfo") => {
97+
// we know this is 32 bytes
98+
let hash_str_fixed: [u8; 32] = hash_str.as_bytes().try_into().unwrap();
99+
nixbase32::decode_fixed(hash_str_fixed).map_err(|e| {
100+
warn!(err=%e, "invalid digest");
101+
StatusCode::NOT_FOUND
102+
})?
103+
}
104+
_ => {
105+
warn!("invalid string");
106+
return Err(StatusCode::NOT_FOUND);
107+
}
108+
})
109+
}
110+
111+
#[cfg(test)]
112+
mod test {
113+
use super::parse_narinfo_str;
114+
use hex_literal::hex;
115+
116+
#[test]
117+
fn success() {
118+
assert_eq!(
119+
hex!("8a12321522fd91efbd60ebb2481af88580f61600"),
120+
parse_narinfo_str("00bgd045z0d4icpbc2yyz4gx48ak44la.narinfo").unwrap()
121+
);
122+
}
123+
124+
#[test]
125+
fn failure() {
126+
assert!(parse_narinfo_str("00bgd045z0d4icpbc2yyz4gx48ak44la").is_err());
127+
assert!(parse_narinfo_str("/00bgd045z0d4icpbc2yyz4gx48ak44la").is_err());
128+
assert!(parse_narinfo_str("000000").is_err());
129+
assert!(parse_narinfo_str("00bgd045z0d4icpbc2yyz4gx48ak44l🦊.narinfo").is_err());
130+
}
131+
}

0 commit comments

Comments
 (0)