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 labels \
115 are {expected} and rm-pool-any (labels starting `rm-pool-` are reserved for the \
116 coordinator's pools)"
117 )]
118 PoolLabelMismatch { label: Label, expected: Label },
119
120 #[error(
121 "{label} names no label to exclude: an exclusion is `not-` and one ordinary label, \
122 as in `not-wsl`"
123 )]
124 InvalidExclusion { label: Label },
125
126 #[error(
127 "{label} contradicts {carried}, which this profile carries: a profile cannot both \
128 carry a label and exclude it"
129 )]
130 ContradictoryExclusion { label: Label, carried: Label },
131}
132
133pub const POOL_LABEL_PREFIX: &str = "rm-pool-";
146
147pub const ANY_POOL_LABEL: &str = "rm-pool-any";
152
153#[must_use]
155pub fn any_pool_label() -> Label {
156 Label::new(ANY_POOL_LABEL).expect("`rm-pool-any` is a valid label")
157}
158
159#[must_use]
161pub fn is_any_pool_label(label: &str) -> bool {
162 label.eq_ignore_ascii_case(ANY_POOL_LABEL)
163}
164
165#[must_use]
169pub fn in_pool_namespace(label: &str) -> bool {
170 label
171 .get(..POOL_LABEL_PREFIX.len())
172 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(POOL_LABEL_PREFIX))
173}
174
175#[must_use]
179pub fn is_pool_label(label: &str) -> bool {
180 is_any_pool_label(label)
181 || Os::ALL
182 .iter()
183 .any(|os| pool_label(*os).as_str().eq_ignore_ascii_case(label))
184}
185
186#[must_use]
191pub fn is_pool_job(required: &[Label]) -> bool {
192 required.iter().any(|label| is_pool_label(label.as_str()))
193}
194
195#[must_use]
198pub fn pool_label(os: Os) -> Label {
199 Label::new(format!("{POOL_LABEL_PREFIX}{}", os.pool_token()))
200 .expect("the pool prefix and a fixed OS token form a valid label")
201}
202
203#[must_use]
206pub fn may_carry_pool(label: &Label, os: Os) -> bool {
207 *label == pool_label(os) || is_any_pool_label(label.as_str())
208}
209
210pub fn check_pool_label(label: &Label, os: Os) -> Result<(), PolicyError> {
216 let expected = pool_label(os);
217 if in_pool_namespace(label.as_str()) && !may_carry_pool(label, os) {
218 return Err(PolicyError::PoolLabelMismatch {
219 label: label.clone(),
220 expected,
221 });
222 }
223 Ok(())
224}
225
226pub const EXCLUSION_PREFIX: &str = "not-";
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum Exclusion {
244 Of(Label),
246 Invalid,
249}
250
251#[must_use]
254pub fn excluded_label(label: &Label) -> Option<Exclusion> {
255 let rest = label.as_str().strip_prefix(EXCLUSION_PREFIX)?;
256 Some(match Label::new(rest) {
257 Ok(excluded)
260 if excluded.as_str() == rest && !excluded.as_str().starts_with(EXCLUSION_PREFIX) =>
261 {
262 Exclusion::Of(excluded)
263 }
264 _ => Exclusion::Invalid,
265 })
266}
267
268#[must_use]
271pub fn exclusion_of(label: &Label) -> Option<Label> {
272 Label::new(format!("{EXCLUSION_PREFIX}{label}")).ok()
273}
274
275#[must_use]
282pub fn label_satisfied(offers: impl Fn(&Label) -> bool, required: &Label) -> bool {
283 match excluded_label(required) {
284 Some(Exclusion::Of(excluded)) => !offers(&excluded),
285 Some(Exclusion::Invalid) => false,
286 None => offers(required),
287 }
288}
289
290pub fn exclusions_in(required: &[Label]) -> impl Iterator<Item = Label> + '_ {
292 required
293 .iter()
294 .filter_map(|label| match excluded_label(label) {
295 Some(Exclusion::Of(excluded)) => Some(excluded),
296 _ => None,
297 })
298}
299
300pub fn check_exclusion_label(labels: &RoutingLabels, label: &Label) -> Result<(), PolicyError> {
307 match excluded_label(label) {
308 Some(Exclusion::Invalid) => Err(PolicyError::InvalidExclusion {
309 label: label.clone(),
310 }),
311 Some(Exclusion::Of(excluded)) if labels.contains(&excluded) => {
312 Err(PolicyError::ContradictoryExclusion {
313 label: label.clone(),
314 carried: excluded,
315 })
316 }
317 Some(Exclusion::Of(_)) => Ok(()),
318 None => match exclusion_of(label).filter(|exclusion| labels.contains(exclusion)) {
319 Some(exclusion) => Err(PolicyError::ContradictoryExclusion {
320 label: label.clone(),
321 carried: exclusion,
322 }),
323 None => Ok(()),
324 },
325 }
326}
327
328pub const WSL_LABEL: &str = "wsl";
331
332#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
341pub struct HostTraits {
342 wsl: Option<bool>,
345}
346
347impl HostTraits {
348 #[must_use]
350 pub const fn wsl(inside: bool) -> Self {
351 Self { wsl: Some(inside) }
352 }
353
354 #[must_use]
356 pub fn labels(&self) -> Vec<Label> {
357 let wsl = Label::new(WSL_LABEL).expect("`wsl` is a valid label");
358 match self.wsl {
359 None => Vec::new(),
360 Some(true) => vec![wsl],
361 Some(false) => exclusion_of(&wsl).into_iter().collect(),
362 }
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
399pub struct RoutingLabels {
400 host_label: Label,
401 additional: BTreeSet<Label>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405struct RoutingLabelsRepr {
406 host_label: Label,
407 #[serde(default)]
408 additional: BTreeSet<Label>,
409}
410
411impl From<RoutingLabelsRepr> for RoutingLabels {
412 fn from(repr: RoutingLabelsRepr) -> Self {
413 Self::from_parts(repr.host_label, repr.additional)
418 }
419}
420
421impl From<RoutingLabels> for RoutingLabelsRepr {
422 fn from(value: RoutingLabels) -> Self {
423 Self {
424 host_label: value.host_label,
425 additional: value.additional,
426 }
427 }
428}
429
430impl RoutingLabels {
431 pub const PREFIX: &'static str = "rm";
433
434 #[must_use]
448 pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
449 let derived = format!(
450 "{}-{}-{}-{}",
451 Self::PREFIX,
452 host_label.as_str(),
453 os.label_token(),
454 arch.label_token()
455 );
456 Self {
457 host_label: Label::new(derived).expect(
458 "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
459 segments are fixed tokens, so the concatenation is always a valid Label",
460 ),
461 additional: BTreeSet::new(),
462 }
463 }
464
465 #[must_use]
469 pub fn derive_for_profile(
470 host_label: &HostLabel,
471 os: Os,
472 arch: Arch,
473 profile: &ProfileName,
474 ) -> Self {
475 let label = format!(
476 "{}-{}-{}-{}-{}",
477 Self::PREFIX,
478 host_label.as_str(),
479 os.label_token(),
480 arch.label_token(),
481 profile.as_str()
482 );
483 Self::from_host_label(Label::new(label).expect("validated selector segments form a label"))
484 }
485
486 #[must_use]
499 pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
500 let additional = additional
501 .into_iter()
502 .filter(|l| *l != host_label)
503 .collect();
504 Self {
505 host_label,
506 additional,
507 }
508 }
509
510 #[must_use]
512 pub fn from_host_label(host_label: Label) -> Self {
513 Self::from_parts(host_label, Vec::new())
514 }
515
516 #[must_use]
518 pub fn host_label(&self) -> &Label {
519 &self.host_label
520 }
521
522 #[must_use]
553 pub fn is_derived_shape(&self) -> bool {
554 let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
555 let [prefix, middle @ .., os, arch] = segments.as_slice() else {
560 return false;
561 };
562 *prefix == Self::PREFIX
565 && !middle.is_empty()
566 && Os::ALL
567 .iter()
568 .any(|candidate| candidate.label_token() == *os)
569 && Arch::ALL
570 .iter()
571 .any(|candidate| candidate.label_token() == *arch)
572 }
573
574 pub fn additional(&self) -> impl Iterator<Item = &Label> {
576 self.additional.iter()
577 }
578
579 pub fn add(&mut self, label: Label) -> bool {
582 if label == self.host_label {
583 return false;
584 }
585 self.additional.insert(label)
586 }
587
588 pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
595 if *label == self.host_label {
596 return Err(PolicyError::HostLabelNotRemovable {
597 label: label.clone(),
598 });
599 }
600 Ok(self.additional.remove(label))
601 }
602
603 #[must_use]
604 pub fn contains(&self, label: &Label) -> bool {
605 self.host_label == *label || self.additional.contains(label)
606 }
607
608 #[must_use]
611 pub fn satisfies(&self, label: &Label) -> bool {
612 label_satisfied(|offered| self.contains(offered), label)
613 }
614
615 pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
619 self.additional
620 .iter()
621 .filter(|label| is_pool_label(label.as_str()))
622 }
623
624 pub fn iter(&self) -> impl Iterator<Item = &Label> {
626 std::iter::once(&self.host_label).chain(self.additional.iter())
627 }
628
629 #[must_use]
631 pub fn count(&self) -> NonZeroUsize {
632 NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
633 }
634
635 #[must_use]
637 pub fn to_non_empty(&self) -> NonEmpty<Label> {
638 let mut out = NonEmpty::of(self.host_label.clone());
639 for label in &self.additional {
640 out.push(label.clone());
641 }
642 out
643 }
644
645 #[must_use]
659 pub fn as_registration_labels(&self) -> Vec<String> {
660 self.iter()
661 .filter(|label| self.satisfies(label))
662 .map(|l| l.as_str().to_string())
663 .collect()
664 }
665
666 #[must_use]
669 pub fn with_labels(&self, extra: impl IntoIterator<Item = Label>) -> Self {
670 let mut labels = self.clone();
671 for label in extra {
672 labels.add(label);
673 }
674 labels
675 }
676
677 #[must_use]
681 pub fn excluding<'a>(&self, excluded: impl IntoIterator<Item = &'a Label>) -> Self {
682 let exclusions: Vec<Label> = excluded
683 .into_iter()
684 .filter(|label| !self.contains(label))
685 .filter_map(exclusion_of)
686 .collect();
687 self.with_labels(exclusions)
688 }
689
690 #[must_use]
701 pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
702 let required = match runs_on.required_labels() {
703 Ok(required) => required,
704 Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
705 };
706
707 let missing: Vec<Label> = required
708 .iter()
709 .filter(|label| !self.satisfies(label))
710 .cloned()
711 .collect();
712
713 if missing.is_empty() {
714 RunsOnMatch::Match {
715 runner_group: runs_on.runner_group().map(str::to_string),
716 }
717 } else {
718 RunsOnMatch::NoMatch { missing }
719 }
720 }
721
722 #[must_use]
730 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
731 let mut tally = DemandTally::default();
732 for job in jobs {
733 match self.matches(job) {
734 RunsOnMatch::Match { .. } => {
735 tally.matched += 1;
736 if let Ok(required) = job.required_labels() {
737 if is_pool_job(&required) {
738 tally.pool_matched += 1;
739 }
740 tally.excluded.extend(exclusions_in(&required));
741 }
742 }
743 RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
744 RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
745 }
746 }
747 tally
748 }
749}
750
751impl fmt::Display for RoutingLabels {
752 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
753 let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
754 f.write_str(&joined.join(","))
755 }
756}
757
758#[derive(Debug, Clone, Default, PartialEq, Eq)]
760pub struct DemandTally {
761 pub matched: u32,
763 pub not_matched: u32,
765 pub unresolvable: Vec<UnresolvableRunsOn>,
769 pub excluded: BTreeSet<Label>,
773 pub pool_matched: u32,
776}
777
778impl DemandTally {
779 #[must_use]
780 pub fn demand(&self) -> u32 {
781 self.matched
782 }
783
784 #[must_use]
788 pub fn non_pool_demand(&self) -> u32 {
789 self.matched.saturating_sub(self.pool_matched)
790 }
791
792 #[must_use]
793 pub fn total_seen(&self) -> u32 {
794 self.matched + self.not_matched + self.unresolvable.len() as u32
795 }
796}
797
798#[derive(Debug, Clone, PartialEq, Eq)]
800pub enum RunsOnMatch {
801 Match { runner_group: Option<String> },
809 NoMatch { missing: Vec<Label> },
812 Unresolvable(UnresolvableRunsOn),
814}
815
816impl RunsOnMatch {
817 #[must_use]
818 pub const fn is_match(&self) -> bool {
819 matches!(self, RunsOnMatch::Match { .. })
820 }
821
822 #[must_use]
823 pub const fn is_unresolvable(&self) -> bool {
824 matches!(self, RunsOnMatch::Unresolvable(_))
825 }
826}
827
828#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
830pub enum UnresolvableRunsOn {
831 #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
834 Expression { raw: String },
835
836 #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
839 RunnerGroupWithoutLabels { group: String },
840
841 #[error("`runs-on` names no labels")]
843 NoLabels,
844
845 #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
847 InvalidLabel {
848 raw: String,
849 #[source]
850 source: ValidationError,
851 },
852
853 #[error(
856 "`runs-on` contains {label}, which excludes no usable label (write `not-` and one \
857 ordinary label, as in `not-wsl`); no runner can take this job"
858 )]
859 InvalidExclusion { label: Label },
860
861 #[error(
864 "`runs-on` asks for {label} and for not-{label}; no runner can carry a label and \
865 lack it, so none can take this job"
866 )]
867 ConflictingExclusion { label: Label },
868}
869
870impl UnresolvableRunsOn {
871 #[must_use]
874 pub const fn is_unsatisfiable_exclusion(&self) -> bool {
875 self.exclusion_kind().is_some()
876 }
877
878 #[must_use]
881 pub const fn exclusion_kind(&self) -> Option<&'static str> {
882 match self {
883 Self::InvalidExclusion { .. } => Some("invalid_exclusion"),
884 Self::ConflictingExclusion { .. } => Some("conflicting_exclusion"),
885 _ => None,
886 }
887 }
888}
889
890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
897#[serde(untagged)]
898pub enum RunsOn {
899 Single(String),
901 Many(Vec<String>),
903 Grouped {
905 #[serde(default)]
906 group: Option<String>,
907 #[serde(default)]
908 labels: RunsOnLabels,
909 },
910}
911
912#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
914#[serde(untagged)]
915pub enum RunsOnLabels {
916 One(String),
917 Many(Vec<String>),
918}
919
920impl Default for RunsOnLabels {
921 fn default() -> Self {
922 Self::Many(Vec::new())
923 }
924}
925
926impl RunsOnLabels {
927 fn as_slice(&self) -> &[String] {
928 match self {
929 RunsOnLabels::One(one) => std::slice::from_ref(one),
930 RunsOnLabels::Many(many) => many,
931 }
932 }
933}
934
935impl RunsOn {
936 #[must_use]
938 pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
939 Self::Many(labels.into_iter().map(Into::into).collect())
940 }
941
942 #[must_use]
944 pub fn runner_group(&self) -> Option<&str> {
945 match self {
946 RunsOn::Grouped { group, .. } => group.as_deref(),
947 _ => None,
948 }
949 }
950
951 #[must_use]
955 pub fn plain_labels(&self) -> Vec<Label> {
956 self.raw_labels()
957 .iter()
958 .filter_map(|raw| Label::new(raw).ok())
959 .filter(|label| excluded_label(label).is_none())
960 .collect()
961 }
962
963 fn raw_labels(&self) -> &[String] {
964 match self {
965 RunsOn::Single(one) => std::slice::from_ref(one),
966 RunsOn::Many(many) => many,
967 RunsOn::Grouped { labels, .. } => labels.as_slice(),
968 }
969 }
970
971 pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
994 let raws = self.raw_labels();
995
996 if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
997 return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
998 }
999
1000 let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
1001
1002 if usable.is_empty() {
1003 return match self.runner_group() {
1004 Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
1005 group: group.to_string(),
1006 }),
1007 None => Err(UnresolvableRunsOn::NoLabels),
1008 };
1009 }
1010
1011 let labels: Vec<Label> = usable
1012 .into_iter()
1013 .map(|raw| {
1014 Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
1015 raw: raw.clone(),
1016 source,
1017 })
1018 })
1019 .collect::<Result<_, _>>()?;
1020
1021 for label in &labels {
1022 match excluded_label(label) {
1023 Some(Exclusion::Invalid) => {
1024 return Err(UnresolvableRunsOn::InvalidExclusion {
1025 label: label.clone(),
1026 });
1027 }
1028 Some(Exclusion::Of(excluded)) if labels.contains(&excluded) => {
1029 return Err(UnresolvableRunsOn::ConflictingExclusion { label: excluded });
1030 }
1031 Some(Exclusion::Of(_)) | None => {}
1032 }
1033 }
1034 Ok(labels)
1035 }
1036}
1037
1038fn is_expression(raw: &str) -> bool {
1039 raw.contains("${{")
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053#[serde(try_from = "AutoscaleConfigRepr")]
1054pub struct AutoscaleConfig {
1055 routing_labels: RoutingLabels,
1056 min_capacity: u16,
1057 max_capacity: NonZeroU16,
1058}
1059
1060#[derive(Debug, Deserialize)]
1061struct AutoscaleConfigRepr {
1062 routing_labels: RoutingLabels,
1063 min_capacity: u16,
1064 max_capacity: NonZeroU16,
1065}
1066
1067impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
1068 type Error = PolicyError;
1069
1070 fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
1071 Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
1072 }
1073}
1074
1075impl AutoscaleConfig {
1076 pub fn new(
1082 routing_labels: RoutingLabels,
1083 min_capacity: u16,
1084 max_capacity: NonZeroU16,
1085 ) -> Result<Self, PolicyError> {
1086 if min_capacity > max_capacity.get() {
1087 return Err(PolicyError::InvertedCapacityRange {
1088 min: min_capacity,
1089 max: max_capacity.get(),
1090 });
1091 }
1092 Ok(Self {
1093 routing_labels,
1094 min_capacity,
1095 max_capacity,
1096 })
1097 }
1098
1099 pub fn v1(
1105 routing_labels: RoutingLabels,
1106 max_capacity: NonZeroU16,
1107 ) -> Result<Self, PolicyError> {
1108 Self::new(routing_labels, 0, max_capacity)
1109 }
1110
1111 #[must_use]
1112 pub fn routing_labels(&self) -> &RoutingLabels {
1113 &self.routing_labels
1114 }
1115
1116 #[must_use]
1117 pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
1118 &mut self.routing_labels
1119 }
1120
1121 #[must_use]
1122 pub const fn min_capacity(&self) -> u16 {
1123 self.min_capacity
1124 }
1125
1126 #[must_use]
1127 pub const fn max_capacity(&self) -> NonZeroU16 {
1128 self.max_capacity
1129 }
1130
1131 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1135 if self.min_capacity > max_capacity.get() {
1136 return Err(PolicyError::InvertedCapacityRange {
1137 min: self.min_capacity,
1138 max: max_capacity.get(),
1139 });
1140 }
1141 self.max_capacity = max_capacity;
1142 Ok(())
1143 }
1144}
1145
1146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1166#[serde(tag = "mode", rename_all = "snake_case")]
1167pub enum PolicyMode {
1168 MonitorOnly,
1171 Autoscale(AutoscaleConfig),
1173}
1174
1175impl PolicyMode {
1176 #[must_use]
1177 pub const fn monitor_only() -> Self {
1178 Self::MonitorOnly
1179 }
1180
1181 pub fn autoscale(
1184 routing_labels: RoutingLabels,
1185 min_capacity: u16,
1186 max_capacity: NonZeroU16,
1187 ) -> Result<Self, PolicyError> {
1188 Ok(Self::Autoscale(AutoscaleConfig::new(
1189 routing_labels,
1190 min_capacity,
1191 max_capacity,
1192 )?))
1193 }
1194
1195 pub fn from_persisted(
1205 routing_labels: Option<RoutingLabels>,
1206 min_capacity: u16,
1207 max_capacity: Option<NonZeroU16>,
1208 ) -> Result<Self, PolicyError> {
1209 match (routing_labels, max_capacity) {
1210 (None, None) => {
1211 if min_capacity != 0 {
1212 return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
1217 }
1218 Ok(Self::MonitorOnly)
1219 }
1220 (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
1221 (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
1222 (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
1223 }
1224 }
1225
1226 #[must_use]
1229 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1230 match self {
1231 PolicyMode::MonitorOnly => None,
1232 PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
1233 }
1234 }
1235
1236 #[must_use]
1237 pub const fn min_capacity(&self) -> u16 {
1238 match self {
1239 PolicyMode::MonitorOnly => 0,
1240 PolicyMode::Autoscale(cfg) => cfg.min_capacity,
1241 }
1242 }
1243
1244 #[must_use]
1245 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1246 match self {
1247 PolicyMode::MonitorOnly => None,
1248 PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
1249 }
1250 }
1251
1252 #[must_use]
1253 pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
1254 match self {
1255 PolicyMode::MonitorOnly => None,
1256 PolicyMode::Autoscale(cfg) => Some(cfg),
1257 }
1258 }
1259
1260 #[must_use]
1261 pub const fn is_autoscale(&self) -> bool {
1262 matches!(self, PolicyMode::Autoscale(_))
1263 }
1264
1265 #[must_use]
1266 pub const fn is_monitor_only(&self) -> bool {
1267 matches!(self, PolicyMode::MonitorOnly)
1268 }
1269}
1270
1271#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1298#[serde(rename_all = "snake_case")]
1299pub enum PolicyState {
1300 Pending,
1301 Active,
1302 Draining,
1303 Disabled,
1304 RepairRequired,
1305 AuthenticationFailed,
1306}
1307
1308impl PolicyState {
1309 pub const ALL: [PolicyState; 6] = [
1310 PolicyState::Pending,
1311 PolicyState::Active,
1312 PolicyState::Draining,
1313 PolicyState::Disabled,
1314 PolicyState::RepairRequired,
1315 PolicyState::AuthenticationFailed,
1316 ];
1317
1318 pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
1321 (PolicyState::Pending, PolicyState::Active),
1322 (PolicyState::Pending, PolicyState::RepairRequired),
1323 (PolicyState::Active, PolicyState::Draining),
1324 (PolicyState::Draining, PolicyState::Disabled),
1325 (PolicyState::Disabled, PolicyState::Pending),
1326 (PolicyState::Pending, PolicyState::AuthenticationFailed),
1328 (PolicyState::Active, PolicyState::AuthenticationFailed),
1329 (PolicyState::Draining, PolicyState::AuthenticationFailed),
1330 (PolicyState::Disabled, PolicyState::AuthenticationFailed),
1331 (
1332 PolicyState::RepairRequired,
1333 PolicyState::AuthenticationFailed,
1334 ),
1335 (PolicyState::AuthenticationFailed, PolicyState::Pending),
1337 ];
1338
1339 #[must_use]
1340 pub fn can_transition_to(self, next: PolicyState) -> bool {
1341 Self::LEGAL.contains(&(self, next))
1342 }
1343
1344 #[must_use]
1346 pub const fn admits_new_runners(self) -> bool {
1347 matches!(self, PolicyState::Active)
1348 }
1349}
1350
1351impl fmt::Display for PolicyState {
1352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1353 f.write_str(match self {
1354 PolicyState::Pending => "pending",
1355 PolicyState::Active => "active",
1356 PolicyState::Draining => "draining",
1357 PolicyState::Disabled => "disabled",
1358 PolicyState::RepairRequired => "repair_required",
1359 PolicyState::AuthenticationFailed => "authentication_failed",
1360 })
1361 }
1362}
1363
1364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1375pub struct ScalePolicy {
1376 pub id: PolicyId,
1377 pub target: ScaleTarget,
1378 pub installation_id: u64,
1379 pub host_id: HostId,
1380 pub requested_host_label: HostLabel,
1382 profile_name: ProfileName,
1384 mode: PolicyMode,
1385 enabled: bool,
1386 state: PolicyState,
1387 pub cache_policy: CachePolicy,
1388 workspace_policy: WorkspacePolicy,
1396 execution_policy: ExecutionPolicy,
1397 revision: u64,
1398}
1399
1400#[derive(Debug, Clone, PartialEq, Eq)]
1426pub struct PersistedPolicy {
1427 pub id: PolicyId,
1428 pub target: ScaleTarget,
1429 pub installation_id: u64,
1431 pub host_id: HostId,
1432 pub requested_host_label: HostLabel,
1433 pub profile_name: ProfileName,
1434 pub routing_labels: Option<RoutingLabels>,
1436 pub min_capacity: u16,
1437 pub max_capacity: Option<NonZeroU16>,
1438 pub enabled: bool,
1440 pub state: PolicyState,
1441 pub cache_policy: CachePolicy,
1442 pub workspace_kind: WorkspaceKind,
1447 pub workspace_root: Option<LocalAbsolutePath>,
1450 pub execution_policy: ExecutionPolicy,
1451 pub revision: u64,
1453}
1454
1455#[derive(Debug, Clone, PartialEq, Eq)]
1457pub struct NamedProfileSpec {
1458 pub requested_host_label: HostLabel,
1459 pub os: Os,
1460 pub arch: Arch,
1461 pub profile_name: ProfileName,
1462 pub min_capacity: u16,
1463 pub max_capacity: NonZeroU16,
1464}
1465
1466impl ScalePolicy {
1467 #[must_use]
1469 pub const fn profile_name(&self) -> &ProfileName {
1470 &self.profile_name
1471 }
1472
1473 #[must_use]
1486 pub fn new(
1487 id: PolicyId,
1488 target: ScaleTarget,
1489 installation_id: u64,
1490 host_id: HostId,
1491 mode: PolicyMode,
1492 cache_policy: CachePolicy,
1493 ) -> Self {
1494 Self::new_for_host_label(
1495 id,
1496 target,
1497 installation_id,
1498 host_id,
1499 HostLabel::new("host").expect("the compatibility host label is valid"),
1500 mode,
1501 cache_policy,
1502 )
1503 }
1504
1505 #[must_use]
1507 pub fn new_for_host_label(
1508 id: PolicyId,
1509 target: ScaleTarget,
1510 installation_id: u64,
1511 host_id: HostId,
1512 requested_host_label: HostLabel,
1513 mode: PolicyMode,
1514 cache_policy: CachePolicy,
1515 ) -> Self {
1516 Self {
1517 id,
1518 target,
1519 installation_id,
1520 host_id,
1521 requested_host_label,
1522 profile_name: ProfileName::default_profile(),
1523 mode,
1524 enabled: false,
1525 state: PolicyState::Pending,
1526 cache_policy,
1527 workspace_policy: WorkspacePolicy::Ephemeral,
1531 execution_policy: ExecutionPolicy::Native,
1532 revision: 0,
1533 }
1534 }
1535
1536 pub fn new_named(
1539 id: PolicyId,
1540 target: ScaleTarget,
1541 installation_id: u64,
1542 host_id: HostId,
1543 spec: NamedProfileSpec,
1544 cache_policy: CachePolicy,
1545 ) -> Result<Self, PolicyError> {
1546 let NamedProfileSpec {
1547 requested_host_label,
1548 os,
1549 arch,
1550 profile_name,
1551 min_capacity,
1552 max_capacity,
1553 } = spec;
1554 if profile_name.is_default() {
1555 return Err(PolicyError::NamedConstructorForDefault);
1556 }
1557 let labels =
1558 RoutingLabels::derive_for_profile(&requested_host_label, os, arch, &profile_name);
1559 let mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1560 let mut policy = Self::new_for_host_label(
1561 id,
1562 target,
1563 installation_id,
1564 host_id,
1565 requested_host_label,
1566 mode,
1567 cache_policy,
1568 );
1569 policy.profile_name = profile_name;
1570 Ok(policy)
1571 }
1572
1573 pub fn new_named_monitor(
1576 id: PolicyId,
1577 target: ScaleTarget,
1578 installation_id: u64,
1579 host_id: HostId,
1580 requested_host_label: HostLabel,
1581 profile_name: ProfileName,
1582 cache_policy: CachePolicy,
1583 ) -> Result<Self, PolicyError> {
1584 if profile_name.is_default() {
1585 return Err(PolicyError::NamedConstructorForDefault);
1586 }
1587 let mut policy = Self::new_for_host_label(
1588 id,
1589 target,
1590 installation_id,
1591 host_id,
1592 requested_host_label,
1593 PolicyMode::monitor_only(),
1594 cache_policy,
1595 );
1596 policy.profile_name = profile_name;
1597 Ok(policy)
1598 }
1599
1600 pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1608 let PersistedPolicy {
1609 id,
1610 target,
1611 installation_id,
1612 host_id,
1613 requested_host_label,
1614 profile_name,
1615 routing_labels,
1616 min_capacity,
1617 max_capacity,
1618 enabled,
1619 state,
1620 cache_policy,
1621 workspace_kind,
1622 workspace_root,
1623 execution_policy,
1624 revision,
1625 } = fields;
1626
1627 let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1628 if !profile_name.is_default()
1629 && let Some(labels) = mode.routing_labels()
1630 {
1631 let suffix = format!("-{}", profile_name.as_str());
1634 if !labels.host_label().as_str().ends_with(&suffix) {
1635 return Err(PolicyError::InvalidProfileSelector);
1636 }
1637 }
1638 let workspace_policy =
1642 WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1643 execution_policy.validate(target.scope(), &workspace_policy)?;
1644 Ok(Self {
1645 id,
1646 target,
1647 installation_id,
1648 host_id,
1649 requested_host_label,
1650 profile_name,
1651 mode,
1652 enabled,
1653 state,
1654 cache_policy,
1655 workspace_policy,
1656 execution_policy,
1657 revision,
1658 })
1659 }
1660
1661 #[must_use]
1667 pub fn to_persisted(&self) -> PersistedPolicy {
1668 PersistedPolicy {
1669 id: self.id,
1670 target: self.target.clone(),
1671 installation_id: self.installation_id,
1672 host_id: self.host_id,
1673 requested_host_label: self.requested_host_label.clone(),
1674 profile_name: self.profile_name.clone(),
1675 routing_labels: self.routing_labels().cloned(),
1676 min_capacity: self.min_capacity(),
1677 max_capacity: self.max_capacity(),
1678 enabled: self.enabled,
1679 state: self.state,
1680 cache_policy: self.cache_policy,
1681 workspace_kind: self.workspace_policy.kind(),
1682 workspace_root: self.workspace_policy.root().cloned(),
1683 execution_policy: self.execution_policy.clone(),
1684 revision: self.revision,
1685 }
1686 }
1687
1688 #[must_use]
1689 pub const fn mode(&self) -> &PolicyMode {
1690 &self.mode
1691 }
1692
1693 #[must_use]
1694 pub const fn state(&self) -> PolicyState {
1695 self.state
1696 }
1697
1698 #[must_use]
1702 pub const fn enabled(&self) -> bool {
1703 self.enabled
1704 }
1705
1706 #[must_use]
1709 pub const fn revision(&self) -> u64 {
1710 self.revision
1711 }
1712
1713 #[must_use]
1714 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1715 self.mode.routing_labels()
1716 }
1717
1718 #[must_use]
1723 pub fn with_runtime_labels(
1724 &self,
1725 labels: impl FnOnce(&RoutingLabels) -> RoutingLabels,
1726 ) -> Self {
1727 let mut copy = self.clone();
1728 if let PolicyMode::Autoscale(cfg) = &mut copy.mode {
1729 let changed = labels(cfg.routing_labels());
1730 *cfg.routing_labels_mut() = changed;
1731 }
1732 copy
1733 }
1734
1735 pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
1737 self.routing_labels()
1738 .into_iter()
1739 .flat_map(RoutingLabels::pool_labels)
1740 }
1741
1742 #[must_use]
1744 pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1745 &self.workspace_policy
1746 }
1747
1748 #[must_use]
1749 pub const fn execution_policy(&self) -> &ExecutionPolicy {
1750 &self.execution_policy
1751 }
1752
1753 pub fn set_execution_policy(&mut self, execution: ExecutionPolicy) -> Result<(), PolicyError> {
1755 execution.validate(self.target.scope(), &self.workspace_policy)?;
1756 if self.execution_policy != execution {
1757 self.execution_policy = execution;
1758 self.revision = self.revision.saturating_add(1);
1759 }
1760 Ok(())
1761 }
1762
1763 pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1781 workspace.permitted_for(self.target.scope())?;
1786 self.execution_policy
1787 .validate(self.target.scope(), &workspace)?;
1788 if self.workspace_policy != workspace {
1789 self.workspace_policy = workspace;
1790 self.revision = self.revision.saturating_add(1);
1791 }
1792 Ok(())
1793 }
1794
1795 #[must_use]
1796 pub const fn min_capacity(&self) -> u16 {
1797 self.mode.min_capacity()
1798 }
1799
1800 #[must_use]
1801 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1802 self.mode.max_capacity()
1803 }
1804
1805 #[must_use]
1808 pub fn is_owned_by(&self, host_id: HostId) -> bool {
1809 self.host_id == host_id
1810 }
1811
1812 #[must_use]
1819 pub const fn owns_runners(&self) -> bool {
1820 self.mode.is_autoscale()
1821 }
1822
1823 #[must_use]
1829 pub const fn may_start_runners(&self) -> bool {
1830 self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1831 }
1832
1833 pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1837 if !self.state.can_transition_to(next) {
1838 return Err(PolicyError::IllegalTransition {
1839 from: self.state,
1840 to: next,
1841 });
1842 }
1843 self.state = next;
1844 self.revision = self.revision.saturating_add(1);
1845 Ok(())
1846 }
1847
1848 #[must_use]
1854 pub fn can_activate(&self) -> bool {
1855 self.state.can_transition_to(PolicyState::Active)
1856 }
1857
1858 #[must_use]
1860 pub fn can_request_disable(&self) -> bool {
1861 self.state.can_transition_to(PolicyState::Draining)
1862 }
1863
1864 pub fn activate(&mut self) -> Result<(), PolicyError> {
1885 self.transition_to(PolicyState::Active)?;
1886 self.enabled = true;
1887 Ok(())
1888 }
1889
1890 pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1909 self.transition_to(PolicyState::Draining)?;
1910 self.enabled = false;
1911 Ok(self.state)
1912 }
1913
1914 pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1924 if self.state != PolicyState::Draining {
1925 return Err(PolicyError::IllegalTransition {
1926 from: self.state,
1927 to: PolicyState::Disabled,
1928 });
1929 }
1930 if active_attempts == 0 {
1931 self.transition_to(PolicyState::Disabled)?;
1932 }
1933 Ok(self.state)
1934 }
1935
1936 pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1942 self.transition_to(PolicyState::AuthenticationFailed)
1943 }
1944
1945 pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1951 self.transition_to(PolicyState::Pending)
1952 }
1953
1954 pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1959 self.transition_to(PolicyState::RepairRequired)
1960 }
1961
1962 pub fn promote_to_autoscale(
1971 &mut self,
1972 routing_labels: RoutingLabels,
1973 min_capacity: u16,
1974 max_capacity: NonZeroU16,
1975 ) -> Result<(), PolicyError> {
1976 if !self.profile_name.is_default() {
1977 return Err(PolicyError::NamedProfileNeedsDerivedSelector);
1978 }
1979 if self.mode.is_autoscale() {
1980 return Err(PolicyError::AlreadyAutoscale);
1981 }
1982 self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1983 self.revision = self.revision.saturating_add(1);
1984 Ok(())
1985 }
1986
1987 pub fn promote_named_to_autoscale(
1990 &mut self,
1991 os: Os,
1992 arch: Arch,
1993 min_capacity: u16,
1994 max_capacity: NonZeroU16,
1995 ) -> Result<(), PolicyError> {
1996 if self.profile_name.is_default() {
1997 return Err(PolicyError::NamedConstructorForDefault);
1998 }
1999 if self.mode.is_autoscale() {
2000 return Err(PolicyError::AlreadyAutoscale);
2001 }
2002 let labels = RoutingLabels::derive_for_profile(
2003 &self.requested_host_label,
2004 os,
2005 arch,
2006 &self.profile_name,
2007 );
2008 self.mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
2009 self.revision = self.revision.saturating_add(1);
2010 Ok(())
2011 }
2012
2013 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
2021 match &mut self.mode {
2022 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2023 PolicyMode::Autoscale(cfg) => {
2024 cfg.set_max_capacity(max_capacity)?;
2025 self.revision = self.revision.saturating_add(1);
2026 Ok(())
2027 }
2028 }
2029 }
2030
2031 pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
2044 match &mut self.mode {
2045 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2046 PolicyMode::Autoscale(cfg) => {
2047 check_exclusion_label(cfg.routing_labels(), &label)?;
2048 let added = cfg.routing_labels_mut().add(label);
2049 if added {
2050 self.revision = self.revision.saturating_add(1);
2051 }
2052 Ok(added)
2053 }
2054 }
2055 }
2056
2057 pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
2064 match &mut self.mode {
2065 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2066 PolicyMode::Autoscale(cfg) => {
2067 let removed = cfg.routing_labels_mut().remove(label)?;
2068 if removed {
2069 self.revision = self.revision.saturating_add(1);
2070 }
2071 Ok(removed)
2072 }
2073 }
2074 }
2075
2076 #[must_use]
2082 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
2083 match self.routing_labels() {
2084 Some(labels) => labels.tally(jobs),
2085 None => DemandTally::default(),
2086 }
2087 }
2088}
2089
2090#[cfg(test)]
2091mod tests {
2092 use super::*;
2093 use crate::model::{HostId, PolicyId, TargetScope};
2094
2095 fn nz(v: u16) -> NonZeroU16 {
2096 NonZeroU16::new(v).expect("test capacity is non-zero")
2097 }
2098
2099 fn label(s: &str) -> Label {
2100 Label::new(s).expect("test label is valid")
2101 }
2102
2103 fn host_labels(host: &str) -> RoutingLabels {
2104 RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
2105 }
2106
2107 fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
2108 ScalePolicy::new(
2109 PolicyId::from_u128(1),
2110 target,
2111 42,
2112 host,
2113 PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
2114 CachePolicy::default(),
2115 )
2116 }
2117
2118 #[test]
2119 fn a_tally_counts_the_matched_pool_jobs_apart() {
2120 let labels = host_labels("home").with_labels([label("rm-pool-windows")]);
2121 let jobs = [
2122 RunsOn::from_job_labels(["rm-home-win-x64", "rm-pool-windows"]),
2123 RunsOn::from_job_labels(["rm-home-win-x64", "RM-POOL-windows"]),
2124 RunsOn::from_job_labels(["rm-home-win-x64"]),
2125 RunsOn::from_job_labels(["rm-home-win-x64", "rm-pool-linux"]),
2126 ];
2127 let tally = labels.tally(&jobs);
2128 assert_eq!(tally.matched, 3);
2129 assert_eq!(tally.pool_matched, 2);
2130 assert_eq!(tally.non_pool_demand(), 1);
2131 let required = |job: &RunsOn| job.required_labels().expect("a static runs-on");
2132 assert!(
2133 is_pool_job(&required(&jobs[3])),
2134 "a pool job this profile does not match"
2135 );
2136 assert!(!is_pool_job(&required(&jobs[2])));
2137 }
2138
2139 #[test]
2140 fn each_os_has_one_pool_label_and_the_namespace_is_reserved() {
2141 assert_eq!(pool_label(Os::Windows).as_str(), "rm-pool-windows");
2142 assert_eq!(pool_label(Os::Linux).as_str(), "rm-pool-linux");
2143 assert_eq!(pool_label(Os::MacOs).as_str(), "rm-pool-macos");
2144 assert!(is_pool_label("RM-POOL-linux"));
2145 assert!(!is_pool_label("rm-home-linux-x64"));
2146 assert!(!is_pool_label("rm-pool"));
2147 assert!(!is_pool_label("rm-pool-linux-x64"));
2149 assert!(in_pool_namespace("rm-pool-linux-x64"));
2150 assert!(check_pool_label(&label("rm-pool-linux"), Os::Linux).is_ok());
2151 assert!(check_pool_label(&label("self-hosted"), Os::Linux).is_ok());
2152 assert!(matches!(
2153 check_pool_label(&label("rm-pool-windows"), Os::Linux),
2154 Err(PolicyError::PoolLabelMismatch { expected, .. }) if expected.as_str() == "rm-pool-linux"
2155 ));
2156 assert!(check_pool_label(&label("rm-pool-linux-heavy"), Os::Linux).is_err());
2157
2158 let mut labels = host_labels("home");
2159 assert_eq!(labels.pool_labels().next(), None);
2160 labels.add(label("linux"));
2161 labels.add(label("rm-pool-windows"));
2162 assert_eq!(
2163 labels.pool_labels().collect::<Vec<_>>(),
2164 [&label("rm-pool-windows")]
2165 );
2166 }
2167
2168 #[test]
2169 fn the_any_pool_is_a_pool_every_os_may_carry_beside_its_own() {
2170 assert_eq!(any_pool_label().as_str(), ANY_POOL_LABEL);
2171 assert!(is_pool_label("rm-pool-any") && is_pool_label("RM-Pool-Any"));
2172 assert!(is_any_pool_label("RM-POOL-ANY"));
2173 assert!(!is_any_pool_label("rm-pool-linux"));
2174 assert!(!is_pool_label("rm-pool-any-heavy"));
2175 for os in Os::ALL {
2176 assert!(check_pool_label(&label("rm-pool-any"), os).is_ok());
2177 }
2178 assert!(check_pool_label(&label("rm-pool-any-heavy"), Os::Linux).is_err());
2179
2180 let mut labels = host_labels("home");
2181 labels.add(label("rm-pool-linux"));
2182 labels.add(label("rm-pool-any"));
2183 labels.add(label("heavy"));
2184 assert_eq!(labels.pool_labels().count(), 2);
2185 }
2186
2187 fn workspace_root() -> LocalAbsolutePath {
2192 LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
2193 .expect("a valid persistent root")
2194 }
2195
2196 fn repository_policy() -> ScalePolicy {
2197 autoscale_policy(
2198 ScaleTarget::repository("acme/api").unwrap(),
2199 HostId::from_u128(1),
2200 4,
2201 )
2202 }
2203
2204 fn organization_policy() -> ScalePolicy {
2205 autoscale_policy(
2206 ScaleTarget::organization("acme").unwrap(),
2207 HostId::from_u128(1),
2208 4,
2209 )
2210 }
2211
2212 #[test]
2213 fn every_constructor_produces_an_ephemeral_workspace() {
2214 for policy in [repository_policy(), organization_policy()] {
2217 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
2218 assert!(!policy.workspace_policy().retains_job_workspace());
2219 assert_eq!(
2220 policy.to_persisted().workspace_kind,
2221 WorkspaceKind::Ephemeral
2222 );
2223 assert_eq!(policy.to_persisted().workspace_root, None);
2224 }
2225
2226 let monitor_only = ScalePolicy::new(
2227 PolicyId::from_u128(2),
2228 ScaleTarget::repository("acme/api").unwrap(),
2229 42,
2230 HostId::from_u128(1),
2231 PolicyMode::MonitorOnly,
2232 CachePolicy::default(),
2233 );
2234 assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
2235 }
2236
2237 #[test]
2238 fn a_repository_policy_can_opt_into_a_persistent_workspace() {
2239 let mut policy = repository_policy();
2240 let before = policy.revision();
2241
2242 policy
2243 .set_workspace_policy(
2244 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2245 .expect("a repository may be persistent"),
2246 )
2247 .expect("a repository policy accepts persistence");
2248
2249 assert!(policy.workspace_policy().is_persistent());
2250 assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
2251 assert_eq!(
2252 policy.revision(),
2253 before + 1,
2254 "a workspace change must bump the optimistic token, or `a2`'s guard \
2255 cannot refuse a write built from a stale read"
2256 );
2257
2258 let unchanged = policy.revision();
2261 policy
2262 .set_workspace_policy(policy.workspace_policy().clone())
2263 .expect("re-setting the same policy is accepted");
2264 assert_eq!(policy.revision(), unchanged);
2265 }
2266
2267 #[test]
2268 fn an_organization_policy_cannot_be_made_persistent() {
2269 let mut policy = organization_policy();
2272 let before = policy.revision();
2273
2274 assert_eq!(
2275 policy.set_workspace_policy(WorkspacePolicy::Persistent {
2276 root: workspace_root()
2277 }),
2278 Err(PolicyError::Workspace(
2279 WorkspaceError::PersistentRequiresRepositoryScope
2280 ))
2281 );
2282 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
2283 assert_eq!(
2284 policy.revision(),
2285 before,
2286 "a refused write consumes nothing"
2287 );
2288
2289 assert_eq!(
2292 WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
2293 Err(WorkspaceError::PersistentRequiresRepositoryScope)
2294 );
2295 }
2296
2297 #[test]
2298 fn a_workspace_policy_round_trips_through_the_persisted_struct() {
2299 let mut policy = repository_policy();
2300 policy
2301 .set_workspace_policy(
2302 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2303 .expect("a repository may be persistent"),
2304 )
2305 .expect("a repository policy accepts persistence");
2306
2307 let restored = ScalePolicy::from_persisted(policy.to_persisted())
2308 .expect("a policy this crate wrote must load");
2309 assert_eq!(restored, policy);
2310 assert_eq!(restored.workspace_policy(), policy.workspace_policy());
2311
2312 let ephemeral = repository_policy();
2313 assert_eq!(
2314 ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
2315 ephemeral
2316 );
2317 }
2318
2319 #[test]
2320 fn an_organization_row_claiming_persistence_fails_closed_on_load() {
2321 let mut fields = organization_policy().to_persisted();
2322 fields.workspace_kind = WorkspaceKind::Persistent;
2323 fields.workspace_root = Some(workspace_root());
2324
2325 assert_eq!(
2326 ScalePolicy::from_persisted(fields),
2327 Err(PolicyError::Workspace(
2328 WorkspaceError::PersistentRequiresRepositoryScope
2329 ))
2330 );
2331 }
2332
2333 #[test]
2334 fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
2335 let base = repository_policy().to_persisted();
2336
2337 let mut without_root = base.clone();
2338 without_root.workspace_kind = WorkspaceKind::Persistent;
2339 assert_eq!(
2340 ScalePolicy::from_persisted(without_root),
2341 Err(PolicyError::Workspace(
2342 WorkspaceError::PersistentWithoutRoot
2343 ))
2344 );
2345
2346 let mut stale_root = base;
2347 stale_root.workspace_root = Some(workspace_root());
2348 assert!(matches!(
2349 ScalePolicy::from_persisted(stale_root),
2350 Err(PolicyError::Workspace(
2351 WorkspaceError::EphemeralWithRoot { .. }
2352 ))
2353 ));
2354 }
2355
2356 #[test]
2357 fn workspace_retention_is_not_the_runner_package_cache_policy() {
2358 let mut policy = repository_policy();
2362 policy.cache_policy = CachePolicy::DiscardRunnerPackage;
2363 policy
2364 .set_workspace_policy(
2365 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2366 .expect("a repository may be persistent"),
2367 )
2368 .expect("a repository policy accepts persistence");
2369
2370 assert!(policy.workspace_policy().retains_job_workspace());
2371 assert!(!policy.cache_policy.retains_runner_package());
2372 assert!(!policy.cache_policy.retains_job_workspace());
2375 }
2376
2377 #[test]
2382 fn the_derived_label_has_the_shape_the_architecture_gives() {
2383 let labels =
2385 RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2386 assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
2387 assert_eq!(labels.count().get(), 1);
2388 }
2389
2390 #[test]
2391 fn the_derived_label_is_host_scoped_by_construction() {
2392 let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2396 let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
2397
2398 assert_ne!(
2399 a.host_label(),
2400 b.host_label(),
2401 "two hosts must not derive the same routing label; with no job \
2402 reservation, a shared label means both hosts start a runner for one job"
2403 );
2404 assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
2405 assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
2406
2407 let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
2409 assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
2410 assert_ne!(a.host_label(), mac.host_label());
2411 }
2412
2413 #[test]
2414 fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
2415 let labels =
2419 RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
2420 assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
2421 }
2422
2423 #[test]
2424 fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
2425 let mut labels = host_labels("home");
2426 let derived = labels.host_label().clone();
2427
2428 assert!(labels.add(label("gpu")));
2429 assert!(labels.add(label("self-hosted")));
2430 assert!(
2431 !labels.add(label("GPU")),
2432 "adding a label that differs only in case must be a no-op, not a duplicate"
2433 );
2434 assert_eq!(labels.count().get(), 3);
2435
2436 assert!(labels.remove(&label("gpu")).unwrap());
2437 assert_eq!(labels.count().get(), 2);
2438 assert!(
2439 !labels.remove(&label("never-added")).unwrap(),
2440 "removing an absent optional label is a no-op, not an error"
2441 );
2442
2443 assert!(
2445 matches!(
2446 labels.remove(&derived),
2447 Err(PolicyError::HostLabelNotRemovable { .. })
2448 ),
2449 "the derived host label must not be removable; it is the only thing \
2450 keeping two hosts from serving each other's jobs"
2451 );
2452 assert!(labels.contains(&derived));
2453
2454 assert!(matches!(
2456 labels.remove(&label("RM-HOME-WIN-X64")),
2457 Err(PolicyError::HostLabelNotRemovable { .. })
2458 ));
2459 }
2460
2461 #[test]
2462 fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
2463 let mut labels = host_labels("home");
2464 let derived = labels.host_label().clone();
2465 assert!(!labels.add(derived));
2466 assert_eq!(labels.count().get(), 1);
2467
2468 let rebuilt = RoutingLabels::from_parts(
2470 labels.host_label().clone(),
2471 vec![labels.host_label().clone(), label("gpu")],
2472 );
2473 assert_eq!(rebuilt.count().get(), 2);
2474 assert_eq!(
2475 rebuilt.as_registration_labels(),
2476 vec!["rm-home-win-x64", "gpu"]
2477 );
2478 }
2479
2480 #[test]
2481 fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
2482 let mut labels = host_labels("home");
2487 labels.add(label("gpu"));
2488 assert_eq!(
2489 labels.as_registration_labels(),
2490 vec!["rm-home-win-x64", "gpu"]
2491 );
2492 assert!(!labels.contains(&label("self-hosted")));
2493 }
2494
2495 #[test]
2496 fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
2497 let mut labels = host_labels("home");
2498 labels.add(label("gpu"));
2499 let json = serde_json::to_string(&labels).unwrap();
2500 let back: RoutingLabels = serde_json::from_str(&json).unwrap();
2501 assert_eq!(labels, back);
2502 assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
2503 }
2504
2505 #[test]
2506 fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
2507 let labels = host_labels("home");
2509 let non_empty = labels.to_non_empty();
2510 assert_eq!(non_empty.count().get(), 1);
2511 assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
2512 }
2513
2514 struct Row {
2521 name: &'static str,
2522 runs_on: RunsOn,
2523 expect: Expect,
2524 }
2525
2526 #[derive(Debug, PartialEq, Eq)]
2527 enum Expect {
2528 Match,
2529 NoMatch,
2530 Unresolvable,
2531 }
2532
2533 fn classify(m: &RunsOnMatch) -> Expect {
2534 match m {
2535 RunsOnMatch::Match { .. } => Expect::Match,
2536 RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
2537 RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
2538 }
2539 }
2540
2541 fn table_policy() -> RoutingLabels {
2542 let mut labels = host_labels("home");
2544 labels.add(label("self-hosted"));
2545 labels.add(label("gpu"));
2546 labels
2547 }
2548
2549 fn table() -> Vec<Row> {
2550 vec![
2551 Row {
2553 name: "string: the derived host label",
2554 runs_on: RunsOn::Single("rm-home-win-x64".into()),
2555 expect: Expect::Match,
2556 },
2557 Row {
2558 name: "string: the derived host label in the wrong case",
2559 runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
2560 expect: Expect::Match,
2561 },
2562 Row {
2563 name: "string: an optional label alone",
2564 runs_on: RunsOn::Single("gpu".into()),
2565 expect: Expect::Match,
2566 },
2567 Row {
2568 name: "string: another host's label",
2569 runs_on: RunsOn::Single("rm-office-win-x64".into()),
2570 expect: Expect::NoMatch,
2571 },
2572 Row {
2573 name: "string: a GitHub-hosted runner label",
2574 runs_on: RunsOn::Single("ubuntu-latest".into()),
2575 expect: Expect::NoMatch,
2576 },
2577 Row {
2579 name: "array: a strict subset of the policy's labels",
2580 runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
2581 expect: Expect::Match,
2582 },
2583 Row {
2584 name: "array: the whole set, out of order and mixed case",
2585 runs_on: RunsOn::Many(vec![
2586 "GPU".into(),
2587 "Rm-Home-Win-X64".into(),
2588 "Self-Hosted".into(),
2589 ]),
2590 expect: Expect::Match,
2591 },
2592 Row {
2593 name: "array: one label the policy does not carry",
2594 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
2595 expect: Expect::NoMatch,
2596 },
2597 Row {
2598 name: "array: an empty array names no labels",
2599 runs_on: RunsOn::Many(vec![]),
2600 expect: Expect::Unresolvable,
2601 },
2602 Row {
2604 name: "map: labels only",
2605 runs_on: RunsOn::Grouped {
2606 group: None,
2607 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
2608 },
2609 expect: Expect::Match,
2610 },
2611 Row {
2612 name: "map: a group plus labels the policy carries",
2613 runs_on: RunsOn::Grouped {
2614 group: Some("Default".into()),
2615 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
2616 },
2617 expect: Expect::Match,
2618 },
2619 Row {
2620 name: "map: labels as a scalar",
2621 runs_on: RunsOn::Grouped {
2622 group: Some("Default".into()),
2623 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2624 },
2625 expect: Expect::Match,
2626 },
2627 Row {
2628 name: "map: a group plus a label the policy does not carry",
2629 runs_on: RunsOn::Grouped {
2630 group: Some("Default".into()),
2631 labels: RunsOnLabels::Many(vec!["macos".into()]),
2632 },
2633 expect: Expect::NoMatch,
2634 },
2635 Row {
2636 name: "map: a group with no labels constrains something we cannot read",
2637 runs_on: RunsOn::Grouped {
2638 group: Some("Default".into()),
2639 labels: RunsOnLabels::Many(vec![]),
2640 },
2641 expect: Expect::Unresolvable,
2642 },
2643 Row {
2645 name: "expression: the whole value",
2646 runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
2647 expect: Expect::Unresolvable,
2648 },
2649 Row {
2650 name: "expression: one element of an array",
2651 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
2652 expect: Expect::Unresolvable,
2653 },
2654 Row {
2655 name: "expression: inside the map form",
2656 runs_on: RunsOn::Grouped {
2657 group: None,
2658 labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
2659 },
2660 expect: Expect::Unresolvable,
2661 },
2662 Row {
2663 name: "not a usable label at all",
2664 runs_on: RunsOn::Single("rm-home,win-x64".into()),
2665 expect: Expect::Unresolvable,
2666 },
2667 ]
2668 }
2669
2670 #[test]
2671 fn runs_on_matching_covers_every_documented_form() {
2672 let policy = table_policy();
2673 for row in table() {
2674 let got = policy.matches(&row.runs_on);
2675 assert_eq!(
2676 classify(&got),
2677 row.expect,
2678 "row {:?}: {:?} produced {got:?}",
2679 row.name,
2680 row.runs_on
2681 );
2682 }
2683 }
2684
2685 #[test]
2686 fn github_job_labels_are_a_case_insensitive_subset_without_a_selector() {
2687 let labels = RoutingLabels::from_parts(
2688 label("rm-macmini-osx-arm64"),
2689 [
2690 label("arm64"),
2691 label("macos"),
2692 label("self-hosted"),
2693 label("extra"),
2694 ],
2695 );
2696 assert!(
2697 labels
2698 .matches(&RunsOn::Many(vec![
2699 "self-hosted".into(),
2700 "macOS".into(),
2701 "ARM64".into(),
2702 ]))
2703 .is_match()
2704 );
2705 for missing in ["X64", "Windows", "Linux", "gpu", "rm-other-osx-arm64"] {
2706 let result = labels.matches(&RunsOn::Many(vec![
2707 "self-hosted".into(),
2708 "macOS".into(),
2709 "ARM64".into(),
2710 missing.into(),
2711 ]));
2712 assert_eq!(
2713 result,
2714 RunsOnMatch::NoMatch {
2715 missing: vec![label(missing)]
2716 }
2717 );
2718 }
2719 }
2720
2721 #[test]
2722 fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2723 let without = host_labels("home");
2727 assert!(
2728 !without
2729 .matches(&RunsOn::Single("self-hosted".into()))
2730 .is_match(),
2731 "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2732 );
2733
2734 let mut with = host_labels("home");
2735 with.add(label("self-hosted"));
2736 assert!(
2737 with.matches(&RunsOn::Single("self-hosted".into()))
2738 .is_match()
2739 );
2740 assert!(
2741 with.matches(&RunsOn::Many(vec![
2742 "rm-home-win-x64".into(),
2743 "self-hosted".into()
2744 ]))
2745 .is_match()
2746 );
2747 }
2748
2749 #[test]
2750 fn a_no_match_names_the_labels_that_were_missing() {
2751 let policy = host_labels("home");
2752 let got = policy.matches(&RunsOn::Many(vec![
2753 "rm-home-win-x64".into(),
2754 "self-hosted".into(),
2755 "GPU".into(),
2756 ]));
2757 match got {
2758 RunsOnMatch::NoMatch { missing } => {
2759 assert_eq!(
2760 missing,
2761 vec![label("self-hosted"), label("gpu")],
2762 "the operator needs to know which labels to add"
2763 );
2764 }
2765 other => panic!("expected NoMatch, got {other:?}"),
2766 }
2767 }
2768
2769 #[test]
2770 fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2771 let policy = host_labels("home");
2772 let got = policy.matches(&RunsOn::Grouped {
2773 group: Some("Default".into()),
2774 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2775 });
2776 assert_eq!(
2777 got,
2778 RunsOnMatch::Match {
2779 runner_group: Some("Default".into())
2780 },
2781 "a policy has no runner-group field, so the domain cannot evaluate \
2782 `group:`; returning it lets `c4`, which can, do so without re-parsing"
2783 );
2784 }
2785
2786 #[test]
2787 fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2788 let policy = host_labels("home");
2789
2790 let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2791 assert!(matches!(
2792 expr,
2793 RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2794 ));
2795
2796 let group = policy.matches(&RunsOn::Grouped {
2797 group: Some("g".into()),
2798 labels: RunsOnLabels::Many(vec![]),
2799 });
2800 assert!(matches!(
2801 group,
2802 RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2803 ));
2804
2805 let none = policy.matches(&RunsOn::Many(vec![]));
2806 assert!(matches!(
2807 none,
2808 RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2809 ));
2810
2811 let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2812 assert!(matches!(
2813 invalid,
2814 RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2815 ));
2816 }
2817
2818 #[test]
2819 fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2820 let policy = table_policy();
2824 let jobs = vec![
2825 RunsOn::Single("rm-home-win-x64".into()), RunsOn::Single("ubuntu-latest".into()), RunsOn::Single("${{ matrix.runner }}".into()), RunsOn::Single("${{ inputs.pool }}".into()), ];
2830 let tally = policy.tally(&jobs);
2831
2832 assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2833 assert_eq!(tally.not_matched, 1);
2834 assert_eq!(
2835 tally.unresolvable.len(),
2836 2,
2837 "and it must not vanish either -- `g2` shows these to the operator"
2838 );
2839 assert_eq!(
2840 tally.total_seen(),
2841 jobs.len() as u32,
2842 "every job seen is accounted for in exactly one bucket"
2843 );
2844 }
2845
2846 #[test]
2847 fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2848 let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2849 assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2850
2851 let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2852 assert_eq!(
2853 many,
2854 RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2855 );
2856
2857 let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2858 assert_eq!(
2859 grouped,
2860 RunsOn::Grouped {
2861 group: Some("g".into()),
2862 labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2863 }
2864 );
2865
2866 let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2867 assert_eq!(
2868 scalar_labels,
2869 RunsOn::Grouped {
2870 group: None,
2871 labels: RunsOnLabels::One("a".into()),
2872 }
2873 );
2874
2875 let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2876 assert_eq!(
2877 group_only,
2878 RunsOn::Grouped {
2879 group: Some("g".into()),
2880 labels: RunsOnLabels::Many(vec![]),
2881 }
2882 );
2883
2884 assert_eq!(
2886 RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2887 RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2888 );
2889 }
2890
2891 #[test]
2896 fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2897 let labels = host_labels("home");
2898
2899 assert!(matches!(
2902 PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2903 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2904 ));
2905 assert!(matches!(
2906 PolicyMode::from_persisted(None, 0, Some(nz(1))),
2907 Err(PolicyError::AutoscaleWithoutRoutingLabels)
2908 ));
2909
2910 assert!(
2912 PolicyMode::from_persisted(None, 0, None)
2913 .unwrap()
2914 .is_monitor_only()
2915 );
2916 assert!(
2917 PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2918 .unwrap()
2919 .is_autoscale()
2920 );
2921 }
2922
2923 #[test]
2924 fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2925 let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2932 assert!(autoscale.routing_labels().is_some());
2933 assert!(autoscale.max_capacity().is_some());
2934
2935 let monitor = PolicyMode::monitor_only();
2936 assert!(monitor.routing_labels().is_none());
2937 assert!(monitor.max_capacity().is_none());
2938 assert_eq!(monitor.min_capacity(), 0);
2939 }
2940
2941 #[test]
2942 fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2943 let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2946 assert!(matches!(
2947 err,
2948 PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2949 ));
2950 assert!(
2951 err.to_string().contains("MonitorOnly"),
2952 "the message must name the shape rule, got: {err}"
2953 );
2954 }
2955
2956 #[test]
2957 fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2958 assert!(matches!(
2964 PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2965 Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2966 ));
2967 assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2968 assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2969
2970 let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2972 assert!(matches!(
2973 cfg.set_max_capacity(nz(1)),
2974 Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2975 ));
2976 assert_eq!(
2977 cfg.max_capacity().get(),
2978 4,
2979 "a refused write changes nothing"
2980 );
2981 }
2982
2983 #[test]
2984 fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2985 for mode in [
2986 PolicyMode::monitor_only(),
2987 PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2988 ] {
2989 let json = serde_json::to_string(&mode).unwrap();
2990 let back: PolicyMode = serde_json::from_str(&json).unwrap();
2991 assert_eq!(mode, back, "{json} did not round-trip");
2992 }
2993
2994 let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2997 let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2998 assert!(
2999 err.to_string().contains("min_capacity"),
3000 "expected the shape error to survive into serde's message, got: {err}"
3001 );
3002 }
3003
3004 #[test]
3005 fn a_policy_round_trips_through_its_persisted_form() {
3006 let mut policy = autoscale_policy(
3014 ScaleTarget::repository("o/r").unwrap(),
3015 HostId::from_u128(7),
3016 3,
3017 );
3018 policy.add_routing_label(label("gpu")).unwrap();
3019 policy.activate().unwrap();
3025 assert_eq!(policy.state(), PolicyState::Active);
3026 assert!(policy.enabled());
3027
3028 let stored = policy.to_persisted();
3029 assert_ne!(
3030 stored.installation_id, stored.revision,
3031 "the fixture must distinguish the two u64 columns, or transposing \
3032 them is unobservable and this test proves nothing"
3033 );
3034 assert_eq!(stored.installation_id, 42);
3035 assert_eq!(stored.revision, policy.revision());
3036
3037 let restored =
3038 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
3039 assert_eq!(restored, policy);
3040 assert_eq!(restored.installation_id, 42);
3041 assert_eq!(restored.revision(), policy.revision());
3042 assert_eq!(restored.state(), PolicyState::Active);
3043 assert!(restored.enabled());
3044 assert_eq!(
3045 restored.routing_labels().unwrap().count().get(),
3046 2,
3047 "the optional label survives alongside the host label"
3048 );
3049 }
3050
3051 #[test]
3052 fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
3053 let mut policy = ScalePolicy::new(
3061 PolicyId::from_u128(2),
3062 ScaleTarget::organization("acme").unwrap(),
3063 9,
3064 HostId::from_u128(7),
3065 PolicyMode::monitor_only(),
3066 CachePolicy::default(),
3067 );
3068 policy.activate().unwrap();
3069
3070 let stored = policy.to_persisted();
3071 assert!(stored.routing_labels.is_none());
3072 assert_eq!(stored.min_capacity, 0);
3073 assert!(stored.max_capacity.is_none());
3074 assert_ne!(stored.installation_id, stored.revision);
3075
3076 let restored =
3077 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
3078 assert_eq!(restored, policy);
3079 assert!(
3080 restored.mode().is_monitor_only(),
3081 "the mode is inferred back from the three columns, not stored"
3082 );
3083 assert!(!restored.owns_runners());
3084 assert_eq!(restored.installation_id, 9);
3085 assert_eq!(restored.revision(), 1);
3086 }
3087
3088 fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
3108 use PolicyState::*;
3109 let mut edges = vec![
3110 (Pending, Active),
3111 (Pending, RepairRequired),
3112 (Active, Draining),
3113 (Draining, Disabled),
3114 (Disabled, Pending),
3115 (AuthenticationFailed, Pending),
3117 ];
3118 for from in PolicyState::ALL {
3120 if from != AuthenticationFailed {
3121 edges.push((from, AuthenticationFailed));
3122 }
3123 }
3124 edges
3125 }
3126
3127 #[test]
3128 fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
3129 let expected = diagram_edges();
3132 assert_eq!(
3133 expected.len(),
3134 11,
3135 "the transcription itself changed; check it against the diagram"
3136 );
3137
3138 let mut legal_seen = 0usize;
3139 let mut illegal_seen = 0usize;
3140
3141 for from in PolicyState::ALL {
3142 for to in PolicyState::ALL {
3143 let expected_legal = expected.contains(&(from, to));
3144 let mut policy = autoscale_policy(
3145 ScaleTarget::repository("o/r").unwrap(),
3146 HostId::from_u128(7),
3147 1,
3148 );
3149 policy.state = from;
3153
3154 let result = policy.transition_to(to);
3155 if expected_legal {
3156 legal_seen += 1;
3157 assert!(
3158 result.is_ok(),
3159 "{from} -> {to} is in the diagram and must be accepted"
3160 );
3161 assert_eq!(policy.state(), to);
3162 } else {
3163 illegal_seen += 1;
3164 assert!(
3165 matches!(result, Err(PolicyError::IllegalTransition { .. })),
3166 "{from} -> {to} is not in the diagram and must be rejected"
3167 );
3168 assert_eq!(policy.state(), from, "a refused transition changes nothing");
3169 }
3170 }
3171 }
3172
3173 assert_eq!(legal_seen, 11);
3174 assert_eq!(illegal_seen, 36 - 11);
3175
3176 let mut published = PolicyState::LEGAL.to_vec();
3179 let mut transcribed = expected;
3180 published.sort_unstable();
3181 transcribed.sort_unstable();
3182 assert_eq!(published, transcribed);
3183 }
3184
3185 #[test]
3186 fn a_policy_state_cannot_transition_to_itself() {
3187 for state in PolicyState::ALL {
3188 assert!(
3189 !state.can_transition_to(state),
3190 "{state} -> {state} is not an edge in the diagram; treating it as \
3191 one would let a repeated authentication failure look like progress"
3192 );
3193 }
3194 }
3195
3196 #[test]
3197 fn the_documented_happy_path_walks_pending_to_disabled() {
3198 let mut policy = autoscale_policy(
3199 ScaleTarget::repository("o/r").unwrap(),
3200 HostId::from_u128(7),
3201 2,
3202 );
3203
3204 assert_eq!(policy.state(), PolicyState::Pending);
3206 assert!(!policy.enabled());
3207 assert!(!policy.may_start_runners());
3208
3209 policy.activate().unwrap();
3210 assert_eq!(policy.state(), PolicyState::Active);
3211 assert!(policy.enabled());
3212 assert!(policy.may_start_runners());
3213
3214 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
3216 assert_eq!(
3217 policy.drain_completed(1).unwrap(),
3218 PolicyState::Draining,
3219 "a policy with a runner still in flight stays draining"
3220 );
3221 assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
3222 }
3223
3224 #[test]
3225 fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
3226 let mut policy = autoscale_policy(
3229 ScaleTarget::repository("o/r").unwrap(),
3230 HostId::from_u128(7),
3231 5,
3232 );
3233 policy.activate().unwrap();
3234
3235 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
3238 assert_eq!(policy.tally(&jobs).demand(), 4);
3239
3240 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
3241 assert!(!policy.enabled());
3242 assert!(
3243 !policy.may_start_runners(),
3244 "a draining policy must not be the reason a new runner starts, even \
3245 with four jobs queued for its labels"
3246 );
3247 assert_eq!(
3248 policy.tally(&jobs).demand(),
3249 4,
3250 "queued demand stays visible while draining (flow 5.2)"
3251 );
3252 }
3253
3254 #[test]
3255 fn re_authentication_is_the_only_way_out_of_authentication_failed() {
3256 for from in [
3257 PolicyState::Pending,
3258 PolicyState::Active,
3259 PolicyState::Draining,
3260 PolicyState::Disabled,
3261 PolicyState::RepairRequired,
3262 ] {
3263 let mut policy = autoscale_policy(
3264 ScaleTarget::repository("o/r").unwrap(),
3265 HostId::from_u128(7),
3266 1,
3267 );
3268 policy.state = from;
3269 policy.authentication_failed().unwrap();
3270 assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
3271
3272 assert!(matches!(
3274 policy.authentication_failed(),
3275 Err(PolicyError::IllegalTransition { .. })
3276 ));
3277
3278 policy.reauthenticated().unwrap();
3279 assert_eq!(policy.state(), PolicyState::Pending);
3280 }
3281 }
3282
3283 #[test]
3284 fn mutant_disabling_revoked_eligibility_gate_is_detected() {
3285 let mut policy = autoscale_policy(
3286 ScaleTarget::repository("o/r").unwrap(),
3287 HostId::from_u128(7),
3288 1,
3289 );
3290 policy.activate().unwrap();
3291 policy.authentication_failed().unwrap();
3292 assert!(!policy.may_start_runners());
3293
3294 let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
3298 assert!(
3299 mutant_may_start,
3300 "omitting revoked state must make the eligibility gate red"
3301 );
3302 }
3303
3304 #[test]
3305 fn a_refused_transition_leaves_the_revision_untouched() {
3306 let mut policy = autoscale_policy(
3307 ScaleTarget::repository("o/r").unwrap(),
3308 HostId::from_u128(7),
3309 1,
3310 );
3311 assert_eq!(policy.revision(), 0);
3312 policy.activate().unwrap();
3313 assert_eq!(policy.revision(), 1);
3314
3315 assert!(policy.activate().is_err());
3316 assert_eq!(
3317 policy.revision(),
3318 1,
3319 "a rejected write must not bump the optimistic-concurrency token, or \
3320 `b2`'s stale-revision check would reject the next honest write"
3321 );
3322 }
3323
3324 #[test]
3329 fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
3330 let mut policy = ScalePolicy::new(
3331 PolicyId::from_u128(2),
3332 ScaleTarget::organization("acme").unwrap(),
3333 9,
3334 HostId::from_u128(7),
3335 PolicyMode::monitor_only(),
3336 CachePolicy::default(),
3337 );
3338 policy.activate().unwrap();
3339
3340 assert!(!policy.owns_runners());
3341 assert!(
3342 !policy.may_start_runners(),
3343 "an active, enabled monitor-only policy still starts nothing (D19)"
3344 );
3345 assert!(policy.routing_labels().is_none());
3346 assert!(policy.max_capacity().is_none());
3347
3348 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
3350 assert_eq!(
3351 policy.tally(&jobs).demand(),
3352 0,
3353 "a monitor-only policy has no demand at all, rather than demand that \
3354 is computed and then ignored"
3355 );
3356
3357 assert!(matches!(
3360 policy.add_routing_label(label("gpu")),
3361 Err(PolicyError::NotAutoscale)
3362 ));
3363 assert!(matches!(
3364 policy.remove_routing_label(&label("gpu")),
3365 Err(PolicyError::NotAutoscale)
3366 ));
3367 assert!(matches!(
3375 PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
3376 Err(PolicyError::AutoscaleWithoutMaxCapacity)
3377 ));
3378 }
3379
3380 #[test]
3381 fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
3382 let mut policy = ScalePolicy::new(
3385 PolicyId::from_u128(2),
3386 ScaleTarget::repository("o/r").unwrap(),
3387 9,
3388 HostId::from_u128(7),
3389 PolicyMode::monitor_only(),
3390 CachePolicy::default(),
3391 );
3392 assert!(policy.routing_labels().is_none());
3393
3394 policy
3395 .promote_to_autoscale(host_labels("home"), 0, nz(3))
3396 .unwrap();
3397
3398 assert!(policy.owns_runners());
3399 assert_eq!(
3400 policy.routing_labels().unwrap().host_label().as_str(),
3401 "rm-home-win-x64"
3402 );
3403 assert_eq!(policy.max_capacity().unwrap().get(), 3);
3404
3405 assert!(matches!(
3407 policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
3408 Err(PolicyError::AlreadyAutoscale)
3409 ));
3410 policy.set_max_capacity(nz(4)).unwrap();
3411 assert_eq!(policy.max_capacity().unwrap().get(), 4);
3412 }
3413
3414 #[test]
3415 fn a_named_monitor_can_only_be_promoted_with_its_derived_selector() {
3416 let mut named = ScalePolicy::new_named_monitor(
3417 PolicyId::from_u128(3),
3418 ScaleTarget::repository("o/r").unwrap(),
3419 9,
3420 HostId::from_u128(7),
3421 HostLabel::new("home").unwrap(),
3422 ProfileName::new("Py-Isolated").unwrap(),
3423 CachePolicy::default(),
3424 )
3425 .unwrap();
3426 assert!(matches!(
3427 named.promote_to_autoscale(host_labels("other"), 0, nz(3)),
3428 Err(PolicyError::NamedProfileNeedsDerivedSelector)
3429 ));
3430 assert!(named.routing_labels().is_none());
3431 named
3432 .promote_named_to_autoscale(Os::Windows, Arch::X64, 0, nz(3))
3433 .unwrap();
3434 assert_eq!(
3435 named.routing_labels().unwrap().host_label().as_str(),
3436 "rm-home-win-x64-py-isolated"
3437 );
3438 }
3439
3440 #[test]
3441 fn a_policy_is_owned_by_exactly_one_host() {
3442 let mine = HostId::from_u128(7);
3443 let theirs = HostId::from_u128(8);
3444 let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
3445 assert!(policy.is_owned_by(mine));
3446 assert!(!policy.is_owned_by(theirs));
3447 }
3448
3449 #[test]
3450 fn an_overridden_host_label_is_detectable_without_being_rejected() {
3451 assert!(host_labels("home").is_derived_shape());
3455 assert!(
3456 RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
3457 .is_derived_shape(),
3458 "a host label containing `-` still derives a four-plus-segment name"
3459 );
3460 assert_eq!(
3466 host_labels("home--pc").host_label().as_str(),
3467 "rm-home--pc-win-x64"
3468 );
3469 assert!(
3470 host_labels("home--pc").is_derived_shape(),
3471 "consecutive dashes are legal inside a host label; the empty middle \
3472 segment they produce is not evidence of an override"
3473 );
3474
3475 for raw in [
3479 "self-hosted",
3480 "ubuntu-latest",
3481 "rm-home-win",
3482 "rm-home-win-x64-extra",
3483 ] {
3484 assert!(
3485 !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
3486 "{raw:?} is not the derived shape"
3487 );
3488 }
3489 assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
3491 assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
3492 assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
3493
3494 let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
3496 overridden.add(label("rm-home-win-x64"));
3497 assert!(!overridden.is_derived_shape());
3498 }
3499
3500 #[test]
3501 fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
3502 let mut policy = autoscale_policy(
3506 ScaleTarget::repository("o/r").unwrap(),
3507 HostId::from_u128(7),
3508 3,
3509 );
3510
3511 assert!(policy.can_activate());
3512 assert!(
3513 !policy.can_request_disable(),
3514 "a pending policy cannot drain; it is also already not enabled, \
3515 which is what makes the command a no-op rather than a failure"
3516 );
3517 assert!(matches!(
3518 policy.request_disable(),
3519 Err(PolicyError::IllegalTransition {
3520 from: PolicyState::Pending,
3521 to: PolicyState::Draining,
3522 })
3523 ));
3524
3525 policy.activate().unwrap();
3526 assert!(!policy.can_activate(), "already active");
3527 assert!(policy.can_request_disable());
3528 assert!(matches!(
3529 policy.activate(),
3530 Err(PolicyError::IllegalTransition { .. })
3531 ));
3532
3533 policy.request_disable().unwrap();
3534 assert!(!policy.can_request_disable(), "already draining");
3535 assert!(!policy.enabled());
3536 }
3537
3538 fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
3562 let host = HostId::from_u128(7);
3563 let mut trace = Vec::new();
3564
3565 let mut policy = autoscale_policy(target.clone(), host, 3);
3566 trace.push(format!("owns_runners={}", policy.owns_runners()));
3567 trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
3568 trace.push(format!(
3569 "owned_by_other={}",
3570 policy.is_owned_by(HostId::from_u128(8))
3571 ));
3572 trace.push(format!("initial_state={}", policy.state()));
3573 trace.push(format!("initial_enabled={}", policy.enabled()));
3574 trace.push(format!(
3575 "may_start_initially={}",
3576 policy.may_start_runners()
3577 ));
3578 trace.push(format!("labels={}", policy.routing_labels().unwrap()));
3579 trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
3580 trace.push(format!("min_capacity={}", policy.min_capacity()));
3581
3582 policy.activate().unwrap();
3584 trace.push(format!("after_activate={}", policy.state()));
3585 trace.push(format!("may_start_active={}", policy.may_start_runners()));
3586
3587 let jobs = vec![
3589 RunsOn::Single("rm-home-win-x64".into()),
3590 RunsOn::Single("ubuntu-latest".into()),
3591 RunsOn::Single("${{ matrix.os }}".into()),
3592 ];
3593 let tally = policy.tally(&jobs);
3594 trace.push(format!(
3595 "demand={} not_matched={} unresolvable={}",
3596 tally.demand(),
3597 tally.not_matched,
3598 tally.unresolvable.len()
3599 ));
3600
3601 let host_record = crate::model::Host::new(
3605 host,
3606 "home-pc",
3607 Os::Windows,
3608 Arch::X64,
3609 nz(4),
3610 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3611 )
3612 .unwrap();
3613 let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
3614 let allocation = allocator.allocate(&policy, 3);
3615 trace.push(format!(
3616 "alloc demand={} desired={} active_owned={} headroom_before={} \
3617 to_start={} limiting={}",
3618 allocation.demand,
3619 allocation.desired,
3620 allocation.active_owned,
3621 allocation.headroom_before,
3622 allocation.to_start,
3623 allocation.limiting_factor
3624 ));
3625 trace.push(format!("headroom_after={}", allocator.headroom()));
3626 trace.push(format!(
3629 "alloc_zero_to_start={}",
3630 allocator.allocate(&policy, 0).to_start
3631 ));
3632
3633 let attempt = crate::attempt::RunnerAttempt::allocate(
3637 crate::model::AttemptId::from_u128(11),
3638 policy.id,
3639 "C:/runners/eq",
3640 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3641 );
3642 trace.push(format!(
3643 "authorize_own_host={:?}",
3644 crate::attempt::authorize(host, &policy, &attempt).is_ok()
3645 ));
3646 trace.push(format!(
3647 "authorize_other_host={}",
3648 crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
3649 .expect_err("an agent on another host must be refused")
3650 ));
3651 let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
3652 crate::model::AttemptId::from_u128(12),
3653 PolicyId::from_u128(999),
3654 "C:/runners/eq-other",
3655 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3656 );
3657 trace.push(format!(
3658 "authorize_other_policy={}",
3659 crate::attempt::authorize(host, &policy, &foreign_attempt)
3660 .expect_err("an attempt under another policy must be refused")
3661 ));
3662
3663 trace.push(format!("disable={}", policy.request_disable().unwrap()));
3665 trace.push(format!(
3666 "drain_with_1={}",
3667 policy.drain_completed(1).unwrap()
3668 ));
3669 trace.push(format!(
3670 "drain_with_0={}",
3671 policy.drain_completed(0).unwrap()
3672 ));
3673
3674 trace.push(format!(
3676 "reactivate_err={}",
3677 policy.transition_to(PolicyState::Active).is_err()
3678 ));
3679
3680 trace.push(format!(
3682 "registration_labels={:?}",
3683 autoscale_policy(target, host, 3)
3684 .routing_labels()
3685 .unwrap()
3686 .as_registration_labels()
3687 ));
3688
3689 trace
3690 }
3691
3692 #[test]
3693 fn repository_and_organization_targets_are_equivalent() {
3694 let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
3695 let organization =
3696 assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
3697
3698 assert_eq!(
3699 repository, organization,
3700 "D18: the two scopes differ only in which GitHub endpoint and which \
3701 App permission the gateway uses. Any difference here is a \
3702 scope-dependent domain rule that must not exist."
3703 );
3704
3705 assert_ne!(
3707 ScaleTarget::repository("o/r").unwrap().scope(),
3708 ScaleTarget::organization("o").unwrap().scope()
3709 );
3710 }
3711
3712 fn job(labels: &[&str]) -> RunsOn {
3717 RunsOn::from_job_labels(labels.iter().map(|label| (*label).to_string()))
3718 }
3719
3720 fn linux_profile(extra: &[&str]) -> RoutingLabels {
3721 RoutingLabels::from_parts(
3722 label("rm-box-linux-x64"),
3723 ["self-hosted", "linux", "x64"]
3724 .iter()
3725 .chain(extra)
3726 .map(|value| label(value)),
3727 )
3728 }
3729
3730 #[test]
3731 fn an_exclusion_is_satisfied_by_a_profile_that_lacks_what_it_excludes() {
3732 let native = linux_profile(&[]);
3733 let wsl = linux_profile(&["wsl"]);
3734 let wanted = job(&["self-hosted", "linux", "x64", "not-wsl"]);
3735 assert!(native.matches(&wanted).is_match());
3736 assert_eq!(
3737 wsl.matches(&wanted),
3738 RunsOnMatch::NoMatch {
3739 missing: vec![label("not-wsl")]
3740 }
3741 );
3742 assert!(!wsl.matches(&job(&["Linux", "NOT-WSL"])).is_match());
3744 assert!(
3745 !native
3746 .matches(&job(&["linux", "not-rm-box-linux-x64"]))
3747 .is_match()
3748 );
3749 assert!(
3750 native
3751 .matches(&job(&["linux", "not-rm-other-linux-x64"]))
3752 .is_match()
3753 );
3754 }
3755
3756 #[test]
3757 fn a_profile_listing_both_a_label_and_its_exclusion_never_satisfies_the_exclusion() {
3758 let both = linux_profile(&["wsl", "not-wsl"]);
3760 assert!(!both.matches(&job(&["linux", "not-wsl"])).is_match());
3761 assert!(both.matches(&job(&["linux", "wsl"])).is_match());
3762 let registered = both.as_registration_labels();
3764 assert!(registered.contains(&"wsl".to_string()));
3765 assert!(!registered.contains(&"not-wsl".to_string()));
3766 }
3767
3768 #[test]
3769 fn a_job_without_exclusions_matches_exactly_as_before() {
3770 let profile = linux_profile(&["gpu"]);
3771 assert!(profile.matches(&job(&["self-hosted", "gpu"])).is_match());
3772 assert!(!profile.matches(&job(&["self-hosted", "arm64"])).is_match());
3773 assert_eq!(
3774 profile.as_registration_labels(),
3775 vec!["rm-box-linux-x64", "gpu", "linux", "self-hosted", "x64"]
3776 );
3777 let tally = profile.tally(&[job(&["self-hosted", "gpu"])]);
3778 assert!(tally.excluded.is_empty());
3779 }
3780
3781 #[test]
3782 fn an_unusable_or_conflicting_exclusion_is_unsatisfiable_not_demand() {
3783 for bad in ["not-", "not-not-wsl", "NOT-Not-x", "not- wsl"] {
3784 let got = job(&["linux", bad]).required_labels();
3785 assert!(
3786 matches!(&got, Err(UnresolvableRunsOn::InvalidExclusion { .. })),
3787 "{bad}: {got:?}"
3788 );
3789 assert!(got.unwrap_err().is_unsatisfiable_exclusion());
3790 }
3791 assert_eq!(
3792 job(&["linux", "not- wsl", "not-gpu"]).plain_labels(),
3793 vec![label("linux")]
3794 );
3795 let conflict = job(&["linux", "WSL", "not-wsl"]).required_labels();
3796 assert_eq!(
3797 conflict,
3798 Err(UnresolvableRunsOn::ConflictingExclusion {
3799 label: label("wsl")
3800 })
3801 );
3802 let tally = linux_profile(&[]).tally(&[job(&["linux", "wsl", "not-wsl"])]);
3803 assert_eq!((tally.matched, tally.unresolvable.len()), (0, 1));
3804 assert!(
3806 !job(&["${{ matrix.os }}"])
3807 .required_labels()
3808 .unwrap_err()
3809 .is_unsatisfiable_exclusion()
3810 );
3811 }
3812
3813 #[test]
3814 fn a_tally_collects_what_its_matched_jobs_exclude() {
3815 let profile = linux_profile(&[]);
3816 let tally = profile.tally(&[
3817 job(&["linux", "not-wsl"]),
3818 job(&["linux", "not-gpu", "not-wsl"]),
3819 job(&["linux"]),
3820 job(&["windows", "not-docker"]),
3822 ]);
3823 assert_eq!(tally.matched, 3);
3824 assert_eq!(
3825 tally.excluded.iter().map(Label::as_str).collect::<Vec<_>>(),
3826 vec!["gpu", "wsl"]
3827 );
3828 }
3829
3830 #[test]
3831 fn excluding_adds_only_exclusions_of_labels_the_profile_lacks() {
3832 let profile = linux_profile(&["gpu"]);
3833 let excluded = [label("wsl"), label("gpu")];
3834 let registered = profile.excluding(&excluded).as_registration_labels();
3835 assert!(registered.contains(&"not-wsl".to_string()));
3836 assert!(!registered.contains(&"not-gpu".to_string()));
3837 assert!(!profile.contains(&label("not-wsl")));
3839 }
3840
3841 #[test]
3842 fn host_traits_give_wsl_or_not_wsl_and_nothing_when_unknown() {
3843 assert!(HostTraits::default().labels().is_empty());
3844 assert_eq!(HostTraits::wsl(true).labels(), vec![label("wsl")]);
3845 assert_eq!(HostTraits::wsl(false).labels(), vec![label("not-wsl")]);
3846 let wanted = job(&["linux", "not-wsl"]);
3847 let base = linux_profile(&[]);
3848 assert!(
3849 !base
3850 .with_labels(HostTraits::wsl(true).labels())
3851 .matches(&wanted)
3852 .is_match()
3853 );
3854 let native = base.with_labels(HostTraits::wsl(false).labels());
3855 assert!(native.matches(&wanted).is_match());
3856 assert!(
3857 native
3858 .as_registration_labels()
3859 .contains(&"not-wsl".to_string())
3860 );
3861 }
3862
3863 #[test]
3864 fn a_profile_label_that_contradicts_another_is_refused() {
3865 let mut policy = autoscale_policy(
3866 ScaleTarget::repository("o/r").unwrap(),
3867 HostId::from_u128(9),
3868 2,
3869 );
3870 policy.add_routing_label(label("wsl")).unwrap();
3871 assert_eq!(
3872 policy.add_routing_label(label("not-wsl")),
3873 Err(PolicyError::ContradictoryExclusion {
3874 label: label("not-wsl"),
3875 carried: label("wsl"),
3876 })
3877 );
3878 let mut other = autoscale_policy(
3879 ScaleTarget::repository("o/r").unwrap(),
3880 HostId::from_u128(9),
3881 2,
3882 );
3883 other.add_routing_label(label("not-gpu")).unwrap();
3884 assert!(matches!(
3885 other.add_routing_label(label("GPU")),
3886 Err(PolicyError::ContradictoryExclusion { .. })
3887 ));
3888 assert!(matches!(
3889 other.add_routing_label(label("not-")),
3890 Err(PolicyError::InvalidExclusion { .. })
3891 ));
3892 assert_eq!(other.add_routing_label(label("docker")), Ok(true));
3893 }
3894
3895 #[test]
3896 fn runtime_labels_change_a_copy_and_leave_the_revision() {
3897 let policy = autoscale_policy(
3898 ScaleTarget::repository("o/r").unwrap(),
3899 HostId::from_u128(9),
3900 2,
3901 );
3902 let copy = policy.with_runtime_labels(|labels| labels.with_labels([label("wsl")]));
3903 assert!(copy.routing_labels().unwrap().contains(&label("wsl")));
3904 assert!(!policy.routing_labels().unwrap().contains(&label("wsl")));
3905 assert_eq!(copy.revision(), policy.revision());
3906 assert_eq!(copy.id, policy.id);
3907 }
3908}