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]
189pub fn pool_label(os: Os) -> Label {
190 Label::new(format!("{POOL_LABEL_PREFIX}{}", os.pool_token()))
191 .expect("the pool prefix and a fixed OS token form a valid label")
192}
193
194#[must_use]
197pub fn may_carry_pool(label: &Label, os: Os) -> bool {
198 *label == pool_label(os) || is_any_pool_label(label.as_str())
199}
200
201pub fn check_pool_label(label: &Label, os: Os) -> Result<(), PolicyError> {
207 let expected = pool_label(os);
208 if in_pool_namespace(label.as_str()) && !may_carry_pool(label, os) {
209 return Err(PolicyError::PoolLabelMismatch {
210 label: label.clone(),
211 expected,
212 });
213 }
214 Ok(())
215}
216
217pub const EXCLUSION_PREFIX: &str = "not-";
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum Exclusion {
235 Of(Label),
237 Invalid,
240}
241
242#[must_use]
245pub fn excluded_label(label: &Label) -> Option<Exclusion> {
246 let rest = label.as_str().strip_prefix(EXCLUSION_PREFIX)?;
247 Some(match Label::new(rest) {
248 Ok(excluded)
251 if excluded.as_str() == rest && !excluded.as_str().starts_with(EXCLUSION_PREFIX) =>
252 {
253 Exclusion::Of(excluded)
254 }
255 _ => Exclusion::Invalid,
256 })
257}
258
259#[must_use]
262pub fn exclusion_of(label: &Label) -> Option<Label> {
263 Label::new(format!("{EXCLUSION_PREFIX}{label}")).ok()
264}
265
266#[must_use]
273pub fn label_satisfied(offers: impl Fn(&Label) -> bool, required: &Label) -> bool {
274 match excluded_label(required) {
275 Some(Exclusion::Of(excluded)) => !offers(&excluded),
276 Some(Exclusion::Invalid) => false,
277 None => offers(required),
278 }
279}
280
281pub fn exclusions_in(required: &[Label]) -> impl Iterator<Item = Label> + '_ {
283 required
284 .iter()
285 .filter_map(|label| match excluded_label(label) {
286 Some(Exclusion::Of(excluded)) => Some(excluded),
287 _ => None,
288 })
289}
290
291pub fn check_exclusion_label(labels: &RoutingLabels, label: &Label) -> Result<(), PolicyError> {
298 match excluded_label(label) {
299 Some(Exclusion::Invalid) => Err(PolicyError::InvalidExclusion {
300 label: label.clone(),
301 }),
302 Some(Exclusion::Of(excluded)) if labels.contains(&excluded) => {
303 Err(PolicyError::ContradictoryExclusion {
304 label: label.clone(),
305 carried: excluded,
306 })
307 }
308 Some(Exclusion::Of(_)) => Ok(()),
309 None => match exclusion_of(label).filter(|exclusion| labels.contains(exclusion)) {
310 Some(exclusion) => Err(PolicyError::ContradictoryExclusion {
311 label: label.clone(),
312 carried: exclusion,
313 }),
314 None => Ok(()),
315 },
316 }
317}
318
319pub const WSL_LABEL: &str = "wsl";
322
323#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
332pub struct HostTraits {
333 wsl: Option<bool>,
336}
337
338impl HostTraits {
339 #[must_use]
341 pub const fn wsl(inside: bool) -> Self {
342 Self { wsl: Some(inside) }
343 }
344
345 #[must_use]
347 pub fn labels(&self) -> Vec<Label> {
348 let wsl = Label::new(WSL_LABEL).expect("`wsl` is a valid label");
349 match self.wsl {
350 None => Vec::new(),
351 Some(true) => vec![wsl],
352 Some(false) => exclusion_of(&wsl).into_iter().collect(),
353 }
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
390pub struct RoutingLabels {
391 host_label: Label,
392 additional: BTreeSet<Label>,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396struct RoutingLabelsRepr {
397 host_label: Label,
398 #[serde(default)]
399 additional: BTreeSet<Label>,
400}
401
402impl From<RoutingLabelsRepr> for RoutingLabels {
403 fn from(repr: RoutingLabelsRepr) -> Self {
404 Self::from_parts(repr.host_label, repr.additional)
409 }
410}
411
412impl From<RoutingLabels> for RoutingLabelsRepr {
413 fn from(value: RoutingLabels) -> Self {
414 Self {
415 host_label: value.host_label,
416 additional: value.additional,
417 }
418 }
419}
420
421impl RoutingLabels {
422 pub const PREFIX: &'static str = "rm";
424
425 #[must_use]
439 pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
440 let derived = format!(
441 "{}-{}-{}-{}",
442 Self::PREFIX,
443 host_label.as_str(),
444 os.label_token(),
445 arch.label_token()
446 );
447 Self {
448 host_label: Label::new(derived).expect(
449 "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
450 segments are fixed tokens, so the concatenation is always a valid Label",
451 ),
452 additional: BTreeSet::new(),
453 }
454 }
455
456 #[must_use]
460 pub fn derive_for_profile(
461 host_label: &HostLabel,
462 os: Os,
463 arch: Arch,
464 profile: &ProfileName,
465 ) -> Self {
466 let label = format!(
467 "{}-{}-{}-{}-{}",
468 Self::PREFIX,
469 host_label.as_str(),
470 os.label_token(),
471 arch.label_token(),
472 profile.as_str()
473 );
474 Self::from_host_label(Label::new(label).expect("validated selector segments form a label"))
475 }
476
477 #[must_use]
490 pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
491 let additional = additional
492 .into_iter()
493 .filter(|l| *l != host_label)
494 .collect();
495 Self {
496 host_label,
497 additional,
498 }
499 }
500
501 #[must_use]
503 pub fn from_host_label(host_label: Label) -> Self {
504 Self::from_parts(host_label, Vec::new())
505 }
506
507 #[must_use]
509 pub fn host_label(&self) -> &Label {
510 &self.host_label
511 }
512
513 #[must_use]
544 pub fn is_derived_shape(&self) -> bool {
545 let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
546 let [prefix, middle @ .., os, arch] = segments.as_slice() else {
551 return false;
552 };
553 *prefix == Self::PREFIX
556 && !middle.is_empty()
557 && Os::ALL
558 .iter()
559 .any(|candidate| candidate.label_token() == *os)
560 && Arch::ALL
561 .iter()
562 .any(|candidate| candidate.label_token() == *arch)
563 }
564
565 pub fn additional(&self) -> impl Iterator<Item = &Label> {
567 self.additional.iter()
568 }
569
570 pub fn add(&mut self, label: Label) -> bool {
573 if label == self.host_label {
574 return false;
575 }
576 self.additional.insert(label)
577 }
578
579 pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
586 if *label == self.host_label {
587 return Err(PolicyError::HostLabelNotRemovable {
588 label: label.clone(),
589 });
590 }
591 Ok(self.additional.remove(label))
592 }
593
594 #[must_use]
595 pub fn contains(&self, label: &Label) -> bool {
596 self.host_label == *label || self.additional.contains(label)
597 }
598
599 #[must_use]
602 pub fn satisfies(&self, label: &Label) -> bool {
603 label_satisfied(|offered| self.contains(offered), label)
604 }
605
606 pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
610 self.additional
611 .iter()
612 .filter(|label| is_pool_label(label.as_str()))
613 }
614
615 pub fn iter(&self) -> impl Iterator<Item = &Label> {
617 std::iter::once(&self.host_label).chain(self.additional.iter())
618 }
619
620 #[must_use]
622 pub fn count(&self) -> NonZeroUsize {
623 NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
624 }
625
626 #[must_use]
628 pub fn to_non_empty(&self) -> NonEmpty<Label> {
629 let mut out = NonEmpty::of(self.host_label.clone());
630 for label in &self.additional {
631 out.push(label.clone());
632 }
633 out
634 }
635
636 #[must_use]
650 pub fn as_registration_labels(&self) -> Vec<String> {
651 self.iter()
652 .filter(|label| self.satisfies(label))
653 .map(|l| l.as_str().to_string())
654 .collect()
655 }
656
657 #[must_use]
660 pub fn with_labels(&self, extra: impl IntoIterator<Item = Label>) -> Self {
661 let mut labels = self.clone();
662 for label in extra {
663 labels.add(label);
664 }
665 labels
666 }
667
668 #[must_use]
672 pub fn excluding<'a>(&self, excluded: impl IntoIterator<Item = &'a Label>) -> Self {
673 let exclusions: Vec<Label> = excluded
674 .into_iter()
675 .filter(|label| !self.contains(label))
676 .filter_map(exclusion_of)
677 .collect();
678 self.with_labels(exclusions)
679 }
680
681 #[must_use]
692 pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
693 let required = match runs_on.required_labels() {
694 Ok(required) => required,
695 Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
696 };
697
698 let missing: Vec<Label> = required
699 .iter()
700 .filter(|label| !self.satisfies(label))
701 .cloned()
702 .collect();
703
704 if missing.is_empty() {
705 RunsOnMatch::Match {
706 runner_group: runs_on.runner_group().map(str::to_string),
707 }
708 } else {
709 RunsOnMatch::NoMatch { missing }
710 }
711 }
712
713 #[must_use]
721 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
722 let mut tally = DemandTally::default();
723 for job in jobs {
724 match self.matches(job) {
725 RunsOnMatch::Match { .. } => {
726 tally.matched += 1;
727 if let Ok(required) = job.required_labels() {
728 tally.excluded.extend(exclusions_in(&required));
729 }
730 }
731 RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
732 RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
733 }
734 }
735 tally
736 }
737}
738
739impl fmt::Display for RoutingLabels {
740 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
741 let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
742 f.write_str(&joined.join(","))
743 }
744}
745
746#[derive(Debug, Clone, Default, PartialEq, Eq)]
748pub struct DemandTally {
749 pub matched: u32,
751 pub not_matched: u32,
753 pub unresolvable: Vec<UnresolvableRunsOn>,
757 pub excluded: BTreeSet<Label>,
761}
762
763impl DemandTally {
764 #[must_use]
765 pub fn demand(&self) -> u32 {
766 self.matched
767 }
768
769 #[must_use]
770 pub fn total_seen(&self) -> u32 {
771 self.matched + self.not_matched + self.unresolvable.len() as u32
772 }
773}
774
775#[derive(Debug, Clone, PartialEq, Eq)]
777pub enum RunsOnMatch {
778 Match { runner_group: Option<String> },
786 NoMatch { missing: Vec<Label> },
789 Unresolvable(UnresolvableRunsOn),
791}
792
793impl RunsOnMatch {
794 #[must_use]
795 pub const fn is_match(&self) -> bool {
796 matches!(self, RunsOnMatch::Match { .. })
797 }
798
799 #[must_use]
800 pub const fn is_unresolvable(&self) -> bool {
801 matches!(self, RunsOnMatch::Unresolvable(_))
802 }
803}
804
805#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
807pub enum UnresolvableRunsOn {
808 #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
811 Expression { raw: String },
812
813 #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
816 RunnerGroupWithoutLabels { group: String },
817
818 #[error("`runs-on` names no labels")]
820 NoLabels,
821
822 #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
824 InvalidLabel {
825 raw: String,
826 #[source]
827 source: ValidationError,
828 },
829
830 #[error(
833 "`runs-on` contains {label}, which excludes no usable label (write `not-` and one \
834 ordinary label, as in `not-wsl`); no runner can take this job"
835 )]
836 InvalidExclusion { label: Label },
837
838 #[error(
841 "`runs-on` asks for {label} and for not-{label}; no runner can carry a label and \
842 lack it, so none can take this job"
843 )]
844 ConflictingExclusion { label: Label },
845}
846
847impl UnresolvableRunsOn {
848 #[must_use]
851 pub const fn is_unsatisfiable_exclusion(&self) -> bool {
852 self.exclusion_kind().is_some()
853 }
854
855 #[must_use]
858 pub const fn exclusion_kind(&self) -> Option<&'static str> {
859 match self {
860 Self::InvalidExclusion { .. } => Some("invalid_exclusion"),
861 Self::ConflictingExclusion { .. } => Some("conflicting_exclusion"),
862 _ => None,
863 }
864 }
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
874#[serde(untagged)]
875pub enum RunsOn {
876 Single(String),
878 Many(Vec<String>),
880 Grouped {
882 #[serde(default)]
883 group: Option<String>,
884 #[serde(default)]
885 labels: RunsOnLabels,
886 },
887}
888
889#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891#[serde(untagged)]
892pub enum RunsOnLabels {
893 One(String),
894 Many(Vec<String>),
895}
896
897impl Default for RunsOnLabels {
898 fn default() -> Self {
899 Self::Many(Vec::new())
900 }
901}
902
903impl RunsOnLabels {
904 fn as_slice(&self) -> &[String] {
905 match self {
906 RunsOnLabels::One(one) => std::slice::from_ref(one),
907 RunsOnLabels::Many(many) => many,
908 }
909 }
910}
911
912impl RunsOn {
913 #[must_use]
915 pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
916 Self::Many(labels.into_iter().map(Into::into).collect())
917 }
918
919 #[must_use]
921 pub fn runner_group(&self) -> Option<&str> {
922 match self {
923 RunsOn::Grouped { group, .. } => group.as_deref(),
924 _ => None,
925 }
926 }
927
928 #[must_use]
932 pub fn plain_labels(&self) -> Vec<Label> {
933 self.raw_labels()
934 .iter()
935 .filter_map(|raw| Label::new(raw).ok())
936 .filter(|label| excluded_label(label).is_none())
937 .collect()
938 }
939
940 fn raw_labels(&self) -> &[String] {
941 match self {
942 RunsOn::Single(one) => std::slice::from_ref(one),
943 RunsOn::Many(many) => many,
944 RunsOn::Grouped { labels, .. } => labels.as_slice(),
945 }
946 }
947
948 pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
971 let raws = self.raw_labels();
972
973 if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
974 return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
975 }
976
977 let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
978
979 if usable.is_empty() {
980 return match self.runner_group() {
981 Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
982 group: group.to_string(),
983 }),
984 None => Err(UnresolvableRunsOn::NoLabels),
985 };
986 }
987
988 let labels: Vec<Label> = usable
989 .into_iter()
990 .map(|raw| {
991 Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
992 raw: raw.clone(),
993 source,
994 })
995 })
996 .collect::<Result<_, _>>()?;
997
998 for label in &labels {
999 match excluded_label(label) {
1000 Some(Exclusion::Invalid) => {
1001 return Err(UnresolvableRunsOn::InvalidExclusion {
1002 label: label.clone(),
1003 });
1004 }
1005 Some(Exclusion::Of(excluded)) if labels.contains(&excluded) => {
1006 return Err(UnresolvableRunsOn::ConflictingExclusion { label: excluded });
1007 }
1008 Some(Exclusion::Of(_)) | None => {}
1009 }
1010 }
1011 Ok(labels)
1012 }
1013}
1014
1015fn is_expression(raw: &str) -> bool {
1016 raw.contains("${{")
1017}
1018
1019#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1030#[serde(try_from = "AutoscaleConfigRepr")]
1031pub struct AutoscaleConfig {
1032 routing_labels: RoutingLabels,
1033 min_capacity: u16,
1034 max_capacity: NonZeroU16,
1035}
1036
1037#[derive(Debug, Deserialize)]
1038struct AutoscaleConfigRepr {
1039 routing_labels: RoutingLabels,
1040 min_capacity: u16,
1041 max_capacity: NonZeroU16,
1042}
1043
1044impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
1045 type Error = PolicyError;
1046
1047 fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
1048 Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
1049 }
1050}
1051
1052impl AutoscaleConfig {
1053 pub fn new(
1059 routing_labels: RoutingLabels,
1060 min_capacity: u16,
1061 max_capacity: NonZeroU16,
1062 ) -> Result<Self, PolicyError> {
1063 if min_capacity > max_capacity.get() {
1064 return Err(PolicyError::InvertedCapacityRange {
1065 min: min_capacity,
1066 max: max_capacity.get(),
1067 });
1068 }
1069 Ok(Self {
1070 routing_labels,
1071 min_capacity,
1072 max_capacity,
1073 })
1074 }
1075
1076 pub fn v1(
1082 routing_labels: RoutingLabels,
1083 max_capacity: NonZeroU16,
1084 ) -> Result<Self, PolicyError> {
1085 Self::new(routing_labels, 0, max_capacity)
1086 }
1087
1088 #[must_use]
1089 pub fn routing_labels(&self) -> &RoutingLabels {
1090 &self.routing_labels
1091 }
1092
1093 #[must_use]
1094 pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
1095 &mut self.routing_labels
1096 }
1097
1098 #[must_use]
1099 pub const fn min_capacity(&self) -> u16 {
1100 self.min_capacity
1101 }
1102
1103 #[must_use]
1104 pub const fn max_capacity(&self) -> NonZeroU16 {
1105 self.max_capacity
1106 }
1107
1108 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1112 if self.min_capacity > max_capacity.get() {
1113 return Err(PolicyError::InvertedCapacityRange {
1114 min: self.min_capacity,
1115 max: max_capacity.get(),
1116 });
1117 }
1118 self.max_capacity = max_capacity;
1119 Ok(())
1120 }
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1143#[serde(tag = "mode", rename_all = "snake_case")]
1144pub enum PolicyMode {
1145 MonitorOnly,
1148 Autoscale(AutoscaleConfig),
1150}
1151
1152impl PolicyMode {
1153 #[must_use]
1154 pub const fn monitor_only() -> Self {
1155 Self::MonitorOnly
1156 }
1157
1158 pub fn autoscale(
1161 routing_labels: RoutingLabels,
1162 min_capacity: u16,
1163 max_capacity: NonZeroU16,
1164 ) -> Result<Self, PolicyError> {
1165 Ok(Self::Autoscale(AutoscaleConfig::new(
1166 routing_labels,
1167 min_capacity,
1168 max_capacity,
1169 )?))
1170 }
1171
1172 pub fn from_persisted(
1182 routing_labels: Option<RoutingLabels>,
1183 min_capacity: u16,
1184 max_capacity: Option<NonZeroU16>,
1185 ) -> Result<Self, PolicyError> {
1186 match (routing_labels, max_capacity) {
1187 (None, None) => {
1188 if min_capacity != 0 {
1189 return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
1194 }
1195 Ok(Self::MonitorOnly)
1196 }
1197 (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
1198 (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
1199 (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
1200 }
1201 }
1202
1203 #[must_use]
1206 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1207 match self {
1208 PolicyMode::MonitorOnly => None,
1209 PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
1210 }
1211 }
1212
1213 #[must_use]
1214 pub const fn min_capacity(&self) -> u16 {
1215 match self {
1216 PolicyMode::MonitorOnly => 0,
1217 PolicyMode::Autoscale(cfg) => cfg.min_capacity,
1218 }
1219 }
1220
1221 #[must_use]
1222 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1223 match self {
1224 PolicyMode::MonitorOnly => None,
1225 PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
1226 }
1227 }
1228
1229 #[must_use]
1230 pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
1231 match self {
1232 PolicyMode::MonitorOnly => None,
1233 PolicyMode::Autoscale(cfg) => Some(cfg),
1234 }
1235 }
1236
1237 #[must_use]
1238 pub const fn is_autoscale(&self) -> bool {
1239 matches!(self, PolicyMode::Autoscale(_))
1240 }
1241
1242 #[must_use]
1243 pub const fn is_monitor_only(&self) -> bool {
1244 matches!(self, PolicyMode::MonitorOnly)
1245 }
1246}
1247
1248#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1275#[serde(rename_all = "snake_case")]
1276pub enum PolicyState {
1277 Pending,
1278 Active,
1279 Draining,
1280 Disabled,
1281 RepairRequired,
1282 AuthenticationFailed,
1283}
1284
1285impl PolicyState {
1286 pub const ALL: [PolicyState; 6] = [
1287 PolicyState::Pending,
1288 PolicyState::Active,
1289 PolicyState::Draining,
1290 PolicyState::Disabled,
1291 PolicyState::RepairRequired,
1292 PolicyState::AuthenticationFailed,
1293 ];
1294
1295 pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
1298 (PolicyState::Pending, PolicyState::Active),
1299 (PolicyState::Pending, PolicyState::RepairRequired),
1300 (PolicyState::Active, PolicyState::Draining),
1301 (PolicyState::Draining, PolicyState::Disabled),
1302 (PolicyState::Disabled, PolicyState::Pending),
1303 (PolicyState::Pending, PolicyState::AuthenticationFailed),
1305 (PolicyState::Active, PolicyState::AuthenticationFailed),
1306 (PolicyState::Draining, PolicyState::AuthenticationFailed),
1307 (PolicyState::Disabled, PolicyState::AuthenticationFailed),
1308 (
1309 PolicyState::RepairRequired,
1310 PolicyState::AuthenticationFailed,
1311 ),
1312 (PolicyState::AuthenticationFailed, PolicyState::Pending),
1314 ];
1315
1316 #[must_use]
1317 pub fn can_transition_to(self, next: PolicyState) -> bool {
1318 Self::LEGAL.contains(&(self, next))
1319 }
1320
1321 #[must_use]
1323 pub const fn admits_new_runners(self) -> bool {
1324 matches!(self, PolicyState::Active)
1325 }
1326}
1327
1328impl fmt::Display for PolicyState {
1329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1330 f.write_str(match self {
1331 PolicyState::Pending => "pending",
1332 PolicyState::Active => "active",
1333 PolicyState::Draining => "draining",
1334 PolicyState::Disabled => "disabled",
1335 PolicyState::RepairRequired => "repair_required",
1336 PolicyState::AuthenticationFailed => "authentication_failed",
1337 })
1338 }
1339}
1340
1341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1352pub struct ScalePolicy {
1353 pub id: PolicyId,
1354 pub target: ScaleTarget,
1355 pub installation_id: u64,
1356 pub host_id: HostId,
1357 pub requested_host_label: HostLabel,
1359 profile_name: ProfileName,
1361 mode: PolicyMode,
1362 enabled: bool,
1363 state: PolicyState,
1364 pub cache_policy: CachePolicy,
1365 workspace_policy: WorkspacePolicy,
1373 execution_policy: ExecutionPolicy,
1374 revision: u64,
1375}
1376
1377#[derive(Debug, Clone, PartialEq, Eq)]
1403pub struct PersistedPolicy {
1404 pub id: PolicyId,
1405 pub target: ScaleTarget,
1406 pub installation_id: u64,
1408 pub host_id: HostId,
1409 pub requested_host_label: HostLabel,
1410 pub profile_name: ProfileName,
1411 pub routing_labels: Option<RoutingLabels>,
1413 pub min_capacity: u16,
1414 pub max_capacity: Option<NonZeroU16>,
1415 pub enabled: bool,
1417 pub state: PolicyState,
1418 pub cache_policy: CachePolicy,
1419 pub workspace_kind: WorkspaceKind,
1424 pub workspace_root: Option<LocalAbsolutePath>,
1427 pub execution_policy: ExecutionPolicy,
1428 pub revision: u64,
1430}
1431
1432#[derive(Debug, Clone, PartialEq, Eq)]
1434pub struct NamedProfileSpec {
1435 pub requested_host_label: HostLabel,
1436 pub os: Os,
1437 pub arch: Arch,
1438 pub profile_name: ProfileName,
1439 pub min_capacity: u16,
1440 pub max_capacity: NonZeroU16,
1441}
1442
1443impl ScalePolicy {
1444 #[must_use]
1446 pub const fn profile_name(&self) -> &ProfileName {
1447 &self.profile_name
1448 }
1449
1450 #[must_use]
1463 pub fn new(
1464 id: PolicyId,
1465 target: ScaleTarget,
1466 installation_id: u64,
1467 host_id: HostId,
1468 mode: PolicyMode,
1469 cache_policy: CachePolicy,
1470 ) -> Self {
1471 Self::new_for_host_label(
1472 id,
1473 target,
1474 installation_id,
1475 host_id,
1476 HostLabel::new("host").expect("the compatibility host label is valid"),
1477 mode,
1478 cache_policy,
1479 )
1480 }
1481
1482 #[must_use]
1484 pub fn new_for_host_label(
1485 id: PolicyId,
1486 target: ScaleTarget,
1487 installation_id: u64,
1488 host_id: HostId,
1489 requested_host_label: HostLabel,
1490 mode: PolicyMode,
1491 cache_policy: CachePolicy,
1492 ) -> Self {
1493 Self {
1494 id,
1495 target,
1496 installation_id,
1497 host_id,
1498 requested_host_label,
1499 profile_name: ProfileName::default_profile(),
1500 mode,
1501 enabled: false,
1502 state: PolicyState::Pending,
1503 cache_policy,
1504 workspace_policy: WorkspacePolicy::Ephemeral,
1508 execution_policy: ExecutionPolicy::Native,
1509 revision: 0,
1510 }
1511 }
1512
1513 pub fn new_named(
1516 id: PolicyId,
1517 target: ScaleTarget,
1518 installation_id: u64,
1519 host_id: HostId,
1520 spec: NamedProfileSpec,
1521 cache_policy: CachePolicy,
1522 ) -> Result<Self, PolicyError> {
1523 let NamedProfileSpec {
1524 requested_host_label,
1525 os,
1526 arch,
1527 profile_name,
1528 min_capacity,
1529 max_capacity,
1530 } = spec;
1531 if profile_name.is_default() {
1532 return Err(PolicyError::NamedConstructorForDefault);
1533 }
1534 let labels =
1535 RoutingLabels::derive_for_profile(&requested_host_label, os, arch, &profile_name);
1536 let mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1537 let mut policy = Self::new_for_host_label(
1538 id,
1539 target,
1540 installation_id,
1541 host_id,
1542 requested_host_label,
1543 mode,
1544 cache_policy,
1545 );
1546 policy.profile_name = profile_name;
1547 Ok(policy)
1548 }
1549
1550 pub fn new_named_monitor(
1553 id: PolicyId,
1554 target: ScaleTarget,
1555 installation_id: u64,
1556 host_id: HostId,
1557 requested_host_label: HostLabel,
1558 profile_name: ProfileName,
1559 cache_policy: CachePolicy,
1560 ) -> Result<Self, PolicyError> {
1561 if profile_name.is_default() {
1562 return Err(PolicyError::NamedConstructorForDefault);
1563 }
1564 let mut policy = Self::new_for_host_label(
1565 id,
1566 target,
1567 installation_id,
1568 host_id,
1569 requested_host_label,
1570 PolicyMode::monitor_only(),
1571 cache_policy,
1572 );
1573 policy.profile_name = profile_name;
1574 Ok(policy)
1575 }
1576
1577 pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1585 let PersistedPolicy {
1586 id,
1587 target,
1588 installation_id,
1589 host_id,
1590 requested_host_label,
1591 profile_name,
1592 routing_labels,
1593 min_capacity,
1594 max_capacity,
1595 enabled,
1596 state,
1597 cache_policy,
1598 workspace_kind,
1599 workspace_root,
1600 execution_policy,
1601 revision,
1602 } = fields;
1603
1604 let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1605 if !profile_name.is_default()
1606 && let Some(labels) = mode.routing_labels()
1607 {
1608 let suffix = format!("-{}", profile_name.as_str());
1611 if !labels.host_label().as_str().ends_with(&suffix) {
1612 return Err(PolicyError::InvalidProfileSelector);
1613 }
1614 }
1615 let workspace_policy =
1619 WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1620 execution_policy.validate(target.scope(), &workspace_policy)?;
1621 Ok(Self {
1622 id,
1623 target,
1624 installation_id,
1625 host_id,
1626 requested_host_label,
1627 profile_name,
1628 mode,
1629 enabled,
1630 state,
1631 cache_policy,
1632 workspace_policy,
1633 execution_policy,
1634 revision,
1635 })
1636 }
1637
1638 #[must_use]
1644 pub fn to_persisted(&self) -> PersistedPolicy {
1645 PersistedPolicy {
1646 id: self.id,
1647 target: self.target.clone(),
1648 installation_id: self.installation_id,
1649 host_id: self.host_id,
1650 requested_host_label: self.requested_host_label.clone(),
1651 profile_name: self.profile_name.clone(),
1652 routing_labels: self.routing_labels().cloned(),
1653 min_capacity: self.min_capacity(),
1654 max_capacity: self.max_capacity(),
1655 enabled: self.enabled,
1656 state: self.state,
1657 cache_policy: self.cache_policy,
1658 workspace_kind: self.workspace_policy.kind(),
1659 workspace_root: self.workspace_policy.root().cloned(),
1660 execution_policy: self.execution_policy.clone(),
1661 revision: self.revision,
1662 }
1663 }
1664
1665 #[must_use]
1666 pub const fn mode(&self) -> &PolicyMode {
1667 &self.mode
1668 }
1669
1670 #[must_use]
1671 pub const fn state(&self) -> PolicyState {
1672 self.state
1673 }
1674
1675 #[must_use]
1679 pub const fn enabled(&self) -> bool {
1680 self.enabled
1681 }
1682
1683 #[must_use]
1686 pub const fn revision(&self) -> u64 {
1687 self.revision
1688 }
1689
1690 #[must_use]
1691 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1692 self.mode.routing_labels()
1693 }
1694
1695 #[must_use]
1700 pub fn with_runtime_labels(
1701 &self,
1702 labels: impl FnOnce(&RoutingLabels) -> RoutingLabels,
1703 ) -> Self {
1704 let mut copy = self.clone();
1705 if let PolicyMode::Autoscale(cfg) = &mut copy.mode {
1706 let changed = labels(cfg.routing_labels());
1707 *cfg.routing_labels_mut() = changed;
1708 }
1709 copy
1710 }
1711
1712 pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
1714 self.routing_labels()
1715 .into_iter()
1716 .flat_map(RoutingLabels::pool_labels)
1717 }
1718
1719 #[must_use]
1721 pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1722 &self.workspace_policy
1723 }
1724
1725 #[must_use]
1726 pub const fn execution_policy(&self) -> &ExecutionPolicy {
1727 &self.execution_policy
1728 }
1729
1730 pub fn set_execution_policy(&mut self, execution: ExecutionPolicy) -> Result<(), PolicyError> {
1732 execution.validate(self.target.scope(), &self.workspace_policy)?;
1733 if self.execution_policy != execution {
1734 self.execution_policy = execution;
1735 self.revision = self.revision.saturating_add(1);
1736 }
1737 Ok(())
1738 }
1739
1740 pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1758 workspace.permitted_for(self.target.scope())?;
1763 self.execution_policy
1764 .validate(self.target.scope(), &workspace)?;
1765 if self.workspace_policy != workspace {
1766 self.workspace_policy = workspace;
1767 self.revision = self.revision.saturating_add(1);
1768 }
1769 Ok(())
1770 }
1771
1772 #[must_use]
1773 pub const fn min_capacity(&self) -> u16 {
1774 self.mode.min_capacity()
1775 }
1776
1777 #[must_use]
1778 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1779 self.mode.max_capacity()
1780 }
1781
1782 #[must_use]
1785 pub fn is_owned_by(&self, host_id: HostId) -> bool {
1786 self.host_id == host_id
1787 }
1788
1789 #[must_use]
1796 pub const fn owns_runners(&self) -> bool {
1797 self.mode.is_autoscale()
1798 }
1799
1800 #[must_use]
1806 pub const fn may_start_runners(&self) -> bool {
1807 self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1808 }
1809
1810 pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1814 if !self.state.can_transition_to(next) {
1815 return Err(PolicyError::IllegalTransition {
1816 from: self.state,
1817 to: next,
1818 });
1819 }
1820 self.state = next;
1821 self.revision = self.revision.saturating_add(1);
1822 Ok(())
1823 }
1824
1825 #[must_use]
1831 pub fn can_activate(&self) -> bool {
1832 self.state.can_transition_to(PolicyState::Active)
1833 }
1834
1835 #[must_use]
1837 pub fn can_request_disable(&self) -> bool {
1838 self.state.can_transition_to(PolicyState::Draining)
1839 }
1840
1841 pub fn activate(&mut self) -> Result<(), PolicyError> {
1862 self.transition_to(PolicyState::Active)?;
1863 self.enabled = true;
1864 Ok(())
1865 }
1866
1867 pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1886 self.transition_to(PolicyState::Draining)?;
1887 self.enabled = false;
1888 Ok(self.state)
1889 }
1890
1891 pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1901 if self.state != PolicyState::Draining {
1902 return Err(PolicyError::IllegalTransition {
1903 from: self.state,
1904 to: PolicyState::Disabled,
1905 });
1906 }
1907 if active_attempts == 0 {
1908 self.transition_to(PolicyState::Disabled)?;
1909 }
1910 Ok(self.state)
1911 }
1912
1913 pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1919 self.transition_to(PolicyState::AuthenticationFailed)
1920 }
1921
1922 pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1928 self.transition_to(PolicyState::Pending)
1929 }
1930
1931 pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1936 self.transition_to(PolicyState::RepairRequired)
1937 }
1938
1939 pub fn promote_to_autoscale(
1948 &mut self,
1949 routing_labels: RoutingLabels,
1950 min_capacity: u16,
1951 max_capacity: NonZeroU16,
1952 ) -> Result<(), PolicyError> {
1953 if !self.profile_name.is_default() {
1954 return Err(PolicyError::NamedProfileNeedsDerivedSelector);
1955 }
1956 if self.mode.is_autoscale() {
1957 return Err(PolicyError::AlreadyAutoscale);
1958 }
1959 self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1960 self.revision = self.revision.saturating_add(1);
1961 Ok(())
1962 }
1963
1964 pub fn promote_named_to_autoscale(
1967 &mut self,
1968 os: Os,
1969 arch: Arch,
1970 min_capacity: u16,
1971 max_capacity: NonZeroU16,
1972 ) -> Result<(), PolicyError> {
1973 if self.profile_name.is_default() {
1974 return Err(PolicyError::NamedConstructorForDefault);
1975 }
1976 if self.mode.is_autoscale() {
1977 return Err(PolicyError::AlreadyAutoscale);
1978 }
1979 let labels = RoutingLabels::derive_for_profile(
1980 &self.requested_host_label,
1981 os,
1982 arch,
1983 &self.profile_name,
1984 );
1985 self.mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1986 self.revision = self.revision.saturating_add(1);
1987 Ok(())
1988 }
1989
1990 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1998 match &mut self.mode {
1999 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2000 PolicyMode::Autoscale(cfg) => {
2001 cfg.set_max_capacity(max_capacity)?;
2002 self.revision = self.revision.saturating_add(1);
2003 Ok(())
2004 }
2005 }
2006 }
2007
2008 pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
2021 match &mut self.mode {
2022 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2023 PolicyMode::Autoscale(cfg) => {
2024 check_exclusion_label(cfg.routing_labels(), &label)?;
2025 let added = cfg.routing_labels_mut().add(label);
2026 if added {
2027 self.revision = self.revision.saturating_add(1);
2028 }
2029 Ok(added)
2030 }
2031 }
2032 }
2033
2034 pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
2041 match &mut self.mode {
2042 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2043 PolicyMode::Autoscale(cfg) => {
2044 let removed = cfg.routing_labels_mut().remove(label)?;
2045 if removed {
2046 self.revision = self.revision.saturating_add(1);
2047 }
2048 Ok(removed)
2049 }
2050 }
2051 }
2052
2053 #[must_use]
2059 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
2060 match self.routing_labels() {
2061 Some(labels) => labels.tally(jobs),
2062 None => DemandTally::default(),
2063 }
2064 }
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069 use super::*;
2070 use crate::model::{HostId, PolicyId, TargetScope};
2071
2072 fn nz(v: u16) -> NonZeroU16 {
2073 NonZeroU16::new(v).expect("test capacity is non-zero")
2074 }
2075
2076 fn label(s: &str) -> Label {
2077 Label::new(s).expect("test label is valid")
2078 }
2079
2080 fn host_labels(host: &str) -> RoutingLabels {
2081 RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
2082 }
2083
2084 fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
2085 ScalePolicy::new(
2086 PolicyId::from_u128(1),
2087 target,
2088 42,
2089 host,
2090 PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
2091 CachePolicy::default(),
2092 )
2093 }
2094
2095 #[test]
2096 fn each_os_has_one_pool_label_and_the_namespace_is_reserved() {
2097 assert_eq!(pool_label(Os::Windows).as_str(), "rm-pool-windows");
2098 assert_eq!(pool_label(Os::Linux).as_str(), "rm-pool-linux");
2099 assert_eq!(pool_label(Os::MacOs).as_str(), "rm-pool-macos");
2100 assert!(is_pool_label("RM-POOL-linux"));
2101 assert!(!is_pool_label("rm-home-linux-x64"));
2102 assert!(!is_pool_label("rm-pool"));
2103 assert!(!is_pool_label("rm-pool-linux-x64"));
2105 assert!(in_pool_namespace("rm-pool-linux-x64"));
2106 assert!(check_pool_label(&label("rm-pool-linux"), Os::Linux).is_ok());
2107 assert!(check_pool_label(&label("self-hosted"), Os::Linux).is_ok());
2108 assert!(matches!(
2109 check_pool_label(&label("rm-pool-windows"), Os::Linux),
2110 Err(PolicyError::PoolLabelMismatch { expected, .. }) if expected.as_str() == "rm-pool-linux"
2111 ));
2112 assert!(check_pool_label(&label("rm-pool-linux-heavy"), Os::Linux).is_err());
2113
2114 let mut labels = host_labels("home");
2115 assert_eq!(labels.pool_labels().next(), None);
2116 labels.add(label("linux"));
2117 labels.add(label("rm-pool-windows"));
2118 assert_eq!(
2119 labels.pool_labels().collect::<Vec<_>>(),
2120 [&label("rm-pool-windows")]
2121 );
2122 }
2123
2124 #[test]
2125 fn the_any_pool_is_a_pool_every_os_may_carry_beside_its_own() {
2126 assert_eq!(any_pool_label().as_str(), ANY_POOL_LABEL);
2127 assert!(is_pool_label("rm-pool-any") && is_pool_label("RM-Pool-Any"));
2128 assert!(is_any_pool_label("RM-POOL-ANY"));
2129 assert!(!is_any_pool_label("rm-pool-linux"));
2130 assert!(!is_pool_label("rm-pool-any-heavy"));
2131 for os in Os::ALL {
2132 assert!(check_pool_label(&label("rm-pool-any"), os).is_ok());
2133 }
2134 assert!(check_pool_label(&label("rm-pool-any-heavy"), Os::Linux).is_err());
2135
2136 let mut labels = host_labels("home");
2137 labels.add(label("rm-pool-linux"));
2138 labels.add(label("rm-pool-any"));
2139 labels.add(label("heavy"));
2140 assert_eq!(labels.pool_labels().count(), 2);
2141 }
2142
2143 fn workspace_root() -> LocalAbsolutePath {
2148 LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
2149 .expect("a valid persistent root")
2150 }
2151
2152 fn repository_policy() -> ScalePolicy {
2153 autoscale_policy(
2154 ScaleTarget::repository("acme/api").unwrap(),
2155 HostId::from_u128(1),
2156 4,
2157 )
2158 }
2159
2160 fn organization_policy() -> ScalePolicy {
2161 autoscale_policy(
2162 ScaleTarget::organization("acme").unwrap(),
2163 HostId::from_u128(1),
2164 4,
2165 )
2166 }
2167
2168 #[test]
2169 fn every_constructor_produces_an_ephemeral_workspace() {
2170 for policy in [repository_policy(), organization_policy()] {
2173 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
2174 assert!(!policy.workspace_policy().retains_job_workspace());
2175 assert_eq!(
2176 policy.to_persisted().workspace_kind,
2177 WorkspaceKind::Ephemeral
2178 );
2179 assert_eq!(policy.to_persisted().workspace_root, None);
2180 }
2181
2182 let monitor_only = ScalePolicy::new(
2183 PolicyId::from_u128(2),
2184 ScaleTarget::repository("acme/api").unwrap(),
2185 42,
2186 HostId::from_u128(1),
2187 PolicyMode::MonitorOnly,
2188 CachePolicy::default(),
2189 );
2190 assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
2191 }
2192
2193 #[test]
2194 fn a_repository_policy_can_opt_into_a_persistent_workspace() {
2195 let mut policy = repository_policy();
2196 let before = policy.revision();
2197
2198 policy
2199 .set_workspace_policy(
2200 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2201 .expect("a repository may be persistent"),
2202 )
2203 .expect("a repository policy accepts persistence");
2204
2205 assert!(policy.workspace_policy().is_persistent());
2206 assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
2207 assert_eq!(
2208 policy.revision(),
2209 before + 1,
2210 "a workspace change must bump the optimistic token, or `a2`'s guard \
2211 cannot refuse a write built from a stale read"
2212 );
2213
2214 let unchanged = policy.revision();
2217 policy
2218 .set_workspace_policy(policy.workspace_policy().clone())
2219 .expect("re-setting the same policy is accepted");
2220 assert_eq!(policy.revision(), unchanged);
2221 }
2222
2223 #[test]
2224 fn an_organization_policy_cannot_be_made_persistent() {
2225 let mut policy = organization_policy();
2228 let before = policy.revision();
2229
2230 assert_eq!(
2231 policy.set_workspace_policy(WorkspacePolicy::Persistent {
2232 root: workspace_root()
2233 }),
2234 Err(PolicyError::Workspace(
2235 WorkspaceError::PersistentRequiresRepositoryScope
2236 ))
2237 );
2238 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
2239 assert_eq!(
2240 policy.revision(),
2241 before,
2242 "a refused write consumes nothing"
2243 );
2244
2245 assert_eq!(
2248 WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
2249 Err(WorkspaceError::PersistentRequiresRepositoryScope)
2250 );
2251 }
2252
2253 #[test]
2254 fn a_workspace_policy_round_trips_through_the_persisted_struct() {
2255 let mut policy = repository_policy();
2256 policy
2257 .set_workspace_policy(
2258 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2259 .expect("a repository may be persistent"),
2260 )
2261 .expect("a repository policy accepts persistence");
2262
2263 let restored = ScalePolicy::from_persisted(policy.to_persisted())
2264 .expect("a policy this crate wrote must load");
2265 assert_eq!(restored, policy);
2266 assert_eq!(restored.workspace_policy(), policy.workspace_policy());
2267
2268 let ephemeral = repository_policy();
2269 assert_eq!(
2270 ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
2271 ephemeral
2272 );
2273 }
2274
2275 #[test]
2276 fn an_organization_row_claiming_persistence_fails_closed_on_load() {
2277 let mut fields = organization_policy().to_persisted();
2278 fields.workspace_kind = WorkspaceKind::Persistent;
2279 fields.workspace_root = Some(workspace_root());
2280
2281 assert_eq!(
2282 ScalePolicy::from_persisted(fields),
2283 Err(PolicyError::Workspace(
2284 WorkspaceError::PersistentRequiresRepositoryScope
2285 ))
2286 );
2287 }
2288
2289 #[test]
2290 fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
2291 let base = repository_policy().to_persisted();
2292
2293 let mut without_root = base.clone();
2294 without_root.workspace_kind = WorkspaceKind::Persistent;
2295 assert_eq!(
2296 ScalePolicy::from_persisted(without_root),
2297 Err(PolicyError::Workspace(
2298 WorkspaceError::PersistentWithoutRoot
2299 ))
2300 );
2301
2302 let mut stale_root = base;
2303 stale_root.workspace_root = Some(workspace_root());
2304 assert!(matches!(
2305 ScalePolicy::from_persisted(stale_root),
2306 Err(PolicyError::Workspace(
2307 WorkspaceError::EphemeralWithRoot { .. }
2308 ))
2309 ));
2310 }
2311
2312 #[test]
2313 fn workspace_retention_is_not_the_runner_package_cache_policy() {
2314 let mut policy = repository_policy();
2318 policy.cache_policy = CachePolicy::DiscardRunnerPackage;
2319 policy
2320 .set_workspace_policy(
2321 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2322 .expect("a repository may be persistent"),
2323 )
2324 .expect("a repository policy accepts persistence");
2325
2326 assert!(policy.workspace_policy().retains_job_workspace());
2327 assert!(!policy.cache_policy.retains_runner_package());
2328 assert!(!policy.cache_policy.retains_job_workspace());
2331 }
2332
2333 #[test]
2338 fn the_derived_label_has_the_shape_the_architecture_gives() {
2339 let labels =
2341 RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2342 assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
2343 assert_eq!(labels.count().get(), 1);
2344 }
2345
2346 #[test]
2347 fn the_derived_label_is_host_scoped_by_construction() {
2348 let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2352 let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
2353
2354 assert_ne!(
2355 a.host_label(),
2356 b.host_label(),
2357 "two hosts must not derive the same routing label; with no job \
2358 reservation, a shared label means both hosts start a runner for one job"
2359 );
2360 assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
2361 assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
2362
2363 let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
2365 assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
2366 assert_ne!(a.host_label(), mac.host_label());
2367 }
2368
2369 #[test]
2370 fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
2371 let labels =
2375 RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
2376 assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
2377 }
2378
2379 #[test]
2380 fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
2381 let mut labels = host_labels("home");
2382 let derived = labels.host_label().clone();
2383
2384 assert!(labels.add(label("gpu")));
2385 assert!(labels.add(label("self-hosted")));
2386 assert!(
2387 !labels.add(label("GPU")),
2388 "adding a label that differs only in case must be a no-op, not a duplicate"
2389 );
2390 assert_eq!(labels.count().get(), 3);
2391
2392 assert!(labels.remove(&label("gpu")).unwrap());
2393 assert_eq!(labels.count().get(), 2);
2394 assert!(
2395 !labels.remove(&label("never-added")).unwrap(),
2396 "removing an absent optional label is a no-op, not an error"
2397 );
2398
2399 assert!(
2401 matches!(
2402 labels.remove(&derived),
2403 Err(PolicyError::HostLabelNotRemovable { .. })
2404 ),
2405 "the derived host label must not be removable; it is the only thing \
2406 keeping two hosts from serving each other's jobs"
2407 );
2408 assert!(labels.contains(&derived));
2409
2410 assert!(matches!(
2412 labels.remove(&label("RM-HOME-WIN-X64")),
2413 Err(PolicyError::HostLabelNotRemovable { .. })
2414 ));
2415 }
2416
2417 #[test]
2418 fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
2419 let mut labels = host_labels("home");
2420 let derived = labels.host_label().clone();
2421 assert!(!labels.add(derived));
2422 assert_eq!(labels.count().get(), 1);
2423
2424 let rebuilt = RoutingLabels::from_parts(
2426 labels.host_label().clone(),
2427 vec![labels.host_label().clone(), label("gpu")],
2428 );
2429 assert_eq!(rebuilt.count().get(), 2);
2430 assert_eq!(
2431 rebuilt.as_registration_labels(),
2432 vec!["rm-home-win-x64", "gpu"]
2433 );
2434 }
2435
2436 #[test]
2437 fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
2438 let mut labels = host_labels("home");
2443 labels.add(label("gpu"));
2444 assert_eq!(
2445 labels.as_registration_labels(),
2446 vec!["rm-home-win-x64", "gpu"]
2447 );
2448 assert!(!labels.contains(&label("self-hosted")));
2449 }
2450
2451 #[test]
2452 fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
2453 let mut labels = host_labels("home");
2454 labels.add(label("gpu"));
2455 let json = serde_json::to_string(&labels).unwrap();
2456 let back: RoutingLabels = serde_json::from_str(&json).unwrap();
2457 assert_eq!(labels, back);
2458 assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
2459 }
2460
2461 #[test]
2462 fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
2463 let labels = host_labels("home");
2465 let non_empty = labels.to_non_empty();
2466 assert_eq!(non_empty.count().get(), 1);
2467 assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
2468 }
2469
2470 struct Row {
2477 name: &'static str,
2478 runs_on: RunsOn,
2479 expect: Expect,
2480 }
2481
2482 #[derive(Debug, PartialEq, Eq)]
2483 enum Expect {
2484 Match,
2485 NoMatch,
2486 Unresolvable,
2487 }
2488
2489 fn classify(m: &RunsOnMatch) -> Expect {
2490 match m {
2491 RunsOnMatch::Match { .. } => Expect::Match,
2492 RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
2493 RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
2494 }
2495 }
2496
2497 fn table_policy() -> RoutingLabels {
2498 let mut labels = host_labels("home");
2500 labels.add(label("self-hosted"));
2501 labels.add(label("gpu"));
2502 labels
2503 }
2504
2505 fn table() -> Vec<Row> {
2506 vec![
2507 Row {
2509 name: "string: the derived host label",
2510 runs_on: RunsOn::Single("rm-home-win-x64".into()),
2511 expect: Expect::Match,
2512 },
2513 Row {
2514 name: "string: the derived host label in the wrong case",
2515 runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
2516 expect: Expect::Match,
2517 },
2518 Row {
2519 name: "string: an optional label alone",
2520 runs_on: RunsOn::Single("gpu".into()),
2521 expect: Expect::Match,
2522 },
2523 Row {
2524 name: "string: another host's label",
2525 runs_on: RunsOn::Single("rm-office-win-x64".into()),
2526 expect: Expect::NoMatch,
2527 },
2528 Row {
2529 name: "string: a GitHub-hosted runner label",
2530 runs_on: RunsOn::Single("ubuntu-latest".into()),
2531 expect: Expect::NoMatch,
2532 },
2533 Row {
2535 name: "array: a strict subset of the policy's labels",
2536 runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
2537 expect: Expect::Match,
2538 },
2539 Row {
2540 name: "array: the whole set, out of order and mixed case",
2541 runs_on: RunsOn::Many(vec![
2542 "GPU".into(),
2543 "Rm-Home-Win-X64".into(),
2544 "Self-Hosted".into(),
2545 ]),
2546 expect: Expect::Match,
2547 },
2548 Row {
2549 name: "array: one label the policy does not carry",
2550 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
2551 expect: Expect::NoMatch,
2552 },
2553 Row {
2554 name: "array: an empty array names no labels",
2555 runs_on: RunsOn::Many(vec![]),
2556 expect: Expect::Unresolvable,
2557 },
2558 Row {
2560 name: "map: labels only",
2561 runs_on: RunsOn::Grouped {
2562 group: None,
2563 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
2564 },
2565 expect: Expect::Match,
2566 },
2567 Row {
2568 name: "map: a group plus labels the policy carries",
2569 runs_on: RunsOn::Grouped {
2570 group: Some("Default".into()),
2571 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
2572 },
2573 expect: Expect::Match,
2574 },
2575 Row {
2576 name: "map: labels as a scalar",
2577 runs_on: RunsOn::Grouped {
2578 group: Some("Default".into()),
2579 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2580 },
2581 expect: Expect::Match,
2582 },
2583 Row {
2584 name: "map: a group plus a label the policy does not carry",
2585 runs_on: RunsOn::Grouped {
2586 group: Some("Default".into()),
2587 labels: RunsOnLabels::Many(vec!["macos".into()]),
2588 },
2589 expect: Expect::NoMatch,
2590 },
2591 Row {
2592 name: "map: a group with no labels constrains something we cannot read",
2593 runs_on: RunsOn::Grouped {
2594 group: Some("Default".into()),
2595 labels: RunsOnLabels::Many(vec![]),
2596 },
2597 expect: Expect::Unresolvable,
2598 },
2599 Row {
2601 name: "expression: the whole value",
2602 runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
2603 expect: Expect::Unresolvable,
2604 },
2605 Row {
2606 name: "expression: one element of an array",
2607 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
2608 expect: Expect::Unresolvable,
2609 },
2610 Row {
2611 name: "expression: inside the map form",
2612 runs_on: RunsOn::Grouped {
2613 group: None,
2614 labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
2615 },
2616 expect: Expect::Unresolvable,
2617 },
2618 Row {
2619 name: "not a usable label at all",
2620 runs_on: RunsOn::Single("rm-home,win-x64".into()),
2621 expect: Expect::Unresolvable,
2622 },
2623 ]
2624 }
2625
2626 #[test]
2627 fn runs_on_matching_covers_every_documented_form() {
2628 let policy = table_policy();
2629 for row in table() {
2630 let got = policy.matches(&row.runs_on);
2631 assert_eq!(
2632 classify(&got),
2633 row.expect,
2634 "row {:?}: {:?} produced {got:?}",
2635 row.name,
2636 row.runs_on
2637 );
2638 }
2639 }
2640
2641 #[test]
2642 fn github_job_labels_are_a_case_insensitive_subset_without_a_selector() {
2643 let labels = RoutingLabels::from_parts(
2644 label("rm-macmini-osx-arm64"),
2645 [
2646 label("arm64"),
2647 label("macos"),
2648 label("self-hosted"),
2649 label("extra"),
2650 ],
2651 );
2652 assert!(
2653 labels
2654 .matches(&RunsOn::Many(vec![
2655 "self-hosted".into(),
2656 "macOS".into(),
2657 "ARM64".into(),
2658 ]))
2659 .is_match()
2660 );
2661 for missing in ["X64", "Windows", "Linux", "gpu", "rm-other-osx-arm64"] {
2662 let result = labels.matches(&RunsOn::Many(vec![
2663 "self-hosted".into(),
2664 "macOS".into(),
2665 "ARM64".into(),
2666 missing.into(),
2667 ]));
2668 assert_eq!(
2669 result,
2670 RunsOnMatch::NoMatch {
2671 missing: vec![label(missing)]
2672 }
2673 );
2674 }
2675 }
2676
2677 #[test]
2678 fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2679 let without = host_labels("home");
2683 assert!(
2684 !without
2685 .matches(&RunsOn::Single("self-hosted".into()))
2686 .is_match(),
2687 "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2688 );
2689
2690 let mut with = host_labels("home");
2691 with.add(label("self-hosted"));
2692 assert!(
2693 with.matches(&RunsOn::Single("self-hosted".into()))
2694 .is_match()
2695 );
2696 assert!(
2697 with.matches(&RunsOn::Many(vec![
2698 "rm-home-win-x64".into(),
2699 "self-hosted".into()
2700 ]))
2701 .is_match()
2702 );
2703 }
2704
2705 #[test]
2706 fn a_no_match_names_the_labels_that_were_missing() {
2707 let policy = host_labels("home");
2708 let got = policy.matches(&RunsOn::Many(vec![
2709 "rm-home-win-x64".into(),
2710 "self-hosted".into(),
2711 "GPU".into(),
2712 ]));
2713 match got {
2714 RunsOnMatch::NoMatch { missing } => {
2715 assert_eq!(
2716 missing,
2717 vec![label("self-hosted"), label("gpu")],
2718 "the operator needs to know which labels to add"
2719 );
2720 }
2721 other => panic!("expected NoMatch, got {other:?}"),
2722 }
2723 }
2724
2725 #[test]
2726 fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2727 let policy = host_labels("home");
2728 let got = policy.matches(&RunsOn::Grouped {
2729 group: Some("Default".into()),
2730 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2731 });
2732 assert_eq!(
2733 got,
2734 RunsOnMatch::Match {
2735 runner_group: Some("Default".into())
2736 },
2737 "a policy has no runner-group field, so the domain cannot evaluate \
2738 `group:`; returning it lets `c4`, which can, do so without re-parsing"
2739 );
2740 }
2741
2742 #[test]
2743 fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2744 let policy = host_labels("home");
2745
2746 let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2747 assert!(matches!(
2748 expr,
2749 RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2750 ));
2751
2752 let group = policy.matches(&RunsOn::Grouped {
2753 group: Some("g".into()),
2754 labels: RunsOnLabels::Many(vec![]),
2755 });
2756 assert!(matches!(
2757 group,
2758 RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2759 ));
2760
2761 let none = policy.matches(&RunsOn::Many(vec![]));
2762 assert!(matches!(
2763 none,
2764 RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2765 ));
2766
2767 let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2768 assert!(matches!(
2769 invalid,
2770 RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2771 ));
2772 }
2773
2774 #[test]
2775 fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2776 let policy = table_policy();
2780 let jobs = vec![
2781 RunsOn::Single("rm-home-win-x64".into()), RunsOn::Single("ubuntu-latest".into()), RunsOn::Single("${{ matrix.runner }}".into()), RunsOn::Single("${{ inputs.pool }}".into()), ];
2786 let tally = policy.tally(&jobs);
2787
2788 assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2789 assert_eq!(tally.not_matched, 1);
2790 assert_eq!(
2791 tally.unresolvable.len(),
2792 2,
2793 "and it must not vanish either -- `g2` shows these to the operator"
2794 );
2795 assert_eq!(
2796 tally.total_seen(),
2797 jobs.len() as u32,
2798 "every job seen is accounted for in exactly one bucket"
2799 );
2800 }
2801
2802 #[test]
2803 fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2804 let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2805 assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2806
2807 let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2808 assert_eq!(
2809 many,
2810 RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2811 );
2812
2813 let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2814 assert_eq!(
2815 grouped,
2816 RunsOn::Grouped {
2817 group: Some("g".into()),
2818 labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2819 }
2820 );
2821
2822 let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2823 assert_eq!(
2824 scalar_labels,
2825 RunsOn::Grouped {
2826 group: None,
2827 labels: RunsOnLabels::One("a".into()),
2828 }
2829 );
2830
2831 let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2832 assert_eq!(
2833 group_only,
2834 RunsOn::Grouped {
2835 group: Some("g".into()),
2836 labels: RunsOnLabels::Many(vec![]),
2837 }
2838 );
2839
2840 assert_eq!(
2842 RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2843 RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2844 );
2845 }
2846
2847 #[test]
2852 fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2853 let labels = host_labels("home");
2854
2855 assert!(matches!(
2858 PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2859 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2860 ));
2861 assert!(matches!(
2862 PolicyMode::from_persisted(None, 0, Some(nz(1))),
2863 Err(PolicyError::AutoscaleWithoutRoutingLabels)
2864 ));
2865
2866 assert!(
2868 PolicyMode::from_persisted(None, 0, None)
2869 .unwrap()
2870 .is_monitor_only()
2871 );
2872 assert!(
2873 PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2874 .unwrap()
2875 .is_autoscale()
2876 );
2877 }
2878
2879 #[test]
2880 fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2881 let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2888 assert!(autoscale.routing_labels().is_some());
2889 assert!(autoscale.max_capacity().is_some());
2890
2891 let monitor = PolicyMode::monitor_only();
2892 assert!(monitor.routing_labels().is_none());
2893 assert!(monitor.max_capacity().is_none());
2894 assert_eq!(monitor.min_capacity(), 0);
2895 }
2896
2897 #[test]
2898 fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2899 let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2902 assert!(matches!(
2903 err,
2904 PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2905 ));
2906 assert!(
2907 err.to_string().contains("MonitorOnly"),
2908 "the message must name the shape rule, got: {err}"
2909 );
2910 }
2911
2912 #[test]
2913 fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2914 assert!(matches!(
2920 PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2921 Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2922 ));
2923 assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2924 assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2925
2926 let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2928 assert!(matches!(
2929 cfg.set_max_capacity(nz(1)),
2930 Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2931 ));
2932 assert_eq!(
2933 cfg.max_capacity().get(),
2934 4,
2935 "a refused write changes nothing"
2936 );
2937 }
2938
2939 #[test]
2940 fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2941 for mode in [
2942 PolicyMode::monitor_only(),
2943 PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2944 ] {
2945 let json = serde_json::to_string(&mode).unwrap();
2946 let back: PolicyMode = serde_json::from_str(&json).unwrap();
2947 assert_eq!(mode, back, "{json} did not round-trip");
2948 }
2949
2950 let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2953 let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2954 assert!(
2955 err.to_string().contains("min_capacity"),
2956 "expected the shape error to survive into serde's message, got: {err}"
2957 );
2958 }
2959
2960 #[test]
2961 fn a_policy_round_trips_through_its_persisted_form() {
2962 let mut policy = autoscale_policy(
2970 ScaleTarget::repository("o/r").unwrap(),
2971 HostId::from_u128(7),
2972 3,
2973 );
2974 policy.add_routing_label(label("gpu")).unwrap();
2975 policy.activate().unwrap();
2981 assert_eq!(policy.state(), PolicyState::Active);
2982 assert!(policy.enabled());
2983
2984 let stored = policy.to_persisted();
2985 assert_ne!(
2986 stored.installation_id, stored.revision,
2987 "the fixture must distinguish the two u64 columns, or transposing \
2988 them is unobservable and this test proves nothing"
2989 );
2990 assert_eq!(stored.installation_id, 42);
2991 assert_eq!(stored.revision, policy.revision());
2992
2993 let restored =
2994 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2995 assert_eq!(restored, policy);
2996 assert_eq!(restored.installation_id, 42);
2997 assert_eq!(restored.revision(), policy.revision());
2998 assert_eq!(restored.state(), PolicyState::Active);
2999 assert!(restored.enabled());
3000 assert_eq!(
3001 restored.routing_labels().unwrap().count().get(),
3002 2,
3003 "the optional label survives alongside the host label"
3004 );
3005 }
3006
3007 #[test]
3008 fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
3009 let mut policy = ScalePolicy::new(
3017 PolicyId::from_u128(2),
3018 ScaleTarget::organization("acme").unwrap(),
3019 9,
3020 HostId::from_u128(7),
3021 PolicyMode::monitor_only(),
3022 CachePolicy::default(),
3023 );
3024 policy.activate().unwrap();
3025
3026 let stored = policy.to_persisted();
3027 assert!(stored.routing_labels.is_none());
3028 assert_eq!(stored.min_capacity, 0);
3029 assert!(stored.max_capacity.is_none());
3030 assert_ne!(stored.installation_id, stored.revision);
3031
3032 let restored =
3033 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
3034 assert_eq!(restored, policy);
3035 assert!(
3036 restored.mode().is_monitor_only(),
3037 "the mode is inferred back from the three columns, not stored"
3038 );
3039 assert!(!restored.owns_runners());
3040 assert_eq!(restored.installation_id, 9);
3041 assert_eq!(restored.revision(), 1);
3042 }
3043
3044 fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
3064 use PolicyState::*;
3065 let mut edges = vec![
3066 (Pending, Active),
3067 (Pending, RepairRequired),
3068 (Active, Draining),
3069 (Draining, Disabled),
3070 (Disabled, Pending),
3071 (AuthenticationFailed, Pending),
3073 ];
3074 for from in PolicyState::ALL {
3076 if from != AuthenticationFailed {
3077 edges.push((from, AuthenticationFailed));
3078 }
3079 }
3080 edges
3081 }
3082
3083 #[test]
3084 fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
3085 let expected = diagram_edges();
3088 assert_eq!(
3089 expected.len(),
3090 11,
3091 "the transcription itself changed; check it against the diagram"
3092 );
3093
3094 let mut legal_seen = 0usize;
3095 let mut illegal_seen = 0usize;
3096
3097 for from in PolicyState::ALL {
3098 for to in PolicyState::ALL {
3099 let expected_legal = expected.contains(&(from, to));
3100 let mut policy = autoscale_policy(
3101 ScaleTarget::repository("o/r").unwrap(),
3102 HostId::from_u128(7),
3103 1,
3104 );
3105 policy.state = from;
3109
3110 let result = policy.transition_to(to);
3111 if expected_legal {
3112 legal_seen += 1;
3113 assert!(
3114 result.is_ok(),
3115 "{from} -> {to} is in the diagram and must be accepted"
3116 );
3117 assert_eq!(policy.state(), to);
3118 } else {
3119 illegal_seen += 1;
3120 assert!(
3121 matches!(result, Err(PolicyError::IllegalTransition { .. })),
3122 "{from} -> {to} is not in the diagram and must be rejected"
3123 );
3124 assert_eq!(policy.state(), from, "a refused transition changes nothing");
3125 }
3126 }
3127 }
3128
3129 assert_eq!(legal_seen, 11);
3130 assert_eq!(illegal_seen, 36 - 11);
3131
3132 let mut published = PolicyState::LEGAL.to_vec();
3135 let mut transcribed = expected;
3136 published.sort_unstable();
3137 transcribed.sort_unstable();
3138 assert_eq!(published, transcribed);
3139 }
3140
3141 #[test]
3142 fn a_policy_state_cannot_transition_to_itself() {
3143 for state in PolicyState::ALL {
3144 assert!(
3145 !state.can_transition_to(state),
3146 "{state} -> {state} is not an edge in the diagram; treating it as \
3147 one would let a repeated authentication failure look like progress"
3148 );
3149 }
3150 }
3151
3152 #[test]
3153 fn the_documented_happy_path_walks_pending_to_disabled() {
3154 let mut policy = autoscale_policy(
3155 ScaleTarget::repository("o/r").unwrap(),
3156 HostId::from_u128(7),
3157 2,
3158 );
3159
3160 assert_eq!(policy.state(), PolicyState::Pending);
3162 assert!(!policy.enabled());
3163 assert!(!policy.may_start_runners());
3164
3165 policy.activate().unwrap();
3166 assert_eq!(policy.state(), PolicyState::Active);
3167 assert!(policy.enabled());
3168 assert!(policy.may_start_runners());
3169
3170 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
3172 assert_eq!(
3173 policy.drain_completed(1).unwrap(),
3174 PolicyState::Draining,
3175 "a policy with a runner still in flight stays draining"
3176 );
3177 assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
3178 }
3179
3180 #[test]
3181 fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
3182 let mut policy = autoscale_policy(
3185 ScaleTarget::repository("o/r").unwrap(),
3186 HostId::from_u128(7),
3187 5,
3188 );
3189 policy.activate().unwrap();
3190
3191 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
3194 assert_eq!(policy.tally(&jobs).demand(), 4);
3195
3196 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
3197 assert!(!policy.enabled());
3198 assert!(
3199 !policy.may_start_runners(),
3200 "a draining policy must not be the reason a new runner starts, even \
3201 with four jobs queued for its labels"
3202 );
3203 assert_eq!(
3204 policy.tally(&jobs).demand(),
3205 4,
3206 "queued demand stays visible while draining (flow 5.2)"
3207 );
3208 }
3209
3210 #[test]
3211 fn re_authentication_is_the_only_way_out_of_authentication_failed() {
3212 for from in [
3213 PolicyState::Pending,
3214 PolicyState::Active,
3215 PolicyState::Draining,
3216 PolicyState::Disabled,
3217 PolicyState::RepairRequired,
3218 ] {
3219 let mut policy = autoscale_policy(
3220 ScaleTarget::repository("o/r").unwrap(),
3221 HostId::from_u128(7),
3222 1,
3223 );
3224 policy.state = from;
3225 policy.authentication_failed().unwrap();
3226 assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
3227
3228 assert!(matches!(
3230 policy.authentication_failed(),
3231 Err(PolicyError::IllegalTransition { .. })
3232 ));
3233
3234 policy.reauthenticated().unwrap();
3235 assert_eq!(policy.state(), PolicyState::Pending);
3236 }
3237 }
3238
3239 #[test]
3240 fn mutant_disabling_revoked_eligibility_gate_is_detected() {
3241 let mut policy = autoscale_policy(
3242 ScaleTarget::repository("o/r").unwrap(),
3243 HostId::from_u128(7),
3244 1,
3245 );
3246 policy.activate().unwrap();
3247 policy.authentication_failed().unwrap();
3248 assert!(!policy.may_start_runners());
3249
3250 let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
3254 assert!(
3255 mutant_may_start,
3256 "omitting revoked state must make the eligibility gate red"
3257 );
3258 }
3259
3260 #[test]
3261 fn a_refused_transition_leaves_the_revision_untouched() {
3262 let mut policy = autoscale_policy(
3263 ScaleTarget::repository("o/r").unwrap(),
3264 HostId::from_u128(7),
3265 1,
3266 );
3267 assert_eq!(policy.revision(), 0);
3268 policy.activate().unwrap();
3269 assert_eq!(policy.revision(), 1);
3270
3271 assert!(policy.activate().is_err());
3272 assert_eq!(
3273 policy.revision(),
3274 1,
3275 "a rejected write must not bump the optimistic-concurrency token, or \
3276 `b2`'s stale-revision check would reject the next honest write"
3277 );
3278 }
3279
3280 #[test]
3285 fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
3286 let mut policy = ScalePolicy::new(
3287 PolicyId::from_u128(2),
3288 ScaleTarget::organization("acme").unwrap(),
3289 9,
3290 HostId::from_u128(7),
3291 PolicyMode::monitor_only(),
3292 CachePolicy::default(),
3293 );
3294 policy.activate().unwrap();
3295
3296 assert!(!policy.owns_runners());
3297 assert!(
3298 !policy.may_start_runners(),
3299 "an active, enabled monitor-only policy still starts nothing (D19)"
3300 );
3301 assert!(policy.routing_labels().is_none());
3302 assert!(policy.max_capacity().is_none());
3303
3304 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
3306 assert_eq!(
3307 policy.tally(&jobs).demand(),
3308 0,
3309 "a monitor-only policy has no demand at all, rather than demand that \
3310 is computed and then ignored"
3311 );
3312
3313 assert!(matches!(
3316 policy.add_routing_label(label("gpu")),
3317 Err(PolicyError::NotAutoscale)
3318 ));
3319 assert!(matches!(
3320 policy.remove_routing_label(&label("gpu")),
3321 Err(PolicyError::NotAutoscale)
3322 ));
3323 assert!(matches!(
3331 PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
3332 Err(PolicyError::AutoscaleWithoutMaxCapacity)
3333 ));
3334 }
3335
3336 #[test]
3337 fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
3338 let mut policy = ScalePolicy::new(
3341 PolicyId::from_u128(2),
3342 ScaleTarget::repository("o/r").unwrap(),
3343 9,
3344 HostId::from_u128(7),
3345 PolicyMode::monitor_only(),
3346 CachePolicy::default(),
3347 );
3348 assert!(policy.routing_labels().is_none());
3349
3350 policy
3351 .promote_to_autoscale(host_labels("home"), 0, nz(3))
3352 .unwrap();
3353
3354 assert!(policy.owns_runners());
3355 assert_eq!(
3356 policy.routing_labels().unwrap().host_label().as_str(),
3357 "rm-home-win-x64"
3358 );
3359 assert_eq!(policy.max_capacity().unwrap().get(), 3);
3360
3361 assert!(matches!(
3363 policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
3364 Err(PolicyError::AlreadyAutoscale)
3365 ));
3366 policy.set_max_capacity(nz(4)).unwrap();
3367 assert_eq!(policy.max_capacity().unwrap().get(), 4);
3368 }
3369
3370 #[test]
3371 fn a_named_monitor_can_only_be_promoted_with_its_derived_selector() {
3372 let mut named = ScalePolicy::new_named_monitor(
3373 PolicyId::from_u128(3),
3374 ScaleTarget::repository("o/r").unwrap(),
3375 9,
3376 HostId::from_u128(7),
3377 HostLabel::new("home").unwrap(),
3378 ProfileName::new("Py-Isolated").unwrap(),
3379 CachePolicy::default(),
3380 )
3381 .unwrap();
3382 assert!(matches!(
3383 named.promote_to_autoscale(host_labels("other"), 0, nz(3)),
3384 Err(PolicyError::NamedProfileNeedsDerivedSelector)
3385 ));
3386 assert!(named.routing_labels().is_none());
3387 named
3388 .promote_named_to_autoscale(Os::Windows, Arch::X64, 0, nz(3))
3389 .unwrap();
3390 assert_eq!(
3391 named.routing_labels().unwrap().host_label().as_str(),
3392 "rm-home-win-x64-py-isolated"
3393 );
3394 }
3395
3396 #[test]
3397 fn a_policy_is_owned_by_exactly_one_host() {
3398 let mine = HostId::from_u128(7);
3399 let theirs = HostId::from_u128(8);
3400 let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
3401 assert!(policy.is_owned_by(mine));
3402 assert!(!policy.is_owned_by(theirs));
3403 }
3404
3405 #[test]
3406 fn an_overridden_host_label_is_detectable_without_being_rejected() {
3407 assert!(host_labels("home").is_derived_shape());
3411 assert!(
3412 RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
3413 .is_derived_shape(),
3414 "a host label containing `-` still derives a four-plus-segment name"
3415 );
3416 assert_eq!(
3422 host_labels("home--pc").host_label().as_str(),
3423 "rm-home--pc-win-x64"
3424 );
3425 assert!(
3426 host_labels("home--pc").is_derived_shape(),
3427 "consecutive dashes are legal inside a host label; the empty middle \
3428 segment they produce is not evidence of an override"
3429 );
3430
3431 for raw in [
3435 "self-hosted",
3436 "ubuntu-latest",
3437 "rm-home-win",
3438 "rm-home-win-x64-extra",
3439 ] {
3440 assert!(
3441 !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
3442 "{raw:?} is not the derived shape"
3443 );
3444 }
3445 assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
3447 assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
3448 assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
3449
3450 let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
3452 overridden.add(label("rm-home-win-x64"));
3453 assert!(!overridden.is_derived_shape());
3454 }
3455
3456 #[test]
3457 fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
3458 let mut policy = autoscale_policy(
3462 ScaleTarget::repository("o/r").unwrap(),
3463 HostId::from_u128(7),
3464 3,
3465 );
3466
3467 assert!(policy.can_activate());
3468 assert!(
3469 !policy.can_request_disable(),
3470 "a pending policy cannot drain; it is also already not enabled, \
3471 which is what makes the command a no-op rather than a failure"
3472 );
3473 assert!(matches!(
3474 policy.request_disable(),
3475 Err(PolicyError::IllegalTransition {
3476 from: PolicyState::Pending,
3477 to: PolicyState::Draining,
3478 })
3479 ));
3480
3481 policy.activate().unwrap();
3482 assert!(!policy.can_activate(), "already active");
3483 assert!(policy.can_request_disable());
3484 assert!(matches!(
3485 policy.activate(),
3486 Err(PolicyError::IllegalTransition { .. })
3487 ));
3488
3489 policy.request_disable().unwrap();
3490 assert!(!policy.can_request_disable(), "already draining");
3491 assert!(!policy.enabled());
3492 }
3493
3494 fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
3518 let host = HostId::from_u128(7);
3519 let mut trace = Vec::new();
3520
3521 let mut policy = autoscale_policy(target.clone(), host, 3);
3522 trace.push(format!("owns_runners={}", policy.owns_runners()));
3523 trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
3524 trace.push(format!(
3525 "owned_by_other={}",
3526 policy.is_owned_by(HostId::from_u128(8))
3527 ));
3528 trace.push(format!("initial_state={}", policy.state()));
3529 trace.push(format!("initial_enabled={}", policy.enabled()));
3530 trace.push(format!(
3531 "may_start_initially={}",
3532 policy.may_start_runners()
3533 ));
3534 trace.push(format!("labels={}", policy.routing_labels().unwrap()));
3535 trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
3536 trace.push(format!("min_capacity={}", policy.min_capacity()));
3537
3538 policy.activate().unwrap();
3540 trace.push(format!("after_activate={}", policy.state()));
3541 trace.push(format!("may_start_active={}", policy.may_start_runners()));
3542
3543 let jobs = vec![
3545 RunsOn::Single("rm-home-win-x64".into()),
3546 RunsOn::Single("ubuntu-latest".into()),
3547 RunsOn::Single("${{ matrix.os }}".into()),
3548 ];
3549 let tally = policy.tally(&jobs);
3550 trace.push(format!(
3551 "demand={} not_matched={} unresolvable={}",
3552 tally.demand(),
3553 tally.not_matched,
3554 tally.unresolvable.len()
3555 ));
3556
3557 let host_record = crate::model::Host::new(
3561 host,
3562 "home-pc",
3563 Os::Windows,
3564 Arch::X64,
3565 nz(4),
3566 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3567 )
3568 .unwrap();
3569 let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
3570 let allocation = allocator.allocate(&policy, 3);
3571 trace.push(format!(
3572 "alloc demand={} desired={} active_owned={} headroom_before={} \
3573 to_start={} limiting={}",
3574 allocation.demand,
3575 allocation.desired,
3576 allocation.active_owned,
3577 allocation.headroom_before,
3578 allocation.to_start,
3579 allocation.limiting_factor
3580 ));
3581 trace.push(format!("headroom_after={}", allocator.headroom()));
3582 trace.push(format!(
3585 "alloc_zero_to_start={}",
3586 allocator.allocate(&policy, 0).to_start
3587 ));
3588
3589 let attempt = crate::attempt::RunnerAttempt::allocate(
3593 crate::model::AttemptId::from_u128(11),
3594 policy.id,
3595 "C:/runners/eq",
3596 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3597 );
3598 trace.push(format!(
3599 "authorize_own_host={:?}",
3600 crate::attempt::authorize(host, &policy, &attempt).is_ok()
3601 ));
3602 trace.push(format!(
3603 "authorize_other_host={}",
3604 crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
3605 .expect_err("an agent on another host must be refused")
3606 ));
3607 let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
3608 crate::model::AttemptId::from_u128(12),
3609 PolicyId::from_u128(999),
3610 "C:/runners/eq-other",
3611 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3612 );
3613 trace.push(format!(
3614 "authorize_other_policy={}",
3615 crate::attempt::authorize(host, &policy, &foreign_attempt)
3616 .expect_err("an attempt under another policy must be refused")
3617 ));
3618
3619 trace.push(format!("disable={}", policy.request_disable().unwrap()));
3621 trace.push(format!(
3622 "drain_with_1={}",
3623 policy.drain_completed(1).unwrap()
3624 ));
3625 trace.push(format!(
3626 "drain_with_0={}",
3627 policy.drain_completed(0).unwrap()
3628 ));
3629
3630 trace.push(format!(
3632 "reactivate_err={}",
3633 policy.transition_to(PolicyState::Active).is_err()
3634 ));
3635
3636 trace.push(format!(
3638 "registration_labels={:?}",
3639 autoscale_policy(target, host, 3)
3640 .routing_labels()
3641 .unwrap()
3642 .as_registration_labels()
3643 ));
3644
3645 trace
3646 }
3647
3648 #[test]
3649 fn repository_and_organization_targets_are_equivalent() {
3650 let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
3651 let organization =
3652 assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
3653
3654 assert_eq!(
3655 repository, organization,
3656 "D18: the two scopes differ only in which GitHub endpoint and which \
3657 App permission the gateway uses. Any difference here is a \
3658 scope-dependent domain rule that must not exist."
3659 );
3660
3661 assert_ne!(
3663 ScaleTarget::repository("o/r").unwrap().scope(),
3664 ScaleTarget::organization("o").unwrap().scope()
3665 );
3666 }
3667
3668 fn job(labels: &[&str]) -> RunsOn {
3673 RunsOn::from_job_labels(labels.iter().map(|label| (*label).to_string()))
3674 }
3675
3676 fn linux_profile(extra: &[&str]) -> RoutingLabels {
3677 RoutingLabels::from_parts(
3678 label("rm-box-linux-x64"),
3679 ["self-hosted", "linux", "x64"]
3680 .iter()
3681 .chain(extra)
3682 .map(|value| label(value)),
3683 )
3684 }
3685
3686 #[test]
3687 fn an_exclusion_is_satisfied_by_a_profile_that_lacks_what_it_excludes() {
3688 let native = linux_profile(&[]);
3689 let wsl = linux_profile(&["wsl"]);
3690 let wanted = job(&["self-hosted", "linux", "x64", "not-wsl"]);
3691 assert!(native.matches(&wanted).is_match());
3692 assert_eq!(
3693 wsl.matches(&wanted),
3694 RunsOnMatch::NoMatch {
3695 missing: vec![label("not-wsl")]
3696 }
3697 );
3698 assert!(!wsl.matches(&job(&["Linux", "NOT-WSL"])).is_match());
3700 assert!(
3701 !native
3702 .matches(&job(&["linux", "not-rm-box-linux-x64"]))
3703 .is_match()
3704 );
3705 assert!(
3706 native
3707 .matches(&job(&["linux", "not-rm-other-linux-x64"]))
3708 .is_match()
3709 );
3710 }
3711
3712 #[test]
3713 fn a_profile_listing_both_a_label_and_its_exclusion_never_satisfies_the_exclusion() {
3714 let both = linux_profile(&["wsl", "not-wsl"]);
3716 assert!(!both.matches(&job(&["linux", "not-wsl"])).is_match());
3717 assert!(both.matches(&job(&["linux", "wsl"])).is_match());
3718 let registered = both.as_registration_labels();
3720 assert!(registered.contains(&"wsl".to_string()));
3721 assert!(!registered.contains(&"not-wsl".to_string()));
3722 }
3723
3724 #[test]
3725 fn a_job_without_exclusions_matches_exactly_as_before() {
3726 let profile = linux_profile(&["gpu"]);
3727 assert!(profile.matches(&job(&["self-hosted", "gpu"])).is_match());
3728 assert!(!profile.matches(&job(&["self-hosted", "arm64"])).is_match());
3729 assert_eq!(
3730 profile.as_registration_labels(),
3731 vec!["rm-box-linux-x64", "gpu", "linux", "self-hosted", "x64"]
3732 );
3733 let tally = profile.tally(&[job(&["self-hosted", "gpu"])]);
3734 assert!(tally.excluded.is_empty());
3735 }
3736
3737 #[test]
3738 fn an_unusable_or_conflicting_exclusion_is_unsatisfiable_not_demand() {
3739 for bad in ["not-", "not-not-wsl", "NOT-Not-x", "not- wsl"] {
3740 let got = job(&["linux", bad]).required_labels();
3741 assert!(
3742 matches!(&got, Err(UnresolvableRunsOn::InvalidExclusion { .. })),
3743 "{bad}: {got:?}"
3744 );
3745 assert!(got.unwrap_err().is_unsatisfiable_exclusion());
3746 }
3747 assert_eq!(
3748 job(&["linux", "not- wsl", "not-gpu"]).plain_labels(),
3749 vec![label("linux")]
3750 );
3751 let conflict = job(&["linux", "WSL", "not-wsl"]).required_labels();
3752 assert_eq!(
3753 conflict,
3754 Err(UnresolvableRunsOn::ConflictingExclusion {
3755 label: label("wsl")
3756 })
3757 );
3758 let tally = linux_profile(&[]).tally(&[job(&["linux", "wsl", "not-wsl"])]);
3759 assert_eq!((tally.matched, tally.unresolvable.len()), (0, 1));
3760 assert!(
3762 !job(&["${{ matrix.os }}"])
3763 .required_labels()
3764 .unwrap_err()
3765 .is_unsatisfiable_exclusion()
3766 );
3767 }
3768
3769 #[test]
3770 fn a_tally_collects_what_its_matched_jobs_exclude() {
3771 let profile = linux_profile(&[]);
3772 let tally = profile.tally(&[
3773 job(&["linux", "not-wsl"]),
3774 job(&["linux", "not-gpu", "not-wsl"]),
3775 job(&["linux"]),
3776 job(&["windows", "not-docker"]),
3778 ]);
3779 assert_eq!(tally.matched, 3);
3780 assert_eq!(
3781 tally.excluded.iter().map(Label::as_str).collect::<Vec<_>>(),
3782 vec!["gpu", "wsl"]
3783 );
3784 }
3785
3786 #[test]
3787 fn excluding_adds_only_exclusions_of_labels_the_profile_lacks() {
3788 let profile = linux_profile(&["gpu"]);
3789 let excluded = [label("wsl"), label("gpu")];
3790 let registered = profile.excluding(&excluded).as_registration_labels();
3791 assert!(registered.contains(&"not-wsl".to_string()));
3792 assert!(!registered.contains(&"not-gpu".to_string()));
3793 assert!(!profile.contains(&label("not-wsl")));
3795 }
3796
3797 #[test]
3798 fn host_traits_give_wsl_or_not_wsl_and_nothing_when_unknown() {
3799 assert!(HostTraits::default().labels().is_empty());
3800 assert_eq!(HostTraits::wsl(true).labels(), vec![label("wsl")]);
3801 assert_eq!(HostTraits::wsl(false).labels(), vec![label("not-wsl")]);
3802 let wanted = job(&["linux", "not-wsl"]);
3803 let base = linux_profile(&[]);
3804 assert!(
3805 !base
3806 .with_labels(HostTraits::wsl(true).labels())
3807 .matches(&wanted)
3808 .is_match()
3809 );
3810 let native = base.with_labels(HostTraits::wsl(false).labels());
3811 assert!(native.matches(&wanted).is_match());
3812 assert!(
3813 native
3814 .as_registration_labels()
3815 .contains(&"not-wsl".to_string())
3816 );
3817 }
3818
3819 #[test]
3820 fn a_profile_label_that_contradicts_another_is_refused() {
3821 let mut policy = autoscale_policy(
3822 ScaleTarget::repository("o/r").unwrap(),
3823 HostId::from_u128(9),
3824 2,
3825 );
3826 policy.add_routing_label(label("wsl")).unwrap();
3827 assert_eq!(
3828 policy.add_routing_label(label("not-wsl")),
3829 Err(PolicyError::ContradictoryExclusion {
3830 label: label("not-wsl"),
3831 carried: label("wsl"),
3832 })
3833 );
3834 let mut other = autoscale_policy(
3835 ScaleTarget::repository("o/r").unwrap(),
3836 HostId::from_u128(9),
3837 2,
3838 );
3839 other.add_routing_label(label("not-gpu")).unwrap();
3840 assert!(matches!(
3841 other.add_routing_label(label("GPU")),
3842 Err(PolicyError::ContradictoryExclusion { .. })
3843 ));
3844 assert!(matches!(
3845 other.add_routing_label(label("not-")),
3846 Err(PolicyError::InvalidExclusion { .. })
3847 ));
3848 assert_eq!(other.add_routing_label(label("docker")), Ok(true));
3849 }
3850
3851 #[test]
3852 fn runtime_labels_change_a_copy_and_leave_the_revision() {
3853 let policy = autoscale_policy(
3854 ScaleTarget::repository("o/r").unwrap(),
3855 HostId::from_u128(9),
3856 2,
3857 );
3858 let copy = policy.with_runtime_labels(|labels| labels.with_labels([label("wsl")]));
3859 assert!(copy.routing_labels().unwrap().contains(&label("wsl")));
3860 assert!(!policy.routing_labels().unwrap().contains(&label("wsl")));
3861 assert_eq!(copy.revision(), policy.revision());
3862 assert_eq!(copy.id, policy.id);
3863 }
3864}