diff --git a/Cargo.lock b/Cargo.lock index 2039fadab..76529523d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3358,6 +3358,39 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", + "test-case-core", +] + [[package]] name = "testsys" version = "0.1.0" @@ -3870,6 +3903,7 @@ dependencies = [ "flate2", "futures", "krane-static", + "lazy_static", "log", "oci-cli-wrapper", "olpc-cjson", @@ -3883,6 +3917,7 @@ dependencies = [ "strum", "tar", "tempfile", + "test-case", "testsys", "tokio", "toml", @@ -3890,6 +3925,7 @@ dependencies = [ "tuftool", "unplug", "uuid", + "which", ] [[package]] @@ -4195,7 +4231,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 82a10165f..1e63d0ad6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,6 +124,7 @@ tabled = "0.10" tar = "0.4" tempfile = "3" term_size = "0.3" +test-case = "3" tinytemplate = "1" tokio = "1" tokio-stream = "0.1" diff --git a/twoliter/Cargo.toml b/twoliter/Cargo.toml index 27d8d7c6e..b5d06f335 100644 --- a/twoliter/Cargo.toml +++ b/twoliter/Cargo.toml @@ -22,6 +22,7 @@ filetime.workspace = true flate2.workspace = true futures.workspace = true krane-static.workspace = true +lazy_static.workspace = true log.workspace = true oci-cli-wrapper.workspace = true olpc-cjson.workspace = true @@ -36,6 +37,7 @@ tokio = { workspace = true, features = ["fs", "macros", "process", "rt-multi-thr toml.workspace = true tracing = { workspace = true, features = ["log"] } uuid = { workspace = true, features = ["v4"] } +which.workspace = true # Binary dependencies. These are binaries that we want to embed in the Twoliter binary buildsys = { workspace = true } @@ -51,6 +53,9 @@ bytes.workspace = true flate2.workspace = true tar.workspace = true +[dev-dependencies] +test-case.workspace = true + [features] default = ["integ-tests"] integ-tests = [] diff --git a/twoliter/embedded/build.Dockerfile b/twoliter/embedded/build.Dockerfile index 0bfad6cd0..8f53dd522 100644 --- a/twoliter/embedded/build.Dockerfile +++ b/twoliter/embedded/build.Dockerfile @@ -1,4 +1,8 @@ -# syntax=docker/dockerfile:1.4.3 +# Twoliter requires minimum Docker version 23.0.0, which ships with a bundled syntax image 1.4.3 +# We refrain from an explicit `syntax` directive as this can lead to unwanted network requests at +# build time. +# See https://hub.docker.com/r/docker/dockerfile for more information + # This Dockerfile has three sections which are used to build rpm.spec packages, to create # kits, and to create Bottlerocket images, respectively. They are marked as Sections 1-3. # buildsys uses Section 1 during build-package calls, Section 2 during build-kit calls, diff --git a/twoliter/src/docker/commands.rs b/twoliter/src/docker/commands.rs index 8b1378917..866eda1f1 100644 --- a/twoliter/src/docker/commands.rs +++ b/twoliter/src/docker/commands.rs @@ -1 +1,24 @@ +use crate::common::exec; +use anyhow::{Context, Result}; +use semver::Version; +use tokio::process::Command; +pub(crate) struct Docker; + +impl Docker { + /// Fetches the version of the docker daemon + pub(crate) async fn server_version() -> Result { + let version_str = exec( + Command::new("docker").args(["version", "--format", "{{.Server.Version}}"]), + true, + ) + .await + // Convert Result> to Option + .ok() + .flatten() + .map(|s| s.trim().to_string()) + .context("Failed to fetch docker version")?; + + Version::parse(&version_str).context("Failed to parse docker version as semver") + } +} diff --git a/twoliter/src/docker/mod.rs b/twoliter/src/docker/mod.rs index 94e6f775c..1c2c16239 100644 --- a/twoliter/src/docker/mod.rs +++ b/twoliter/src/docker/mod.rs @@ -2,3 +2,4 @@ mod commands; mod image; pub(crate) use self::image::ImageUri; +pub(crate) use commands::Docker; diff --git a/twoliter/src/main.rs b/twoliter/src/main.rs index ed55a2d2a..66a060881 100644 --- a/twoliter/src/main.rs +++ b/twoliter/src/main.rs @@ -3,10 +3,12 @@ use anyhow::Result; use clap::Parser; mod cargo_make; +pub(crate) mod cleanup; mod cmd; mod common; mod compatibility; mod docker; +mod preflight; mod project; mod schema_version; /// Test code that should only be compiled when running tests. @@ -20,5 +22,6 @@ mod tools; async fn main() -> Result<()> { let args = Args::parse(); init_logger(args.log_level); + preflight::preflight().await?; cmd::run(args).await } diff --git a/twoliter/src/preflight.rs b/twoliter/src/preflight.rs new file mode 100644 index 000000000..576a9a3e8 --- /dev/null +++ b/twoliter/src/preflight.rs @@ -0,0 +1,80 @@ +//! This module performs checks that the current environment is compatible with twoliter, as well +//! as any other "global" setup that must occur before the build process begins. +use anyhow::{ensure, Result}; +use lazy_static::lazy_static; +use semver::{Comparator, Op, Prerelease, VersionReq}; +use which::which_global; + +use crate::docker::Docker; + +const REQUIRED_TOOLS: &[&str] = &["docker", "gzip", "lz4"]; + +lazy_static! { + // Twoliter relies on minimum Dockerfile syntax 1.4.3, which is shipped in Docker 23.0.0 by default + // We do not use explicit `syntax=` directives to avoid network connections during the build. + static ref MINIMUM_DOCKER_VERSION: VersionReq = VersionReq { + comparators: [ + Comparator { + op: Op::GreaterEq, + major: 23, + minor: None, + patch: None, + pre: Prerelease::default(), + } + ].into() + }; +} + +/// Runs all common setup required for twoliter. +/// +/// * Ensures that any required system tools are installed an accessible. +/// * Sets up interrupt handler to cleanup on SIGINT +pub(crate) async fn preflight() -> Result<()> { + check_environment().await?; + + Ok(()) +} + +pub(crate) async fn check_environment() -> Result<()> { + check_for_required_tools()?; + check_docker_version().await?; + + Ok(()) +} + +fn check_for_required_tools() -> Result<()> { + for tool in REQUIRED_TOOLS { + ensure!( + which_global(tool).is_ok(), + "Failed to find required tool `{tool}` in PATH" + ); + } + Ok(()) +} + +async fn check_docker_version() -> Result<()> { + let docker_version = Docker::server_version().await?; + + ensure!( + MINIMUM_DOCKER_VERSION.matches(&docker_version), + "docker found in PATH does not meet the minimum version requirements for twoliter: {}", + MINIMUM_DOCKER_VERSION.to_string(), + ); + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use semver::Version; + use test_case::test_case; + + #[test_case(Version::parse("25.0.5").unwrap(), true; "25.0.5 passes")] + #[test_case(Version::parse("27.1.4").unwrap(), true; "27.1.4 passes")] + #[test_case(Version::parse("18.0.9").unwrap(), false; "18.0.9 fails")] + #[test_case(Version::parse("20.10.27").unwrap(), false)] + fn test_docker_version_req(version: Version, is_ok: bool) { + assert_eq!(MINIMUM_DOCKER_VERSION.matches(&version), is_ok) + } +}