release-tool 0.4.0

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::{ArtifactManifest, ReleaseCandidate, TargetPlan};
use crate::oci::{
    OCI_REGISTRY_PUBLISHER, OciClient, PreparedImageAction, PreparedOciImage, PreparedOciOutput,
    canonical_reference, command_error, image_identity, image_reference,
};
use crate::tag::TagCatalog;
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

pub struct OciImageTarget {
    root: PathBuf,
    name: String,
    depends_on: Vec<String>,
    image: String,
    platform: String,
    reuse_check: Vec<String>,
    build: Vec<String>,
    client: OciClient,
    runner: Arc<dyn CommandRunner>,
}

enum ReuseDecision {
    Reuse,
    Build,
}

impl OciImageTarget {
    pub fn new(
        root: impl Into<PathBuf>,
        config: &TargetConfig,
        runner: Arc<dyn CommandRunner>,
    ) -> Result<Self> {
        let TargetConfig::OciImage {
            name,
            depends_on,
            image,
            platform,
            reuse_check,
            build,
            ..
        } = config
        else {
            bail!("OciImageTarget requires an oci_image target config");
        };
        let root = root.into();
        Ok(Self {
            client: OciClient::new(&root, Arc::clone(&runner)),
            root,
            name: name.clone(),
            depends_on: depends_on.clone(),
            image: image.clone(),
            platform: platform.clone(),
            reuse_check: reuse_check.clone(),
            build: build.clone(),
            runner,
        })
    }

    fn dependency_outputs(
        &self,
        plan: &TargetPlan,
        dependencies: &PreparedDependencies,
    ) -> Result<BTreeMap<String, PreparedOciOutput>> {
        if dependencies.len() != self.depends_on.len()
            || self
                .depends_on
                .iter()
                .any(|name| !dependencies.contains_key(name))
        {
            bail!(
                "OCI target `{}` received a different dependency set than its ReleasePlan",
                self.name
            );
        }
        self.depends_on
            .iter()
            .map(|name| {
                let manifest = &dependencies[name];
                if manifest.target != *name {
                    bail!("OCI dependency manifest key/name mismatch for `{name}`");
                }
                if manifest.release != plan.release {
                    bail!(
                        "OCI dependency `{name}` was prepared for a different release than target `{}`",
                        self.name
                    );
                }
                Ok((name.clone(), PreparedOciImage::read(manifest)?.output()))
            })
            .collect()
    }

    fn environment(
        &self,
        plan: &TargetPlan,
        current_reference: &str,
        dependencies: &BTreeMap<String, PreparedOciOutput>,
    ) -> Result<BTreeMap<String, String>> {
        Ok(BTreeMap::from([
            ("RELEASE_TARGET".to_owned(), self.name.clone()),
            ("RELEASE_VERSION".to_owned(), plan.release.tag.to_string()),
            ("RELEASE_COMMIT".to_owned(), plan.release.commit.clone()),
            ("RELEASE_IMAGE".to_owned(), current_reference.to_owned()),
            (
                "RELEASE_DEPENDENCIES_JSON".to_owned(),
                serde_json::to_string(dependencies)?,
            ),
        ]))
    }

    fn reuse_decision(
        &self,
        plan: &TargetPlan,
        current_reference: &str,
        candidate_reference: &str,
        dependencies: &BTreeMap<String, PreparedOciOutput>,
    ) -> Result<ReuseDecision> {
        let (program, arguments) = self
            .reuse_check
            .split_first()
            .context("OCI reuse_check command must not be empty")?;
        let mut request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
        request.environment = self.environment(plan, current_reference, dependencies)?;
        request.environment.insert(
            "RELEASE_CANDIDATE_IMAGE".to_owned(),
            candidate_reference.to_owned(),
        );
        let result = self.runner.execute(&request)?;
        match result.status {
            0 => Ok(ReuseDecision::Reuse),
            10 => Ok(ReuseDecision::Build),
            _ => Err(command_error(
                result,
                &format!("check whether OCI target `{}` can be reused", self.name),
            )),
        }
    }

    fn build_image(
        &self,
        plan: &TargetPlan,
        current_reference: &str,
        dependencies: &BTreeMap<String, PreparedOciOutput>,
    ) -> Result<(PreparedImageAction, crate::oci::ImageConfigMetadata)> {
        let (program, arguments) = self
            .build
            .split_first()
            .context("OCI build command must not be empty")?;
        let mut request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
        request.environment = self.environment(plan, current_reference, dependencies)?;
        self.runner
            .execute_inheriting_output(&request)?
            .require_success(&format!("build OCI target `{}`", self.name))?;
        let local = self.client.local_image(current_reference)?;
        self.validate_platform(&local.platform)?;
        if self.client.remote_image(current_reference)?.is_some() {
            bail!(
                "OCI image `{current_reference}` appeared during Prepare; refusing to overwrite or adopt a concurrent publication"
            );
        }
        Ok((
            PreparedImageAction::Build {
                local_reference: current_reference.to_owned(),
            },
            local,
        ))
    }

