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}