feat(error): type the public library API with ecr::Error
anyhow at the public boundary left library consumers unable to match on failure modes: every mistake was an opaque error, and the ones worth branching on (host prerequisites, unsupported architectures) could only be recognized by string matching. Public functions now return Result<T, ecr::Error> (thiserror) with semantic variants for those conditions: user namespaces unavailable, binfmt_misc unregistered, architecture not served by the registry, missing subuid/subgid entry. anyhow stays the internal error type; everything else converts to Error::Other, which transparently wraps the full context chain. Errors raised inside the namespace child cross the process boundary as formatted chains and also surface as Other; the error pipe wraps them in anyhow before formatting so the chain still survives. The CLI maps the error back into anyhow, so its output is unchanged.
This commit is contained in:
Generated
+1
@@ -354,6 +354,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"users",
|
"users",
|
||||||
"which",
|
"which",
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ fn main() -> Result<()> {
|
|||||||
log::debug!("Cleanup complete.");
|
log::debug!("Cleanup complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result.map_err(anyhow::Error::from)
|
||||||
} else {
|
} else {
|
||||||
// Namespace/chroot mode
|
// Namespace/chroot mode
|
||||||
let exit_code = namespace_mode(&args, rootfs.dir(), &config, &arch)?;
|
let exit_code = namespace_mode(&args, rootfs.dir(), &config, &arch)?;
|
||||||
@@ -122,7 +122,7 @@ fn namespace_mode(args: &Args, rootfs: &Path, config: &Config, arch: &str) -> Re
|
|||||||
working_dir,
|
working_dir,
|
||||||
};
|
};
|
||||||
|
|
||||||
let exit_code = ecr::exec(rootfs, &opts);
|
let exit_code = ecr::exec(rootfs, &opts).map_err(anyhow::Error::from);
|
||||||
|
|
||||||
// Cleanup happens automatically via tempfile
|
// Cleanup happens automatically via tempfile
|
||||||
if exit_code.is_ok() {
|
if exit_code.is_ok() {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ tempfile = "3"
|
|||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use nix::unistd::{chroot, execve};
|
use nix::unistd::{chroot, execve};
|
||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -59,7 +61,7 @@ pub fn run_chroot(
|
|||||||
std::ffi::CString::new(s.as_str())
|
std::ffi::CString::new(s.as_str())
|
||||||
.with_context(|| format!("Argument contains a null byte: {:?}", s))
|
.with_context(|| format!("Argument contains a null byte: {:?}", s))
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
(program, args)
|
(program, args)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -79,7 +81,7 @@ pub fn run_chroot(
|
|||||||
std::ffi::CString::new(format!("{}={}", k, v))
|
std::ffi::CString::new(format!("{}={}", k, v))
|
||||||
.with_context(|| format!("Environment variable contains a null byte: {}={}", k, v))
|
.with_context(|| format!("Environment variable contains a null byte: {}={}", k, v))
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
std::env::set_current_dir(working_dir).with_context(|| {
|
std::env::set_current_dir(working_dir).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
@@ -105,14 +107,14 @@ pub fn run_chroot(
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(()), // Never reached
|
Ok(_) => Ok(()), // Never reached
|
||||||
Err(e) => Err(anyhow!("Failed to exec {}: {}", program_path.display(), e)),
|
Err(e) => Err(anyhow!("Failed to exec {}: {}", program_path.display(), e).into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the program to exec. Commands containing a '/' are used as-is
|
/// Resolve the program to exec. Commands containing a '/' are used as-is
|
||||||
/// (existing check gives a clearer error than a raw ENOENT); a bare command
|
/// (existing check gives a clearer error than a raw ENOENT); a bare command
|
||||||
/// name is looked up in the caller-composed PATH, like execvp(3) would.
|
/// name is looked up in the caller-composed PATH, like execvp(3) would.
|
||||||
fn resolve_program(program: &str, env: &[(String, String)]) -> Result<PathBuf> {
|
fn resolve_program(program: &str, env: &[(String, String)]) -> anyhow::Result<PathBuf> {
|
||||||
if program.contains('/') {
|
if program.contains('/') {
|
||||||
let path = PathBuf::from(program);
|
let path = PathBuf::from(program);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
|||||||
+21
-19
@@ -1,4 +1,6 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
/// Known distributions with optimized direct tarball downloads
|
/// Known distributions with optimized direct tarball downloads
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -29,7 +31,7 @@ impl Distro {
|
|||||||
match name.to_lowercase().as_str() {
|
match name.to_lowercase().as_str() {
|
||||||
"ubuntu" => Ok(Distro::Ubuntu),
|
"ubuntu" => Ok(Distro::Ubuntu),
|
||||||
"alpine" => Ok(Distro::Alpine),
|
"alpine" => Ok(Distro::Alpine),
|
||||||
_ => Err(anyhow!("Unknown distribution: {}", name)),
|
_ => Err(anyhow!("Unknown distribution: {}", name).into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,12 +50,12 @@ pub fn parse_image_ref(input: &str, arch: &str) -> Result<ImageSource> {
|
|||||||
.strip_prefix("docker://")
|
.strip_prefix("docker://")
|
||||||
.or_else(|| input.strip_prefix("oci://"))
|
.or_else(|| input.strip_prefix("oci://"))
|
||||||
{
|
{
|
||||||
return parse_oci_ref(rest, arch);
|
return Ok(parse_oci_ref(rest, arch)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for registry prefix (contains /)
|
// Check for registry prefix (contains /)
|
||||||
if input.contains('/') {
|
if input.contains('/') {
|
||||||
return parse_oci_ref(input, arch);
|
return Ok(parse_oci_ref(input, arch)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as known distro
|
// Try to parse as known distro
|
||||||
@@ -89,12 +91,12 @@ pub fn parse_image_ref(input: &str, arch: &str) -> Result<ImageSource> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Default to Docker Hub for unknown distros (debian, fedora, etc.)
|
// Default to Docker Hub for unknown distros (debian, fedora, etc.)
|
||||||
_ => parse_oci_ref(input, arch),
|
_ => Ok(parse_oci_ref(input, arch)?),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse OCI image reference (registry/repo:tag or repo:tag)
|
/// Parse OCI image reference (registry/repo:tag or repo:tag)
|
||||||
fn parse_oci_ref(input: &str, arch: &str) -> Result<ImageSource> {
|
fn parse_oci_ref(input: &str, arch: &str) -> anyhow::Result<ImageSource> {
|
||||||
// Split off the tag. A ':' is a tag separator only when it appears after
|
// Split off the tag. A ':' is a tag separator only when it appears after
|
||||||
// the last '/'; any ':' before a '/' is a port number
|
// the last '/'; any ':' before a '/' is a port number
|
||||||
// (e.g. localhost:5000/image). With no '/' the single ':' is the tag.
|
// (e.g. localhost:5000/image). With no '/' the single ':' is the tag.
|
||||||
@@ -158,10 +160,10 @@ pub fn map_arch(distro: Distro, arch: &str) -> String {
|
|||||||
|
|
||||||
/// Resolve the download URL for a known distro (optimized path)
|
/// Resolve the download URL for a known distro (optimized path)
|
||||||
pub fn resolve_distro_url(distro: &Distro, version: Option<&str>, arch: &str) -> Result<String> {
|
pub fn resolve_distro_url(distro: &Distro, version: Option<&str>, arch: &str) -> Result<String> {
|
||||||
match distro {
|
Ok(match distro {
|
||||||
Distro::Ubuntu => resolve_ubuntu_url(version, arch),
|
Distro::Ubuntu => resolve_ubuntu_url(version, arch),
|
||||||
Distro::Alpine => resolve_alpine_url(version, arch),
|
Distro::Alpine => resolve_alpine_url(version, arch),
|
||||||
}
|
}?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL of the checksum artifact covering the tarball at `tarball_url`, when
|
/// URL of the checksum artifact covering the tarball at `tarball_url`, when
|
||||||
@@ -179,7 +181,7 @@ pub fn checksum_artifact_url(distro: &Distro, tarball_url: &str) -> Option<Strin
|
|||||||
|
|
||||||
/// Fetch the latest Ubuntu codename from a changelogs.ubuntu.com meta-release file.
|
/// Fetch the latest Ubuntu codename from a changelogs.ubuntu.com meta-release file.
|
||||||
/// Pass the LTS-only URL to get the latest LTS, or the full URL for the latest release.
|
/// Pass the LTS-only URL to get the latest LTS, or the full URL for the latest release.
|
||||||
fn fetch_ubuntu_codename(meta_release_url: &str) -> Result<String> {
|
fn fetch_ubuntu_codename(meta_release_url: &str) -> anyhow::Result<String> {
|
||||||
let text = reqwest::blocking::get(meta_release_url)
|
let text = reqwest::blocking::get(meta_release_url)
|
||||||
.with_context(|| format!("Failed to fetch {}", meta_release_url))?
|
.with_context(|| format!("Failed to fetch {}", meta_release_url))?
|
||||||
.text()
|
.text()
|
||||||
@@ -211,7 +213,7 @@ fn fetch_ubuntu_codename(meta_release_url: &str) -> Result<String> {
|
|||||||
/// tells us the exact version number needed for the tarball filename.
|
/// tells us the exact version number needed for the tarball filename.
|
||||||
/// Fetch the latest minirootfs version for a given Alpine CDN branch.
|
/// Fetch the latest minirootfs version for a given Alpine CDN branch.
|
||||||
/// `branch` is the directory name on the CDN, e.g. `"latest-stable"` or `"v3.23"`.
|
/// `branch` is the directory name on the CDN, e.g. `"latest-stable"` or `"v3.23"`.
|
||||||
fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> Result<String> {
|
fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> anyhow::Result<String> {
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct AlpineRelease {
|
struct AlpineRelease {
|
||||||
file: Option<String>,
|
file: Option<String>,
|
||||||
@@ -259,13 +261,13 @@ fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> Result<String>
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_alpine_latest_version(arch: &str) -> Result<String> {
|
fn fetch_alpine_latest_version(arch: &str) -> anyhow::Result<String> {
|
||||||
fetch_alpine_version_from_branch("latest-stable", arch)
|
fetch_alpine_version_from_branch("latest-stable", arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a `major.minor` Alpine series (e.g. `"3.24"`) to its current
|
/// Resolve a `major.minor` Alpine series (e.g. `"3.24"`) to its current
|
||||||
/// patch release by querying the CDN branch `v{minor}`.
|
/// patch release by querying the CDN branch `v{minor}`.
|
||||||
fn fetch_alpine_minor_version(minor: &str, arch: &str) -> Result<String> {
|
fn fetch_alpine_minor_version(minor: &str, arch: &str) -> anyhow::Result<String> {
|
||||||
fetch_alpine_version_from_branch(&format!("v{}", minor), arch)
|
fetch_alpine_version_from_branch(&format!("v{}", minor), arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,16 +279,16 @@ pub fn resolve_distro_version(
|
|||||||
version: Option<&str>,
|
version: Option<&str>,
|
||||||
arch: &str,
|
arch: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
match distro {
|
Ok(match distro {
|
||||||
Distro::Ubuntu => resolve_ubuntu_version(version),
|
Distro::Ubuntu => resolve_ubuntu_version(version),
|
||||||
Distro::Alpine => resolve_alpine_version(version, arch),
|
Distro::Alpine => resolve_alpine_version(version, arch),
|
||||||
}
|
}?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a map of YY.MM version strings to codenames by parsing the Ubuntu
|
/// Build a map of YY.MM version strings to codenames by parsing the Ubuntu
|
||||||
/// meta-release file (e.g. "24.04" → "noble", "22.04" → "jammy").
|
/// meta-release file (e.g. "24.04" → "noble", "22.04" → "jammy").
|
||||||
/// Uses the full meta-release (not -lts) so non-LTS versions are also covered.
|
/// Uses the full meta-release (not -lts) so non-LTS versions are also covered.
|
||||||
fn fetch_ubuntu_version_map() -> Result<std::collections::HashMap<String, String>> {
|
fn fetch_ubuntu_version_map() -> anyhow::Result<std::collections::HashMap<String, String>> {
|
||||||
let text = reqwest::blocking::get("https://changelogs.ubuntu.com/meta-release")
|
let text = reqwest::blocking::get("https://changelogs.ubuntu.com/meta-release")
|
||||||
.context("Failed to fetch Ubuntu meta-release")?
|
.context("Failed to fetch Ubuntu meta-release")?
|
||||||
.text()
|
.text()
|
||||||
@@ -318,7 +320,7 @@ fn fetch_ubuntu_version_map() -> Result<std::collections::HashMap<String, String
|
|||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_ubuntu_version(version: Option<&str>) -> Result<String> {
|
fn resolve_ubuntu_version(version: Option<&str>) -> anyhow::Result<String> {
|
||||||
let version = version.unwrap_or("latest");
|
let version = version.unwrap_or("latest");
|
||||||
match version {
|
match version {
|
||||||
"latest" => fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release"),
|
"latest" => fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release"),
|
||||||
@@ -358,7 +360,7 @@ fn is_ubuntu_version_number(s: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_alpine_version(version: Option<&str>, arch: &str) -> Result<String> {
|
fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
|
||||||
let alpine_arch = map_arch(Distro::Alpine, arch);
|
let alpine_arch = map_arch(Distro::Alpine, arch);
|
||||||
Ok(match version.unwrap_or("latest") {
|
Ok(match version.unwrap_or("latest") {
|
||||||
"latest" | "stable" => fetch_alpine_latest_version(&alpine_arch)?,
|
"latest" | "stable" => fetch_alpine_latest_version(&alpine_arch)?,
|
||||||
@@ -380,7 +382,7 @@ fn resolve_alpine_version(version: Option<&str>, arch: &str) -> Result<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> Result<String> {
|
fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
|
||||||
let codename = resolve_ubuntu_version(version)?;
|
let codename = resolve_ubuntu_version(version)?;
|
||||||
let arch = map_arch(Distro::Ubuntu, arch);
|
let arch = map_arch(Distro::Ubuntu, arch);
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
@@ -389,7 +391,7 @@ fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> Result<String> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_alpine_url(version: Option<&str>, arch: &str) -> Result<String> {
|
fn resolve_alpine_url(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
|
||||||
let version_str = version.unwrap_or("latest");
|
let version_str = version.unwrap_or("latest");
|
||||||
let alpine_arch = map_arch(Distro::Alpine, arch);
|
let alpine_arch = map_arch(Distro::Alpine, arch);
|
||||||
let version_num = resolve_alpine_version(Some(version_str), arch)?;
|
let version_num = resolve_alpine_version(Some(version_str), arch)?;
|
||||||
|
|||||||
+29
-19
@@ -1,5 +1,5 @@
|
|||||||
use crate::progress::{Progress, Unit};
|
use crate::progress::{Progress, Unit};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::distro::{Distro, ImageSource};
|
use crate::distro::{Distro, ImageSource};
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
/// Path of the digest sidecar file for a cached OCI image.
|
/// Path of the digest sidecar file for a cached OCI image.
|
||||||
/// e.g. `foo.tar.gz` → `foo.tar.gz.digest`
|
/// e.g. `foo.tar.gz` → `foo.tar.gz.digest`
|
||||||
@@ -42,7 +43,7 @@ impl DownloadHasher {
|
|||||||
/// a bare sha256 hex string (distro checksum files); None verifies nothing.
|
/// a bare sha256 hex string (distro checksum files); None verifies nothing.
|
||||||
/// Algorithms outside sha256/sha512 cannot be checked: warn and accept
|
/// Algorithms outside sha256/sha512 cannot be checked: warn and accept
|
||||||
/// rather than reject images a registry may legitimately serve.
|
/// rather than reject images a registry may legitimately serve.
|
||||||
fn verify(&self, expected: Option<&str>) -> Result<()> {
|
fn verify(&self, expected: Option<&str>) -> anyhow::Result<()> {
|
||||||
let Some(expected) = expected else {
|
let Some(expected) = expected else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -84,12 +85,17 @@ fn hex_encode(bytes: &[u8]) -> String {
|
|||||||
/// layers. The registry returns the content-addressable digest in the
|
/// layers. The registry returns the content-addressable digest in the
|
||||||
/// `Docker-Content-Digest` response header of any manifest GET.
|
/// `Docker-Content-Digest` response header of any manifest GET.
|
||||||
pub fn fetch_oci_digest(registry: &str, repository: &str, tag: &str) -> Result<String> {
|
pub fn fetch_oci_digest(registry: &str, repository: &str, tag: &str) -> Result<String> {
|
||||||
tokio::runtime::Runtime::new()
|
let digest = tokio::runtime::Runtime::new()
|
||||||
.context("Failed to create Tokio runtime")?
|
.context("Failed to create Tokio runtime")?
|
||||||
.block_on(fetch_oci_digest_async(registry, repository, tag))
|
.block_on(fetch_oci_digest_async(registry, repository, tag))?;
|
||||||
|
Ok(digest)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_oci_digest_async(registry: &str, repository: &str, tag: &str) -> Result<String> {
|
async fn fetch_oci_digest_async(
|
||||||
|
registry: &str,
|
||||||
|
repository: &str,
|
||||||
|
tag: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
@@ -153,7 +159,7 @@ pub fn download_image(
|
|||||||
let url = crate::distro::resolve_distro_url(distro, version.as_deref(), arch)?;
|
let url = crate::distro::resolve_distro_url(distro, version.as_deref(), arch)?;
|
||||||
log::debug!("Resolved URL: {}", url);
|
log::debug!("Resolved URL: {}", url);
|
||||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
return Err(anyhow!("Unsupported URL scheme: {}", url));
|
return Err(anyhow!("Unsupported URL scheme: {}", url).into());
|
||||||
}
|
}
|
||||||
// Verify the tarball against the checksum the distro publishes
|
// Verify the tarball against the checksum the distro publishes
|
||||||
// next to it. A fetch failure (missing artifact, network error)
|
// next to it. A fetch failure (missing artifact, network error)
|
||||||
@@ -170,12 +176,12 @@ pub fn download_image(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
rt.block_on(download_file_async(
|
Ok(rt.block_on(download_file_async(
|
||||||
&url,
|
&url,
|
||||||
dest,
|
dest,
|
||||||
progress,
|
progress,
|
||||||
expected.as_deref(),
|
expected.as_deref(),
|
||||||
))
|
))?)
|
||||||
}
|
}
|
||||||
ImageSource::OciImage {
|
ImageSource::OciImage {
|
||||||
registry,
|
registry,
|
||||||
@@ -184,20 +190,20 @@ pub fn download_image(
|
|||||||
architecture,
|
architecture,
|
||||||
} => {
|
} => {
|
||||||
log::debug!("Pulling OCI image: {}/{}:{}", registry, repository, tag);
|
log::debug!("Pulling OCI image: {}/{}:{}", registry, repository, tag);
|
||||||
rt.block_on(download_oci_image_async(
|
Ok(rt.block_on(download_oci_image_async(
|
||||||
registry,
|
registry,
|
||||||
repository,
|
repository,
|
||||||
tag,
|
tag,
|
||||||
architecture,
|
architecture,
|
||||||
dest,
|
dest,
|
||||||
progress,
|
progress,
|
||||||
))
|
))?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the sha256 the distro publishes for the tarball at `tarball_url`.
|
/// Fetch the sha256 the distro publishes for the tarball at `tarball_url`.
|
||||||
async fn fetch_published_checksum(distro: &Distro, tarball_url: &str) -> Result<String> {
|
async fn fetch_published_checksum(distro: &Distro, tarball_url: &str) -> anyhow::Result<String> {
|
||||||
let Some(checksum_url) = crate::distro::checksum_artifact_url(distro, tarball_url) else {
|
let Some(checksum_url) = crate::distro::checksum_artifact_url(distro, tarball_url) else {
|
||||||
return Err(anyhow!("no checksum artifact for {}", tarball_url));
|
return Err(anyhow!("no checksum artifact for {}", tarball_url));
|
||||||
};
|
};
|
||||||
@@ -252,7 +258,7 @@ async fn download_file_async(
|
|||||||
dest: &Path,
|
dest: &Path,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
expected: Option<&str>,
|
expected: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
log::debug!("Downloading: {}", url);
|
log::debug!("Downloading: {}", url);
|
||||||
|
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
@@ -292,7 +298,7 @@ async fn download_file_async(
|
|||||||
// reaches the cache; on any failure after the temp file is created we
|
// reaches the cache; on any failure after the temp file is created we
|
||||||
// remove it so stale .partial files don't accumulate.
|
// remove it so stale .partial files don't accumulate.
|
||||||
let mut hasher = DownloadHasher::new();
|
let mut hasher = DownloadHasher::new();
|
||||||
let result: Result<()> = async {
|
let result: anyhow::Result<()> = async {
|
||||||
let mut file = tokio::fs::File::create(&temp_dest)
|
let mut file = tokio::fs::File::create(&temp_dest)
|
||||||
.await
|
.await
|
||||||
.context("Failed to create temporary file")?;
|
.context("Failed to create temporary file")?;
|
||||||
@@ -335,7 +341,7 @@ async fn download_oci_image_async(
|
|||||||
arch: &str,
|
arch: &str,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(300))
|
.timeout(Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
@@ -361,7 +367,7 @@ async fn try_download_oci_image(
|
|||||||
arch: &str,
|
arch: &str,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
// Get authentication token for the registry
|
// Get authentication token for the registry
|
||||||
let token = get_auth_token(client, registry, repository).await?;
|
let token = get_auth_token(client, registry, repository).await?;
|
||||||
|
|
||||||
@@ -560,7 +566,7 @@ async fn try_download_oci_image(
|
|||||||
// using the tar + flate2 crates — no external `tar` binary required.
|
// using the tar + flate2 crates — no external `tar` binary required.
|
||||||
let temp_dest = dest.with_extension("tar.partial");
|
let temp_dest = dest.with_extension("tar.partial");
|
||||||
|
|
||||||
let bundle_result: Result<()> = (|| {
|
let bundle_result: anyhow::Result<()> = (|| {
|
||||||
use flate2::{write::GzEncoder, Compression};
|
use flate2::{write::GzEncoder, Compression};
|
||||||
|
|
||||||
let out_file =
|
let out_file =
|
||||||
@@ -597,7 +603,11 @@ async fn try_download_oci_image(
|
|||||||
Ok(tag_digest)
|
Ok(tag_digest)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_auth_token(client: &Client, registry: &str, repository: &str) -> Result<String> {
|
async fn get_auth_token(
|
||||||
|
client: &Client,
|
||||||
|
registry: &str,
|
||||||
|
repository: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
// Docker Hub has a well-known, stable auth endpoint.
|
// Docker Hub has a well-known, stable auth endpoint.
|
||||||
if registry == "docker.io" {
|
if registry == "docker.io" {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
@@ -697,7 +707,7 @@ fn parse_www_authenticate(header: &str) -> Option<(String, String)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a bearer token from a fully-constructed token URL.
|
/// Fetch a bearer token from a fully-constructed token URL.
|
||||||
async fn fetch_bearer_token(client: &Client, token_url: &str) -> Result<String> {
|
async fn fetch_bearer_token(client: &Client, token_url: &str) -> anyhow::Result<String> {
|
||||||
let response = client
|
let response = client
|
||||||
.get(token_url)
|
.get(token_url)
|
||||||
.send()
|
.send()
|
||||||
@@ -728,7 +738,7 @@ async fn download_blob_with_auth(
|
|||||||
label: &str,
|
label: &str,
|
||||||
expected_digest: &str,
|
expected_digest: &str,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut request = client.get(url);
|
let mut request = client.get(url);
|
||||||
if !token.is_empty() {
|
if !token.is_empty() {
|
||||||
request = request.header("Authorization", format!("Bearer {}", token));
|
request = request.header("Authorization", format!("Bearer {}", token));
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! Typed error for the public library API.
|
||||||
|
//!
|
||||||
|
//! Public functions return [`Result`] carrying [`Error`] so consumers can
|
||||||
|
//! match on the failure modes that call for different handling — missing
|
||||||
|
//! host prerequisites, an architecture the registry does not serve — versus
|
||||||
|
//! ordinary failures. Everything without a semantic variant surfaces as
|
||||||
|
//! [`Error::Other`], which preserves the full anyhow context chain.
|
||||||
|
|
||||||
|
/// Errors returned by the ecr library API.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// Unprivileged user namespaces are disabled on this host. The
|
||||||
|
/// remediation names the sysctl to set (the condition has two causes:
|
||||||
|
/// `kernel.unprivileged_userns_clone=0` and `user.max_user_namespaces=0`).
|
||||||
|
#[error("user namespaces not available\n\n{remediation}")]
|
||||||
|
UserNamespacesUnavailable { remediation: String },
|
||||||
|
|
||||||
|
/// binfmt_misc is not registered for the target architecture, so
|
||||||
|
/// foreign-architecture binaries cannot run without QEMU user emulation.
|
||||||
|
#[error(
|
||||||
|
"binfmt_misc not registered for {arch}\n\n\
|
||||||
|
Install QEMU user emulation:\n\
|
||||||
|
\x20 Ubuntu/Debian: sudo apt install qemu-user-static\n\
|
||||||
|
\x20 Arch: sudo pacman -S qemu-user-static-binfmt\n\
|
||||||
|
\x20 Alpine: sudo apk add qemu-user-static"
|
||||||
|
)]
|
||||||
|
BinfmtNotRegistered { arch: String },
|
||||||
|
|
||||||
|
/// The registry publishes no linux manifest for the requested
|
||||||
|
/// architecture; `available` lists the architectures it does serve.
|
||||||
|
#[error("No manifest found for architecture '{arch}'. Available: {available}")]
|
||||||
|
ArchNotAvailable { arch: String, available: String },
|
||||||
|
|
||||||
|
/// /etc/subuid or /etc/subgid has no usable entry for this user (either
|
||||||
|
/// absent or with fewer than two mappable ids).
|
||||||
|
#[error(
|
||||||
|
"{0}\n\n\
|
||||||
|
Add a matching entry to /etc/subuid and /etc/subgid, e.g.:\n\
|
||||||
|
\x20 <user>:100000:65536"
|
||||||
|
)]
|
||||||
|
MissingSubidRange(String),
|
||||||
|
|
||||||
|
/// Any other failure, preserving the full context chain. Errors raised
|
||||||
|
/// inside the namespace child (mounts, resolv.conf, chroot exec) also
|
||||||
|
/// surface here: they cross the process boundary as formatted chains,
|
||||||
|
/// losing their variant.
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result alias for the public library API: `Result<T>` carries [`Error`].
|
||||||
|
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variants_render_remediation() {
|
||||||
|
let e = Error::UserNamespacesUnavailable {
|
||||||
|
remediation: "sysctl -w kernel.unprivileged_userns_clone=1".to_string(),
|
||||||
|
};
|
||||||
|
let msg = e.to_string();
|
||||||
|
assert!(msg.contains("user namespaces not available"));
|
||||||
|
assert!(msg.contains("sysctl -w"));
|
||||||
|
|
||||||
|
let e = Error::BinfmtNotRegistered {
|
||||||
|
arch: "riscv64".to_string(),
|
||||||
|
};
|
||||||
|
let msg = e.to_string();
|
||||||
|
assert!(msg.contains("binfmt_misc not registered for riscv64"));
|
||||||
|
assert!(msg.contains("qemu-user-static"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_preserves_chain_through_sources() {
|
||||||
|
let inner = anyhow::Error::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"missing file",
|
||||||
|
))
|
||||||
|
.context("extract failed");
|
||||||
|
let e = Error::Other(inner);
|
||||||
|
// Walk the source chain: the io error must remain reachable for
|
||||||
|
// error-report crates, even though Display shows only the top.
|
||||||
|
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&e);
|
||||||
|
let mut found = false;
|
||||||
|
while let Some(err) = source {
|
||||||
|
if err.to_string().contains("missing file") {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
source = err.source();
|
||||||
|
}
|
||||||
|
assert!(found, "chain root lost through Error::Other");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,14 @@ use std::collections::HashSet;
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Context;
|
||||||
|
|
||||||
use crate::chroot;
|
use crate::chroot;
|
||||||
use crate::mount::{self, BindTarget};
|
use crate::mount::{self, BindTarget};
|
||||||
use crate::namespace;
|
use crate::namespace;
|
||||||
use crate::qemu;
|
use crate::qemu;
|
||||||
use crate::utils::{self, Arch};
|
use crate::utils::{self, Arch};
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
/// Options for [`exec`].
|
/// Options for [`exec`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
+23
-13
@@ -1,5 +1,7 @@
|
|||||||
use crate::progress::{Progress, Unit};
|
use crate::progress::{Progress, Unit};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufReader, Read};
|
use std::io::{BufReader, Read};
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -15,15 +17,19 @@ pub fn extract_tarball(tarball: &Path, dest: &Path, progress: &dyn Progress) ->
|
|||||||
// reading the entire compressed archive twice for large OCI images).
|
// reading the entire compressed archive twice for large OCI images).
|
||||||
if filename.starts_with("oci-") {
|
if filename.starts_with("oci-") {
|
||||||
log::debug!("Detected multi-layer OCI image, extracting layers...");
|
log::debug!("Detected multi-layer OCI image, extracting layers...");
|
||||||
return extract_multi_layer_oci(tarball, dest, progress);
|
return Ok(extract_multi_layer_oci(tarball, dest, progress)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
extract_single_archive(tarball, dest, progress)
|
Ok(extract_single_archive(tarball, dest, progress)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a plain rootfs tarball, detecting the compression format from
|
/// Extract a plain rootfs tarball, detecting the compression format from
|
||||||
/// the filename with a magic-byte fallback.
|
/// the filename with a magic-byte fallback.
|
||||||
fn extract_single_archive(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> {
|
fn extract_single_archive(
|
||||||
|
tarball: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
progress: &dyn Progress,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
|
||||||
let file = File::open(tarball)
|
let file = File::open(tarball)
|
||||||
@@ -79,7 +85,11 @@ fn extract_single_archive(tarball: &Path, dest: &Path, progress: &dyn Progress)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a multi-layer OCI image
|
/// Extract a multi-layer OCI image
|
||||||
fn extract_multi_layer_oci(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> {
|
fn extract_multi_layer_oci(
|
||||||
|
tarball: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
progress: &dyn Progress,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
|
||||||
// Extract the outer OCI bundle (layers.manifest + layer_N.tar.gz files)
|
// Extract the outer OCI bundle (layers.manifest + layer_N.tar.gz files)
|
||||||
@@ -148,7 +158,7 @@ fn extract_oci_layer(
|
|||||||
dest: &Path,
|
dest: &Path,
|
||||||
fixups: &mut DeferredFixups,
|
fixups: &mut DeferredFixups,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
let layer_name = layer_path
|
let layer_name = layer_path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -196,7 +206,7 @@ fn extract_gz<R: std::io::Read>(
|
|||||||
dest: &Path,
|
dest: &Path,
|
||||||
fixups: &mut DeferredFixups,
|
fixups: &mut DeferredFixups,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let gz_decoder = flate2::read::GzDecoder::new(reader);
|
let gz_decoder = flate2::read::GzDecoder::new(reader);
|
||||||
let archive = tar::Archive::new(gz_decoder);
|
let archive = tar::Archive::new(gz_decoder);
|
||||||
|
|
||||||
@@ -210,7 +220,7 @@ fn extract_xz<R: std::io::Read>(
|
|||||||
dest: &Path,
|
dest: &Path,
|
||||||
fixups: &mut DeferredFixups,
|
fixups: &mut DeferredFixups,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let xz_decoder = xz2::read::XzDecoder::new(reader);
|
let xz_decoder = xz2::read::XzDecoder::new(reader);
|
||||||
let archive = tar::Archive::new(xz_decoder);
|
let archive = tar::Archive::new(xz_decoder);
|
||||||
|
|
||||||
@@ -224,7 +234,7 @@ fn extract_zst<R: std::io::Read>(
|
|||||||
dest: &Path,
|
dest: &Path,
|
||||||
fixups: &mut DeferredFixups,
|
fixups: &mut DeferredFixups,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let zst_decoder = zstd::Decoder::new(reader)?;
|
let zst_decoder = zstd::Decoder::new(reader)?;
|
||||||
let archive = tar::Archive::new(zst_decoder);
|
let archive = tar::Archive::new(zst_decoder);
|
||||||
|
|
||||||
@@ -238,7 +248,7 @@ fn extract_tar<R: std::io::Read>(
|
|||||||
dest: &Path,
|
dest: &Path,
|
||||||
fixups: &mut DeferredFixups,
|
fixups: &mut DeferredFixups,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let archive = tar::Archive::new(reader);
|
let archive = tar::Archive::new(reader);
|
||||||
|
|
||||||
extract_archive(archive, dest, "Extracting tar archive", fixups, progress)?;
|
extract_archive(archive, dest, "Extracting tar archive", fixups, progress)?;
|
||||||
@@ -295,7 +305,7 @@ fn normalize_within(dest: &Path, target: &Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply hard links and directory modes collected during extraction.
|
/// Apply hard links and directory modes collected during extraction.
|
||||||
fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
|
fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> anyhow::Result<()> {
|
||||||
for (dst, target) in fixups.hard_links.drain(..) {
|
for (dst, target) in fixups.hard_links.drain(..) {
|
||||||
apply_hard_link(dest, &dst, &target)?;
|
apply_hard_link(dest, &dst, &target)?;
|
||||||
}
|
}
|
||||||
@@ -311,7 +321,7 @@ fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
|
|||||||
/// exists on disk; otherwise the entry is preserved verbatim as a symlink —
|
/// exists on disk; otherwise the entry is preserved verbatim as a symlink —
|
||||||
/// some images (Fedora's ca-trust directory-hash) ship links whose targets
|
/// some images (Fedora's ca-trust directory-hash) ship links whose targets
|
||||||
/// are generated after linking and never exist anywhere in the archive.
|
/// are generated after linking and never exist anywhere in the archive.
|
||||||
fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> Result<()> {
|
fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> anyhow::Result<()> {
|
||||||
if dst.symlink_metadata().is_ok() {
|
if dst.symlink_metadata().is_ok() {
|
||||||
std::fs::remove_file(dst)
|
std::fs::remove_file(dst)
|
||||||
.with_context(|| format!("Failed to replace {}", dst.display()))?;
|
.with_context(|| format!("Failed to replace {}", dst.display()))?;
|
||||||
@@ -339,7 +349,7 @@ fn extract_archive<R: std::io::Read>(
|
|||||||
label: &str,
|
label: &str,
|
||||||
fixups: &mut DeferredFixups,
|
fixups: &mut DeferredFixups,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
progress.begin(label, Unit::Files, None);
|
progress.begin(label, Unit::Files, None);
|
||||||
|
|
||||||
archive.set_preserve_permissions(true);
|
archive.set_preserve_permissions(true);
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
//! only ELF, uImage and raw images.
|
//! only ELF, uImage and raw images.
|
||||||
|
|
||||||
use crate::progress::{Progress, Unit};
|
use crate::progress::{Progress, Unit};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use std::io::{Read, Seek};
|
use std::io::{Read, Seek};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -42,7 +44,7 @@ fn alpine_kernel_arch(arch: &str) -> &'static str {
|
|||||||
|
|
||||||
/// Get the Alpine version branch for kernel downloads
|
/// Get the Alpine version branch for kernel downloads
|
||||||
/// We use the latest stable branch
|
/// We use the latest stable branch
|
||||||
fn get_alpine_branch() -> Result<String> {
|
fn get_alpine_branch() -> anyhow::Result<String> {
|
||||||
// Fetch the latest-stable branch from Alpine CDN
|
// Fetch the latest-stable branch from Alpine CDN
|
||||||
// The URL redirects to the current stable version
|
// The URL redirects to the current stable version
|
||||||
let client = reqwest::blocking::Client::builder()
|
let client = reqwest::blocking::Client::builder()
|
||||||
@@ -71,7 +73,7 @@ fn get_alpine_branch() -> Result<String> {
|
|||||||
fetch_alpine_branch_from_yaml()
|
fetch_alpine_branch_from_yaml()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_alpine_branch_from_yaml() -> Result<String> {
|
fn fetch_alpine_branch_from_yaml() -> anyhow::Result<String> {
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct AlpineRelease {
|
struct AlpineRelease {
|
||||||
version: Option<String>,
|
version: Option<String>,
|
||||||
@@ -104,7 +106,7 @@ fn fetch_alpine_branch_from_yaml() -> Result<String> {
|
|||||||
/// package index. Returns the package name and version. The index is
|
/// package index. Returns the package name and version. The index is
|
||||||
/// scanned once; whichever candidate flavor appears with the highest
|
/// scanned once; whichever candidate flavor appears with the highest
|
||||||
/// preference (lowest index) wins.
|
/// preference (lowest index) wins.
|
||||||
fn find_kernel_package(branch: &str, arch: &str) -> Result<(&'static str, String)> {
|
fn find_kernel_package(branch: &str, arch: &str) -> anyhow::Result<(&'static str, String)> {
|
||||||
// Alpine package index URL
|
// Alpine package index URL
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz",
|
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz",
|
||||||
@@ -241,7 +243,7 @@ fn download_alpine_kernel(
|
|||||||
arch: &str,
|
arch: &str,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let (package, version) = find_kernel_package(branch, arch)?;
|
let (package, version) = find_kernel_package(branch, arch)?;
|
||||||
log::debug!("Found {} version: {}", package, version);
|
log::debug!("Found {} version: {}", package, version);
|
||||||
|
|
||||||
@@ -268,7 +270,11 @@ fn download_alpine_kernel(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_kernel_async(url: &str, dest: &Path, progress: &dyn Progress) -> Result<()> {
|
async fn download_kernel_async(
|
||||||
|
url: &str,
|
||||||
|
dest: &Path,
|
||||||
|
progress: &dyn Progress,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300))
|
.timeout(Duration::from_secs(300))
|
||||||
@@ -327,7 +333,7 @@ async fn download_kernel_async(url: &str, dest: &Path, progress: &dyn Progress)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the kernel image from an Alpine APK file
|
/// Extract the kernel image from an Alpine APK file
|
||||||
fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> Result<()> {
|
fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> anyhow::Result<()> {
|
||||||
// The kernel binary is named after the flavor: boot/vmlinuz-virt for
|
// The kernel binary is named after the flavor: boot/vmlinuz-virt for
|
||||||
// linux-virt, boot/vmlinuz-lts for linux-lts
|
// linux-virt, boot/vmlinuz-lts for linux-lts
|
||||||
let flavor = package
|
let flavor = package
|
||||||
@@ -366,7 +372,7 @@ fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> Resul
|
|||||||
/// loader understands only ELF, uImage and raw images — a gzipped Image
|
/// loader understands only ELF, uImage and raw images — a gzipped Image
|
||||||
/// is loaded verbatim and never boots. Uncompressed formats (x86 bzImage,
|
/// is loaded verbatim and never boots. Uncompressed formats (x86 bzImage,
|
||||||
/// ppc64le ELF, raw Image) are left untouched.
|
/// ppc64le ELF, raw Image) are left untouched.
|
||||||
fn decompress_kernel_if_gzipped(path: &Path) -> Result<()> {
|
fn decompress_kernel_if_gzipped(path: &Path) -> anyhow::Result<()> {
|
||||||
let mut file =
|
let mut file =
|
||||||
std::fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
std::fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
//! The main entry points are [`rootfs`] (cache-aware rootfs preparation and
|
//! The main entry points are [`rootfs`] (cache-aware rootfs preparation and
|
||||||
//! persistence) and [`exec`] (running a command inside a prepared rootfs
|
//! persistence) and [`exec`] (running a command inside a prepared rootfs
|
||||||
//! with a caller-composed environment, explicit bind targets and a target
|
//! with a caller-composed environment, explicit bind targets and a target
|
||||||
//! architecture).
|
//! architecture). Public functions return [`Result`] carrying the [`Error`]
|
||||||
|
//! enum, so consumers can match on host-readiness failures (`Error`
|
||||||
|
//! variants) versus ordinary failures ([`Error::Other`], which preserves the
|
||||||
|
//! full context chain).
|
||||||
|
|
||||||
pub mod chroot;
|
pub mod chroot;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod distro;
|
pub mod distro;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
|
pub mod error;
|
||||||
pub mod exec;
|
pub mod exec;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
pub mod kernel;
|
pub mod kernel;
|
||||||
@@ -26,6 +30,7 @@ pub mod qemu_vm;
|
|||||||
pub mod rootfs;
|
pub mod rootfs;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
pub use error::{Error, Result};
|
||||||
pub use exec::{exec, ExecOptions};
|
pub use exec::{exec, ExecOptions};
|
||||||
pub use mount::BindTarget;
|
pub use mount::BindTarget;
|
||||||
pub use progress::{NoProgress, Progress, Unit};
|
pub use progress::{NoProgress, Progress, Unit};
|
||||||
|
|||||||
+12
-9
@@ -1,4 +1,6 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use nix::mount::{mount, MsFlags};
|
use nix::mount::{mount, MsFlags};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -10,7 +12,7 @@ use tempfile::TempDir;
|
|||||||
/// path would silently split the option string at the wrong boundary and
|
/// path would silently split the option string at the wrong boundary and
|
||||||
/// produce a cryptic kernel error; a bare backslash would be mis-interpreted
|
/// produce a cryptic kernel error; a bare backslash would be mis-interpreted
|
||||||
/// as starting an escape sequence.
|
/// as starting an escape sequence.
|
||||||
fn escape_overlay_path(path: &Path) -> Result<String> {
|
fn escape_overlay_path(path: &Path) -> anyhow::Result<String> {
|
||||||
let s = path.to_str().ok_or_else(|| {
|
let s = path.to_str().ok_or_else(|| {
|
||||||
anyhow!(
|
anyhow!(
|
||||||
"Overlay path '{}' contains non-UTF-8 characters",
|
"Overlay path '{}' contains non-UTF-8 characters",
|
||||||
@@ -48,7 +50,8 @@ pub fn in_rootfs(rootfs: &Path, target: &Path) -> Result<std::path::PathBuf> {
|
|||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Path '{}' escapes the rootfs: '..' components are not allowed",
|
"Path '{}' escapes the rootfs: '..' components are not allowed",
|
||||||
target.display()
|
target.display()
|
||||||
));
|
)
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
Ok(rootfs.join(relative))
|
Ok(rootfs.join(relative))
|
||||||
}
|
}
|
||||||
@@ -96,7 +99,7 @@ pub fn setup_mounts(rootfs: &Path, binds: &[BindTarget]) -> Result<Vec<TempDir>>
|
|||||||
Ok(overlay_temps)
|
Ok(overlay_temps)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mount_proc(rootfs: &Path) -> Result<()> {
|
fn mount_proc(rootfs: &Path) -> anyhow::Result<()> {
|
||||||
let proc_path = rootfs.join("proc");
|
let proc_path = rootfs.join("proc");
|
||||||
std::fs::create_dir_all(&proc_path)?;
|
std::fs::create_dir_all(&proc_path)?;
|
||||||
|
|
||||||
@@ -108,7 +111,7 @@ fn mount_proc(rootfs: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mount_sys(rootfs: &Path) -> Result<()> {
|
fn mount_sys(rootfs: &Path) -> anyhow::Result<()> {
|
||||||
let sys_path = rootfs.join("sys");
|
let sys_path = rootfs.join("sys");
|
||||||
std::fs::create_dir_all(&sys_path)?;
|
std::fs::create_dir_all(&sys_path)?;
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ fn mount_sys(rootfs: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mount_dev(rootfs: &Path) -> Result<()> {
|
fn mount_dev(rootfs: &Path) -> anyhow::Result<()> {
|
||||||
let dev_path = rootfs.join("dev");
|
let dev_path = rootfs.join("dev");
|
||||||
std::fs::create_dir_all(&dev_path)?;
|
std::fs::create_dir_all(&dev_path)?;
|
||||||
|
|
||||||
@@ -164,7 +167,7 @@ fn mount_dev(rootfs: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mount_devpts(rootfs: &Path) -> Result<()> {
|
fn mount_devpts(rootfs: &Path) -> anyhow::Result<()> {
|
||||||
let devpts_path = rootfs.join("dev/pts");
|
let devpts_path = rootfs.join("dev/pts");
|
||||||
std::fs::create_dir_all(&devpts_path)?;
|
std::fs::create_dir_all(&devpts_path)?;
|
||||||
|
|
||||||
@@ -184,7 +187,7 @@ fn mount_devpts(rootfs: &Path) -> Result<()> {
|
|||||||
|
|
||||||
/// Setup overlay mount for a workspace directory
|
/// Setup overlay mount for a workspace directory
|
||||||
/// Returns a TempDir that must be kept alive for the overlay to work
|
/// Returns a TempDir that must be kept alive for the overlay to work
|
||||||
fn setup_overlay(source: &Path, mount_point: &Path) -> Result<TempDir> {
|
fn setup_overlay(source: &Path, mount_point: &Path) -> anyhow::Result<TempDir> {
|
||||||
std::fs::create_dir_all(mount_point)?;
|
std::fs::create_dir_all(mount_point)?;
|
||||||
|
|
||||||
// Create temp directories for overlay
|
// Create temp directories for overlay
|
||||||
@@ -220,7 +223,7 @@ fn setup_overlay(source: &Path, mount_point: &Path) -> Result<TempDir> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Setup read-write bind mount
|
/// Setup read-write bind mount
|
||||||
fn setup_bind_rw(source: &Path, mount_point: &Path) -> Result<()> {
|
fn setup_bind_rw(source: &Path, mount_point: &Path) -> anyhow::Result<()> {
|
||||||
std::fs::create_dir_all(mount_point)?;
|
std::fs::create_dir_all(mount_point)?;
|
||||||
|
|
||||||
let source = source.canonicalize()?;
|
let source = source.canonicalize()?;
|
||||||
|
|||||||
+46
-54
@@ -1,7 +1,8 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use crate::{Error, Result};
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
use nix::sched::{clone, CloneFlags};
|
use nix::sched::{clone, CloneFlags};
|
||||||
use nix::sys::signal::Signal;
|
use nix::sys::signal::Signal;
|
||||||
use nix::unistd::{getgid, getuid, Pid};
|
use nix::unistd::{getgid, getuid, sethostname, Pid};
|
||||||
|
|
||||||
/// RAII wrapper that closes a raw file descriptor on drop.
|
/// RAII wrapper that closes a raw file descriptor on drop.
|
||||||
/// Guarantees all pipe fds are closed on every return path, including
|
/// Guarantees all pipe fds are closed on every return path, including
|
||||||
@@ -33,12 +34,12 @@ pub fn check_user_namespace() -> Result<()> {
|
|||||||
// Check kernel.unprivileged_userns_clone on systems that have it
|
// Check kernel.unprivileged_userns_clone on systems that have it
|
||||||
if let Ok(content) = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") {
|
if let Ok(content) = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") {
|
||||||
if content.trim() == "0" {
|
if content.trim() == "0" {
|
||||||
return Err(anyhow!(
|
return Err(Error::UserNamespacesUnavailable {
|
||||||
"User namespaces not available\n\n\
|
remediation: "Enable with:\n \
|
||||||
Enable with:\n\
|
sysctl -w kernel.unprivileged_userns_clone=1\n\n\
|
||||||
sysctl -w kernel.unprivileged_userns_clone=1\n\n\
|
Or check AppArmor profile restrictions."
|
||||||
Or check AppArmor profile restrictions."
|
.to_string(),
|
||||||
));
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +47,11 @@ pub fn check_user_namespace() -> Result<()> {
|
|||||||
if let Ok(content) = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") {
|
if let Ok(content) = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") {
|
||||||
if let Ok(max) = content.trim().parse::<u32>() {
|
if let Ok(max) = content.trim().parse::<u32>() {
|
||||||
if max == 0 {
|
if max == 0 {
|
||||||
return Err(anyhow!(
|
return Err(Error::UserNamespacesUnavailable {
|
||||||
"User namespaces not available\n\n\
|
remediation: "Enable with:\n \
|
||||||
Enable with:\n\
|
sysctl -w user.max_user_namespaces=10000"
|
||||||
sysctl -w user.max_user_namespaces=10000"
|
.to_string(),
|
||||||
));
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,14 +80,14 @@ where
|
|||||||
let mut fds: [i32; 2] = [-1, -1];
|
let mut fds: [i32; 2] = [-1, -1];
|
||||||
|
|
||||||
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
||||||
return Err(anyhow!("Failed to create sync pipe"));
|
return Err(anyhow!("Failed to create sync pipe").into());
|
||||||
}
|
}
|
||||||
parent_read = AutoCloseFd(fds[0]);
|
parent_read = AutoCloseFd(fds[0]);
|
||||||
parent_write = AutoCloseFd(fds[1]);
|
parent_write = AutoCloseFd(fds[1]);
|
||||||
// parent_read/write auto-closed if subsequent pipes fail ↑
|
// parent_read/write auto-closed if subsequent pipes fail ↑
|
||||||
|
|
||||||
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
||||||
return Err(anyhow!("Failed to create done pipe"));
|
return Err(anyhow!("Failed to create done pipe").into());
|
||||||
}
|
}
|
||||||
child_read = AutoCloseFd(fds[0]);
|
child_read = AutoCloseFd(fds[0]);
|
||||||
child_write = AutoCloseFd(fds[1]);
|
child_write = AutoCloseFd(fds[1]);
|
||||||
@@ -97,7 +98,7 @@ where
|
|||||||
libc::fcntl(child_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC);
|
libc::fcntl(child_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC);
|
||||||
|
|
||||||
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
||||||
return Err(anyhow!("Failed to create error pipe"));
|
return Err(anyhow!("Failed to create error pipe").into());
|
||||||
}
|
}
|
||||||
error_read = AutoCloseFd(fds[0]);
|
error_read = AutoCloseFd(fds[0]);
|
||||||
error_write = AutoCloseFd(fds[1]);
|
error_write = AutoCloseFd(fds[1]);
|
||||||
@@ -141,15 +142,20 @@ where
|
|||||||
let result = if let Some(func) = f.take() {
|
let result = if let Some(func) = f.take() {
|
||||||
func()
|
func()
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Function already called"))
|
Err(anyhow!("Function already called").into())
|
||||||
};
|
};
|
||||||
|
|
||||||
// On failure, write the full error chain to the error pipe
|
// On failure, write the full error chain to the error pipe
|
||||||
// before signalling done, so the parent can reconstruct it.
|
// before signalling done, so the parent can reconstruct it.
|
||||||
if let Err(ref e) = result {
|
// Wrapping in anyhow makes `{:#}` print the whole chain,
|
||||||
let msg = format!("{:#}", e);
|
// not just the top message.
|
||||||
|
let failure = match result {
|
||||||
|
Ok(()) => None,
|
||||||
|
Err(e) => Some(format!("{:#}", anyhow::Error::from(e))),
|
||||||
|
};
|
||||||
|
if let Some(msg) = &failure {
|
||||||
let bytes = msg.as_bytes();
|
let bytes = msg.as_bytes();
|
||||||
libc::write(ew, bytes.as_ptr() as *const libc::c_void, bytes.len());
|
libc::write(ew, bytes.as_ptr() as *mut libc::c_void, bytes.len());
|
||||||
}
|
}
|
||||||
libc::close(ew);
|
libc::close(ew);
|
||||||
|
|
||||||
@@ -157,7 +163,7 @@ where
|
|||||||
libc::write(cw, c"done".as_ptr() as *const libc::c_void, 4);
|
libc::write(cw, c"done".as_ptr() as *const libc::c_void, 4);
|
||||||
libc::close(cw);
|
libc::close(cw);
|
||||||
|
|
||||||
if result.is_ok() {
|
if failure.is_none() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
@@ -221,7 +227,8 @@ where
|
|||||||
drop(error_read);
|
drop(error_read);
|
||||||
|
|
||||||
// Wait for child process
|
// Wait for child process
|
||||||
let status = nix::sys::wait::waitpid(pid, None)?;
|
let status =
|
||||||
|
nix::sys::wait::waitpid(pid, None).context("Failed to wait for the namespace child")?;
|
||||||
|
|
||||||
match status {
|
match status {
|
||||||
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
|
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
|
||||||
@@ -229,7 +236,7 @@ where
|
|||||||
// If the child reported an error (e.g., setup failure), return it.
|
// If the child reported an error (e.g., setup failure), return it.
|
||||||
// Otherwise, just forward the exit code without an error message.
|
// Otherwise, just forward the exit code without an error message.
|
||||||
if let Some(msg) = child_error {
|
if let Some(msg) = child_error {
|
||||||
Err(anyhow!("{}", msg))
|
Err(anyhow!("{}", msg).into())
|
||||||
} else {
|
} else {
|
||||||
Ok(code)
|
Ok(code)
|
||||||
}
|
}
|
||||||
@@ -253,20 +260,16 @@ fn setup_user_namespace(pid: Pid) -> Result<()> {
|
|||||||
// IDs 1..sub_uid_count-1 to the subordinate range. A count of 0 or 1
|
// IDs 1..sub_uid_count-1 to the subordinate range. A count of 0 or 1
|
||||||
// leaves no subordinate IDs to map and indicates a malformed /etc/subuid.
|
// leaves no subordinate IDs to map and indicates a malformed /etc/subuid.
|
||||||
if sub_uid_count < 2 {
|
if sub_uid_count < 2 {
|
||||||
return Err(anyhow!(
|
return Err(Error::MissingSubidRange(format!(
|
||||||
"subuid count {} for uid {} is too small (need at least 2); \
|
"subuid count {} for uid {} is too small (need at least 2)",
|
||||||
check /etc/subuid",
|
sub_uid_count, uid
|
||||||
sub_uid_count,
|
)));
|
||||||
uid
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if sub_gid_count < 2 {
|
if sub_gid_count < 2 {
|
||||||
return Err(anyhow!(
|
return Err(Error::MissingSubidRange(format!(
|
||||||
"subgid count {} for gid {} is too small (need at least 2); \
|
"subgid count {} for gid {} is too small (need at least 2)",
|
||||||
check /etc/subgid",
|
sub_gid_count, gid
|
||||||
sub_gid_count,
|
)));
|
||||||
gid
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow setgroups so apt and other tools can drop privileges
|
// Allow setgroups so apt and other tools can drop privileges
|
||||||
@@ -292,9 +295,7 @@ fn setup_user_namespace(pid: Pid) -> Result<()> {
|
|||||||
.context("Failed to execute newuidmap")?;
|
.context("Failed to execute newuidmap")?;
|
||||||
|
|
||||||
if !uid_result.success() {
|
if !uid_result.success() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!("newuidmap failed - ensure subuid entry exists in /etc/subuid").into());
|
||||||
"newuidmap failed - ensure subuid entry exists in /etc/subuid"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newgidmap format: newgidmap pid ns_start host_start count ...
|
// newgidmap format: newgidmap pid ns_start host_start count ...
|
||||||
@@ -311,9 +312,7 @@ fn setup_user_namespace(pid: Pid) -> Result<()> {
|
|||||||
.context("Failed to execute newgidmap")?;
|
.context("Failed to execute newgidmap")?;
|
||||||
|
|
||||||
if !gid_result.success() {
|
if !gid_result.success() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!("newgidmap failed - ensure subgid entry exists in /etc/subgid").into());
|
||||||
"newgidmap failed - ensure subgid entry exists in /etc/subgid"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -341,13 +340,11 @@ fn get_subuid_range(uid: nix::unistd::Uid) -> Result<(u32, u32)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(Error::MissingSubidRange(format!(
|
||||||
"No subuid entry found for user {} (uid {}). \
|
"no subuid entry found for user {} (uid {})",
|
||||||
Add one to /etc/subuid, e.g.:\n {}:100000:65536",
|
|
||||||
username.as_deref().unwrap_or("<unknown>"),
|
username.as_deref().unwrap_or("<unknown>"),
|
||||||
uid,
|
uid,
|
||||||
username.as_deref().unwrap_or(&uid.to_string()),
|
)))
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get subordinate GID range for a group from /etc/subgid
|
/// Get subordinate GID range for a group from /etc/subgid
|
||||||
@@ -372,13 +369,11 @@ fn get_subgid_range(gid: nix::unistd::Gid) -> Result<(u32, u32)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(Error::MissingSubidRange(format!(
|
||||||
"No subgid entry found for group {} (gid {}). \
|
"no subgid entry found for group {} (gid {})",
|
||||||
Add one to /etc/subgid, e.g.:\n {}:100000:65536",
|
|
||||||
groupname.as_deref().unwrap_or("<unknown>"),
|
groupname.as_deref().unwrap_or("<unknown>"),
|
||||||
gid,
|
gid,
|
||||||
groupname.as_deref().unwrap_or(&gid.to_string()),
|
)))
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a unique hostname for a chroot run:
|
/// Generate a unique hostname for a chroot run:
|
||||||
@@ -393,10 +388,7 @@ fn generate_hostname(distro: &str) -> String {
|
|||||||
|
|
||||||
/// Set hostname in UTS namespace
|
/// Set hostname in UTS namespace
|
||||||
pub fn set_hostname(distro: &str) -> Result<()> {
|
pub fn set_hostname(distro: &str) -> Result<()> {
|
||||||
use nix::unistd::sethostname;
|
|
||||||
|
|
||||||
let hostname = generate_hostname(distro);
|
let hostname = generate_hostname(distro);
|
||||||
|
|
||||||
sethostname(&hostname).with_context(|| format!("Failed to set hostname to {}", hostname))?;
|
sethostname(&hostname).with_context(|| format!("Failed to set hostname to {}", hostname))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use crate::{Error, Result};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Check if binfmt_misc is registered for the target architecture
|
/// Check if binfmt_misc is registered for the target architecture
|
||||||
@@ -8,14 +8,9 @@ pub fn check_binfmt(arch: &str) -> Result<()> {
|
|||||||
let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch);
|
let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch);
|
||||||
|
|
||||||
if !Path::new(&binfmt_path).exists() {
|
if !Path::new(&binfmt_path).exists() {
|
||||||
return Err(anyhow!(
|
return Err(Error::BinfmtNotRegistered {
|
||||||
"binfmt_misc not registered for {}\n\n\
|
arch: arch.to_string(),
|
||||||
Install QEMU user emulation:\n\
|
});
|
||||||
Ubuntu/Debian: sudo apt install qemu-user-static\n\
|
|
||||||
Arch: sudo pacman -S qemu-user-static-binfmt\n\
|
|
||||||
Alpine: sudo apk add qemu-user-static",
|
|
||||||
arch
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("QEMU binfmt_misc registered for {}", arch);
|
log::debug!("QEMU binfmt_misc registered for {}", arch);
|
||||||
|
|||||||
+11
-14
@@ -1,5 +1,7 @@
|
|||||||
use crate::progress::{Progress, Unit};
|
use crate::progress::{Progress, Unit};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use cpio::{newc, NewcBuilder};
|
use cpio::{newc, NewcBuilder};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
@@ -28,18 +30,12 @@ pub struct QemuConfig {
|
|||||||
pub fn launch_qemu(config: QemuConfig, progress: &dyn Progress) -> Result<()> {
|
pub fn launch_qemu(config: QemuConfig, progress: &dyn Progress) -> Result<()> {
|
||||||
// Check that kernel exists
|
// Check that kernel exists
|
||||||
if !config.kernel_path.exists() {
|
if !config.kernel_path.exists() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!("Kernel not found: {}", config.kernel_path.display()).into());
|
||||||
"Kernel not found: {}",
|
|
||||||
config.kernel_path.display()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that rootfs exists
|
// Check that rootfs exists
|
||||||
if !config.rootfs_path.exists() {
|
if !config.rootfs_path.exists() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!("Rootfs not found: {}", config.rootfs_path.display()).into());
|
||||||
"Rootfs not found: {}",
|
|
||||||
config.rootfs_path.display()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate memory string format
|
// Validate memory string format
|
||||||
@@ -171,7 +167,8 @@ pub fn launch_qemu(config: QemuConfig, progress: &dyn Progress) -> Result<()> {
|
|||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"QEMU exited with non-zero status: {}",
|
"QEMU exited with non-zero status: {}",
|
||||||
status.code().unwrap_or(-1)
|
status.code().unwrap_or(-1)
|
||||||
));
|
)
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -268,7 +265,7 @@ fn check_kvm_capabilities() -> bool {
|
|||||||
/// Create an uncompressed cpio initramfs from a directory.
|
/// Create an uncompressed cpio initramfs from a directory.
|
||||||
/// Entries are streamed straight to disk so large rootfs images never have
|
/// Entries are streamed straight to disk so large rootfs images never have
|
||||||
/// to fit in memory as a whole archive.
|
/// to fit in memory as a whole archive.
|
||||||
fn create_initramfs(rootfs: &Path, progress: &dyn Progress) -> Result<PathBuf> {
|
fn create_initramfs(rootfs: &Path, progress: &dyn Progress) -> anyhow::Result<PathBuf> {
|
||||||
// Create a temporary file for the initramfs (uncompressed cpio)
|
// Create a temporary file for the initramfs (uncompressed cpio)
|
||||||
// Use a temp file in the same directory as rootfs, or fall back to /tmp
|
// Use a temp file in the same directory as rootfs, or fall back to /tmp
|
||||||
let initramfs_path = rootfs
|
let initramfs_path = rootfs
|
||||||
@@ -314,7 +311,7 @@ fn write_cpio_archive<W: Write>(
|
|||||||
rootfs: &Path,
|
rootfs: &Path,
|
||||||
writer: &mut W,
|
writer: &mut W,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<u64> {
|
) -> anyhow::Result<u64> {
|
||||||
// Track hard links by (device, inode): the value is the synthetic cpio
|
// Track hard links by (device, inode): the value is the synthetic cpio
|
||||||
// inode assigned to the first occurrence. The Linux initramfs loader
|
// inode assigned to the first occurrence. The Linux initramfs loader
|
||||||
// turns later zero-size entries sharing that inode into hard links.
|
// turns later zero-size entries sharing that inode into hard links.
|
||||||
@@ -460,7 +457,7 @@ fn write_cpio_entry<W: Write>(
|
|||||||
nlink: u32,
|
nlink: u32,
|
||||||
ino: u32,
|
ino: u32,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
) -> Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let builder = NewcBuilder::new(name)
|
let builder = NewcBuilder::new(name)
|
||||||
.mode(mode)
|
.mode(mode)
|
||||||
.uid(0)
|
.uid(0)
|
||||||
@@ -494,7 +491,7 @@ fn write_dir_entries(
|
|||||||
next_ino: &mut u32,
|
next_ino: &mut u32,
|
||||||
written_names: &mut std::collections::HashSet<String>,
|
written_names: &mut std::collections::HashSet<String>,
|
||||||
total_data: &mut u64,
|
total_data: &mut u64,
|
||||||
) -> Result<u64> {
|
) -> anyhow::Result<u64> {
|
||||||
// Read directory entries
|
// Read directory entries
|
||||||
let dir_entries: Vec<_> = match std::fs::read_dir(current) {
|
let dir_entries: Vec<_> = match std::fs::read_dir(current) {
|
||||||
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
|
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
|
||||||
|
|||||||
+12
-10
@@ -38,7 +38,9 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
use crate::distro::{
|
use crate::distro::{
|
||||||
@@ -75,7 +77,7 @@ impl RootfsCache {
|
|||||||
pub fn default_dir() -> Result<PathBuf> {
|
pub fn default_dir() -> Result<PathBuf> {
|
||||||
dirs::cache_dir()
|
dirs::cache_dir()
|
||||||
.map(|p| p.join("ecr"))
|
.map(|p| p.join("ecr"))
|
||||||
.ok_or_else(|| anyhow!("Could not determine cache directory"))
|
.ok_or_else(|| anyhow!("Could not determine cache directory").into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dir(&self) -> &Path {
|
pub fn dir(&self) -> &Path {
|
||||||
@@ -141,7 +143,7 @@ impl RootfsCache {
|
|||||||
log::debug!("Using cached tarball: {}", cache_path.display());
|
log::debug!("Using cached tarball: {}", cache_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
extract_prepared(cache_path, arch, progress)
|
Ok(extract_prepared(cache_path, arch, progress)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepare a provisioned rootfs with cache amortization: when the cache
|
/// Prepare a provisioned rootfs with cache amortization: when the cache
|
||||||
@@ -163,7 +165,7 @@ impl RootfsCache {
|
|||||||
provision: F,
|
provision: F,
|
||||||
) -> Result<PreparedRootfs>
|
) -> Result<PreparedRootfs>
|
||||||
where
|
where
|
||||||
F: FnOnce(&PreparedRootfs) -> Result<()>,
|
F: FnOnce(&PreparedRootfs) -> anyhow::Result<()>,
|
||||||
{
|
{
|
||||||
let arch = req.resolved_arch();
|
let arch = req.resolved_arch();
|
||||||
let source = resolve_source(&req.image, &arch)?;
|
let source = resolve_source(&req.image, &arch)?;
|
||||||
@@ -172,7 +174,7 @@ impl RootfsCache {
|
|||||||
|
|
||||||
if !req.no_cache && cache_path.exists() && marker.exists() {
|
if !req.no_cache && cache_path.exists() && marker.exists() {
|
||||||
log::debug!("Using provisioned cache entry: {}", cache_path.display());
|
log::debug!("Using provisioned cache entry: {}", cache_path.display());
|
||||||
return extract_prepared(cache_path, arch, progress);
|
return Ok(extract_prepared(cache_path, arch, progress)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootfs = self.prepare(req, progress)?;
|
let rootfs = self.prepare(req, progress)?;
|
||||||
@@ -283,7 +285,7 @@ impl PreparedRootfs {
|
|||||||
/// "lts", "edge", …) to a concrete version, so the cache key is stable
|
/// "lts", "edge", …) to a concrete version, so the cache key is stable
|
||||||
/// (e.g. "ubuntu-noble-amd64", not "ubuntu-latest-amd64") and a future
|
/// (e.g. "ubuntu-noble-amd64", not "ubuntu-latest-amd64") and a future
|
||||||
/// release automatically gets its own cache entry.
|
/// release automatically gets its own cache entry.
|
||||||
fn resolve_source(image: &str, arch: &str) -> Result<ImageSource> {
|
fn resolve_source(image: &str, arch: &str) -> anyhow::Result<ImageSource> {
|
||||||
let source = parse_image_ref(image, arch)?;
|
let source = parse_image_ref(image, arch)?;
|
||||||
match source {
|
match source {
|
||||||
ImageSource::DirectTarball { distro, version } => {
|
ImageSource::DirectTarball { distro, version } => {
|
||||||
@@ -301,7 +303,7 @@ fn extract_prepared(
|
|||||||
cache_path: PathBuf,
|
cache_path: PathBuf,
|
||||||
arch: String,
|
arch: String,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> Result<PreparedRootfs> {
|
) -> anyhow::Result<PreparedRootfs> {
|
||||||
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
|
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
|
||||||
let rootfs = temp.path().to_path_buf();
|
let rootfs = temp.path().to_path_buf();
|
||||||
|
|
||||||
@@ -318,7 +320,7 @@ fn extract_prepared(
|
|||||||
/// Write a tarball of `rootfs` at `dest`, compressed according to dest's
|
/// Write a tarball of `rootfs` at `dest`, compressed according to dest's
|
||||||
/// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a
|
/// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a
|
||||||
/// `.partial` file.
|
/// `.partial` file.
|
||||||
fn pack_rootfs(rootfs: &Path, dest: &Path) -> Result<()> {
|
fn pack_rootfs(rootfs: &Path, dest: &Path) -> anyhow::Result<()> {
|
||||||
// Packing the tree into a tarball placed inside it would recurse
|
// Packing the tree into a tarball placed inside it would recurse
|
||||||
// forever: the walker keeps seeing the archive grow.
|
// forever: the walker keeps seeing the archive grow.
|
||||||
let rootfs_canonical = rootfs
|
let rootfs_canonical = rootfs
|
||||||
@@ -341,7 +343,7 @@ fn pack_rootfs(rootfs: &Path, dest: &Path) -> Result<()> {
|
|||||||
let file = std::fs::File::create(&partial)
|
let file = std::fs::File::create(&partial)
|
||||||
.with_context(|| format!("Failed to create {}", partial.display()))?;
|
.with_context(|| format!("Failed to create {}", partial.display()))?;
|
||||||
|
|
||||||
let result: Result<()> = (|| {
|
let result: anyhow::Result<()> = (|| {
|
||||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||||
let encoder = write_rootfs_tar(rootfs, encoder)?;
|
let encoder = write_rootfs_tar(rootfs, encoder)?;
|
||||||
@@ -373,7 +375,7 @@ fn pack_rootfs(rootfs: &Path, dest: &Path) -> Result<()> {
|
|||||||
|
|
||||||
/// Write the rootfs tree into a tar archive streamed to `writer`, returning
|
/// Write the rootfs tree into a tar archive streamed to `writer`, returning
|
||||||
/// the writer so the caller can finalise the compression stream.
|
/// the writer so the caller can finalise the compression stream.
|
||||||
fn write_rootfs_tar<W: std::io::Write>(rootfs: &Path, writer: W) -> Result<W> {
|
fn write_rootfs_tar<W: std::io::Write>(rootfs: &Path, writer: W) -> anyhow::Result<W> {
|
||||||
let mut builder = tar::Builder::new(writer);
|
let mut builder = tar::Builder::new(writer);
|
||||||
// Store symlinks as symlinks; following them would duplicate the target's
|
// Store symlinks as symlinks; following them would duplicate the target's
|
||||||
// contents into the archive (and recurse into symlinked directories).
|
// contents into the archive (and recurse into symlinked directories).
|
||||||
|
|||||||
+10
-6
@@ -1,4 +1,6 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::anyhow;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -214,7 +216,7 @@ pub fn detect_shell(rootfs: &Path) -> &'static str {
|
|||||||
/// Returns an error if the format is invalid
|
/// Returns an error if the format is invalid
|
||||||
pub fn validate_memory_string(s: &str) -> Result<()> {
|
pub fn validate_memory_string(s: &str) -> Result<()> {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return Err(anyhow!("Memory size cannot be empty"));
|
return Err(anyhow!("Memory size cannot be empty").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must end with a valid suffix or be a plain number
|
// Must end with a valid suffix or be a plain number
|
||||||
@@ -225,12 +227,12 @@ pub fn validate_memory_string(s: &str) -> Result<()> {
|
|||||||
|
|
||||||
// Check for negative numbers
|
// Check for negative numbers
|
||||||
if numeric_part.starts_with('-') {
|
if numeric_part.starts_with('-') {
|
||||||
return Err(anyhow!("Memory size cannot be negative: {}", s));
|
return Err(anyhow!("Memory size cannot be negative: {}", s).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be a valid positive number
|
// Must be a valid positive number
|
||||||
if numeric_part.is_empty() {
|
if numeric_part.is_empty() {
|
||||||
return Err(anyhow!("Memory size must have a numeric value: {}", s));
|
return Err(anyhow!("Memory size must have a numeric value: {}", s).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a valid integer
|
// Check if it's a valid integer
|
||||||
@@ -238,7 +240,8 @@ pub fn validate_memory_string(s: &str) -> Result<()> {
|
|||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Memory size must be a positive integer with optional suffix: {}",
|
"Memory size must be a positive integer with optional suffix: {}",
|
||||||
s
|
s
|
||||||
));
|
)
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check suffix is valid
|
// Check suffix is valid
|
||||||
@@ -249,7 +252,8 @@ pub fn validate_memory_string(s: &str) -> Result<()> {
|
|||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Invalid memory suffix '{}'. Valid suffixes: K, M, G, T",
|
"Invalid memory suffix '{}'. Valid suffixes: K, M, G, T",
|
||||||
suffix
|
suffix
|
||||||
));
|
)
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user