AkurAI Build
Menu

AkurAI-Build

public

Latest change df12d49d3b4d5523258f3992e856462728218411 - structure: promote db/server/deploy into the library crate by Olafur Bui

//! Digest-owned deployment bundles and toolchain-free smoke checks.
//!
//! Extracted from `main.rs` after the architecture review: the deploy
//! engine is library code with its own tests, while `main.rs` stays CLI
//! parsing and dispatch.

use std::{
    collections::BTreeMap,
    fs::{self, OpenOptions},
    path::{Component, Path, PathBuf},
    process::Command as ProcessCommand,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};

use crate::{
    DEFAULT_ADDRESS, artifact,
    db::{Database, generate_secret, load_secret},
    server,
};

#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct DeploymentManifest {
    schema: String,
    bunfork_version: String,
    mode: DeployMode,
    listen: String,
    created_at: u64,
    files: BTreeMap<String, artifact::FileRecord>,
}

/// Typed deployment mode: serde parsing rejects unknown values at the
/// boundary, so invalid states are unrepresentable past deserialization.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
enum DeployMode {
    Native,
    Static,
}

pub fn smoke_test(pages: &Path, public: &Path) -> Result<()> {
    ensure!(
        public.is_dir(),
        "public directory does not exist: {}",
        public.display()
    );
    let routes = server::PageRouter::scan(pages)?;
    ensure!(routes.route_count() > 0, "no pages discovered");

    let directory = tempfile::tempdir()?;
    let key_file = directory.path().join("key");
    let live = directory.path().join("live.db");
    let backup = directory.path().join("backup.db");
    generate_secret(&key_file)?;
    let key = load_secret(&key_file, "BUNFORK_TEST_KEY")?;
    let database = Database::open_for_migration(&live, key.as_str())?;
    ensure!(database.migrate()? == 1, "initial migration did not run");
    database.upsert_vector("test", "test", "north", "North", &[1.0, 0.0])?;
    database.upsert_vector("test", "test", "east", "East", &[0.0, 1.0])?;
    let matches = database.search_vectors("test", "test", &[0.9, 0.1], 1)?;
    ensure!(
        matches.first().map(|item| item.id.as_str()) == Some("north"),
        "vector search failed"
    );
    database.backup(&backup, key.as_str())?;
    Database::open_existing(&backup, key.as_str())?.validate_schema()?;
    println!(
        "ok: {} routes, encrypted migration/vector/backup checks passed",
        routes.route_count()
    );
    Ok(())
}

fn ensure_source_checkout() -> Result<()> {
    ensure!(
        Path::new("Cargo.toml").is_file() && Path::new("src").is_dir(),
        "this command must run from the bunfork source checkout \
         (no Cargo.toml in the current directory)"
    );
    Ok(())
}

