Skip to main content

release_tool/target/
docker_archive.rs

1use super::{PreparedDependencies, 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(
103        &self,
104        plan: &TargetPlan,
105        dependencies: &PreparedDependencies,
106        staging: &Path,
107    ) -> Result<ArtifactManifest> {
108        if !dependencies.is_empty() {
109            bail!("Docker archive targets do not accept target dependencies");
110        }
111        if plan.name != self.name || plan.publisher != self.publisher {
112            bail!(
113                "target plan does not belong to Docker target `{}`",
114                self.name
115            );
116        }
117        let expected = self.resolve(&plan.release)?;
118        if expected.artifacts != plan.artifacts {
119            bail!("Docker target plan artifact inventory changed before Prepare");
120        }
121        fs::create_dir_all(staging)
122            .with_context(|| format!("failed to create staging directory {}", staging.display()))?;
123        let version = plan.release.tag.to_string();
124        let image = expand(&self.image, &version, None)?;
125        self.run_configured(&self.build, &version, &image)?;
126        self.run_configured(&self.local_check, &version, &image)?;
127
128        let inspect_arguments = vec![
129            "image".to_owned(),
130            "inspect".to_owned(),
131            "--format".to_owned(),
132            "{{.Os}}/{{.Architecture}}".to_owned(),
133            image.clone(),
134        ];
135        let actual_platform = self.run("docker", &inspect_arguments)?;
136        if actual_platform.trim() != self.platform {
137            bail!(
138                "Docker image {image} has platform {}, expected {}",
139                actual_platform.trim(),
140                self.platform
141            );
142        }
143
144        let raw = staging.join("image.tar");
145        let compressed_raw = staging.join("image.tar.xz");
146        let asset_name = expand(&self.asset, &version, None)?;
147        let archive = staging.join(&asset_name);
148        let checksum = staging.join(format!("{asset_name}.sha256"));
149        for path in [&raw, &compressed_raw, &archive, &checksum] {
150            if path.exists() {
151                fs::remove_file(path)
152                    .with_context(|| format!("failed to replace {}", path.display()))?;
153            }
154        }
155        self.run(
156            "docker",
157            &[
158                "save".to_owned(),
159                "-o".to_owned(),
160                raw.display().to_string(),
161                image,
162            ],
163        )?;
164        self.run("xz", &["--threads=0".to_owned(), raw.display().to_string()])?;
165        fs::rename(&compressed_raw, &archive).with_context(|| {
166            format!(
167                "failed to move {} to {}",
168                compressed_raw.display(),
169                archive.display()
170            )
171        })?;
172        let archive_digest = sha256(&archive)?;
173        fs::write(&checksum, format!("{archive_digest}  {asset_name}\n"))
174            .with_context(|| format!("failed to write {}", checksum.display()))?;
175        let checksum_digest = sha256(&checksum)?;
176        let identities = &plan.artifacts;
177        if identities.len() != 2 {
178            bail!("Docker target plan must contain archive and checksum identities");
179        }
180        Ok(ArtifactManifest {
181            target: self.name.clone(),
182            publisher: self.publisher.clone(),
183            release: plan.release.clone(),
184            artifacts: vec![
185                PreparedArtifact {
186                    identity: identities[0].clone(),
187                    path: archive,
188                    sha256: archive_digest,
189                },
190                PreparedArtifact {
191                    identity: identities[1].clone(),
192                    path: checksum,
193                    sha256: checksum_digest,
194                },
195            ],
196        })
197    }
198}
199
200fn sha256(path: &Path) -> Result<String> {
201    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
202    Ok(hex::encode(Sha256::digest(bytes)))
203}