    fn previous_candidate(
        &self,
        release: &ReleaseCandidate,
    ) -> Result<Option<(String, crate::oci::RemoteImageMetadata)>> {
        let request = CommandRequest::new("git", ["ls-remote", "--tags", "origin"], &self.root);
        let output = self
            .runner
            .execute(&request)?
            .require_success("list previous sealed releases for OCI reuse")?
            .stdout;
        let tags = TagCatalog::parse_ls_remote(&output)?;
        let mut candidates = tags.iter().collect::<Vec<_>>();
        candidates.reverse();
        for candidate in candidates {
            if !candidate.annotated || candidate.name >= release.tag {
                continue;
            }
            let (repository, _, tagged_reference) =
                image_reference(&self.image, &candidate.name.to_string())?;
            let Some(remote) = self.client.remote_image(&tagged_reference)? else {
                continue;
            };
            self.validate_platform(&remote.config.platform)?;
            return Ok(Some((
                canonical_reference(&repository, &remote.manifest_digest),
                remote,
            )));
        }
        Ok(None)
    }

    fn validate_platform(&self, actual: &str) -> Result<()> {
        if actual != self.platform {
            bail!(
                "OCI target `{}` platform is {actual}, expected {}",
                self.name,
                self.platform
            );
        }
        Ok(())
    }
}

impl TargetAdapter for OciImageTarget {
    fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
        Ok(TargetPlan {
            name: self.name.clone(),
            publisher: OCI_REGISTRY_PUBLISHER.to_owned(),
            release: release.clone(),
            artifacts: vec![image_identity(&self.image, &self.platform, release)?],
        })
    }

    fn prepare(
        &self,
        plan: &TargetPlan,
        dependencies: &PreparedDependencies,
        staging: &Path,
    ) -> Result<ArtifactManifest> {
        if plan.name != self.name || plan.publisher != OCI_REGISTRY_PUBLISHER {
            bail!("target plan does not belong to OCI target `{}`", self.name);
        }
        let expected = self.resolve(&plan.release)?;
        if expected.artifacts != plan.artifacts {
            bail!("OCI target plan inventory changed before Prepare");
        }
        let dependency_outputs = self.dependency_outputs(plan, dependencies)?;
        let (repository, tag, current_reference) =
            image_reference(&self.image, &plan.release.tag.to_string())?;

        let (action, config) = if let Some(current) =
            self.client.remote_image(&current_reference)?
        {
            self.validate_platform(&current.config.platform)?;
            if !plan.release.tag_already_sealed {
                bail!(
                    "OCI artifacts exist for an unsealed release candidate; refusing to adopt orphan publication state"
                );
            }
            let candidate = canonical_reference(&repository, &current.manifest_digest);
            match self.reuse_decision(plan, &current_reference, &candidate, &dependency_outputs)? {
                ReuseDecision::Reuse => (
                    PreparedImageAction::Existing {
                        manifest_digest: current.manifest_digest,
                    },
                    current.config,
                ),
                ReuseDecision::Build => bail!(
                    "current OCI image `{current_reference}` does not satisfy target `{}`; refusing to overwrite an immutable release tag",
                    self.name
                ),
            }
        } else if let Some((candidate, previous)) = self.previous_candidate(&plan.release)? {
            match self.reuse_decision(plan, &current_reference, &candidate, &dependency_outputs)? {
                ReuseDecision::Reuse => (
                    PreparedImageAction::Reuse {
                        source_reference: candidate,
                        manifest_digest: previous.manifest_digest,
                    },
                    previous.config,
                ),
                ReuseDecision::Build => {
                    self.build_image(plan, &current_reference, &dependency_outputs)?
                }
            }
        } else {
            self.build_image(plan, &current_reference, &dependency_outputs)?
        };

        let prepared = PreparedOciImage {
            schema_version: 1,
            target: self.name.clone(),
            repository,
            tag,
            platform: self.platform.clone(),
            config_digest: config.config_digest,
            action,
        };
        let artifact = prepared.write(&plan.artifacts[0], staging)?;
        Ok(ArtifactManifest {
            target: self.name.clone(),
            publisher: OCI_REGISTRY_PUBLISHER.to_owned(),
            release: plan.release.clone(),
            artifacts: vec![artifact],
        })
    }
}