1use std::{
2 fs,
3 path::{Path, PathBuf},
4 process::Command,
5};
6
7use chrono::Utc;
8use flate2::{Compression, write::GzEncoder};
9use sha2::{Digest, Sha256};
10use tar::Builder;
11use tracing::info;
12
13use crate::{DeployError, DeployResult, DeploymentManifest};
14
15#[derive(Debug, Clone)]
16pub struct BundleArtifact {
17 pub bundle_path: PathBuf,
18 pub checksum_sha256: String,
19 pub binary_path: PathBuf,
20}
21
22pub struct BundleBuilder {
23 manifest_path: PathBuf,
24 manifest: DeploymentManifest,
25}
26
27impl BundleBuilder {
28 pub fn new(manifest_path: impl Into<PathBuf>, manifest: DeploymentManifest) -> Self {
29 Self { manifest_path: manifest_path.into(), manifest }
30 }
31
32 pub fn build(&self) -> DeployResult<BundleArtifact> {
33 self.manifest.validate()?;
34 let project_dir = self.manifest_path.parent().ok_or_else(|| DeployError::BundleBuild {
35 message: "manifest path has no parent directory".to_string(),
36 })?;
37 let canonical_project_dir = project_dir.canonicalize()?;
38
39 info!(agent.name = %self.manifest.agent.name, "building deployment bundle");
40
41 let mut build = Command::new("cargo");
42 build.current_dir(project_dir).arg("build");
43 match self.manifest.build.profile.as_str() {
44 "release" => {
45 build.arg("--release");
46 }
47 profile => {
48 build.arg("--profile").arg(profile);
49 }
50 }
51 build.arg("--bin").arg(&self.manifest.agent.binary);
52 if let Some(target) = &self.manifest.build.target {
53 build.arg("--target").arg(target);
54 }
55 if !self.manifest.build.features.is_empty() {
56 build.arg("--features").arg(self.manifest.build.features.join(","));
57 }
58
59 let output = build.output()?;
60 if !output.status.success() {
61 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
62 return Err(DeployError::BundleBuild { message: stderr });
63 }
64
65 let binary_path = self.resolve_binary_path(project_dir)?;
66 if !binary_path.exists() {
67 return Err(DeployError::BundleBuild {
68 message: format!("expected compiled binary at {}", binary_path.display()),
69 });
70 }
71
72 let dist_dir = project_dir.join(".adk-deploy").join("dist");
73 fs::create_dir_all(&dist_dir)?;
74 let timestamp = Utc::now().format("%Y%m%d%H%M%S");
75 let archive_name = format!("{}-{}.tar.gz", self.manifest.agent.name, timestamp);
76 let bundle_path = dist_dir.join(archive_name);
77
78 let file = fs::File::create(&bundle_path)?;
79 let encoder = GzEncoder::new(file, Compression::default());
80 let mut tar = Builder::new(encoder);
81 tar.append_path_with_name(&binary_path, format!("bin/{}", self.manifest.agent.binary))?;
82 tar.append_path_with_name(&self.manifest_path, "adk-deploy.toml")?;
83
84 for asset in &self.manifest.build.assets {
85 if let Some((source, archive_name)) = resolve_asset_path(&canonical_project_dir, asset)?
86 {
87 tar.append_path_with_name(&source, Path::new("assets").join(archive_name))?;
88 }
89 }
90
91 tar.finish()?;
92 let encoder = tar.into_inner()?;
93 let _file = encoder.finish()?;
94
95 let checksum_sha256 = checksum_file(&bundle_path)?;
96 let sums_path =
97 dist_dir.join(format!("{}.sha256", bundle_path.file_name().unwrap().to_string_lossy()));
98 fs::write(&sums_path, format!("{checksum_sha256} {}\n", bundle_path.display()))?;
99
100 Ok(BundleArtifact { bundle_path, checksum_sha256, binary_path })
101 }
102
103 fn resolve_binary_path(&self, project_dir: &Path) -> DeployResult<PathBuf> {
104 let profile = if self.manifest.build.profile == "release" {
105 "release".to_string()
106 } else {
107 self.manifest.build.profile.clone()
108 };
109
110 let mut path = project_dir.join("target");
111 if let Some(target) = &self.manifest.build.target {
112 path = path.join(target);
113 }
114 path = path.join(profile).join(binary_name(&self.manifest.agent.binary));
115 Ok(path)
116 }
117}
118
119fn binary_name(binary: &str) -> String {
120 if cfg!(windows) { format!("{binary}.exe") } else { binary.to_string() }
121}
122
123fn checksum_file(path: &Path) -> DeployResult<String> {
124 let bytes = fs::read(path)?;
125 let mut hasher = Sha256::new();
126 hasher.update(bytes);
127 Ok(hex::encode(hasher.finalize()))
128}
129
130fn resolve_asset_path(project_dir: &Path, asset: &str) -> DeployResult<Option<(PathBuf, PathBuf)>> {
131 let source = project_dir.join(asset);
132 if !source.exists() {
133 return Ok(None);
134 }
135 let canonical_source = source.canonicalize()?;
136 let relative = canonical_source
137 .strip_prefix(project_dir)
138 .map_err(|_| DeployError::BundleBuild {
139 message: format!("asset path escapes project directory: {asset}"),
140 })?
141 .to_path_buf();
142 Ok(Some((canonical_source, relative)))
143}
144
145#[cfg(test)]
146mod tests {
147 use super::resolve_asset_path;
148 use tempfile::tempdir;
149
150 #[test]
151 fn rejects_asset_paths_outside_project_root() {
152 let project = tempdir().unwrap();
153 let outside = tempdir().unwrap();
154 let escaped = outside.path().join("secret.txt");
155 std::fs::write(&escaped, "secret").unwrap();
156
157 let result = resolve_asset_path(project.path(), escaped.to_str().unwrap());
158 assert!(result.is_err());
159 }
160}