#![deny(missing_docs)]
use anyhow::{Context, Result, bail};
use console::{Term, style};
use indexmap::IndexMap;
use indicatif::{ProgressBar, ProgressStyle};
use ocidir::{
OciDir,
cap_std::fs::Dir,
oci_spec::image::{Arch, ConfigBuilder, Descriptor, ImageConfigurationBuilder, MediaType, Os},
};
use serde::Deserialize;
use std::{
collections::{BTreeMap, HashMap, HashSet, VecDeque},
fs::{self, File},
io::Write,
path::{Path, PathBuf},
process::Command,
};
use tar::{EntryType, Header, HeaderMode};
use crate::builder_ext::BuilderExt;
mod builder_ext;
mod dpkg;
mod rpm;
const LIBRARY_PATH: &str = "/lib";
const DEFAULT_PATH: &str = "/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub source: PathBuf,
pub target: PathBuf,
#[serde(default)]
pub mode: Option<u32>,
#[serde(default)]
pub uid: Option<u64>,
#[serde(default)]
pub gid: Option<u64>,
}
impl Entry {
fn relative_target_path(&self) -> PathBuf {
PathBuf::from(".").join(self.target.strip_prefix("/").unwrap_or(&self.target))
}
}
fn resolve_entries(entries: &[Entry]) -> Result<Vec<Entry>> {
let analyzer = lddtree::DependencyAnalyzer::new("/".into());
let mut deps = HashMap::new();
let shared_library_path = system_search_path();
for entry in entries {
deps.insert(entry.target.clone(), entry.clone());
let mut todo = VecDeque::new();
todo.push_back(entry.source.clone());
while !todo.is_empty() {
let current = todo.pop_front().unwrap();
if current != entry.source {
deps.insert(
current.clone(),
Entry {
source: current.clone(),
target: entry
.target
.join(current.strip_prefix(&entry.source).unwrap()),
mode: None,
uid: entry.uid,
gid: entry.gid,
},
);
}
if current.is_dir() {
for entry in fs::read_dir(¤t)
.context(format!("failed to read directory {}", current.display()))?
{
let entry = entry.context("failed to read directory entry")?;
let path = entry.path();
todo.push_back(path);
}
} else {
analyze(&analyzer, &mut deps, &shared_library_path, ¤t)?;
}
}
}
Ok(deps.drain().map(|(_k, v)| v).collect())
}
fn analyze(
analyzer: &lddtree::DependencyAnalyzer,
deps: &mut HashMap<PathBuf, Entry>,
shared_library_path: &Path,
target: &Path,
) -> Result<()> {
let tree = match analyzer.clone().analyze(target) {
Ok(tree) => tree,
Err(err) => {
log::trace!("failed to analyze {}: {}", target.display(), err);
return Ok(());
}
};
if let Some(interpreter) = &tree.interpreter {
let interpreter_path = PathBuf::from(interpreter);
deps.insert(
interpreter_path.clone(),
Entry {
source: interpreter_path.clone(),
target: interpreter_path,
mode: None,
uid: None,
gid: None,
},
);
}
for (_, library) in tree.libraries {
log::debug!("Found library {} in {}", library.name, target.display());
let library_path = library.realpath.clone().unwrap_or(library.path.clone());
let dest_path = if library.name.contains('/') {
library.path.clone()
} else {
let mut dest_path = shared_library_path.to_path_buf();
dest_path.push(library.name);
dest_path
};
deps.insert(
dest_path.clone(),
Entry {
source: library_path,
target: dest_path,
mode: None,
uid: None,
gid: None,
},
);
}
Ok(())
}
fn system_search_path() -> PathBuf {
if let Ok(path) = std::env::var("GNOCI_SYSTEM_PATH").map(PathBuf::from) {
return path;
}
match Command::new("ld.so")
.arg("--help")
.output()
.context("failed to run ld.so --help")
.and_then(|output| {
if output.status.success() {
Ok(String::from_utf8(output.stdout)
.context("failed to convert ld.so --help output to string")?)
} else {
Err(anyhow::anyhow!(
"ld.so --help failed with status: {}",
output.status
))
}
})
.and_then(|output_str| {
output_str
.lines()
.find(|line| line.ends_with("(system search path)"))
.and_then(|line| line.split_whitespace().next().map(PathBuf::from))
.ok_or_else(|| {
anyhow::anyhow!("failed to find system search path in ld.so --help output")
})
}) {
Ok(path) => path,
Err(err) => {
log::info!("Failed to determine system search path: {err}");
PathBuf::from(LIBRARY_PATH)
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageConfiguration {
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub exposed_ports: Vec<String>,
#[serde(default = "default_env")]
pub env: IndexMap<String, String>,
#[serde(default)]
pub entrypoint: Vec<String>,
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default)]
pub workingdir: Option<String>,
#[serde(default)]
pub stopsignal: Option<String>,
#[serde(default)]
pub author: Option<String>,
}
fn default_env() -> IndexMap<String, String> {
let mut env = IndexMap::new();
env.insert("PATH".to_string(), DEFAULT_PATH.to_string());
env
}
impl ImageConfiguration {
pub(crate) fn into_oci_config(
mut self,
labels: Vec<(String, String)>,
creation_time: chrono::DateTime<chrono::Utc>,
) -> Result<ocidir::oci_spec::image::ImageConfiguration> {
let mut inner_builder = ConfigBuilder::default();
self.env
.entry("PATH".to_string())
.or_insert(DEFAULT_PATH.to_string());
let mut labels_map = self.labels;
for (key, value) in labels {
labels_map.insert(key, value);
}
if !labels_map.is_empty() {
inner_builder = inner_builder.labels(labels_map);
}
if let Some(user) = self.user {
inner_builder = inner_builder.user(user);
}
if !self.exposed_ports.is_empty() {
inner_builder = inner_builder.exposed_ports(self.exposed_ports);
}
if !self.env.is_empty() {
inner_builder = inner_builder.env(
self.env
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>(),
);
}
if !self.entrypoint.is_empty() {
inner_builder = inner_builder.entrypoint(self.entrypoint);
}
if !self.cmd.is_empty() {
inner_builder = inner_builder.cmd(self.cmd);
}
if !self.volumes.is_empty() {
inner_builder = inner_builder.volumes(self.volumes);
}
if let Some(workingdir) = self.workingdir {
inner_builder = inner_builder.working_dir(workingdir);
}
if let Some(stopsignal) = self.stopsignal {
inner_builder = inner_builder.stop_signal(stopsignal);
}
let inner_config = inner_builder.build()?;
let mut config_builder = ImageConfigurationBuilder::default()
.architecture(Arch::Amd64)
.os(Os::Linux)
.config(inner_config)
.created(creation_time.to_rfc3339());
if let Some(author) = self.author {
config_builder = config_builder.author(author);
}
Ok(config_builder.build()?)
}
}
pub struct ImageBuilder<'a> {
entries: Vec<Entry>,
config: ImageConfiguration,
path: PathBuf,
labels: Vec<(String, String)>,
tag: Option<&'a str>,
creation_time: Option<chrono::DateTime<chrono::Utc>>,
multi: Option<&'a indicatif::MultiProgress>,
}
impl<'a> ImageBuilder<'a> {
pub fn new(entries: Vec<Entry>, config: ImageConfiguration, path: impl AsRef<Path>) -> Self {
Self {
entries,
config,
path: path.as_ref().to_path_buf(),
tag: None,
creation_time: None,
multi: None,
labels: Vec::new(),
}
}
pub fn tag(mut self, tag: Option<&'a str>) -> Self {
self.tag = tag;
self
}
pub fn creation_time(mut self, creation_time: chrono::DateTime<chrono::Utc>) -> Self {
self.creation_time = Some(creation_time);
self
}
pub fn multi(mut self, multi: &'a indicatif::MultiProgress) -> Self {
self.multi = Some(multi);
self
}
pub fn labels(mut self, labels: Vec<(String, String)>) -> Self {
self.labels = labels;
self
}
pub fn build(self) -> Result<Descriptor> {
eprintln!(
"{:>10} files",
style("Resolving").for_stderr().bright().green()
);
let resolved_entries = resolve_entries(&self.entries)?;
log::debug!(
"Resolved {} entries with {} dependencies",
self.entries.len(),
resolved_entries.len() - self.entries.len()
);
eprintln!(
"{:>10} image layer",
style("Writing").for_stderr().bright().green()
);
let path = &self.path;
if !path.exists() {
fs::create_dir_all(path)?;
} else if !path.is_dir() {
bail!("The specified path {} is not a directory", path.display());
}
let dir = Dir::open_ambient_dir(path, ocidir::cap_std::ambient_authority())?;
let oci_dir = OciDir::ensure(dir)?;
let mut layer_builder = oci_dir.create_layer(None)?;
layer_builder.mode(HeaderMode::Deterministic);
let mut builder = LayerBuilder::new();
for entry in &resolved_entries {
let entry = entry.clone();
builder.0.insert(
entry.relative_target_path(),
Box::new(move |writer| write_entry(writer, &entry)),
);
}
let os_release =
fs::read_to_string("/etc/os-release").context("failed to read /etc/os-release")?;
let is_debian_like = os_release
.lines()
.any(|line| line.starts_with("ID_LIKE=") && line.contains("debian"));
if is_debian_like {
builder.add_dpkg_files(&resolved_entries)?;
} else {
builder.write_rpm_manifest(&resolved_entries)?;
builder.add_rpm_license_files(&resolved_entries)?;
if Path::new("/etc/redhat-release").exists() {
builder.add_file("/etc/redhat-release");
}
}
builder.add_file("/etc/os-release");
builder.build(&mut layer_builder, self.multi)?;
let layer = layer_builder.into_inner()?.complete()?;
let creation_time = self.creation_time.unwrap_or_else(creation_time);
let mut config = self
.config
.into_oci_config(self.labels, creation_time)
.expect("failed to create OCI config");
let mut manifest = oci_dir
.new_empty_manifest()?
.media_type(MediaType::ImageManifest)
.build()?;
oci_dir.push_layer_full(
&mut manifest,
&mut config,
layer,
Option::<HashMap<String, String>>::None,
"gnoci",
creation_time,
);
eprintln!(
"{:>10} image manifest",
style("Writing").for_stderr().bright().green()
);
Ok(oci_dir.insert_manifest_and_config(
manifest,
config,
self.tag,
ocidir::oci_spec::image::Platform::default(),
)?)
}
}
#[must_use]
pub fn creation_time() -> chrono::DateTime<chrono::Utc> {
if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH")
&& let Ok(epoch) = epoch.parse::<i64>()
{
return chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
.unwrap_or(chrono::Utc::now());
}
chrono::Utc::now()
}
type FileFn<W> = Box<dyn Fn(&mut tar::Builder<W>) -> Result<()>>;
struct LayerBuilder<W: Write>(BTreeMap<PathBuf, FileFn<W>>);
impl<W: Write> LayerBuilder<W> {
fn new() -> Self {
Self(BTreeMap::new())
}
fn add_file(&mut self, path: impl AsRef<Path>) {
let path = path.as_ref().to_owned();
debug_assert!(path.is_absolute());
let relative_path = Path::new(".").join(path.strip_prefix("/").unwrap());
self.0.insert(
relative_path.clone(),
Box::new(move |writer| {
writer.append_file(
&relative_path,
&mut File::open(&path)
.context(format!("failed to open file {}", path.display()))?,
)?;
Ok(())
}),
);
}
fn build(
self,
tar_builder: &mut tar::Builder<W>,
multi: Option<&indicatif::MultiProgress>,
) -> Result<()> {
let mut paths_added = HashSet::new();
let pb = ProgressBar::new(self.0.len() as u64)
.with_style(
ProgressStyle::with_template(
if Term::stdout().size().1 > 80 {
"{prefix:>10.cyan.bold} [{bar:57}] {pos}/{len} {wide_msg}"
} else {
"{prefix:>10.cyan.bold} [{bar:57}] {pos}/{len}"
},
)
.unwrap()
.progress_chars("=> "),
)
.with_prefix("Packaging");
let pb = if let Some(multi) = multi {
multi.add(pb)
} else {
pb
};
for (path, func) in self.0 {
log::debug!("Adding entry: {}", path.display());
pb.set_message(format!("{}", path.display()));
for ancestor in path
.ancestors()
.skip(1)
.filter(|p| *p != Path::new(""))
.collect::<Vec<_>>()
.iter()
.rev()
{
if !paths_added.contains(*ancestor) {
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Directory);
header.set_path(ancestor)?;
header.set_mode(0o755);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
tar_builder.append(&header, &b""[..])?;
paths_added.insert(ancestor.to_path_buf());
}
}
paths_added.insert(path.clone());
func(tar_builder)?;
pb.inc(1);
}
pb.finish_and_clear();
Ok(())
}
}
fn write_entry(builder: &mut tar::Builder<impl Write>, entry: &Entry) -> Result<()> {
let metadata = fs::metadata(&entry.source).context(format!(
"Failed to read metadata of {}",
entry.source.display()
))?;
builder.append_xattr_header(&entry.source).context(format!(
"Failed to append xattrs for {}",
entry.source.display()
))?;
let target = entry.relative_target_path();
let mut header = Header::new_gnu();
header.set_metadata_in_mode(&metadata, tar::HeaderMode::Deterministic);
if let Some(uid) = &entry.uid {
header.set_uid(*uid);
}
if let Some(gid) = &entry.gid {
header.set_gid(*gid);
}
if let Some(mode) = &entry.mode {
header.set_mode(*mode);
}
header.set_cksum();
if metadata.is_dir() {
builder.append_data(&mut header, &target, &b""[..])?;
} else {
let data = fs::read(&entry.source)?;
builder.append_data(&mut header, &target, &*data)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn config_with_env(
env: IndexMap<String, String>,
) -> ocidir::oci_spec::image::ImageConfiguration {
let creation_time =
chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).expect("valid timestamp");
ImageConfiguration {
env,
..Default::default()
}
.into_oci_config(Vec::new(), creation_time)
.expect("OCI config should build")
}
fn env_map(config: &ocidir::oci_spec::image::ImageConfiguration) -> HashMap<String, String> {
let env = config
.config()
.as_ref()
.and_then(|config| config.env().as_ref())
.expect("config env should be set");
env.iter()
.map(|entry| {
let (key, value) = entry
.split_once('=')
.expect("env entries must be key=value");
(key.to_string(), value.to_string())
})
.collect()
}
#[test]
fn image_builder_defaults_path_when_env_missing() {
let config = config_with_env(IndexMap::new());
let env = env_map(&config);
assert_eq!(env.get("PATH").map(String::as_str), Some(DEFAULT_PATH));
}
#[test]
fn image_builder_defaults_path_when_only_non_path_env_is_set() {
let mut env = IndexMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let config = config_with_env(env);
let env = env_map(&config);
assert_eq!(env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(env.get("PATH").map(String::as_str), Some(DEFAULT_PATH));
}
#[test]
fn image_builder_preserves_user_path_value() {
let mut env = IndexMap::new();
env.insert("PATH".to_string(), "/custom/bin".to_string());
let config = config_with_env(env);
let env = env_map(&config);
assert_eq!(env.get("PATH").map(String::as_str), Some("/custom/bin"));
}
}