diff options
Diffstat (limited to 'tvix/build')
-rw-r--r-- | tvix/build/Cargo.toml | 34 | ||||
-rw-r--r-- | tvix/build/build.rs | 34 | ||||
-rw-r--r-- | tvix/build/default.nix | 11 | ||||
-rw-r--r-- | tvix/build/protos/LICENSE | 21 | ||||
-rw-r--r-- | tvix/build/protos/build.proto | 163 | ||||
-rw-r--r-- | tvix/build/protos/default.nix | 51 | ||||
-rw-r--r-- | tvix/build/protos/rpc_build.proto | 13 | ||||
-rw-r--r-- | tvix/build/src/bin/tvix-build.rs | 121 | ||||
-rw-r--r-- | tvix/build/src/buildservice/dummy.rs | 19 | ||||
-rw-r--r-- | tvix/build/src/buildservice/from_addr.rs | 90 | ||||
-rw-r--r-- | tvix/build/src/buildservice/grpc.rs | 28 | ||||
-rw-r--r-- | tvix/build/src/buildservice/mod.rs | 16 | ||||
-rw-r--r-- | tvix/build/src/lib.rs | 2 | ||||
-rw-r--r-- | tvix/build/src/proto/grpc_buildservice_wrapper.rs | 35 | ||||
-rw-r--r-- | tvix/build/src/proto/mod.rs | 265 |
15 files changed, 903 insertions, 0 deletions
diff --git a/tvix/build/Cargo.toml b/tvix/build/Cargo.toml new file mode 100644 index 000000000000..b9073b7ff61a --- /dev/null +++ b/tvix/build/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "tvix-build" +version = "0.1.0" +edition = "2021" + +[dependencies] +bytes = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +itertools = { workspace = true } +prost = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-listener = { workspace = true, features = ["tonic012"] } +tonic = { workspace = true, features = ["tls", "tls-roots"] } +tvix-castore = { path = "../castore" } +tvix-tracing = { path = "../tracing" } +tracing = { workspace = true } +url = { workspace = true } +mimalloc = { workspace = true } +tonic-reflection = { workspace = true, optional = true } + +[build-dependencies] +prost-build = { workspace = true } +tonic-build = { workspace = true } + +[features] +default = [] +tonic-reflection = ["dep:tonic-reflection", "tvix-castore/tonic-reflection"] + +[dev-dependencies] +rstest = { workspace = true } + +[lints] +workspace = true diff --git a/tvix/build/build.rs b/tvix/build/build.rs new file mode 100644 index 000000000000..fe230cbeca8a --- /dev/null +++ b/tvix/build/build.rs @@ -0,0 +1,34 @@ +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); + }; + + builder + .build_server(true) + .build_client(true) + .emit_rerun_if_changed(false) + .bytes(["."]) + .extern_path(".tvix.castore.v1", "::tvix_castore::proto") + .compile( + &[ + "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 custom tree 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 000000000000..17b52354bbeb --- /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 = old.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 000000000000..2034ada6fd9a --- /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 000000000000..7a3c49db4873 --- /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 all references 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 000000000000..287b513136c9 --- /dev/null +++ b/tvix/build/protos/default.nix @@ -0,0 +1,51 @@ +{ depot, pkgs, lib, ... }: +let + protos = lib.sourceByRegex depot.path.origSrc [ + "buf.yaml" + "buf.gen.yaml" + # We need to include castore.proto (only), as it's referred. + "^tvix(/castore(/protos(/castore\.proto)?)?)?$" + "^tvix(/build(/protos(/.*\.proto)?)?)?$" + ]; +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 000000000000..73eebf78febe --- /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 000000000000..b840e031db80 --- /dev/null +++ b/tvix/build/src/bin/tvix-build.rs @@ -0,0 +1,121 @@ +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; + +use mimalloc::MiMalloc; + +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +#[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 + Send + Sync>> { + let cli = Cli::parse(); + + let _ = tvix_tracing::TracingBuilder::default() + .level(cli.log_level) + .enable_progressbar(); + + 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, blob_service, 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")] + { + router = router.add_service( + tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(CASTORE_FILE_DESCRIPTOR_SET) + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1alpha()?, + ); + router = router.add_service( + tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(CASTORE_FILE_DESCRIPTOR_SET) + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1()?, + ); + } + + 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 000000000000..d20444755e73 --- /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 000000000000..cc5403edefff --- /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 000000000000..9d22d8397abf --- /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 000000000000..a61d782919b9 --- /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 000000000000..b173657e431c --- /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 000000000000..024f075de9ad --- /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 000000000000..b36049d05b9d --- /dev/null +++ b/tvix/build/src/proto/mod.rs @@ -0,0 +1,265 @@ +use std::path::{Path, PathBuf}; + +use itertools::Itertools; +use tvix_castore::DirectoryError; + +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, DirectoryError), + + #[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 names. Make sure they're sorted + + let mut last_name: bytes::Bytes = "".into(); + for (i, node) in self.inputs.iter().enumerate() { + // TODO(flokli): store result somewhere + let (name, _node) = node + .clone() + .into_name_and_node() + .map_err(|e| ValidateBuildRequestError::InvalidInputNode(i, e))?; + + if name.as_ref() <= last_name.as_ref() { + return Err(ValidateBuildRequestError::InputNodesNotSorted); + } else { + last_name = name.into() + } + } + + // 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 +} |