release-tool 0.4.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use super::{PublicationReceipt, PublicationState, Publisher, VerificationReport, receipt};
use crate::command::{CommandRequest, CommandRunner};
use crate::config::TargetConfig;
use crate::domain::{ArtifactManifest, TargetPlan};
use crate::oci::{
    OCI_REGISTRY_PUBLISHER, OciClient, PreparedImageAction, PreparedOciImage, canonical_reference,
    command_error, image_identity, image_reference, verify_local, verify_remote,
};
use anyhow::{Result, bail};
use std::path::PathBuf;
use std::sync::Arc;

pub struct OciRegistryPublisher {
    root: PathBuf,
    target: String,
    image: String,
    platform: String,
    client: OciClient,
    runner: Arc<dyn CommandRunner>,
}

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

    fn validate_plan(&self, plan: &TargetPlan) -> Result<()> {
        if plan.name != self.target || plan.publisher != OCI_REGISTRY_PUBLISHER {
            bail!("target plan does not belong to the OCI registry publisher");
        }
        let expected = vec![image_identity(&self.image, &self.platform, &plan.release)?];
        if plan.artifacts != expected {
            bail!("OCI target plan inventory differs from its configuration");
        }
        Ok(())
    }

    fn validate_manifest(&self, manifest: &ArtifactManifest) -> Result<PreparedOciImage> {
        let plan = TargetPlan {
            name: manifest.target.clone(),
            publisher: manifest.publisher.clone(),
            release: manifest.release.clone(),
            artifacts: manifest
                .artifacts
                .iter()
                .map(|artifact| artifact.identity.clone())
                .collect(),
        };
        self.validate_plan(&plan)?;
        PreparedOciImage::read(manifest)
    }

    fn verify_prepared_remote(
        &self,
        prepared: &PreparedOciImage,
        remote: &crate::oci::RemoteImageMetadata,
    ) -> Result<()> {
        verify_remote(prepared, remote)
    }

    fn publish_prepared(&self, prepared: &PreparedOciImage) -> Result<bool> {
        let current = prepared.current_reference();
        if let Some(remote) = self.client.remote_image(&current)? {
            self.verify_prepared_remote(prepared, &remote)?;
            return Ok(false);
        }

        let request = match &prepared.action {
            PreparedImageAction::Existing { .. } => {
                bail!(
                    "OCI image `{current}` disappeared after Prepare; refusing to recreate unexpected remote state"
                )
            }
            PreparedImageAction::Reuse {
                source_reference, ..
            } => CommandRequest::new(
                "docker",
                [
                    "buildx",
                    "imagetools",
                    "create",
                    "--prefer-index=false",
                    "--tag",
                    current.as_str(),
                    source_reference.as_str(),
                ],
                &self.root,
            ),
            PreparedImageAction::Build { local_reference } => {
                let local = self.client.local_image(local_reference)?;
                verify_local(prepared, &local)?;
                CommandRequest::new(
                    "docker",
                    ["image", "push", local_reference.as_str()],
                    &self.root,
                )
            }
        };

        let write = self.runner.execute(&request)?;
        let write_succeeded = write.status == 0;
        match self.client.remote_image(&current) {
            Ok(Some(remote)) => match self.verify_prepared_remote(prepared, &remote) {
                Ok(()) => Ok(true),
                Err(verification_error) if !write_succeeded => Err(command_error(
                    write,
                    "publish OCI image",
                )
                .context(format!(
                    "OCI write failed and remote reconciliation found invalid state: {verification_error:#}"
                ))),
                Err(verification_error) => Err(verification_error),
            },
            Ok(None) if !write_succeeded => Err(command_error(write, "publish OCI image")
                .context(format!("OCI write failed and `{current}` remains absent"))),
            Err(inspect_error) if !write_succeeded => Err(command_error(
                write,
                "publish OCI image",
            )
            .context(format!(
                "OCI write failed; remote reconciliation also failed: {inspect_error:#}"
            ))),
            Ok(None) => bail!("OCI image `{current}` is absent after a successful write"),
            Err(inspect_error) => Err(inspect_error),
        }
    }
}

impl Publisher for OciRegistryPublisher {
    fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState> {
        self.validate_plan(plan)?;
        let (repository, _, current) = image_reference(&self.image, &plan.release.tag.to_string())?;
        let Some(remote) = self.client.remote_image(&current)? else {
            return Ok(PublicationState::Absent);
        };
        if remote.config.platform != self.platform {
            return Ok(PublicationState::Invalid {
                reason: format!(
                    "OCI image `{current}` platform is {}, expected {}",
                    remote.config.platform, self.platform
                ),
            });
        }
        if !plan.release.tag_already_sealed {
            return Ok(PublicationState::Invalid {
                reason: "OCI artifacts exist for an unsealed release candidate; refusing to adopt orphan publication state"
                    .to_owned(),
            });
        }
        // A present tag is not complete until the target-owned freshness check has run with its
        // prepared dependencies. Keep it in the Prepare path instead of calling verify_existing.
        Ok(PublicationState::Partial {
            present: vec![canonical_reference(&repository, &remote.manifest_digest)],
            missing: vec![format!("reuse_check:{}", self.target)],
        })
    }

    fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt> {
        if !manifest.release.tag_already_sealed {
            bail!(
                "release {} is not sealed on the remote",
                manifest.release.tag
            );
        }
        let prepared = self.validate_manifest(manifest)?;
        let wrote = self.publish_prepared(&prepared)?;
        self.verify(manifest)?;
        Ok(receipt(manifest, OCI_REGISTRY_PUBLISHER, !wrote))
    }

    fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport> {
        let prepared = self.validate_manifest(manifest)?;
        let current = prepared.current_reference();
        let remote = self
            .client
            .remote_image(&current)?
            .ok_or_else(|| anyhow::anyhow!("published OCI image is missing: {current}"))?;
        self.verify_prepared_remote(&prepared, &remote)?;
        Ok(VerificationReport {
            target: manifest.target.clone(),
            verified: true,
            artifacts: vec![canonical_reference(
                &prepared.repository,
                &remote.manifest_digest,
            )],
        })
    }

    fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport> {
        self.validate_plan(plan)?;
        bail!(
            "OCI image targets require Prepare/reuse_check before existing artifacts are verified"
        )
    }
}