about summary refs log tree commit diff
path: root/tvix/build
diff options
context:
space:
mode:
Diffstat (limited to 'tvix/build')
-rw-r--r--tvix/build/Cargo.toml36
-rw-r--r--tvix/build/build.rs38
-rw-r--r--tvix/build/default.nix11
-rw-r--r--tvix/build/protos/LICENSE21
-rw-r--r--tvix/build/protos/build.proto163
-rw-r--r--tvix/build/protos/default.nix56
-rw-r--r--tvix/build/protos/rpc_build.proto13
-rw-r--r--tvix/build/src/bin/tvix-build.rs112
-rw-r--r--tvix/build/src/buildservice/dummy.rs19
-rw-r--r--tvix/build/src/buildservice/from_addr.rs90
-rw-r--r--tvix/build/src/buildservice/grpc.rs28
-rw-r--r--tvix/build/src/buildservice/mod.rs16
-rw-r--r--tvix/build/src/lib.rs2
-rw-r--r--tvix/build/src/proto/grpc_buildservice_wrapper.rs35
-rw-r--r--tvix/build/src/proto/mod.rs264
15 files changed, 904 insertions, 0 deletions
diff --git a/tvix/build/Cargo.toml b/tvix/build/Cargo.toml
new file mode 100644
index 0000000000..6faa3ad7ab
--- /dev/null
+++ b/tvix/build/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "tvix-build"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+bytes = "1.4.0"
+clap = { version = "4.0", features = ["derive", "env"] }
+itertools = "0.12.0"
+prost = "0.12.1"
+thiserror = "1.0.56"
+tokio = { version = "1.32.0" }
+tokio-listener = { version = "0.4.1", features = [ "tonic011" ] }
+tonic = { version = "0.11.0", features = ["tls", "tls-roots"] }
+tvix-castore = { path = "../castore" }
+tvix-tracing = { path = "../tracing" }
+tracing = "0.1.40"
+url = "2.4.0"
+
+[dependencies.tonic-reflection]
+optional = true
+version = "0.11.0"
+
+[build-dependencies]
+prost-build = "0.12.1"
+tonic-build = "0.11.0"
+
+[features]
+default = []
+tonic-reflection = ["dep:tonic-reflection", "tvix-castore/tonic-reflection"]
+
+[dev-dependencies]
+rstest = "0.19.0"
+
+[lints]
+workspace = true
diff --git a/tvix/build/build.rs b/tvix/build/build.rs
new file mode 100644
index 0000000000..c3518ea877
--- /dev/null
+++ b/tvix/build/build.rs
@@ -0,0 +1,38 @@
+use std::io::Result;
+
+fn main() -> Result<()> {
+    #[allow(unused_mut)]
+    let mut builder = tonic_build::configure();
+
+    #[cfg(feature = "tonic-reflection")]
+    {
+        let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
+        let descriptor_path = out_dir.join("tvix.build.v1.bin");
+
+        builder = builder.file_descriptor_set_path(descriptor_path);
+    };
+
+    // https://github.com/hyperium/tonic/issues/908
+    let mut config = prost_build::Config::new();
+    config.bytes(["."]);
+    config.extern_path(".tvix.castore.v1", "::tvix_castore::proto");
+
+    builder
+        .build_server(true)
+        .build_client(true)
+        .emit_rerun_if_changed(false)
+        .compile_with_config(
+            config,
+            &[
+                "tvix/build/protos/build.proto",
+                "tvix/build/protos/rpc_build.proto",
+            ],
+            // If we are in running `cargo build` manually, using `../..` works fine,
+            // but in case we run inside a nix build, we need to instead point PROTO_ROOT
+            // to a sparseTree containing that structure.
+            &[match std::env::var_os("PROTO_ROOT") {
+                Some(proto_root) => proto_root.to_str().unwrap().to_owned(),
+                None => "../..".to_string(),
+            }],
+        )
+}
diff --git a/tvix/build/default.nix b/tvix/build/default.nix
new file mode 100644
index 0000000000..aafab92fd5
--- /dev/null
+++ b/tvix/build/default.nix
@@ -0,0 +1,11 @@
+{ depot, lib, ... }:
+
+(depot.tvix.crates.workspaceMembers.tvix-build.build.override {
+  runTests = true;
+}).overrideAttrs (old: rec {
+  meta.ci.targets = lib.filter (x: lib.hasPrefix "with-features" x || x == "no-features") (lib.attrNames passthru);
+  passthru = depot.tvix.utils.mkFeaturePowerset {
+    inherit (old) crateName;
+    features = [ "tonic-reflection" ];
+  };
+})
diff --git a/tvix/build/protos/LICENSE b/tvix/build/protos/LICENSE
new file mode 100644
index 0000000000..2034ada6fd
--- /dev/null
+++ b/tvix/build/protos/LICENSE
@@ -0,0 +1,21 @@
+Copyright © The Tvix Authors
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+“Software”), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
diff --git a/tvix/build/protos/build.proto b/tvix/build/protos/build.proto
new file mode 100644
index 0000000000..f1f6bf0b05
--- /dev/null
+++ b/tvix/build/protos/build.proto
@@ -0,0 +1,163 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2022 The Tvix Authors
+
+syntax = "proto3";
+
+package tvix.build.v1;
+
+import "tvix/castore/protos/castore.proto";
+
+option go_package = "code.tvl.fyi/tvix/build-go;buildv1";
+
+// A BuildRequest describes the request of something to be run on the builder.
+// It is distinct from an actual [Build] that has already happened, or might be
+// currently ongoing.
+//
+// A BuildRequest can be seen as a more normalized version of a Derivation
+// (parsed from A-Term), "writing out" some of the Nix-internal details about
+// how e.g. environment variables in the build are set.
+//
+// Nix has some impurities when building a Derivation, for example the --cores option
+// ends up as an environment variable in the build, that's not part of the ATerm.
+//
+// As of now, we serialize this into the BuildRequest, so builders can stay dumb.
+// This might change in the future.
+//
+// There's also a big difference when it comes to how inputs are modelled:
+//  - Nix only uses store path (strings) to describe the inputs.
+//    As store paths can be input-addressed, a certain store path can contain
+//    different contents (as not all store paths are binary reproducible).
+//    This requires that for every input-addressed input, the builder has access
+//    to either the input's deriver (and needs to build it) or else a trusted
+//    source for the built input.
+//    to upload input-addressed paths, requiring the trusted users concept.
+//  - tvix-build records a list of tvix.castore.v1.Node as inputs.
+//    These map from the store path base name to their contents, relieving the
+//    builder from having to "trust" any input-addressed paths, contrary to Nix.
+//
+// While this approach gives a better hermeticity, it has one downside:
+// A BuildRequest can only be sent once the contents of all its inputs are known.
+//
+// As of now, we're okay to accept this, but it prevents uploading an
+// entirely-non-IFD subgraph of BuildRequests eagerly.
+//
+// FUTUREWORK: We might be introducing another way to refer to inputs, to
+// support "send all BuildRequest for a nixpkgs eval to a remote builder and put
+// the laptop to sleep" usecases later.
+message BuildRequest {
+  // The list of all root nodes that should be visible in `inputs_dir` at the
+  // time of the build.
+  // As root nodes are content-addressed, no additional signatures are needed
+  // to substitute / make these available in the build environment.
+  // Inputs MUST be sorted by their names.
+  repeated tvix.castore.v1.Node inputs = 1;
+
+  // The command (and its args) executed as the build script.
+  // In the case of a Nix derivation, this is usually
+  // ["/path/to/some-bash/bin/bash", "-e", "/path/to/some/builder.sh"].
+  repeated string command_args = 2;
+
+  // The working dir of the command, relative to the build root.
+  // "build", in the case of Nix.
+  // This MUST be a clean relative path, without any ".", "..", or superfluous
+  // slashes.
+  string working_dir = 3;
+
+  // A list of "scratch" paths, relative to the build root.
+  // These will be write-able during the build.
+  // [build, nix/store] in the case of Nix.
+  // These MUST be clean relative paths, without any ".", "..", or superfluous
+  // slashes, and sorted.
+  repeated string scratch_paths = 4;
+
+  // The path where the castore input nodes will be located at,
+  // "nix/store" in case of Nix.
+  // Builds might also write into here (Nix builds do that).
+  // This MUST be a clean relative path, without any ".", "..", or superfluous
+  // slashes.
+  string inputs_dir = 5;
+
+  // The list of output paths the build is expected to produce,
+  // relative to the root.
+  // If the path is not produced, the build is considered to have failed.
+  // These MUST be clean relative paths, without any ".", "..", or superfluous
+  // slashes, and sorted.
+  repeated string outputs = 6;
+
+  // The list of environment variables and their values that should be set
+  // inside the build environment.
+  // This includes both environment vars set inside the derivation, as well as
+  // more "ephemeral" ones like NIX_BUILD_CORES, controlled by the `--cores`
+  // CLI option of `nix-build`.
+  // For now, we consume this as an option when turning a Derivation into a BuildRequest,
+  // similar to how Nix has a `--cores` option.
+  // We don't want to bleed these very nix-specific sandbox impl details into
+  // (dumber) builders if we don't have to.
+  // Environment variables are sorted by their keys.
+  repeated EnvVar environment_vars = 7;
+
+  message EnvVar {
+    // name of the environment variable. Must not contain =.
+    string key = 1;
+    bytes value = 2;
+  }
+
+  // A set of constraints that need to be satisfied on a build host before a
+  // Build can be started.
+  BuildConstraints constraints = 8;
+
+  // BuildConstraints represents certain conditions that must be fulfilled
+  // inside the build environment to be able to build this.
+  // Constraints can be things like required architecture and minimum amount of memory.
+  // The required input paths are *not* represented in here, because it
+  // wouldn't be hermetic enough - see the comment around inputs too.
+  message BuildConstraints {
+    // The system that's needed to execute the build.
+    // Must not be empty.
+    string system = 1;
+
+    // The amount of memory required to be available for the build, in bytes.
+    uint64 min_memory = 2;
+
+    // A list of (absolute) paths that need to be available in the build
+    // environment, like `/dev/kvm`.
+    // This is distinct from the castore nodes in inputs.
+    // TODO: check if these should be individual constraints instead.
+    // These MUST be clean absolute paths, without any ".", "..", or superfluous
+    // slashes, and sorted.
+    repeated string available_ro_paths = 3;
+
+    // Whether the build should be able to access the network,
+    bool network_access = 4;
+
+    // Whether to provide a /bin/sh inside the build environment, usually a static bash.
+    bool provide_bin_sh = 5;
+  }
+
+  // Additional (small) files and their contents that should be placed into the
+  // build environment, but outside inputs_dir.
+  // Used for passAsFile and structuredAttrs in Nix.
+  repeated AdditionalFile additional_files = 9;
+
+  message AdditionalFile {
+    string path = 1;
+    bytes contents = 2;
+  }
+
+  // TODO: allow describing something like "preferLocal", to influence composition?
+}
+
+// A Build is (one possible) outcome of executing a [BuildRequest].
+message Build {
+  // The orginal build request producing the build.
+  BuildRequest build_request = 1; // <- TODO: define hashing scheme for BuildRequest, refer to it by hash?
+
+  // The outputs that were produced after successfully building.
+  // They are sorted by their names.
+  repeated tvix.castore.v1.Node outputs = 2;
+
+  // TODO: where did this run, how long, logs, …
+}
+
+/// TODO: check remarkable notes on constraints again
+/// TODO: https://github.com/adisbladis/go-nix/commit/603df5db86ab97ba29f6f94d74f4e51642c56834
diff --git a/tvix/build/protos/default.nix b/tvix/build/protos/default.nix
new file mode 100644
index 0000000000..790655ac75
--- /dev/null
+++ b/tvix/build/protos/default.nix
@@ -0,0 +1,56 @@
+{ depot, pkgs, ... }:
+let
+  protos = depot.nix.sparseTree {
+    name = "build-protos";
+    root = depot.path.origSrc;
+    paths = [
+      # We need to include castore.proto (only), as it's referred.
+      ../../castore/protos/castore.proto
+      ./build.proto
+      ./rpc_build.proto
+      ../../../buf.yaml
+      ../../../buf.gen.yaml
+    ];
+  };
+in
+depot.nix.readTree.drvTargets {
+  inherit protos;
+
+  # Lints and ensures formatting of the proto files.
+  check = pkgs.stdenv.mkDerivation {
+    name = "proto-check";
+    src = protos;
+
+    nativeBuildInputs = [
+      pkgs.buf
+    ];
+
+    buildPhase = ''
+      export HOME=$TMPDIR
+      buf lint
+      buf format -d --exit-code
+      touch $out
+    '';
+  };
+
+  # Produces the golang bindings.
+  go-bindings = pkgs.stdenv.mkDerivation {
+    name = "go-bindings";
+
+    src = protos;
+
+    nativeBuildInputs = [
+      pkgs.buf
+      pkgs.protoc-gen-go
+      pkgs.protoc-gen-go-grpc
+    ];
+
+    buildPhase = ''
+      export HOME=$TMPDIR
+      buf generate
+
+      mkdir -p $out
+      cp tvix/build/protos/*.pb.go $out/
+    '';
+  };
+}
diff --git a/tvix/build/protos/rpc_build.proto b/tvix/build/protos/rpc_build.proto
new file mode 100644
index 0000000000..73eebf78fe
--- /dev/null
+++ b/tvix/build/protos/rpc_build.proto
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2022 The Tvix Authors
+syntax = "proto3";
+
+package tvix.build.v1;
+
+import "tvix/build/protos/build.proto";
+
+option go_package = "code.tvl.fyi/tvix/build-go;buildv1";
+
+service BuildService {
+  rpc DoBuild(BuildRequest) returns (Build);
+}
diff --git a/tvix/build/src/bin/tvix-build.rs b/tvix/build/src/bin/tvix-build.rs
new file mode 100644
index 0000000000..c96145ebb4
--- /dev/null
+++ b/tvix/build/src/bin/tvix-build.rs
@@ -0,0 +1,112 @@
+use std::sync::Arc;
+
+use clap::Parser;
+use clap::Subcommand;
+use tokio_listener::Listener;
+use tokio_listener::SystemOptions;
+use tokio_listener::UserOptions;
+use tonic::{self, transport::Server};
+use tracing::{info, Level};
+use tvix_build::{
+    buildservice,
+    proto::{build_service_server::BuildServiceServer, GRPCBuildServiceWrapper},
+};
+use tvix_castore::blobservice;
+use tvix_castore::directoryservice;
+
+#[cfg(feature = "tonic-reflection")]
+use tvix_build::proto::FILE_DESCRIPTOR_SET;
+#[cfg(feature = "tonic-reflection")]
+use tvix_castore::proto::FILE_DESCRIPTOR_SET as CASTORE_FILE_DESCRIPTOR_SET;
+
+#[derive(Parser)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+    /// A global log level to use when printing logs.
+    /// It's also possible to set `RUST_LOG` according to
+    /// `tracing_subscriber::filter::EnvFilter`, which will always have
+    /// priority.
+    #[arg(long, default_value_t=Level::INFO)]
+    log_level: Level,
+
+    #[command(subcommand)]
+    command: Commands,
+}
+#[derive(Subcommand)]
+enum Commands {
+    /// Runs the tvix-build daemon.
+    Daemon {
+        #[arg(long, short = 'l')]
+        listen_address: Option<String>,
+
+        #[arg(long, env, default_value = "grpc+http://[::1]:8000")]
+        blob_service_addr: String,
+
+        #[arg(long, env, default_value = "grpc+http://[::1]:8000")]
+        directory_service_addr: String,
+
+        #[arg(long, env, default_value = "dummy://")]
+        build_service_addr: String,
+    },
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let cli = Cli::parse();
+
+    let _ = tvix_tracing::TracingBuilder::default().level(cli.log_level);
+
+    match cli.command {
+        Commands::Daemon {
+            listen_address,
+            blob_service_addr,
+            directory_service_addr,
+            build_service_addr,
+        } => {
+            // initialize stores
+            let blob_service = blobservice::from_addr(&blob_service_addr).await?;
+            let directory_service = directoryservice::from_addr(&directory_service_addr).await?;
+
+            let build_service = buildservice::from_addr(
+                &build_service_addr,
+                Arc::from(blob_service),
+                Arc::from(directory_service),
+            )
+            .await?;
+
+            let listen_address = listen_address
+                .unwrap_or_else(|| "[::]:8000".to_string())
+                .parse()
+                .unwrap();
+
+            let mut server = Server::builder();
+
+            #[allow(unused_mut)]
+            let mut router = server.add_service(BuildServiceServer::new(
+                GRPCBuildServiceWrapper::new(build_service),
+            ));
+
+            #[cfg(feature = "tonic-reflection")]
+            {
+                let reflection_svc = tonic_reflection::server::Builder::configure()
+                    .register_encoded_file_descriptor_set(CASTORE_FILE_DESCRIPTOR_SET)
+                    .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
+                    .build()?;
+                router = router.add_service(reflection_svc);
+            }
+
+            info!(listen_address=%listen_address, "listening");
+
+            let listener = Listener::bind(
+                &listen_address,
+                &SystemOptions::default(),
+                &UserOptions::default(),
+            )
+            .await?;
+
+            router.serve_with_incoming(listener).await?;
+        }
+    }
+
+    Ok(())
+}
diff --git a/tvix/build/src/buildservice/dummy.rs b/tvix/build/src/buildservice/dummy.rs
new file mode 100644
index 0000000000..d20444755e
--- /dev/null
+++ b/tvix/build/src/buildservice/dummy.rs
@@ -0,0 +1,19 @@
+use tonic::async_trait;
+use tracing::instrument;
+
+use super::BuildService;
+use crate::proto::{Build, BuildRequest};
+
+#[derive(Default)]
+pub struct DummyBuildService {}
+
+#[async_trait]
+impl BuildService for DummyBuildService {
+    #[instrument(skip(self), ret, err)]
+    async fn do_build(&self, _request: BuildRequest) -> std::io::Result<Build> {
+        Err(std::io::Error::new(
+            std::io::ErrorKind::Other,
+            "builds are not supported with DummyBuildService",
+        ))
+    }
+}
diff --git a/tvix/build/src/buildservice/from_addr.rs b/tvix/build/src/buildservice/from_addr.rs
new file mode 100644
index 0000000000..cc5403edef
--- /dev/null
+++ b/tvix/build/src/buildservice/from_addr.rs
@@ -0,0 +1,90 @@
+use super::{grpc::GRPCBuildService, BuildService, DummyBuildService};
+use tvix_castore::{blobservice::BlobService, directoryservice::DirectoryService};
+use url::Url;
+
+/// Constructs a new instance of a [BuildService] from an URI.
+///
+/// The following schemes are supported by the following services:
+/// - `dummy://` ([DummyBuildService])
+/// - `grpc+*://` ([GRPCBuildService])
+///
+/// As some of these [BuildService] need to talk to a [BlobService] and
+/// [DirectoryService], these also need to be passed in.
+pub async fn from_addr<BS, DS>(
+    uri: &str,
+    _blob_service: BS,
+    _directory_service: DS,
+) -> std::io::Result<Box<dyn BuildService>>
+where
+    BS: AsRef<dyn BlobService> + Send + Sync + Clone + 'static,
+    DS: AsRef<dyn DirectoryService> + Send + Sync + Clone + 'static,
+{
+    let url = Url::parse(uri)
+        .map_err(|e| std::io::Error::other(format!("unable to parse url: {}", e)))?;
+
+    Ok(match url.scheme() {
+        // dummy doesn't care about parameters.
+        "dummy" => Box::<DummyBuildService>::default(),
+        scheme => {
+            if scheme.starts_with("grpc+") {
+                let client = crate::proto::build_service_client::BuildServiceClient::new(
+                    tvix_castore::tonic::channel_from_url(&url)
+                        .await
+                        .map_err(std::io::Error::other)?,
+                );
+                // FUTUREWORK: also allow responding to {blob,directory}_service
+                // requests from the remote BuildService?
+                Box::new(GRPCBuildService::from_client(client))
+            } else {
+                Err(std::io::Error::other(format!(
+                    "unknown scheme: {}",
+                    url.scheme()
+                )))?
+            }
+        }
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Arc;
+
+    use super::from_addr;
+    use rstest::rstest;
+    use tvix_castore::{
+        blobservice::{BlobService, MemoryBlobService},
+        directoryservice::{DirectoryService, MemoryDirectoryService},
+    };
+
+    #[rstest]
+    /// This uses an unsupported scheme.
+    #[case::unsupported_scheme("http://foo.example/test", false)]
+    /// This configures dummy
+    #[case::valid_dummy("dummy://", true)]
+    /// Correct scheme to connect to a unix socket.
+    #[case::grpc_valid_unix_socket("grpc+unix:///path/to/somewhere", true)]
+    /// Correct scheme for unix socket, but setting a host too, which is invalid.
+    #[case::grpc_invalid_unix_socket_and_host("grpc+unix://host.example/path/to/somewhere", false)]
+    /// Correct scheme to connect to localhost, with port 12345
+    #[case::grpc_valid_ipv6_localhost_port_12345("grpc+http://[::1]:12345", true)]
+    /// Correct scheme to connect to localhost over http, without specifying a port.
+    #[case::grpc_valid_http_host_without_port("grpc+http://localhost", true)]
+    /// Correct scheme to connect to localhost over http, without specifying a port.
+    #[case::grpc_valid_https_host_without_port("grpc+https://localhost", true)]
+    /// Correct scheme to connect to localhost over http, but with additional path, which is invalid.
+    #[case::grpc_invalid_host_and_path("grpc+http://localhost/some-path", false)]
+    #[tokio::test]
+    async fn test_from_addr(#[case] uri_str: &str, #[case] exp_succeed: bool) {
+        let blob_service: Arc<dyn BlobService> = Arc::from(MemoryBlobService::default());
+        let directory_service: Arc<dyn DirectoryService> =
+            Arc::from(MemoryDirectoryService::default());
+
+        let resp = from_addr(uri_str, blob_service, directory_service).await;
+
+        if exp_succeed {
+            resp.expect("should succeed");
+        } else {
+            assert!(resp.is_err(), "should fail");
+        }
+    }
+}
diff --git a/tvix/build/src/buildservice/grpc.rs b/tvix/build/src/buildservice/grpc.rs
new file mode 100644
index 0000000000..9d22d8397a
--- /dev/null
+++ b/tvix/build/src/buildservice/grpc.rs
@@ -0,0 +1,28 @@
+use tonic::{async_trait, transport::Channel};
+
+use crate::proto::{build_service_client::BuildServiceClient, Build, BuildRequest};
+
+use super::BuildService;
+
+pub struct GRPCBuildService {
+    client: BuildServiceClient<Channel>,
+}
+
+impl GRPCBuildService {
+    #[allow(dead_code)]
+    pub fn from_client(client: BuildServiceClient<Channel>) -> Self {
+        Self { client }
+    }
+}
+
+#[async_trait]
+impl BuildService for GRPCBuildService {
+    async fn do_build(&self, request: BuildRequest) -> std::io::Result<Build> {
+        let mut client = self.client.clone();
+        client
+            .do_build(request)
+            .await
+            .map(|resp| resp.into_inner())
+            .map_err(std::io::Error::other)
+    }
+}
diff --git a/tvix/build/src/buildservice/mod.rs b/tvix/build/src/buildservice/mod.rs
new file mode 100644
index 0000000000..a61d782919
--- /dev/null
+++ b/tvix/build/src/buildservice/mod.rs
@@ -0,0 +1,16 @@
+use tonic::async_trait;
+
+use crate::proto::{Build, BuildRequest};
+
+mod dummy;
+mod from_addr;
+mod grpc;
+
+pub use dummy::DummyBuildService;
+pub use from_addr::from_addr;
+
+#[async_trait]
+pub trait BuildService: Send + Sync {
+    /// TODO: document
+    async fn do_build(&self, request: BuildRequest) -> std::io::Result<Build>;
+}
diff --git a/tvix/build/src/lib.rs b/tvix/build/src/lib.rs
new file mode 100644
index 0000000000..b173657e43
--- /dev/null
+++ b/tvix/build/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod buildservice;
+pub mod proto;
diff --git a/tvix/build/src/proto/grpc_buildservice_wrapper.rs b/tvix/build/src/proto/grpc_buildservice_wrapper.rs
new file mode 100644
index 0000000000..024f075de9
--- /dev/null
+++ b/tvix/build/src/proto/grpc_buildservice_wrapper.rs
@@ -0,0 +1,35 @@
+use crate::buildservice::BuildService;
+use std::ops::Deref;
+use tonic::async_trait;
+
+use super::{Build, BuildRequest};
+
+/// Implements the gRPC server trait ([crate::proto::build_service_server::BuildService]
+/// for anything implementing [BuildService].
+pub struct GRPCBuildServiceWrapper<BUILD> {
+    inner: BUILD,
+}
+
+impl<BUILD> GRPCBuildServiceWrapper<BUILD> {
+    pub fn new(build_service: BUILD) -> Self {
+        Self {
+            inner: build_service,
+        }
+    }
+}
+
+#[async_trait]
+impl<BUILD> crate::proto::build_service_server::BuildService for GRPCBuildServiceWrapper<BUILD>
+where
+    BUILD: Deref<Target = dyn BuildService> + Send + Sync + 'static,
+{
+    async fn do_build(
+        &self,
+        request: tonic::Request<BuildRequest>,
+    ) -> Result<tonic::Response<Build>, tonic::Status> {
+        match self.inner.do_build(request.into_inner()).await {
+            Ok(resp) => Ok(tonic::Response::new(resp)),
+            Err(e) => Err(tonic::Status::internal(e.to_string())),
+        }
+    }
+}
diff --git a/tvix/build/src/proto/mod.rs b/tvix/build/src/proto/mod.rs
new file mode 100644
index 0000000000..e359b5b5b7
--- /dev/null
+++ b/tvix/build/src/proto/mod.rs
@@ -0,0 +1,264 @@
+use std::path::{Path, PathBuf};
+
+use itertools::Itertools;
+use tvix_castore::proto::{NamedNode, ValidateNodeError};
+
+mod grpc_buildservice_wrapper;
+
+pub use grpc_buildservice_wrapper::GRPCBuildServiceWrapper;
+
+tonic::include_proto!("tvix.build.v1");
+
+#[cfg(feature = "tonic-reflection")]
+/// Compiled file descriptors for implementing [gRPC
+/// reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) with e.g.
+/// [`tonic_reflection`](https://docs.rs/tonic-reflection).
+pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("tvix.build.v1");
+
+/// Errors that occur during the validation of [BuildRequest] messages.
+#[derive(Debug, thiserror::Error)]
+pub enum ValidateBuildRequestError {
+    #[error("invalid input node at position {0}: {1}")]
+    InvalidInputNode(usize, ValidateNodeError),
+
+    #[error("input nodes are not sorted by name")]
+    InputNodesNotSorted,
+
+    #[error("invalid working_dir")]
+    InvalidWorkingDir,
+
+    #[error("scratch_paths not sorted")]
+    ScratchPathsNotSorted,
+
+    #[error("invalid scratch path at position {0}")]
+    InvalidScratchPath(usize),
+
+    #[error("invalid inputs_dir")]
+    InvalidInputsDir,
+
+    #[error("invalid output path at position {0}")]
+    InvalidOutputPath(usize),
+
+    #[error("outputs not sorted")]
+    OutputsNotSorted,
+
+    #[error("invalid environment variable at position {0}")]
+    InvalidEnvVar(usize),
+
+    #[error("EnvVar not sorted by their keys")]
+    EnvVarNotSorted,
+
+    #[error("invalid build constraints: {0}")]
+    InvalidBuildConstraints(ValidateBuildConstraintsError),
+
+    #[error("invalid additional file path at position: {0}")]
+    InvalidAdditionalFilePath(usize),
+
+    #[error("additional_files not sorted")]
+    AdditionalFilesNotSorted,
+}
+
+/// Checks a path to be without any '..' components, and clean (no superfluous
+/// slashes).
+fn is_clean_path<P: AsRef<Path>>(p: P) -> bool {
+    let p = p.as_ref();
+
+    // Look at all components, bail in case of ".", ".." and empty normal
+    // segments (superfluous slashes)
+    // We still need to assemble a cleaned PathBuf, and compare the OsString
+    // later, as .components() already does do some normalization before
+    // yielding.
+    let mut cleaned_p = PathBuf::new();
+    for component in p.components() {
+        match component {
+            std::path::Component::Prefix(_) => {}
+            std::path::Component::RootDir => {}
+            std::path::Component::CurDir => return false,
+            std::path::Component::ParentDir => return false,
+            std::path::Component::Normal(a) => {
+                if a.is_empty() {
+                    return false;
+                }
+            }
+        }
+        cleaned_p.push(component);
+    }
+
+    // if cleaned_p looks like p, we're good.
+    if cleaned_p.as_os_str() != p.as_os_str() {
+        return false;
+    }
+
+    true
+}
+
+fn is_clean_relative_path<P: AsRef<Path>>(p: P) -> bool {
+    if p.as_ref().is_absolute() {
+        return false;
+    }
+
+    is_clean_path(p)
+}
+
+fn is_clean_absolute_path<P: AsRef<Path>>(p: P) -> bool {
+    if !p.as_ref().is_absolute() {
+        return false;
+    }
+
+    is_clean_path(p)
+}
+
+/// Checks if a given list is sorted.
+fn is_sorted<I>(data: I) -> bool
+where
+    I: Iterator,
+    I::Item: Ord + Clone,
+{
+    data.tuple_windows().all(|(a, b)| a <= b)
+}
+
+impl BuildRequest {
+    /// Ensures the build request is valid.
+    /// This means, all input nodes need to be valid, paths in lists need to be sorted,
+    /// and all restrictions around paths themselves (relative, clean, …) need
+    // to be fulfilled.
+    pub fn validate(&self) -> Result<(), ValidateBuildRequestError> {
+        // validate all input nodes
+        for (i, n) in self.inputs.iter().enumerate() {
+            // ensure the input node itself is valid
+            n.validate()
+                .map_err(|e| ValidateBuildRequestError::InvalidInputNode(i, e))?;
+        }
+
+        // now we can look at the names, and make sure they're sorted.
+        if !is_sorted(
+            self.inputs
+                .iter()
+                .map(|e| e.node.as_ref().unwrap().get_name()),
+        ) {
+            Err(ValidateBuildRequestError::InputNodesNotSorted)?
+        }
+
+        // validate working_dir
+        if !is_clean_relative_path(&self.working_dir) {
+            Err(ValidateBuildRequestError::InvalidWorkingDir)?;
+        }
+
+        // validate scratch paths
+        for (i, p) in self.scratch_paths.iter().enumerate() {
+            if !is_clean_relative_path(p) {
+                Err(ValidateBuildRequestError::InvalidScratchPath(i))?
+            }
+        }
+        if !is_sorted(self.scratch_paths.iter().map(|e| e.as_bytes())) {
+            Err(ValidateBuildRequestError::ScratchPathsNotSorted)?;
+        }
+
+        // validate inputs_dir
+        if !is_clean_relative_path(&self.inputs_dir) {
+            Err(ValidateBuildRequestError::InvalidInputsDir)?;
+        }
+
+        // validate outputs
+        for (i, p) in self.outputs.iter().enumerate() {
+            if !is_clean_relative_path(p) {
+                Err(ValidateBuildRequestError::InvalidOutputPath(i))?
+            }
+        }
+        if !is_sorted(self.outputs.iter().map(|e| e.as_bytes())) {
+            Err(ValidateBuildRequestError::OutputsNotSorted)?;
+        }
+
+        // validate environment_vars.
+        for (i, e) in self.environment_vars.iter().enumerate() {
+            if e.key.is_empty() || e.key.contains('=') {
+                Err(ValidateBuildRequestError::InvalidEnvVar(i))?
+            }
+        }
+        if !is_sorted(self.environment_vars.iter().map(|e| e.key.as_bytes())) {
+            Err(ValidateBuildRequestError::EnvVarNotSorted)?;
+        }
+
+        // validate build constraints
+        if let Some(constraints) = self.constraints.as_ref() {
+            constraints
+                .validate()
+                .map_err(ValidateBuildRequestError::InvalidBuildConstraints)?;
+        }
+
+        // validate additional_files
+        for (i, additional_file) in self.additional_files.iter().enumerate() {
+            if !is_clean_relative_path(&additional_file.path) {
+                Err(ValidateBuildRequestError::InvalidAdditionalFilePath(i))?
+            }
+        }
+        if !is_sorted(self.additional_files.iter().map(|e| e.path.as_bytes())) {
+            Err(ValidateBuildRequestError::AdditionalFilesNotSorted)?;
+        }
+
+        Ok(())
+    }
+}
+
+/// Errors that occur during the validation of
+/// [build_request::BuildConstraints] messages.
+#[derive(Debug, thiserror::Error)]
+pub enum ValidateBuildConstraintsError {
+    #[error("invalid system")]
+    InvalidSystem,
+
+    #[error("invalid available_ro_paths at position {0}")]
+    InvalidAvailableRoPaths(usize),
+
+    #[error("available_ro_paths not sorted")]
+    AvailableRoPathsNotSorted,
+}
+
+impl build_request::BuildConstraints {
+    pub fn validate(&self) -> Result<(), ValidateBuildConstraintsError> {
+        // validate system
+        if self.system.is_empty() {
+            Err(ValidateBuildConstraintsError::InvalidSystem)?;
+        }
+        // validate available_ro_paths
+        for (i, p) in self.available_ro_paths.iter().enumerate() {
+            if !is_clean_absolute_path(p) {
+                Err(ValidateBuildConstraintsError::InvalidAvailableRoPaths(i))?
+            }
+        }
+        if !is_sorted(self.available_ro_paths.iter().map(|e| e.as_bytes())) {
+            Err(ValidateBuildConstraintsError::AvailableRoPathsNotSorted)?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{is_clean_path, is_clean_relative_path};
+    use rstest::rstest;
+
+    #[rstest]
+    #[case::fail_trailing_slash("foo/bar/", false)]
+    #[case::fail_dotdot("foo/../bar", false)]
+    #[case::fail_singledot("foo/./bar", false)]
+    #[case::fail_unnecessary_slashes("foo//bar", false)]
+    #[case::fail_absolute_unnecessary_slashes("//foo/bar", false)]
+    #[case::ok_empty("", true)]
+    #[case::ok_relative("foo/bar", true)]
+    #[case::ok_absolute("/", true)]
+    #[case::ok_absolute2("/foo/bar", true)]
+    fn test_is_clean_path(#[case] s: &str, #[case] expected: bool) {
+        assert_eq!(is_clean_path(s), expected);
+    }
+
+    #[rstest]
+    #[case::fail_absolute("/", false)]
+    #[case::ok_relative("foo/bar", true)]
+    fn test_is_clean_relative_path(#[case] s: &str, #[case] expected: bool) {
+        assert_eq!(is_clean_relative_path(s), expected);
+    }
+
+    // TODO: add tests for BuildRequest validation itself
+}