pub fn deploy(
    out: &Path,
    pages: &Path,
    public: &Path,
    static_artifact: Option<&Path>,
    static_manifest: Option<&Path>,
    force: bool,
) -> Result<()> {
    ensure_source_checkout()?;
    let project = fs::canonicalize(".")?;
    let deploy_lock_path = project.join(".bunfork-deploy.lock");
    let deploy_lock = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(&deploy_lock_path)
        .context("open deployment lock")?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&deploy_lock_path, fs::Permissions::from_mode(0o600))?;
    }
    deploy_lock
        .try_lock()
        .context("another deployment is already running")?;
    validate_deploy_output(out)?;
    let static_bundle = match (static_artifact, static_manifest) {
        (Some(root), Some(manifest_path)) => {
            let root = fs::canonicalize(root).context("resolve static artifact")?;
            let manifest_path =
                fs::canonicalize(manifest_path).context("resolve static manifest")?;
            ensure!(
                root.starts_with(&project) && root != project,
                "static artifact must be a project child"
            );
            ensure!(
                manifest_path.starts_with(&project),
                "static manifest must be a project child"
            );
            let manifest = artifact::read_manifest(&manifest_path)?;
            artifact::verify(&root, &manifest)?;
            Some((root, manifest_path))
        }
        (None, None) => None,
        _ => anyhow::bail!("--artifact and --static-manifest must be used together"),
    };
    let native_sources = if static_bundle.is_none() {
        let pages = fs::canonicalize(pages).context("resolve pages directory")?;
        let public = fs::canonicalize(public).context("resolve public directory")?;
        for (label, source) in [("pages", &pages), ("public", &public)] {
            ensure!(
                source.starts_with(&project) && source != &project,
                "{label} must be a child of the project directory"
            );
        }
        let routes = server::PageRouter::scan(&pages)?;
        ensure!(routes.route_count() > 0, "no pages discovered");
        ensure!(
            public.is_dir(),
            "public directory does not exist: {}",
            public.display()
        );
        server::validate_public_tree(&public)?;
        Some((pages, public))
    } else {
        None
    };
    for document in ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"] {
        ensure!(
            Path::new(document).is_file(),
            "missing deployment document {document}"
        );
    }
    if let Some((pages, public)) = &native_sources {
        run_process("cargo", &["test", "--locked"])?;
        smoke_test(pages, public)?;
    } else {
        run_process("cargo", &["test", "--locked", "--all-targets"])?;
    }
    run_process("cargo", &["build", "--release", "--locked"])?;
    let release_binary = cargo_target_directory()?
        .join("release")
        .join(format!("bunfork{}", std::env::consts::EXE_SUFFIX));

    let absolute_out = project.join(out);
    let temporary = absolute_out.with_extension(format!("tmp-{}", std::process::id()));
    // Timestamp-first suffix keeps preserved releases sortable by age; the
    // pid uniquifies concurrent-second deployments.
    let previous = absolute_out.with_extension(format!(
        "previous-{}-{}",
        timestamp_or_zero(),
        std::process::id()
    ));
    let sources = native_sources
        .as_ref()
        .map(|(pages, public)| vec![pages.as_path(), public.as_path()])
        .or_else(|| {
            static_bundle
                .as_ref()
                .map(|(root, manifest)| vec![root.as_path(), manifest.as_path()])
        })
        .unwrap_or_default();
    for source in sources {
        ensure!(
            !absolute_out.starts_with(source)
                && !temporary.starts_with(source)
                && !source.starts_with(&absolute_out),
            "deployment sources and output must be disjoint"
        );
    }

    let output_exists = fs::symlink_metadata(&absolute_out).ok();
    if let Some(metadata) = &output_exists {
        ensure!(
            force,
            "{} already exists; pass --force to replace it",
            out.display()
        );
        ensure!(
            metadata.is_dir() && !metadata.file_type().is_symlink(),
            "deployment output must be a real directory"
        );
        validate_owned_deployment(&absolute_out)?;
    }
    ensure!(
        fs::symlink_metadata(&temporary).is_err(),
        "temporary deployment path already exists"
    );
    ensure!(
        fs::symlink_metadata(&previous).is_err(),
        "previous deployment path already exists"
    );
    fs::create_dir_all(&temporary)?;

    let result = (|| -> Result<()> {
        if let Some((pages, public)) = &native_sources {
            copy_tree(pages, &temporary.join("app/pages"))?;
            let templates = pages
                .parent()
                .map(|parent| parent.join("templates"))
                .filter(|path| path.is_dir());
            if let Some(templates) = templates {
                copy_tree(&templates, &temporary.join("app/templates"))?;
            }
            copy_tree(public, &temporary.join("public"))?;
        } else if let Some((root, manifest)) = &static_bundle {
            copy_tree(root, &temporary.join("site"))?;
            copy_tree(manifest, &temporary.join("bunfork-static.json"))?;
        }
        copy_tree(Path::new("deploy"), &temporary.join("deploy"))?;
        let binary = temporary.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX));
        copy_regular_file(&release_binary, &binary, true)
            .with_context(|| format!("copy release binary {}", release_binary.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&binary, fs::Permissions::from_mode(0o755))?;
        }
        for document in ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"] {
            copy_tree(Path::new(document), &temporary.join(document))?;
        }
        let manifest = DeploymentManifest {
            schema: "bunfork-deployment-v2".to_owned(),
            bunfork_version: env!("CARGO_PKG_VERSION").to_owned(),
            mode: if static_bundle.is_some() {
                DeployMode::Static
            } else {
                DeployMode::Native
            },
            listen: DEFAULT_ADDRESS.to_owned(),
            created_at: timestamp()?,
            files: artifact::inventory_excluding(&temporary, "bunfork.json")?,
        };
        fs::write(
            temporary.join("bunfork.json"),
            serde_json::to_vec_pretty(&manifest)?,
        )?;
        sync_tree(&temporary)?;
        validate_owned_deployment(&temporary)?;
        if output_exists.is_some() {
            fs::rename(&absolute_out, &previous).context("preserve previous deployment")?;
        }
        if let Err(error) = fs::rename(&temporary, &absolute_out) {
            if output_exists.is_some() {
                let _ = fs::rename(&previous, &absolute_out);
            }
            return Err(error).context("install deployment");
        }
        OpenOptions::new().read(true).open(&project)?.sync_all()?;
        Ok(())
    })();
    if result.is_err() {
        let _ = fs::remove_dir_all(&temporary);
    }
    result?;
    if output_exists.is_some() {
        println!(
            "deployment ready at {}; previous release preserved at {}",
            absolute_out.display(),
            previous.display()
        );
    } else {
        println!(
            "deployment ready at {} (runtime database/key/token paths are not bundled)",
            absolute_out.display()
        );
    }
    deploy_lock.unlock().context("unlock deployment")?;
    Ok(())
}

