use std::path::PathBuf;
use anyhow::{Context, Result};
use bv_builder::{
build::{self},
layering::PackingStrategy,
oci,
popularity::{self, PopularityMap},
registry,
resolve,
spec::BuildSpec,
};
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(
name = "bv-builder",
about = "Build reproducible factored OCI images from conda package specs"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Resolve {
spec: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
},
Build {
spec: PathBuf,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long, default_value = "0")]
max_layers: usize,
#[arg(long)]
popularity: Option<PathBuf>,
},
Push {
image: PathBuf,
reference: String,
},
Verify {
reference: String,
#[arg(long)]
digest: String,
},
Pack {
specs_dir: PathBuf,
#[arg(long, default_value = "popularity.json")]
output: PathBuf,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Resolve { spec, out } => {
let build_spec = load_spec(&spec)?;
eprintln!(" Resolving {} {}...", build_spec.name, build_spec.version);
let resolved = resolve::resolve(&build_spec)
.await
.context("resolve packages")?;
eprintln!(
" Resolved {} packages",
resolved.packages.len()
);
let json = serde_json::to_string_pretty(&resolved)?;
if let Some(out) = out {
std::fs::write(&out, &json)
.with_context(|| format!("write resolved spec to {}", out.display()))?;
eprintln!(" Written to {}", out.display());
} else {
println!("{json}");
}
}
Commands::Build { spec, output, max_layers, popularity: pop_path } => {
let build_spec = load_spec(&spec)?;
eprintln!(" Resolving {} {}...", build_spec.name, build_spec.version);
let resolved = resolve::resolve(&build_spec)
.await
.context("resolve packages")?;
let pop_map: Option<PopularityMap> = pop_path
.as_ref()
.map(|p| PopularityMap::load(p))
.transpose()
.context("load popularity map")?;
let strategy = if max_layers > 0 {
PackingStrategy::PopularityBased { max_layers }
} else {
PackingStrategy::OnePerPackage
};
eprintln!(
" Building {} layers...",
resolved.packages.len()
);
let image = build::build(&resolved, &strategy, pop_map.as_ref())
.await
.context("build OCI image")?;
let manifest = image.manifest_json()?;
let manifest_digest = format!(
"sha256:{}",
build::sha256_hex(&manifest)
);
eprintln!(" Manifest digest: {manifest_digest}");
eprintln!(" Layers: {}", image.layers.len());
if let Some(out) = output {
save_oci_tarball(&image, &out)?;
eprintln!(" Written to {}", out.display());
}
let snapshot = registry::build_repodata_snapshot(&resolved)?;
let snapshot_digest = format!(
"sha256:{}",
build::sha256_hex(&snapshot)
);
eprintln!(" Repodata snapshot digest: {snapshot_digest}");
}
Commands::Push { image: _image, reference } => {
eprintln!(" Push to {reference} is not supported without a local OCI tarball implementation yet.");
eprintln!(" Use `bv-builder build --output image.tar && bv-builder push` after implementing tarball round-trip.");
anyhow::bail!("push from tarball not yet implemented");
}
Commands::Verify { reference, digest } => {
eprintln!(" Verifying {reference}...");
oci::verify(&reference, &digest)
.await
.context("verify image digest")?;
eprintln!(" Digest verified: {digest}");
}
Commands::Pack { specs_dir, output } => {
eprintln!(" Scanning specs in {}...", specs_dir.display());
let pop = popularity::compute_from_spec_dir(&specs_dir)
.context("compute popularity from spec directory")?;
let total: u64 = pop.packages.values().sum();
eprintln!(
" {} unique packages, {} total occurrences",
pop.packages.len(),
total
);
pop.save(&output)
.with_context(|| format!("write popularity map to {}", output.display()))?;
eprintln!(" Written to {}", output.display());
}
}
Ok(())
}
fn load_spec(path: &PathBuf) -> Result<BuildSpec> {
let s = std::fs::read_to_string(path)
.with_context(|| format!("read spec '{}'", path.display()))?;
serde_yaml::from_str(&s).with_context(|| format!("parse spec '{}'", path.display()))
}
fn save_oci_tarball(image: &bv_builder::build::OciImage, path: &PathBuf) -> Result<()> {
let f = std::fs::File::create(path)
.with_context(|| format!("create {}", path.display()))?;
let mut builder = tar::Builder::new(f);
for layer in &image.layers {
let hex = layer.descriptor.digest.strip_prefix("sha256:").unwrap_or(&layer.descriptor.digest);
let entry_path = format!("blobs/sha256/{hex}");
let mut header = tar::Header::new_ustar();
header.set_size(layer.compressed.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(
&mut header,
&entry_path,
layer.compressed.as_slice(),
)?;
}
let config_hex = build::sha256_hex(&image.config);
let mut ch = tar::Header::new_ustar();
ch.set_size(image.config.len() as u64);
ch.set_mode(0o644);
ch.set_cksum();
builder.append_data(
&mut ch,
format!("blobs/sha256/{config_hex}"),
image.config.as_slice(),
)?;
let manifest = image.manifest_json()?;
let mut mh = tar::Header::new_ustar();
mh.set_size(manifest.len() as u64);
mh.set_mode(0o644);
mh.set_cksum();
builder.append_data(&mut mh, "manifest.json", manifest.as_slice())?;
builder.finish()?;
Ok(())
}