1use std::collections::BTreeSet;
25use std::fmt;
26use std::num::{NonZeroU16, NonZeroUsize};
27
28use serde::{Deserialize, Serialize};
29
30use crate::execution::{ExecutionError, ExecutionPolicy};
31use crate::model::{
32 Arch, CachePolicy, HostId, HostLabel, Label, NonEmpty, Os, PolicyId, ProfileName, ScaleTarget,
33 ValidationError,
34};
35use crate::path::LocalAbsolutePath;
36use crate::workspace::{WorkspaceError, WorkspaceKind, WorkspacePolicy};
37
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum PolicyError {
44 #[error(transparent)]
45 Invalid(#[from] ValidationError),
46
47 #[error(transparent)]
50 Workspace(#[from] WorkspaceError),
51
52 #[error(transparent)]
53 Execution(#[from] ExecutionError),
54
55 #[error(
56 "an Autoscale policy requires routing labels; a policy with none is a \
57 MonitorOnly policy (D19)"
58 )]
59 AutoscaleWithoutRoutingLabels,
60
61 #[error(
62 "an Autoscale policy requires max_capacity; without a ceiling it could \
63 oversubscribe the host (D7, D19)"
64 )]
65 AutoscaleWithoutMaxCapacity,
66
67 #[error(
79 "a MonitorOnly policy must not carry a non-zero min_capacity ({min}); it \
80 never starts a runner (D19)"
81 )]
82 MonitorOnlyWithMinCapacity { min: u16 },
83
84 #[error("min_capacity ({min}) must not exceed max_capacity ({max})")]
85 InvertedCapacityRange { min: u16, max: u16 },
86
87 #[error("{to} is not a legal transition from {from}")]
88 IllegalTransition { from: PolicyState, to: PolicyState },
89
90 #[error(
91 "only a MonitorOnly policy can be promoted to Autoscale; this one is already Autoscale"
92 )]
93 AlreadyAutoscale,
94
95 #[error(
96 "this operation needs an Autoscale policy; a MonitorOnly policy has no \
97 capacity and no routing labels to change (D19)"
98 )]
99 NotAutoscale,
100
101 #[error("the host label {label} is the routing identity of this policy and cannot be removed")]
102 HostLabelNotRemovable { label: Label },
103
104 #[error("a named profile's stored selector is not its derived selector")]
105 InvalidProfileSelector,
106
107 #[error("the native default profile must retain its compatibility selector")]
108 NamedConstructorForDefault,
109
110 #[error("a named profile must derive its selector when promoted to Autoscale")]
111 NamedProfileNeedsDerivedSelector,
112
113 #[error(
114 "{label} is a shared pool label for another operating system; this host's pool label \
115 is {expected} (labels starting `rm-pool-` are reserved for the coordinator's pools)"
116 )]
117 PoolLabelMismatch { label: Label, expected: Label },
118}
119
120pub const POOL_LABEL_PREFIX: &str = "rm-pool-";
132
133#[must_use]
137pub fn in_pool_namespace(label: &str) -> bool {
138 label
139 .get(..POOL_LABEL_PREFIX.len())
140 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(POOL_LABEL_PREFIX))
141}
142
143#[must_use]
147pub fn is_pool_label(label: &str) -> bool {
148 Os::ALL
149 .iter()
150 .any(|os| pool_label(*os).as_str().eq_ignore_ascii_case(label))
151}
152
153#[must_use]
156pub fn pool_label(os: Os) -> Label {
157 Label::new(format!("{POOL_LABEL_PREFIX}{}", os.pool_token()))
158 .expect("the pool prefix and a fixed OS token form a valid label")
159}
160
161pub fn check_pool_label(label: &Label, os: Os) -> Result<(), PolicyError> {
166 let expected = pool_label(os);
167 if in_pool_namespace(label.as_str()) && *label != expected {
168 return Err(PolicyError::PoolLabelMismatch {
169 label: label.clone(),
170 expected,
171 });
172 }
173 Ok(())
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
209pub struct RoutingLabels {
210 host_label: Label,
211 additional: BTreeSet<Label>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215struct RoutingLabelsRepr {
216 host_label: Label,
217 #[serde(default)]
218 additional: BTreeSet<Label>,
219}
220
221impl From<RoutingLabelsRepr> for RoutingLabels {
222 fn from(repr: RoutingLabelsRepr) -> Self {
223 Self::from_parts(repr.host_label, repr.additional)
228 }
229}
230
231impl From<RoutingLabels> for RoutingLabelsRepr {
232 fn from(value: RoutingLabels) -> Self {
233 Self {
234 host_label: value.host_label,
235 additional: value.additional,
236 }
237 }
238}
239
240impl RoutingLabels {
241 pub const PREFIX: &'static str = "rm";
243
244 #[must_use]
258 pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
259 let derived = format!(
260 "{}-{}-{}-{}",
261 Self::PREFIX,
262 host_label.as_str(),
263 os.label_token(),
264 arch.label_token()
265 );
266 Self {
267 host_label: Label::new(derived).expect(
268 "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
269 segments are fixed tokens, so the concatenation is always a valid Label",
270 ),
271 additional: BTreeSet::new(),
272 }
273 }
274
275 #[must_use]
279 pub fn derive_for_profile(
280 host_label: &HostLabel,
281 os: Os,
282 arch: Arch,
283 profile: &ProfileName,
284 ) -> Self {
285 let label = format!(
286 "{}-{}-{}-{}-{}",
287 Self::PREFIX,
288 host_label.as_str(),
289 os.label_token(),
290 arch.label_token(),
291 profile.as_str()
292 );
293 Self::from_host_label(Label::new(label).expect("validated selector segments form a label"))
294 }
295
296 #[must_use]
309 pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
310 let additional = additional
311 .into_iter()
312 .filter(|l| *l != host_label)
313 .collect();
314 Self {
315 host_label,
316 additional,
317 }
318 }
319
320 #[must_use]
322 pub fn from_host_label(host_label: Label) -> Self {
323 Self::from_parts(host_label, Vec::new())
324 }
325
326 #[must_use]
328 pub fn host_label(&self) -> &Label {
329 &self.host_label
330 }
331
332 #[must_use]
363 pub fn is_derived_shape(&self) -> bool {
364 let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
365 let [prefix, middle @ .., os, arch] = segments.as_slice() else {
370 return false;
371 };
372 *prefix == Self::PREFIX
375 && !middle.is_empty()
376 && Os::ALL
377 .iter()
378 .any(|candidate| candidate.label_token() == *os)
379 && Arch::ALL
380 .iter()
381 .any(|candidate| candidate.label_token() == *arch)
382 }
383
384 pub fn additional(&self) -> impl Iterator<Item = &Label> {
386 self.additional.iter()
387 }
388
389 pub fn add(&mut self, label: Label) -> bool {
392 if label == self.host_label {
393 return false;
394 }
395 self.additional.insert(label)
396 }
397
398 pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
405 if *label == self.host_label {
406 return Err(PolicyError::HostLabelNotRemovable {
407 label: label.clone(),
408 });
409 }
410 Ok(self.additional.remove(label))
411 }
412
413 #[must_use]
414 pub fn contains(&self, label: &Label) -> bool {
415 self.host_label == *label || self.additional.contains(label)
416 }
417
418 #[must_use]
422 pub fn pool_label(&self) -> Option<&Label> {
423 self.additional
424 .iter()
425 .find(|label| is_pool_label(label.as_str()))
426 }
427
428 pub fn iter(&self) -> impl Iterator<Item = &Label> {
430 std::iter::once(&self.host_label).chain(self.additional.iter())
431 }
432
433 #[must_use]
435 pub fn count(&self) -> NonZeroUsize {
436 NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
437 }
438
439 #[must_use]
441 pub fn to_non_empty(&self) -> NonEmpty<Label> {
442 let mut out = NonEmpty::of(self.host_label.clone());
443 for label in &self.additional {
444 out.push(label.clone());
445 }
446 out
447 }
448
449 #[must_use]
459 pub fn as_registration_labels(&self) -> Vec<String> {
460 self.iter().map(|l| l.as_str().to_string()).collect()
461 }
462
463 #[must_use]
470 pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
471 let required = match runs_on.required_labels() {
472 Ok(required) => required,
473 Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
474 };
475
476 let missing: Vec<Label> = required
477 .iter()
478 .filter(|label| !self.contains(label))
479 .cloned()
480 .collect();
481
482 if missing.is_empty() {
483 RunsOnMatch::Match {
484 runner_group: runs_on.runner_group().map(str::to_string),
485 }
486 } else {
487 RunsOnMatch::NoMatch { missing }
488 }
489 }
490
491 #[must_use]
499 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
500 let mut tally = DemandTally::default();
501 for job in jobs {
502 match self.matches(job) {
503 RunsOnMatch::Match { .. } => tally.matched += 1,
504 RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
505 RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
506 }
507 }
508 tally
509 }
510}
511
512impl fmt::Display for RoutingLabels {
513 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
514 let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
515 f.write_str(&joined.join(","))
516 }
517}
518
519#[derive(Debug, Clone, Default, PartialEq, Eq)]
521pub struct DemandTally {
522 pub matched: u32,
524 pub not_matched: u32,
526 pub unresolvable: Vec<UnresolvableRunsOn>,
530}
531
532impl DemandTally {
533 #[must_use]
534 pub fn demand(&self) -> u32 {
535 self.matched
536 }
537
538 #[must_use]
539 pub fn total_seen(&self) -> u32 {
540 self.matched + self.not_matched + self.unresolvable.len() as u32
541 }
542}
543
544#[derive(Debug, Clone, PartialEq, Eq)]
546pub enum RunsOnMatch {
547 Match { runner_group: Option<String> },
555 NoMatch { missing: Vec<Label> },
558 Unresolvable(UnresolvableRunsOn),
560}
561
562impl RunsOnMatch {
563 #[must_use]
564 pub const fn is_match(&self) -> bool {
565 matches!(self, RunsOnMatch::Match { .. })
566 }
567
568 #[must_use]
569 pub const fn is_unresolvable(&self) -> bool {
570 matches!(self, RunsOnMatch::Unresolvable(_))
571 }
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
576pub enum UnresolvableRunsOn {
577 #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
580 Expression { raw: String },
581
582 #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
585 RunnerGroupWithoutLabels { group: String },
586
587 #[error("`runs-on` names no labels")]
589 NoLabels,
590
591 #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
593 InvalidLabel {
594 raw: String,
595 #[source]
596 source: ValidationError,
597 },
598}
599
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607#[serde(untagged)]
608pub enum RunsOn {
609 Single(String),
611 Many(Vec<String>),
613 Grouped {
615 #[serde(default)]
616 group: Option<String>,
617 #[serde(default)]
618 labels: RunsOnLabels,
619 },
620}
621
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(untagged)]
625pub enum RunsOnLabels {
626 One(String),
627 Many(Vec<String>),
628}
629
630impl Default for RunsOnLabels {
631 fn default() -> Self {
632 Self::Many(Vec::new())
633 }
634}
635
636impl RunsOnLabels {
637 fn as_slice(&self) -> &[String] {
638 match self {
639 RunsOnLabels::One(one) => std::slice::from_ref(one),
640 RunsOnLabels::Many(many) => many,
641 }
642 }
643}
644
645impl RunsOn {
646 #[must_use]
648 pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
649 Self::Many(labels.into_iter().map(Into::into).collect())
650 }
651
652 #[must_use]
654 pub fn runner_group(&self) -> Option<&str> {
655 match self {
656 RunsOn::Grouped { group, .. } => group.as_deref(),
657 _ => None,
658 }
659 }
660
661 fn raw_labels(&self) -> &[String] {
662 match self {
663 RunsOn::Single(one) => std::slice::from_ref(one),
664 RunsOn::Many(many) => many,
665 RunsOn::Grouped { labels, .. } => labels.as_slice(),
666 }
667 }
668
669 pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
686 let raws = self.raw_labels();
687
688 if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
689 return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
690 }
691
692 let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
693
694 if usable.is_empty() {
695 return match self.runner_group() {
696 Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
697 group: group.to_string(),
698 }),
699 None => Err(UnresolvableRunsOn::NoLabels),
700 };
701 }
702
703 usable
704 .into_iter()
705 .map(|raw| {
706 Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
707 raw: raw.clone(),
708 source,
709 })
710 })
711 .collect()
712 }
713}
714
715fn is_expression(raw: &str) -> bool {
716 raw.contains("${{")
717}
718
719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730#[serde(try_from = "AutoscaleConfigRepr")]
731pub struct AutoscaleConfig {
732 routing_labels: RoutingLabels,
733 min_capacity: u16,
734 max_capacity: NonZeroU16,
735}
736
737#[derive(Debug, Deserialize)]
738struct AutoscaleConfigRepr {
739 routing_labels: RoutingLabels,
740 min_capacity: u16,
741 max_capacity: NonZeroU16,
742}
743
744impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
745 type Error = PolicyError;
746
747 fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
748 Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
749 }
750}
751
752impl AutoscaleConfig {
753 pub fn new(
759 routing_labels: RoutingLabels,
760 min_capacity: u16,
761 max_capacity: NonZeroU16,
762 ) -> Result<Self, PolicyError> {
763 if min_capacity > max_capacity.get() {
764 return Err(PolicyError::InvertedCapacityRange {
765 min: min_capacity,
766 max: max_capacity.get(),
767 });
768 }
769 Ok(Self {
770 routing_labels,
771 min_capacity,
772 max_capacity,
773 })
774 }
775
776 pub fn v1(
782 routing_labels: RoutingLabels,
783 max_capacity: NonZeroU16,
784 ) -> Result<Self, PolicyError> {
785 Self::new(routing_labels, 0, max_capacity)
786 }
787
788 #[must_use]
789 pub fn routing_labels(&self) -> &RoutingLabels {
790 &self.routing_labels
791 }
792
793 #[must_use]
794 pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
795 &mut self.routing_labels
796 }
797
798 #[must_use]
799 pub const fn min_capacity(&self) -> u16 {
800 self.min_capacity
801 }
802
803 #[must_use]
804 pub const fn max_capacity(&self) -> NonZeroU16 {
805 self.max_capacity
806 }
807
808 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
812 if self.min_capacity > max_capacity.get() {
813 return Err(PolicyError::InvertedCapacityRange {
814 min: self.min_capacity,
815 max: max_capacity.get(),
816 });
817 }
818 self.max_capacity = max_capacity;
819 Ok(())
820 }
821}
822
823#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
843#[serde(tag = "mode", rename_all = "snake_case")]
844pub enum PolicyMode {
845 MonitorOnly,
848 Autoscale(AutoscaleConfig),
850}
851
852impl PolicyMode {
853 #[must_use]
854 pub const fn monitor_only() -> Self {
855 Self::MonitorOnly
856 }
857
858 pub fn autoscale(
861 routing_labels: RoutingLabels,
862 min_capacity: u16,
863 max_capacity: NonZeroU16,
864 ) -> Result<Self, PolicyError> {
865 Ok(Self::Autoscale(AutoscaleConfig::new(
866 routing_labels,
867 min_capacity,
868 max_capacity,
869 )?))
870 }
871
872 pub fn from_persisted(
882 routing_labels: Option<RoutingLabels>,
883 min_capacity: u16,
884 max_capacity: Option<NonZeroU16>,
885 ) -> Result<Self, PolicyError> {
886 match (routing_labels, max_capacity) {
887 (None, None) => {
888 if min_capacity != 0 {
889 return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
894 }
895 Ok(Self::MonitorOnly)
896 }
897 (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
898 (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
899 (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
900 }
901 }
902
903 #[must_use]
906 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
907 match self {
908 PolicyMode::MonitorOnly => None,
909 PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
910 }
911 }
912
913 #[must_use]
914 pub const fn min_capacity(&self) -> u16 {
915 match self {
916 PolicyMode::MonitorOnly => 0,
917 PolicyMode::Autoscale(cfg) => cfg.min_capacity,
918 }
919 }
920
921 #[must_use]
922 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
923 match self {
924 PolicyMode::MonitorOnly => None,
925 PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
926 }
927 }
928
929 #[must_use]
930 pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
931 match self {
932 PolicyMode::MonitorOnly => None,
933 PolicyMode::Autoscale(cfg) => Some(cfg),
934 }
935 }
936
937 #[must_use]
938 pub const fn is_autoscale(&self) -> bool {
939 matches!(self, PolicyMode::Autoscale(_))
940 }
941
942 #[must_use]
943 pub const fn is_monitor_only(&self) -> bool {
944 matches!(self, PolicyMode::MonitorOnly)
945 }
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
975#[serde(rename_all = "snake_case")]
976pub enum PolicyState {
977 Pending,
978 Active,
979 Draining,
980 Disabled,
981 RepairRequired,
982 AuthenticationFailed,
983}
984
985impl PolicyState {
986 pub const ALL: [PolicyState; 6] = [
987 PolicyState::Pending,
988 PolicyState::Active,
989 PolicyState::Draining,
990 PolicyState::Disabled,
991 PolicyState::RepairRequired,
992 PolicyState::AuthenticationFailed,
993 ];
994
995 pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
998 (PolicyState::Pending, PolicyState::Active),
999 (PolicyState::Pending, PolicyState::RepairRequired),
1000 (PolicyState::Active, PolicyState::Draining),
1001 (PolicyState::Draining, PolicyState::Disabled),
1002 (PolicyState::Disabled, PolicyState::Pending),
1003 (PolicyState::Pending, PolicyState::AuthenticationFailed),
1005 (PolicyState::Active, PolicyState::AuthenticationFailed),
1006 (PolicyState::Draining, PolicyState::AuthenticationFailed),
1007 (PolicyState::Disabled, PolicyState::AuthenticationFailed),
1008 (
1009 PolicyState::RepairRequired,
1010 PolicyState::AuthenticationFailed,
1011 ),
1012 (PolicyState::AuthenticationFailed, PolicyState::Pending),
1014 ];
1015
1016 #[must_use]
1017 pub fn can_transition_to(self, next: PolicyState) -> bool {
1018 Self::LEGAL.contains(&(self, next))
1019 }
1020
1021 #[must_use]
1023 pub const fn admits_new_runners(self) -> bool {
1024 matches!(self, PolicyState::Active)
1025 }
1026}
1027
1028impl fmt::Display for PolicyState {
1029 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1030 f.write_str(match self {
1031 PolicyState::Pending => "pending",
1032 PolicyState::Active => "active",
1033 PolicyState::Draining => "draining",
1034 PolicyState::Disabled => "disabled",
1035 PolicyState::RepairRequired => "repair_required",
1036 PolicyState::AuthenticationFailed => "authentication_failed",
1037 })
1038 }
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1052pub struct ScalePolicy {
1053 pub id: PolicyId,
1054 pub target: ScaleTarget,
1055 pub installation_id: u64,
1056 pub host_id: HostId,
1057 pub requested_host_label: HostLabel,
1059 profile_name: ProfileName,
1061 mode: PolicyMode,
1062 enabled: bool,
1063 state: PolicyState,
1064 pub cache_policy: CachePolicy,
1065 workspace_policy: WorkspacePolicy,
1073 execution_policy: ExecutionPolicy,
1074 revision: u64,
1075}
1076
1077#[derive(Debug, Clone, PartialEq, Eq)]
1103pub struct PersistedPolicy {
1104 pub id: PolicyId,
1105 pub target: ScaleTarget,
1106 pub installation_id: u64,
1108 pub host_id: HostId,
1109 pub requested_host_label: HostLabel,
1110 pub profile_name: ProfileName,
1111 pub routing_labels: Option<RoutingLabels>,
1113 pub min_capacity: u16,
1114 pub max_capacity: Option<NonZeroU16>,
1115 pub enabled: bool,
1117 pub state: PolicyState,
1118 pub cache_policy: CachePolicy,
1119 pub workspace_kind: WorkspaceKind,
1124 pub workspace_root: Option<LocalAbsolutePath>,
1127 pub execution_policy: ExecutionPolicy,
1128 pub revision: u64,
1130}
1131
1132#[derive(Debug, Clone, PartialEq, Eq)]
1134pub struct NamedProfileSpec {
1135 pub requested_host_label: HostLabel,
1136 pub os: Os,
1137 pub arch: Arch,
1138 pub profile_name: ProfileName,
1139 pub min_capacity: u16,
1140 pub max_capacity: NonZeroU16,
1141}
1142
1143impl ScalePolicy {
1144 #[must_use]
1146 pub const fn profile_name(&self) -> &ProfileName {
1147 &self.profile_name
1148 }
1149
1150 #[must_use]
1163 pub fn new(
1164 id: PolicyId,
1165 target: ScaleTarget,
1166 installation_id: u64,
1167 host_id: HostId,
1168 mode: PolicyMode,
1169 cache_policy: CachePolicy,
1170 ) -> Self {
1171 Self::new_for_host_label(
1172 id,
1173 target,
1174 installation_id,
1175 host_id,
1176 HostLabel::new("host").expect("the compatibility host label is valid"),
1177 mode,
1178 cache_policy,
1179 )
1180 }
1181
1182 #[must_use]
1184 pub fn new_for_host_label(
1185 id: PolicyId,
1186 target: ScaleTarget,
1187 installation_id: u64,
1188 host_id: HostId,
1189 requested_host_label: HostLabel,
1190 mode: PolicyMode,
1191 cache_policy: CachePolicy,
1192 ) -> Self {
1193 Self {
1194 id,
1195 target,
1196 installation_id,
1197 host_id,
1198 requested_host_label,
1199 profile_name: ProfileName::default_profile(),
1200 mode,
1201 enabled: false,
1202 state: PolicyState::Pending,
1203 cache_policy,
1204 workspace_policy: WorkspacePolicy::Ephemeral,
1208 execution_policy: ExecutionPolicy::Native,
1209 revision: 0,
1210 }
1211 }
1212
1213 pub fn new_named(
1216 id: PolicyId,
1217 target: ScaleTarget,
1218 installation_id: u64,
1219 host_id: HostId,
1220 spec: NamedProfileSpec,
1221 cache_policy: CachePolicy,
1222 ) -> Result<Self, PolicyError> {
1223 let NamedProfileSpec {
1224 requested_host_label,
1225 os,
1226 arch,
1227 profile_name,
1228 min_capacity,
1229 max_capacity,
1230 } = spec;
1231 if profile_name.is_default() {
1232 return Err(PolicyError::NamedConstructorForDefault);
1233 }
1234 let labels =
1235 RoutingLabels::derive_for_profile(&requested_host_label, os, arch, &profile_name);
1236 let mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1237 let mut policy = Self::new_for_host_label(
1238 id,
1239 target,
1240 installation_id,
1241 host_id,
1242 requested_host_label,
1243 mode,
1244 cache_policy,
1245 );
1246 policy.profile_name = profile_name;
1247 Ok(policy)
1248 }
1249
1250 pub fn new_named_monitor(
1253 id: PolicyId,
1254 target: ScaleTarget,
1255 installation_id: u64,
1256 host_id: HostId,
1257 requested_host_label: HostLabel,
1258 profile_name: ProfileName,
1259 cache_policy: CachePolicy,
1260 ) -> Result<Self, PolicyError> {
1261 if profile_name.is_default() {
1262 return Err(PolicyError::NamedConstructorForDefault);
1263 }
1264 let mut policy = Self::new_for_host_label(
1265 id,
1266 target,
1267 installation_id,
1268 host_id,
1269 requested_host_label,
1270 PolicyMode::monitor_only(),
1271 cache_policy,
1272 );
1273 policy.profile_name = profile_name;
1274 Ok(policy)
1275 }
1276
1277 pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1285 let PersistedPolicy {
1286 id,
1287 target,
1288 installation_id,
1289 host_id,
1290 requested_host_label,
1291 profile_name,
1292 routing_labels,
1293 min_capacity,
1294 max_capacity,
1295 enabled,
1296 state,
1297 cache_policy,
1298 workspace_kind,
1299 workspace_root,
1300 execution_policy,
1301 revision,
1302 } = fields;
1303
1304 let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1305 if !profile_name.is_default()
1306 && let Some(labels) = mode.routing_labels()
1307 {
1308 let suffix = format!("-{}", profile_name.as_str());
1311 if !labels.host_label().as_str().ends_with(&suffix) {
1312 return Err(PolicyError::InvalidProfileSelector);
1313 }
1314 }
1315 let workspace_policy =
1319 WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1320 execution_policy.validate(target.scope(), &workspace_policy)?;
1321 Ok(Self {
1322 id,
1323 target,
1324 installation_id,
1325 host_id,
1326 requested_host_label,
1327 profile_name,
1328 mode,
1329 enabled,
1330 state,
1331 cache_policy,
1332 workspace_policy,
1333 execution_policy,
1334 revision,
1335 })
1336 }
1337
1338 #[must_use]
1344 pub fn to_persisted(&self) -> PersistedPolicy {
1345 PersistedPolicy {
1346 id: self.id,
1347 target: self.target.clone(),
1348 installation_id: self.installation_id,
1349 host_id: self.host_id,
1350 requested_host_label: self.requested_host_label.clone(),
1351 profile_name: self.profile_name.clone(),
1352 routing_labels: self.routing_labels().cloned(),
1353 min_capacity: self.min_capacity(),
1354 max_capacity: self.max_capacity(),
1355 enabled: self.enabled,
1356 state: self.state,
1357 cache_policy: self.cache_policy,
1358 workspace_kind: self.workspace_policy.kind(),
1359 workspace_root: self.workspace_policy.root().cloned(),
1360 execution_policy: self.execution_policy.clone(),
1361 revision: self.revision,
1362 }
1363 }
1364
1365 #[must_use]
1366 pub const fn mode(&self) -> &PolicyMode {
1367 &self.mode
1368 }
1369
1370 #[must_use]
1371 pub const fn state(&self) -> PolicyState {
1372 self.state
1373 }
1374
1375 #[must_use]
1379 pub const fn enabled(&self) -> bool {
1380 self.enabled
1381 }
1382
1383 #[must_use]
1386 pub const fn revision(&self) -> u64 {
1387 self.revision
1388 }
1389
1390 #[must_use]
1391 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1392 self.mode.routing_labels()
1393 }
1394
1395 #[must_use]
1398 pub fn pool_label(&self) -> Option<&Label> {
1399 self.routing_labels().and_then(RoutingLabels::pool_label)
1400 }
1401
1402 #[must_use]
1404 pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1405 &self.workspace_policy
1406 }
1407
1408 #[must_use]
1409 pub const fn execution_policy(&self) -> &ExecutionPolicy {
1410 &self.execution_policy
1411 }
1412
1413 pub fn set_execution_policy(&mut self, execution: ExecutionPolicy) -> Result<(), PolicyError> {
1415 execution.validate(self.target.scope(), &self.workspace_policy)?;
1416 if self.execution_policy != execution {
1417 self.execution_policy = execution;
1418 self.revision = self.revision.saturating_add(1);
1419 }
1420 Ok(())
1421 }
1422
1423 pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1441 workspace.permitted_for(self.target.scope())?;
1446 self.execution_policy
1447 .validate(self.target.scope(), &workspace)?;
1448 if self.workspace_policy != workspace {
1449 self.workspace_policy = workspace;
1450 self.revision = self.revision.saturating_add(1);
1451 }
1452 Ok(())
1453 }
1454
1455 #[must_use]
1456 pub const fn min_capacity(&self) -> u16 {
1457 self.mode.min_capacity()
1458 }
1459
1460 #[must_use]
1461 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1462 self.mode.max_capacity()
1463 }
1464
1465 #[must_use]
1468 pub fn is_owned_by(&self, host_id: HostId) -> bool {
1469 self.host_id == host_id
1470 }
1471
1472 #[must_use]
1479 pub const fn owns_runners(&self) -> bool {
1480 self.mode.is_autoscale()
1481 }
1482
1483 #[must_use]
1489 pub const fn may_start_runners(&self) -> bool {
1490 self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1491 }
1492
1493 pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1497 if !self.state.can_transition_to(next) {
1498 return Err(PolicyError::IllegalTransition {
1499 from: self.state,
1500 to: next,
1501 });
1502 }
1503 self.state = next;
1504 self.revision = self.revision.saturating_add(1);
1505 Ok(())
1506 }
1507
1508 #[must_use]
1514 pub fn can_activate(&self) -> bool {
1515 self.state.can_transition_to(PolicyState::Active)
1516 }
1517
1518 #[must_use]
1520 pub fn can_request_disable(&self) -> bool {
1521 self.state.can_transition_to(PolicyState::Draining)
1522 }
1523
1524 pub fn activate(&mut self) -> Result<(), PolicyError> {
1545 self.transition_to(PolicyState::Active)?;
1546 self.enabled = true;
1547 Ok(())
1548 }
1549
1550 pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1569 self.transition_to(PolicyState::Draining)?;
1570 self.enabled = false;
1571 Ok(self.state)
1572 }
1573
1574 pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1584 if self.state != PolicyState::Draining {
1585 return Err(PolicyError::IllegalTransition {
1586 from: self.state,
1587 to: PolicyState::Disabled,
1588 });
1589 }
1590 if active_attempts == 0 {
1591 self.transition_to(PolicyState::Disabled)?;
1592 }
1593 Ok(self.state)
1594 }
1595
1596 pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1602 self.transition_to(PolicyState::AuthenticationFailed)
1603 }
1604
1605 pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1611 self.transition_to(PolicyState::Pending)
1612 }
1613
1614 pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1619 self.transition_to(PolicyState::RepairRequired)
1620 }
1621
1622 pub fn promote_to_autoscale(
1631 &mut self,
1632 routing_labels: RoutingLabels,
1633 min_capacity: u16,
1634 max_capacity: NonZeroU16,
1635 ) -> Result<(), PolicyError> {
1636 if !self.profile_name.is_default() {
1637 return Err(PolicyError::NamedProfileNeedsDerivedSelector);
1638 }
1639 if self.mode.is_autoscale() {
1640 return Err(PolicyError::AlreadyAutoscale);
1641 }
1642 self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1643 self.revision = self.revision.saturating_add(1);
1644 Ok(())
1645 }
1646
1647 pub fn promote_named_to_autoscale(
1650 &mut self,
1651 os: Os,
1652 arch: Arch,
1653 min_capacity: u16,
1654 max_capacity: NonZeroU16,
1655 ) -> Result<(), PolicyError> {
1656 if self.profile_name.is_default() {
1657 return Err(PolicyError::NamedConstructorForDefault);
1658 }
1659 if self.mode.is_autoscale() {
1660 return Err(PolicyError::AlreadyAutoscale);
1661 }
1662 let labels = RoutingLabels::derive_for_profile(
1663 &self.requested_host_label,
1664 os,
1665 arch,
1666 &self.profile_name,
1667 );
1668 self.mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1669 self.revision = self.revision.saturating_add(1);
1670 Ok(())
1671 }
1672
1673 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1681 match &mut self.mode {
1682 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1683 PolicyMode::Autoscale(cfg) => {
1684 cfg.set_max_capacity(max_capacity)?;
1685 self.revision = self.revision.saturating_add(1);
1686 Ok(())
1687 }
1688 }
1689 }
1690
1691 pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
1701 match &mut self.mode {
1702 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1703 PolicyMode::Autoscale(cfg) => {
1704 let added = cfg.routing_labels_mut().add(label);
1705 if added {
1706 self.revision = self.revision.saturating_add(1);
1707 }
1708 Ok(added)
1709 }
1710 }
1711 }
1712
1713 pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
1720 match &mut self.mode {
1721 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1722 PolicyMode::Autoscale(cfg) => {
1723 let removed = cfg.routing_labels_mut().remove(label)?;
1724 if removed {
1725 self.revision = self.revision.saturating_add(1);
1726 }
1727 Ok(removed)
1728 }
1729 }
1730 }
1731
1732 #[must_use]
1738 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
1739 match self.routing_labels() {
1740 Some(labels) => labels.tally(jobs),
1741 None => DemandTally::default(),
1742 }
1743 }
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748 use super::*;
1749 use crate::model::{HostId, PolicyId, TargetScope};
1750
1751 fn nz(v: u16) -> NonZeroU16 {
1752 NonZeroU16::new(v).expect("test capacity is non-zero")
1753 }
1754
1755 fn label(s: &str) -> Label {
1756 Label::new(s).expect("test label is valid")
1757 }
1758
1759 fn host_labels(host: &str) -> RoutingLabels {
1760 RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
1761 }
1762
1763 fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
1764 ScalePolicy::new(
1765 PolicyId::from_u128(1),
1766 target,
1767 42,
1768 host,
1769 PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
1770 CachePolicy::default(),
1771 )
1772 }
1773
1774 #[test]
1775 fn each_os_has_one_pool_label_and_the_namespace_is_reserved() {
1776 assert_eq!(pool_label(Os::Windows).as_str(), "rm-pool-windows");
1777 assert_eq!(pool_label(Os::Linux).as_str(), "rm-pool-linux");
1778 assert_eq!(pool_label(Os::MacOs).as_str(), "rm-pool-macos");
1779 assert!(is_pool_label("RM-POOL-linux"));
1780 assert!(!is_pool_label("rm-home-linux-x64"));
1781 assert!(!is_pool_label("rm-pool"));
1782 assert!(!is_pool_label("rm-pool-linux-x64"));
1784 assert!(in_pool_namespace("rm-pool-linux-x64"));
1785 assert!(check_pool_label(&label("rm-pool-linux"), Os::Linux).is_ok());
1786 assert!(check_pool_label(&label("self-hosted"), Os::Linux).is_ok());
1787 assert!(matches!(
1788 check_pool_label(&label("rm-pool-windows"), Os::Linux),
1789 Err(PolicyError::PoolLabelMismatch { expected, .. }) if expected.as_str() == "rm-pool-linux"
1790 ));
1791 assert!(check_pool_label(&label("rm-pool-linux-heavy"), Os::Linux).is_err());
1792
1793 let mut labels = host_labels("home");
1794 assert_eq!(labels.pool_label(), None);
1795 labels.add(label("linux"));
1796 labels.add(label("rm-pool-windows"));
1797 assert_eq!(labels.pool_label(), Some(&label("rm-pool-windows")));
1798 }
1799
1800 fn workspace_root() -> LocalAbsolutePath {
1805 LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
1806 .expect("a valid persistent root")
1807 }
1808
1809 fn repository_policy() -> ScalePolicy {
1810 autoscale_policy(
1811 ScaleTarget::repository("acme/api").unwrap(),
1812 HostId::from_u128(1),
1813 4,
1814 )
1815 }
1816
1817 fn organization_policy() -> ScalePolicy {
1818 autoscale_policy(
1819 ScaleTarget::organization("acme").unwrap(),
1820 HostId::from_u128(1),
1821 4,
1822 )
1823 }
1824
1825 #[test]
1826 fn every_constructor_produces_an_ephemeral_workspace() {
1827 for policy in [repository_policy(), organization_policy()] {
1830 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1831 assert!(!policy.workspace_policy().retains_job_workspace());
1832 assert_eq!(
1833 policy.to_persisted().workspace_kind,
1834 WorkspaceKind::Ephemeral
1835 );
1836 assert_eq!(policy.to_persisted().workspace_root, None);
1837 }
1838
1839 let monitor_only = ScalePolicy::new(
1840 PolicyId::from_u128(2),
1841 ScaleTarget::repository("acme/api").unwrap(),
1842 42,
1843 HostId::from_u128(1),
1844 PolicyMode::MonitorOnly,
1845 CachePolicy::default(),
1846 );
1847 assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
1848 }
1849
1850 #[test]
1851 fn a_repository_policy_can_opt_into_a_persistent_workspace() {
1852 let mut policy = repository_policy();
1853 let before = policy.revision();
1854
1855 policy
1856 .set_workspace_policy(
1857 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1858 .expect("a repository may be persistent"),
1859 )
1860 .expect("a repository policy accepts persistence");
1861
1862 assert!(policy.workspace_policy().is_persistent());
1863 assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
1864 assert_eq!(
1865 policy.revision(),
1866 before + 1,
1867 "a workspace change must bump the optimistic token, or `a2`'s guard \
1868 cannot refuse a write built from a stale read"
1869 );
1870
1871 let unchanged = policy.revision();
1874 policy
1875 .set_workspace_policy(policy.workspace_policy().clone())
1876 .expect("re-setting the same policy is accepted");
1877 assert_eq!(policy.revision(), unchanged);
1878 }
1879
1880 #[test]
1881 fn an_organization_policy_cannot_be_made_persistent() {
1882 let mut policy = organization_policy();
1885 let before = policy.revision();
1886
1887 assert_eq!(
1888 policy.set_workspace_policy(WorkspacePolicy::Persistent {
1889 root: workspace_root()
1890 }),
1891 Err(PolicyError::Workspace(
1892 WorkspaceError::PersistentRequiresRepositoryScope
1893 ))
1894 );
1895 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1896 assert_eq!(
1897 policy.revision(),
1898 before,
1899 "a refused write consumes nothing"
1900 );
1901
1902 assert_eq!(
1905 WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
1906 Err(WorkspaceError::PersistentRequiresRepositoryScope)
1907 );
1908 }
1909
1910 #[test]
1911 fn a_workspace_policy_round_trips_through_the_persisted_struct() {
1912 let mut policy = repository_policy();
1913 policy
1914 .set_workspace_policy(
1915 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1916 .expect("a repository may be persistent"),
1917 )
1918 .expect("a repository policy accepts persistence");
1919
1920 let restored = ScalePolicy::from_persisted(policy.to_persisted())
1921 .expect("a policy this crate wrote must load");
1922 assert_eq!(restored, policy);
1923 assert_eq!(restored.workspace_policy(), policy.workspace_policy());
1924
1925 let ephemeral = repository_policy();
1926 assert_eq!(
1927 ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
1928 ephemeral
1929 );
1930 }
1931
1932 #[test]
1933 fn an_organization_row_claiming_persistence_fails_closed_on_load() {
1934 let mut fields = organization_policy().to_persisted();
1935 fields.workspace_kind = WorkspaceKind::Persistent;
1936 fields.workspace_root = Some(workspace_root());
1937
1938 assert_eq!(
1939 ScalePolicy::from_persisted(fields),
1940 Err(PolicyError::Workspace(
1941 WorkspaceError::PersistentRequiresRepositoryScope
1942 ))
1943 );
1944 }
1945
1946 #[test]
1947 fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
1948 let base = repository_policy().to_persisted();
1949
1950 let mut without_root = base.clone();
1951 without_root.workspace_kind = WorkspaceKind::Persistent;
1952 assert_eq!(
1953 ScalePolicy::from_persisted(without_root),
1954 Err(PolicyError::Workspace(
1955 WorkspaceError::PersistentWithoutRoot
1956 ))
1957 );
1958
1959 let mut stale_root = base;
1960 stale_root.workspace_root = Some(workspace_root());
1961 assert!(matches!(
1962 ScalePolicy::from_persisted(stale_root),
1963 Err(PolicyError::Workspace(
1964 WorkspaceError::EphemeralWithRoot { .. }
1965 ))
1966 ));
1967 }
1968
1969 #[test]
1970 fn workspace_retention_is_not_the_runner_package_cache_policy() {
1971 let mut policy = repository_policy();
1975 policy.cache_policy = CachePolicy::DiscardRunnerPackage;
1976 policy
1977 .set_workspace_policy(
1978 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1979 .expect("a repository may be persistent"),
1980 )
1981 .expect("a repository policy accepts persistence");
1982
1983 assert!(policy.workspace_policy().retains_job_workspace());
1984 assert!(!policy.cache_policy.retains_runner_package());
1985 assert!(!policy.cache_policy.retains_job_workspace());
1988 }
1989
1990 #[test]
1995 fn the_derived_label_has_the_shape_the_architecture_gives() {
1996 let labels =
1998 RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
1999 assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
2000 assert_eq!(labels.count().get(), 1);
2001 }
2002
2003 #[test]
2004 fn the_derived_label_is_host_scoped_by_construction() {
2005 let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2009 let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
2010
2011 assert_ne!(
2012 a.host_label(),
2013 b.host_label(),
2014 "two hosts must not derive the same routing label; with no job \
2015 reservation, a shared label means both hosts start a runner for one job"
2016 );
2017 assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
2018 assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
2019
2020 let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
2022 assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
2023 assert_ne!(a.host_label(), mac.host_label());
2024 }
2025
2026 #[test]
2027 fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
2028 let labels =
2032 RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
2033 assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
2034 }
2035
2036 #[test]
2037 fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
2038 let mut labels = host_labels("home");
2039 let derived = labels.host_label().clone();
2040
2041 assert!(labels.add(label("gpu")));
2042 assert!(labels.add(label("self-hosted")));
2043 assert!(
2044 !labels.add(label("GPU")),
2045 "adding a label that differs only in case must be a no-op, not a duplicate"
2046 );
2047 assert_eq!(labels.count().get(), 3);
2048
2049 assert!(labels.remove(&label("gpu")).unwrap());
2050 assert_eq!(labels.count().get(), 2);
2051 assert!(
2052 !labels.remove(&label("never-added")).unwrap(),
2053 "removing an absent optional label is a no-op, not an error"
2054 );
2055
2056 assert!(
2058 matches!(
2059 labels.remove(&derived),
2060 Err(PolicyError::HostLabelNotRemovable { .. })
2061 ),
2062 "the derived host label must not be removable; it is the only thing \
2063 keeping two hosts from serving each other's jobs"
2064 );
2065 assert!(labels.contains(&derived));
2066
2067 assert!(matches!(
2069 labels.remove(&label("RM-HOME-WIN-X64")),
2070 Err(PolicyError::HostLabelNotRemovable { .. })
2071 ));
2072 }
2073
2074 #[test]
2075 fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
2076 let mut labels = host_labels("home");
2077 let derived = labels.host_label().clone();
2078 assert!(!labels.add(derived));
2079 assert_eq!(labels.count().get(), 1);
2080
2081 let rebuilt = RoutingLabels::from_parts(
2083 labels.host_label().clone(),
2084 vec![labels.host_label().clone(), label("gpu")],
2085 );
2086 assert_eq!(rebuilt.count().get(), 2);
2087 assert_eq!(
2088 rebuilt.as_registration_labels(),
2089 vec!["rm-home-win-x64", "gpu"]
2090 );
2091 }
2092
2093 #[test]
2094 fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
2095 let mut labels = host_labels("home");
2100 labels.add(label("gpu"));
2101 assert_eq!(
2102 labels.as_registration_labels(),
2103 vec!["rm-home-win-x64", "gpu"]
2104 );
2105 assert!(!labels.contains(&label("self-hosted")));
2106 }
2107
2108 #[test]
2109 fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
2110 let mut labels = host_labels("home");
2111 labels.add(label("gpu"));
2112 let json = serde_json::to_string(&labels).unwrap();
2113 let back: RoutingLabels = serde_json::from_str(&json).unwrap();
2114 assert_eq!(labels, back);
2115 assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
2116 }
2117
2118 #[test]
2119 fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
2120 let labels = host_labels("home");
2122 let non_empty = labels.to_non_empty();
2123 assert_eq!(non_empty.count().get(), 1);
2124 assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
2125 }
2126
2127 struct Row {
2134 name: &'static str,
2135 runs_on: RunsOn,
2136 expect: Expect,
2137 }
2138
2139 #[derive(Debug, PartialEq, Eq)]
2140 enum Expect {
2141 Match,
2142 NoMatch,
2143 Unresolvable,
2144 }
2145
2146 fn classify(m: &RunsOnMatch) -> Expect {
2147 match m {
2148 RunsOnMatch::Match { .. } => Expect::Match,
2149 RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
2150 RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
2151 }
2152 }
2153
2154 fn table_policy() -> RoutingLabels {
2155 let mut labels = host_labels("home");
2157 labels.add(label("self-hosted"));
2158 labels.add(label("gpu"));
2159 labels
2160 }
2161
2162 fn table() -> Vec<Row> {
2163 vec![
2164 Row {
2166 name: "string: the derived host label",
2167 runs_on: RunsOn::Single("rm-home-win-x64".into()),
2168 expect: Expect::Match,
2169 },
2170 Row {
2171 name: "string: the derived host label in the wrong case",
2172 runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
2173 expect: Expect::Match,
2174 },
2175 Row {
2176 name: "string: an optional label alone",
2177 runs_on: RunsOn::Single("gpu".into()),
2178 expect: Expect::Match,
2179 },
2180 Row {
2181 name: "string: another host's label",
2182 runs_on: RunsOn::Single("rm-office-win-x64".into()),
2183 expect: Expect::NoMatch,
2184 },
2185 Row {
2186 name: "string: a GitHub-hosted runner label",
2187 runs_on: RunsOn::Single("ubuntu-latest".into()),
2188 expect: Expect::NoMatch,
2189 },
2190 Row {
2192 name: "array: a strict subset of the policy's labels",
2193 runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
2194 expect: Expect::Match,
2195 },
2196 Row {
2197 name: "array: the whole set, out of order and mixed case",
2198 runs_on: RunsOn::Many(vec![
2199 "GPU".into(),
2200 "Rm-Home-Win-X64".into(),
2201 "Self-Hosted".into(),
2202 ]),
2203 expect: Expect::Match,
2204 },
2205 Row {
2206 name: "array: one label the policy does not carry",
2207 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
2208 expect: Expect::NoMatch,
2209 },
2210 Row {
2211 name: "array: an empty array names no labels",
2212 runs_on: RunsOn::Many(vec![]),
2213 expect: Expect::Unresolvable,
2214 },
2215 Row {
2217 name: "map: labels only",
2218 runs_on: RunsOn::Grouped {
2219 group: None,
2220 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
2221 },
2222 expect: Expect::Match,
2223 },
2224 Row {
2225 name: "map: a group plus labels the policy carries",
2226 runs_on: RunsOn::Grouped {
2227 group: Some("Default".into()),
2228 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
2229 },
2230 expect: Expect::Match,
2231 },
2232 Row {
2233 name: "map: labels as a scalar",
2234 runs_on: RunsOn::Grouped {
2235 group: Some("Default".into()),
2236 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2237 },
2238 expect: Expect::Match,
2239 },
2240 Row {
2241 name: "map: a group plus a label the policy does not carry",
2242 runs_on: RunsOn::Grouped {
2243 group: Some("Default".into()),
2244 labels: RunsOnLabels::Many(vec!["macos".into()]),
2245 },
2246 expect: Expect::NoMatch,
2247 },
2248 Row {
2249 name: "map: a group with no labels constrains something we cannot read",
2250 runs_on: RunsOn::Grouped {
2251 group: Some("Default".into()),
2252 labels: RunsOnLabels::Many(vec![]),
2253 },
2254 expect: Expect::Unresolvable,
2255 },
2256 Row {
2258 name: "expression: the whole value",
2259 runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
2260 expect: Expect::Unresolvable,
2261 },
2262 Row {
2263 name: "expression: one element of an array",
2264 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
2265 expect: Expect::Unresolvable,
2266 },
2267 Row {
2268 name: "expression: inside the map form",
2269 runs_on: RunsOn::Grouped {
2270 group: None,
2271 labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
2272 },
2273 expect: Expect::Unresolvable,
2274 },
2275 Row {
2276 name: "not a usable label at all",
2277 runs_on: RunsOn::Single("rm-home,win-x64".into()),
2278 expect: Expect::Unresolvable,
2279 },
2280 ]
2281 }
2282
2283 #[test]
2284 fn runs_on_matching_covers_every_documented_form() {
2285 let policy = table_policy();
2286 for row in table() {
2287 let got = policy.matches(&row.runs_on);
2288 assert_eq!(
2289 classify(&got),
2290 row.expect,
2291 "row {:?}: {:?} produced {got:?}",
2292 row.name,
2293 row.runs_on
2294 );
2295 }
2296 }
2297
2298 #[test]
2299 fn github_job_labels_are_a_case_insensitive_subset_without_a_selector() {
2300 let labels = RoutingLabels::from_parts(
2301 label("rm-macmini-osx-arm64"),
2302 [
2303 label("arm64"),
2304 label("macos"),
2305 label("self-hosted"),
2306 label("extra"),
2307 ],
2308 );
2309 assert!(
2310 labels
2311 .matches(&RunsOn::Many(vec![
2312 "self-hosted".into(),
2313 "macOS".into(),
2314 "ARM64".into(),
2315 ]))
2316 .is_match()
2317 );
2318 for missing in ["X64", "Windows", "Linux", "gpu", "rm-other-osx-arm64"] {
2319 let result = labels.matches(&RunsOn::Many(vec![
2320 "self-hosted".into(),
2321 "macOS".into(),
2322 "ARM64".into(),
2323 missing.into(),
2324 ]));
2325 assert_eq!(
2326 result,
2327 RunsOnMatch::NoMatch {
2328 missing: vec![label(missing)]
2329 }
2330 );
2331 }
2332 }
2333
2334 #[test]
2335 fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2336 let without = host_labels("home");
2340 assert!(
2341 !without
2342 .matches(&RunsOn::Single("self-hosted".into()))
2343 .is_match(),
2344 "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2345 );
2346
2347 let mut with = host_labels("home");
2348 with.add(label("self-hosted"));
2349 assert!(
2350 with.matches(&RunsOn::Single("self-hosted".into()))
2351 .is_match()
2352 );
2353 assert!(
2354 with.matches(&RunsOn::Many(vec![
2355 "rm-home-win-x64".into(),
2356 "self-hosted".into()
2357 ]))
2358 .is_match()
2359 );
2360 }
2361
2362 #[test]
2363 fn a_no_match_names_the_labels_that_were_missing() {
2364 let policy = host_labels("home");
2365 let got = policy.matches(&RunsOn::Many(vec![
2366 "rm-home-win-x64".into(),
2367 "self-hosted".into(),
2368 "GPU".into(),
2369 ]));
2370 match got {
2371 RunsOnMatch::NoMatch { missing } => {
2372 assert_eq!(
2373 missing,
2374 vec![label("self-hosted"), label("gpu")],
2375 "the operator needs to know which labels to add"
2376 );
2377 }
2378 other => panic!("expected NoMatch, got {other:?}"),
2379 }
2380 }
2381
2382 #[test]
2383 fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2384 let policy = host_labels("home");
2385 let got = policy.matches(&RunsOn::Grouped {
2386 group: Some("Default".into()),
2387 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2388 });
2389 assert_eq!(
2390 got,
2391 RunsOnMatch::Match {
2392 runner_group: Some("Default".into())
2393 },
2394 "a policy has no runner-group field, so the domain cannot evaluate \
2395 `group:`; returning it lets `c4`, which can, do so without re-parsing"
2396 );
2397 }
2398
2399 #[test]
2400 fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2401 let policy = host_labels("home");
2402
2403 let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2404 assert!(matches!(
2405 expr,
2406 RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2407 ));
2408
2409 let group = policy.matches(&RunsOn::Grouped {
2410 group: Some("g".into()),
2411 labels: RunsOnLabels::Many(vec![]),
2412 });
2413 assert!(matches!(
2414 group,
2415 RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2416 ));
2417
2418 let none = policy.matches(&RunsOn::Many(vec![]));
2419 assert!(matches!(
2420 none,
2421 RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2422 ));
2423
2424 let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2425 assert!(matches!(
2426 invalid,
2427 RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2428 ));
2429 }
2430
2431 #[test]
2432 fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2433 let policy = table_policy();
2437 let jobs = vec![
2438 RunsOn::Single("rm-home-win-x64".into()), RunsOn::Single("ubuntu-latest".into()), RunsOn::Single("${{ matrix.runner }}".into()), RunsOn::Single("${{ inputs.pool }}".into()), ];
2443 let tally = policy.tally(&jobs);
2444
2445 assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2446 assert_eq!(tally.not_matched, 1);
2447 assert_eq!(
2448 tally.unresolvable.len(),
2449 2,
2450 "and it must not vanish either -- `g2` shows these to the operator"
2451 );
2452 assert_eq!(
2453 tally.total_seen(),
2454 jobs.len() as u32,
2455 "every job seen is accounted for in exactly one bucket"
2456 );
2457 }
2458
2459 #[test]
2460 fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2461 let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2462 assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2463
2464 let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2465 assert_eq!(
2466 many,
2467 RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2468 );
2469
2470 let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2471 assert_eq!(
2472 grouped,
2473 RunsOn::Grouped {
2474 group: Some("g".into()),
2475 labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2476 }
2477 );
2478
2479 let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2480 assert_eq!(
2481 scalar_labels,
2482 RunsOn::Grouped {
2483 group: None,
2484 labels: RunsOnLabels::One("a".into()),
2485 }
2486 );
2487
2488 let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2489 assert_eq!(
2490 group_only,
2491 RunsOn::Grouped {
2492 group: Some("g".into()),
2493 labels: RunsOnLabels::Many(vec![]),
2494 }
2495 );
2496
2497 assert_eq!(
2499 RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2500 RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2501 );
2502 }
2503
2504 #[test]
2509 fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2510 let labels = host_labels("home");
2511
2512 assert!(matches!(
2515 PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2516 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2517 ));
2518 assert!(matches!(
2519 PolicyMode::from_persisted(None, 0, Some(nz(1))),
2520 Err(PolicyError::AutoscaleWithoutRoutingLabels)
2521 ));
2522
2523 assert!(
2525 PolicyMode::from_persisted(None, 0, None)
2526 .unwrap()
2527 .is_monitor_only()
2528 );
2529 assert!(
2530 PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2531 .unwrap()
2532 .is_autoscale()
2533 );
2534 }
2535
2536 #[test]
2537 fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2538 let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2545 assert!(autoscale.routing_labels().is_some());
2546 assert!(autoscale.max_capacity().is_some());
2547
2548 let monitor = PolicyMode::monitor_only();
2549 assert!(monitor.routing_labels().is_none());
2550 assert!(monitor.max_capacity().is_none());
2551 assert_eq!(monitor.min_capacity(), 0);
2552 }
2553
2554 #[test]
2555 fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2556 let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2559 assert!(matches!(
2560 err,
2561 PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2562 ));
2563 assert!(
2564 err.to_string().contains("MonitorOnly"),
2565 "the message must name the shape rule, got: {err}"
2566 );
2567 }
2568
2569 #[test]
2570 fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2571 assert!(matches!(
2577 PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2578 Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2579 ));
2580 assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2581 assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2582
2583 let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2585 assert!(matches!(
2586 cfg.set_max_capacity(nz(1)),
2587 Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2588 ));
2589 assert_eq!(
2590 cfg.max_capacity().get(),
2591 4,
2592 "a refused write changes nothing"
2593 );
2594 }
2595
2596 #[test]
2597 fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2598 for mode in [
2599 PolicyMode::monitor_only(),
2600 PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2601 ] {
2602 let json = serde_json::to_string(&mode).unwrap();
2603 let back: PolicyMode = serde_json::from_str(&json).unwrap();
2604 assert_eq!(mode, back, "{json} did not round-trip");
2605 }
2606
2607 let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2610 let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2611 assert!(
2612 err.to_string().contains("min_capacity"),
2613 "expected the shape error to survive into serde's message, got: {err}"
2614 );
2615 }
2616
2617 #[test]
2618 fn a_policy_round_trips_through_its_persisted_form() {
2619 let mut policy = autoscale_policy(
2627 ScaleTarget::repository("o/r").unwrap(),
2628 HostId::from_u128(7),
2629 3,
2630 );
2631 policy.add_routing_label(label("gpu")).unwrap();
2632 policy.activate().unwrap();
2638 assert_eq!(policy.state(), PolicyState::Active);
2639 assert!(policy.enabled());
2640
2641 let stored = policy.to_persisted();
2642 assert_ne!(
2643 stored.installation_id, stored.revision,
2644 "the fixture must distinguish the two u64 columns, or transposing \
2645 them is unobservable and this test proves nothing"
2646 );
2647 assert_eq!(stored.installation_id, 42);
2648 assert_eq!(stored.revision, policy.revision());
2649
2650 let restored =
2651 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2652 assert_eq!(restored, policy);
2653 assert_eq!(restored.installation_id, 42);
2654 assert_eq!(restored.revision(), policy.revision());
2655 assert_eq!(restored.state(), PolicyState::Active);
2656 assert!(restored.enabled());
2657 assert_eq!(
2658 restored.routing_labels().unwrap().count().get(),
2659 2,
2660 "the optional label survives alongside the host label"
2661 );
2662 }
2663
2664 #[test]
2665 fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
2666 let mut policy = ScalePolicy::new(
2674 PolicyId::from_u128(2),
2675 ScaleTarget::organization("acme").unwrap(),
2676 9,
2677 HostId::from_u128(7),
2678 PolicyMode::monitor_only(),
2679 CachePolicy::default(),
2680 );
2681 policy.activate().unwrap();
2682
2683 let stored = policy.to_persisted();
2684 assert!(stored.routing_labels.is_none());
2685 assert_eq!(stored.min_capacity, 0);
2686 assert!(stored.max_capacity.is_none());
2687 assert_ne!(stored.installation_id, stored.revision);
2688
2689 let restored =
2690 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2691 assert_eq!(restored, policy);
2692 assert!(
2693 restored.mode().is_monitor_only(),
2694 "the mode is inferred back from the three columns, not stored"
2695 );
2696 assert!(!restored.owns_runners());
2697 assert_eq!(restored.installation_id, 9);
2698 assert_eq!(restored.revision(), 1);
2699 }
2700
2701 fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
2721 use PolicyState::*;
2722 let mut edges = vec![
2723 (Pending, Active),
2724 (Pending, RepairRequired),
2725 (Active, Draining),
2726 (Draining, Disabled),
2727 (Disabled, Pending),
2728 (AuthenticationFailed, Pending),
2730 ];
2731 for from in PolicyState::ALL {
2733 if from != AuthenticationFailed {
2734 edges.push((from, AuthenticationFailed));
2735 }
2736 }
2737 edges
2738 }
2739
2740 #[test]
2741 fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
2742 let expected = diagram_edges();
2745 assert_eq!(
2746 expected.len(),
2747 11,
2748 "the transcription itself changed; check it against the diagram"
2749 );
2750
2751 let mut legal_seen = 0usize;
2752 let mut illegal_seen = 0usize;
2753
2754 for from in PolicyState::ALL {
2755 for to in PolicyState::ALL {
2756 let expected_legal = expected.contains(&(from, to));
2757 let mut policy = autoscale_policy(
2758 ScaleTarget::repository("o/r").unwrap(),
2759 HostId::from_u128(7),
2760 1,
2761 );
2762 policy.state = from;
2766
2767 let result = policy.transition_to(to);
2768 if expected_legal {
2769 legal_seen += 1;
2770 assert!(
2771 result.is_ok(),
2772 "{from} -> {to} is in the diagram and must be accepted"
2773 );
2774 assert_eq!(policy.state(), to);
2775 } else {
2776 illegal_seen += 1;
2777 assert!(
2778 matches!(result, Err(PolicyError::IllegalTransition { .. })),
2779 "{from} -> {to} is not in the diagram and must be rejected"
2780 );
2781 assert_eq!(policy.state(), from, "a refused transition changes nothing");
2782 }
2783 }
2784 }
2785
2786 assert_eq!(legal_seen, 11);
2787 assert_eq!(illegal_seen, 36 - 11);
2788
2789 let mut published = PolicyState::LEGAL.to_vec();
2792 let mut transcribed = expected;
2793 published.sort_unstable();
2794 transcribed.sort_unstable();
2795 assert_eq!(published, transcribed);
2796 }
2797
2798 #[test]
2799 fn a_policy_state_cannot_transition_to_itself() {
2800 for state in PolicyState::ALL {
2801 assert!(
2802 !state.can_transition_to(state),
2803 "{state} -> {state} is not an edge in the diagram; treating it as \
2804 one would let a repeated authentication failure look like progress"
2805 );
2806 }
2807 }
2808
2809 #[test]
2810 fn the_documented_happy_path_walks_pending_to_disabled() {
2811 let mut policy = autoscale_policy(
2812 ScaleTarget::repository("o/r").unwrap(),
2813 HostId::from_u128(7),
2814 2,
2815 );
2816
2817 assert_eq!(policy.state(), PolicyState::Pending);
2819 assert!(!policy.enabled());
2820 assert!(!policy.may_start_runners());
2821
2822 policy.activate().unwrap();
2823 assert_eq!(policy.state(), PolicyState::Active);
2824 assert!(policy.enabled());
2825 assert!(policy.may_start_runners());
2826
2827 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2829 assert_eq!(
2830 policy.drain_completed(1).unwrap(),
2831 PolicyState::Draining,
2832 "a policy with a runner still in flight stays draining"
2833 );
2834 assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
2835 }
2836
2837 #[test]
2838 fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
2839 let mut policy = autoscale_policy(
2842 ScaleTarget::repository("o/r").unwrap(),
2843 HostId::from_u128(7),
2844 5,
2845 );
2846 policy.activate().unwrap();
2847
2848 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
2851 assert_eq!(policy.tally(&jobs).demand(), 4);
2852
2853 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2854 assert!(!policy.enabled());
2855 assert!(
2856 !policy.may_start_runners(),
2857 "a draining policy must not be the reason a new runner starts, even \
2858 with four jobs queued for its labels"
2859 );
2860 assert_eq!(
2861 policy.tally(&jobs).demand(),
2862 4,
2863 "queued demand stays visible while draining (flow 5.2)"
2864 );
2865 }
2866
2867 #[test]
2868 fn re_authentication_is_the_only_way_out_of_authentication_failed() {
2869 for from in [
2870 PolicyState::Pending,
2871 PolicyState::Active,
2872 PolicyState::Draining,
2873 PolicyState::Disabled,
2874 PolicyState::RepairRequired,
2875 ] {
2876 let mut policy = autoscale_policy(
2877 ScaleTarget::repository("o/r").unwrap(),
2878 HostId::from_u128(7),
2879 1,
2880 );
2881 policy.state = from;
2882 policy.authentication_failed().unwrap();
2883 assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
2884
2885 assert!(matches!(
2887 policy.authentication_failed(),
2888 Err(PolicyError::IllegalTransition { .. })
2889 ));
2890
2891 policy.reauthenticated().unwrap();
2892 assert_eq!(policy.state(), PolicyState::Pending);
2893 }
2894 }
2895
2896 #[test]
2897 fn mutant_disabling_revoked_eligibility_gate_is_detected() {
2898 let mut policy = autoscale_policy(
2899 ScaleTarget::repository("o/r").unwrap(),
2900 HostId::from_u128(7),
2901 1,
2902 );
2903 policy.activate().unwrap();
2904 policy.authentication_failed().unwrap();
2905 assert!(!policy.may_start_runners());
2906
2907 let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
2911 assert!(
2912 mutant_may_start,
2913 "omitting revoked state must make the eligibility gate red"
2914 );
2915 }
2916
2917 #[test]
2918 fn a_refused_transition_leaves_the_revision_untouched() {
2919 let mut policy = autoscale_policy(
2920 ScaleTarget::repository("o/r").unwrap(),
2921 HostId::from_u128(7),
2922 1,
2923 );
2924 assert_eq!(policy.revision(), 0);
2925 policy.activate().unwrap();
2926 assert_eq!(policy.revision(), 1);
2927
2928 assert!(policy.activate().is_err());
2929 assert_eq!(
2930 policy.revision(),
2931 1,
2932 "a rejected write must not bump the optimistic-concurrency token, or \
2933 `b2`'s stale-revision check would reject the next honest write"
2934 );
2935 }
2936
2937 #[test]
2942 fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
2943 let mut policy = ScalePolicy::new(
2944 PolicyId::from_u128(2),
2945 ScaleTarget::organization("acme").unwrap(),
2946 9,
2947 HostId::from_u128(7),
2948 PolicyMode::monitor_only(),
2949 CachePolicy::default(),
2950 );
2951 policy.activate().unwrap();
2952
2953 assert!(!policy.owns_runners());
2954 assert!(
2955 !policy.may_start_runners(),
2956 "an active, enabled monitor-only policy still starts nothing (D19)"
2957 );
2958 assert!(policy.routing_labels().is_none());
2959 assert!(policy.max_capacity().is_none());
2960
2961 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
2963 assert_eq!(
2964 policy.tally(&jobs).demand(),
2965 0,
2966 "a monitor-only policy has no demand at all, rather than demand that \
2967 is computed and then ignored"
2968 );
2969
2970 assert!(matches!(
2973 policy.add_routing_label(label("gpu")),
2974 Err(PolicyError::NotAutoscale)
2975 ));
2976 assert!(matches!(
2977 policy.remove_routing_label(&label("gpu")),
2978 Err(PolicyError::NotAutoscale)
2979 ));
2980 assert!(matches!(
2988 PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
2989 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2990 ));
2991 }
2992
2993 #[test]
2994 fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
2995 let mut policy = ScalePolicy::new(
2998 PolicyId::from_u128(2),
2999 ScaleTarget::repository("o/r").unwrap(),
3000 9,
3001 HostId::from_u128(7),
3002 PolicyMode::monitor_only(),
3003 CachePolicy::default(),
3004 );
3005 assert!(policy.routing_labels().is_none());
3006
3007 policy
3008 .promote_to_autoscale(host_labels("home"), 0, nz(3))
3009 .unwrap();
3010
3011 assert!(policy.owns_runners());
3012 assert_eq!(
3013 policy.routing_labels().unwrap().host_label().as_str(),
3014 "rm-home-win-x64"
3015 );
3016 assert_eq!(policy.max_capacity().unwrap().get(), 3);
3017
3018 assert!(matches!(
3020 policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
3021 Err(PolicyError::AlreadyAutoscale)
3022 ));
3023 policy.set_max_capacity(nz(4)).unwrap();
3024 assert_eq!(policy.max_capacity().unwrap().get(), 4);
3025 }
3026
3027 #[test]
3028 fn a_named_monitor_can_only_be_promoted_with_its_derived_selector() {
3029 let mut named = ScalePolicy::new_named_monitor(
3030 PolicyId::from_u128(3),
3031 ScaleTarget::repository("o/r").unwrap(),
3032 9,
3033 HostId::from_u128(7),
3034 HostLabel::new("home").unwrap(),
3035 ProfileName::new("Py-Isolated").unwrap(),
3036 CachePolicy::default(),
3037 )
3038 .unwrap();
3039 assert!(matches!(
3040 named.promote_to_autoscale(host_labels("other"), 0, nz(3)),
3041 Err(PolicyError::NamedProfileNeedsDerivedSelector)
3042 ));
3043 assert!(named.routing_labels().is_none());
3044 named
3045 .promote_named_to_autoscale(Os::Windows, Arch::X64, 0, nz(3))
3046 .unwrap();
3047 assert_eq!(
3048 named.routing_labels().unwrap().host_label().as_str(),
3049 "rm-home-win-x64-py-isolated"
3050 );
3051 }
3052
3053 #[test]
3054 fn a_policy_is_owned_by_exactly_one_host() {
3055 let mine = HostId::from_u128(7);
3056 let theirs = HostId::from_u128(8);
3057 let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
3058 assert!(policy.is_owned_by(mine));
3059 assert!(!policy.is_owned_by(theirs));
3060 }
3061
3062 #[test]
3063 fn an_overridden_host_label_is_detectable_without_being_rejected() {
3064 assert!(host_labels("home").is_derived_shape());
3068 assert!(
3069 RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
3070 .is_derived_shape(),
3071 "a host label containing `-` still derives a four-plus-segment name"
3072 );
3073 assert_eq!(
3079 host_labels("home--pc").host_label().as_str(),
3080 "rm-home--pc-win-x64"
3081 );
3082 assert!(
3083 host_labels("home--pc").is_derived_shape(),
3084 "consecutive dashes are legal inside a host label; the empty middle \
3085 segment they produce is not evidence of an override"
3086 );
3087
3088 for raw in [
3092 "self-hosted",
3093 "ubuntu-latest",
3094 "rm-home-win",
3095 "rm-home-win-x64-extra",
3096 ] {
3097 assert!(
3098 !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
3099 "{raw:?} is not the derived shape"
3100 );
3101 }
3102 assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
3104 assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
3105 assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
3106
3107 let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
3109 overridden.add(label("rm-home-win-x64"));
3110 assert!(!overridden.is_derived_shape());
3111 }
3112
3113 #[test]
3114 fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
3115 let mut policy = autoscale_policy(
3119 ScaleTarget::repository("o/r").unwrap(),
3120 HostId::from_u128(7),
3121 3,
3122 );
3123
3124 assert!(policy.can_activate());
3125 assert!(
3126 !policy.can_request_disable(),
3127 "a pending policy cannot drain; it is also already not enabled, \
3128 which is what makes the command a no-op rather than a failure"
3129 );
3130 assert!(matches!(
3131 policy.request_disable(),
3132 Err(PolicyError::IllegalTransition {
3133 from: PolicyState::Pending,
3134 to: PolicyState::Draining,
3135 })
3136 ));
3137
3138 policy.activate().unwrap();
3139 assert!(!policy.can_activate(), "already active");
3140 assert!(policy.can_request_disable());
3141 assert!(matches!(
3142 policy.activate(),
3143 Err(PolicyError::IllegalTransition { .. })
3144 ));
3145
3146 policy.request_disable().unwrap();
3147 assert!(!policy.can_request_disable(), "already draining");
3148 assert!(!policy.enabled());
3149 }
3150
3151 fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
3175 let host = HostId::from_u128(7);
3176 let mut trace = Vec::new();
3177
3178 let mut policy = autoscale_policy(target.clone(), host, 3);
3179 trace.push(format!("owns_runners={}", policy.owns_runners()));
3180 trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
3181 trace.push(format!(
3182 "owned_by_other={}",
3183 policy.is_owned_by(HostId::from_u128(8))
3184 ));
3185 trace.push(format!("initial_state={}", policy.state()));
3186 trace.push(format!("initial_enabled={}", policy.enabled()));
3187 trace.push(format!(
3188 "may_start_initially={}",
3189 policy.may_start_runners()
3190 ));
3191 trace.push(format!("labels={}", policy.routing_labels().unwrap()));
3192 trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
3193 trace.push(format!("min_capacity={}", policy.min_capacity()));
3194
3195 policy.activate().unwrap();
3197 trace.push(format!("after_activate={}", policy.state()));
3198 trace.push(format!("may_start_active={}", policy.may_start_runners()));
3199
3200 let jobs = vec![
3202 RunsOn::Single("rm-home-win-x64".into()),
3203 RunsOn::Single("ubuntu-latest".into()),
3204 RunsOn::Single("${{ matrix.os }}".into()),
3205 ];
3206 let tally = policy.tally(&jobs);
3207 trace.push(format!(
3208 "demand={} not_matched={} unresolvable={}",
3209 tally.demand(),
3210 tally.not_matched,
3211 tally.unresolvable.len()
3212 ));
3213
3214 let host_record = crate::model::Host::new(
3218 host,
3219 "home-pc",
3220 Os::Windows,
3221 Arch::X64,
3222 nz(4),
3223 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3224 )
3225 .unwrap();
3226 let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
3227 let allocation = allocator.allocate(&policy, 3);
3228 trace.push(format!(
3229 "alloc demand={} desired={} active_owned={} headroom_before={} \
3230 to_start={} limiting={}",
3231 allocation.demand,
3232 allocation.desired,
3233 allocation.active_owned,
3234 allocation.headroom_before,
3235 allocation.to_start,
3236 allocation.limiting_factor
3237 ));
3238 trace.push(format!("headroom_after={}", allocator.headroom()));
3239 trace.push(format!(
3242 "alloc_zero_to_start={}",
3243 allocator.allocate(&policy, 0).to_start
3244 ));
3245
3246 let attempt = crate::attempt::RunnerAttempt::allocate(
3250 crate::model::AttemptId::from_u128(11),
3251 policy.id,
3252 "C:/runners/eq",
3253 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3254 );
3255 trace.push(format!(
3256 "authorize_own_host={:?}",
3257 crate::attempt::authorize(host, &policy, &attempt).is_ok()
3258 ));
3259 trace.push(format!(
3260 "authorize_other_host={}",
3261 crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
3262 .expect_err("an agent on another host must be refused")
3263 ));
3264 let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
3265 crate::model::AttemptId::from_u128(12),
3266 PolicyId::from_u128(999),
3267 "C:/runners/eq-other",
3268 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3269 );
3270 trace.push(format!(
3271 "authorize_other_policy={}",
3272 crate::attempt::authorize(host, &policy, &foreign_attempt)
3273 .expect_err("an attempt under another policy must be refused")
3274 ));
3275
3276 trace.push(format!("disable={}", policy.request_disable().unwrap()));
3278 trace.push(format!(
3279 "drain_with_1={}",
3280 policy.drain_completed(1).unwrap()
3281 ));
3282 trace.push(format!(
3283 "drain_with_0={}",
3284 policy.drain_completed(0).unwrap()
3285 ));
3286
3287 trace.push(format!(
3289 "reactivate_err={}",
3290 policy.transition_to(PolicyState::Active).is_err()
3291 ));
3292
3293 trace.push(format!(
3295 "registration_labels={:?}",
3296 autoscale_policy(target, host, 3)
3297 .routing_labels()
3298 .unwrap()
3299 .as_registration_labels()
3300 ));
3301
3302 trace
3303 }
3304
3305 #[test]
3306 fn repository_and_organization_targets_are_equivalent() {
3307 let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
3308 let organization =
3309 assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
3310
3311 assert_eq!(
3312 repository, organization,
3313 "D18: the two scopes differ only in which GitHub endpoint and which \
3314 App permission the gateway uses. Any difference here is a \
3315 scope-dependent domain rule that must not exist."
3316 );
3317
3318 assert_ne!(
3320 ScaleTarget::repository("o/r").unwrap().scope(),
3321 ScaleTarget::organization("o").unwrap().scope()
3322 );
3323 }
3324}