boatramp_core/mode.rs
1//! Deployment mode — the single knob that selects the per-mode coordinator
2//! while the guest-facing behavior contract stays identical.
3//!
4//! Every mode furnishes the same primitive — a **single-writer coordinator** —
5//! and that is the *only* thing that fundamentally differs between them:
6//!
7//! | mode | coordinator | metadata | blobs |
8//! | --- | --- | --- | --- |
9//! | [`SingleNode`](DeploymentMode::SingleNode) | the process itself | local KV | local fs |
10//! | [`Cluster`](DeploymentMode::Cluster) | the Raft leader | embedded Raft | shared s3/R2 |
11//! | [`Cloudflare`](DeploymentMode::Cloudflare) | the Raft leader (in Containers) | embedded Raft | R2 |
12//!
13//! The messaging guarantees (`crate::messaging`) and the `wasi:*` interfaces are
14//! identical across all three; a cross-mode conformance suite asserts it.
15//! [`Cloudflare`](DeploymentMode::Cloudflare) is **boatramp's cluster mode running
16//! on Cloudflare Containers** behind an edge Worker — the same Raft-leader
17//! coordinator as self-hosted cluster, not a separate Durable-Object fork — so
18//! the only CF-specific piece is the deployment/management layer
19//! (see `docs/CLOUDFLARE.md`).
20
21use serde::{Deserialize, Serialize};
22
23/// Which deployment mode a boatramp instance runs in.
24///
25/// This is config (uniform across targets), not a backend choice: the same
26/// commands and manifests apply in every mode, and only the host-side
27/// coordinator/backends are selected from it.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
29#[serde(rename_all = "kebab-case")]
30pub enum DeploymentMode {
31 /// The default single binary: zero external dependencies, in-process
32 /// everything.
33 #[default]
34 SingleNode,
35 /// N boatramp nodes coordinating among themselves via embedded Raft.
36 Cluster,
37 /// boatramp's cluster mode running on Cloudflare Containers behind an edge
38 /// Worker (same Raft-leader coordinator as [`Cluster`](Self::Cluster)).
39 Cloudflare,
40}
41
42impl DeploymentMode {
43 /// The single-writer coordinator this mode provides — the one piece that
44 /// differs across modes. Cloudflare runs the
45 /// cluster on Containers, so it shares the cluster's coordinator.
46 pub fn coordinator(self) -> &'static str {
47 match self {
48 Self::SingleNode => "the process itself (in-process mutex)",
49 Self::Cluster => "the Raft leader",
50 Self::Cloudflare => "the Raft leader (cluster on CF Containers)",
51 }
52 }
53
54 /// Whether this mode needs boatramp's **managed deployment** layer — a
55 /// platform-specific package + orchestration beyond just running the binary.
56 /// Single-node and self-hosted cluster are run by starting the boatramp
57 /// binary directly; [`Cloudflare`](DeploymentMode::Cloudflare) additionally
58 /// needs the container image, the edge Worker, and the CF binding/topology
59 /// generation (`boatramp deploy --target cloudflare`). The boatramp binary
60 /// itself runs unchanged in either case.
61 pub fn needs_managed_deployment(self) -> bool {
62 matches!(self, Self::Cloudflare)
63 }
64
65 /// The lowercase, kebab-case wire/config name.
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Self::SingleNode => "single-node",
69 Self::Cluster => "cluster",
70 Self::Cloudflare => "cloudflare",
71 }
72 }
73}
74
75impl std::fmt::Display for DeploymentMode {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.write_str(self.as_str())
78 }
79}
80
81impl std::str::FromStr for DeploymentMode {
82 type Err = String;
83
84 fn from_str(s: &str) -> Result<Self, Self::Err> {
85 match s {
86 "single-node" | "single" => Ok(Self::SingleNode),
87 "cluster" => Ok(Self::Cluster),
88 "cloudflare" | "cf" => Ok(Self::Cloudflare),
89 other => Err(format!(
90 "unknown deployment mode `{other}` (expected single-node | cluster | cloudflare)"
91 )),
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn round_trips_through_str() {
102 for mode in [
103 DeploymentMode::SingleNode,
104 DeploymentMode::Cluster,
105 DeploymentMode::Cloudflare,
106 ] {
107 assert_eq!(mode.as_str().parse::<DeploymentMode>().unwrap(), mode);
108 }
109 }
110
111 #[test]
112 fn default_is_single_node() {
113 assert_eq!(DeploymentMode::default(), DeploymentMode::SingleNode);
114 assert!(!DeploymentMode::default().needs_managed_deployment());
115 }
116
117 #[test]
118 fn only_cloudflare_needs_managed_deployment() {
119 // Single-node and cluster are run by starting the binary directly;
120 // Cloudflare additionally needs the container image + edge Worker + CF
121 // bindings (the binary itself runs unchanged, in a Container).
122 assert!(!DeploymentMode::SingleNode.needs_managed_deployment());
123 assert!(!DeploymentMode::Cluster.needs_managed_deployment());
124 assert!(DeploymentMode::Cloudflare.needs_managed_deployment());
125 }
126
127 #[test]
128 fn rejects_unknown_mode() {
129 assert!("kubernetes".parse::<DeploymentMode>().is_err());
130 }
131}