Skip to main content

release_tool/target/
oci_image.rs

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(&current_reference)?
246        {
247            self.validate_platform(&current.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, &current.manifest_digest);
254            match self.reuse_decision(plan, &current_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, &current_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, &current_reference, &dependency_outputs)?
277                }
278            }
279        } else {
280            self.build_image(plan, &current_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}