adk-deploy 0.8.4

Deployment manifest, bundling, and control-plane client for ADK-Rust
Documentation
use std::{
    fs,
    path::{Path, PathBuf},
    process::Command,
};

use chrono::Utc;
use flate2::{Compression, write::GzEncoder};
use sha2::{Digest, Sha256};
use tar::Builder;
use tracing::info;

use crate::{DeployError, DeployResult, DeploymentManifest};

#[derive(Debug, Clone)]
pub struct BundleArtifact {
    pub bundle_path: PathBuf,
    pub checksum_sha256: String,
    pub binary_path: PathBuf,
}

pub struct BundleBuilder {
    manifest_path: PathBuf,
    manifest: DeploymentManifest,
}

impl BundleBuilder {
    pub fn new(manifest_path: impl Into<PathBuf>, manifest: DeploymentManifest) -> Self {
        Self { manifest_path: manifest_path.into(), manifest }
    }

    pub fn build(&self) -> DeployResult<BundleArtifact> {
        self.manifest.validate()?;
        let project_dir = self.manifest_path.parent().ok_or_else(|| DeployError::BundleBuild {
            message: "manifest path has no parent directory".to_string(),
        })?;
        let canonical_project_dir = project_dir.canonicalize()?;

        info!(agent.name = %self.manifest.agent.name, "building deployment bundle");

        let mut build = Command::new("cargo");
        build.current_dir(project_dir).arg("build");
        match self.manifest.build.profile.as_str() {
            "release" => {
                build.arg("--release");
            }
            profile => {
                build.arg("--profile").arg(profile);
            }
        }
        build.arg("--bin").arg(&self.manifest.agent.binary);
        if let Some(target) = &self.manifest.build.target {
            build.arg("--target").arg(target);
        }
        if !self.manifest.build.features.is_empty() {
            build.arg("--features").arg(self.manifest.build.features.join(","));
        }

        let output = build.output()?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
            return Err(DeployError::BundleBuild { message: stderr });
        }

        let binary_path = self.resolve_binary_path(project_dir)?;
        if !binary_path.exists() {
            return Err(DeployError::BundleBuild {
                message: format!("expected compiled binary at {}", binary_path.display()),
            });
        }

        let dist_dir = project_dir.join(".adk-deploy").join("dist");
        fs::create_dir_all(&dist_dir)?;
        let timestamp = Utc::now().format("%Y%m%d%H%M%S");
        let archive_name = format!("{}-{}.tar.gz", self.manifest.agent.name, timestamp);
        let bundle_path = dist_dir.join(archive_name);

        let file = fs::File::create(&bundle_path)?;
        let encoder = GzEncoder::new(file, Compression::default());
        let mut tar = Builder::new(encoder);
        tar.append_path_with_name(&binary_path, format!("bin/{}", self.manifest.agent.binary))?;
        tar.append_path_with_name(&self.manifest_path, "adk-deploy.toml")?;

        for asset in &self.manifest.build.assets {
            if let Some((source, archive_name)) = resolve_asset_path(&canonical_project_dir, asset)?
            {
                tar.append_path_with_name(&source, Path::new("assets").join(archive_name))?;
            }
        }

        tar.finish()?;
        let encoder = tar.into_inner()?;
        let _file = encoder.finish()?;

        let checksum_sha256 = checksum_file(&bundle_path)?;
        let sums_path =
            dist_dir.join(format!("{}.sha256", bundle_path.file_name().unwrap().to_string_lossy()));
        fs::write(&sums_path, format!("{checksum_sha256}  {}\n", bundle_path.display()))?;

        Ok(BundleArtifact { bundle_path, checksum_sha256, binary_path })
    }

    fn resolve_binary_path(&self, project_dir: &Path) -> DeployResult<PathBuf> {
        let profile = if self.manifest.build.profile == "release" {
            "release".to_string()
        } else {
            self.manifest.build.profile.clone()
        };

        let mut path = project_dir.join("target");
        if let Some(target) = &self.manifest.build.target {
            path = path.join(target);
        }
        path = path.join(profile).join(binary_name(&self.manifest.agent.binary));
        Ok(path)
    }
}

fn binary_name(binary: &str) -> String {
    if cfg!(windows) { format!("{binary}.exe") } else { binary.to_string() }
}

fn checksum_file(path: &Path) -> DeployResult<String> {
    let bytes = fs::read(path)?;
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    Ok(hex::encode(hasher.finalize()))
}

fn resolve_asset_path(project_dir: &Path, asset: &str) -> DeployResult<Option<(PathBuf, PathBuf)>> {
    let source = project_dir.join(asset);
    if !source.exists() {
        return Ok(None);
    }
    let canonical_source = source.canonicalize()?;
    let relative = canonical_source
        .strip_prefix(project_dir)
        .map_err(|_| DeployError::BundleBuild {
            message: format!("asset path escapes project directory: {asset}"),
        })?
        .to_path_buf();
    Ok(Some((canonical_source, relative)))
}

#[cfg(test)]
mod tests {
    use super::resolve_asset_path;
    use tempfile::tempdir;

    #[test]
    fn rejects_asset_paths_outside_project_root() {
        let project = tempdir().unwrap();
        let outside = tempdir().unwrap();
        let escaped = outside.path().join("secret.txt");
        std::fs::write(&escaped, "secret").unwrap();

        let result = resolve_asset_path(project.path(), escaped.to_str().unwrap());
        assert!(result.is_err());
    }
}