release-tool 0.3.1

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use super::{PreparedDependencies, TargetAdapter};
use crate::command::{CommandRequest, CommandRunner};
use crate::config::TargetConfig;
use crate::domain::{
    ArtifactIdentity, ArtifactManifest, PreparedArtifact, ReleaseCandidate, TargetPlan,
};
use crate::lifecycle::expand;
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

pub struct DockerArchiveTarget {
    root: PathBuf,
    name: String,
    publisher: String,
    platform: String,
    image: String,
    asset: String,
    build: Vec<String>,
    local_check: Vec<String>,
    runner: Arc<dyn CommandRunner>,
}

impl DockerArchiveTarget {
    pub fn new(
        root: impl Into<PathBuf>,
        config: &TargetConfig,
        runner: Arc<dyn CommandRunner>,
    ) -> Result<Self> {
        let TargetConfig::DockerArchive {
            name,
            publisher,
            platform,
            image,
            asset,
            build,
            local_check,
            ..
        } = config
        else {
            bail!("DockerArchiveTarget requires a docker_archive target config");
        };
        Ok(Self {
            root: root.into(),
            name: name.clone(),
            publisher: publisher.clone(),
            platform: platform.clone(),
            image: image.clone(),
            asset: asset.clone(),
            build: build.clone(),
            local_check: local_check.clone(),
            runner,
        })
    }

    fn run_configured(&self, command: &[String], version: &str, image: &str) -> Result<()> {
        let expanded = command
            .iter()
            .map(|argument| expand(argument, version, Some(image)))
            .collect::<Result<Vec<_>>>()?;
        let (program, arguments) = expanded
            .split_first()
            .context("configured command must not be empty")?;
        let request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
        self.runner
            .execute_inheriting_output(&request)?
            .require_success(&format!("{program} {}", arguments.join(" ")))?;
        Ok(())
    }

    fn run(&self, program: &str, arguments: &[String]) -> Result<String> {
        let request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
        Ok(self
            .runner
            .execute(&request)?
            .require_success(&format!("{program} {}", arguments.join(" ")))?
            .stdout)
    }
}

impl TargetAdapter for DockerArchiveTarget {
    fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
        let version = release.tag.to_string();
        let asset = expand(&self.asset, &version, None)?;
        Ok(TargetPlan {
            name: self.name.clone(),
            publisher: self.publisher.clone(),
            release: release.clone(),
            artifacts: vec![
                ArtifactIdentity::GithubReleaseAsset {
                    name: asset.clone(),
                },
                ArtifactIdentity::GithubReleaseAsset {
                    name: format!("{asset}.sha256"),
                },
            ],
        })
    }

    fn prepare(
        &self,
        plan: &TargetPlan,
        dependencies: &PreparedDependencies,
        staging: &Path,
    ) -> Result<ArtifactManifest> {
        if !dependencies.is_empty() {
            bail!("Docker archive targets do not accept target dependencies");
        }
        if plan.name != self.name || plan.publisher != self.publisher {
            bail!(
                "target plan does not belong to Docker target `{}`",
                self.name
            );
        }
        let expected = self.resolve(&plan.release)?;
        if expected.artifacts != plan.artifacts {
            bail!("Docker target plan artifact inventory changed before Prepare");
        }
        fs::create_dir_all(staging)
            .with_context(|| format!("failed to create staging directory {}", staging.display()))?;
        let version = plan.release.tag.to_string();
        let image = expand(&self.image, &version, None)?;
        self.run_configured(&self.build, &version, &image)?;
        self.run_configured(&self.local_check, &version, &image)?;

        let inspect_arguments = vec![
            "image".to_owned(),
            "inspect".to_owned(),
            "--format".to_owned(),
            "{{.Os}}/{{.Architecture}}".to_owned(),
            image.clone(),
        ];
        let actual_platform = self.run("docker", &inspect_arguments)?;
        if actual_platform.trim() != self.platform {
            bail!(
                "Docker image {image} has platform {}, expected {}",
                actual_platform.trim(),
                self.platform
            );
        }

        let raw = staging.join("image.tar");
        let compressed_raw = staging.join("image.tar.xz");
        let asset_name = expand(&self.asset, &version, None)?;
        let archive = staging.join(&asset_name);
        let checksum = staging.join(format!("{asset_name}.sha256"));
        for path in [&raw, &compressed_raw, &archive, &checksum] {
            if path.exists() {
                fs::remove_file(path)
                    .with_context(|| format!("failed to replace {}", path.display()))?;
            }
        }
        self.run(
            "docker",
            &[
                "save".to_owned(),
                "-o".to_owned(),
                raw.display().to_string(),
                image,
            ],
        )?;
        self.run("xz", &["--threads=0".to_owned(), raw.display().to_string()])?;
        fs::rename(&compressed_raw, &archive).with_context(|| {
            format!(
                "failed to move {} to {}",
                compressed_raw.display(),
                archive.display()
            )
        })?;
        let archive_digest = sha256(&archive)?;
        fs::write(&checksum, format!("{archive_digest}  {asset_name}\n"))
            .with_context(|| format!("failed to write {}", checksum.display()))?;
        let checksum_digest = sha256(&checksum)?;
        let identities = &plan.artifacts;
        if identities.len() != 2 {
            bail!("Docker target plan must contain archive and checksum identities");
        }
        Ok(ArtifactManifest {
            target: self.name.clone(),
            publisher: self.publisher.clone(),
            release: plan.release.clone(),
            artifacts: vec![
                PreparedArtifact {
                    identity: identities[0].clone(),
                    path: archive,
                    sha256: archive_digest,
                },
                PreparedArtifact {
                    identity: identities[1].clone(),
                    path: checksum,
                    sha256: checksum_digest,
                },
            ],
        })
    }
}

fn sha256(path: &Path) -> Result<String> {
    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
    Ok(hex::encode(Sha256::digest(bytes)))
}