Skip to main content

release_tool/publisher/
oci_registry.rs

1use super::{PublicationReceipt, PublicationState, Publisher, VerificationReport, receipt};
2use crate::command::{CommandRequest, CommandRunner};
3use crate::config::TargetConfig;
4use crate::domain::{ArtifactManifest, TargetPlan};
5use crate::oci::{
6    OCI_REGISTRY_PUBLISHER, OciClient, PreparedImageAction, PreparedOciImage, canonical_reference,
7    command_error, image_identity, image_reference, verify_local, verify_remote,
8};
9use anyhow::{Result, bail};
10use std::path::PathBuf;
11use std::sync::Arc;
12
13pub struct OciRegistryPublisher {
14    root: PathBuf,
15    target: String,
16    image: String,
17    platform: String,
18    client: OciClient,
19    runner: Arc<dyn CommandRunner>,
20}
21
22impl OciRegistryPublisher {
23    pub fn new(
24        root: impl Into<PathBuf>,
25        target_config: &TargetConfig,
26        runner: Arc<dyn CommandRunner>,
27    ) -> Result<Self> {
28        let TargetConfig::OciImage {
29            name,
30            image,
31            platform,
32            ..
33        } = target_config
34        else {
35            bail!("OciRegistryPublisher requires an oci_image target config");
36        };
37        let root = root.into();
38        Ok(Self {
39            client: OciClient::new(&root, Arc::clone(&runner)),
40            root,
41            target: name.clone(),
42            image: image.clone(),
43            platform: platform.clone(),
44            runner,
45        })
46    }
47
48    fn validate_plan(&self, plan: &TargetPlan) -> Result<()> {
49        if plan.name != self.target || plan.publisher != OCI_REGISTRY_PUBLISHER {
50            bail!("target plan does not belong to the OCI registry publisher");
51        }
52        let expected = vec![image_identity(&self.image, &self.platform, &plan.release)?];
53        if plan.artifacts != expected {
54            bail!("OCI target plan inventory differs from its configuration");
55        }
56        Ok(())
57    }
58
59    fn validate_manifest(&self, manifest: &ArtifactManifest) -> Result<PreparedOciImage> {
60        let plan = TargetPlan {
61            name: manifest.target.clone(),
62            publisher: manifest.publisher.clone(),
63            release: manifest.release.clone(),
64            artifacts: manifest
65                .artifacts
66                .iter()
67                .map(|artifact| artifact.identity.clone())
68                .collect(),
69        };
70        self.validate_plan(&plan)?;
71        PreparedOciImage::read(manifest)
72    }
73
74    fn verify_prepared_remote(
75        &self,
76        prepared: &PreparedOciImage,
77        remote: &crate::oci::RemoteImageMetadata,
78    ) -> Result<()> {
79        verify_remote(prepared, remote)
80    }
81
82    fn publish_prepared(&self, prepared: &PreparedOciImage) -> Result<bool> {
83        let current = prepared.current_reference();
84        if let Some(remote) = self.client.remote_image(&current)? {
85            self.verify_prepared_remote(prepared, &remote)?;
86            return Ok(false);
87        }
88
89        let request = match &prepared.action {
90            PreparedImageAction::Existing { .. } => {
91                bail!(
92                    "OCI image `{current}` disappeared after Prepare; refusing to recreate unexpected remote state"
93                )
94            }
95            PreparedImageAction::Reuse {
96                source_reference, ..
97            } => CommandRequest::new(
98                "docker",
99                [
100                    "buildx",
101                    "imagetools",
102                    "create",
103                    "--prefer-index=false",
104                    "--tag",
105                    current.as_str(),
106                    source_reference.as_str(),
107                ],
108                &self.root,
109            ),
110            PreparedImageAction::Build { local_reference } => {
111                let local = self.client.local_image(local_reference)?;
112                verify_local(prepared, &local)?;
113                CommandRequest::new(
114                    "docker",
115                    ["image", "push", local_reference.as_str()],
116                    &self.root,
117                )
118            }
119        };
120
121        let write = self.runner.execute(&request)?;
122        let write_succeeded = write.status == 0;
123        match self.client.remote_image(&current) {
124            Ok(Some(remote)) => match self.verify_prepared_remote(prepared, &remote) {
125                Ok(()) => Ok(true),
126                Err(verification_error) if !write_succeeded => Err(command_error(
127                    write,
128                    "publish OCI image",
129                )
130                .context(format!(
131                    "OCI write failed and remote reconciliation found invalid state: {verification_error:#}"
132                ))),
133                Err(verification_error) => Err(verification_error),
134            },
135            Ok(None) if !write_succeeded => Err(command_error(write, "publish OCI image")
136                .context(format!("OCI write failed and `{current}` remains absent"))),
137            Err(inspect_error) if !write_succeeded => Err(command_error(
138                write,
139                "publish OCI image",
140            )
141            .context(format!(
142                "OCI write failed; remote reconciliation also failed: {inspect_error:#}"
143            ))),
144            Ok(None) => bail!("OCI image `{current}` is absent after a successful write"),
145            Err(inspect_error) => Err(inspect_error),
146        }
147    }
148}
149
150impl Publisher for OciRegistryPublisher {
151    fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState> {
152        self.validate_plan(plan)?;
153        let (repository, _, current) = image_reference(&self.image, &plan.release.tag.to_string())?;
154        let Some(remote) = self.client.remote_image(&current)? else {
155            return Ok(PublicationState::Absent);
156        };
157        if remote.config.platform != self.platform {
158            return Ok(PublicationState::Invalid {
159                reason: format!(
160                    "OCI image `{current}` platform is {}, expected {}",
161                    remote.config.platform, self.platform
162                ),
163            });
164        }
165        if !plan.release.tag_already_sealed {
166            return Ok(PublicationState::Invalid {
167                reason: "OCI artifacts exist for an unsealed release candidate; refusing to adopt orphan publication state"
168                    .to_owned(),
169            });
170        }
171        // A present tag is not complete until the target-owned freshness check has run with its
172        // prepared dependencies. Keep it in the Prepare path instead of calling verify_existing.
173        Ok(PublicationState::Partial {
174            present: vec![canonical_reference(&repository, &remote.manifest_digest)],
175            missing: vec![format!("reuse_check:{}", self.target)],
176        })
177    }
178
179    fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt> {
180        if !manifest.release.tag_already_sealed {
181            bail!(
182                "release {} is not sealed on the remote",
183                manifest.release.tag
184            );
185        }
186        let prepared = self.validate_manifest(manifest)?;
187        let wrote = self.publish_prepared(&prepared)?;
188        self.verify(manifest)?;
189        Ok(receipt(manifest, OCI_REGISTRY_PUBLISHER, !wrote))
190    }
191
192    fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport> {
193        let prepared = self.validate_manifest(manifest)?;
194        let current = prepared.current_reference();
195        let remote = self
196            .client
197            .remote_image(&current)?
198            .ok_or_else(|| anyhow::anyhow!("published OCI image is missing: {current}"))?;
199        self.verify_prepared_remote(&prepared, &remote)?;
200        Ok(VerificationReport {
201            target: manifest.target.clone(),
202            verified: true,
203            artifacts: vec![canonical_reference(
204                &prepared.repository,
205                &remote.manifest_digest,
206            )],
207        })
208    }
209
210    fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport> {
211        self.validate_plan(plan)?;
212        bail!(
213            "OCI image targets require Prepare/reuse_check before existing artifacts are verified"
214        )
215    }
216}