diff --git a/core/crates/ployz-build/src/capture.rs b/core/crates/ployz-build/src/capture.rs new file mode 100644 index 0000000000..3e782262c9 --- /dev/null +++ b/core/crates/ployz-build/src/capture.rs @@ -0,0 +1,104 @@ +//! The CLI writes the capture and the Build Machine reads it, so the names are a +//! client-to-daemon contract that releases keep. + +use std::path::{Path, PathBuf}; + +pub const SOURCE: &str = "source"; +/// The directory of material that is no source: registry configuration, recipes and Sealed values. +pub const PRIVATE: &str = "private"; +pub const COMPOSE: &str = "compose.yaml"; +/// The Build's Sealed values, still sealed, as a file under [`PRIVATE`]. +pub const SEALED_RECORD: &str = "sealed.json"; +const SEALED_ARGS: &str = "sealed-args.json"; +const RAILPACK_PLAN: &str = "railpack.json"; +const RAILPACK_SCRATCH: &str = "railpack"; +const DOCKER: &str = "docker"; +const DOCKER_CONFIG: &str = "config.json"; + +/// The directories a capture starts with, parents first. +#[must_use] +pub fn directories(root: &Path) -> [PathBuf; 3] { + [source(root), private(root), docker(root)] +} + +#[must_use] +pub fn source(root: &Path) -> PathBuf { + root.join(SOURCE) +} + +#[must_use] +pub fn private(root: &Path) -> PathBuf { + root.join(PRIVATE) +} + +#[must_use] +pub fn compose(root: &Path) -> PathBuf { + root.join(COMPOSE) +} + +#[must_use] +pub fn private_file(root: &Path, name: &str) -> PathBuf { + private(root).join(name) +} + +/// The directory the Docker client reads its configuration from. +#[must_use] +pub fn docker(root: &Path) -> PathBuf { + private(root).join(DOCKER) +} + +#[must_use] +pub fn docker_config(root: &Path) -> PathBuf { + docker(root).join(DOCKER_CONFIG) +} + +/// The Railpack recipes the capture carries. +#[must_use] +pub fn railpack_plan(root: &Path) -> PathBuf { + private_file(root, RAILPACK_PLAN) +} + +#[must_use] +pub fn sealed_record(root: &Path) -> PathBuf { + private_file(root, SEALED_RECORD) +} + +/// The names of the Sealed values each target declares as Build arguments. +#[must_use] +pub fn sealed_args(root: &Path) -> PathBuf { + private_file(root, SEALED_ARGS) +} + +/// The scratch directory a Railpack build writes its plan and images to. It is not +/// [`railpack_plan`], which the capture carries. +#[must_use] +pub fn railpack_scratch(root: &Path) -> PathBuf { + private_file(root, RAILPACK_SCRATCH) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_layout_keeps_the_names_releases_exchange() { + let root = Path::new("/capture"); + let relative = |path: PathBuf| { + path.strip_prefix(root) + .unwrap() + .to_str() + .unwrap() + .to_owned() + }; + assert_eq!( + directories(root).map(relative), + ["source", "private", "private/docker"] + ); + assert_eq!(relative(compose(root)), "compose.yaml"); + assert_eq!(relative(docker_config(root)), "private/docker/config.json"); + assert_eq!(relative(railpack_plan(root)), "private/railpack.json"); + assert_eq!(relative(railpack_scratch(root)), "private/railpack"); + assert_eq!(relative(sealed_record(root)), "private/sealed.json"); + assert_eq!(relative(sealed_args(root)), "private/sealed-args.json"); + } +} diff --git a/core/crates/ployz-build/src/index.rs b/core/crates/ployz-build/src/index.rs index a05de228d1..04120589de 100644 --- a/core/crates/ployz-build/src/index.rs +++ b/core/crates/ployz-build/src/index.rs @@ -51,8 +51,10 @@ pub(crate) fn build( let mut refs = Vec::new(); let mut tags = Vec::new(); for (number, platform) in target.target.platforms.iter().enumerate() { - let archive = request.working_dir.join("private/railpack/variant.tar"); - let metadata = request.working_dir.join("private/railpack/variant.json"); + let archive = + crate::capture::railpack_scratch(request.working_dir).join("variant.tar"); + let metadata = + crate::capture::railpack_scratch(request.working_dir).join("variant.json"); let mut args = bake_arguments( request, crate::Produce::Output(crate::Output::Load), @@ -145,7 +147,8 @@ pub(crate) fn build( tag, ], )?; - let archive = request.working_dir.join("private/railpack/complete.tar"); + let archive = + crate::capture::railpack_scratch(request.working_dir).join("complete.tar"); output( "copy assembled Railpack image", &[ diff --git a/core/crates/ployz-build/src/lib.rs b/core/crates/ployz-build/src/lib.rs index 0b5bacf568..a55e619a47 100644 --- a/core/crates/ployz-build/src/lib.rs +++ b/core/crates/ployz-build/src/lib.rs @@ -11,6 +11,7 @@ //! bounded attempt, and one image bound to the content that attempt produced. mod builder; +pub mod capture; mod execution; mod fence; pub use fence::{CgroupPath, Fence, FenceInstaller}; diff --git a/core/crates/ployz-build/src/railpack.rs b/core/crates/ployz-build/src/railpack.rs index 517662f14f..b2ff779043 100644 --- a/core/crates/ployz-build/src/railpack.rs +++ b/core/crates/ployz-build/src/railpack.rs @@ -96,7 +96,7 @@ pub(crate) fn prepare( } } } - let directory = request.working_dir.join("private/railpack"); + let directory = crate::capture::railpack_scratch(request.working_dir); fs::DirBuilder::new() .mode(0o700) .create(&directory) diff --git a/core/crates/ployz-build/src/received_recipe.rs b/core/crates/ployz-build/src/received_recipe.rs index b1fdd7fd66..c4568b4936 100644 --- a/core/crates/ployz-build/src/received_recipe.rs +++ b/core/crates/ployz-build/src/received_recipe.rs @@ -77,7 +77,7 @@ pub fn validate_capture( .copied() .chain(definition.image_contexts.keys().map(String::as_str)) .collect(); - let bytes = fs::read(root.join("compose.yaml")).map_err(|_| "Build recipe is missing")?; + let bytes = fs::read(crate::capture::compose(root)).map_err(|_| "Build recipe is missing")?; let document: Value = serde_norway::from_slice(&bytes).map_err(|_| "invalid captured Build recipe")?; no_interpolation(&document)?; @@ -141,7 +141,7 @@ pub fn validate_capture( } default_ssh |= id == "default"; for path in paths.split(',') { - contained(root, Path::new(path), "private", true)?; + contained(root, Path::new(path), crate::capture::PRIVATE, true)?; } } } @@ -161,7 +161,12 @@ pub fn validate_capture( return Err("remote Dockerfile path escapes its context".into()); } } else { - contained(root, &Path::new(context).join(recipe), "source", true)?; + contained( + root, + &Path::new(context).join(recipe), + crate::capture::SOURCE, + true, + )?; } } if let Some(contexts) = build.get("additional_contexts") { @@ -249,7 +254,7 @@ pub fn validate_capture( } } let railpack = railpack(root, definition, services, sealed)?; - let config_path = root.join("private/docker/config.json"); + let config_path = crate::capture::docker_config(root); let mut config: serde_json::Value = serde_json::from_slice( &fs::read(&config_path).map_err(|_| "Build registry configuration is missing")?, ) @@ -279,7 +284,7 @@ fn railpack( services: &Mapping, sealed: SealedFiles, ) -> Result, InputError> { - let bytes = match fs::read(root.join("private/railpack.json")) { + let bytes = match fs::read(crate::capture::railpack_plan(root)) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(_) => return Err("cannot read captured Railpack recipes".into()), @@ -296,7 +301,7 @@ fn railpack( { return Err("Railpack recipe does not identify one admitted target".into()); } - contained(root, &recipe.context, "source", false)?; + contained(root, &recipe.context, crate::capture::SOURCE, false)?; if !root.join(&recipe.context).is_dir() { return Err("Railpack context must be a captured directory".into()); } @@ -339,7 +344,7 @@ fn secret_file(root: &Path, path: &Path, sealed: SealedFiles) -> Result<(), Inpu .filter(|name| crate::valid_name(name)) .map(|_| ()) .ok_or_else(|| "Build secret is not a sealed value's file".into()), - _ => contained(root, path, "private", true), + _ => contained(root, path, crate::capture::PRIVATE, true), } } @@ -394,7 +399,7 @@ fn context_path( } return Ok(()); } - contained(root, Path::new(value), "source", false)?; + contained(root, Path::new(value), crate::capture::SOURCE, false)?; if !root.join(value).is_dir() { return Err("Build source context is not a directory".into()); } diff --git a/core/crates/ployz-build/src/sealed.rs b/core/crates/ployz-build/src/sealed.rs index b6fd63302b..cdc6567e58 100644 --- a/core/crates/ployz-build/src/sealed.rs +++ b/core/crates/ployz-build/src/sealed.rs @@ -63,7 +63,7 @@ impl Staged { targets: &[Target], opened: &OpenedSealed, ) -> Result, BuildError> { - let bytes = match fs::read(root.join("private/sealed-args.json")) { + let bytes = match fs::read(crate::capture::sealed_args(root)) { Ok(bytes) => bytes, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(io_error(error)), diff --git a/core/crates/ployz-build/src/upload.rs b/core/crates/ployz-build/src/upload.rs index b4c3add9ee..1e01809e49 100644 --- a/core/crates/ployz-build/src/upload.rs +++ b/core/crates/ployz-build/src/upload.rs @@ -119,7 +119,7 @@ impl AdmittedUpload { /// # Errors /// Reports a file that exists but cannot be read. pub fn private_file(&self, name: &str) -> io::Result>> { - match fs::read(self.upload.root.join("private").join(name)) { + match fs::read(crate::capture::private_file(&self.upload.root, name)) { Ok(bytes) => Ok(Some(bytes)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), @@ -232,10 +232,10 @@ impl Upload { .filter(|bytes| *bytes <= PRIVATE_LIMIT as usize) .ok_or("Build entry metadata exceeds byte limit")?; let relative = received_path(path)?; - let source = relative.starts_with("source"); + let source = relative.starts_with(crate::capture::SOURCE); if !source - && !relative.starts_with("private") - && relative != Path::new("compose.yaml") + && !relative.starts_with(crate::capture::PRIVATE) + && relative != Path::new(crate::capture::COMPOSE) { return Err("Build entry is outside source and private material".into()); } diff --git a/core/crates/ployz-core/src/domain/ingress.rs b/core/crates/ployz-core/src/domain/ingress.rs index 50c344f827..bc42385c32 100644 --- a/core/crates/ployz-core/src/domain/ingress.rs +++ b/core/crates/ployz-core/src/domain/ingress.rs @@ -14,13 +14,35 @@ use crate::{ tunnel_service_spec, }; -const CADDY_INGRESS_COMMAND: [&str; 4] = ["caddy", "run", "-c", "/config/caddy/Caddyfile"]; -const CADDY_INGRESS_ADMIN: &str = "unix//run/ingress/caddy/admin.sock"; +pub const INGRESS_CONFIG_MOUNT: &str = "/config"; +const INGRESS_DATA_MOUNT: &str = "/data"; +pub const INGRESS_RUNTIME_MOUNT: &str = "/run/ingress"; +/// The port of Caddy's loopback listener, which the Tunnel Connector dials. +pub const INGRESS_LOOPBACK_PORT: u16 = 8480; +pub const INGRESS_LOOPBACK_BIND: &str = "127.0.0.1"; +pub const INGRESS_LOOPBACK_BIND_V6: &str = "::1"; /// Environment variable selecting Caddy's administrative endpoint. pub const CADDY_ADMIN_ENV: &str = "CADDY_ADMIN"; const INGRESS_PROXY_DATA_PATH: &str = "/var/lib/ployz/ingress"; const INGRESS_PROXY_RUNTIME_PATH: &str = "/run/ployz/ingress"; +#[must_use] +pub fn ingress_certs_dir() -> String { + format!("{INGRESS_CONFIG_MOUNT}/caddy/certs") +} + +fn caddy_ingress_command() -> Vec { + ["caddy", "run", "-c"] + .map(str::to_owned) + .into_iter() + .chain([format!("{INGRESS_CONFIG_MOUNT}/caddy/Caddyfile")]) + .collect() +} + +fn caddy_ingress_admin() -> String { + format!("unix/{INGRESS_RUNTIME_MOUNT}/caddy/admin.sock") +} + /// The `ip:port` token an Ingress Proxy config names one upstream by. #[must_use] pub fn ingress_upstream(address: ContainerAddress, port: NonZeroU16) -> String { @@ -206,9 +228,9 @@ fn holder_container(image: String) -> ServiceContainerSpec { pub(crate) fn caddy_container(image: String) -> ServiceContainerSpec { ServiceContainerSpec { image, - command: CADDY_INGRESS_COMMAND.map(str::to_owned).into(), + command: caddy_ingress_command(), entrypoint: Vec::new(), - environment: BTreeMap::from([(CADDY_ADMIN_ENV.into(), CADDY_INGRESS_ADMIN.into())]), + environment: BTreeMap::from([(CADDY_ADMIN_ENV.into(), caddy_ingress_admin().into())]), sealed_identity: None, labels: Default::default(), hostname: None, @@ -255,9 +277,9 @@ fn caddy_volume_graph() -> ServiceVolumeGraph { bind_volume(runtime.clone(), INGRESS_PROXY_RUNTIME_PATH), ], vec![ - mount(&data, "/config"), - mount(&data, "/data"), - mount(&runtime, "/run/ingress"), + mount(&data, INGRESS_CONFIG_MOUNT), + mount(&data, INGRESS_DATA_MOUNT), + mount(&runtime, INGRESS_RUNTIME_MOUNT), ], ) .expect("static Caddy Volume graph is valid") @@ -301,6 +323,21 @@ mod tests { use super::*; + #[test] + fn the_proxy_paths_and_loopback_port_keep_their_values() { + assert_eq!(ingress_certs_dir(), "/config/caddy/certs"); + assert_eq!( + caddy_ingress_command(), + ["caddy", "run", "-c", "/config/caddy/Caddyfile"] + ); + assert_eq!(caddy_ingress_admin(), "unix//run/ingress/caddy/admin.sock"); + assert_eq!(INGRESS_LOOPBACK_PORT, 8480); + assert_eq!(INGRESS_LOOPBACK_BIND, "127.0.0.1"); + assert_eq!(INGRESS_LOOPBACK_BIND_V6, "::1"); + assert_eq!(INGRESS_PROXY_DATA_PATH, "/var/lib/ployz/ingress"); + assert_eq!(INGRESS_PROXY_RUNTIME_PATH, "/run/ployz/ingress"); + } + fn edge() -> BTreeSet { [PlacementConstraint::parse("node.labels.edge==true").unwrap()].into() } diff --git a/core/crates/ployz-core/src/domain/tunnel.rs b/core/crates/ployz-core/src/domain/tunnel.rs index b09aa0c09c..ad8724016b 100644 --- a/core/crates/ployz-core/src/domain/tunnel.rs +++ b/core/crates/ployz-core/src/domain/tunnel.rs @@ -4,9 +4,10 @@ use std::collections::BTreeMap; use crate::{ - Ciphertext, ContainerLabels, ContainerPath, MachinePath, QualifiedService, RawVolumeSource, - RequestedServiceSpec, ServiceMode, ServiceMount, ServiceMountGraph, ServiceNetwork, - ServiceVolume, ServiceVolumeGraph, ServiceVolumeReference, UpdateConfig, caddy_container, + Ciphertext, ContainerLabels, ContainerPath, INGRESS_LOOPBACK_BIND, INGRESS_LOOPBACK_PORT, + MachinePath, QualifiedService, RawVolumeSource, RequestedServiceSpec, ServiceMode, + ServiceMount, ServiceMountGraph, ServiceNetwork, ServiceVolume, ServiceVolumeGraph, + ServiceVolumeReference, UpdateConfig, caddy_container, }; /// The label on the Tunnel Connector's spec that carries the sealed tunnel token, so @@ -22,7 +23,10 @@ const TUNNEL_TOKEN_MOUNT: &str = "/run/tunnel"; /// The uid the pinned cloudflared image runs as. The token file is owned by it. pub const TUNNEL_USER_ID: u32 = 65532; /// Caddy's loopback listener the connector proxies every request to. -pub const TUNNEL_ORIGIN: &str = "http://127.0.0.1:8480"; +#[must_use] +pub fn tunnel_origin() -> String { + format!("http://{INGRESS_LOOPBACK_BIND}:{INGRESS_LOOPBACK_PORT}") +} /// Why a Machine orders no certificate while every Server uses the Tunnel Door. The /// daemon records it on each wanted hostname, and a domain's status reads it back. pub const TUNNEL_SERVES_CERTIFICATE: &str = @@ -42,7 +46,7 @@ pub fn tunnel_service_spec(image: &str, sealed_token: &Ciphertext) -> RequestedS "--no-autoupdate", "run", "--url", - TUNNEL_ORIGIN, + &tunnel_origin(), "--token-file", &format!("{TUNNEL_TOKEN_MOUNT}/{TUNNEL_TOKEN_FILE}"), ] @@ -112,6 +116,11 @@ mod tests { Ciphertext::from_bytes(vec![9; 48]) } + #[test] + fn the_origin_is_the_loopback_listener() { + assert_eq!(tunnel_origin(), "http://127.0.0.1:8480"); + } + #[test] fn the_connector_joins_the_holder_and_carries_its_sealed_token_as_a_label() { let spec = tunnel_service_spec("cloudflared:2026", &sealed()); @@ -122,7 +131,7 @@ mod tests { spec.container .command .join(" ") - .contains(&format!("--url {TUNNEL_ORIGIN}")) + .contains("--url http://127.0.0.1:8480") ); let [mount] = spec.mount_graph.volume_graph().mounts() else { panic!("the connector has one mount"); diff --git a/core/crates/ployz/src/build/inputs.rs b/core/crates/ployz/src/build/inputs.rs index 750801a1ed..e206501b0f 100644 --- a/core/crates/ployz/src/build/inputs.rs +++ b/core/crates/ployz/src/build/inputs.rs @@ -8,6 +8,7 @@ use std::{ path::{Path, PathBuf}, }; +use ployz_build::capture; use sha2::{Digest as _, Sha256}; use super::{ @@ -62,10 +63,10 @@ impl BuildInputs { .mode(0o700) .create(&root) .map_err(input_error)?; - for directory in ["source", "private", "private/docker"] { + for directory in capture::directories(&root) { fs::DirBuilder::new() .mode(0o700) - .create(root.join(directory)) + .create(directory) .map_err(input_error)?; } let inputs = Self { @@ -73,7 +74,7 @@ impl BuildInputs { captures: BTreeMap::new(), }; // The Build Machine requires registry configuration; these Builds carry no credentials. - inputs.private(&inputs.root.join("private/docker/config.json"), b"{}")?; + inputs.private(&capture::docker_config(&inputs.root), b"{}")?; Ok(inputs) } @@ -133,10 +134,7 @@ impl BuildInputs { "build context contains its capture directory".into(), )); } - let target = self - .root - .join("source") - .join(self.captures.len().to_string()); + let target = capture::source(&self.root).join(self.captures.len().to_string()); if !matches!(input, Input::Context { .. }) && !fs::metadata(source).map_err(input_error)?.is_file() { @@ -204,7 +202,7 @@ impl BuildInputs { // The Build Machine reads a list of recipes. let bytes = serde_json::to_vec(&[recipe]).map_err(|error| input_error(io::Error::other(error)))?; - self.private(&self.root.join("private/railpack.json"), &bytes) + self.private(&capture::railpack_plan(&self.root), &bytes) } /// Keep the Build's Sealed values, still sealed, in private upload material. @@ -214,7 +212,7 @@ impl BuildInputs { pub(super) fn sealed(&self, record: &ployz_core::SealedRecord) -> Result<(), Error> { let bytes = serde_json::to_vec(record).map_err(|error| input_error(io::Error::other(error)))?; - self.private(&self.root.join("private/sealed.json"), &bytes) + self.private(&capture::sealed_record(&self.root), &bytes) } /// Keep only the Sealed ARG names, for the Builder that opens them. @@ -224,7 +222,7 @@ impl BuildInputs { pub(super) fn sealed_args(&self, target: &str, names: &[String]) -> Result<(), Error> { let bytes = serde_json::to_vec(&BTreeMap::from([(target, names)])) .map_err(|error| input_error(io::Error::other(error)))?; - self.private(&self.root.join("private/sealed-args.json"), &bytes) + self.private(&capture::sealed_args(&self.root), &bytes) } /// Write the captured Buildx file beside its sources. @@ -232,7 +230,7 @@ impl BuildInputs { /// # Errors /// Fails if the private file cannot be written. pub(super) fn recipe(&self, yaml: &str) -> Result<(), Error> { - self.private(&self.root.join("compose.yaml"), yaml.as_bytes()) + self.private(&capture::compose(&self.root), yaml.as_bytes()) } fn private(&self, path: &Path, content: &[u8]) -> Result<(), Error> { @@ -908,6 +906,59 @@ mod tests { assert!(!root.exists()); } + #[test] + fn a_capture_reads_back_through_the_layout_the_build_machine_uses() { + let inputs = BuildInputs::new().unwrap(); + let root = inputs.root().to_owned(); + inputs.recipe("services: {}\n").unwrap(); + inputs + .railpack(&ployz_build::Railpack { + name: "api".into(), + context: PathBuf::from("source/0"), + variables: BTreeMap::new(), + sealed: BTreeMap::new(), + refresh_cache: false, + }) + .unwrap(); + inputs.sealed_args("api", &["TOKEN".to_owned()]).unwrap(); + for directory in capture::directories(&root) { + assert!(directory.is_dir(), "{directory:?}"); + } + assert_eq!(fs::read(capture::docker_config(&root)).unwrap(), b"{}"); + assert_eq!( + fs::read_to_string(capture::compose(&root)).unwrap(), + "services: {}\n" + ); + let plan: serde_json::Value = + serde_json::from_slice(&fs::read(capture::railpack_plan(&root)).unwrap()).unwrap(); + assert_eq!(plan.pointer("/0/name"), Some(&serde_json::json!("api"))); + let args: serde_json::Value = + serde_json::from_slice(&fs::read(capture::sealed_args(&root)).unwrap()).unwrap(); + assert_eq!(args.pointer("/api/0"), Some(&serde_json::json!("TOKEN"))); + let mut listing = Vec::new(); + let mut pending = vec![root.clone()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + listing.push(path.strip_prefix(&root).unwrap().to_owned()); + if path.is_dir() { + pending.push(path); + } + } + } + listing.sort(); + let expected = [ + "compose.yaml", + "private", + "private/docker", + "private/docker/config.json", + "private/railpack.json", + "private/sealed-args.json", + "source", + ]; + assert_eq!(listing, expected.map(PathBuf::from)); + } + #[test] fn included_special_files_and_invalid_patterns_still_fail() { let fixture = tempfile::tempdir().unwrap(); diff --git a/core/crates/ployz/src/build/local.rs b/core/crates/ployz/src/build/local.rs index 1adab1ef46..db29dad432 100644 --- a/core/crates/ployz/src/build/local.rs +++ b/core/crates/ployz/src/build/local.rs @@ -2,7 +2,9 @@ use std::{collections::BTreeMap, path::Path}; -use ployz_build::{BuildError, BuiltImage, Output, OutputScrub, Staged, remote::Definition}; +use ployz_build::{ + BuildError, BuiltImage, Output, OutputScrub, Staged, capture, remote::Definition, +}; use super::{CapturedBuild, CapturedTarget, Error, invalid}; @@ -118,11 +120,8 @@ impl CapturedBuild { "PATH".to_owned(), std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()), ), - ("HOME".to_owned(), path(&root.join("private"))), - ( - "DOCKER_CONFIG".to_owned(), - path(&root.join("private/docker")), - ), + ("HOME".to_owned(), path(&capture::private(root))), + ("DOCKER_CONFIG".to_owned(), path(&capture::docker(root))), ]); let (cache, cache_scope) = match &self.local_cache { super::LocalCache::Runner => (true, None), @@ -143,7 +142,7 @@ impl CapturedBuild { let scrub = self.opened.as_ref().map(OutputScrub::new); let request = ployz_build::Request { image_contexts: &definition.image_contexts, - compose_file: Path::new("compose.yaml"), + compose_file: Path::new(capture::COMPOSE), sealed_override, working_dir: root, environment: &environment, diff --git a/core/crates/ployz/src/cloud_login.rs b/core/crates/ployz/src/cloud_login.rs index 8fc6052c3a..341cf4ba45 100644 --- a/core/crates/ployz/src/cloud_login.rs +++ b/core/crates/ployz/src/cloud_login.rs @@ -183,7 +183,7 @@ pub(crate) struct CredentialStore { impl CredentialStore { pub(crate) fn beside(config: &Path) -> Self { Self { - path: config.with_file_name("cloud.json"), + path: crate::config_dir::ConfigDir::new(config).cloud(), } } diff --git a/core/crates/ployz/src/config_dir.rs b/core/crates/ployz/src/config_dir.rs new file mode 100644 index 0000000000..67d7ceca61 --- /dev/null +++ b/core/crates/ployz/src/config_dir.rs @@ -0,0 +1,60 @@ +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ConfigDir<'a>(&'a Path); + +impl<'a> ConfigDir<'a> { + pub(crate) fn new(config: &'a Path) -> Self { + Self(config) + } + + pub(crate) fn mcp_token(self) -> PathBuf { + self.0.with_file_name("mcp.json") + } + + /// The Cloud sign-in. + pub(crate) fn cloud(self) -> PathBuf { + self.0.with_file_name("cloud.json") + } + + pub(crate) fn device_key(self) -> PathBuf { + self.0.with_file_name("device.key") + } + + /// The sealing key of the in-process Config Store. + pub(crate) fn store_key(self) -> PathBuf { + self.0.with_file_name("store.key") + } + + /// The links from directories to Projects and Environments. + pub(crate) fn links(self) -> PathBuf { + self.0.with_file_name("links.json") + } + + /// The cache of published Cloud facts. + pub(crate) fn published(self) -> PathBuf { + self.0.with_file_name("published") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_file_sits_beside_the_config_under_its_name() { + let config = Path::new("/home/ada/.ployz/config.toml"); + let dir = ConfigDir::new(config); + let files = [ + (dir.mcp_token(), "mcp.json"), + (dir.cloud(), "cloud.json"), + (dir.device_key(), "device.key"), + (dir.store_key(), "store.key"), + (dir.links(), "links.json"), + (dir.published(), "published"), + ]; + for (path, name) in files { + assert_eq!(path, Path::new("/home/ada/.ployz").join(name)); + } + } +} diff --git a/core/crates/ployz/src/handlers/device.rs b/core/crates/ployz/src/handlers/device.rs index f919cf7dbf..5a0bc23a70 100644 --- a/core/crates/ployz/src/handlers/device.rs +++ b/core/crates/ployz/src/handlers/device.rs @@ -1092,7 +1092,7 @@ pub(crate) mod key { } pub(crate) fn path(config: &Path) -> PathBuf { - config.with_file_name("device.key") + crate::config_dir::ConfigDir::new(config).device_key() } pub(crate) fn public(secret: &[u8; 32]) -> DeviceKey { diff --git a/core/crates/ployz/src/handlers/link.rs b/core/crates/ployz/src/handlers/link.rs index adfba4edd5..6c71bb3f0c 100644 --- a/core/crates/ployz/src/handlers/link.rs +++ b/core/crates/ployz/src/handlers/link.rs @@ -263,7 +263,7 @@ struct Link { } fn links_path(config: &Path) -> PathBuf { - config.with_file_name("links.json") + crate::config_dir::ConfigDir::new(config).links() } /// Every link, by canonical directory. diff --git a/core/crates/ployz/src/handlers/mcp/login.rs b/core/crates/ployz/src/handlers/mcp/login.rs index 38a11a03ae..02f596a7b1 100644 --- a/core/crates/ployz/src/handlers/mcp/login.rs +++ b/core/crates/ployz/src/handlers/mcp/login.rs @@ -38,7 +38,7 @@ impl Stored { } fn path(config: &Path) -> PathBuf { - config.with_file_name("mcp.json") + crate::config_dir::ConfigDir::new(config).mcp_token() } fn failed(path: PathBuf, source: io::Error) -> LoginError { diff --git a/core/crates/ployz/src/handlers/store.rs b/core/crates/ployz/src/handlers/store.rs index 8823eae5c9..a40a83ff58 100644 --- a/core/crates/ployz/src/handlers/store.rs +++ b/core/crates/ployz/src/handlers/store.rs @@ -812,7 +812,7 @@ pub(crate) fn backend_at(config: &Path) -> Result, Error> { .filter(|path| *path != ":memory:") { Some(path) => format!("{path}.key").into(), - None => config.with_file_name("store.key"), + None => crate::config_dir::ConfigDir::new(config).store_key(), }; let key = SealingKey::from_file(&key)?; let store = ConfigStore::open(url, key)?; diff --git a/core/crates/ployz/src/lib.rs b/core/crates/ployz/src/lib.rs index e303d0d402..c94647e9f4 100644 --- a/core/crates/ployz/src/lib.rs +++ b/core/crates/ployz/src/lib.rs @@ -11,6 +11,7 @@ mod cloud_enroll; mod cloud_login; mod cluster; mod cluster_teardown; +mod config_dir; pub mod connect; pub mod context; pub mod deploy; diff --git a/core/crates/ployz/src/published/mod.rs b/core/crates/ployz/src/published/mod.rs index 9d3d99b3d7..113292903c 100644 --- a/core/crates/ployz/src/published/mod.rs +++ b/core/crates/ployz/src/published/mod.rs @@ -185,7 +185,7 @@ impl Setup { cloud: named.unwrap_or_else(|| crate::cloud_enroll::cloud_origin(DEFAULT_CLOUD)), configured, config: config.to_path_buf(), - dir: config.with_file_name("published"), + dir: crate::config_dir::ConfigDir::new(config).published(), } } diff --git a/core/crates/ployzd/src/build.rs b/core/crates/ployzd/src/build.rs index ad8654ed60..a5c6544bc7 100644 --- a/core/crates/ployzd/src/build.rs +++ b/core/crates/ployzd/src/build.rs @@ -372,7 +372,7 @@ fn receive_and_execute( break; } } - let sealed = match upload.private_file(sealed::RECORD) { + let sealed = match upload.private_file(ployz_build::capture::SEALED_RECORD) { Ok(None) => None, Ok(Some(record)) => match sealed::open(&record, sealing_key_dir) { Ok(opened) => { diff --git a/core/crates/ployzd/src/build/sealed.rs b/core/crates/ployzd/src/build/sealed.rs index af4f03725b..3da1448f3d 100644 --- a/core/crates/ployzd/src/build/sealed.rs +++ b/core/crates/ployzd/src/build/sealed.rs @@ -6,9 +6,6 @@ use ployz_core::OpenedSealed; use crate::sealed_env::{SealedRecord, Unseal as _}; -/// The private file a capture writes its Sealed values to, still sealed. -pub(crate) const RECORD: &str = "sealed.json"; - /// Open the record with this Machine's Sealing Key. The message names a /// variable at most, never what it holds. pub(crate) fn open(record: &[u8], key_dir: Option<&Path>) -> Result { diff --git a/core/crates/ployzd/src/dns/service.rs b/core/crates/ployzd/src/dns/service.rs index e3e527164a..700bb84b91 100644 --- a/core/crates/ployzd/src/dns/service.rs +++ b/core/crates/ployzd/src/dns/service.rs @@ -11,6 +11,7 @@ use tokio::{process::Command, sync::watch}; use tokio_util::sync::CancellationToken; use super::{ + ABDICATED, process::{InstalledExe, identity_of}, spec::{DnsSpec, SpecFile}, }; @@ -191,8 +192,8 @@ ExecStart={exe} --socket {socket} dns EnvironmentFile=-/etc/default/ployz Restart=always RestartSec=1 -RestartPreventExitStatus=78 -SuccessExitStatus=78 +RestartPreventExitStatus={abdicated} +SuccessExitStatus={abdicated} FileDescriptorStoreMax=4 TimeoutStopSec=10 NoNewPrivileges=true @@ -207,6 +208,7 @@ CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_DAC_OVERRIDE ", exe = exe.display(), socket = socket.display(), + abdicated = ABDICATED, ) } diff --git a/core/crates/ployzd/src/ingress/caddy.rs b/core/crates/ployzd/src/ingress/caddy.rs index 7c195bd734..68102e6548 100644 --- a/core/crates/ployzd/src/ingress/caddy.rs +++ b/core/crates/ployzd/src/ingress/caddy.rs @@ -2,7 +2,9 @@ use chrono::{SecondsFormat, Utc}; use ployz_core::{ - HOSTNAME_VERIFY_PATH, HttpProtocol, INGRESS_VERIFY_PATH, Machine, MachineId, ingress_upstream, + HOSTNAME_VERIFY_PATH, HttpProtocol, INGRESS_LOOPBACK_BIND, INGRESS_LOOPBACK_BIND_V6, + INGRESS_LOOPBACK_PORT, INGRESS_RUNTIME_MOUNT, INGRESS_VERIFY_PATH, Machine, MachineId, + ingress_certs_dir, ingress_upstream, }; use reqwest::{Client, StatusCode, header}; use serde_json::Value; @@ -27,8 +29,6 @@ use crate::{ mod trace; pub const CONFIG_FILE: &str = "Caddyfile"; -const CONTAINER_CERTS_DIR: &str = "/config/caddy/certs"; -const CONTAINER_RUNTIME_DIR: &str = "/run/ingress"; /// Request fields that name the visitor. Every header goes, since any of them can carry an address. const VISITOR_FIELDS: [&str; 3] = ["request>remote_ip", "request>client_ip", "request>headers"]; /// Fields Caddy's debug logs put the client address in outside any request, as TLS handshakes do. @@ -148,7 +148,7 @@ impl CaddyAdmin for AdminClient { Err(Error::Admin(_)) => return Ok(BTreeSet::new()), Err(error) => return Err(error), }; - let prefix = format!("{CONTAINER_CERTS_DIR}/"); + let prefix = format!("{}/", ingress_certs_dir()); Ok(serde_json::from_str::>>(&files)? .unwrap_or_default() .iter() @@ -322,8 +322,7 @@ https:// {{\n\ \timport common_proxy\n\ \theader_up X-Forwarded-Proto {{header.X-Forwarded-Proto}}\n\ }}\n", - trace::LOOPBACK_BIND, - trace::LOOPBACK_BIND_V6, + INGRESS_LOOPBACK_BIND, INGRESS_LOOPBACK_BIND_V6, ); write_loopback_sites(&mut output, projection, &unpublished, &hostname_verify); if projection.sites.iter().any(|site| { @@ -378,8 +377,8 @@ https:// {{\n\ continue; }; let stem = super::certificate_file_stem(&site.hostname, material); - let tls = - format!("\ttls {CONTAINER_CERTS_DIR}/{stem}.crt {CONTAINER_CERTS_DIR}/{stem}.key\n"); + let certs = ingress_certs_dir(); + let tls = format!("\ttls {certs}/{stem}.crt {certs}/{stem}.key\n"); // Pinned material with no HTTPS route left answers like the HTTP catch-all. let (trace, answer) = match https { Some((_, route)) => (published(route), proxy(route)), @@ -412,7 +411,7 @@ fn write_loopback_sites( hostname_verify: &str, ) { let local_machine = &projection.machine.id; - let port = trace::LOOPBACK_PORT; + let port = INGRESS_LOOPBACK_PORT; let loopback = format!("\timport loopback_site {local_machine}\n"); let _ = write!( output, @@ -491,7 +490,7 @@ fn write_log_snippets(output: &mut String) { \tlog_append namespace {{args[0]}}\n\ \tlog_append service {{args[1]}}\n\ \tlog ployz_requests {{\n\ -\t\toutput file {CONTAINER_RUNTIME_DIR}/{} {{\n\ +\t\toutput file {INGRESS_RUNTIME_MOUNT}/{} {{\n\ \t\t\troll_size 10MiB\n\ \t\t\troll_keep 1\n\ \t\t\troll_uncompressed\n\ diff --git a/core/crates/ployzd/src/ingress/caddy/trace.rs b/core/crates/ployzd/src/ingress/caddy/trace.rs index 40d63892fe..2bb7f1b09c 100644 --- a/core/crates/ployzd/src/ingress/caddy/trace.rs +++ b/core/crates/ployzd/src/ingress/caddy/trace.rs @@ -3,7 +3,7 @@ //! Caddy answers the page itself. Facts known at render time reach it as snippet arguments; //! everything about the request stays a Caddy placeholder, so a request never reloads Caddy. -use ployz_core::MachineId; +use ployz_core::{INGRESS_LOOPBACK_BIND, INGRESS_LOOPBACK_PORT, MachineId}; use std::fmt::Write as _; use crate::ingress::{IngressEndpoint, PublicationTrace}; @@ -54,15 +54,12 @@ pub(super) fn servers_options() -> String { } let _ = write!( output, - "\tservers {LOOPBACK_BIND}:{LOOPBACK_PORT} {{\n\t\ttrusted_proxies static {LOOPBACK_RANGES}\n\t\tclient_ip_headers CF-Connecting-IP X-Forwarded-For\n\t}}\n" + "\tservers {INGRESS_LOOPBACK_BIND}:{INGRESS_LOOPBACK_PORT} {{\n\t\ttrusted_proxies static {LOOPBACK_RANGES}\n\t\tclient_ip_headers CF-Connecting-IP X-Forwarded-For\n\t}}\n" ); output } /// Where the loopback site listens: the Tunnel door dials it on the Machine itself. -pub(super) const LOOPBACK_PORT: u16 = 8480; -pub(super) const LOOPBACK_BIND: &str = "127.0.0.1"; -pub(super) const LOOPBACK_BIND_V6: &str = "::1"; const LOOPBACK_RANGES: &str = "127.0.0.1/32 ::1/128"; /// One line of the page. Keys are only ever appended, so readers can rely on the order. diff --git a/core/crates/ployzd/src/installer/assess.rs b/core/crates/ployzd/src/installer/assess.rs index 07bd58411f..da303510db 100644 --- a/core/crates/ployzd/src/installer/assess.rs +++ b/core/crates/ployzd/src/installer/assess.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::Value; use super::os_release::OsRelease; -use super::storage::ZfsRoute; +use super::zfs_route::ZfsRoute; use super::{Error, InstallPaths, command_exists, run_command, run_host}; const GIB: u64 = 1024 * 1024 * 1024; diff --git a/core/crates/ployzd/src/installer/host.rs b/core/crates/ployzd/src/installer/host.rs index 05471c68de..27c37fa872 100644 --- a/core/crates/ployzd/src/installer/host.rs +++ b/core/crates/ployzd/src/installer/host.rs @@ -13,7 +13,9 @@ use tokio::time::timeout; use tonic::transport::Endpoint; use crate::filesystem::{MACHINE_API_SOCKET_MODE, PLOYZ_DIR_MODE, atomic_write}; -use ployz_core::{DescribeContractRequest, MachineRpcClient, MachineVersion, op}; +use ployz_core::{ + DescribeContractRequest, MachineRpcClient, MachineVersion, NETWORK_CONFLICT_EXIT_STATUS, op, +}; use super::assess::{docker_blocker, read_docker}; use super::os_release::OsRelease; @@ -282,7 +284,7 @@ EnvironmentFile=-/etc/default/ployz TimeoutStartSec=20 TimeoutStopSec=15 Restart=always -RestartPreventExitStatus=78 +RestartPreventExitStatus={NETWORK_CONFLICT_EXIT_STATUS} RestartSec=2 NoNewPrivileges=true ProtectSystem=full @@ -634,6 +636,13 @@ mod tests { assert_eq!(listen, super::super::DEFAULT_SOCKET_PATH); } + #[test] + fn daemon_unit_does_not_restart_after_a_network_conflict_refusal() { + let unit = machine_daemon_service_unit(Path::new("/usr/local/bin")); + assert!(unit.contains("\nRestartPreventExitStatus=78\n")); + assert_eq!(NETWORK_CONFLICT_EXIT_STATUS, 78); + } + #[test] fn running_machine_api_version_must_match_the_target() { let target = MachineVersion::parse("1.2.3-beta.4").unwrap(); diff --git a/core/crates/ployzd/src/installer/mod.rs b/core/crates/ployzd/src/installer/mod.rs index 0fab110780..8d908b8038 100644 --- a/core/crates/ployzd/src/installer/mod.rs +++ b/core/crates/ployzd/src/installer/mod.rs @@ -10,6 +10,7 @@ mod storage; #[cfg(test)] mod test_support; pub mod upgrade; +mod zfs_route; pub use self::host::write_units; pub use self::prepare::{discard_zfs_build, prepare}; diff --git a/core/crates/ployzd/src/installer/prepare.rs b/core/crates/ployzd/src/installer/prepare.rs index 668aea7e55..fa15168eed 100644 --- a/core/crates/ployzd/src/installer/prepare.rs +++ b/core/crates/ployzd/src/installer/prepare.rs @@ -15,10 +15,11 @@ use super::assess::{assess, read_facts}; use super::host::{install_docker, verify_docker}; use super::os_release::OsRelease; use super::storage::{ - self, BuildFailure, OPENZFS_BUILD_DEPENDENCIES, OPENZFS_SHA256, ZfsRoute, - debian_contrib_source, debian_kernel_flavour_step, download_openzfs, enable_debian_contrib, - kernel_devel, make_openzfs_rpms, require_kernel_devel, uname, zfs_installed, zfs_preflight, + self, BuildFailure, OPENZFS_BUILD_DEPENDENCIES, OPENZFS_SHA256, debian_contrib_source, + debian_kernel_flavour_step, download_openzfs, enable_debian_contrib, kernel_devel, + make_openzfs_rpms, require_kernel_devel, uname, zfs_installed, zfs_preflight, }; +use super::zfs_route::ZfsRoute; use super::{ DEFAULT_RUN_DIR, Error, InstallPaths, apt_get, command_exists, refuse, require_root, run_apt, run_command, run_host, diff --git a/core/crates/ployzd/src/installer/storage.rs b/core/crates/ployzd/src/installer/storage.rs index 366bb710c6..c320accd42 100644 --- a/core/crates/ployzd/src/installer/storage.rs +++ b/core/crates/ployzd/src/installer/storage.rs @@ -17,6 +17,7 @@ use super::assess::{ root_reserve_blocker, secure_boot_blocker, virtualization_blocker, zfs_os_route, }; use super::os_release::OsRelease; +use super::zfs_route::ZfsRoute; use super::{Error, InstallPaths, command_exists, refuse, run_apt, run_command, run_host}; use super::{ host::write_file_atomically, @@ -290,83 +291,34 @@ fn partition_of(partition: &Path, disk: &Path) -> bool { pub(super) const STAGE: &str = "prepare ZFS storage"; -/// How the ZFS kernel module becomes loadable on the Machine's distro. -#[derive(Clone, Copy)] -pub(super) enum ZfsRoute { - /// Canonical's prebuilt module package for the running kernel. - Ubuntu, - /// Debian's `zfs-dkms` from `contrib`, built on the Machine and rebuilt by DKMS for new - /// kernels. `keyring` is the path the release's own images name: apt refuses a source whose - /// Signed-By differs from another entry for the same suite. - Debian { - codename: &'static str, - keyring: &'static str, - }, - /// The pinned [`OPENZFS_VERSION`] built on the Machine into DKMS and userspace RPMs. - AmazonLinux, -} - -impl ZfsRoute { - pub(super) fn for_os(os: &OsRelease) -> Option { - match (os.id.as_str(), os.version_id.as_deref()) { - ("ubuntu", _) => Some(Self::Ubuntu), - ("debian", Some("12")) => Some(Self::Debian { - codename: "bookworm", - keyring: "debian-archive-keyring.gpg", - }), - ("debian", Some("13")) => Some(Self::Debian { - codename: "trixie", - keyring: "debian-archive-keyring.pgp", - }), - (_, Some("2023")) if os.is_amazon_linux() => Some(Self::AmazonLinux), - _ => None, - } - } - - /// Whether the Machine builds the module itself, unsigned, rather than installing Ubuntu's. - pub(super) fn builds_module(self) -> bool { - !matches!(self, Self::Ubuntu) - } - - pub(super) fn prepare_minutes(self) -> u32 { - match self { - Self::Ubuntu => 1, - Self::Debian { .. } => 10, - Self::AmazonLinux => 20, - } - } - - pub(super) fn package_manager(self) -> &'static str { - match self { - Self::Ubuntu | Self::Debian { .. } => "apt-get", - Self::AmazonLinux => "dnf", - } - } - - fn install(self, paths: &InstallPaths, os: &OsRelease, kernel: &str) -> Result<(), Error> { - super::prepare::install_waiting_for_preparation( - paths, - self.package_manager(), - || match self { - Self::Ubuntu => install_zfs_packages(kernel), - Self::Debian { codename, keyring } => prepare_debian_zfs( - &paths.apt_dir, - os, - codename, - keyring, - kernel, - super::prepare::prepared_dkms_build(paths, kernel), - ), - Self::AmazonLinux => prepare_amazon_zfs( - &super::prepare::prepare_dir(paths).join("zfs"), - &paths.modules_load_dir, - os, - kernel, - OPENZFS_SHA256, - ), - }, - ) - } +fn install_route( + route: ZfsRoute, + paths: &InstallPaths, + os: &OsRelease, + kernel: &str, +) -> Result<(), Error> { + super::prepare::install_waiting_for_preparation( + paths, + route.package_manager(), + || match route { + ZfsRoute::Ubuntu => install_zfs_packages(kernel), + ZfsRoute::Debian { codename, keyring } => prepare_debian_zfs( + &paths.apt_dir, + os, + codename, + keyring, + kernel, + super::prepare::prepared_dkms_build(paths, kernel), + ), + ZfsRoute::AmazonLinux => prepare_amazon_zfs( + &super::prepare::prepare_dir(paths).join("zfs"), + &paths.modules_load_dir, + os, + kernel, + OPENZFS_SHA256, + ), + }, + ) } fn prepare_zfs(paths: &InstallPaths) -> Result<(), Error> { @@ -381,7 +333,7 @@ fn prepare_zfs(paths: &InstallPaths) -> Result<(), Error> { require_host_root_reserve()?; let cap = zfs_arc_max()?; if install { - route.install(paths, &os, &kernel)?; + install_route(route, paths, &os, &kernel)?; } // Written only once ZFS is in place, so every refusal above leaves the host untouched. persist_zfs_arc_max(paths, cap)?; diff --git a/core/crates/ployzd/src/installer/storage/tests.rs b/core/crates/ployzd/src/installer/storage/tests.rs index 68f095aa6b..56b9e82cd8 100644 --- a/core/crates/ployzd/src/installer/storage/tests.rs +++ b/core/crates/ployzd/src/installer/storage/tests.rs @@ -579,10 +579,7 @@ fn zfs_install_takes_over_a_prepared_build_contract() { let root = contract_root(); let paths = InstallPaths::at(&root); let os = OsRelease::read(&paths.os_release).unwrap(); - ZfsRoute::for_os(&os) - .unwrap() - .install(&paths, &os, KERNEL) - .unwrap(); + install_route(ZfsRoute::for_os(&os).unwrap(), &paths, &os, KERNEL).unwrap(); if name == "no-build" { discard_at(&paths).unwrap(); } diff --git a/core/crates/ployzd/src/installer/zfs_route.rs b/core/crates/ployzd/src/installer/zfs_route.rs new file mode 100644 index 0000000000..0bdebcdc47 --- /dev/null +++ b/core/crates/ployzd/src/installer/zfs_route.rs @@ -0,0 +1,106 @@ +use super::os_release::OsRelease; + +#[derive(Clone, Copy)] +pub(super) enum ZfsRoute { + /// Canonical's prebuilt module package for the running kernel. + Ubuntu, + /// Debian's `zfs-dkms` from `contrib`, built on the Machine and rebuilt by DKMS for new + /// kernels. `keyring` is the path the release's own images name: apt refuses a source whose + /// Signed-By differs from another entry for the same suite. + Debian { + codename: &'static str, + keyring: &'static str, + }, + /// The pinned `OPENZFS_VERSION` built on the Machine into DKMS and userspace RPMs. + AmazonLinux, +} + +impl ZfsRoute { + pub(super) fn for_os(os: &OsRelease) -> Option { + match (os.id.as_str(), os.version_id.as_deref()) { + ("ubuntu", _) => Some(Self::Ubuntu), + ("debian", Some("12")) => Some(Self::Debian { + codename: "bookworm", + keyring: "debian-archive-keyring.gpg", + }), + ("debian", Some("13")) => Some(Self::Debian { + codename: "trixie", + keyring: "debian-archive-keyring.pgp", + }), + (_, Some("2023")) if os.is_amazon_linux() => Some(Self::AmazonLinux), + _ => None, + } + } + + /// Whether the Machine builds the module itself, unsigned, rather than installing Ubuntu's. + pub(super) fn builds_module(self) -> bool { + !matches!(self, Self::Ubuntu) + } + + pub(super) fn prepare_minutes(self) -> u32 { + match self { + Self::Ubuntu => 1, + Self::Debian { .. } => 10, + Self::AmazonLinux => 20, + } + } + + pub(super) fn package_manager(self) -> &'static str { + match self { + Self::Ubuntu | Self::Debian { .. } => "apt-get", + Self::AmazonLinux => "dnf", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn os(id: &str, name: &str, version_id: Option<&str>) -> OsRelease { + OsRelease { + id: id.to_owned(), + name: name.to_owned(), + version_id: version_id.map(str::to_owned), + } + } + + #[test] + fn each_supported_distro_has_its_route() { + let ubuntu = ZfsRoute::for_os(&os("ubuntu", "Ubuntu", Some("24.04"))).unwrap(); + assert!(matches!(ubuntu, ZfsRoute::Ubuntu)); + assert!(!ubuntu.builds_module()); + assert_eq!( + (ubuntu.prepare_minutes(), ubuntu.package_manager()), + (1, "apt-get") + ); + let bookworm = ZfsRoute::for_os(&os("debian", "Debian", Some("12"))).unwrap(); + assert!(matches!( + bookworm, + ZfsRoute::Debian { + codename: "bookworm", + keyring: "debian-archive-keyring.gpg" + } + )); + let trixie = ZfsRoute::for_os(&os("debian", "Debian", Some("13"))).unwrap(); + assert!(matches!( + trixie, + ZfsRoute::Debian { + codename: "trixie", + keyring: "debian-archive-keyring.pgp" + } + )); + assert!(trixie.builds_module()); + assert_eq!( + (trixie.prepare_minutes(), trixie.package_manager()), + (10, "apt-get") + ); + let amazon = ZfsRoute::for_os(&os("amzn", "Amazon Linux", Some("2023"))).unwrap(); + assert!(matches!(amazon, ZfsRoute::AmazonLinux)); + assert_eq!( + (amazon.prepare_minutes(), amazon.package_manager()), + (20, "dnf") + ); + assert!(ZfsRoute::for_os(&os("debian", "Debian", Some("11"))).is_none()); + } +}