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}