Menu
AkurAI-Build
publicLatest 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());
}
}