fn validate_deploy_output(out: &Path) -> Result<()> {
    let mut components = out.components();
    let Some(Component::Normal(name)) = components.next() else {
        anyhow::bail!("deploy output must be one direct child of the project directory");
    };
    ensure!(
        components.next().is_none(),
        "deploy output must be one direct child of the project directory"
    );
    let name = name.to_str().context("deploy output must be valid UTF-8")?;
    ensure!(
        !name.starts_with('.')
            && !matches!(
                name,
                "app" | "backups" | "data" | "deploy" | "migrations" | "public" | "src" | "target"
            ),
        "refuse protected deployment output {name}"
    );
    Ok(())
}

fn validate_owned_deployment(path: &Path) -> Result<()> {
    let manifest_path = path.join("bunfork.json");
    let metadata = fs::symlink_metadata(&manifest_path)
        .with_context(|| format!("refuse to replace unowned directory {}", path.display()))?;
    ensure!(
        metadata.is_file()
            && !metadata.file_type().is_symlink()
            && metadata.len() <= 32 * 1024 * 1024,
        "refuse to replace unowned directory {}: invalid bunfork.json",
        path.display()
    );
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        ensure!(
            metadata.nlink() == 1,
            "deployment manifest cannot be hard-linked"
        );
    }
    let manifest: DeploymentManifest = serde_json::from_slice(
        &fs::read(&manifest_path).context("read deployment ownership manifest")?,
    )
    .context("parse deployment ownership manifest")?;
    // Ownership is proven by the verified v2 schema and the complete digest
    // inventory checked below, not by the producing binary: requiring an
    // exact CARGO_PKG_VERSION (or listen address) match stranded every
    // prior release after an upgrade, forcing users to delete otherwise
    // valid, fully verified deployments by hand.
    ensure!(
        manifest.schema == "bunfork-deployment-v2"
            && !manifest.bunfork_version.is_empty()
            && !manifest.listen.is_empty()
            && !manifest.files.is_empty(),
        "refuse to replace unowned or incompatible directory {}",
        path.display()
    );
    let actual = artifact::inventory_excluding(path, "bunfork.json")?;
    ensure!(
        actual == manifest.files,
        "refuse to replace modified deployment {}",
        path.display()
    );
    let mode_root = if manifest.mode == DeployMode::Static {
        "site"
    } else {
        "app/pages"
    };
    ensure!(
        path.join(mode_root).is_dir(),
        "deployment is missing {mode_root}"
    );
    if manifest.mode == DeployMode::Static {
        let static_manifest = artifact::read_manifest(&path.join("bunfork-static.json"))?;
        artifact::verify(&path.join("site"), &static_manifest)?;
    } else {
        server::PageRouter::scan(&path.join("app/pages"))?;
        server::validate_public_tree(&path.join("public"))?;
    }
    let binary = path.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX));
    let binary_metadata = fs::symlink_metadata(&binary)
        .with_context(|| format!("refuse to replace unowned directory {}", path.display()))?;
    ensure!(
        binary_metadata.is_file() && !binary_metadata.file_type().is_symlink(),
        "refuse to replace unowned directory {}: invalid bunfork binary",
        path.display()
    );
    Ok(())
}

fn sync_tree(root: &Path) -> Result<()> {
    let mut directories = vec![root.to_path_buf()];
    let mut index = 0;
    while index < directories.len() {
        let directory = directories[index].clone();
        index += 1;
        for entry in fs::read_dir(&directory)? {
            let entry = entry?;
            let file_type = entry.file_type()?;
            ensure!(
                !file_type.is_symlink(),
                "deployment tree contains a symlink"
            );
            if file_type.is_dir() {
                directories.push(entry.path());
            } else {
                ensure!(
                    file_type.is_file(),
                    "deployment tree contains a special file"
                );
                OpenOptions::new()
                    .read(true)
                    .open(entry.path())?
                    .sync_all()?;
            }
        }
    }
    for directory in directories.into_iter().rev() {
        OpenOptions::new().read(true).open(directory)?.sync_all()?;
    }
    Ok(())
}

fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
    let metadata = fs::symlink_metadata(source)
        .with_context(|| format!("inspect deployment source {}", source.display()))?;
    ensure!(
        !metadata.file_type().is_symlink(),
        "deployment source cannot contain symlinks"
    );
    if metadata.is_file() {
        return copy_regular_file(source, destination, false);
    }
    ensure!(
        metadata.is_dir(),
        "unsupported deployment source {}",
        source.display()
    );
    fs::create_dir_all(destination)?;
    let mut entries = fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
    entries.sort_by_key(|entry| entry.file_name());
    for entry in entries {
        copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
    }
    Ok(())
}

fn copy_regular_file(source: &Path, destination: &Path, allow_hard_link: bool) -> Result<()> {
    let metadata = fs::symlink_metadata(source)?;
    ensure!(
        metadata.is_file() && !metadata.file_type().is_symlink(),
        "deployment source must be a regular file"
    );
    #[cfg(unix)]
    if !allow_hard_link {
        use std::os::unix::fs::MetadataExt;
        ensure!(
            metadata.nlink() == 1,
            "deployment source cannot be hard-linked"
        );
    }
    if let Some(parent) = destination.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut source_options = OpenOptions::new();
    source_options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        source_options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
    }
    let mut input = source_options.open(source)?;
    let mut destination_options = OpenOptions::new();
    destination_options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        destination_options.mode(0o644);
    }
    let mut output = destination_options.open(destination)?;
    std::io::copy(&mut input, &mut output)?;
    output.sync_all()?;
    Ok(())
}

fn run_process(program: &str, arguments: &[&str]) -> Result<()> {
    let status = ProcessCommand::new(program)
        .args(arguments)
        // Dependency build scripts run arbitrary code during cargo test/build;
        // never hand them live Bunfork secrets from the invoking shell.
        .env_remove("BUNFORK_DB_KEY")
        .env_remove("BUNFORK_API_TOKEN")
        .status()
        .with_context(|| format!("run {program}"))?;
    ensure!(
        status.success(),
        "{program} {} failed with {status}",
        arguments.join(" ")
    );
    Ok(())
}

fn cargo_target_directory() -> Result<PathBuf> {
    let output = ProcessCommand::new("cargo")
        .args(["metadata", "--no-deps", "--format-version", "1", "--locked"])
        .output()
        .context("read Cargo target directory")?;
    ensure!(
        output.status.success(),
        "cargo metadata failed with {}",
        output.status
    );
    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)?;
    let target = metadata["target_directory"]
        .as_str()
        .context("cargo metadata omitted target_directory")?;
    Ok(PathBuf::from(target))
}

fn timestamp() -> Result<u64> {
    Ok(SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock is before Unix epoch")?
        .as_secs())
}

pub fn timestamp_or_zero() -> u64 {
    timestamp().unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn refuses_unowned_deployment_directory() {
        let directory = tempfile::tempdir().expect("temporary directory");
        let output = directory.path().join("src");
        fs::create_dir(&output).expect("create sentinel directory");
        fs::write(output.join("sentinel"), b"keep me").expect("write sentinel");

        assert!(validate_owned_deployment(&output).is_err());
        assert_eq!(
            fs::read(output.join("sentinel")).expect("read sentinel"),
            b"keep me"
        );
    }

    #[test]
    fn refuses_protected_deployment_outputs() {
        for path in [
            ".git",
            ".refrepos",
            "app",
            "backups",
            "data",
            "deploy",
            "migrations",
            "public",
            "src",
            "target",
        ] {
            assert!(validate_deploy_output(Path::new(path)).is_err(), "{path}");
        }
        validate_deploy_output(Path::new("bunfork-dist")).expect("safe output");
    }

    #[test]
    fn accepts_complete_owned_deployment_directory() {
        let directory = tempfile::tempdir().expect("temporary directory");
        let output = directory.path().join("dist");
        for required in ["app/pages", "public", "deploy"] {
            fs::create_dir_all(output.join(required)).expect("create deployment tree");
        }
        fs::write(
            output.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX)),
            b"binary",
        )
        .expect("write binary");
        // A manifest produced by an older Bunfork on a custom listen
        // address must still count as owned: replacement safety comes from
        // the digest inventory, not the producing version.
        let manifest = DeploymentManifest {
            schema: "bunfork-deployment-v2".to_owned(),
            bunfork_version: "0.0.1".to_owned(),
            mode: DeployMode::Native,
            listen: "0.0.0.0:3200".to_owned(),
            created_at: 1,
            files: artifact::inventory_excluding(&output, "bunfork.json")
                .expect("inventory deployment"),
        };
        fs::write(
            output.join("bunfork.json"),
            serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
        )
        .expect("write manifest");

        validate_owned_deployment(&output).expect("owned deployment");
        fs::write(
            output.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX)),
            b"mutated",
        )
        .expect("mutate binary");
        assert!(validate_owned_deployment(&output).is_err());
    }
}