forjar 1.6.2

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! FJ-2104: OCI image assembler — builds complete images from resource definitions.
//!
//! Connects the layer builder (FJ-2102) with OCI types (FJ-2101) to produce
//! loadable OCI images from `type: image` resource definitions.

use crate::core::store::layer_builder::{
    build_layer, compute_dual_digest, write_oci_layout, LayerEntry,
};
use crate::core::types::{
    ImageBuildPlan, LayerBuildResult, LayerStrategy, OciDescriptor, OciHistoryEntry,
    OciImageConfig, OciIndex, OciLayerConfig, OciManifest,
};
use std::collections::HashMap;
use std::path::Path;

/// Result of assembling a complete OCI image.
#[derive(Debug)]
pub struct AssembledImage {
    /// Path to the OCI layout directory.
    pub layout_dir: std::path::PathBuf,
    /// Image manifest.
    pub manifest: OciManifest,
    /// Image config.
    pub config: OciImageConfig,
    /// Per-layer build results.
    pub layers: Vec<LayerBuildResult>,
    /// Total compressed size.
    pub total_size: u64,
}

/// Assemble a complete OCI image from a build plan.
///
/// Takes an `ImageBuildPlan` and produces an OCI layout directory
/// containing blobs, manifest, config, and index. The resulting
/// directory can be loaded with `docker load` or pushed to a registry.
pub fn assemble_image(
    plan: &ImageBuildPlan,
    layer_entries: &[Vec<LayerEntry>],
    output_dir: &Path,
    layer_config: &OciLayerConfig,
    target_arch: Option<&str>,
) -> Result<AssembledImage, String> {
    if plan.layers.len() != layer_entries.len() {
        return Err(format!(
            "layer count mismatch: plan has {} layers but {} entry sets provided",
            plan.layers.len(),
            layer_entries.len(),
        ));
    }

    let built_layers = build_all_layers(layer_entries, layer_config)?;

    // Collect diff_ids and descriptors
    let diff_ids: Vec<String> = built_layers
        .iter()
        .map(|(r, _)| r.diff_id.clone())
        .collect();
    let layer_descriptors: Vec<OciDescriptor> = built_layers
        .iter()
        .map(|(r, _)| r.to_descriptor())
        .collect();
    let layer_results: Vec<LayerBuildResult> =
        built_layers.iter().map(|(r, _)| r.clone()).collect();
    let total_size: u64 = built_layers.iter().map(|(r, _)| r.compressed_size).sum();

    // Build and serialize image config (E12: support target architecture)
    let config = build_image_config(plan, target_arch, diff_ids);
    let config_json =
        serde_json::to_vec_pretty(&config).map_err(|e| format!("serialize config: {e}"))?;
    let config_digest = compute_dual_digest(&config_json);

    // Build and serialize manifest
    let manifest = OciManifest::new(config_digest.oci_digest(), layer_descriptors);
    let manifest_json =
        serde_json::to_vec_pretty(&manifest).map_err(|e| format!("serialize manifest: {e}"))?;
    let manifest_digest = compute_dual_digest(&manifest_json);

    // Write OCI layout, manifest blob, index, and Docker-compat manifest
    write_oci_layout(output_dir, &built_layers, &config_json)?;
    write_manifest_and_index(output_dir, &manifest_json, &manifest_digest.sha256)?;
    write_docker_manifest(output_dir, plan, &layer_results, &config_digest.sha256)?;

    // FJ-2200: Postcondition — valid OCI layout
    debug_assert!(
        output_dir.join("oci-layout").exists(),
        "assemble_image: oci-layout missing"
    );
    debug_assert!(
        output_dir.join("index.json").exists(),
        "assemble_image: index.json missing"
    );
    debug_assert!(
        manifest.layers.len() == layer_results.len(),
        "assemble_image: manifest layer count mismatch"
    );

    Ok(AssembledImage {
        layout_dir: output_dir.to_path_buf(),
        manifest,
        config,
        layers: layer_results,
        total_size,
    })
}

