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
121pub const POOL_LABEL_PREFIX: &str = "rm-pool-";
134
135pub const ANY_POOL_LABEL: &str = "rm-pool-any";
140
141#[must_use]
143pub fn any_pool_label() -> Label {
144 Label::new(ANY_POOL_LABEL).expect("`rm-pool-any` is a valid label")
145}
146
147#[must_use]
149pub fn is_any_pool_label(label: &str) -> bool {
150 label.eq_ignore_ascii_case(ANY_POOL_LABEL)
151}
152
153#[must_use]
157pub fn in_pool_namespace(label: &str) -> bool {
158 label
159 .get(..POOL_LABEL_PREFIX.len())
160 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(POOL_LABEL_PREFIX))
161}
162
163#[must_use]
167pub fn is_pool_label(label: &str) -> bool {
168 is_any_pool_label(label)
169 || Os::ALL
170 .iter()
171 .any(|os| pool_label(*os).as_str().eq_ignore_ascii_case(label))
172}
173
174#[must_use]
177pub fn pool_label(os: Os) -> Label {
178 Label::new(format!("{POOL_LABEL_PREFIX}{}", os.pool_token()))
179 .expect("the pool prefix and a fixed OS token form a valid label")
180}
181
182#[must_use]
185pub fn may_carry_pool(label: &Label, os: Os) -> bool {
186 *label == pool_label(os) || is_any_pool_label(label.as_str())
187}
188
189pub fn check_pool_label(label: &Label, os: Os) -> Result<(), PolicyError> {
195 let expected = pool_label(os);
196 if in_pool_namespace(label.as_str()) && !may_carry_pool(label, os) {
197 return Err(PolicyError::PoolLabelMismatch {
198 label: label.clone(),
199 expected,
200 });
201 }
202 Ok(())
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
238pub struct RoutingLabels {
239 host_label: Label,
240 additional: BTreeSet<Label>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244struct RoutingLabelsRepr {
245 host_label: Label,
246 #[serde(default)]
247 additional: BTreeSet<Label>,
248}
249
250impl From<RoutingLabelsRepr> for RoutingLabels {
251 fn from(repr: RoutingLabelsRepr) -> Self {
252 Self::from_parts(repr.host_label, repr.additional)
257 }
258}
259
260impl From<RoutingLabels> for RoutingLabelsRepr {
261 fn from(value: RoutingLabels) -> Self {
262 Self {
263 host_label: value.host_label,
264 additional: value.additional,
265 }
266 }
267}
268
269impl RoutingLabels {
270 pub const PREFIX: &'static str = "rm";
272
273 #[must_use]
287 pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
288 let derived = format!(
289 "{}-{}-{}-{}",
290 Self::PREFIX,
291 host_label.as_str(),
292 os.label_token(),
293 arch.label_token()
294 );
295 Self {
296 host_label: Label::new(derived).expect(
297 "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
298 segments are fixed tokens, so the concatenation is always a valid Label",
299 ),
300 additional: BTreeSet::new(),
301 }
302 }
303
304 #[must_use]
308 pub fn derive_for_profile(
309 host_label: &HostLabel,
310 os: Os,
311 arch: Arch,
312 profile: &ProfileName,
313 ) -> Self {
314 let label = format!(
315 "{}-{}-{}-{}-{}",
316 Self::PREFIX,
317 host_label.as_str(),
318 os.label_token(),
319 arch.label_token(),
320 profile.as_str()
321 );
322 Self::from_host_label(Label::new(label).expect("validated selector segments form a label"))
323 }
324
325 #[must_use]
338 pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
339 let additional = additional
340 .into_iter()
341 .filter(|l| *l != host_label)
342 .collect();
343 Self {
344 host_label,
345 additional,
346 }
347 }
348
349 #[must_use]
351 pub fn from_host_label(host_label: Label) -> Self {
352 Self::from_parts(host_label, Vec::new())
353 }
354
355 #[must_use]
357 pub fn host_label(&self) -> &Label {
358 &self.host_label
359 }
360
361 #[must_use]
392 pub fn is_derived_shape(&self) -> bool {
393 let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
394 let [prefix, middle @ .., os, arch] = segments.as_slice() else {
399 return false;
400 };
401 *prefix == Self::PREFIX
404 && !middle.is_empty()
405 && Os::ALL
406 .iter()
407 .any(|candidate| candidate.label_token() == *os)
408 && Arch::ALL
409 .iter()
410 .any(|candidate| candidate.label_token() == *arch)
411 }
412
413 pub fn additional(&self) -> impl Iterator<Item = &Label> {
415 self.additional.iter()
416 }
417
418 pub fn add(&mut self, label: Label) -> bool {
421 if label == self.host_label {
422 return false;
423 }
424 self.additional.insert(label)
425 }
426
427 pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
434 if *label == self.host_label {
435 return Err(PolicyError::HostLabelNotRemovable {
436 label: label.clone(),
437 });
438 }
439 Ok(self.additional.remove(label))
440 }
441
442 #[must_use]
443 pub fn contains(&self, label: &Label) -> bool {
444 self.host_label == *label || self.additional.contains(label)
445 }
446
447 pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
451 self.additional
452 .iter()
453 .filter(|label| is_pool_label(label.as_str()))
454 }
455
456 pub fn iter(&self) -> impl Iterator<Item = &Label> {
458 std::iter::once(&self.host_label).chain(self.additional.iter())
459 }
460
461 #[must_use]
463 pub fn count(&self) -> NonZeroUsize {
464 NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
465 }
466
467 #[must_use]
469 pub fn to_non_empty(&self) -> NonEmpty<Label> {
470 let mut out = NonEmpty::of(self.host_label.clone());
471 for label in &self.additional {
472 out.push(label.clone());
473 }
474 out
475 }
476
477 #[must_use]
487 pub fn as_registration_labels(&self) -> Vec<String> {
488 self.iter().map(|l| l.as_str().to_string()).collect()
489 }
490
491 #[must_use]
498 pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
499 let required = match runs_on.required_labels() {
500 Ok(required) => required,
501 Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
502 };
503
504 let missing: Vec<Label> = required
505 .iter()
506 .filter(|label| !self.contains(label))
507 .cloned()
508 .collect();
509
510 if missing.is_empty() {
511 RunsOnMatch::Match {
512 runner_group: runs_on.runner_group().map(str::to_string),
513 }
514 } else {
515 RunsOnMatch::NoMatch { missing }
516 }
517 }
518
519 #[must_use]
527 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
528 let mut tally = DemandTally::default();
529 for job in jobs {
530 match self.matches(job) {
531 RunsOnMatch::Match { .. } => tally.matched += 1,
532 RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
533 RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
534 }
535 }
536 tally
537 }
538}
539
540impl fmt::Display for RoutingLabels {
541 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
542 let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
543 f.write_str(&joined.join(","))
544 }
545}
546
547#[derive(Debug, Clone, Default, PartialEq, Eq)]
549pub struct DemandTally {
550 pub matched: u32,
552 pub not_matched: u32,
554 pub unresolvable: Vec<UnresolvableRunsOn>,
558}
559
560impl DemandTally {
561 #[must_use]
562 pub fn demand(&self) -> u32 {
563 self.matched
564 }
565
566 #[must_use]
567 pub fn total_seen(&self) -> u32 {
568 self.matched + self.not_matched + self.unresolvable.len() as u32
569 }
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
574pub enum RunsOnMatch {
575 Match { runner_group: Option<String> },
583 NoMatch { missing: Vec<Label> },
586 Unresolvable(UnresolvableRunsOn),
588}
589
590impl RunsOnMatch {
591 #[must_use]
592 pub const fn is_match(&self) -> bool {
593 matches!(self, RunsOnMatch::Match { .. })
594 }
595
596 #[must_use]
597 pub const fn is_unresolvable(&self) -> bool {
598 matches!(self, RunsOnMatch::Unresolvable(_))
599 }
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
604pub enum UnresolvableRunsOn {
605 #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
608 Expression { raw: String },
609
610 #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
613 RunnerGroupWithoutLabels { group: String },
614
615 #[error("`runs-on` names no labels")]
617 NoLabels,
618
619 #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
621 InvalidLabel {
622 raw: String,
623 #[source]
624 source: ValidationError,
625 },
626}
627
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
635#[serde(untagged)]
636pub enum RunsOn {
637 Single(String),
639 Many(Vec<String>),
641 Grouped {
643 #[serde(default)]
644 group: Option<String>,
645 #[serde(default)]
646 labels: RunsOnLabels,
647 },
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652#[serde(untagged)]
653pub enum RunsOnLabels {
654 One(String),
655 Many(Vec<String>),
656}
657
658impl Default for RunsOnLabels {
659 fn default() -> Self {
660 Self::Many(Vec::new())
661 }
662}
663
664impl RunsOnLabels {
665 fn as_slice(&self) -> &[String] {
666 match self {
667 RunsOnLabels::One(one) => std::slice::from_ref(one),
668 RunsOnLabels::Many(many) => many,
669 }
670 }
671}
672
673impl RunsOn {
674 #[must_use]
676 pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
677 Self::Many(labels.into_iter().map(Into::into).collect())
678 }
679
680 #[must_use]
682 pub fn runner_group(&self) -> Option<&str> {
683 match self {
684 RunsOn::Grouped { group, .. } => group.as_deref(),
685 _ => None,
686 }
687 }
688
689 fn raw_labels(&self) -> &[String] {
690 match self {
691 RunsOn::Single(one) => std::slice::from_ref(one),
692 RunsOn::Many(many) => many,
693 RunsOn::Grouped { labels, .. } => labels.as_slice(),
694 }
695 }
696
697 pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
714 let raws = self.raw_labels();
715
716 if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
717 return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
718 }
719
720 let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
721
722 if usable.is_empty() {
723 return match self.runner_group() {
724 Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
725 group: group.to_string(),
726 }),
727 None => Err(UnresolvableRunsOn::NoLabels),
728 };
729 }
730
731 usable
732 .into_iter()
733 .map(|raw| {
734 Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
735 raw: raw.clone(),
736 source,
737 })
738 })
739 .collect()
740 }
741}
742
743fn is_expression(raw: &str) -> bool {
744 raw.contains("${{")
745}
746
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
758#[serde(try_from = "AutoscaleConfigRepr")]
759pub struct AutoscaleConfig {
760 routing_labels: RoutingLabels,
761 min_capacity: u16,
762 max_capacity: NonZeroU16,
763}
764
765#[derive(Debug, Deserialize)]
766struct AutoscaleConfigRepr {
767 routing_labels: RoutingLabels,
768 min_capacity: u16,
769 max_capacity: NonZeroU16,
770}
771
772impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
773 type Error = PolicyError;
774
775 fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
776 Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
777 }
778}
779
780impl AutoscaleConfig {
781 pub fn new(
787 routing_labels: RoutingLabels,
788 min_capacity: u16,
789 max_capacity: NonZeroU16,
790 ) -> Result<Self, PolicyError> {
791 if min_capacity > max_capacity.get() {
792 return Err(PolicyError::InvertedCapacityRange {
793 min: min_capacity,
794 max: max_capacity.get(),
795 });
796 }
797 Ok(Self {
798 routing_labels,
799 min_capacity,
800 max_capacity,
801 })
802 }
803
804 pub fn v1(
810 routing_labels: RoutingLabels,
811 max_capacity: NonZeroU16,
812 ) -> Result<Self, PolicyError> {
813 Self::new(routing_labels, 0, max_capacity)
814 }
815
816 #[must_use]
817 pub fn routing_labels(&self) -> &RoutingLabels {
818 &self.routing_labels
819 }
820
821 #[must_use]
822 pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
823 &mut self.routing_labels
824 }
825
826 #[must_use]
827 pub const fn min_capacity(&self) -> u16 {
828 self.min_capacity
829 }
830
831 #[must_use]
832 pub const fn max_capacity(&self) -> NonZeroU16 {
833 self.max_capacity
834 }
835
836 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
840 if self.min_capacity > max_capacity.get() {
841 return Err(PolicyError::InvertedCapacityRange {
842 min: self.min_capacity,
843 max: max_capacity.get(),
844 });
845 }
846 self.max_capacity = max_capacity;
847 Ok(())
848 }
849}
850
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
871#[serde(tag = "mode", rename_all = "snake_case")]
872pub enum PolicyMode {
873 MonitorOnly,
876 Autoscale(AutoscaleConfig),
878}
879
880impl PolicyMode {
881 #[must_use]
882 pub const fn monitor_only() -> Self {
883 Self::MonitorOnly
884 }
885
886 pub fn autoscale(
889 routing_labels: RoutingLabels,
890 min_capacity: u16,
891 max_capacity: NonZeroU16,
892 ) -> Result<Self, PolicyError> {
893 Ok(Self::Autoscale(AutoscaleConfig::new(
894 routing_labels,
895 min_capacity,
896 max_capacity,
897 )?))
898 }
899
900 pub fn from_persisted(
910 routing_labels: Option<RoutingLabels>,
911 min_capacity: u16,
912 max_capacity: Option<NonZeroU16>,
913 ) -> Result<Self, PolicyError> {
914 match (routing_labels, max_capacity) {
915 (None, None) => {
916 if min_capacity != 0 {
917 return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
922 }
923 Ok(Self::MonitorOnly)
924 }
925 (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
926 (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
927 (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
928 }
929 }
930
931 #[must_use]
934 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
935 match self {
936 PolicyMode::MonitorOnly => None,
937 PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
938 }
939 }
940
941 #[must_use]
942 pub const fn min_capacity(&self) -> u16 {
943 match self {
944 PolicyMode::MonitorOnly => 0,
945 PolicyMode::Autoscale(cfg) => cfg.min_capacity,
946 }
947 }
948
949 #[must_use]
950 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
951 match self {
952 PolicyMode::MonitorOnly => None,
953 PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
954 }
955 }
956
957 #[must_use]
958 pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
959 match self {
960 PolicyMode::MonitorOnly => None,
961 PolicyMode::Autoscale(cfg) => Some(cfg),
962 }
963 }
964
965 #[must_use]
966 pub const fn is_autoscale(&self) -> bool {
967 matches!(self, PolicyMode::Autoscale(_))
968 }
969
970 #[must_use]
971 pub const fn is_monitor_only(&self) -> bool {
972 matches!(self, PolicyMode::MonitorOnly)
973 }
974}
975
976#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1003#[serde(rename_all = "snake_case")]
1004pub enum PolicyState {
1005 Pending,
1006 Active,
1007 Draining,
1008 Disabled,
1009 RepairRequired,
1010 AuthenticationFailed,
1011}
1012
1013impl PolicyState {
1014 pub const ALL: [PolicyState; 6] = [
1015 PolicyState::Pending,
1016 PolicyState::Active,
1017 PolicyState::Draining,
1018 PolicyState::Disabled,
1019 PolicyState::RepairRequired,
1020 PolicyState::AuthenticationFailed,
1021 ];
1022
1023 pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
1026 (PolicyState::Pending, PolicyState::Active),
1027 (PolicyState::Pending, PolicyState::RepairRequired),
1028 (PolicyState::Active, PolicyState::Draining),
1029 (PolicyState::Draining, PolicyState::Disabled),
1030 (PolicyState::Disabled, PolicyState::Pending),
1031 (PolicyState::Pending, PolicyState::AuthenticationFailed),
1033 (PolicyState::Active, PolicyState::AuthenticationFailed),
1034 (PolicyState::Draining, PolicyState::AuthenticationFailed),
1035 (PolicyState::Disabled, PolicyState::AuthenticationFailed),
1036 (
1037 PolicyState::RepairRequired,
1038 PolicyState::AuthenticationFailed,
1039 ),
1040 (PolicyState::AuthenticationFailed, PolicyState::Pending),
1042 ];
1043
1044 #[must_use]
1045 pub fn can_transition_to(self, next: PolicyState) -> bool {
1046 Self::LEGAL.contains(&(self, next))
1047 }
1048
1049 #[must_use]
1051 pub const fn admits_new_runners(self) -> bool {
1052 matches!(self, PolicyState::Active)
1053 }
1054}
1055
1056impl fmt::Display for PolicyState {
1057 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1058 f.write_str(match self {
1059 PolicyState::Pending => "pending",
1060 PolicyState::Active => "active",
1061 PolicyState::Draining => "draining",
1062 PolicyState::Disabled => "disabled",
1063 PolicyState::RepairRequired => "repair_required",
1064 PolicyState::AuthenticationFailed => "authentication_failed",
1065 })
1066 }
1067}
1068
1069#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1080pub struct ScalePolicy {
1081 pub id: PolicyId,
1082 pub target: ScaleTarget,
1083 pub installation_id: u64,
1084 pub host_id: HostId,
1085 pub requested_host_label: HostLabel,
1087 profile_name: ProfileName,
1089 mode: PolicyMode,
1090 enabled: bool,
1091 state: PolicyState,
1092 pub cache_policy: CachePolicy,
1093 workspace_policy: WorkspacePolicy,
1101 execution_policy: ExecutionPolicy,
1102 revision: u64,
1103}
1104
1105#[derive(Debug, Clone, PartialEq, Eq)]
1131pub struct PersistedPolicy {
1132 pub id: PolicyId,
1133 pub target: ScaleTarget,
1134 pub installation_id: u64,
1136 pub host_id: HostId,
1137 pub requested_host_label: HostLabel,
1138 pub profile_name: ProfileName,
1139 pub routing_labels: Option<RoutingLabels>,
1141 pub min_capacity: u16,
1142 pub max_capacity: Option<NonZeroU16>,
1143 pub enabled: bool,
1145 pub state: PolicyState,
1146 pub cache_policy: CachePolicy,
1147 pub workspace_kind: WorkspaceKind,
1152 pub workspace_root: Option<LocalAbsolutePath>,
1155 pub execution_policy: ExecutionPolicy,
1156 pub revision: u64,
1158}
1159
1160#[derive(Debug, Clone, PartialEq, Eq)]
1162pub struct NamedProfileSpec {
1163 pub requested_host_label: HostLabel,
1164 pub os: Os,
1165 pub arch: Arch,
1166 pub profile_name: ProfileName,
1167 pub min_capacity: u16,
1168 pub max_capacity: NonZeroU16,
1169}
1170
1171impl ScalePolicy {
1172 #[must_use]
1174 pub const fn profile_name(&self) -> &ProfileName {
1175 &self.profile_name
1176 }
1177
1178 #[must_use]
1191 pub fn new(
1192 id: PolicyId,
1193 target: ScaleTarget,
1194 installation_id: u64,
1195 host_id: HostId,
1196 mode: PolicyMode,
1197 cache_policy: CachePolicy,
1198 ) -> Self {
1199 Self::new_for_host_label(
1200 id,
1201 target,
1202 installation_id,
1203 host_id,
1204 HostLabel::new("host").expect("the compatibility host label is valid"),
1205 mode,
1206 cache_policy,
1207 )
1208 }
1209
1210 #[must_use]
1212 pub fn new_for_host_label(
1213 id: PolicyId,
1214 target: ScaleTarget,
1215 installation_id: u64,
1216 host_id: HostId,
1217 requested_host_label: HostLabel,
1218 mode: PolicyMode,
1219 cache_policy: CachePolicy,
1220 ) -> Self {
1221 Self {
1222 id,
1223 target,
1224 installation_id,
1225 host_id,
1226 requested_host_label,
1227 profile_name: ProfileName::default_profile(),
1228 mode,
1229 enabled: false,
1230 state: PolicyState::Pending,
1231 cache_policy,
1232 workspace_policy: WorkspacePolicy::Ephemeral,
1236 execution_policy: ExecutionPolicy::Native,
1237 revision: 0,
1238 }
1239 }
1240
1241 pub fn new_named(
1244 id: PolicyId,
1245 target: ScaleTarget,
1246 installation_id: u64,
1247 host_id: HostId,
1248 spec: NamedProfileSpec,
1249 cache_policy: CachePolicy,
1250 ) -> Result<Self, PolicyError> {
1251 let NamedProfileSpec {
1252 requested_host_label,
1253 os,
1254 arch,
1255 profile_name,
1256 min_capacity,
1257 max_capacity,
1258 } = spec;
1259 if profile_name.is_default() {
1260 return Err(PolicyError::NamedConstructorForDefault);
1261 }
1262 let labels =
1263 RoutingLabels::derive_for_profile(&requested_host_label, os, arch, &profile_name);
1264 let mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1265 let mut policy = Self::new_for_host_label(
1266 id,
1267 target,
1268 installation_id,
1269 host_id,
1270 requested_host_label,
1271 mode,
1272 cache_policy,
1273 );
1274 policy.profile_name = profile_name;
1275 Ok(policy)
1276 }
1277
1278 pub fn new_named_monitor(
1281 id: PolicyId,
1282 target: ScaleTarget,
1283 installation_id: u64,
1284 host_id: HostId,
1285 requested_host_label: HostLabel,
1286 profile_name: ProfileName,
1287 cache_policy: CachePolicy,
1288 ) -> Result<Self, PolicyError> {
1289 if profile_name.is_default() {
1290 return Err(PolicyError::NamedConstructorForDefault);
1291 }
1292 let mut policy = Self::new_for_host_label(
1293 id,
1294 target,
1295 installation_id,
1296 host_id,
1297 requested_host_label,
1298 PolicyMode::monitor_only(),
1299 cache_policy,
1300 );
1301 policy.profile_name = profile_name;
1302 Ok(policy)
1303 }
1304
1305 pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1313 let PersistedPolicy {
1314 id,
1315 target,
1316 installation_id,
1317 host_id,
1318 requested_host_label,
1319 profile_name,
1320 routing_labels,
1321 min_capacity,
1322 max_capacity,
1323 enabled,
1324 state,
1325 cache_policy,
1326 workspace_kind,
1327 workspace_root,
1328 execution_policy,
1329 revision,
1330 } = fields;
1331
1332 let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1333 if !profile_name.is_default()
1334 && let Some(labels) = mode.routing_labels()
1335 {
1336 let suffix = format!("-{}", profile_name.as_str());
1339 if !labels.host_label().as_str().ends_with(&suffix) {
1340 return Err(PolicyError::InvalidProfileSelector);
1341 }
1342 }
1343 let workspace_policy =
1347 WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1348 execution_policy.validate(target.scope(), &workspace_policy)?;
1349 Ok(Self {
1350 id,
1351 target,
1352 installation_id,
1353 host_id,
1354 requested_host_label,
1355 profile_name,
1356 mode,
1357 enabled,
1358 state,
1359 cache_policy,
1360 workspace_policy,
1361 execution_policy,
1362 revision,
1363 })
1364 }
1365
1366 #[must_use]
1372 pub fn to_persisted(&self) -> PersistedPolicy {
1373 PersistedPolicy {
1374 id: self.id,
1375 target: self.target.clone(),
1376 installation_id: self.installation_id,
1377 host_id: self.host_id,
1378 requested_host_label: self.requested_host_label.clone(),
1379 profile_name: self.profile_name.clone(),
1380 routing_labels: self.routing_labels().cloned(),
1381 min_capacity: self.min_capacity(),
1382 max_capacity: self.max_capacity(),
1383 enabled: self.enabled,
1384 state: self.state,
1385 cache_policy: self.cache_policy,
1386 workspace_kind: self.workspace_policy.kind(),
1387 workspace_root: self.workspace_policy.root().cloned(),
1388 execution_policy: self.execution_policy.clone(),
1389 revision: self.revision,
1390 }
1391 }
1392
1393 #[must_use]
1394 pub const fn mode(&self) -> &PolicyMode {
1395 &self.mode
1396 }
1397
1398 #[must_use]
1399 pub const fn state(&self) -> PolicyState {
1400 self.state
1401 }
1402
1403 #[must_use]
1407 pub const fn enabled(&self) -> bool {
1408 self.enabled
1409 }
1410
1411 #[must_use]
1414 pub const fn revision(&self) -> u64 {
1415 self.revision
1416 }
1417
1418 #[must_use]
1419 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1420 self.mode.routing_labels()
1421 }
1422
1423 pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
1425 self.routing_labels()
1426 .into_iter()
1427 .flat_map(RoutingLabels::pool_labels)
1428 }
1429
1430 #[must_use]
1432 pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1433 &self.workspace_policy
1434 }
1435
1436 #[must_use]
1437 pub const fn execution_policy(&self) -> &ExecutionPolicy {
1438 &self.execution_policy
1439 }
1440
1441 pub fn set_execution_policy(&mut self, execution: ExecutionPolicy) -> Result<(), PolicyError> {
1443 execution.validate(self.target.scope(), &self.workspace_policy)?;
1444 if self.execution_policy != execution {
1445 self.execution_policy = execution;
1446 self.revision = self.revision.saturating_add(1);
1447 }
1448 Ok(())
1449 }
1450
1451 pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1469 workspace.permitted_for(self.target.scope())?;
1474 self.execution_policy
1475 .validate(self.target.scope(), &workspace)?;
1476 if self.workspace_policy != workspace {
1477 self.workspace_policy = workspace;
1478 self.revision = self.revision.saturating_add(1);
1479 }
1480 Ok(())
1481 }
1482
1483 #[must_use]
1484 pub const fn min_capacity(&self) -> u16 {
1485 self.mode.min_capacity()
1486 }
1487
1488 #[must_use]
1489 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1490 self.mode.max_capacity()
1491 }
1492
1493 #[must_use]
1496 pub fn is_owned_by(&self, host_id: HostId) -> bool {
1497 self.host_id == host_id
1498 }
1499
1500 #[must_use]
1507 pub const fn owns_runners(&self) -> bool {
1508 self.mode.is_autoscale()
1509 }
1510
1511 #[must_use]
1517 pub const fn may_start_runners(&self) -> bool {
1518 self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1519 }
1520
1521 pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1525 if !self.state.can_transition_to(next) {
1526 return Err(PolicyError::IllegalTransition {
1527 from: self.state,
1528 to: next,
1529 });
1530 }
1531 self.state = next;
1532 self.revision = self.revision.saturating_add(1);
1533 Ok(())
1534 }
1535
1536 #[must_use]
1542 pub fn can_activate(&self) -> bool {
1543 self.state.can_transition_to(PolicyState::Active)
1544 }
1545
1546 #[must_use]
1548 pub fn can_request_disable(&self) -> bool {
1549 self.state.can_transition_to(PolicyState::Draining)
1550 }
1551
1552 pub fn activate(&mut self) -> Result<(), PolicyError> {
1573 self.transition_to(PolicyState::Active)?;
1574 self.enabled = true;
1575 Ok(())
1576 }
1577
1578 pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1597 self.transition_to(PolicyState::Draining)?;
1598 self.enabled = false;
1599 Ok(self.state)
1600 }
1601
1602 pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1612 if self.state != PolicyState::Draining {
1613 return Err(PolicyError::IllegalTransition {
1614 from: self.state,
1615 to: PolicyState::Disabled,
1616 });
1617 }
1618 if active_attempts == 0 {
1619 self.transition_to(PolicyState::Disabled)?;
1620 }
1621 Ok(self.state)
1622 }
1623
1624 pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1630 self.transition_to(PolicyState::AuthenticationFailed)
1631 }
1632
1633 pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1639 self.transition_to(PolicyState::Pending)
1640 }
1641
1642 pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1647 self.transition_to(PolicyState::RepairRequired)
1648 }
1649
1650 pub fn promote_to_autoscale(
1659 &mut self,
1660 routing_labels: RoutingLabels,
1661 min_capacity: u16,
1662 max_capacity: NonZeroU16,
1663 ) -> Result<(), PolicyError> {
1664 if !self.profile_name.is_default() {
1665 return Err(PolicyError::NamedProfileNeedsDerivedSelector);
1666 }
1667 if self.mode.is_autoscale() {
1668 return Err(PolicyError::AlreadyAutoscale);
1669 }
1670 self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1671 self.revision = self.revision.saturating_add(1);
1672 Ok(())
1673 }
1674
1675 pub fn promote_named_to_autoscale(
1678 &mut self,
1679 os: Os,
1680 arch: Arch,
1681 min_capacity: u16,
1682 max_capacity: NonZeroU16,
1683 ) -> Result<(), PolicyError> {
1684 if self.profile_name.is_default() {
1685 return Err(PolicyError::NamedConstructorForDefault);
1686 }
1687 if self.mode.is_autoscale() {
1688 return Err(PolicyError::AlreadyAutoscale);
1689 }
1690 let labels = RoutingLabels::derive_for_profile(
1691 &self.requested_host_label,
1692 os,
1693 arch,
1694 &self.profile_name,
1695 );
1696 self.mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1697 self.revision = self.revision.saturating_add(1);
1698 Ok(())
1699 }
1700
1701 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1709 match &mut self.mode {
1710 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1711 PolicyMode::Autoscale(cfg) => {
1712 cfg.set_max_capacity(max_capacity)?;
1713 self.revision = self.revision.saturating_add(1);
1714 Ok(())
1715 }
1716 }
1717 }
1718
1719 pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
1729 match &mut self.mode {
1730 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1731 PolicyMode::Autoscale(cfg) => {
1732 let added = cfg.routing_labels_mut().add(label);
1733 if added {
1734 self.revision = self.revision.saturating_add(1);
1735 }
1736 Ok(added)
1737 }
1738 }
1739 }
1740
1741 pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
1748 match &mut self.mode {
1749 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1750 PolicyMode::Autoscale(cfg) => {
1751 let removed = cfg.routing_labels_mut().remove(label)?;
1752 if removed {
1753 self.revision = self.revision.saturating_add(1);
1754 }
1755 Ok(removed)
1756 }
1757 }
1758 }
1759
1760 #[must_use]
1766 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
1767 match self.routing_labels() {
1768 Some(labels) => labels.tally(jobs),
1769 None => DemandTally::default(),
1770 }
1771 }
1772}
1773
1774#[cfg(test)]
1775mod tests {
1776 use super::*;
1777 use crate::model::{HostId, PolicyId, TargetScope};
1778
1779 fn nz(v: u16) -> NonZeroU16 {
1780 NonZeroU16::new(v).expect("test capacity is non-zero")
1781 }
1782
1783 fn label(s: &str) -> Label {
1784 Label::new(s).expect("test label is valid")
1785 }
1786
1787 fn host_labels(host: &str) -> RoutingLabels {
1788 RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
1789 }
1790
1791 fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
1792 ScalePolicy::new(
1793 PolicyId::from_u128(1),
1794 target,
1795 42,
1796 host,
1797 PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
1798 CachePolicy::default(),
1799 )
1800 }
1801
1802 #[test]
1803 fn each_os_has_one_pool_label_and_the_namespace_is_reserved() {
1804 assert_eq!(pool_label(Os::Windows).as_str(), "rm-pool-windows");
1805 assert_eq!(pool_label(Os::Linux).as_str(), "rm-pool-linux");
1806 assert_eq!(pool_label(Os::MacOs).as_str(), "rm-pool-macos");
1807 assert!(is_pool_label("RM-POOL-linux"));
1808 assert!(!is_pool_label("rm-home-linux-x64"));
1809 assert!(!is_pool_label("rm-pool"));
1810 assert!(!is_pool_label("rm-pool-linux-x64"));
1812 assert!(in_pool_namespace("rm-pool-linux-x64"));
1813 assert!(check_pool_label(&label("rm-pool-linux"), Os::Linux).is_ok());
1814 assert!(check_pool_label(&label("self-hosted"), Os::Linux).is_ok());
1815 assert!(matches!(
1816 check_pool_label(&label("rm-pool-windows"), Os::Linux),
1817 Err(PolicyError::PoolLabelMismatch { expected, .. }) if expected.as_str() == "rm-pool-linux"
1818 ));
1819 assert!(check_pool_label(&label("rm-pool-linux-heavy"), Os::Linux).is_err());
1820
1821 let mut labels = host_labels("home");
1822 assert_eq!(labels.pool_labels().next(), None);
1823 labels.add(label("linux"));
1824 labels.add(label("rm-pool-windows"));
1825 assert_eq!(
1826 labels.pool_labels().collect::<Vec<_>>(),
1827 [&label("rm-pool-windows")]
1828 );
1829 }
1830
1831 #[test]
1832 fn the_any_pool_is_a_pool_every_os_may_carry_beside_its_own() {
1833 assert_eq!(any_pool_label().as_str(), ANY_POOL_LABEL);
1834 assert!(is_pool_label("rm-pool-any") && is_pool_label("RM-Pool-Any"));
1835 assert!(is_any_pool_label("RM-POOL-ANY"));
1836 assert!(!is_any_pool_label("rm-pool-linux"));
1837 assert!(!is_pool_label("rm-pool-any-heavy"));
1838 for os in Os::ALL {
1839 assert!(check_pool_label(&label("rm-pool-any"), os).is_ok());
1840 }
1841 assert!(check_pool_label(&label("rm-pool-any-heavy"), Os::Linux).is_err());
1842
1843 let mut labels = host_labels("home");
1844 labels.add(label("rm-pool-linux"));
1845 labels.add(label("rm-pool-any"));
1846 labels.add(label("heavy"));
1847 assert_eq!(labels.pool_labels().count(), 2);
1848 }
1849
1850 fn workspace_root() -> LocalAbsolutePath {
1855 LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
1856 .expect("a valid persistent root")
1857 }
1858
1859 fn repository_policy() -> ScalePolicy {
1860 autoscale_policy(
1861 ScaleTarget::repository("acme/api").unwrap(),
1862 HostId::from_u128(1),
1863 4,
1864 )
1865 }
1866
1867 fn organization_policy() -> ScalePolicy {
1868 autoscale_policy(
1869 ScaleTarget::organization("acme").unwrap(),
1870 HostId::from_u128(1),
1871 4,
1872 )
1873 }
1874
1875 #[test]
1876 fn every_constructor_produces_an_ephemeral_workspace() {
1877 for policy in [repository_policy(), organization_policy()] {
1880 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1881 assert!(!policy.workspace_policy().retains_job_workspace());
1882 assert_eq!(
1883 policy.to_persisted().workspace_kind,
1884 WorkspaceKind::Ephemeral
1885 );
1886 assert_eq!(policy.to_persisted().workspace_root, None);
1887 }
1888
1889 let monitor_only = ScalePolicy::new(
1890 PolicyId::from_u128(2),
1891 ScaleTarget::repository("acme/api").unwrap(),
1892 42,
1893 HostId::from_u128(1),
1894 PolicyMode::MonitorOnly,
1895 CachePolicy::default(),
1896 );
1897 assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
1898 }
1899
1900 #[test]
1901 fn a_repository_policy_can_opt_into_a_persistent_workspace() {
1902 let mut policy = repository_policy();
1903 let before = policy.revision();
1904
1905 policy
1906 .set_workspace_policy(
1907 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1908 .expect("a repository may be persistent"),
1909 )
1910 .expect("a repository policy accepts persistence");
1911
1912 assert!(policy.workspace_policy().is_persistent());
1913 assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
1914 assert_eq!(
1915 policy.revision(),
1916 before + 1,
1917 "a workspace change must bump the optimistic token, or `a2`'s guard \
1918 cannot refuse a write built from a stale read"
1919 );
1920
1921 let unchanged = policy.revision();
1924 policy
1925 .set_workspace_policy(policy.workspace_policy().clone())
1926 .expect("re-setting the same policy is accepted");
1927 assert_eq!(policy.revision(), unchanged);
1928 }
1929
1930 #[test]
1931 fn an_organization_policy_cannot_be_made_persistent() {
1932 let mut policy = organization_policy();
1935 let before = policy.revision();
1936
1937 assert_eq!(
1938 policy.set_workspace_policy(WorkspacePolicy::Persistent {
1939 root: workspace_root()
1940 }),
1941 Err(PolicyError::Workspace(
1942 WorkspaceError::PersistentRequiresRepositoryScope
1943 ))
1944 );
1945 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1946 assert_eq!(
1947 policy.revision(),
1948 before,
1949 "a refused write consumes nothing"
1950 );
1951
1952 assert_eq!(
1955 WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
1956 Err(WorkspaceError::PersistentRequiresRepositoryScope)
1957 );
1958 }
1959
1960 #[test]
1961 fn a_workspace_policy_round_trips_through_the_persisted_struct() {
1962 let mut policy = repository_policy();
1963 policy
1964 .set_workspace_policy(
1965 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1966 .expect("a repository may be persistent"),
1967 )
1968 .expect("a repository policy accepts persistence");
1969
1970 let restored = ScalePolicy::from_persisted(policy.to_persisted())
1971 .expect("a policy this crate wrote must load");
1972 assert_eq!(restored, policy);
1973 assert_eq!(restored.workspace_policy(), policy.workspace_policy());
1974
1975 let ephemeral = repository_policy();
1976 assert_eq!(
1977 ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
1978 ephemeral
1979 );
1980 }
1981
1982 #[test]
1983 fn an_organization_row_claiming_persistence_fails_closed_on_load() {
1984 let mut fields = organization_policy().to_persisted();
1985 fields.workspace_kind = WorkspaceKind::Persistent;
1986 fields.workspace_root = Some(workspace_root());
1987
1988 assert_eq!(
1989 ScalePolicy::from_persisted(fields),
1990 Err(PolicyError::Workspace(
1991 WorkspaceError::PersistentRequiresRepositoryScope
1992 ))
1993 );
1994 }
1995
1996 #[test]
1997 fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
1998 let base = repository_policy().to_persisted();
1999
2000 let mut without_root = base.clone();
2001 without_root.workspace_kind = WorkspaceKind::Persistent;
2002 assert_eq!(
2003 ScalePolicy::from_persisted(without_root),
2004 Err(PolicyError::Workspace(
2005 WorkspaceError::PersistentWithoutRoot
2006 ))
2007 );
2008
2009 let mut stale_root = base;
2010 stale_root.workspace_root = Some(workspace_root());
2011 assert!(matches!(
2012 ScalePolicy::from_persisted(stale_root),
2013 Err(PolicyError::Workspace(
2014 WorkspaceError::EphemeralWithRoot { .. }
2015 ))
2016 ));
2017 }
2018
2019 #[test]
2020 fn workspace_retention_is_not_the_runner_package_cache_policy() {
2021 let mut policy = repository_policy();
2025 policy.cache_policy = CachePolicy::DiscardRunnerPackage;
2026 policy
2027 .set_workspace_policy(
2028 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2029 .expect("a repository may be persistent"),
2030 )
2031 .expect("a repository policy accepts persistence");
2032
2033 assert!(policy.workspace_policy().retains_job_workspace());
2034 assert!(!policy.cache_policy.retains_runner_package());
2035 assert!(!policy.cache_policy.retains_job_workspace());
2038 }
2039
2040 #[test]
2045 fn the_derived_label_has_the_shape_the_architecture_gives() {
2046 let labels =
2048 RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2049 assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
2050 assert_eq!(labels.count().get(), 1);
2051 }
2052
2053 #[test]
2054 fn the_derived_label_is_host_scoped_by_construction() {
2055 let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2059 let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
2060
2061 assert_ne!(
2062 a.host_label(),
2063 b.host_label(),
2064 "two hosts must not derive the same routing label; with no job \
2065 reservation, a shared label means both hosts start a runner for one job"
2066 );
2067 assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
2068 assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
2069
2070 let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
2072 assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
2073 assert_ne!(a.host_label(), mac.host_label());
2074 }
2075
2076 #[test]
2077 fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
2078 let labels =
2082 RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
2083 assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
2084 }
2085
2086 #[test]
2087 fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
2088 let mut labels = host_labels("home");
2089 let derived = labels.host_label().clone();
2090
2091 assert!(labels.add(label("gpu")));
2092 assert!(labels.add(label("self-hosted")));
2093 assert!(
2094 !labels.add(label("GPU")),
2095 "adding a label that differs only in case must be a no-op, not a duplicate"
2096 );
2097 assert_eq!(labels.count().get(), 3);
2098
2099 assert!(labels.remove(&label("gpu")).unwrap());
2100 assert_eq!(labels.count().get(), 2);
2101 assert!(
2102 !labels.remove(&label("never-added")).unwrap(),
2103 "removing an absent optional label is a no-op, not an error"
2104 );
2105
2106 assert!(
2108 matches!(
2109 labels.remove(&derived),
2110 Err(PolicyError::HostLabelNotRemovable { .. })
2111 ),
2112 "the derived host label must not be removable; it is the only thing \
2113 keeping two hosts from serving each other's jobs"
2114 );
2115 assert!(labels.contains(&derived));
2116
2117 assert!(matches!(
2119 labels.remove(&label("RM-HOME-WIN-X64")),
2120 Err(PolicyError::HostLabelNotRemovable { .. })
2121 ));
2122 }
2123
2124 #[test]
2125 fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
2126 let mut labels = host_labels("home");
2127 let derived = labels.host_label().clone();
2128 assert!(!labels.add(derived));
2129 assert_eq!(labels.count().get(), 1);
2130
2131 let rebuilt = RoutingLabels::from_parts(
2133 labels.host_label().clone(),
2134 vec![labels.host_label().clone(), label("gpu")],
2135 );
2136 assert_eq!(rebuilt.count().get(), 2);
2137 assert_eq!(
2138 rebuilt.as_registration_labels(),
2139 vec!["rm-home-win-x64", "gpu"]
2140 );
2141 }
2142
2143 #[test]
2144 fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
2145 let mut labels = host_labels("home");
2150 labels.add(label("gpu"));
2151 assert_eq!(
2152 labels.as_registration_labels(),
2153 vec!["rm-home-win-x64", "gpu"]
2154 );
2155 assert!(!labels.contains(&label("self-hosted")));
2156 }
2157
2158 #[test]
2159 fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
2160 let mut labels = host_labels("home");
2161 labels.add(label("gpu"));
2162 let json = serde_json::to_string(&labels).unwrap();
2163 let back: RoutingLabels = serde_json::from_str(&json).unwrap();
2164 assert_eq!(labels, back);
2165 assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
2166 }
2167
2168 #[test]
2169 fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
2170 let labels = host_labels("home");
2172 let non_empty = labels.to_non_empty();
2173 assert_eq!(non_empty.count().get(), 1);
2174 assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
2175 }
2176
2177 struct Row {
2184 name: &'static str,
2185 runs_on: RunsOn,
2186 expect: Expect,
2187 }
2188
2189 #[derive(Debug, PartialEq, Eq)]
2190 enum Expect {
2191 Match,
2192 NoMatch,
2193 Unresolvable,
2194 }
2195
2196 fn classify(m: &RunsOnMatch) -> Expect {
2197 match m {
2198 RunsOnMatch::Match { .. } => Expect::Match,
2199 RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
2200 RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
2201 }
2202 }
2203
2204 fn table_policy() -> RoutingLabels {
2205 let mut labels = host_labels("home");
2207 labels.add(label("self-hosted"));
2208 labels.add(label("gpu"));
2209 labels
2210 }
2211
2212 fn table() -> Vec<Row> {
2213 vec![
2214 Row {
2216 name: "string: the derived host label",
2217 runs_on: RunsOn::Single("rm-home-win-x64".into()),
2218 expect: Expect::Match,
2219 },
2220 Row {
2221 name: "string: the derived host label in the wrong case",
2222 runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
2223 expect: Expect::Match,
2224 },
2225 Row {
2226 name: "string: an optional label alone",
2227 runs_on: RunsOn::Single("gpu".into()),
2228 expect: Expect::Match,
2229 },
2230 Row {
2231 name: "string: another host's label",
2232 runs_on: RunsOn::Single("rm-office-win-x64".into()),
2233 expect: Expect::NoMatch,
2234 },
2235 Row {
2236 name: "string: a GitHub-hosted runner label",
2237 runs_on: RunsOn::Single("ubuntu-latest".into()),
2238 expect: Expect::NoMatch,
2239 },
2240 Row {
2242 name: "array: a strict subset of the policy's labels",
2243 runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
2244 expect: Expect::Match,
2245 },
2246 Row {
2247 name: "array: the whole set, out of order and mixed case",
2248 runs_on: RunsOn::Many(vec![
2249 "GPU".into(),
2250 "Rm-Home-Win-X64".into(),
2251 "Self-Hosted".into(),
2252 ]),
2253 expect: Expect::Match,
2254 },
2255 Row {
2256 name: "array: one label the policy does not carry",
2257 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
2258 expect: Expect::NoMatch,
2259 },
2260 Row {
2261 name: "array: an empty array names no labels",
2262 runs_on: RunsOn::Many(vec![]),
2263 expect: Expect::Unresolvable,
2264 },
2265 Row {
2267 name: "map: labels only",
2268 runs_on: RunsOn::Grouped {
2269 group: None,
2270 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
2271 },
2272 expect: Expect::Match,
2273 },
2274 Row {
2275 name: "map: a group plus labels the policy carries",
2276 runs_on: RunsOn::Grouped {
2277 group: Some("Default".into()),
2278 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
2279 },
2280 expect: Expect::Match,
2281 },
2282 Row {
2283 name: "map: labels as a scalar",
2284 runs_on: RunsOn::Grouped {
2285 group: Some("Default".into()),
2286 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2287 },
2288 expect: Expect::Match,
2289 },
2290 Row {
2291 name: "map: a group plus a label the policy does not carry",
2292 runs_on: RunsOn::Grouped {
2293 group: Some("Default".into()),
2294 labels: RunsOnLabels::Many(vec!["macos".into()]),
2295 },
2296 expect: Expect::NoMatch,
2297 },
2298 Row {
2299 name: "map: a group with no labels constrains something we cannot read",
2300 runs_on: RunsOn::Grouped {
2301 group: Some("Default".into()),
2302 labels: RunsOnLabels::Many(vec![]),
2303 },
2304 expect: Expect::Unresolvable,
2305 },
2306 Row {
2308 name: "expression: the whole value",
2309 runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
2310 expect: Expect::Unresolvable,
2311 },
2312 Row {
2313 name: "expression: one element of an array",
2314 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
2315 expect: Expect::Unresolvable,
2316 },
2317 Row {
2318 name: "expression: inside the map form",
2319 runs_on: RunsOn::Grouped {
2320 group: None,
2321 labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
2322 },
2323 expect: Expect::Unresolvable,
2324 },
2325 Row {
2326 name: "not a usable label at all",
2327 runs_on: RunsOn::Single("rm-home,win-x64".into()),
2328 expect: Expect::Unresolvable,
2329 },
2330 ]
2331 }
2332
2333 #[test]
2334 fn runs_on_matching_covers_every_documented_form() {
2335 let policy = table_policy();
2336 for row in table() {
2337 let got = policy.matches(&row.runs_on);
2338 assert_eq!(
2339 classify(&got),
2340 row.expect,
2341 "row {:?}: {:?} produced {got:?}",
2342 row.name,
2343 row.runs_on
2344 );
2345 }
2346 }
2347
2348 #[test]
2349 fn github_job_labels_are_a_case_insensitive_subset_without_a_selector() {
2350 let labels = RoutingLabels::from_parts(
2351 label("rm-macmini-osx-arm64"),
2352 [
2353 label("arm64"),
2354 label("macos"),
2355 label("self-hosted"),
2356 label("extra"),
2357 ],
2358 );
2359 assert!(
2360 labels
2361 .matches(&RunsOn::Many(vec![
2362 "self-hosted".into(),
2363 "macOS".into(),
2364 "ARM64".into(),
2365 ]))
2366 .is_match()
2367 );
2368 for missing in ["X64", "Windows", "Linux", "gpu", "rm-other-osx-arm64"] {
2369 let result = labels.matches(&RunsOn::Many(vec![
2370 "self-hosted".into(),
2371 "macOS".into(),
2372 "ARM64".into(),
2373 missing.into(),
2374 ]));
2375 assert_eq!(
2376 result,
2377 RunsOnMatch::NoMatch {
2378 missing: vec![label(missing)]
2379 }
2380 );
2381 }
2382 }
2383
2384 #[test]
2385 fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2386 let without = host_labels("home");
2390 assert!(
2391 !without
2392 .matches(&RunsOn::Single("self-hosted".into()))
2393 .is_match(),
2394 "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2395 );
2396
2397 let mut with = host_labels("home");
2398 with.add(label("self-hosted"));
2399 assert!(
2400 with.matches(&RunsOn::Single("self-hosted".into()))
2401 .is_match()
2402 );
2403 assert!(
2404 with.matches(&RunsOn::Many(vec![
2405 "rm-home-win-x64".into(),
2406 "self-hosted".into()
2407 ]))
2408 .is_match()
2409 );
2410 }
2411
2412 #[test]
2413 fn a_no_match_names_the_labels_that_were_missing() {
2414 let policy = host_labels("home");
2415 let got = policy.matches(&RunsOn::Many(vec![
2416 "rm-home-win-x64".into(),
2417 "self-hosted".into(),
2418 "GPU".into(),
2419 ]));
2420 match got {
2421 RunsOnMatch::NoMatch { missing } => {
2422 assert_eq!(
2423 missing,
2424 vec![label("self-hosted"), label("gpu")],
2425 "the operator needs to know which labels to add"
2426 );
2427 }
2428 other => panic!("expected NoMatch, got {other:?}"),
2429 }
2430 }
2431
2432 #[test]
2433 fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2434 let policy = host_labels("home");
2435 let got = policy.matches(&RunsOn::Grouped {
2436 group: Some("Default".into()),
2437 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2438 });
2439 assert_eq!(
2440 got,
2441 RunsOnMatch::Match {
2442 runner_group: Some("Default".into())
2443 },
2444 "a policy has no runner-group field, so the domain cannot evaluate \
2445 `group:`; returning it lets `c4`, which can, do so without re-parsing"
2446 );
2447 }
2448
2449 #[test]
2450 fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2451 let policy = host_labels("home");
2452
2453 let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2454 assert!(matches!(
2455 expr,
2456 RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2457 ));
2458
2459 let group = policy.matches(&RunsOn::Grouped {
2460 group: Some("g".into()),
2461 labels: RunsOnLabels::Many(vec![]),
2462 });
2463 assert!(matches!(
2464 group,
2465 RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2466 ));
2467
2468 let none = policy.matches(&RunsOn::Many(vec![]));
2469 assert!(matches!(
2470 none,
2471 RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2472 ));
2473
2474 let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2475 assert!(matches!(
2476 invalid,
2477 RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2478 ));
2479 }
2480
2481 #[test]
2482 fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2483 let policy = table_policy();
2487 let jobs = vec![
2488 RunsOn::Single("rm-home-win-x64".into()), RunsOn::Single("ubuntu-latest".into()), RunsOn::Single("${{ matrix.runner }}".into()), RunsOn::Single("${{ inputs.pool }}".into()), ];
2493 let tally = policy.tally(&jobs);
2494
2495 assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2496 assert_eq!(tally.not_matched, 1);
2497 assert_eq!(
2498 tally.unresolvable.len(),
2499 2,
2500 "and it must not vanish either -- `g2` shows these to the operator"
2501 );
2502 assert_eq!(
2503 tally.total_seen(),
2504 jobs.len() as u32,
2505 "every job seen is accounted for in exactly one bucket"
2506 );
2507 }
2508
2509 #[test]
2510 fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2511 let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2512 assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2513
2514 let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2515 assert_eq!(
2516 many,
2517 RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2518 );
2519
2520 let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2521 assert_eq!(
2522 grouped,
2523 RunsOn::Grouped {
2524 group: Some("g".into()),
2525 labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2526 }
2527 );
2528
2529 let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2530 assert_eq!(
2531 scalar_labels,
2532 RunsOn::Grouped {
2533 group: None,
2534 labels: RunsOnLabels::One("a".into()),
2535 }
2536 );
2537
2538 let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2539 assert_eq!(
2540 group_only,
2541 RunsOn::Grouped {
2542 group: Some("g".into()),
2543 labels: RunsOnLabels::Many(vec![]),
2544 }
2545 );
2546
2547 assert_eq!(
2549 RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2550 RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2551 );
2552 }
2553
2554 #[test]
2559 fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2560 let labels = host_labels("home");
2561
2562 assert!(matches!(
2565 PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2566 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2567 ));
2568 assert!(matches!(
2569 PolicyMode::from_persisted(None, 0, Some(nz(1))),
2570 Err(PolicyError::AutoscaleWithoutRoutingLabels)
2571 ));
2572
2573 assert!(
2575 PolicyMode::from_persisted(None, 0, None)
2576 .unwrap()
2577 .is_monitor_only()
2578 );
2579 assert!(
2580 PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2581 .unwrap()
2582 .is_autoscale()
2583 );
2584 }
2585
2586 #[test]
2587 fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2588 let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2595 assert!(autoscale.routing_labels().is_some());
2596 assert!(autoscale.max_capacity().is_some());
2597
2598 let monitor = PolicyMode::monitor_only();
2599 assert!(monitor.routing_labels().is_none());
2600 assert!(monitor.max_capacity().is_none());
2601 assert_eq!(monitor.min_capacity(), 0);
2602 }
2603
2604 #[test]
2605 fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2606 let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2609 assert!(matches!(
2610 err,
2611 PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2612 ));
2613 assert!(
2614 err.to_string().contains("MonitorOnly"),
2615 "the message must name the shape rule, got: {err}"
2616 );
2617 }
2618
2619 #[test]
2620 fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2621 assert!(matches!(
2627 PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2628 Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2629 ));
2630 assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2631 assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2632
2633 let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2635 assert!(matches!(
2636 cfg.set_max_capacity(nz(1)),
2637 Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2638 ));
2639 assert_eq!(
2640 cfg.max_capacity().get(),
2641 4,
2642 "a refused write changes nothing"
2643 );
2644 }
2645
2646 #[test]
2647 fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2648 for mode in [
2649 PolicyMode::monitor_only(),
2650 PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2651 ] {
2652 let json = serde_json::to_string(&mode).unwrap();
2653 let back: PolicyMode = serde_json::from_str(&json).unwrap();
2654 assert_eq!(mode, back, "{json} did not round-trip");
2655 }
2656
2657 let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2660 let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2661 assert!(
2662 err.to_string().contains("min_capacity"),
2663 "expected the shape error to survive into serde's message, got: {err}"
2664 );
2665 }
2666
2667 #[test]
2668 fn a_policy_round_trips_through_its_persisted_form() {
2669 let mut policy = autoscale_policy(
2677 ScaleTarget::repository("o/r").unwrap(),
2678 HostId::from_u128(7),
2679 3,
2680 );
2681 policy.add_routing_label(label("gpu")).unwrap();
2682 policy.activate().unwrap();
2688 assert_eq!(policy.state(), PolicyState::Active);
2689 assert!(policy.enabled());
2690
2691 let stored = policy.to_persisted();
2692 assert_ne!(
2693 stored.installation_id, stored.revision,
2694 "the fixture must distinguish the two u64 columns, or transposing \
2695 them is unobservable and this test proves nothing"
2696 );
2697 assert_eq!(stored.installation_id, 42);
2698 assert_eq!(stored.revision, policy.revision());
2699
2700 let restored =
2701 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2702 assert_eq!(restored, policy);
2703 assert_eq!(restored.installation_id, 42);
2704 assert_eq!(restored.revision(), policy.revision());
2705 assert_eq!(restored.state(), PolicyState::Active);
2706 assert!(restored.enabled());
2707 assert_eq!(
2708 restored.routing_labels().unwrap().count().get(),
2709 2,
2710 "the optional label survives alongside the host label"
2711 );
2712 }
2713
2714 #[test]
2715 fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
2716 let mut policy = ScalePolicy::new(
2724 PolicyId::from_u128(2),
2725 ScaleTarget::organization("acme").unwrap(),
2726 9,
2727 HostId::from_u128(7),
2728 PolicyMode::monitor_only(),
2729 CachePolicy::default(),
2730 );
2731 policy.activate().unwrap();
2732
2733 let stored = policy.to_persisted();
2734 assert!(stored.routing_labels.is_none());
2735 assert_eq!(stored.min_capacity, 0);
2736 assert!(stored.max_capacity.is_none());
2737 assert_ne!(stored.installation_id, stored.revision);
2738
2739 let restored =
2740 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2741 assert_eq!(restored, policy);
2742 assert!(
2743 restored.mode().is_monitor_only(),
2744 "the mode is inferred back from the three columns, not stored"
2745 );
2746 assert!(!restored.owns_runners());
2747 assert_eq!(restored.installation_id, 9);
2748 assert_eq!(restored.revision(), 1);
2749 }
2750
2751 fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
2771 use PolicyState::*;
2772 let mut edges = vec![
2773 (Pending, Active),
2774 (Pending, RepairRequired),
2775 (Active, Draining),
2776 (Draining, Disabled),
2777 (Disabled, Pending),
2778 (AuthenticationFailed, Pending),
2780 ];
2781 for from in PolicyState::ALL {
2783 if from != AuthenticationFailed {
2784 edges.push((from, AuthenticationFailed));
2785 }
2786 }
2787 edges
2788 }
2789
2790 #[test]
2791 fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
2792 let expected = diagram_edges();
2795 assert_eq!(
2796 expected.len(),
2797 11,
2798 "the transcription itself changed; check it against the diagram"
2799 );
2800
2801 let mut legal_seen = 0usize;
2802 let mut illegal_seen = 0usize;
2803
2804 for from in PolicyState::ALL {
2805 for to in PolicyState::ALL {
2806 let expected_legal = expected.contains(&(from, to));
2807 let mut policy = autoscale_policy(
2808 ScaleTarget::repository("o/r").unwrap(),
2809 HostId::from_u128(7),
2810 1,
2811 );
2812 policy.state = from;
2816
2817 let result = policy.transition_to(to);
2818 if expected_legal {
2819 legal_seen += 1;
2820 assert!(
2821 result.is_ok(),
2822 "{from} -> {to} is in the diagram and must be accepted"
2823 );
2824 assert_eq!(policy.state(), to);
2825 } else {
2826 illegal_seen += 1;
2827 assert!(
2828 matches!(result, Err(PolicyError::IllegalTransition { .. })),
2829 "{from} -> {to} is not in the diagram and must be rejected"
2830 );
2831 assert_eq!(policy.state(), from, "a refused transition changes nothing");
2832 }
2833 }
2834 }
2835
2836 assert_eq!(legal_seen, 11);
2837 assert_eq!(illegal_seen, 36 - 11);
2838
2839 let mut published = PolicyState::LEGAL.to_vec();
2842 let mut transcribed = expected;
2843 published.sort_unstable();
2844 transcribed.sort_unstable();
2845 assert_eq!(published, transcribed);
2846 }
2847
2848 #[test]
2849 fn a_policy_state_cannot_transition_to_itself() {
2850 for state in PolicyState::ALL {
2851 assert!(
2852 !state.can_transition_to(state),
2853 "{state} -> {state} is not an edge in the diagram; treating it as \
2854 one would let a repeated authentication failure look like progress"
2855 );
2856 }
2857 }
2858
2859 #[test]
2860 fn the_documented_happy_path_walks_pending_to_disabled() {
2861 let mut policy = autoscale_policy(
2862 ScaleTarget::repository("o/r").unwrap(),
2863 HostId::from_u128(7),
2864 2,
2865 );
2866
2867 assert_eq!(policy.state(), PolicyState::Pending);
2869 assert!(!policy.enabled());
2870 assert!(!policy.may_start_runners());
2871
2872 policy.activate().unwrap();
2873 assert_eq!(policy.state(), PolicyState::Active);
2874 assert!(policy.enabled());
2875 assert!(policy.may_start_runners());
2876
2877 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2879 assert_eq!(
2880 policy.drain_completed(1).unwrap(),
2881 PolicyState::Draining,
2882 "a policy with a runner still in flight stays draining"
2883 );
2884 assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
2885 }
2886
2887 #[test]
2888 fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
2889 let mut policy = autoscale_policy(
2892 ScaleTarget::repository("o/r").unwrap(),
2893 HostId::from_u128(7),
2894 5,
2895 );
2896 policy.activate().unwrap();
2897
2898 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
2901 assert_eq!(policy.tally(&jobs).demand(), 4);
2902
2903 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2904 assert!(!policy.enabled());
2905 assert!(
2906 !policy.may_start_runners(),
2907 "a draining policy must not be the reason a new runner starts, even \
2908 with four jobs queued for its labels"
2909 );
2910 assert_eq!(
2911 policy.tally(&jobs).demand(),
2912 4,
2913 "queued demand stays visible while draining (flow 5.2)"
2914 );
2915 }
2916
2917 #[test]
2918 fn re_authentication_is_the_only_way_out_of_authentication_failed() {
2919 for from in [
2920 PolicyState::Pending,
2921 PolicyState::Active,
2922 PolicyState::Draining,
2923 PolicyState::Disabled,
2924 PolicyState::RepairRequired,
2925 ] {
2926 let mut policy = autoscale_policy(
2927 ScaleTarget::repository("o/r").unwrap(),
2928 HostId::from_u128(7),
2929 1,
2930 );
2931 policy.state = from;
2932 policy.authentication_failed().unwrap();
2933 assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
2934
2935 assert!(matches!(
2937 policy.authentication_failed(),
2938 Err(PolicyError::IllegalTransition { .. })
2939 ));
2940
2941 policy.reauthenticated().unwrap();
2942 assert_eq!(policy.state(), PolicyState::Pending);
2943 }
2944 }
2945
2946 #[test]
2947 fn mutant_disabling_revoked_eligibility_gate_is_detected() {
2948 let mut policy = autoscale_policy(
2949 ScaleTarget::repository("o/r").unwrap(),
2950 HostId::from_u128(7),
2951 1,
2952 );
2953 policy.activate().unwrap();
2954 policy.authentication_failed().unwrap();
2955 assert!(!policy.may_start_runners());
2956
2957 let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
2961 assert!(
2962 mutant_may_start,
2963 "omitting revoked state must make the eligibility gate red"
2964 );
2965 }
2966
2967 #[test]
2968 fn a_refused_transition_leaves_the_revision_untouched() {
2969 let mut policy = autoscale_policy(
2970 ScaleTarget::repository("o/r").unwrap(),
2971 HostId::from_u128(7),
2972 1,
2973 );
2974 assert_eq!(policy.revision(), 0);
2975 policy.activate().unwrap();
2976 assert_eq!(policy.revision(), 1);
2977
2978 assert!(policy.activate().is_err());
2979 assert_eq!(
2980 policy.revision(),
2981 1,
2982 "a rejected write must not bump the optimistic-concurrency token, or \
2983 `b2`'s stale-revision check would reject the next honest write"
2984 );
2985 }
2986
2987 #[test]
2992 fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
2993 let mut policy = ScalePolicy::new(
2994 PolicyId::from_u128(2),
2995 ScaleTarget::organization("acme").unwrap(),
2996 9,
2997 HostId::from_u128(7),
2998 PolicyMode::monitor_only(),
2999 CachePolicy::default(),
3000 );
3001 policy.activate().unwrap();
3002
3003 assert!(!policy.owns_runners());
3004 assert!(
3005 !policy.may_start_runners(),
3006 "an active, enabled monitor-only policy still starts nothing (D19)"
3007 );
3008 assert!(policy.routing_labels().is_none());
3009 assert!(policy.max_capacity().is_none());
3010
3011 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
3013 assert_eq!(
3014 policy.tally(&jobs).demand(),
3015 0,
3016 "a monitor-only policy has no demand at all, rather than demand that \
3017 is computed and then ignored"
3018 );
3019
3020 assert!(matches!(
3023 policy.add_routing_label(label("gpu")),
3024 Err(PolicyError::NotAutoscale)
3025 ));
3026 assert!(matches!(
3027 policy.remove_routing_label(&label("gpu")),
3028 Err(PolicyError::NotAutoscale)
3029 ));
3030 assert!(matches!(
3038 PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
3039 Err(PolicyError::AutoscaleWithoutMaxCapacity)
3040 ));
3041 }
3042
3043 #[test]
3044 fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
3045 let mut policy = ScalePolicy::new(
3048 PolicyId::from_u128(2),
3049 ScaleTarget::repository("o/r").unwrap(),
3050 9,
3051 HostId::from_u128(7),
3052 PolicyMode::monitor_only(),
3053 CachePolicy::default(),
3054 );
3055 assert!(policy.routing_labels().is_none());
3056
3057 policy
3058 .promote_to_autoscale(host_labels("home"), 0, nz(3))
3059 .unwrap();
3060
3061 assert!(policy.owns_runners());
3062 assert_eq!(
3063 policy.routing_labels().unwrap().host_label().as_str(),
3064 "rm-home-win-x64"
3065 );
3066 assert_eq!(policy.max_capacity().unwrap().get(), 3);
3067
3068 assert!(matches!(
3070 policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
3071 Err(PolicyError::AlreadyAutoscale)
3072 ));
3073 policy.set_max_capacity(nz(4)).unwrap();
3074 assert_eq!(policy.max_capacity().unwrap().get(), 4);
3075 }
3076
3077 #[test]
3078 fn a_named_monitor_can_only_be_promoted_with_its_derived_selector() {
3079 let mut named = ScalePolicy::new_named_monitor(
3080 PolicyId::from_u128(3),
3081 ScaleTarget::repository("o/r").unwrap(),
3082 9,
3083 HostId::from_u128(7),
3084 HostLabel::new("home").unwrap(),
3085 ProfileName::new("Py-Isolated").unwrap(),
3086 CachePolicy::default(),
3087 )
3088 .unwrap();
3089 assert!(matches!(
3090 named.promote_to_autoscale(host_labels("other"), 0, nz(3)),
3091 Err(PolicyError::NamedProfileNeedsDerivedSelector)
3092 ));
3093 assert!(named.routing_labels().is_none());
3094 named
3095 .promote_named_to_autoscale(Os::Windows, Arch::X64, 0, nz(3))
3096 .unwrap();
3097 assert_eq!(
3098 named.routing_labels().unwrap().host_label().as_str(),
3099 "rm-home-win-x64-py-isolated"
3100 );
3101 }
3102
3103 #[test]
3104 fn a_policy_is_owned_by_exactly_one_host() {
3105 let mine = HostId::from_u128(7);
3106 let theirs = HostId::from_u128(8);
3107 let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
3108 assert!(policy.is_owned_by(mine));
3109 assert!(!policy.is_owned_by(theirs));
3110 }
3111
3112 #[test]
3113 fn an_overridden_host_label_is_detectable_without_being_rejected() {
3114 assert!(host_labels("home").is_derived_shape());
3118 assert!(
3119 RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
3120 .is_derived_shape(),
3121 "a host label containing `-` still derives a four-plus-segment name"
3122 );
3123 assert_eq!(
3129 host_labels("home--pc").host_label().as_str(),
3130 "rm-home--pc-win-x64"
3131 );
3132 assert!(
3133 host_labels("home--pc").is_derived_shape(),
3134 "consecutive dashes are legal inside a host label; the empty middle \
3135 segment they produce is not evidence of an override"
3136 );
3137
3138 for raw in [
3142 "self-hosted",
3143 "ubuntu-latest",
3144 "rm-home-win",
3145 "rm-home-win-x64-extra",
3146 ] {
3147 assert!(
3148 !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
3149 "{raw:?} is not the derived shape"
3150 );
3151 }
3152 assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
3154 assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
3155 assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
3156
3157 let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
3159 overridden.add(label("rm-home-win-x64"));
3160 assert!(!overridden.is_derived_shape());
3161 }
3162
3163 #[test]
3164 fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
3165 let mut policy = autoscale_policy(
3169 ScaleTarget::repository("o/r").unwrap(),
3170 HostId::from_u128(7),
3171 3,
3172 );
3173
3174 assert!(policy.can_activate());
3175 assert!(
3176 !policy.can_request_disable(),
3177 "a pending policy cannot drain; it is also already not enabled, \
3178 which is what makes the command a no-op rather than a failure"
3179 );
3180 assert!(matches!(
3181 policy.request_disable(),
3182 Err(PolicyError::IllegalTransition {
3183 from: PolicyState::Pending,
3184 to: PolicyState::Draining,
3185 })
3186 ));
3187
3188 policy.activate().unwrap();
3189 assert!(!policy.can_activate(), "already active");
3190 assert!(policy.can_request_disable());
3191 assert!(matches!(
3192 policy.activate(),
3193 Err(PolicyError::IllegalTransition { .. })
3194 ));
3195
3196 policy.request_disable().unwrap();
3197 assert!(!policy.can_request_disable(), "already draining");
3198 assert!(!policy.enabled());
3199 }
3200
3201 fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
3225 let host = HostId::from_u128(7);
3226 let mut trace = Vec::new();
3227
3228 let mut policy = autoscale_policy(target.clone(), host, 3);
3229 trace.push(format!("owns_runners={}", policy.owns_runners()));
3230 trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
3231 trace.push(format!(
3232 "owned_by_other={}",
3233 policy.is_owned_by(HostId::from_u128(8))
3234 ));
3235 trace.push(format!("initial_state={}", policy.state()));
3236 trace.push(format!("initial_enabled={}", policy.enabled()));
3237 trace.push(format!(
3238 "may_start_initially={}",
3239 policy.may_start_runners()
3240 ));
3241 trace.push(format!("labels={}", policy.routing_labels().unwrap()));
3242 trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
3243 trace.push(format!("min_capacity={}", policy.min_capacity()));
3244
3245 policy.activate().unwrap();
3247 trace.push(format!("after_activate={}", policy.state()));
3248 trace.push(format!("may_start_active={}", policy.may_start_runners()));
3249
3250 let jobs = vec![
3252 RunsOn::Single("rm-home-win-x64".into()),
3253 RunsOn::Single("ubuntu-latest".into()),
3254 RunsOn::Single("${{ matrix.os }}".into()),
3255 ];
3256 let tally = policy.tally(&jobs);
3257 trace.push(format!(
3258 "demand={} not_matched={} unresolvable={}",
3259 tally.demand(),
3260 tally.not_matched,
3261 tally.unresolvable.len()
3262 ));
3263
3264 let host_record = crate::model::Host::new(
3268 host,
3269 "home-pc",
3270 Os::Windows,
3271 Arch::X64,
3272 nz(4),
3273 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3274 )
3275 .unwrap();
3276 let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
3277 let allocation = allocator.allocate(&policy, 3);
3278 trace.push(format!(
3279 "alloc demand={} desired={} active_owned={} headroom_before={} \
3280 to_start={} limiting={}",
3281 allocation.demand,
3282 allocation.desired,
3283 allocation.active_owned,
3284 allocation.headroom_before,
3285 allocation.to_start,
3286 allocation.limiting_factor
3287 ));
3288 trace.push(format!("headroom_after={}", allocator.headroom()));
3289 trace.push(format!(
3292 "alloc_zero_to_start={}",
3293 allocator.allocate(&policy, 0).to_start
3294 ));
3295
3296 let attempt = crate::attempt::RunnerAttempt::allocate(
3300 crate::model::AttemptId::from_u128(11),
3301 policy.id,
3302 "C:/runners/eq",
3303 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3304 );
3305 trace.push(format!(
3306 "authorize_own_host={:?}",
3307 crate::attempt::authorize(host, &policy, &attempt).is_ok()
3308 ));
3309 trace.push(format!(
3310 "authorize_other_host={}",
3311 crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
3312 .expect_err("an agent on another host must be refused")
3313 ));
3314 let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
3315 crate::model::AttemptId::from_u128(12),
3316 PolicyId::from_u128(999),
3317 "C:/runners/eq-other",
3318 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3319 );
3320 trace.push(format!(
3321 "authorize_other_policy={}",
3322 crate::attempt::authorize(host, &policy, &foreign_attempt)
3323 .expect_err("an attempt under another policy must be refused")
3324 ));
3325
3326 trace.push(format!("disable={}", policy.request_disable().unwrap()));
3328 trace.push(format!(
3329 "drain_with_1={}",
3330 policy.drain_completed(1).unwrap()
3331 ));
3332 trace.push(format!(
3333 "drain_with_0={}",
3334 policy.drain_completed(0).unwrap()
3335 ));
3336
3337 trace.push(format!(
3339 "reactivate_err={}",
3340 policy.transition_to(PolicyState::Active).is_err()
3341 ));
3342
3343 trace.push(format!(
3345 "registration_labels={:?}",
3346 autoscale_policy(target, host, 3)
3347 .routing_labels()
3348 .unwrap()
3349 .as_registration_labels()
3350 ));
3351
3352 trace
3353 }
3354
3355 #[test]
3356 fn repository_and_organization_targets_are_equivalent() {
3357 let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
3358 let organization =
3359 assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
3360
3361 assert_eq!(
3362 repository, organization,
3363 "D18: the two scopes differ only in which GitHub endpoint and which \
3364 App permission the gateway uses. Any difference here is a \
3365 scope-dependent domain rule that must not exist."
3366 );
3367
3368 assert_ne!(
3370 ScaleTarget::repository("o/r").unwrap().scope(),
3371 ScaleTarget::organization("o").unwrap().scope()
3372 );
3373 }
3374}