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
720pub async fn reconcile_once(
730 deploy: &DeployStore,
731 backends: &BackendRegistry,
732 nodes: &[Node],
733 policy: &BackendPolicy,
734 activity: &dyn ActivitySource,
735 resolver: Option<&dyn ComputeBindingResolver>,
736) -> Result<ReconcileReport, crate::error::DeployError> {
737 let mut report = ReconcileReport::default();
738 let caps: BTreeMap<String, Capabilities> = backends
740 .iter()
741 .map(|(id, b)| (id.clone(), b.capabilities()))
742 .collect();
743 for (project_name, workload) in deploy.list_compute_workloads_all().await? {
746 let project = ProjectRef::new(&project_name);
747 let Some(spec) = deploy.get_compute_spec(&workload.active).await? else {
748 report
749 .errors
750 .push(format!("{}: active spec missing", workload.name));
751 continue;
752 };
753
754 let mut observed = deploy.list_replica_states(project, &workload.name).await?;
757 for state in &mut observed {
758 if state.phase == ReplicaPhase::Zero {
759 continue;
760 }
761 if let Some(backend) = backends.get(&state.backend) {
762 if let Ok(health) = backend.health(&state.handle).await {
763 state.healthy = matches!(health, Health::Healthy);
764 }
765 }
766 }
767
768 if let Some(resolver) = resolver {
772 if !spec.bindings.is_empty() {
773 for state in &observed {
774 if state.phase == ReplicaPhase::Running {
775 resolver
776 .resolve(
777 &project_name,
778 &workload.name,
779 state.handle.replica,
780 &spec.bindings,
781 )
782 .await;
783 }
784 }
785 }
786 }
787
788 let workload_activity = activity.activity(&workload.name).await;
789 for action in reconcile_plan(
790 &workload,
791 &spec,
792 nodes,
793 policy,
794 &observed,
795 workload_activity,
796 &caps,
797 ) {
798 match action {
799 Action::Launch {
800 workload: wl,
801 replica,
802 node,
803 backend,
804 } => {
805 let Some(b) = backends.get(&backend) else {
806 report
807 .errors
808 .push(format!("{wl}/{replica}: no backend {backend:?}"));
809 continue;
810 };
811 let node_region = region_of_node(nodes, node);
812 let binding_env = match resolver {
815 Some(r) if !spec.bindings.is_empty() => {
816 r.resolve(&project_name, &wl, replica, &spec.bindings).await
817 }
818 _ => Vec::new(),
819 };
820 match launch_one(
821 b.as_ref(),
822 &wl,
823 replica,
824 node,
825 node_region,
826 &spec,
827 &binding_env,
828 )
829 .await
830 {
831 Ok(state) => match deploy.set_replica_state(project, &state).await {
832 Ok(()) => report.launched += 1,
833 Err(e) => report.errors.push(format!("{wl}/{replica}: persist: {e}")),
834 },
835 Err(e) => report.errors.push(format!("{wl}/{replica}: launch: {e}")),
836 }
837 }
838 Action::Stop { handle } => {
839 if let Some(b) = observed
840 .iter()
841 .find(|o| o.handle == handle)
842 .and_then(|o| backends.get(&o.backend))
843 {
844 if let Err(e) = b.stop(&handle).await {
845 report
846 .errors
847 .push(format!("{}/{}: stop: {e}", handle.workload, handle.replica));
848 }
849 }
850 match deploy
851 .delete_replica_state(project, &handle.workload, handle.replica)
852 .await
853 {
854 Ok(()) => report.stopped += 1,
855 Err(e) => report.errors.push(format!(
856 "{}/{}: forget: {e}",
857 handle.workload, handle.replica
858 )),
859 }
860 if let Some(resolver) = resolver {
862 if !spec.bindings.is_empty() {
863 resolver
864 .release(
865 &project_name,
866 &handle.workload,
867 handle.replica,
868 &spec.bindings,
869 )
870 .await;
871 }
872 }
873 }
874 Action::Snapshot { handle } => {
875 let Some(obs) = observed.iter().find(|o| o.handle == handle).cloned() else {
876 continue; };
878 let Some(b) = backends.get(&obs.backend) else {
879 report.errors.push(format!(
880 "{}/{}: no backend {:?}",
881 handle.workload, handle.replica, obs.backend
882 ));
883 continue;
884 };
885 match b.snapshot(&handle).await {
886 Ok(Some(snapshot)) => {
889 let parked = ObservedInstance {
890 healthy: false,
891 phase: ReplicaPhase::Zero,
892 snapshot: Some(snapshot),
893 ..obs
894 };
895 match deploy.set_replica_state(project, &parked).await {
896 Ok(()) => report.slept += 1,
897 Err(e) => report.errors.push(format!(
898 "{}/{}: persist zero: {e}",
899 handle.workload, handle.replica
900 )),
901 }
902 }
903 Ok(None) => {}
905 Err(e) => report.errors.push(format!(
906 "{}/{}: snapshot: {e}",
907 handle.workload, handle.replica
908 )),
909 }
910 }
911 Action::Restore {
912 snapshot,
913 node,
914 backend,
915 } => {
916 let Some(b) = backends.get(&backend) else {
917 report.errors.push(format!(
918 "{}/{}: no backend {backend:?}",
919 snapshot.workload, snapshot.replica
920 ));
921 continue;
922 };
923 match b.restore(&snapshot).await {
924 Ok(instance) => {
925 let state = ObservedInstance {
926 handle: instance.handle,
927 node,
928 backend: backend.clone(),
929 endpoint: instance.endpoint,
930 region: region_of_node(nodes, node),
931 healthy: true,
932 phase: ReplicaPhase::Running,
933 snapshot: None,
934 };
935 match deploy.set_replica_state(project, &state).await {
936 Ok(()) => report.woke += 1,
937 Err(e) => report.errors.push(format!(
938 "{}/{}: persist running: {e}",
939 snapshot.workload, snapshot.replica
940 )),
941 }
942 }
943 Err(e) => report.errors.push(format!(
944 "{}/{}: restore: {e}",
945 snapshot.workload, snapshot.replica
946 )),
947 }
948 }
949 }
950 }
951 }
952 Ok(report)
953}
954
955fn region_of_node(nodes: &[Node], id: u64) -> Option<String> {
957 nodes
958 .iter()
959 .find(|n| n.id == id)
960 .and_then(|n| n.region.clone())
961}
962
963async fn launch_one(
965 backend: &dyn ComputeBackend,
966 workload: &str,
967 replica: u32,
968 node: u64,
969 node_region: Option<String>,
970 spec: &ComputeSpec,
971 extra_env: &[(String, String)],
972) -> Result<ObservedInstance, BackendError> {
973 let artifact = backend.materialize(spec).await?;
974 let mut spec = spec.clone();
977 for (k, v) in extra_env {
978 spec.env.entry(k.clone()).or_insert_with(|| v.clone());
979 }
980 let instance = backend
981 .launch(&LaunchRequest {
982 workload: workload.to_string(),
983 replica,
984 spec: spec.clone(),
985 artifact,
986 })
987 .await?;
988 Ok(ObservedInstance {
989 handle: instance.handle,
990 node,
991 backend: backend.id().to_string(),
992 endpoint: instance.endpoint,
993 region: node_region,
994 healthy: true,
995 phase: ReplicaPhase::Running,
996 snapshot: None,
997 })
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 fn spec(vcpus: u32, mem_mib: u32) -> ComputeSpec {
1005 ComputeSpec {
1006 version: 1,
1007 root: RootSource::Rootfs("r".repeat(64)),
1008 kernel: "k".repeat(64),
1009 kernel_cmdline: None,
1010 vcpus,
1011 mem_mib,
1012 entrypoint: vec![],
1013 env: BTreeMap::new(),
1014 port: 80,
1015 restart: RestartPolicy::Always,
1016 scale_to_zero: false,
1017 volumes: vec![],
1018 writable_root: false,
1019 isolation: IsolationRequirement::Trusted,
1020 prefer_backend: None,
1021 bindings: vec![],
1022 }
1023 }
1024
1025 fn workload(replicas: u32, placement: PlacementConstraints) -> ComputeWorkload {
1026 ComputeWorkload {
1027 version: 1,
1028 name: "w".into(),
1029 active: "h".into(),
1030 replicas,
1031 placement,
1032 }
1033 }
1034
1035 fn node(
1036 id: u64,
1037 region: &str,
1038 cpus: u32,
1039 mem: u32,
1040 backends: &[(&str, IsolationClass)],
1041 ) -> Node {
1042 Node {
1043 id,
1044 region: Some(region.into()),
1045 labels: BTreeMap::new(),
1046 free_vcpus: cpus,
1047 free_mem_mib: mem,
1048 backends: backends
1049 .iter()
1050 .map(|(id, iso)| BackendKind {
1051 id: (*id).to_string(),
1052 isolation: *iso,
1053 persistent_volumes: true,
1058 scale_to_zero: true,
1059 })
1060 .collect(),
1061 }
1062 }
1063
1064 fn vmm(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1065 node(id, region, cpus, mem, &[("vmm", IsolationClass::VmKvm)])
1066 }
1067
1068 fn container(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1069 node(
1070 id,
1071 region,
1072 cpus,
1073 mem,
1074 &[("container", IsolationClass::Namespace)],
1075 )
1076 }
1077
1078 #[test]
1079 fn isolation_class_strength_and_satisfaction() {
1080 assert!(IsolationClass::VmKvm.is_strong());
1081 assert!(IsolationClass::Platform.is_strong());
1082 assert!(!IsolationClass::Namespace.is_strong());
1083 assert!(!IsolationClass::Container.is_strong());
1084 assert!(IsolationClass::Namespace.satisfies(IsolationRequirement::Trusted));
1086 assert!(!IsolationClass::Namespace.satisfies(IsolationRequirement::Untrusted));
1087 assert!(IsolationClass::VmKvm.satisfies(IsolationRequirement::Untrusted));
1088 }
1089
1090 #[test]
1091 fn endpoint_url() {
1092 assert_eq!(
1093 Endpoint {
1094 scheme: Scheme::Http,
1095 host: "10.0.0.5".into(),
1096 port: 8080
1097 }
1098 .url(),
1099 "http://10.0.0.5:8080"
1100 );
1101 }
1102
1103 #[test]
1104 fn policy_permits_force_forbid_allow() {
1105 assert!(BackendPolicy::default().permits("vmm"));
1106 let forbid = BackendPolicy {
1107 forbid: vec!["container".into()],
1108 ..Default::default()
1109 };
1110 assert!(forbid.permits("vmm"));
1111 assert!(!forbid.permits("container"));
1112 let allow = BackendPolicy {
1113 allow: Some(vec!["vmm".into()]),
1114 ..Default::default()
1115 };
1116 assert!(allow.permits("vmm"));
1117 assert!(!allow.permits("docker"));
1118 let force = BackendPolicy {
1119 force: Some("vmm".into()),
1120 forbid: vec!["vmm".into()],
1121 ..Default::default()
1122 };
1123 assert!(force.permits("vmm"), "force overrides forbid");
1124 assert!(!force.permits("container"));
1125 }
1126
1127 #[test]
1128 fn policy_from_shared_kernel_allowed_maps_posture_to_strong_isolation() {
1129 assert!(BackendPolicy::from_shared_kernel_allowed(false).require_strong_isolation);
1131 let permissive = BackendPolicy::from_shared_kernel_allowed(true);
1133 assert!(!permissive.require_strong_isolation);
1134 assert_eq!(permissive, BackendPolicy::default());
1135 }
1136
1137 #[test]
1138 fn worst_fit_spreads_and_picks_a_backend() {
1139 let nodes = vec![vmm(1, "eu", 4, 4096), vmm(2, "eu", 4, 4096)];
1140 let placed = place_replicas(
1141 2,
1142 &PlacementConstraints::default(),
1143 &spec(1, 256),
1144 &nodes,
1145 &BackendPolicy::default(),
1146 );
1147 assert_eq!(placed.len(), 2);
1148 assert_ne!(placed[0].node, placed[1].node, "worst-fit → one each");
1149 assert!(placed.iter().all(|p| p.backend == "vmm"));
1150 }
1151
1152 #[test]
1153 fn capacity_shortfall_returns_fewer() {
1154 let nodes = vec![vmm(1, "eu", 4, 8192)];
1155 let placed = place_replicas(
1156 5,
1157 &PlacementConstraints::default(),
1158 &spec(2, 256),
1159 &nodes,
1160 &BackendPolicy::default(),
1161 );
1162 assert_eq!(placed.len(), 2, "only two 2-vCPU replicas fit");
1163 }
1164
1165 #[test]
1166 fn untrusted_skips_shared_kernel_nodes() {
1167 let nodes = vec![container(1, "eu", 8, 8192)];
1169 let mut s = spec(1, 128);
1170 s.isolation = IsolationRequirement::Untrusted;
1171 assert!(place_replicas(
1172 2,
1173 &PlacementConstraints::default(),
1174 &s,
1175 &nodes,
1176 &BackendPolicy::default()
1177 )
1178 .is_empty());
1179 let nodes = vec![vmm(1, "eu", 8, 8192)];
1181 let placed = place_replicas(
1182 2,
1183 &PlacementConstraints::default(),
1184 &s,
1185 &nodes,
1186 &BackendPolicy::default(),
1187 );
1188 assert_eq!(placed.len(), 2);
1189 assert!(placed.iter().all(|p| p.backend == "vmm"));
1190 }
1191
1192 fn node_with_caps(id: &str, iso: IsolationClass, volumes: bool, s2z: bool) -> Node {
1195 Node {
1196 id: 1,
1197 region: Some("eu".into()),
1198 labels: BTreeMap::new(),
1199 free_vcpus: 8,
1200 free_mem_mib: 8192,
1201 backends: vec![BackendKind {
1202 id: id.into(),
1203 isolation: iso,
1204 persistent_volumes: volumes,
1205 scale_to_zero: s2z,
1206 }],
1207 }
1208 }
1209
1210 #[test]
1211 fn volume_spec_needs_a_volume_capable_backend() {
1212 let mut s = spec(1, 128);
1213 s.volumes = vec![VolumeRef {
1214 mount: "/data".into(),
1215 name: "db".into(),
1216 size_mib: 64,
1217 }];
1218 let no_vol = vec![node_with_caps(
1221 "container",
1222 IsolationClass::Namespace,
1223 false,
1224 false,
1225 )];
1226 assert!(
1227 place_replicas(
1228 1,
1229 &PlacementConstraints::default(),
1230 &s,
1231 &no_vol,
1232 &BackendPolicy::default()
1233 )
1234 .is_empty(),
1235 "a volume spec must not place on a volume-incapable backend"
1236 );
1237 let vol_ok = vec![node_with_caps("vmm", IsolationClass::VmKvm, true, false)];
1239 assert_eq!(
1240 place_replicas(
1241 1,
1242 &PlacementConstraints::default(),
1243 &s,
1244 &vol_ok,
1245 &BackendPolicy::default()
1246 )
1247 .len(),
1248 1
1249 );
1250 }
1251
1252 #[test]
1253 fn scale_to_zero_spec_needs_a_capable_backend() {
1254 let mut s = spec(1, 128);
1255 s.scale_to_zero = true;
1256 let no_s2z = vec![node_with_caps(
1259 "docker",
1260 IsolationClass::Container,
1261 false,
1262 false,
1263 )];
1264 assert!(
1265 place_replicas(
1266 1,
1267 &PlacementConstraints::default(),
1268 &s,
1269 &no_s2z,
1270 &BackendPolicy::default()
1271 )
1272 .is_empty(),
1273 "a scale-to-zero spec must not place on a scale-to-zero-incapable backend"
1274 );
1275 let s2z_ok = vec![node_with_caps(
1277 "container",
1278 IsolationClass::Namespace,
1279 false,
1280 true,
1281 )];
1282 assert_eq!(
1283 place_replicas(
1284 1,
1285 &PlacementConstraints::default(),
1286 &s,
1287 &s2z_ok,
1288 &BackendPolicy::default()
1289 )
1290 .len(),
1291 1
1292 );
1293 }
1294
1295 #[test]
1296 fn strict_posture_skips_shared_kernel_even_for_trusted_workload() {
1297 let nodes = vec![container(1, "eu", 8, 8192)];
1300 let s = spec(1, 128); assert_eq!(
1302 place_replicas(
1303 2,
1304 &PlacementConstraints::default(),
1305 &s,
1306 &nodes,
1307 &BackendPolicy::default()
1308 )
1309 .len(),
1310 2,
1311 "a trusted workload uses the shared-kernel node by default"
1312 );
1313 let strict = BackendPolicy {
1315 require_strong_isolation: true,
1316 ..Default::default()
1317 };
1318 assert!(
1319 place_replicas(2, &PlacementConstraints::default(), &s, &nodes, &strict).is_empty(),
1320 "strict posture refuses shared-kernel even for a trusted workload"
1321 );
1322 let vnodes = vec![vmm(1, "eu", 8, 8192)];
1324 assert_eq!(
1325 place_replicas(2, &PlacementConstraints::default(), &s, &vnodes, &strict).len(),
1326 2
1327 );
1328 }
1329
1330 #[test]
1331 fn prefer_backend_is_honored_when_eligible() {
1332 let n = node(
1333 1,
1334 "eu",
1335 8,
1336 8192,
1337 &[
1338 ("vmm", IsolationClass::VmKvm),
1339 ("container", IsolationClass::Namespace),
1340 ],
1341 );
1342 let mut s = spec(1, 128);
1343 s.prefer_backend = Some("container".into());
1344 let placed = place_replicas(
1345 1,
1346 &PlacementConstraints::default(),
1347 &s,
1348 &[n],
1349 &BackendPolicy::default(),
1350 );
1351 assert_eq!(placed[0].backend, "container");
1352 }
1353
1354 #[test]
1355 fn policy_force_overrides_preference() {
1356 let n = node(
1357 1,
1358 "eu",
1359 8,
1360 8192,
1361 &[
1362 ("vmm", IsolationClass::VmKvm),
1363 ("container", IsolationClass::Namespace),
1364 ],
1365 );
1366 let mut s = spec(1, 128);
1367 s.prefer_backend = Some("container".into());
1368 let policy = BackendPolicy {
1369 force: Some("vmm".into()),
1370 ..Default::default()
1371 };
1372 let placed = place_replicas(1, &PlacementConstraints::default(), &s, &[n], &policy);
1373 assert_eq!(
1374 placed[0].backend, "vmm",
1375 "policy force beats the spec preference"
1376 );
1377 }
1378
1379 fn observed(workload: &str, replica: u32, node: u64, healthy: bool) -> ObservedInstance {
1380 ObservedInstance {
1381 handle: InstanceHandle {
1382 workload: workload.into(),
1383 replica,
1384 backend_ref: format!("ref-{replica}"),
1385 },
1386 node,
1387 backend: "vmm".into(),
1388 endpoint: Endpoint {
1389 scheme: Scheme::Http,
1390 host: "10.0.0.2".into(),
1391 port: 80,
1392 },
1393 region: None,
1394 healthy,
1395 phase: ReplicaPhase::Running,
1396 snapshot: None,
1397 }
1398 }
1399
1400 fn zeroed(workload: &str, replica: u32, node: u64) -> ObservedInstance {
1402 let mut o = observed(workload, replica, node, false);
1403 o.phase = ReplicaPhase::Zero;
1404 o.snapshot = Some(Snapshot {
1405 workload: workload.into(),
1406 replica,
1407 data_ref: format!("snap-{replica}"),
1408 });
1409 o
1410 }
1411
1412 fn plan(
1415 wl: &ComputeWorkload,
1416 spec: &ComputeSpec,
1417 nodes: &[Node],
1418 policy: &BackendPolicy,
1419 observed: &[ObservedInstance],
1420 ) -> Vec<Action> {
1421 reconcile_plan(
1422 wl,
1423 spec,
1424 nodes,
1425 policy,
1426 observed,
1427 WorkloadActivity::Active,
1428 &BTreeMap::new(),
1429 )
1430 }
1431
1432 fn s2z_caps() -> BTreeMap<String, Capabilities> {
1435 let mut m = BTreeMap::new();
1436 m.insert(
1437 "vmm".to_string(),
1438 Capabilities {
1439 isolation: IsolationClass::VmKvm,
1440 scale_to_zero: true,
1441 persistent_volumes: false,
1442 max_vcpus: None,
1443 max_mem_mib: None,
1444 },
1445 );
1446 m
1447 }
1448
1449 fn s2z_spec() -> ComputeSpec {
1451 let mut s = spec(1, 256);
1452 s.scale_to_zero = true;
1453 s
1454 }
1455
1456 #[test]
1457 fn idle_running_replica_is_snapshotted_when_scale_to_zero() {
1458 let nodes = vec![vmm(1, "eu", 8, 8192)];
1459 let obs = vec![observed("w", 0, 1, true)];
1460 let actions = reconcile_plan(
1461 &workload(1, Default::default()),
1462 &s2z_spec(),
1463 &nodes,
1464 &BackendPolicy::default(),
1465 &obs,
1466 WorkloadActivity::Idle,
1467 &s2z_caps(),
1468 );
1469 assert_eq!(actions.len(), 1);
1470 assert!(matches!(&actions[0], Action::Snapshot { handle } if handle.replica == 0));
1471 }
1472
1473 #[test]
1474 fn idle_replica_not_snapshotted_without_opt_in_or_capability() {
1475 let nodes = vec![vmm(1, "eu", 8, 8192)];
1476 let obs = vec![observed("w", 0, 1, true)];
1477 let no_cap = reconcile_plan(
1479 &workload(1, Default::default()),
1480 &s2z_spec(),
1481 &nodes,
1482 &BackendPolicy::default(),
1483 &obs,
1484 WorkloadActivity::Idle,
1485 &BTreeMap::new(),
1486 );
1487 assert!(no_cap.is_empty(), "no capable backend: {no_cap:?}");
1488 let no_opt = reconcile_plan(
1490 &workload(1, Default::default()),
1491 &spec(1, 256),
1492 &nodes,
1493 &BackendPolicy::default(),
1494 &obs,
1495 WorkloadActivity::Idle,
1496 &s2z_caps(),
1497 );
1498 assert!(no_opt.is_empty(), "not opted in: {no_opt:?}");
1499 }
1500
1501 #[test]
1502 fn zeroed_replica_wakes_on_activity() {
1503 let nodes = vec![vmm(1, "eu", 8, 8192)];
1504 let obs = vec![zeroed("w", 0, 1)];
1505 let actions = reconcile_plan(
1506 &workload(1, Default::default()),
1507 &s2z_spec(),
1508 &nodes,
1509 &BackendPolicy::default(),
1510 &obs,
1511 WorkloadActivity::Active,
1512 &s2z_caps(),
1513 );
1514 assert_eq!(actions.len(), 1);
1515 assert!(
1516 matches!(&actions[0], Action::Restore { snapshot, node, .. } if snapshot.replica == 0 && *node == 1)
1517 );
1518 }
1519
1520 #[test]
1521 fn zeroed_replica_stays_parked_when_idle_and_is_not_relaunched() {
1522 let nodes = vec![vmm(1, "eu", 8, 8192)];
1523 let obs = vec![zeroed("w", 0, 1)];
1524 let actions = reconcile_plan(
1525 &workload(1, Default::default()),
1526 &s2z_spec(),
1527 &nodes,
1528 &BackendPolicy::default(),
1529 &obs,
1530 WorkloadActivity::Idle,
1531 &s2z_caps(),
1532 );
1533 assert!(
1536 actions.is_empty(),
1537 "parked replica left untouched: {actions:?}"
1538 );
1539 }
1540
1541 #[test]
1542 fn out_of_range_zeroed_replica_is_stopped_not_restored() {
1543 let nodes = vec![vmm(1, "eu", 8, 8192)];
1544 let obs = vec![zeroed("w", 1, 1)]; let actions = reconcile_plan(
1546 &workload(1, Default::default()),
1547 &s2z_spec(),
1548 &nodes,
1549 &BackendPolicy::default(),
1550 &obs,
1551 WorkloadActivity::Active,
1552 &s2z_caps(),
1553 );
1554 assert!(actions
1555 .iter()
1556 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1557 assert!(
1558 !actions.iter().any(|a| matches!(a, Action::Restore { .. })),
1559 "out-of-range parked replica is stopped, not restored"
1560 );
1561 }
1562
1563 #[test]
1564 fn reconcile_scales_up_from_nothing() {
1565 let nodes = vec![vmm(1, "eu", 8, 8192), vmm(2, "eu", 8, 8192)];
1566 let actions = plan(
1567 &workload(2, Default::default()),
1568 &spec(1, 256),
1569 &nodes,
1570 &BackendPolicy::default(),
1571 &[],
1572 );
1573 let launches: Vec<u32> = actions
1574 .iter()
1575 .filter_map(|a| match a {
1576 Action::Launch { replica, .. } => Some(*replica),
1577 _ => None,
1578 })
1579 .collect();
1580 assert_eq!(launches, vec![0, 1], "both ordinals launched");
1581 assert!(!actions.iter().any(|a| matches!(a, Action::Stop { .. })));
1582 }
1583
1584 #[test]
1585 fn reconcile_is_noop_when_at_desired() {
1586 let nodes = vec![vmm(1, "eu", 8, 8192)];
1587 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, true)];
1588 let actions = plan(
1589 &workload(2, Default::default()),
1590 &spec(1, 256),
1591 &nodes,
1592 &BackendPolicy::default(),
1593 &obs,
1594 );
1595 assert!(actions.is_empty(), "already converged");
1596 }
1597
1598 #[test]
1599 fn reconcile_scales_down_stops_out_of_range() {
1600 let nodes = vec![vmm(1, "eu", 8, 8192)];
1601 let obs = vec![
1602 observed("w", 0, 1, true),
1603 observed("w", 1, 1, true),
1604 observed("w", 2, 1, true),
1605 ];
1606 let actions = plan(
1607 &workload(2, Default::default()),
1608 &spec(1, 256),
1609 &nodes,
1610 &BackendPolicy::default(),
1611 &obs,
1612 );
1613 assert_eq!(actions.len(), 1);
1614 assert!(matches!(&actions[0], Action::Stop { handle } if handle.replica == 2));
1615 }
1616
1617 #[test]
1618 fn reconcile_replaces_unhealthy_when_restart_always() {
1619 let nodes = vec![vmm(1, "eu", 8, 8192)];
1620 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1621 let actions = plan(
1622 &workload(2, Default::default()),
1623 &spec(1, 256),
1624 &nodes,
1625 &BackendPolicy::default(),
1626 &obs,
1627 );
1628 assert!(actions
1630 .iter()
1631 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1632 assert!(actions
1633 .iter()
1634 .any(|a| matches!(a, Action::Launch { replica: 1, .. })));
1635 }
1636
1637 #[test]
1638 fn reconcile_leaves_terminal_replicas_for_restart_never() {
1639 let nodes = vec![vmm(1, "eu", 8, 8192)];
1640 let mut s = spec(1, 256);
1641 s.restart = RestartPolicy::Never;
1642 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1643 let actions = plan(
1644 &workload(2, Default::default()),
1645 &s,
1646 &nodes,
1647 &BackendPolicy::default(),
1648 &obs,
1649 );
1650 assert!(
1652 actions.is_empty(),
1653 "run-to-completion replica is terminal: {actions:?}"
1654 );
1655 }
1656
1657 #[test]
1658 fn reconcile_only_touches_its_own_workload() {
1659 let nodes = vec![vmm(1, "eu", 8, 8192)];
1660 let obs = vec![observed("other", 0, 1, true), observed("other", 5, 1, true)];
1661 let actions = plan(
1662 &workload(1, Default::default()),
1663 &spec(1, 256),
1664 &nodes,
1665 &BackendPolicy::default(),
1666 &obs,
1667 );
1668 assert_eq!(actions.len(), 1);
1670 assert!(
1671 matches!(&actions[0], Action::Launch { workload, replica: 0, .. } if workload == "w")
1672 );
1673 }
1674
1675 struct FakeBackend;
1677
1678 #[async_trait]
1679 impl ComputeBackend for FakeBackend {
1680 fn id(&self) -> &'static str {
1681 "fake"
1682 }
1683 fn capabilities(&self) -> Capabilities {
1684 Capabilities {
1685 isolation: IsolationClass::Namespace,
1686 scale_to_zero: false,
1687 persistent_volumes: false,
1688 max_vcpus: None,
1689 max_mem_mib: None,
1690 }
1691 }
1692 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1693 Ok(Artifact::Image {
1694 reference: "img:latest".into(),
1695 })
1696 }
1697 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1698 Ok(Instance {
1699 handle: InstanceHandle {
1700 workload: req.workload.clone(),
1701 replica: req.replica,
1702 backend_ref: format!("fake-{}", req.replica),
1703 },
1704 endpoint: Endpoint {
1705 scheme: Scheme::Http,
1706 host: "127.0.0.1".into(),
1707 port: 8080,
1708 },
1709 })
1710 }
1711 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1712 Ok(())
1713 }
1714 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1715 Ok(Health::Healthy)
1716 }
1717 }
1718
1719 #[tokio::test]
1720 async fn fake_backend_round_trips_through_the_trait() {
1721 let backend: Box<dyn ComputeBackend> = Box::new(FakeBackend);
1722 assert_eq!(backend.id(), "fake");
1723 let s = spec(1, 128);
1724 let artifact = backend.materialize(&s).await.unwrap();
1725 let inst = backend
1726 .launch(&LaunchRequest {
1727 workload: "w".into(),
1728 replica: 0,
1729 spec: s,
1730 artifact,
1731 })
1732 .await
1733 .unwrap();
1734 assert_eq!(inst.endpoint.url(), "http://127.0.0.1:8080");
1735 assert_eq!(backend.health(&inst.handle).await.unwrap(), Health::Healthy);
1736 backend.stop(&inst.handle).await.unwrap();
1737 assert!(backend.snapshot(&inst.handle).await.unwrap().is_none());
1739 }
1740
1741 struct NullStorage;
1744
1745 #[async_trait]
1746 impl crate::Storage for NullStorage {
1747 async fn get(&self, _: &str) -> Result<crate::GetObject, crate::StorageError> {
1748 Err(crate::StorageError::NotFound(String::new()))
1749 }
1750 async fn get_range(
1751 &self,
1752 _: &str,
1753 _: u64,
1754 _: Option<u64>,
1755 ) -> Result<crate::GetObject, crate::StorageError> {
1756 Err(crate::StorageError::NotFound(String::new()))
1757 }
1758 async fn put(
1759 &self,
1760 _: &str,
1761 _: crate::ByteStream,
1762 _: crate::PutMeta,
1763 ) -> Result<crate::ObjectMeta, crate::StorageError> {
1764 Err(crate::StorageError::unsupported("null"))
1765 }
1766 async fn head(&self, _: &str) -> Result<crate::ObjectMeta, crate::StorageError> {
1767 Err(crate::StorageError::NotFound(String::new()))
1768 }
1769 async fn delete(&self, _: &str) -> Result<(), crate::StorageError> {
1770 Ok(())
1771 }
1772 async fn list(&self, _: &str) -> Result<Vec<crate::ObjectMeta>, crate::StorageError> {
1773 Ok(Vec::new())
1774 }
1775 }
1776
1777 fn fake_node() -> Node {
1778 Node {
1779 id: 1,
1780 region: Some("eu".into()),
1781 labels: BTreeMap::new(),
1782 free_vcpus: 8,
1783 free_mem_mib: 8192,
1784 backends: vec![BackendKind {
1785 id: "fake".into(),
1786 isolation: IsolationClass::Namespace,
1787 persistent_volumes: true,
1790 scale_to_zero: true,
1791 }],
1792 }
1793 }
1794
1795 struct S2zBackend;
1799
1800 #[async_trait]
1801 impl ComputeBackend for S2zBackend {
1802 fn id(&self) -> &'static str {
1803 "fake"
1804 }
1805 fn capabilities(&self) -> Capabilities {
1806 Capabilities {
1807 isolation: IsolationClass::Namespace,
1808 scale_to_zero: true,
1809 persistent_volumes: false,
1810 max_vcpus: None,
1811 max_mem_mib: None,
1812 }
1813 }
1814 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1815 Ok(Artifact::Image {
1816 reference: "img:latest".into(),
1817 })
1818 }
1819 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1820 Ok(Instance {
1821 handle: InstanceHandle {
1822 workload: req.workload.clone(),
1823 replica: req.replica,
1824 backend_ref: format!("fake-{}", req.replica),
1825 },
1826 endpoint: Endpoint {
1827 scheme: Scheme::Http,
1828 host: "127.0.0.1".into(),
1829 port: 8080,
1830 },
1831 })
1832 }
1833 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1834 Ok(())
1835 }
1836 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1837 Ok(Health::Healthy)
1838 }
1839 async fn snapshot(
1840 &self,
1841 handle: &InstanceHandle,
1842 ) -> Result<Option<Snapshot>, BackendError> {
1843 Ok(Some(Snapshot {
1844 workload: handle.workload.clone(),
1845 replica: handle.replica,
1846 data_ref: format!("snap-{}", handle.replica),
1847 }))
1848 }
1849 async fn restore(&self, snapshot: &Snapshot) -> Result<Instance, BackendError> {
1850 Ok(Instance {
1851 handle: InstanceHandle {
1852 workload: snapshot.workload.clone(),
1853 replica: snapshot.replica,
1854 backend_ref: format!("restored-{}", snapshot.replica),
1855 },
1856 endpoint: Endpoint {
1857 scheme: Scheme::Http,
1858 host: "127.0.0.1".into(),
1859 port: 8080,
1860 },
1861 })
1862 }
1863 }
1864
1865 struct FixedActivity(WorkloadActivity);
1867
1868 #[async_trait]
1869 impl ActivitySource for FixedActivity {
1870 async fn activity(&self, _workload: &str) -> WorkloadActivity {
1871 self.0
1872 }
1873 }
1874
1875 #[tokio::test]
1876 async fn reconcile_sleeps_idle_replica_then_wakes_it_on_activity() {
1877 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
1878 let mut s = spec(1, 128);
1879 s.scale_to_zero = true;
1880 let hash = deploy.put_compute_spec(&s).await.unwrap();
1881 deploy
1882 .set_compute_workload(
1883 crate::project::ProjectRef::DEFAULT,
1884 &ComputeWorkload {
1885 version: 1,
1886 name: "w".into(),
1887 active: hash,
1888 replicas: 1,
1889 placement: Default::default(),
1890 },
1891 )
1892 .await
1893 .unwrap();
1894 let mut backends: BackendRegistry = BTreeMap::new();
1895 backends.insert("fake".into(), Arc::new(S2zBackend));
1896 let nodes = vec![fake_node()];
1897 let policy = BackendPolicy::default();
1898
1899 let r = reconcile_once(
1901 &deploy,
1902 &backends,
1903 &nodes,
1904 &policy,
1905 &FixedActivity(WorkloadActivity::Active),
1906 None,
1907 )
1908 .await
1909 .unwrap();
1910 assert_eq!(r.launched, 1, "{:?}", r.errors);
1911
1912 let r = reconcile_once(
1914 &deploy,
1915 &backends,
1916 &nodes,
1917 &policy,
1918 &FixedActivity(WorkloadActivity::Idle),
1919 None,
1920 )
1921 .await
1922 .unwrap();
1923 assert_eq!(r.slept, 1, "{:?}", r.errors);
1924 let parked = deploy
1925 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
1926 .await
1927 .unwrap();
1928 assert_eq!(parked.len(), 1);
1929 assert_eq!(parked[0].phase, ReplicaPhase::Zero);
1930 assert!(parked[0].snapshot.is_some(), "carries its snapshot");
1931 assert!(!parked[0].healthy);
1932
1933 let r = reconcile_once(
1935 &deploy,
1936 &backends,
1937 &nodes,
1938 &policy,
1939 &FixedActivity(WorkloadActivity::Idle),
1940 None,
1941 )
1942 .await
1943 .unwrap();
1944 assert_eq!((r.slept, r.woke, r.launched), (0, 0, 0), "{:?}", r.errors);
1945
1946 let r = reconcile_once(
1948 &deploy,
1949 &backends,
1950 &nodes,
1951 &policy,
1952 &FixedActivity(WorkloadActivity::Active),
1953 None,
1954 )
1955 .await
1956 .unwrap();
1957 assert_eq!(r.woke, 1, "{:?}", r.errors);
1958 let woken = deploy
1959 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
1960 .await
1961 .unwrap();
1962 assert_eq!(woken.len(), 1);
1963 assert_eq!(woken[0].phase, ReplicaPhase::Running);
1964 assert!(woken[0].snapshot.is_none());
1965 assert!(woken[0].healthy);
1966 }
1967
1968 #[tokio::test]
1969 async fn reconcile_once_launches_converges_then_stops() {
1970 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
1971 let s = spec(1, 128);
1972 let hash = deploy.put_compute_spec(&s).await.unwrap();
1973 deploy
1974 .set_compute_workload(
1975 crate::project::ProjectRef::DEFAULT,
1976 &ComputeWorkload {
1977 version: 1,
1978 name: "w".into(),
1979 active: hash.clone(),
1980 replicas: 2,
1981 placement: Default::default(),
1982 },
1983 )
1984 .await
1985 .unwrap();
1986 let nodes = vec![fake_node()];
1987 let mut backends: BackendRegistry = BTreeMap::new();
1988 backends.insert("fake".into(), Arc::new(FakeBackend));
1989 let policy = BackendPolicy::default();
1990
1991 let r = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive, None)
1993 .await
1994 .unwrap();
1995 assert_eq!((r.launched, r.stopped), (2, 0), "{:?}", r.errors);
1996 assert!(r.errors.is_empty(), "{:?}", r.errors);
1997 let states = deploy
1998 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
1999 .await
2000 .unwrap();
2001 assert_eq!(states.len(), 2);
2002 assert!(
2004 states.iter().all(|s| s.region.as_deref() == Some("eu")),
2005 "replicas carry their node's region"
2006 );
2007
2008 let r2 = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive, None)
2010 .await
2011 .unwrap();
2012 assert_eq!((r2.launched, r2.stopped), (0, 0));
2013
2014 deploy
2016 .set_compute_workload(
2017 crate::project::ProjectRef::DEFAULT,
2018 &ComputeWorkload {
2019 version: 1,
2020 name: "w".into(),
2021 active: hash,
2022 replicas: 0,
2023 placement: Default::default(),
2024 },
2025 )
2026 .await
2027 .unwrap();
2028 let r3 = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive, None)
2029 .await
2030 .unwrap();
2031 assert_eq!(r3.stopped, 2);
2032 assert!(deploy
2033 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2034 .await
2035 .unwrap()
2036 .is_empty());
2037 }
2038}