Skip to main content

adk_deploy/
bundle.rs

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}