tovuk 0.1.103

Deploy Rust workers, static frontends, and full-stack services to Tovuk.
use super::super::{
    constants::{ARCHIVE_EXCLUDES, ARCHIVE_LIMIT_BYTES, WALK_EXCLUDED_DIRS},
    errors::{Result, agent_error},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use flate2::{Compression, write::GzEncoder};
use std::{fs, io::Cursor, path::Path};
use tar::Header;
use walkdir::{DirEntry, WalkDir};

pub(super) fn create_archive_base64(project_dir: &Path, json_output: bool) -> Result<String> {
    let mut archive = Vec::new();
    {
        let encoder = GzEncoder::new(&mut archive, Compression::default());
        let mut builder = tar::Builder::new(encoder);
        for entry in WalkDir::new(project_dir)
            .into_iter()
            .filter_entry(|entry| !is_archive_excluded_entry(project_dir, entry))
            .flatten()
        {
            if !entry.file_type().is_file() {
                continue;
            }
            let relative = match entry.path().strip_prefix(project_dir) {
                Ok(relative) => relative,
                Err(_error) => continue,
            };
            append_archive_file(&mut builder, entry.path(), relative, json_output)?;
        }
        let encoder = builder.into_inner().map_err(|error| {
            agent_error(
                "archive_failed",
                "Could not create source archive.",
                format!("Check project files and retry: {error}"),
                json_output,
            )
        })?;
        encoder.finish().map_err(|error| {
            agent_error(
                "archive_failed",
                "Could not create source archive.",
                format!("Check project files and retry: {error}"),
                json_output,
            )
        })?;
    }
    if archive.len() > ARCHIVE_LIMIT_BYTES {
        return Err(agent_error(
            "archive_too_large",
            "Source archive is too large.",
            "Remove build outputs, target directories, logs, and local caches before deploying.",
            json_output,
        ));
    }
    Ok(BASE64.encode(archive))
}

fn append_archive_file<W: std::io::Write>(
    builder: &mut tar::Builder<W>,
    source_path: &Path,
    relative: &Path,
    json_output: bool,
) -> Result<()> {
    let archive_path = Path::new(".").join(relative);
    if relative == Path::new("tovuk.toml") {
        let source = fs::read_to_string(source_path).map_err(|error| {
            archive_error(
                format!("Could not read tovuk.toml for deploy: {error}"),
                json_output,
            )
        })?;
        let sanitized = deploy_tovuk_toml(&source, json_output)?;
        return append_archive_bytes(builder, &archive_path, sanitized.as_bytes(), json_output);
    }

    builder
        .append_path_with_name(source_path, archive_path)
        .map_err(|error| {
            archive_error(
                format!("Check project files and retry: {error}"),
                json_output,
            )
        })
}

fn deploy_tovuk_toml(source: &str, json_output: bool) -> Result<String> {
    let mut table = source.parse::<toml::Table>().map_err(|error| {
        archive_error(
            format!("tovuk.toml became invalid before deploy archiving: {error}"),
            json_output,
        )
    })?;
    table.remove("dev");
    toml::to_string_pretty(&table).map_err(|error| {
        archive_error(
            format!("Could not serialize deploy tovuk.toml: {error}"),
            json_output,
        )
    })
}

fn append_archive_bytes<W: std::io::Write>(
    builder: &mut tar::Builder<W>,
    archive_path: &Path,
    bytes: &[u8],
    json_output: bool,
) -> Result<()> {
    let mut header = Header::new_gnu();
    header.set_size(bytes.len() as u64);
    header.set_mode(0o644);
    header.set_cksum();
    builder
        .append_data(&mut header, archive_path, Cursor::new(bytes))
        .map_err(|error| {
            archive_error(
                format!("Check project files and retry: {error}"),
                json_output,
            )
        })
}

fn archive_error(instruction: String, json_output: bool) -> super::super::errors::CliError {
    agent_error(
        "archive_failed",
        "Could not create source archive.",
        instruction,
        json_output,
    )
}

fn is_archive_excluded_entry(project_dir: &Path, entry: &DirEntry) -> bool {
    if entry.path() == project_dir {
        return false;
    }
    let relative = match entry.path().strip_prefix(project_dir) {
        Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
        Err(_error) => return true,
    };
    is_archive_excluded(&relative, entry.file_type().is_dir())
}

fn is_archive_excluded(relative: &str, is_dir: bool) -> bool {
    let basename = relative.rsplit('/').next().unwrap_or(relative);
    if is_dir && WALK_EXCLUDED_DIRS.contains(&basename) {
        return true;
    }
    ARCHIVE_EXCLUDES.iter().any(|pattern| match *pattern {
        "*.pem" => basename_has_extension(basename, "pem"),
        "*.key" => basename_has_extension(basename, "key"),
        "*.p12" => basename_has_extension(basename, "p12"),
        "*.pfx" => basename_has_extension(basename, "pfx"),
        "*.tfstate" => basename_has_extension(basename, "tfstate"),
        "*.tfstate.*" => basename.contains(".tfstate."),
        "*.sqlite" => basename_has_extension(basename, "sqlite"),
        "*.sqlite3" => basename_has_extension(basename, "sqlite3"),
        "*.db" => basename_has_extension(basename, "db"),
        "*.log" => basename_has_extension(basename, "log"),
        "._*" => basename.starts_with("._"),
        ".env.*" => basename.starts_with(".env."),
        pattern => relative == pattern || relative.starts_with(&format!("{pattern}/")),
    })
}

fn basename_has_extension(basename: &str, extension: &str) -> bool {
    Path::new(basename)
        .extension()
        .is_some_and(|value| value.eq_ignore_ascii_case(extension))
}

#[cfg(test)]
mod tests {
    use super::{create_archive_base64, deploy_tovuk_toml, is_archive_excluded};
    use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
    use flate2::read::GzDecoder;
    use std::{
        collections::BTreeMap,
        fs,
        io::Read as _,
        path::PathBuf,
        time::{SystemTime, UNIX_EPOCH},
    };

    #[test]
    fn excludes_common_frontend_build_outputs() {
        for path in [
            ".next/server/app/page.js",
            "out/index.html",
            "dist/assets/app.js",
            "build/static/app.js",
            ".cache/tool/state",
            ".turbo/cache/file",
        ] {
            assert!(
                is_archive_excluded(path, false),
                "{path} should be excluded"
            );
        }
    }

    #[test]
    fn deploy_tovuk_toml_strips_local_dev_config() -> Result<(), Box<dyn std::error::Error>> {
        let sanitized = deploy_tovuk_toml(
            r#"
name = "demo"
kind = "fullstack"

[dev]
worker_port = 3001
frontend_port = 5174

[capabilities]
static_frontend = true
worker = true
"#,
            true,
        )?;

        let table = sanitized.parse::<toml::Table>()?;
        if table.get("dev").is_some() {
            return Err("deploy tovuk.toml should not include [dev]".into());
        }
        if table.get("name").and_then(toml::Value::as_str) != Some("demo") {
            return Err(format!("unexpected sanitized name: {sanitized}").into());
        }
        if table.get("capabilities").is_none() {
            return Err("deploy tovuk.toml should keep [capabilities]".into());
        }
        Ok(())
    }

    #[test]
    fn source_archive_strips_local_dev_config() -> Result<(), Box<dyn std::error::Error>> {
        let project_dir = temp_project_dir("archive-strips-dev")?;
        fs::write(
            project_dir.join("tovuk.toml"),
            r#"
name = "demo"

[dev]
worker_port = 3001

[capabilities]
static_frontend = false
worker = true
"#,
        )?;
        fs::write(project_dir.join("main.rs"), "fn main() {}\n")?;

        let archive = create_archive_base64(&project_dir, true)?;
        let files = unpack_archive(&archive)?;
        let tovuk_toml =
            archive_file(&files, "tovuk.toml").ok_or("archive should include tovuk.toml")?;
        let table = tovuk_toml.parse::<toml::Table>()?;

        if table.get("dev").is_some() {
            return Err("archived tovuk.toml should not include [dev]".into());
        }
        if archive_file(&files, "main.rs") != Some("fn main() {}\n") {
            return Err(format!("archive files were wrong: {:?}", files.keys()).into());
        }

        let _ignore = fs::remove_dir_all(project_dir);
        Ok(())
    }

    fn unpack_archive(
        archive_base64: &str,
    ) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
        let bytes = BASE64.decode(archive_base64)?;
        let decoder = GzDecoder::new(&bytes[..]);
        let mut archive = tar::Archive::new(decoder);
        let mut files = BTreeMap::new();
        for entry in archive.entries()? {
            let mut entry = entry?;
            let path = entry.path()?.to_string_lossy().into_owned();
            let mut contents = String::new();
            entry.read_to_string(&mut contents)?;
            files.insert(path, contents);
        }
        Ok(files)
    }

    fn archive_file<'a>(files: &'a BTreeMap<String, String>, path: &str) -> Option<&'a str> {
        files
            .iter()
            .find(|(archive_path, _contents)| archive_path.trim_start_matches("./") == path)
            .map(|(_archive_path, contents)| contents.as_str())
    }

    fn temp_project_dir(name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
        let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
        let path = std::env::temp_dir().join(format!("tovuk-{name}-{nanos}"));
        let _ignore = fs::remove_dir_all(&path);
        fs::create_dir_all(&path)?;
        Ok(path)
    }
}