1use super::{PreparedDependencies, TargetAdapter};
2use crate::command::{CommandRequest, CommandRunner};
3use crate::config::TargetConfig;
4use crate::domain::{ArtifactManifest, ReleaseCandidate, TargetPlan};
5use crate::oci::{
6 OCI_REGISTRY_PUBLISHER, OciClient, PreparedImageAction, PreparedOciImage, PreparedOciOutput,
7 canonical_reference, command_error, image_identity, image_reference,
8};
9use crate::tag::TagCatalog;
10use anyhow::{Context, Result, bail};
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15pub struct OciImageTarget {
16 root: PathBuf,
17 name: String,
18 depends_on: Vec<String>,
19 image: String,
20 platform: String,
21 reuse_check: Vec<String>,
22 build: Vec<String>,
23 client: OciClient,
24 runner: Arc<dyn CommandRunner>,
25}
26
27enum ReuseDecision {
28 Reuse,
29 Build,
30}
31
32impl OciImageTarget {
33 pub fn new(
34 root: impl Into<PathBuf>,
35 config: &TargetConfig,
36 runner: Arc<dyn CommandRunner>,
37 ) -> Result<Self> {
38 let TargetConfig::OciImage {
39 name,
40 depends_on,
41 image,
42 platform,
43 reuse_check,
44 build,
45 ..
46 } = config
47 else {
48 bail!("OciImageTarget requires an oci_image target config");
49 };
50 let root = root.into();
51 Ok(Self {
52 client: OciClient::new(&root, Arc::clone(&runner)),
53 root,
54 name: name.clone(),
55 depends_on: depends_on.clone(),
56 image: image.clone(),
57 platform: platform.clone(),
58 reuse_check: reuse_check.clone(),
59 build: build.clone(),
60 runner,
61 })
62 }
63
64 fn dependency_outputs(
65 &self,
66 plan: &TargetPlan,
67 dependencies: &PreparedDependencies,
68 ) -> Result<BTreeMap<String, PreparedOciOutput>> {
69 if dependencies.len() != self.depends_on.len()
70 || self
71 .depends_on
72 .iter()
73 .any(|name| !dependencies.contains_key(name))
74 {
75 bail!(
76 "OCI target `{}` received a different dependency set than its ReleasePlan",
77 self.name
78 );
79 }
80 self.depends_on
81 .iter()
82 .map(|name| {
83 let manifest = &dependencies[name];
84 if manifest.target != *name {
85 bail!("OCI dependency manifest key/name mismatch for `{name}`");
86 }
87 if manifest.release != plan.release {
88 bail!(
89 "OCI dependency `{name}` was prepared for a different release than target `{}`",
90 self.name
91 );
92 }
93 Ok((name.clone(), PreparedOciImage::read(manifest)?.output()))
94 })
95 .collect()
96 }
97
98 fn environment(
99 &self,
100 plan: &TargetPlan,
101 current_reference: &str,
102 dependencies: &BTreeMap<String, PreparedOciOutput>,
103 ) -> Result<BTreeMap<String, String>> {
104 Ok(BTreeMap::from([
105 ("RELEASE_TARGET".to_owned(), self.name.clone()),
106 ("RELEASE_VERSION".to_owned(), plan.release.tag.to_string()),
107 ("RELEASE_COMMIT".to_owned(), plan.release.commit.clone()),
108 ("RELEASE_IMAGE".to_owned(), current_reference.to_owned()),
109 (
110 "RELEASE_DEPENDENCIES_JSON".to_owned(),
111 serde_json::to_string(dependencies)?,
112 ),
113 ]))
114 }
115
116 fn reuse_decision(
117 &self,
118 plan: &TargetPlan,
119 current_reference: &str,
120 candidate_reference: &str,
121 dependencies: &BTreeMap<String, PreparedOciOutput>,
122 ) -> Result<ReuseDecision> {
123 let (program, arguments) = self
124 .reuse_check
125 .split_first()
126 .context("OCI reuse_check command must not be empty")?;
127 let mut request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
128 request.environment = self.environment(plan, current_reference, dependencies)?;
129 request.environment.insert(
130 "RELEASE_CANDIDATE_IMAGE".to_owned(),
131 candidate_reference.to_owned(),
132 );
133 let result = self.runner.execute(&request)?;
134 match result.status {
135 0 => Ok(ReuseDecision::Reuse),
136 10 => Ok(ReuseDecision::Build),
137 _ => Err(command_error(
138 result,
139 &format!("check whether OCI target `{}` can be reused", self.name),
140 )),
141 }
142 }
143
144 fn build_image(
145 &self,
146 plan: &TargetPlan,
147 current_reference: &str,
148 dependencies: &BTreeMap<String, PreparedOciOutput>,
149 ) -> Result<(PreparedImageAction, crate::oci::ImageConfigMetadata)> {
150 let (program, arguments) = self
151 .build
152 .split_first()
153 .context("OCI build command must not be empty")?;
154 let mut request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
155 request.environment = self.environment(plan, current_reference, dependencies)?;
156 self.runner
157 .execute_inheriting_output(&request)?
158 .require_success(&format!("build OCI target `{}`", self.name))?;
159 let local = self.client.local_image(current_reference)?;
160 self.validate_platform(&local.platform)?;
161 if self.client.remote_image(current_reference)?.is_some() {
162 bail!(
163 "OCI image `{current_reference}` appeared during Prepare; refusing to overwrite or adopt a concurrent publication"
164 );
165 }
166 Ok((
167 PreparedImageAction::Build {
168 local_reference: current_reference.to_owned(),
169 },
170 local,
171 ))
172 }
173
174 fn previous_candidate(
175 &self,
176 release: &ReleaseCandidate,
177 ) -> Result<Option<(String, crate::oci::RemoteImageMetadata)>> {
178 let request = CommandRequest::new("git", ["ls-remote", "--tags", "origin"], &self.root);
179 let output = self
180 .runner
181 .execute(&request)?
182 .require_success("list previous sealed releases for OCI reuse")?
183 .stdout;
184 let tags = TagCatalog::parse_ls_remote(&output)?;
185 let mut candidates = tags.iter().collect::<Vec<_>>();
186 candidates.reverse();
187 for candidate in candidates {
188 if !candidate.annotated || candidate.name >= release.tag {
189 continue;
190 }
191 let (repository, _, tagged_reference) =
192 image_reference(&self.image, &candidate.name.to_string())?;
193 let Some(remote) = self.client.remote_image(&tagged_reference)? else {
194 continue;
195 };
196 self.validate_platform(&remote.config.platform)?;
197 return Ok(Some((
198 canonical_reference(&repository, &remote.manifest_digest),
199 remote,
200 )));
201 }
202 Ok(None)
203 }
204
205 fn validate_platform(&self, actual: &str) -> Result<()> {
206 if actual != self.platform {
207 bail!(
208 "OCI target `{}` platform is {actual}, expected {}",
209 self.name,
210 self.platform
211 );
212 }
213 Ok(())
214 }
215}
216
217impl TargetAdapter for OciImageTarget {
218 fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
219 Ok(TargetPlan {
220 name: self.name.clone(),
221 publisher: OCI_REGISTRY_PUBLISHER.to_owned(),
222 release: release.clone(),
223 artifacts: vec![image_identity(&self.image, &self.platform, release)?],
224 })
225 }
226
227 fn prepare(
228 &self,
229 plan: &TargetPlan,
230 dependencies: &PreparedDependencies,
231 staging: &Path,
232 ) -> Result<ArtifactManifest> {
233 if plan.name != self.name || plan.publisher != OCI_REGISTRY_PUBLISHER {
234 bail!("target plan does not belong to OCI target `{}`", self.name);
235 }
236 let expected = self.resolve(&plan.release)?;
237 if expected.artifacts != plan.artifacts {
238 bail!("OCI target plan inventory changed before Prepare");
239 }
240 let dependency_outputs = self.dependency_outputs(plan, dependencies)?;
241 let (repository, tag, current_reference) =
242 image_reference(&self.image, &plan.release.tag.to_string())?;
243
244 let (action, config) = if let Some(current) =
245 self.client.remote_image(¤t_reference)?
246 {
247 self.validate_platform(¤t.config.platform)?;
248 if !plan.release.tag_already_sealed {
249 bail!(
250 "OCI artifacts exist for an unsealed release candidate; refusing to adopt orphan publication state"
251 );
252 }
253 let candidate = canonical_reference(&repository, ¤t.manifest_digest);
254 match self.reuse_decision(plan, ¤t_reference, &candidate, &dependency_outputs)? {
255 ReuseDecision::Reuse => (
256 PreparedImageAction::Existing {
257 manifest_digest: current.manifest_digest,
258 },
259 current.config,
260 ),
261 ReuseDecision::Build => bail!(
262 "current OCI image `{current_reference}` does not satisfy target `{}`; refusing to overwrite an immutable release tag",
263 self.name
264 ),
265 }
266 } else if let Some((candidate, previous)) = self.previous_candidate(&plan.release)? {
267 match self.reuse_decision(plan, ¤t_reference, &candidate, &dependency_outputs)? {
268 ReuseDecision::Reuse => (
269 PreparedImageAction::Reuse {
270 source_reference: candidate,
271 manifest_digest: previous.manifest_digest,
272 },
273 previous.config,
274 ),
275 ReuseDecision::Build => {
276 self.build_image(plan, ¤t_reference, &dependency_outputs)?
277 }
278 }
279 } else {
280 self.build_image(plan, ¤t_reference, &dependency_outputs)?
281 };
282
283 let prepared = PreparedOciImage {
284 schema_version: 1,
285 target: self.name.clone(),
286 repository,
287 tag,
288 platform: self.platform.clone(),
289 config_digest: config.config_digest,
290 action,
291 };
292 let artifact = prepared.write(&plan.artifacts[0], staging)?;
293 Ok(ArtifactManifest {
294 target: self.name.clone(),
295 publisher: OCI_REGISTRY_PUBLISHER.to_owned(),
296 release: plan.release.clone(),
297 artifacts: vec![artifact],
298 })
299 }
300}