use std::path::PathBuf;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("--quorum must be at least 1")]
BadQuorum,
#[error("--primary {0:?} must be one of --region {1:?}")]
PrimaryNotListed(String, Vec<String>),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("running `wrangler deploy` failed (is wrangler installed?): {0}")]
WranglerSpawn(String),
#[error("`wrangler deploy` exited with {0}")]
WranglerFailed(std::process::ExitStatus),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct CloudflareArgs {
#[arg(long = "region", required = true)]
regions: Vec<String>,
#[arg(long)]
primary: String,
#[arg(long, default_value_t = 3)]
quorum: usize,
#[arg(long, default_value = "boatramp:latest")]
image: String,
#[arg(long = "domain")]
domains: Vec<String>,
#[arg(long, default_value = "boatramp-blobs")]
r2_bucket: String,
#[arg(long, default_value = "boatramp-sql")]
d1: String,
#[arg(long, default_value_t = 7000)]
mesh_port: u16,
#[arg(long, default_value = "./cloudflare")]
out: PathBuf,
#[arg(long)]
apply: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Voter,
Learner,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Node {
id: u64,
region: String,
role: Role,
url: String,
}
fn mesh_url(id: u64, mesh_port: u16) -> String {
format!("http://boatramp-node-{id}.internal:{mesh_port}")
}
const RAFT_STORE_DIR: &str = "/var/lib/boatramp/raft";
fn plan_topology(
regions: &[String],
primary: &str,
quorum: usize,
mesh_port: u16,
) -> Result<Vec<Node>> {
if quorum == 0 {
return Err(Error::BadQuorum);
}
if !regions.iter().any(|r| r == primary) {
return Err(Error::PrimaryNotListed(
primary.to_string(),
regions.to_vec(),
));
}
let mut nodes = Vec::new();
let mut id = 1u64;
for _ in 0..quorum {
nodes.push(Node {
id,
region: primary.to_string(),
role: Role::Voter,
url: mesh_url(id, mesh_port),
});
id += 1;
}
for region in regions.iter().filter(|r| *r != primary) {
nodes.push(Node {
id,
region: region.clone(),
role: Role::Learner,
url: mesh_url(id, mesh_port),
});
id += 1;
}
Ok(nodes)
}
fn render_node_config(nodes: &[Node], this_id: u64, mesh_port: u16) -> String {
let _ = nodes;
let mut out = String::new();
out.push_str(
"// Generated by `boatramp cloudflare` — uniform cluster config (boatramp.cfg).\n",
);
if this_id == 1 {
out.push_str("// This is the FOUNDER: start it with env BOATRAMP_CLUSTER_INIT=1.\n");
} else {
out.push_str(
"// This is a JOINER: start it with env BOATRAMP_CLUSTER_JOIN=<ticket>, where the\n\
// ticket comes from `boatramp cluster add` run against the founder.\n",
);
}
out.push_str(
"// The root anchor defaults to serve.auth_root_public_key; the node id is derived\n\
// from its own mesh key. No peer map, no node_id, no bootstrap flag.\n",
);
out.push_str("(\n");
out.push_str(" cluster: (\n");
out.push_str(&format!(" listen: \"0.0.0.0:{mesh_port}\",\n"));
out.push_str(&format!(" store_dir: \"{RAFT_STORE_DIR}\",\n"));
out.push_str(" ),\n");
out.push_str(")\n");
out
}
fn render_dockerfile(mesh_port: u16) -> String {
format!(
"# Generated by `boatramp cloudflare`.\n\
FROM rust:1-slim AS build\n\
WORKDIR /src\n\
COPY . .\n\
RUN cargo build --release -p boatramp --features cluster\n\
\n\
FROM debian:stable-slim\n\
COPY --from=build /src/target/release/boatramp /usr/local/bin/boatramp\n\
# The node-local cluster config is mounted/copied as boatramp.cfg.\n\
COPY boatramp.cfg /etc/boatramp/boatramp.cfg\n\
# Durable Raft store — back this with a persistent volume so a voter\n\
# keeps its log/vote across restarts (CF Containers durable storage).\n\
VOLUME [\"{RAFT_STORE_DIR}\"]\n\
EXPOSE {mesh_port}\n\
ENTRYPOINT [\"boatramp\", \"--config\", \"/etc/boatramp/boatramp.cfg\", \"serve\"]\n"
)
}
fn render_wrangler(args: &CloudflareArgs, nodes: &[Node]) -> String {
let routes: String = args
.domains
.iter()
.map(|d| format!(" {{ \"pattern\": \"{d}/*\", \"zone_name\": \"{d}\" }}"))
.collect::<Vec<_>>()
.join(",\n");
let instances = nodes.len();
format!(
"// Generated by `boatramp cloudflare` — verify schema against the CF\n\
// platform docs.\n\
{{\n\
\x20 \"name\": \"boatramp\",\n\
\x20 // Rust→Wasm edge Worker: `worker-build` emits the Wasm + a thin JS\n\
\x20 // bootstrap shim at build/worker/shim.mjs (the only JS, generated).\n\
\x20 \"main\": \"build/worker/shim.mjs\",\n\
\x20 \"build\": {{ \"command\": \"worker-build --release\" }},\n\
\x20 \"compatibility_date\": \"2025-01-01\",\n\
\x20 \"routes\": [\n{routes}\n ],\n\
\x20 \"containers\": [\n\
\x20\x20\x20 {{ \"class_name\": \"BoatrampNode\", \"image\": \"{image}\", \"instances\": {instances} }}\n\
\x20 ],\n\
\x20 \"durable_objects\": {{\n\
\x20\x20\x20 \"bindings\": [\n\
\x20\x20\x20\x20\x20 {{ \"name\": \"NODE\", \"class_name\": \"BoatrampNode\" }},\n\
\x20\x20\x20\x20\x20 {{ \"name\": \"CACHE\", \"class_name\": \"CacheCoordinator\" }}\n\
\x20\x20\x20 ]\n\
\x20 }},\n\
\x20 \"r2_buckets\": [ {{ \"binding\": \"BLOBS\", \"bucket_name\": \"{r2}\" }} ],\n\
\x20 \"d1_databases\": [ {{ \"binding\": \"SQL\", \"database_name\": \"{d1}\" }} ]\n\
}}\n",
routes = routes,
instances = instances,
image = args.image,
r2 = args.r2_bucket,
d1 = args.d1,
)
}
fn render_worker_rs() -> String {
r#"//! boatramp edge Worker (Rust -> Wasm via workers-rs). Generated by
//! `boatramp cloudflare`. The edge applies the SAME routing as the Container by
//! calling `boatramp_types::route::resolve` over the site's deploy Manifest:
//! redirects/clean-URLs are answered at the edge, files stream from R2, and
//! anything dynamic (proxy, custom 404, handlers, ranges, access control) is
//! forwarded to a boatramp Container. Build with `worker-build --release`;
//! refined against the platform at beta.
//!
//! Depends on `boatramp-types` (not the full `boatramp-core`): the small,
//! wasm-clean routing/config/manifest layer, so the edge wasm stays lean and
//! shares one definition with the origin.
use std::collections::BTreeMap;
use boatramp_types::manifest::Manifest;
use boatramp_types::route::{resolve, Outcome};
use worker::*;
#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
// Only GET/HEAD are served at the edge; everything else is the origin's.
if !matches!(req.method(), Method::Get | Method::Head) {
return forward(req, &env).await;
}
let url = req.url()?;
let blobs = env.bucket("BLOBS")?;
// Load the site's current Manifest (file set + DeployConfig) the Container
// publishes to R2 for the edge. Absent -> let the origin handle it.
let Some(bytes) = read_object(&blobs, "manifest/current.json").await? else {
return forward(req, &env).await;
};
let manifest = match Manifest::from_bytes(&bytes) {
Ok(manifest) => manifest,
Err(_) => return forward(req, &env).await,
};
// The exact routing the Container runs — shared code, never re-implemented.
match resolve(&manifest.config, &manifest.files, url.path()) {
Outcome::Redirect { location, status } => {
let mut headers = Headers::new();
headers.set("location", &location)?;
Ok(Response::empty()?.with_status(status).with_headers(headers))
}
Outcome::File { entry, .. } => match serve_blob(&blobs, &entry).await? {
Some(response) => Ok(response),
None => forward(req, &env).await,
},
// Proxy + custom-404 streaming need the full pipeline -> the Container.
Outcome::Proxy { .. } | Outcome::NotFound { .. } => forward(req, &env).await,
}
}
/// Serve a content-addressed blob (`<2hex>/<hash>`) from R2 with its type.
async fn serve_blob(
blobs: &Bucket,
entry: &boatramp_types::file::FileEntry,
) -> Result<Option<Response>> {
let key = format!("{}/{}", &entry.hash[..2.min(entry.hash.len())], entry.hash);
let Some(bytes) = read_object(blobs, &key).await? else {
return Ok(None);
};
let mut headers = Headers::new();
if let Some(content_type) = &entry.content_type {
headers.set("content-type", content_type)?;
}
headers.set("cache-control", "public")?;
Ok(Some(Response::from_bytes(bytes)?.with_headers(headers)))
}
async fn read_object(blobs: &Bucket, key: &str) -> Result<Option<Vec<u8>>> {
match blobs.get(key).execute().await? {
Some(object) => match object.body() {
Some(body) => Ok(Some(body.bytes().await?)),
None => Ok(None),
},
None => Ok(None),
}
}
/// Forward to a boatramp Container (the cluster runs the full serving pipeline).
async fn forward(req: Request, env: &Env) -> Result<Response> {
let stub = env
.durable_object("NODE")?
.id_from_name("boatramp")?
.get_stub()?;
stub.fetch_with_request(req).await
}
/// Cache-invalidation coordinator (in Rust/Wasm): on a
/// control-plane write a Container POSTs the changed keys here; the DO fans them
/// out to every Container's `/api/cache/invalidate`. The fan-out registry +
/// transport are refined against the Containers API at beta.
#[durable_object]
pub struct CacheCoordinator {
state: State,
env: Env,
}
#[durable_object]
impl DurableObject for CacheCoordinator {
fn new(state: State, env: Env) -> Self {
Self { state, env }
}
async fn fetch(&mut self, mut req: Request) -> Result<Response> {
// Body: {"keys":[...]} -> broadcast to the Container frontends (beta).
let _ = (&self.state, &self.env, req.text().await?, BTreeMap::<String, ()>::new());
Response::empty()
}
}
"#
.to_string()
}
fn render_worker_cargo() -> String {
"# boatramp edge Worker - Rust -> Wasm (workers-rs). Built with `worker-build`.\n\
[package]\n\
name = \"boatramp-edge\"\n\
version = \"0.1.0\"\n\
edition = \"2021\"\n\
\n\
[lib]\n\
crate-type = [\"cdylib\"]\n\
\n\
[dependencies]\n\
worker = \"0.4\"\n\
# Share the Container's routing/config: the edge runs the SAME logic via\n\
# `boatramp_types::route::resolve`. `boatramp-types` is the small,\n\
# wasm-clean layer (no Storage/KV/wasmtime), so the edge wasm stays lean.\n\
# Point this at the deployed boatramp rev.\n\
boatramp-types = { git = \"https://github.com/BoatRamp/BoatRamp\" }\n\
# wasm32-unknown-unknown needs getrandom's browser backend (pulled in\n\
# transitively by boatramp-types).\n\
getrandom = { version = \"0.2\", features = [\"js\"] }\n\
\n\
[profile.release]\n\
opt-level = \"s\"\n\
lto = true\n"
.to_string()
}
fn render_readme(args: &CloudflareArgs, nodes: &[Node]) -> String {
let topo: String = nodes
.iter()
.map(|n| format!("- node {} — {} ({:?})", n.id, n.region, n.role))
.collect::<Vec<_>>()
.join("\n");
format!(
"# boatramp on Cloudflare (generated)\n\n\
boatramp's cluster mode on CF Containers + an edge Worker\n\
(docs/CLOUDFLARE.md).\n\n\
## Topology\n\n{topo}\n\n\
Voting quorum in `{primary}`; other regions are read-only learners.\n\n\
## Deploy\n\n\
1. Build + push the image `{image}` (see `Dockerfile`), one per node \
with that node's `nodes/<id>.cfg` copied to `boatramp.cfg`.\n\
2. `wrangler deploy` (uses `wrangler.jsonc`) — or `boatramp cloudflare \
… --apply`.\n\n\
> Live `wrangler deploy` + the CF platform wiring (Container\n\
> networking for the Raft mesh, always-on voters, durable volumes) are\n\
> require live Cloudflare-platform validation.\n",
topo = topo,
primary = args.primary,
image = args.image,
)
}
pub async fn run(args: CloudflareArgs, _config: &ProjectConfig) -> Result<()> {
let nodes = plan_topology(&args.regions, &args.primary, args.quorum, args.mesh_port)?;
let out = &args.out;
std::fs::create_dir_all(out.join("worker/src"))?;
std::fs::create_dir_all(out.join("nodes"))?;
std::fs::write(out.join("Dockerfile"), render_dockerfile(args.mesh_port))?;
std::fs::write(out.join("wrangler.jsonc"), render_wrangler(&args, &nodes))?;
std::fs::write(out.join("worker/src/lib.rs"), render_worker_rs())?;
std::fs::write(out.join("worker/Cargo.toml"), render_worker_cargo())?;
std::fs::write(out.join("README.md"), render_readme(&args, &nodes))?;
for n in &nodes {
std::fs::write(
out.join("nodes").join(format!("{}.cfg", n.id)),
render_node_config(&nodes, n.id, args.mesh_port),
)?;
}
let voters = nodes.iter().filter(|n| n.role == Role::Voter).count();
let learners = nodes.len() - voters;
tracing::info!(
nodes = nodes.len(), voters, learners, out = %out.display(),
"cloudflare: generated deployment artifacts"
);
println!(
"Generated {} node(s) ({voters} voters in {}, {learners} learner(s)) → {}",
nodes.len(),
args.primary,
out.display()
);
if args.apply {
apply_with_wrangler(out).await?;
} else {
println!("Review the artifacts, then `wrangler deploy` (or re-run with --apply).");
}
Ok(())
}
async fn apply_with_wrangler(out: &std::path::Path) -> Result<()> {
tracing::info!("cloudflare: applying via `wrangler deploy` (beta)");
let status = tokio::process::Command::new("wrangler")
.arg("deploy")
.current_dir(out)
.status()
.await
.map_err(|e| Error::WranglerSpawn(e.to_string()))?;
if !status.success() {
return Err(Error::WranglerFailed(status));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn regions() -> Vec<String> {
vec!["wnam".into(), "enam".into(), "weur".into()]
}
#[test]
fn topology_is_quorum_in_primary_plus_learners_elsewhere() {
let nodes = plan_topology(®ions(), "wnam", 3, 7000).unwrap();
assert_eq!(nodes.len(), 5);
let voters: Vec<&Node> = nodes.iter().filter(|n| n.role == Role::Voter).collect();
assert_eq!(voters.len(), 3);
assert!(voters.iter().all(|n| n.region == "wnam"));
let learners: Vec<&Node> = nodes.iter().filter(|n| n.role == Role::Learner).collect();
assert_eq!(learners.len(), 2);
assert_eq!(nodes[0].id, 1);
assert_eq!(nodes[0].role, Role::Voter);
}
#[test]
fn primary_must_be_a_listed_region() {
assert!(plan_topology(®ions(), "apac", 3, 7000).is_err());
}
#[test]
fn node_config_is_uniform_and_env_designates_founder_vs_joiner() {
let nodes = plan_topology(®ions(), "wnam", 3, 7000).unwrap();
let cfg1 = render_node_config(&nodes, 1, 7000);
assert!(cfg1.contains("BOATRAMP_CLUSTER_INIT=1"));
assert!(!cfg1.contains("node_id:"));
assert!(!cfg1.contains("bootstrap:"));
assert!(!cfg1.contains("voters:"));
assert!(!cfg1.contains("peers:"));
assert!(cfg1.contains(&format!("store_dir: \"{RAFT_STORE_DIR}\"")));
let cfg4 = render_node_config(&nodes, 4, 7000);
assert!(cfg4.contains("BOATRAMP_CLUSTER_JOIN"));
let body = |s: &str| {
s.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
};
assert_eq!(body(&cfg1), body(&cfg4), "the config body is uniform");
}
#[test]
fn node_config_parses_as_a_server_config() {
let nodes = plan_topology(®ions(), "wnam", 3, 7000).unwrap();
let parsed = crate::config::ServerConfig::parse(&render_node_config(&nodes, 1, 7000))
.expect("generated node config is valid boatramp.cfg RON");
let cluster = parsed.cluster.expect("node config has a cluster section");
assert!(cluster.seeds.is_empty()); assert_eq!(
cluster.store_dir.as_deref(),
Some(std::path::Path::new(RAFT_STORE_DIR))
);
}
#[test]
fn dockerfile_declares_the_durable_raft_volume() {
let d = render_dockerfile(7000);
assert!(
d.contains(&format!("VOLUME [\"{RAFT_STORE_DIR}\"]")),
"voters need a persistent volume for the Raft store"
);
assert!(d.contains("--features cluster"));
}
#[test]
fn wrangler_wires_the_bindings_and_routes() {
let args = CloudflareArgs {
regions: regions(),
primary: "wnam".into(),
quorum: 3,
image: "registry/boatramp:v1".into(),
domains: vec!["example.com".into()],
r2_bucket: "blobs".into(),
d1: "sql".into(),
mesh_port: 7000,
out: PathBuf::from("/tmp/x"),
apply: false,
};
let nodes =
plan_topology(&args.regions, &args.primary, args.quorum, args.mesh_port).unwrap();
let w = render_wrangler(&args, &nodes);
assert!(w.contains("registry/boatramp:v1")); assert!(w.contains("\"instances\": 5")); assert!(w.contains("\"bucket_name\": \"blobs\"")); assert!(w.contains("\"database_name\": \"sql\"")); assert!(w.contains("example.com/*")); assert!(w.contains("build/worker/shim.mjs"));
assert!(w.contains("worker-build"));
assert!(w.contains("CacheCoordinator"));
}
#[test]
fn edge_worker_is_rust_wasm_not_js() {
let lib = render_worker_rs();
assert!(lib.contains("use worker::*;"));
assert!(lib.contains("#[event(fetch)]"));
assert!(lib.contains("env.bucket(\"BLOBS\")")); assert!(lib.contains("#[durable_object]")); assert!(!lib.contains("export default")); let cargo = render_worker_cargo();
assert!(cargo.contains("crate-type = [\"cdylib\"]")); assert!(cargo.contains("worker ="));
}
#[test]
fn edge_worker_reuses_boatramp_types_routing() {
let lib = render_worker_rs();
assert!(lib.contains("use boatramp_types::route::{resolve, Outcome};"));
assert!(lib.contains("use boatramp_types::manifest::Manifest;"));
assert!(!lib.contains("boatramp_core"));
assert!(lib.contains("resolve(&manifest.config, &manifest.files, url.path())"));
assert!(lib.contains("Outcome::Redirect"));
assert!(lib.contains("Outcome::File"));
assert!(lib.contains("Outcome::Proxy"));
assert!(lib.contains("Outcome::NotFound"));
let cargo = render_worker_cargo();
assert!(cargo.contains("boatramp-types = { git"));
assert!(!cargo.contains("boatramp-core = { git"));
assert!(cargo.contains("getrandom = { version = \"0.2\", features = [\"js\"] }"));
}
}