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(¤t)? {
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(¤t) {
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(¤t)? 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 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(¤t)?
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}