/// Build all layers from entry sets (E18: concurrent when multiple layers exist).
fn build_all_layers(
    layer_entries: &[Vec<LayerEntry>],
    layer_config: &OciLayerConfig,
) -> Result<Vec<(LayerBuildResult, Vec<u8>)>, String> {
    if layer_entries.len() > 1 {
        let results: Vec<Result<(LayerBuildResult, Vec<u8>), String>> = std::thread::scope(|s| {
            let handles: Vec<_> = layer_entries
                .iter()
                .enumerate()
                .map(|(i, entries)| {
                    s.spawn(move || {
                        build_layer(entries, layer_config)
                            .map_err(|e| format!("layer {i} build failed: {e}"))
                    })
                })
                .collect();
            handles
                .into_iter()
                .enumerate()
                .map(|(i, h)| {
                    h.join()
                        .unwrap_or_else(|_| Err(format!("layer {i} build thread panicked")))
                })
                .collect()
        });
        results.into_iter().collect()
    } else {
        // Single layer: no thread overhead
        layer_entries
            .iter()
            .enumerate()
            .map(|(i, entries)| {
                build_layer(entries, layer_config)
                    .map_err(|e| format!("layer {i} build failed: {e}"))
            })
            .collect()
    }
}

/// Build the OCI image config: history, architecture, entrypoint, labels.
fn build_image_config(
    plan: &ImageBuildPlan,
    target_arch: Option<&str>,
    diff_ids: Vec<String>,
) -> OciImageConfig {
    let history: Vec<OciHistoryEntry> = plan
        .layers
        .iter()
        .map(|strategy| OciHistoryEntry {
            created: None,
            created_by: Some(strategy_description(strategy)),
            empty_layer: false,
            comment: None,
        })
        .collect();

    let arch = target_arch.unwrap_or("amd64");
    let mut config = OciImageConfig::for_arch(arch, "linux", diff_ids);
    config.history = history;
    if let Some(ref ep) = plan.entrypoint {
        config.config.entrypoint = ep.clone();
    }
    for (k, v) in &plan.labels {
        config.config.labels.insert(k.clone(), v.clone());
    }
    config
}

/// Write the manifest blob and OCI index.json.
fn write_manifest_and_index(
    output_dir: &Path,
    manifest_json: &[u8],
    manifest_hex: &str,
) -> Result<(), String> {
    std::fs::write(
        output_dir.join(format!("blobs/sha256/{manifest_hex}")),
        manifest_json,
    )
    .map_err(|e| format!("write manifest blob: {e}"))?;

    let index = OciIndex::single(OciDescriptor {
        media_type: "application/vnd.oci.image.manifest.v1+json".into(),
        digest: format!("sha256:{manifest_hex}"),
        size: manifest_json.len() as u64,
        annotations: HashMap::new(),
    });
    let index_json =
        serde_json::to_vec_pretty(&index).map_err(|e| format!("serialize index: {e}"))?;
    std::fs::write(output_dir.join("index.json"), &index_json)
        .map_err(|e| format!("write index.json: {e}"))
}

/// Write the Docker-compat manifest.json (consumed by `docker load`).
fn write_docker_manifest(
    output_dir: &Path,
    plan: &ImageBuildPlan,
    layer_results: &[LayerBuildResult],
    config_hex: &str,
) -> Result<(), String> {
    let docker_layers: Vec<String> = layer_results
        .iter()
        .map(|r| {
            let hex = r.digest.strip_prefix("sha256:").unwrap_or(&r.digest);
            format!("blobs/sha256/{hex}")
        })
        .collect();
    let docker_manifest = serde_json::json!([{
        "RepoTags": [&plan.tag],
        "Config": format!("blobs/sha256/{config_hex}"),
        "Layers": docker_layers,
    }]);
    std::fs::write(
        output_dir.join("manifest.json"),
        serde_json::to_vec_pretty(&docker_manifest)
            .map_err(|e| format!("serialize docker manifest: {e}"))?,
    )
    .map_err(|e| format!("write manifest.json: {e}"))
}

fn strategy_description(strategy: &LayerStrategy) -> String {
    match strategy {
        LayerStrategy::Packages { names } => format!("forjar: packages {}", names.join(", ")),
        LayerStrategy::Files { paths } => format!("forjar: files {}", paths.join(", ")),
        LayerStrategy::Build { command, .. } => format!("forjar: build {command}"),
        LayerStrategy::Derivation { store_path } => format!("forjar: derivation {store_path}"),
    }
}