1use std::collections::{BTreeMap, BTreeSet};
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15
16pub use boatramp_types::compute::*;
17
18use crate::deploy::DeployStore;
19use crate::project::ProjectRef;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum IsolationClass {
30 VmKvm,
32 Namespace,
34 Container,
36 Platform,
38}
39
40impl IsolationClass {
41 pub fn is_strong(self) -> bool {
44 matches!(self, Self::VmKvm | Self::Platform)
45 }
46
47 pub fn satisfies(self, req: IsolationRequirement) -> bool {
49 match req {
50 IsolationRequirement::Trusted => true,
51 IsolationRequirement::Untrusted => self.is_strong(),
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Capabilities {
59 pub isolation: IsolationClass,
61 pub scale_to_zero: bool,
63 pub persistent_volumes: bool,
65 pub max_vcpus: Option<u32>,
67 pub max_mem_mib: Option<u32>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Artifact {
74 VmImages {
76 rootfs_path: String,
78 kernel_path: String,
80 },
81 Rootfs {
83 dir: String,
85 },
86 Image {
88 reference: String,
90 },
91}
92
93#[derive(Debug, Clone)]
95pub struct LaunchRequest {
96 pub workload: String,
98 pub replica: u32,
100 pub spec: ComputeSpec,
102 pub artifact: Artifact,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct InstanceHandle {
109 pub workload: String,
111 pub replica: u32,
113 pub backend_ref: String,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum Scheme {
121 Http,
123 Https,
125}
126
127impl Scheme {
128 pub fn as_str(self) -> &'static str {
130 match self {
131 Self::Http => "http",
132 Self::Https => "https",
133 }
134 }
135}
136
137impl std::fmt::Display for Scheme {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 f.write_str(self.as_str())
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Endpoint {
146 pub scheme: Scheme,
148 pub host: String,
150 pub port: u16,
152}
153
154impl Endpoint {
155 pub fn url(&self) -> String {
157 format!("{}://{}:{}", self.scheme, self.host, self.port)
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct Instance {
164 pub handle: InstanceHandle,
166 pub endpoint: Endpoint,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Health {
173 Healthy,
175 Unhealthy,
177 Unknown,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Snapshot {
185 pub workload: String,
187 pub replica: u32,
189 pub data_ref: String,
191}
192
193#[derive(Debug, thiserror::Error)]
195pub enum BackendError {
196 #[error("operation not supported by this backend")]
198 Unsupported,
199 #[error("materialize: {0}")]
201 Materialize(String),
202 #[error("launch: {0}")]
204 Launch(String),
205 #[error("stop: {0}")]
207 Stop(String),
208 #[error("{0}")]
210 Other(String),
211}
212
213#[async_trait]
219pub trait ComputeBackend: Send + Sync {
220 fn id(&self) -> &'static str;
222
223 fn capabilities(&self) -> Capabilities;
225
226 async fn materialize(&self, spec: &ComputeSpec) -> Result<Artifact, BackendError>;
229
230 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError>;
232
233 async fn stop(&self, handle: &InstanceHandle) -> Result<(), BackendError>;
235
236 async fn health(&self, handle: &InstanceHandle) -> Result<Health, BackendError>;
238
239 async fn snapshot(&self, _handle: &InstanceHandle) -> Result<Option<Snapshot>, BackendError> {
241 Ok(None)
242 }
243
244 async fn restore(&self, _snapshot: &Snapshot) -> Result<Instance, BackendError> {
246 Err(BackendError::Unsupported)
247 }
248}
249
250#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(default, deny_unknown_fields)]
258pub struct BackendPolicy {
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub allow: Option<Vec<String>>,
262 #[serde(skip_serializing_if = "Vec::is_empty")]
264 pub forbid: Vec<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub force: Option<String>,
268 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
275 pub require_strong_isolation: bool,
276}
277
278impl BackendPolicy {
279 pub fn permits(&self, id: &str) -> bool {
281 if let Some(force) = &self.force {
282 return id == force;
283 }
284 if self.forbid.iter().any(|x| x == id) {
285 return false;
286 }
287 match &self.allow {
288 Some(allow) => allow.iter().any(|x| x == id),
289 None => true,
290 }
291 }
292
293 pub fn from_shared_kernel_allowed(allow_shared_kernel: bool) -> Self {
301 Self {
302 require_strong_isolation: !allow_shared_kernel,
303 ..Default::default()
304 }
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct BackendKind {
318 pub id: String,
320 pub isolation: IsolationClass,
322 pub persistent_volumes: bool,
326 pub scale_to_zero: bool,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct Node {
336 pub id: u64,
338 pub region: Option<String>,
340 pub labels: BTreeMap<String, String>,
342 pub free_vcpus: u32,
344 pub free_mem_mib: u32,
346 pub backends: Vec<BackendKind>,
348}
349
350impl Node {
351 fn pick_backend(&self, spec: &ComputeSpec, policy: &BackendPolicy) -> Option<String> {
355 let eligible = |b: &BackendKind| {
356 policy.permits(&b.id)
357 && b.isolation.satisfies(spec.isolation)
358 && (!policy.require_strong_isolation || b.isolation.is_strong())
361 && (spec.volumes.is_empty() || b.persistent_volumes)
364 && (!spec.scale_to_zero || b.scale_to_zero)
367 };
368 if let Some(pref) = &spec.prefer_backend {
369 if let Some(b) = self.backends.iter().find(|b| &b.id == pref && eligible(b)) {
370 return Some(b.id.clone());
371 }
372 }
373 self.backends
374 .iter()
375 .find(|b| eligible(b))
376 .map(|b| b.id.clone())
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct Placement {
383 pub node: u64,
385 pub backend: String,
387}
388
389pub fn place_replicas(
396 count: u32,
397 placement: &PlacementConstraints,
398 spec: &ComputeSpec,
399 nodes: &[Node],
400 policy: &BackendPolicy,
401) -> Vec<Placement> {
402 let need_cpu = spec.vcpus.max(1);
403 let need_mem = spec.mem_mib.max(1);
404
405 let mut free: Vec<(u64, u32, u32, &Node)> = nodes
407 .iter()
408 .filter(|n| placement.allows(n.region.as_deref(), &n.labels))
409 .map(|n| (n.id, n.free_vcpus, n.free_mem_mib, n))
410 .collect();
411
412 let mut placements = Vec::new();
413 for _ in 0..count {
414 let pick = free
416 .iter_mut()
417 .filter(|(_, c, m, n)| {
418 *c >= need_cpu && *m >= need_mem && n.pick_backend(spec, policy).is_some()
419 })
420 .max_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)));
421 match pick {
422 Some(slot) => {
423 let backend = slot
424 .3
425 .pick_backend(spec, policy)
426 .expect("filtered to nodes with an eligible backend");
427 placements.push(Placement {
428 node: slot.0,
429 backend,
430 });
431 slot.1 -= need_cpu;
432 slot.2 -= need_mem;
433 }
434 None => break, }
436 }
437 placements
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
446pub enum ReplicaPhase {
447 #[default]
449 Running,
450 Zero,
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct ObservedInstance {
461 pub handle: InstanceHandle,
463 pub node: u64,
466 pub backend: String,
468 pub endpoint: Endpoint,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub region: Option<String>,
476 pub healthy: bool,
478 #[serde(default)]
481 pub phase: ReplicaPhase,
482 #[serde(default)]
484 pub snapshot: Option<Snapshot>,
485}
486
487pub fn replica_state_key(project: &str, workload: &str, replica: u32) -> String {
490 format!("project/{project}/compute_state/{workload}/{replica}")
491}
492
493pub fn replica_state_prefix(project: &str, workload: &str) -> String {
495 format!("project/{project}/compute_state/{workload}/")
496}
497
498pub fn replica_states_project_prefix(project: &str) -> String {
500 format!("project/{project}/compute_state/")
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
505pub enum Action {
506 Launch {
508 workload: String,
510 replica: u32,
512 node: u64,
514 backend: String,
516 },
517 Stop {
519 handle: InstanceHandle,
521 },
522 Snapshot {
525 handle: InstanceHandle,
527 },
528 Restore {
530 snapshot: Snapshot,
532 node: u64,
534 backend: String,
536 },
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
542pub enum WorkloadActivity {
543 #[default]
546 Active,
547 Idle,
549}
550
551pub fn reconcile_plan(
562 workload: &ComputeWorkload,
563 spec: &ComputeSpec,
564 nodes: &[Node],
565 policy: &BackendPolicy,
566 observed: &[ObservedInstance],
567 activity: WorkloadActivity,
568 caps: &BTreeMap<String, Capabilities>,
569) -> Vec<Action> {
570 let desired = workload.replicas;
571 let mut actions = Vec::new();
572
573 let sleeps =
576 |backend: &str| spec.scale_to_zero && caps.get(backend).is_some_and(|c| c.scale_to_zero);
577
578 let mut healthy: BTreeSet<u32> = BTreeSet::new();
580 let mut terminal: BTreeSet<u32> = BTreeSet::new(); let mut zeroed: BTreeSet<u32> = BTreeSet::new(); for inst in observed
583 .iter()
584 .filter(|i| i.handle.workload == workload.name)
585 {
586 let ord = inst.handle.replica;
587 if ord >= desired {
588 actions.push(Action::Stop {
591 handle: inst.handle.clone(),
592 });
593 } else if inst.phase == ReplicaPhase::Zero {
594 zeroed.insert(ord);
595 if matches!(activity, WorkloadActivity::Active) {
597 if let Some(snapshot) = inst.snapshot.clone() {
598 actions.push(Action::Restore {
599 snapshot,
600 node: inst.node,
601 backend: inst.backend.clone(),
602 });
603 }
604 }
605 } else if inst.healthy {
606 healthy.insert(ord);
607 if matches!(activity, WorkloadActivity::Idle) && sleeps(&inst.backend) {
609 actions.push(Action::Snapshot {
610 handle: inst.handle.clone(),
611 });
612 }
613 } else if matches!(spec.restart, RestartPolicy::Never) {
614 terminal.insert(ord); } else {
616 actions.push(Action::Stop {
617 handle: inst.handle.clone(),
618 });
619 }
621 }
622
623 let need: Vec<u32> = (0..desired)
626 .filter(|ord| !healthy.contains(ord) && !terminal.contains(ord) && !zeroed.contains(ord))
627 .collect();
628 if need.is_empty() {
629 return actions;
630 }
631
632 let placements = place_replicas(need.len() as u32, &workload.placement, spec, nodes, policy);
635 for (ord, place) in need.iter().zip(placements) {
636 actions.push(Action::Launch {
637 workload: workload.name.clone(),
638 replica: *ord,
639 node: place.node,
640 backend: place.backend,
641 });
642 }
643 actions
644}
645
646pub type BackendRegistry = BTreeMap<String, Arc<dyn ComputeBackend>>;
653
654#[async_trait]
660pub trait ActivitySource: Send + Sync {
661 async fn activity(&self, workload: &str) -> WorkloadActivity;
663}
664
665pub struct AlwaysActive;
668
669#[async_trait]
670impl ActivitySource for AlwaysActive {
671 async fn activity(&self, _workload: &str) -> WorkloadActivity {
672 WorkloadActivity::Active
673 }
674}
675
676#[derive(Debug, Default, Clone, PartialEq, Eq)]
678pub struct ReconcileReport {
679 pub launched: usize,
681 pub stopped: usize,
683 pub slept: usize,
685 pub woke: usize,
687 pub errors: Vec<String>,
689}
690
691#[async_trait]
699pub trait ComputeBindingResolver: Send + Sync {
700 async fn resolve(
703 &self,
704 project: &str,
705 workload: &str,
706 replica: u32,
707 bindings: &[ComputeBinding],
708 ) -> Vec<(String, String)>;
709
710 async fn release(
712 &self,
713 project: &str,
714 workload: &str,
715 replica: u32,
716 bindings: &[ComputeBinding],
717 );
718}
719
720#[async_trait]
732pub trait ManagedDbEnvResolver: Send + Sync {
733 async fn managed_db_env(&self, project: &str, workload: &str) -> Vec<(String, String)>;
735
736 fn managed_db_privilege(&self, _project: &str, _workload: &str) -> Option<PrivilegeDirective> {
742 None
743 }
744}
745
746#[derive(Debug, Clone, PartialEq, Eq)]
750pub enum PrivilegeDirective {
751 Rootless { uid: u32, gid: u32 },
754 Caps(Vec<String>),
757}
758
759impl PrivilegeDirective {
760 pub fn apply(&self, spec: &mut ComputeSpec) {
763 match self {
764 Self::Rootless { uid, gid } if spec.user.is_none() => {
765 spec.user = Some(format!("{uid}:{gid}"));
766 }
767 Self::Caps(caps) if spec.cap_add.is_empty() => {
768 spec.cap_add = caps.clone();
769 }
770 _ => {}
771 }
772 }
773}
774
775pub async fn reconcile_once(
785 deploy: &DeployStore,
786 backends: &BackendRegistry,
787 nodes: &[Node],
788 policy: &BackendPolicy,
789 activity: &dyn ActivitySource,
790 resolver: Option<&dyn ComputeBindingResolver>,
791 managed_db: Option<&dyn ManagedDbEnvResolver>,
792) -> Result<ReconcileReport, crate::error::DeployError> {
793 let mut report = ReconcileReport::default();
794 let caps: BTreeMap<String, Capabilities> = backends
796 .iter()
797 .map(|(id, b)| (id.clone(), b.capabilities()))
798 .collect();
799 for (project_name, workload) in deploy.list_compute_workloads_all().await? {
802 let project = ProjectRef::new(&project_name);
803 let Some(spec) = deploy.get_compute_spec(&workload.active).await? else {
804 report
805 .errors
806 .push(format!("{}: active spec missing", workload.name));
807 continue;
808 };
809
810 let mut observed = deploy.list_replica_states(project, &workload.name).await?;
813 for state in &mut observed {
814 if state.phase == ReplicaPhase::Zero {
815 continue;
816 }
817 if let Some(backend) = backends.get(&state.backend) {
818 if let Ok(health) = backend.health(&state.handle).await {
819 state.healthy = matches!(health, Health::Healthy);
820 }
821 }
822 }
823
824 if let Some(resolver) = resolver {
828 if !spec.bindings.is_empty() {
829 for state in &observed {
830 if state.phase == ReplicaPhase::Running {
831 resolver
832 .resolve(
833 &project_name,
834 &workload.name,
835 state.handle.replica,
836 &spec.bindings,
837 )
838 .await;
839 }
840 }
841 }
842 }
843
844 let workload_activity = activity.activity(&workload.name).await;
845 for action in reconcile_plan(
846 &workload,
847 &spec,
848 nodes,
849 policy,
850 &observed,
851 workload_activity,
852 &caps,
853 ) {
854 match action {
855 Action::Launch {
856 workload: wl,
857 replica,
858 node,
859 backend,
860 } => {
861 let Some(b) = backends.get(&backend) else {
862 report
863 .errors
864 .push(format!("{wl}/{replica}: no backend {backend:?}"));
865 continue;
866 };
867 let node_region = region_of_node(nodes, node);
868 let mut launch_env = match resolver {
871 Some(r) if !spec.bindings.is_empty() => {
872 r.resolve(&project_name, &wl, replica, &spec.bindings).await
873 }
874 _ => Vec::new(),
875 };
876 if let Some(m) = managed_db {
881 launch_env.extend(m.managed_db_env(&project_name, &wl).await);
882 }
883 let privilege =
888 managed_db.and_then(|m| m.managed_db_privilege(&project_name, &wl));
889 match launch_one(
890 b.as_ref(),
891 &wl,
892 replica,
893 node,
894 node_region,
895 &spec,
896 &launch_env,
897 privilege.as_ref(),
898 )
899 .await
900 {
901 Ok(state) => match deploy.set_replica_state(project, &state).await {
902 Ok(()) => report.launched += 1,
903 Err(e) => report.errors.push(format!("{wl}/{replica}: persist: {e}")),
904 },
905 Err(e) => report.errors.push(format!("{wl}/{replica}: launch: {e}")),
906 }
907 }
908 Action::Stop { handle } => {
909 if let Some(b) = observed
910 .iter()
911 .find(|o| o.handle == handle)
912 .and_then(|o| backends.get(&o.backend))
913 {
914 if let Err(e) = b.stop(&handle).await {
915 report
916 .errors
917 .push(format!("{}/{}: stop: {e}", handle.workload, handle.replica));
918 }
919 }
920 match deploy
921 .delete_replica_state(project, &handle.workload, handle.replica)
922 .await
923 {
924 Ok(()) => report.stopped += 1,
925 Err(e) => report.errors.push(format!(
926 "{}/{}: forget: {e}",
927 handle.workload, handle.replica
928 )),
929 }
930 if let Some(resolver) = resolver {
932 if !spec.bindings.is_empty() {
933 resolver
934 .release(
935 &project_name,
936 &handle.workload,
937 handle.replica,
938 &spec.bindings,
939 )
940 .await;
941 }
942 }
943 }
944 Action::Snapshot { handle } => {
945 let Some(obs) = observed.iter().find(|o| o.handle == handle).cloned() else {
946 continue; };
948 let Some(b) = backends.get(&obs.backend) else {
949 report.errors.push(format!(
950 "{}/{}: no backend {:?}",
951 handle.workload, handle.replica, obs.backend
952 ));
953 continue;
954 };
955 match b.snapshot(&handle).await {
956 Ok(Some(snapshot)) => {
959 let parked = ObservedInstance {
960 healthy: false,
961 phase: ReplicaPhase::Zero,
962 snapshot: Some(snapshot),
963 ..obs
964 };
965 match deploy.set_replica_state(project, &parked).await {
966 Ok(()) => report.slept += 1,
967 Err(e) => report.errors.push(format!(
968 "{}/{}: persist zero: {e}",
969 handle.workload, handle.replica
970 )),
971 }
972 }
973 Ok(None) => {}
975 Err(e) => report.errors.push(format!(
976 "{}/{}: snapshot: {e}",
977 handle.workload, handle.replica
978 )),
979 }
980 }
981 Action::Restore {
982 snapshot,
983 node,
984 backend,
985 } => {
986 let Some(b) = backends.get(&backend) else {
987 report.errors.push(format!(
988 "{}/{}: no backend {backend:?}",
989 snapshot.workload, snapshot.replica
990 ));
991 continue;
992 };
993 match b.restore(&snapshot).await {
994 Ok(instance) => {
995 let state = ObservedInstance {
996 handle: instance.handle,
997 node,
998 backend: backend.clone(),
999 endpoint: instance.endpoint,
1000 region: region_of_node(nodes, node),
1001 healthy: true,
1002 phase: ReplicaPhase::Running,
1003 snapshot: None,
1004 };
1005 match deploy.set_replica_state(project, &state).await {
1006 Ok(()) => report.woke += 1,
1007 Err(e) => report.errors.push(format!(
1008 "{}/{}: persist running: {e}",
1009 snapshot.workload, snapshot.replica
1010 )),
1011 }
1012 }
1013 Err(e) => report.errors.push(format!(
1014 "{}/{}: restore: {e}",
1015 snapshot.workload, snapshot.replica
1016 )),
1017 }
1018 }
1019 }
1020 }
1021 }
1022 Ok(report)
1023}
1024
1025fn region_of_node(nodes: &[Node], id: u64) -> Option<String> {
1027 nodes
1028 .iter()
1029 .find(|n| n.id == id)
1030 .and_then(|n| n.region.clone())
1031}
1032
1033#[allow(clippy::too_many_arguments)]
1035async fn launch_one(
1036 backend: &dyn ComputeBackend,
1037 workload: &str,
1038 replica: u32,
1039 node: u64,
1040 node_region: Option<String>,
1041 spec: &ComputeSpec,
1042 extra_env: &[(String, String)],
1043 privilege: Option<&PrivilegeDirective>,
1044) -> Result<ObservedInstance, BackendError> {
1045 let artifact = backend.materialize(spec).await?;
1046 let mut spec = spec.clone();
1049 for (k, v) in extra_env {
1050 spec.env.entry(k.clone()).or_insert_with(|| v.clone());
1051 }
1052 if let Some(p) = privilege {
1055 p.apply(&mut spec);
1056 }
1057 let instance = backend
1058 .launch(&LaunchRequest {
1059 workload: workload.to_string(),
1060 replica,
1061 spec: spec.clone(),
1062 artifact,
1063 })
1064 .await?;
1065 Ok(ObservedInstance {
1066 handle: instance.handle,
1067 node,
1068 backend: backend.id().to_string(),
1069 endpoint: instance.endpoint,
1070 region: node_region,
1071 healthy: true,
1072 phase: ReplicaPhase::Running,
1073 snapshot: None,
1074 })
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080
1081 #[test]
1082 fn privilege_directive_applies_without_overriding_operator_values() {
1083 let mut s = spec(1, 64);
1085 PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
1086 assert_eq!(s.user.as_deref(), Some("999:999"));
1087 assert!(s.cap_add.is_empty());
1088
1089 let mut s = spec(1, 64);
1091 s.user = Some("1000".into());
1092 PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
1093 assert_eq!(s.user.as_deref(), Some("1000"));
1094
1095 let mut s = spec(1, 64);
1097 PrivilegeDirective::Caps(vec!["CHOWN".into(), "SETUID".into()]).apply(&mut s);
1098 assert_eq!(s.cap_add, vec!["CHOWN".to_string(), "SETUID".to_string()]);
1099
1100 let mut s = spec(1, 64);
1102 s.cap_add = vec!["NET_BIND_SERVICE".into()];
1103 PrivilegeDirective::Caps(vec!["CHOWN".into()]).apply(&mut s);
1104 assert_eq!(s.cap_add, vec!["NET_BIND_SERVICE".to_string()]);
1105 }
1106
1107 fn spec(vcpus: u32, mem_mib: u32) -> ComputeSpec {
1108 ComputeSpec {
1109 version: 1,
1110 root: RootSource::Rootfs("r".repeat(64)),
1111 kernel: "k".repeat(64),
1112 kernel_cmdline: None,
1113 vcpus,
1114 mem_mib,
1115 entrypoint: vec![],
1116 env: BTreeMap::new(),
1117 port: 80,
1118 restart: RestartPolicy::Always,
1119 scale_to_zero: false,
1120 volumes: vec![],
1121 writable_root: false,
1122 cap_add: Vec::new(),
1123 user: None,
1124 isolation: IsolationRequirement::Trusted,
1125 prefer_backend: None,
1126 bindings: vec![],
1127 }
1128 }
1129
1130 fn workload(replicas: u32, placement: PlacementConstraints) -> ComputeWorkload {
1131 ComputeWorkload {
1132 version: 1,
1133 name: "w".into(),
1134 active: "h".into(),
1135 replicas,
1136 placement,
1137 }
1138 }
1139
1140 fn node(
1141 id: u64,
1142 region: &str,
1143 cpus: u32,
1144 mem: u32,
1145 backends: &[(&str, IsolationClass)],
1146 ) -> Node {
1147 Node {
1148 id,
1149 region: Some(region.into()),
1150 labels: BTreeMap::new(),
1151 free_vcpus: cpus,
1152 free_mem_mib: mem,
1153 backends: backends
1154 .iter()
1155 .map(|(id, iso)| BackendKind {
1156 id: (*id).to_string(),
1157 isolation: *iso,
1158 persistent_volumes: true,
1163 scale_to_zero: true,
1164 })
1165 .collect(),
1166 }
1167 }
1168
1169 fn vmm(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1170 node(id, region, cpus, mem, &[("vmm", IsolationClass::VmKvm)])
1171 }
1172
1173 fn container(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1174 node(
1175 id,
1176 region,
1177 cpus,
1178 mem,
1179 &[("container", IsolationClass::Namespace)],
1180 )
1181 }
1182
1183 #[test]
1184 fn isolation_class_strength_and_satisfaction() {
1185 assert!(IsolationClass::VmKvm.is_strong());
1186 assert!(IsolationClass::Platform.is_strong());
1187 assert!(!IsolationClass::Namespace.is_strong());
1188 assert!(!IsolationClass::Container.is_strong());
1189 assert!(IsolationClass::Namespace.satisfies(IsolationRequirement::Trusted));
1191 assert!(!IsolationClass::Namespace.satisfies(IsolationRequirement::Untrusted));
1192 assert!(IsolationClass::VmKvm.satisfies(IsolationRequirement::Untrusted));
1193 }
1194
1195 #[test]
1196 fn endpoint_url() {
1197 assert_eq!(
1198 Endpoint {
1199 scheme: Scheme::Http,
1200 host: "10.0.0.5".into(),
1201 port: 8080
1202 }
1203 .url(),
1204 "http://10.0.0.5:8080"
1205 );
1206 }
1207
1208 #[test]
1209 fn policy_permits_force_forbid_allow() {
1210 assert!(BackendPolicy::default().permits("vmm"));
1211 let forbid = BackendPolicy {
1212 forbid: vec!["container".into()],
1213 ..Default::default()
1214 };
1215 assert!(forbid.permits("vmm"));
1216 assert!(!forbid.permits("container"));
1217 let allow = BackendPolicy {
1218 allow: Some(vec!["vmm".into()]),
1219 ..Default::default()
1220 };
1221 assert!(allow.permits("vmm"));
1222 assert!(!allow.permits("docker"));
1223 let force = BackendPolicy {
1224 force: Some("vmm".into()),
1225 forbid: vec!["vmm".into()],
1226 ..Default::default()
1227 };
1228 assert!(force.permits("vmm"), "force overrides forbid");
1229 assert!(!force.permits("container"));
1230 }
1231
1232 #[test]
1233 fn policy_from_shared_kernel_allowed_maps_posture_to_strong_isolation() {
1234 assert!(BackendPolicy::from_shared_kernel_allowed(false).require_strong_isolation);
1236 let permissive = BackendPolicy::from_shared_kernel_allowed(true);
1238 assert!(!permissive.require_strong_isolation);
1239 assert_eq!(permissive, BackendPolicy::default());
1240 }
1241
1242 #[test]
1243 fn worst_fit_spreads_and_picks_a_backend() {
1244 let nodes = vec![vmm(1, "eu", 4, 4096), vmm(2, "eu", 4, 4096)];
1245 let placed = place_replicas(
1246 2,
1247 &PlacementConstraints::default(),
1248 &spec(1, 256),
1249 &nodes,
1250 &BackendPolicy::default(),
1251 );
1252 assert_eq!(placed.len(), 2);
1253 assert_ne!(placed[0].node, placed[1].node, "worst-fit → one each");
1254 assert!(placed.iter().all(|p| p.backend == "vmm"));
1255 }
1256
1257 #[test]
1258 fn capacity_shortfall_returns_fewer() {
1259 let nodes = vec![vmm(1, "eu", 4, 8192)];
1260 let placed = place_replicas(
1261 5,
1262 &PlacementConstraints::default(),
1263 &spec(2, 256),
1264 &nodes,
1265 &BackendPolicy::default(),
1266 );
1267 assert_eq!(placed.len(), 2, "only two 2-vCPU replicas fit");
1268 }
1269
1270 #[test]
1271 fn untrusted_skips_shared_kernel_nodes() {
1272 let nodes = vec![container(1, "eu", 8, 8192)];
1274 let mut s = spec(1, 128);
1275 s.isolation = IsolationRequirement::Untrusted;
1276 assert!(place_replicas(
1277 2,
1278 &PlacementConstraints::default(),
1279 &s,
1280 &nodes,
1281 &BackendPolicy::default()
1282 )
1283 .is_empty());
1284 let nodes = vec![vmm(1, "eu", 8, 8192)];
1286 let placed = place_replicas(
1287 2,
1288 &PlacementConstraints::default(),
1289 &s,
1290 &nodes,
1291 &BackendPolicy::default(),
1292 );
1293 assert_eq!(placed.len(), 2);
1294 assert!(placed.iter().all(|p| p.backend == "vmm"));
1295 }
1296
1297 fn node_with_caps(id: &str, iso: IsolationClass, volumes: bool, s2z: bool) -> Node {
1300 Node {
1301 id: 1,
1302 region: Some("eu".into()),
1303 labels: BTreeMap::new(),
1304 free_vcpus: 8,
1305 free_mem_mib: 8192,
1306 backends: vec![BackendKind {
1307 id: id.into(),
1308 isolation: iso,
1309 persistent_volumes: volumes,
1310 scale_to_zero: s2z,
1311 }],
1312 }
1313 }
1314
1315 #[test]
1316 fn volume_spec_needs_a_volume_capable_backend() {
1317 let mut s = spec(1, 128);
1318 s.volumes = vec![VolumeRef {
1319 mount: "/data".into(),
1320 name: "db".into(),
1321 size_mib: 64,
1322 }];
1323 let no_vol = vec![node_with_caps(
1326 "container",
1327 IsolationClass::Namespace,
1328 false,
1329 false,
1330 )];
1331 assert!(
1332 place_replicas(
1333 1,
1334 &PlacementConstraints::default(),
1335 &s,
1336 &no_vol,
1337 &BackendPolicy::default()
1338 )
1339 .is_empty(),
1340 "a volume spec must not place on a volume-incapable backend"
1341 );
1342 let vol_ok = vec![node_with_caps("vmm", IsolationClass::VmKvm, true, false)];
1344 assert_eq!(
1345 place_replicas(
1346 1,
1347 &PlacementConstraints::default(),
1348 &s,
1349 &vol_ok,
1350 &BackendPolicy::default()
1351 )
1352 .len(),
1353 1
1354 );
1355 }
1356
1357 #[test]
1358 fn scale_to_zero_spec_needs_a_capable_backend() {
1359 let mut s = spec(1, 128);
1360 s.scale_to_zero = true;
1361 let no_s2z = vec![node_with_caps(
1364 "docker",
1365 IsolationClass::Container,
1366 false,
1367 false,
1368 )];
1369 assert!(
1370 place_replicas(
1371 1,
1372 &PlacementConstraints::default(),
1373 &s,
1374 &no_s2z,
1375 &BackendPolicy::default()
1376 )
1377 .is_empty(),
1378 "a scale-to-zero spec must not place on a scale-to-zero-incapable backend"
1379 );
1380 let s2z_ok = vec![node_with_caps(
1382 "container",
1383 IsolationClass::Namespace,
1384 false,
1385 true,
1386 )];
1387 assert_eq!(
1388 place_replicas(
1389 1,
1390 &PlacementConstraints::default(),
1391 &s,
1392 &s2z_ok,
1393 &BackendPolicy::default()
1394 )
1395 .len(),
1396 1
1397 );
1398 }
1399
1400 #[test]
1401 fn strict_posture_skips_shared_kernel_even_for_trusted_workload() {
1402 let nodes = vec![container(1, "eu", 8, 8192)];
1405 let s = spec(1, 128); assert_eq!(
1407 place_replicas(
1408 2,
1409 &PlacementConstraints::default(),
1410 &s,
1411 &nodes,
1412 &BackendPolicy::default()
1413 )
1414 .len(),
1415 2,
1416 "a trusted workload uses the shared-kernel node by default"
1417 );
1418 let strict = BackendPolicy {
1420 require_strong_isolation: true,
1421 ..Default::default()
1422 };
1423 assert!(
1424 place_replicas(2, &PlacementConstraints::default(), &s, &nodes, &strict).is_empty(),
1425 "strict posture refuses shared-kernel even for a trusted workload"
1426 );
1427 let vnodes = vec![vmm(1, "eu", 8, 8192)];
1429 assert_eq!(
1430 place_replicas(2, &PlacementConstraints::default(), &s, &vnodes, &strict).len(),
1431 2
1432 );
1433 }
1434
1435 #[test]
1436 fn prefer_backend_is_honored_when_eligible() {
1437 let n = node(
1438 1,
1439 "eu",
1440 8,
1441 8192,
1442 &[
1443 ("vmm", IsolationClass::VmKvm),
1444 ("container", IsolationClass::Namespace),
1445 ],
1446 );
1447 let mut s = spec(1, 128);
1448 s.prefer_backend = Some("container".into());
1449 let placed = place_replicas(
1450 1,
1451 &PlacementConstraints::default(),
1452 &s,
1453 &[n],
1454 &BackendPolicy::default(),
1455 );
1456 assert_eq!(placed[0].backend, "container");
1457 }
1458
1459 #[test]
1460 fn policy_force_overrides_preference() {
1461 let n = node(
1462 1,
1463 "eu",
1464 8,
1465 8192,
1466 &[
1467 ("vmm", IsolationClass::VmKvm),
1468 ("container", IsolationClass::Namespace),
1469 ],
1470 );
1471 let mut s = spec(1, 128);
1472 s.prefer_backend = Some("container".into());
1473 let policy = BackendPolicy {
1474 force: Some("vmm".into()),
1475 ..Default::default()
1476 };
1477 let placed = place_replicas(1, &PlacementConstraints::default(), &s, &[n], &policy);
1478 assert_eq!(
1479 placed[0].backend, "vmm",
1480 "policy force beats the spec preference"
1481 );
1482 }
1483
1484 fn observed(workload: &str, replica: u32, node: u64, healthy: bool) -> ObservedInstance {
1485 ObservedInstance {
1486 handle: InstanceHandle {
1487 workload: workload.into(),
1488 replica,
1489 backend_ref: format!("ref-{replica}"),
1490 },
1491 node,
1492 backend: "vmm".into(),
1493 endpoint: Endpoint {
1494 scheme: Scheme::Http,
1495 host: "10.0.0.2".into(),
1496 port: 80,
1497 },
1498 region: None,
1499 healthy,
1500 phase: ReplicaPhase::Running,
1501 snapshot: None,
1502 }
1503 }
1504
1505 fn zeroed(workload: &str, replica: u32, node: u64) -> ObservedInstance {
1507 let mut o = observed(workload, replica, node, false);
1508 o.phase = ReplicaPhase::Zero;
1509 o.snapshot = Some(Snapshot {
1510 workload: workload.into(),
1511 replica,
1512 data_ref: format!("snap-{replica}"),
1513 });
1514 o
1515 }
1516
1517 fn plan(
1520 wl: &ComputeWorkload,
1521 spec: &ComputeSpec,
1522 nodes: &[Node],
1523 policy: &BackendPolicy,
1524 observed: &[ObservedInstance],
1525 ) -> Vec<Action> {
1526 reconcile_plan(
1527 wl,
1528 spec,
1529 nodes,
1530 policy,
1531 observed,
1532 WorkloadActivity::Active,
1533 &BTreeMap::new(),
1534 )
1535 }
1536
1537 fn s2z_caps() -> BTreeMap<String, Capabilities> {
1540 let mut m = BTreeMap::new();
1541 m.insert(
1542 "vmm".to_string(),
1543 Capabilities {
1544 isolation: IsolationClass::VmKvm,
1545 scale_to_zero: true,
1546 persistent_volumes: false,
1547 max_vcpus: None,
1548 max_mem_mib: None,
1549 },
1550 );
1551 m
1552 }
1553
1554 fn s2z_spec() -> ComputeSpec {
1556 let mut s = spec(1, 256);
1557 s.scale_to_zero = true;
1558 s
1559 }
1560
1561 #[test]
1562 fn idle_running_replica_is_snapshotted_when_scale_to_zero() {
1563 let nodes = vec![vmm(1, "eu", 8, 8192)];
1564 let obs = vec![observed("w", 0, 1, true)];
1565 let actions = reconcile_plan(
1566 &workload(1, Default::default()),
1567 &s2z_spec(),
1568 &nodes,
1569 &BackendPolicy::default(),
1570 &obs,
1571 WorkloadActivity::Idle,
1572 &s2z_caps(),
1573 );
1574 assert_eq!(actions.len(), 1);
1575 assert!(matches!(&actions[0], Action::Snapshot { handle } if handle.replica == 0));
1576 }
1577
1578 #[test]
1579 fn idle_replica_not_snapshotted_without_opt_in_or_capability() {
1580 let nodes = vec![vmm(1, "eu", 8, 8192)];
1581 let obs = vec![observed("w", 0, 1, true)];
1582 let no_cap = reconcile_plan(
1584 &workload(1, Default::default()),
1585 &s2z_spec(),
1586 &nodes,
1587 &BackendPolicy::default(),
1588 &obs,
1589 WorkloadActivity::Idle,
1590 &BTreeMap::new(),
1591 );
1592 assert!(no_cap.is_empty(), "no capable backend: {no_cap:?}");
1593 let no_opt = reconcile_plan(
1595 &workload(1, Default::default()),
1596 &spec(1, 256),
1597 &nodes,
1598 &BackendPolicy::default(),
1599 &obs,
1600 WorkloadActivity::Idle,
1601 &s2z_caps(),
1602 );
1603 assert!(no_opt.is_empty(), "not opted in: {no_opt:?}");
1604 }
1605
1606 #[test]
1607 fn zeroed_replica_wakes_on_activity() {
1608 let nodes = vec![vmm(1, "eu", 8, 8192)];
1609 let obs = vec![zeroed("w", 0, 1)];
1610 let actions = reconcile_plan(
1611 &workload(1, Default::default()),
1612 &s2z_spec(),
1613 &nodes,
1614 &BackendPolicy::default(),
1615 &obs,
1616 WorkloadActivity::Active,
1617 &s2z_caps(),
1618 );
1619 assert_eq!(actions.len(), 1);
1620 assert!(
1621 matches!(&actions[0], Action::Restore { snapshot, node, .. } if snapshot.replica == 0 && *node == 1)
1622 );
1623 }
1624
1625 #[test]
1626 fn zeroed_replica_stays_parked_when_idle_and_is_not_relaunched() {
1627 let nodes = vec![vmm(1, "eu", 8, 8192)];
1628 let obs = vec![zeroed("w", 0, 1)];
1629 let actions = reconcile_plan(
1630 &workload(1, Default::default()),
1631 &s2z_spec(),
1632 &nodes,
1633 &BackendPolicy::default(),
1634 &obs,
1635 WorkloadActivity::Idle,
1636 &s2z_caps(),
1637 );
1638 assert!(
1641 actions.is_empty(),
1642 "parked replica left untouched: {actions:?}"
1643 );
1644 }
1645
1646 #[test]
1647 fn out_of_range_zeroed_replica_is_stopped_not_restored() {
1648 let nodes = vec![vmm(1, "eu", 8, 8192)];
1649 let obs = vec![zeroed("w", 1, 1)]; let actions = reconcile_plan(
1651 &workload(1, Default::default()),
1652 &s2z_spec(),
1653 &nodes,
1654 &BackendPolicy::default(),
1655 &obs,
1656 WorkloadActivity::Active,
1657 &s2z_caps(),
1658 );
1659 assert!(actions
1660 .iter()
1661 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1662 assert!(
1663 !actions.iter().any(|a| matches!(a, Action::Restore { .. })),
1664 "out-of-range parked replica is stopped, not restored"
1665 );
1666 }
1667
1668 #[test]
1669 fn reconcile_scales_up_from_nothing() {
1670 let nodes = vec![vmm(1, "eu", 8, 8192), vmm(2, "eu", 8, 8192)];
1671 let actions = plan(
1672 &workload(2, Default::default()),
1673 &spec(1, 256),
1674 &nodes,
1675 &BackendPolicy::default(),
1676 &[],
1677 );
1678 let launches: Vec<u32> = actions
1679 .iter()
1680 .filter_map(|a| match a {
1681 Action::Launch { replica, .. } => Some(*replica),
1682 _ => None,
1683 })
1684 .collect();
1685 assert_eq!(launches, vec![0, 1], "both ordinals launched");
1686 assert!(!actions.iter().any(|a| matches!(a, Action::Stop { .. })));
1687 }
1688
1689 #[test]
1690 fn reconcile_is_noop_when_at_desired() {
1691 let nodes = vec![vmm(1, "eu", 8, 8192)];
1692 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, true)];
1693 let actions = plan(
1694 &workload(2, Default::default()),
1695 &spec(1, 256),
1696 &nodes,
1697 &BackendPolicy::default(),
1698 &obs,
1699 );
1700 assert!(actions.is_empty(), "already converged");
1701 }
1702
1703 #[test]
1704 fn reconcile_scales_down_stops_out_of_range() {
1705 let nodes = vec![vmm(1, "eu", 8, 8192)];
1706 let obs = vec![
1707 observed("w", 0, 1, true),
1708 observed("w", 1, 1, true),
1709 observed("w", 2, 1, true),
1710 ];
1711 let actions = plan(
1712 &workload(2, Default::default()),
1713 &spec(1, 256),
1714 &nodes,
1715 &BackendPolicy::default(),
1716 &obs,
1717 );
1718 assert_eq!(actions.len(), 1);
1719 assert!(matches!(&actions[0], Action::Stop { handle } if handle.replica == 2));
1720 }
1721
1722 #[test]
1723 fn reconcile_replaces_unhealthy_when_restart_always() {
1724 let nodes = vec![vmm(1, "eu", 8, 8192)];
1725 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1726 let actions = plan(
1727 &workload(2, Default::default()),
1728 &spec(1, 256),
1729 &nodes,
1730 &BackendPolicy::default(),
1731 &obs,
1732 );
1733 assert!(actions
1735 .iter()
1736 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1737 assert!(actions
1738 .iter()
1739 .any(|a| matches!(a, Action::Launch { replica: 1, .. })));
1740 }
1741
1742 #[test]
1743 fn reconcile_leaves_terminal_replicas_for_restart_never() {
1744 let nodes = vec![vmm(1, "eu", 8, 8192)];
1745 let mut s = spec(1, 256);
1746 s.restart = RestartPolicy::Never;
1747 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1748 let actions = plan(
1749 &workload(2, Default::default()),
1750 &s,
1751 &nodes,
1752 &BackendPolicy::default(),
1753 &obs,
1754 );
1755 assert!(
1757 actions.is_empty(),
1758 "run-to-completion replica is terminal: {actions:?}"
1759 );
1760 }
1761
1762 #[test]
1763 fn reconcile_only_touches_its_own_workload() {
1764 let nodes = vec![vmm(1, "eu", 8, 8192)];
1765 let obs = vec![observed("other", 0, 1, true), observed("other", 5, 1, true)];
1766 let actions = plan(
1767 &workload(1, Default::default()),
1768 &spec(1, 256),
1769 &nodes,
1770 &BackendPolicy::default(),
1771 &obs,
1772 );
1773 assert_eq!(actions.len(), 1);
1775 assert!(
1776 matches!(&actions[0], Action::Launch { workload, replica: 0, .. } if workload == "w")
1777 );
1778 }
1779
1780 struct FakeBackend;
1782
1783 #[async_trait]
1784 impl ComputeBackend for FakeBackend {
1785 fn id(&self) -> &'static str {
1786 "fake"
1787 }
1788 fn capabilities(&self) -> Capabilities {
1789 Capabilities {
1790 isolation: IsolationClass::Namespace,
1791 scale_to_zero: false,
1792 persistent_volumes: false,
1793 max_vcpus: None,
1794 max_mem_mib: None,
1795 }
1796 }
1797 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1798 Ok(Artifact::Image {
1799 reference: "img:latest".into(),
1800 })
1801 }
1802 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1803 Ok(Instance {
1804 handle: InstanceHandle {
1805 workload: req.workload.clone(),
1806 replica: req.replica,
1807 backend_ref: format!("fake-{}", req.replica),
1808 },
1809 endpoint: Endpoint {
1810 scheme: Scheme::Http,
1811 host: "127.0.0.1".into(),
1812 port: 8080,
1813 },
1814 })
1815 }
1816 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1817 Ok(())
1818 }
1819 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1820 Ok(Health::Healthy)
1821 }
1822 }
1823
1824 #[tokio::test]
1825 async fn fake_backend_round_trips_through_the_trait() {
1826 let backend: Box<dyn ComputeBackend> = Box::new(FakeBackend);
1827 assert_eq!(backend.id(), "fake");
1828 let s = spec(1, 128);
1829 let artifact = backend.materialize(&s).await.unwrap();
1830 let inst = backend
1831 .launch(&LaunchRequest {
1832 workload: "w".into(),
1833 replica: 0,
1834 spec: s,
1835 artifact,
1836 })
1837 .await
1838 .unwrap();
1839 assert_eq!(inst.endpoint.url(), "http://127.0.0.1:8080");
1840 assert_eq!(backend.health(&inst.handle).await.unwrap(), Health::Healthy);
1841 backend.stop(&inst.handle).await.unwrap();
1842 assert!(backend.snapshot(&inst.handle).await.unwrap().is_none());
1844 }
1845
1846 struct NullStorage;
1849
1850 #[async_trait]
1851 impl crate::Storage for NullStorage {
1852 async fn get(&self, _: &str) -> Result<crate::GetObject, crate::StorageError> {
1853 Err(crate::StorageError::NotFound(String::new()))
1854 }
1855 async fn get_range(
1856 &self,
1857 _: &str,
1858 _: u64,
1859 _: Option<u64>,
1860 ) -> Result<crate::GetObject, crate::StorageError> {
1861 Err(crate::StorageError::NotFound(String::new()))
1862 }
1863 async fn put(
1864 &self,
1865 _: &str,
1866 _: crate::ByteStream,
1867 _: crate::PutMeta,
1868 ) -> Result<crate::ObjectMeta, crate::StorageError> {
1869 Err(crate::StorageError::unsupported("null"))
1870 }
1871 async fn head(&self, _: &str) -> Result<crate::ObjectMeta, crate::StorageError> {
1872 Err(crate::StorageError::NotFound(String::new()))
1873 }
1874 async fn delete(&self, _: &str) -> Result<(), crate::StorageError> {
1875 Ok(())
1876 }
1877 async fn list(&self, _: &str) -> Result<Vec<crate::ObjectMeta>, crate::StorageError> {
1878 Ok(Vec::new())
1879 }
1880 }
1881
1882 fn fake_node() -> Node {
1883 Node {
1884 id: 1,
1885 region: Some("eu".into()),
1886 labels: BTreeMap::new(),
1887 free_vcpus: 8,
1888 free_mem_mib: 8192,
1889 backends: vec![BackendKind {
1890 id: "fake".into(),
1891 isolation: IsolationClass::Namespace,
1892 persistent_volumes: true,
1895 scale_to_zero: true,
1896 }],
1897 }
1898 }
1899
1900 struct S2zBackend;
1904
1905 #[async_trait]
1906 impl ComputeBackend for S2zBackend {
1907 fn id(&self) -> &'static str {
1908 "fake"
1909 }
1910 fn capabilities(&self) -> Capabilities {
1911 Capabilities {
1912 isolation: IsolationClass::Namespace,
1913 scale_to_zero: true,
1914 persistent_volumes: false,
1915 max_vcpus: None,
1916 max_mem_mib: None,
1917 }
1918 }
1919 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1920 Ok(Artifact::Image {
1921 reference: "img:latest".into(),
1922 })
1923 }
1924 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1925 Ok(Instance {
1926 handle: InstanceHandle {
1927 workload: req.workload.clone(),
1928 replica: req.replica,
1929 backend_ref: format!("fake-{}", req.replica),
1930 },
1931 endpoint: Endpoint {
1932 scheme: Scheme::Http,
1933 host: "127.0.0.1".into(),
1934 port: 8080,
1935 },
1936 })
1937 }
1938 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1939 Ok(())
1940 }
1941 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1942 Ok(Health::Healthy)
1943 }
1944 async fn snapshot(
1945 &self,
1946 handle: &InstanceHandle,
1947 ) -> Result<Option<Snapshot>, BackendError> {
1948 Ok(Some(Snapshot {
1949 workload: handle.workload.clone(),
1950 replica: handle.replica,
1951 data_ref: format!("snap-{}", handle.replica),
1952 }))
1953 }
1954 async fn restore(&self, snapshot: &Snapshot) -> Result<Instance, BackendError> {
1955 Ok(Instance {
1956 handle: InstanceHandle {
1957 workload: snapshot.workload.clone(),
1958 replica: snapshot.replica,
1959 backend_ref: format!("restored-{}", snapshot.replica),
1960 },
1961 endpoint: Endpoint {
1962 scheme: Scheme::Http,
1963 host: "127.0.0.1".into(),
1964 port: 8080,
1965 },
1966 })
1967 }
1968 }
1969
1970 struct FixedActivity(WorkloadActivity);
1972
1973 #[async_trait]
1974 impl ActivitySource for FixedActivity {
1975 async fn activity(&self, _workload: &str) -> WorkloadActivity {
1976 self.0
1977 }
1978 }
1979
1980 #[tokio::test]
1981 async fn reconcile_sleeps_idle_replica_then_wakes_it_on_activity() {
1982 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
1983 let mut s = spec(1, 128);
1984 s.scale_to_zero = true;
1985 let hash = deploy.put_compute_spec(&s).await.unwrap();
1986 deploy
1987 .set_compute_workload(
1988 crate::project::ProjectRef::DEFAULT,
1989 &ComputeWorkload {
1990 version: 1,
1991 name: "w".into(),
1992 active: hash,
1993 replicas: 1,
1994 placement: Default::default(),
1995 },
1996 )
1997 .await
1998 .unwrap();
1999 let mut backends: BackendRegistry = BTreeMap::new();
2000 backends.insert("fake".into(), Arc::new(S2zBackend));
2001 let nodes = vec![fake_node()];
2002 let policy = BackendPolicy::default();
2003
2004 let r = reconcile_once(
2006 &deploy,
2007 &backends,
2008 &nodes,
2009 &policy,
2010 &FixedActivity(WorkloadActivity::Active),
2011 None,
2012 None,
2013 )
2014 .await
2015 .unwrap();
2016 assert_eq!(r.launched, 1, "{:?}", r.errors);
2017
2018 let r = reconcile_once(
2020 &deploy,
2021 &backends,
2022 &nodes,
2023 &policy,
2024 &FixedActivity(WorkloadActivity::Idle),
2025 None,
2026 None,
2027 )
2028 .await
2029 .unwrap();
2030 assert_eq!(r.slept, 1, "{:?}", r.errors);
2031 let parked = deploy
2032 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2033 .await
2034 .unwrap();
2035 assert_eq!(parked.len(), 1);
2036 assert_eq!(parked[0].phase, ReplicaPhase::Zero);
2037 assert!(parked[0].snapshot.is_some(), "carries its snapshot");
2038 assert!(!parked[0].healthy);
2039
2040 let r = reconcile_once(
2042 &deploy,
2043 &backends,
2044 &nodes,
2045 &policy,
2046 &FixedActivity(WorkloadActivity::Idle),
2047 None,
2048 None,
2049 )
2050 .await
2051 .unwrap();
2052 assert_eq!((r.slept, r.woke, r.launched), (0, 0, 0), "{:?}", r.errors);
2053
2054 let r = reconcile_once(
2056 &deploy,
2057 &backends,
2058 &nodes,
2059 &policy,
2060 &FixedActivity(WorkloadActivity::Active),
2061 None,
2062 None,
2063 )
2064 .await
2065 .unwrap();
2066 assert_eq!(r.woke, 1, "{:?}", r.errors);
2067 let woken = deploy
2068 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2069 .await
2070 .unwrap();
2071 assert_eq!(woken.len(), 1);
2072 assert_eq!(woken[0].phase, ReplicaPhase::Running);
2073 assert!(woken[0].snapshot.is_none());
2074 assert!(woken[0].healthy);
2075 }
2076
2077 #[tokio::test]
2078 async fn reconcile_once_launches_converges_then_stops() {
2079 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
2080 let s = spec(1, 128);
2081 let hash = deploy.put_compute_spec(&s).await.unwrap();
2082 deploy
2083 .set_compute_workload(
2084 crate::project::ProjectRef::DEFAULT,
2085 &ComputeWorkload {
2086 version: 1,
2087 name: "w".into(),
2088 active: hash.clone(),
2089 replicas: 2,
2090 placement: Default::default(),
2091 },
2092 )
2093 .await
2094 .unwrap();
2095 let nodes = vec![fake_node()];
2096 let mut backends: BackendRegistry = BTreeMap::new();
2097 backends.insert("fake".into(), Arc::new(FakeBackend));
2098 let policy = BackendPolicy::default();
2099
2100 let r = reconcile_once(
2102 &deploy,
2103 &backends,
2104 &nodes,
2105 &policy,
2106 &AlwaysActive,
2107 None,
2108 None,
2109 )
2110 .await
2111 .unwrap();
2112 assert_eq!((r.launched, r.stopped), (2, 0), "{:?}", r.errors);
2113 assert!(r.errors.is_empty(), "{:?}", r.errors);
2114 let states = deploy
2115 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2116 .await
2117 .unwrap();
2118 assert_eq!(states.len(), 2);
2119 assert!(
2121 states.iter().all(|s| s.region.as_deref() == Some("eu")),
2122 "replicas carry their node's region"
2123 );
2124
2125 let r2 = reconcile_once(
2127 &deploy,
2128 &backends,
2129 &nodes,
2130 &policy,
2131 &AlwaysActive,
2132 None,
2133 None,
2134 )
2135 .await
2136 .unwrap();
2137 assert_eq!((r2.launched, r2.stopped), (0, 0));
2138
2139 deploy
2141 .set_compute_workload(
2142 crate::project::ProjectRef::DEFAULT,
2143 &ComputeWorkload {
2144 version: 1,
2145 name: "w".into(),
2146 active: hash,
2147 replicas: 0,
2148 placement: Default::default(),
2149 },
2150 )
2151 .await
2152 .unwrap();
2153 let r3 = reconcile_once(
2154 &deploy,
2155 &backends,
2156 &nodes,
2157 &policy,
2158 &AlwaysActive,
2159 None,
2160 None,
2161 )
2162 .await
2163 .unwrap();
2164 assert_eq!(r3.stopped, 2);
2165 assert!(deploy
2166 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2167 .await
2168 .unwrap()
2169 .is_empty());
2170 }
2171}