Skip to main content

release_tool/target/
docker_archive.rs

1use super::TargetAdapter;
2use crate::command::{CommandRequest, CommandRunner};
3use crate::config::TargetConfig;
4use crate::domain::{
5    ArtifactIdentity, ArtifactManifest, PreparedArtifact, ReleaseCandidate, TargetPlan,
6};
7use crate::lifecycle::expand;
8use anyhow::{Context, Result, bail};
9use sha2::{Digest, Sha256};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14pub struct DockerArchiveTarget {
15    root: PathBuf,
16    name: String,
17    publisher: String,
18    platform: String,
19    image: String,
20    asset: String,
21    build: Vec<String>,
22    local_check: Vec<String>,
23    runner: Arc<dyn CommandRunner>,
24}
25
26impl DockerArchiveTarget {
27    pub fn new(
28        root: impl Into<PathBuf>,
29        config: &TargetConfig,
30        runner: Arc<dyn CommandRunner>,
31    ) -> Result<Self> {
32        let TargetConfig::DockerArchive {
33            name,
34            publisher,
35            platform,
36            image,
37            asset,
38            build,
39            local_check,
40            ..
41        } = config
42        else {
43            bail!("DockerArchiveTarget requires a docker_archive target config");
44        };
45        Ok(Self {
46            root: root.into(),
47            name: name.clone(),
48            publisher: publisher.clone(),
49            platform: platform.clone(),
50            image: image.clone(),
51            asset: asset.clone(),
52            build: build.clone(),
53            local_check: local_check.clone(),
54            runner,
55        })
56    }
57
58    fn run_configured(&self, command: &[String], version: &str, image: &str) -> Result<()> {
59        let expanded = command
60            .iter()
61            .map(|argument| expand(argument, version, Some(image)))
62            .collect::<Result<Vec<_>>>()?;
63        let (program, arguments) = expanded
64            .split_first()
65            .context("configured command must not be empty")?;
66        let request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
67        self.runner
68            .execute_inheriting_output(&request)?
69            .require_success(&format!("{program} {}", arguments.join(" ")))?;
70        Ok(())
71    }
72
73    fn run(&self, program: &str, arguments: &[String]) -> Result<String> {
74        let request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
75        Ok(self
76            .runner
77            .execute(&request)?
78            .require_success(&format!("{program} {}", arguments.join(" ")))?
79            .stdout)
80    }
81}
82
83impl TargetAdapter for DockerArchiveTarget {
84    fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
85        let version = release.tag.to_string();
86        let asset = expand(&self.asset, &version, None)?;
87        Ok(TargetPlan {
88            name: self.name.clone(),
89            publisher: self.publisher.clone(),
90            release: release.clone(),
91            artifacts: vec![
92                ArtifactIdentity::GithubReleaseAsset {
93                    name: asset.clone(),
94                },
95                ArtifactIdentity::GithubReleaseAsset {
96                    name: format!("{asset}.sha256"),
97                },
98            ],
99        })
100    }
101
102    fn prepare(&self, plan: &TargetPlan, staging: &Path) -> Result<ArtifactManifest> {
103        if plan.name != self.name || plan.publisher != self.publisher {
104            bail!(
105                "target plan does not belong to Docker target `{}`",
106                self.name
107            );
108        }
109        let expected = self.resolve(&plan.release)?;
110        if expected.artifacts != plan.artifacts {
111            bail!("Docker target plan artifact inventory changed before Prepare");
112        }
113        fs::create_dir_all(staging)
114            .with_context(|| format!("failed to create staging directory {}", staging.display()))?;
115        let version = plan.release.tag.to_string();
116        let image = expand(&self.image, &version, None)?;
117        self.run_configured(&self.build, &version, &image)?;
118        self.run_configured(&self.local_check, &version, &image)?;
119
120        let inspect_arguments = vec![
121            "image".to_owned(),
122            "inspect".to_owned(),
123            "--format".to_owned(),
124            "{{.Os}}/{{.Architecture}}".to_owned(),
125            image.clone(),
126        ];
127        let actual_platform = self.run("docker", &inspect_arguments)?;
128        if actual_platform.trim() != self.platform {
129            bail!(
130                "Docker image {image} has platform {}, expected {}",
131                actual_platform.trim(),
132                self.platform
133            );
134        }
135
136        let raw = staging.join("image.tar");
137        let compressed_raw = staging.join("image.tar.xz");
138        let asset_name = expand(&self.asset, &version, None)?;
139        let archive = staging.join(&asset_name);
140        let checksum = staging.join(format!("{asset_name}.sha256"));
141        for path in [&raw, &compressed_raw, &archive, &checksum] {
142            if path.exists() {
143                fs::remove_file(path)
144                    .with_context(|| format!("failed to replace {}", path.display()))?;
145            }
146        }
147        self.run(
148            "docker",
149            &[
150                "save".to_owned(),
151                "-o".to_owned(),
152                raw.display().to_string(),
153                image,
154            ],
155        )?;
156        self.run("xz", &["--threads=0".to_owned(), raw.display().to_string()])?;
157        fs::rename(&compressed_raw, &archive).with_context(|| {
158            format!(
159                "failed to move {} to {}",
160                compressed_raw.display(),
161                archive.display()
162            )
163        })?;
164        let archive_digest = sha256(&archive)?;
165        fs::write(&checksum, format!("{archive_digest}  {asset_name}\n"))
166            .with_context(|| format!("failed to write {}", checksum.display()))?;
167        let checksum_digest = sha256(&checksum)?;
168        let identities = &plan.artifacts;
169        if identities.len() != 2 {
170            bail!("Docker target plan must contain archive and checksum identities");
171        }
172        Ok(ArtifactManifest {
173            target: self.name.clone(),
174            publisher: self.publisher.clone(),
175            release: plan.release.clone(),
176            artifacts: vec![
177                PreparedArtifact {
178                    identity: identities[0].clone(),
179                    path: archive,
180                    sha256: archive_digest,
181                },
182                PreparedArtifact {
183                    identity: identities[1].clone(),
184                    path: checksum,
185                    sha256: checksum_digest,
186                },
187            ],
188        })
189    }
190}
191
192fn sha256(path: &Path) -> Result<String> {
193    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
194    Ok(hex::encode(Sha256::digest(bytes)))
195}