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
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
147pub struct RoutingLabels {
148 host_label: Label,
149 additional: BTreeSet<Label>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153struct RoutingLabelsRepr {
154 host_label: Label,
155 #[serde(default)]
156 additional: BTreeSet<Label>,
157}
158
159impl From<RoutingLabelsRepr> for RoutingLabels {
160 fn from(repr: RoutingLabelsRepr) -> Self {
161 Self::from_parts(repr.host_label, repr.additional)
166 }
167}
168
169impl From<RoutingLabels> for RoutingLabelsRepr {
170 fn from(value: RoutingLabels) -> Self {
171 Self {
172 host_label: value.host_label,
173 additional: value.additional,
174 }
175 }
176}
177
178impl RoutingLabels {
179 pub const PREFIX: &'static str = "rm";
181
182 #[must_use]
196 pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
197 let derived = format!(
198 "{}-{}-{}-{}",
199 Self::PREFIX,
200 host_label.as_str(),
201 os.label_token(),
202 arch.label_token()
203 );
204 Self {
205 host_label: Label::new(derived).expect(
206 "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
207 segments are fixed tokens, so the concatenation is always a valid Label",
208 ),
209 additional: BTreeSet::new(),
210 }
211 }
212
213 #[must_use]
217 pub fn derive_for_profile(
218 host_label: &HostLabel,
219 os: Os,
220 arch: Arch,
221 profile: &ProfileName,
222 ) -> Self {
223 let label = format!(
224 "{}-{}-{}-{}-{}",
225 Self::PREFIX,
226 host_label.as_str(),
227 os.label_token(),
228 arch.label_token(),
229 profile.as_str()
230 );
231 Self::from_host_label(Label::new(label).expect("validated selector segments form a label"))
232 }
233
234 #[must_use]
247 pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
248 let additional = additional
249 .into_iter()
250 .filter(|l| *l != host_label)
251 .collect();
252 Self {
253 host_label,
254 additional,
255 }
256 }
257
258 #[must_use]
260 pub fn from_host_label(host_label: Label) -> Self {
261 Self::from_parts(host_label, Vec::new())
262 }
263
264 #[must_use]
266 pub fn host_label(&self) -> &Label {
267 &self.host_label
268 }
269
270 #[must_use]
301 pub fn is_derived_shape(&self) -> bool {
302 let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
303 let [prefix, middle @ .., os, arch] = segments.as_slice() else {
308 return false;
309 };
310 *prefix == Self::PREFIX
313 && !middle.is_empty()
314 && Os::ALL
315 .iter()
316 .any(|candidate| candidate.label_token() == *os)
317 && Arch::ALL
318 .iter()
319 .any(|candidate| candidate.label_token() == *arch)
320 }
321
322 pub fn additional(&self) -> impl Iterator<Item = &Label> {
324 self.additional.iter()
325 }
326
327 pub fn add(&mut self, label: Label) -> bool {
330 if label == self.host_label {
331 return false;
332 }
333 self.additional.insert(label)
334 }
335
336 pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
343 if *label == self.host_label {
344 return Err(PolicyError::HostLabelNotRemovable {
345 label: label.clone(),
346 });
347 }
348 Ok(self.additional.remove(label))
349 }
350
351 #[must_use]
352 pub fn contains(&self, label: &Label) -> bool {
353 self.host_label == *label || self.additional.contains(label)
354 }
355
356 pub fn iter(&self) -> impl Iterator<Item = &Label> {
358 std::iter::once(&self.host_label).chain(self.additional.iter())
359 }
360
361 #[must_use]
363 pub fn count(&self) -> NonZeroUsize {
364 NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
365 }
366
367 #[must_use]
369 pub fn to_non_empty(&self) -> NonEmpty<Label> {
370 let mut out = NonEmpty::of(self.host_label.clone());
371 for label in &self.additional {
372 out.push(label.clone());
373 }
374 out
375 }
376
377 #[must_use]
387 pub fn as_registration_labels(&self) -> Vec<String> {
388 self.iter().map(|l| l.as_str().to_string()).collect()
389 }
390
391 #[must_use]
398 pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
399 let required = match runs_on.required_labels() {
400 Ok(required) => required,
401 Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
402 };
403
404 let missing: Vec<Label> = required
405 .iter()
406 .filter(|label| !self.contains(label))
407 .cloned()
408 .collect();
409
410 if missing.is_empty() {
411 RunsOnMatch::Match {
412 runner_group: runs_on.runner_group().map(str::to_string),
413 }
414 } else {
415 RunsOnMatch::NoMatch { missing }
416 }
417 }
418
419 #[must_use]
427 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
428 let mut tally = DemandTally::default();
429 for job in jobs {
430 match self.matches(job) {
431 RunsOnMatch::Match { .. } => tally.matched += 1,
432 RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
433 RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
434 }
435 }
436 tally
437 }
438}
439
440impl fmt::Display for RoutingLabels {
441 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
442 let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
443 f.write_str(&joined.join(","))
444 }
445}
446
447#[derive(Debug, Clone, Default, PartialEq, Eq)]
449pub struct DemandTally {
450 pub matched: u32,
452 pub not_matched: u32,
454 pub unresolvable: Vec<UnresolvableRunsOn>,
458}
459
460impl DemandTally {
461 #[must_use]
462 pub fn demand(&self) -> u32 {
463 self.matched
464 }
465
466 #[must_use]
467 pub fn total_seen(&self) -> u32 {
468 self.matched + self.not_matched + self.unresolvable.len() as u32
469 }
470}
471
472#[derive(Debug, Clone, PartialEq, Eq)]
474pub enum RunsOnMatch {
475 Match { runner_group: Option<String> },
483 NoMatch { missing: Vec<Label> },
486 Unresolvable(UnresolvableRunsOn),
488}
489
490impl RunsOnMatch {
491 #[must_use]
492 pub const fn is_match(&self) -> bool {
493 matches!(self, RunsOnMatch::Match { .. })
494 }
495
496 #[must_use]
497 pub const fn is_unresolvable(&self) -> bool {
498 matches!(self, RunsOnMatch::Unresolvable(_))
499 }
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
504pub enum UnresolvableRunsOn {
505 #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
508 Expression { raw: String },
509
510 #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
513 RunnerGroupWithoutLabels { group: String },
514
515 #[error("`runs-on` names no labels")]
517 NoLabels,
518
519 #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
521 InvalidLabel {
522 raw: String,
523 #[source]
524 source: ValidationError,
525 },
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(untagged)]
536pub enum RunsOn {
537 Single(String),
539 Many(Vec<String>),
541 Grouped {
543 #[serde(default)]
544 group: Option<String>,
545 #[serde(default)]
546 labels: RunsOnLabels,
547 },
548}
549
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(untagged)]
553pub enum RunsOnLabels {
554 One(String),
555 Many(Vec<String>),
556}
557
558impl Default for RunsOnLabels {
559 fn default() -> Self {
560 Self::Many(Vec::new())
561 }
562}
563
564impl RunsOnLabels {
565 fn as_slice(&self) -> &[String] {
566 match self {
567 RunsOnLabels::One(one) => std::slice::from_ref(one),
568 RunsOnLabels::Many(many) => many,
569 }
570 }
571}
572
573impl RunsOn {
574 #[must_use]
576 pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
577 Self::Many(labels.into_iter().map(Into::into).collect())
578 }
579
580 #[must_use]
582 pub fn runner_group(&self) -> Option<&str> {
583 match self {
584 RunsOn::Grouped { group, .. } => group.as_deref(),
585 _ => None,
586 }
587 }
588
589 fn raw_labels(&self) -> &[String] {
590 match self {
591 RunsOn::Single(one) => std::slice::from_ref(one),
592 RunsOn::Many(many) => many,
593 RunsOn::Grouped { labels, .. } => labels.as_slice(),
594 }
595 }
596
597 pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
614 let raws = self.raw_labels();
615
616 if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
617 return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
618 }
619
620 let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
621
622 if usable.is_empty() {
623 return match self.runner_group() {
624 Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
625 group: group.to_string(),
626 }),
627 None => Err(UnresolvableRunsOn::NoLabels),
628 };
629 }
630
631 usable
632 .into_iter()
633 .map(|raw| {
634 Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
635 raw: raw.clone(),
636 source,
637 })
638 })
639 .collect()
640 }
641}
642
643fn is_expression(raw: &str) -> bool {
644 raw.contains("${{")
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(try_from = "AutoscaleConfigRepr")]
659pub struct AutoscaleConfig {
660 routing_labels: RoutingLabels,
661 min_capacity: u16,
662 max_capacity: NonZeroU16,
663}
664
665#[derive(Debug, Deserialize)]
666struct AutoscaleConfigRepr {
667 routing_labels: RoutingLabels,
668 min_capacity: u16,
669 max_capacity: NonZeroU16,
670}
671
672impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
673 type Error = PolicyError;
674
675 fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
676 Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
677 }
678}
679
680impl AutoscaleConfig {
681 pub fn new(
687 routing_labels: RoutingLabels,
688 min_capacity: u16,
689 max_capacity: NonZeroU16,
690 ) -> Result<Self, PolicyError> {
691 if min_capacity > max_capacity.get() {
692 return Err(PolicyError::InvertedCapacityRange {
693 min: min_capacity,
694 max: max_capacity.get(),
695 });
696 }
697 Ok(Self {
698 routing_labels,
699 min_capacity,
700 max_capacity,
701 })
702 }
703
704 pub fn v1(
710 routing_labels: RoutingLabels,
711 max_capacity: NonZeroU16,
712 ) -> Result<Self, PolicyError> {
713 Self::new(routing_labels, 0, max_capacity)
714 }
715
716 #[must_use]
717 pub fn routing_labels(&self) -> &RoutingLabels {
718 &self.routing_labels
719 }
720
721 #[must_use]
722 pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
723 &mut self.routing_labels
724 }
725
726 #[must_use]
727 pub const fn min_capacity(&self) -> u16 {
728 self.min_capacity
729 }
730
731 #[must_use]
732 pub const fn max_capacity(&self) -> NonZeroU16 {
733 self.max_capacity
734 }
735
736 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
740 if self.min_capacity > max_capacity.get() {
741 return Err(PolicyError::InvertedCapacityRange {
742 min: self.min_capacity,
743 max: max_capacity.get(),
744 });
745 }
746 self.max_capacity = max_capacity;
747 Ok(())
748 }
749}
750
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771#[serde(tag = "mode", rename_all = "snake_case")]
772pub enum PolicyMode {
773 MonitorOnly,
776 Autoscale(AutoscaleConfig),
778}
779
780impl PolicyMode {
781 #[must_use]
782 pub const fn monitor_only() -> Self {
783 Self::MonitorOnly
784 }
785
786 pub fn autoscale(
789 routing_labels: RoutingLabels,
790 min_capacity: u16,
791 max_capacity: NonZeroU16,
792 ) -> Result<Self, PolicyError> {
793 Ok(Self::Autoscale(AutoscaleConfig::new(
794 routing_labels,
795 min_capacity,
796 max_capacity,
797 )?))
798 }
799
800 pub fn from_persisted(
810 routing_labels: Option<RoutingLabels>,
811 min_capacity: u16,
812 max_capacity: Option<NonZeroU16>,
813 ) -> Result<Self, PolicyError> {
814 match (routing_labels, max_capacity) {
815 (None, None) => {
816 if min_capacity != 0 {
817 return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
822 }
823 Ok(Self::MonitorOnly)
824 }
825 (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
826 (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
827 (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
828 }
829 }
830
831 #[must_use]
834 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
835 match self {
836 PolicyMode::MonitorOnly => None,
837 PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
838 }
839 }
840
841 #[must_use]
842 pub const fn min_capacity(&self) -> u16 {
843 match self {
844 PolicyMode::MonitorOnly => 0,
845 PolicyMode::Autoscale(cfg) => cfg.min_capacity,
846 }
847 }
848
849 #[must_use]
850 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
851 match self {
852 PolicyMode::MonitorOnly => None,
853 PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
854 }
855 }
856
857 #[must_use]
858 pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
859 match self {
860 PolicyMode::MonitorOnly => None,
861 PolicyMode::Autoscale(cfg) => Some(cfg),
862 }
863 }
864
865 #[must_use]
866 pub const fn is_autoscale(&self) -> bool {
867 matches!(self, PolicyMode::Autoscale(_))
868 }
869
870 #[must_use]
871 pub const fn is_monitor_only(&self) -> bool {
872 matches!(self, PolicyMode::MonitorOnly)
873 }
874}
875
876#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
903#[serde(rename_all = "snake_case")]
904pub enum PolicyState {
905 Pending,
906 Active,
907 Draining,
908 Disabled,
909 RepairRequired,
910 AuthenticationFailed,
911}
912
913impl PolicyState {
914 pub const ALL: [PolicyState; 6] = [
915 PolicyState::Pending,
916 PolicyState::Active,
917 PolicyState::Draining,
918 PolicyState::Disabled,
919 PolicyState::RepairRequired,
920 PolicyState::AuthenticationFailed,
921 ];
922
923 pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
926 (PolicyState::Pending, PolicyState::Active),
927 (PolicyState::Pending, PolicyState::RepairRequired),
928 (PolicyState::Active, PolicyState::Draining),
929 (PolicyState::Draining, PolicyState::Disabled),
930 (PolicyState::Disabled, PolicyState::Pending),
931 (PolicyState::Pending, PolicyState::AuthenticationFailed),
933 (PolicyState::Active, PolicyState::AuthenticationFailed),
934 (PolicyState::Draining, PolicyState::AuthenticationFailed),
935 (PolicyState::Disabled, PolicyState::AuthenticationFailed),
936 (
937 PolicyState::RepairRequired,
938 PolicyState::AuthenticationFailed,
939 ),
940 (PolicyState::AuthenticationFailed, PolicyState::Pending),
942 ];
943
944 #[must_use]
945 pub fn can_transition_to(self, next: PolicyState) -> bool {
946 Self::LEGAL.contains(&(self, next))
947 }
948
949 #[must_use]
951 pub const fn admits_new_runners(self) -> bool {
952 matches!(self, PolicyState::Active)
953 }
954}
955
956impl fmt::Display for PolicyState {
957 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
958 f.write_str(match self {
959 PolicyState::Pending => "pending",
960 PolicyState::Active => "active",
961 PolicyState::Draining => "draining",
962 PolicyState::Disabled => "disabled",
963 PolicyState::RepairRequired => "repair_required",
964 PolicyState::AuthenticationFailed => "authentication_failed",
965 })
966 }
967}
968
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
980pub struct ScalePolicy {
981 pub id: PolicyId,
982 pub target: ScaleTarget,
983 pub installation_id: u64,
984 pub host_id: HostId,
985 pub requested_host_label: HostLabel,
987 profile_name: ProfileName,
989 mode: PolicyMode,
990 enabled: bool,
991 state: PolicyState,
992 pub cache_policy: CachePolicy,
993 workspace_policy: WorkspacePolicy,
1001 execution_policy: ExecutionPolicy,
1002 revision: u64,
1003}
1004
1005#[derive(Debug, Clone, PartialEq, Eq)]
1031pub struct PersistedPolicy {
1032 pub id: PolicyId,
1033 pub target: ScaleTarget,
1034 pub installation_id: u64,
1036 pub host_id: HostId,
1037 pub requested_host_label: HostLabel,
1038 pub profile_name: ProfileName,
1039 pub routing_labels: Option<RoutingLabels>,
1041 pub min_capacity: u16,
1042 pub max_capacity: Option<NonZeroU16>,
1043 pub enabled: bool,
1045 pub state: PolicyState,
1046 pub cache_policy: CachePolicy,
1047 pub workspace_kind: WorkspaceKind,
1052 pub workspace_root: Option<LocalAbsolutePath>,
1055 pub execution_policy: ExecutionPolicy,
1056 pub revision: u64,
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Eq)]
1062pub struct NamedProfileSpec {
1063 pub requested_host_label: HostLabel,
1064 pub os: Os,
1065 pub arch: Arch,
1066 pub profile_name: ProfileName,
1067 pub min_capacity: u16,
1068 pub max_capacity: NonZeroU16,
1069}
1070
1071impl ScalePolicy {
1072 #[must_use]
1074 pub const fn profile_name(&self) -> &ProfileName {
1075 &self.profile_name
1076 }
1077
1078 #[must_use]
1091 pub fn new(
1092 id: PolicyId,
1093 target: ScaleTarget,
1094 installation_id: u64,
1095 host_id: HostId,
1096 mode: PolicyMode,
1097 cache_policy: CachePolicy,
1098 ) -> Self {
1099 Self::new_for_host_label(
1100 id,
1101 target,
1102 installation_id,
1103 host_id,
1104 HostLabel::new("host").expect("the compatibility host label is valid"),
1105 mode,
1106 cache_policy,
1107 )
1108 }
1109
1110 #[must_use]
1112 pub fn new_for_host_label(
1113 id: PolicyId,
1114 target: ScaleTarget,
1115 installation_id: u64,
1116 host_id: HostId,
1117 requested_host_label: HostLabel,
1118 mode: PolicyMode,
1119 cache_policy: CachePolicy,
1120 ) -> Self {
1121 Self {
1122 id,
1123 target,
1124 installation_id,
1125 host_id,
1126 requested_host_label,
1127 profile_name: ProfileName::default_profile(),
1128 mode,
1129 enabled: false,
1130 state: PolicyState::Pending,
1131 cache_policy,
1132 workspace_policy: WorkspacePolicy::Ephemeral,
1136 execution_policy: ExecutionPolicy::Native,
1137 revision: 0,
1138 }
1139 }
1140
1141 pub fn new_named(
1144 id: PolicyId,
1145 target: ScaleTarget,
1146 installation_id: u64,
1147 host_id: HostId,
1148 spec: NamedProfileSpec,
1149 cache_policy: CachePolicy,
1150 ) -> Result<Self, PolicyError> {
1151 let NamedProfileSpec {
1152 requested_host_label,
1153 os,
1154 arch,
1155 profile_name,
1156 min_capacity,
1157 max_capacity,
1158 } = spec;
1159 if profile_name.is_default() {
1160 return Err(PolicyError::NamedConstructorForDefault);
1161 }
1162 let labels =
1163 RoutingLabels::derive_for_profile(&requested_host_label, os, arch, &profile_name);
1164 let mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1165 let mut policy = Self::new_for_host_label(
1166 id,
1167 target,
1168 installation_id,
1169 host_id,
1170 requested_host_label,
1171 mode,
1172 cache_policy,
1173 );
1174 policy.profile_name = profile_name;
1175 Ok(policy)
1176 }
1177
1178 pub fn new_named_monitor(
1181 id: PolicyId,
1182 target: ScaleTarget,
1183 installation_id: u64,
1184 host_id: HostId,
1185 requested_host_label: HostLabel,
1186 profile_name: ProfileName,
1187 cache_policy: CachePolicy,
1188 ) -> Result<Self, PolicyError> {
1189 if profile_name.is_default() {
1190 return Err(PolicyError::NamedConstructorForDefault);
1191 }
1192 let mut policy = Self::new_for_host_label(
1193 id,
1194 target,
1195 installation_id,
1196 host_id,
1197 requested_host_label,
1198 PolicyMode::monitor_only(),
1199 cache_policy,
1200 );
1201 policy.profile_name = profile_name;
1202 Ok(policy)
1203 }
1204
1205 pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1213 let PersistedPolicy {
1214 id,
1215 target,
1216 installation_id,
1217 host_id,
1218 requested_host_label,
1219 profile_name,
1220 routing_labels,
1221 min_capacity,
1222 max_capacity,
1223 enabled,
1224 state,
1225 cache_policy,
1226 workspace_kind,
1227 workspace_root,
1228 execution_policy,
1229 revision,
1230 } = fields;
1231
1232 let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1233 if !profile_name.is_default()
1234 && let Some(labels) = mode.routing_labels()
1235 {
1236 let suffix = format!("-{}", profile_name.as_str());
1239 if !labels.host_label().as_str().ends_with(&suffix) {
1240 return Err(PolicyError::InvalidProfileSelector);
1241 }
1242 }
1243 let workspace_policy =
1247 WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1248 execution_policy.validate(target.scope(), &workspace_policy)?;
1249 Ok(Self {
1250 id,
1251 target,
1252 installation_id,
1253 host_id,
1254 requested_host_label,
1255 profile_name,
1256 mode,
1257 enabled,
1258 state,
1259 cache_policy,
1260 workspace_policy,
1261 execution_policy,
1262 revision,
1263 })
1264 }
1265
1266 #[must_use]
1272 pub fn to_persisted(&self) -> PersistedPolicy {
1273 PersistedPolicy {
1274 id: self.id,
1275 target: self.target.clone(),
1276 installation_id: self.installation_id,
1277 host_id: self.host_id,
1278 requested_host_label: self.requested_host_label.clone(),
1279 profile_name: self.profile_name.clone(),
1280 routing_labels: self.routing_labels().cloned(),
1281 min_capacity: self.min_capacity(),
1282 max_capacity: self.max_capacity(),
1283 enabled: self.enabled,
1284 state: self.state,
1285 cache_policy: self.cache_policy,
1286 workspace_kind: self.workspace_policy.kind(),
1287 workspace_root: self.workspace_policy.root().cloned(),
1288 execution_policy: self.execution_policy.clone(),
1289 revision: self.revision,
1290 }
1291 }
1292
1293 #[must_use]
1294 pub const fn mode(&self) -> &PolicyMode {
1295 &self.mode
1296 }
1297
1298 #[must_use]
1299 pub const fn state(&self) -> PolicyState {
1300 self.state
1301 }
1302
1303 #[must_use]
1307 pub const fn enabled(&self) -> bool {
1308 self.enabled
1309 }
1310
1311 #[must_use]
1314 pub const fn revision(&self) -> u64 {
1315 self.revision
1316 }
1317
1318 #[must_use]
1319 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1320 self.mode.routing_labels()
1321 }
1322
1323 #[must_use]
1325 pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1326 &self.workspace_policy
1327 }
1328
1329 #[must_use]
1330 pub const fn execution_policy(&self) -> &ExecutionPolicy {
1331 &self.execution_policy
1332 }
1333
1334 pub fn set_execution_policy(&mut self, execution: ExecutionPolicy) -> Result<(), PolicyError> {
1336 execution.validate(self.target.scope(), &self.workspace_policy)?;
1337 if self.execution_policy != execution {
1338 self.execution_policy = execution;
1339 self.revision = self.revision.saturating_add(1);
1340 }
1341 Ok(())
1342 }
1343
1344 pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1362 workspace.permitted_for(self.target.scope())?;
1367 self.execution_policy
1368 .validate(self.target.scope(), &workspace)?;
1369 if self.workspace_policy != workspace {
1370 self.workspace_policy = workspace;
1371 self.revision = self.revision.saturating_add(1);
1372 }
1373 Ok(())
1374 }
1375
1376 #[must_use]
1377 pub const fn min_capacity(&self) -> u16 {
1378 self.mode.min_capacity()
1379 }
1380
1381 #[must_use]
1382 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1383 self.mode.max_capacity()
1384 }
1385
1386 #[must_use]
1389 pub fn is_owned_by(&self, host_id: HostId) -> bool {
1390 self.host_id == host_id
1391 }
1392
1393 #[must_use]
1400 pub const fn owns_runners(&self) -> bool {
1401 self.mode.is_autoscale()
1402 }
1403
1404 #[must_use]
1410 pub const fn may_start_runners(&self) -> bool {
1411 self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1412 }
1413
1414 pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1418 if !self.state.can_transition_to(next) {
1419 return Err(PolicyError::IllegalTransition {
1420 from: self.state,
1421 to: next,
1422 });
1423 }
1424 self.state = next;
1425 self.revision = self.revision.saturating_add(1);
1426 Ok(())
1427 }
1428
1429 #[must_use]
1435 pub fn can_activate(&self) -> bool {
1436 self.state.can_transition_to(PolicyState::Active)
1437 }
1438
1439 #[must_use]
1441 pub fn can_request_disable(&self) -> bool {
1442 self.state.can_transition_to(PolicyState::Draining)
1443 }
1444
1445 pub fn activate(&mut self) -> Result<(), PolicyError> {
1466 self.transition_to(PolicyState::Active)?;
1467 self.enabled = true;
1468 Ok(())
1469 }
1470
1471 pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1490 self.transition_to(PolicyState::Draining)?;
1491 self.enabled = false;
1492 Ok(self.state)
1493 }
1494
1495 pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1505 if self.state != PolicyState::Draining {
1506 return Err(PolicyError::IllegalTransition {
1507 from: self.state,
1508 to: PolicyState::Disabled,
1509 });
1510 }
1511 if active_attempts == 0 {
1512 self.transition_to(PolicyState::Disabled)?;
1513 }
1514 Ok(self.state)
1515 }
1516
1517 pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1523 self.transition_to(PolicyState::AuthenticationFailed)
1524 }
1525
1526 pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1532 self.transition_to(PolicyState::Pending)
1533 }
1534
1535 pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1540 self.transition_to(PolicyState::RepairRequired)
1541 }
1542
1543 pub fn promote_to_autoscale(
1552 &mut self,
1553 routing_labels: RoutingLabels,
1554 min_capacity: u16,
1555 max_capacity: NonZeroU16,
1556 ) -> Result<(), PolicyError> {
1557 if !self.profile_name.is_default() {
1558 return Err(PolicyError::NamedProfileNeedsDerivedSelector);
1559 }
1560 if self.mode.is_autoscale() {
1561 return Err(PolicyError::AlreadyAutoscale);
1562 }
1563 self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1564 self.revision = self.revision.saturating_add(1);
1565 Ok(())
1566 }
1567
1568 pub fn promote_named_to_autoscale(
1571 &mut self,
1572 os: Os,
1573 arch: Arch,
1574 min_capacity: u16,
1575 max_capacity: NonZeroU16,
1576 ) -> Result<(), PolicyError> {
1577 if self.profile_name.is_default() {
1578 return Err(PolicyError::NamedConstructorForDefault);
1579 }
1580 if self.mode.is_autoscale() {
1581 return Err(PolicyError::AlreadyAutoscale);
1582 }
1583 let labels = RoutingLabels::derive_for_profile(
1584 &self.requested_host_label,
1585 os,
1586 arch,
1587 &self.profile_name,
1588 );
1589 self.mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1590 self.revision = self.revision.saturating_add(1);
1591 Ok(())
1592 }
1593
1594 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1602 match &mut self.mode {
1603 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1604 PolicyMode::Autoscale(cfg) => {
1605 cfg.set_max_capacity(max_capacity)?;
1606 self.revision = self.revision.saturating_add(1);
1607 Ok(())
1608 }
1609 }
1610 }
1611
1612 pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
1622 match &mut self.mode {
1623 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1624 PolicyMode::Autoscale(cfg) => {
1625 let added = cfg.routing_labels_mut().add(label);
1626 if added {
1627 self.revision = self.revision.saturating_add(1);
1628 }
1629 Ok(added)
1630 }
1631 }
1632 }
1633
1634 pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
1641 match &mut self.mode {
1642 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1643 PolicyMode::Autoscale(cfg) => {
1644 let removed = cfg.routing_labels_mut().remove(label)?;
1645 if removed {
1646 self.revision = self.revision.saturating_add(1);
1647 }
1648 Ok(removed)
1649 }
1650 }
1651 }
1652
1653 #[must_use]
1659 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
1660 match self.routing_labels() {
1661 Some(labels) => labels.tally(jobs),
1662 None => DemandTally::default(),
1663 }
1664 }
1665}
1666
1667#[cfg(test)]
1668mod tests {
1669 use super::*;
1670 use crate::model::{HostId, PolicyId, TargetScope};
1671
1672 fn nz(v: u16) -> NonZeroU16 {
1673 NonZeroU16::new(v).expect("test capacity is non-zero")
1674 }
1675
1676 fn label(s: &str) -> Label {
1677 Label::new(s).expect("test label is valid")
1678 }
1679
1680 fn host_labels(host: &str) -> RoutingLabels {
1681 RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
1682 }
1683
1684 fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
1685 ScalePolicy::new(
1686 PolicyId::from_u128(1),
1687 target,
1688 42,
1689 host,
1690 PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
1691 CachePolicy::default(),
1692 )
1693 }
1694
1695 fn workspace_root() -> LocalAbsolutePath {
1700 LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
1701 .expect("a valid persistent root")
1702 }
1703
1704 fn repository_policy() -> ScalePolicy {
1705 autoscale_policy(
1706 ScaleTarget::repository("acme/api").unwrap(),
1707 HostId::from_u128(1),
1708 4,
1709 )
1710 }
1711
1712 fn organization_policy() -> ScalePolicy {
1713 autoscale_policy(
1714 ScaleTarget::organization("acme").unwrap(),
1715 HostId::from_u128(1),
1716 4,
1717 )
1718 }
1719
1720 #[test]
1721 fn every_constructor_produces_an_ephemeral_workspace() {
1722 for policy in [repository_policy(), organization_policy()] {
1725 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1726 assert!(!policy.workspace_policy().retains_job_workspace());
1727 assert_eq!(
1728 policy.to_persisted().workspace_kind,
1729 WorkspaceKind::Ephemeral
1730 );
1731 assert_eq!(policy.to_persisted().workspace_root, None);
1732 }
1733
1734 let monitor_only = ScalePolicy::new(
1735 PolicyId::from_u128(2),
1736 ScaleTarget::repository("acme/api").unwrap(),
1737 42,
1738 HostId::from_u128(1),
1739 PolicyMode::MonitorOnly,
1740 CachePolicy::default(),
1741 );
1742 assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
1743 }
1744
1745 #[test]
1746 fn a_repository_policy_can_opt_into_a_persistent_workspace() {
1747 let mut policy = repository_policy();
1748 let before = policy.revision();
1749
1750 policy
1751 .set_workspace_policy(
1752 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1753 .expect("a repository may be persistent"),
1754 )
1755 .expect("a repository policy accepts persistence");
1756
1757 assert!(policy.workspace_policy().is_persistent());
1758 assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
1759 assert_eq!(
1760 policy.revision(),
1761 before + 1,
1762 "a workspace change must bump the optimistic token, or `a2`'s guard \
1763 cannot refuse a write built from a stale read"
1764 );
1765
1766 let unchanged = policy.revision();
1769 policy
1770 .set_workspace_policy(policy.workspace_policy().clone())
1771 .expect("re-setting the same policy is accepted");
1772 assert_eq!(policy.revision(), unchanged);
1773 }
1774
1775 #[test]
1776 fn an_organization_policy_cannot_be_made_persistent() {
1777 let mut policy = organization_policy();
1780 let before = policy.revision();
1781
1782 assert_eq!(
1783 policy.set_workspace_policy(WorkspacePolicy::Persistent {
1784 root: workspace_root()
1785 }),
1786 Err(PolicyError::Workspace(
1787 WorkspaceError::PersistentRequiresRepositoryScope
1788 ))
1789 );
1790 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1791 assert_eq!(
1792 policy.revision(),
1793 before,
1794 "a refused write consumes nothing"
1795 );
1796
1797 assert_eq!(
1800 WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
1801 Err(WorkspaceError::PersistentRequiresRepositoryScope)
1802 );
1803 }
1804
1805 #[test]
1806 fn a_workspace_policy_round_trips_through_the_persisted_struct() {
1807 let mut policy = repository_policy();
1808 policy
1809 .set_workspace_policy(
1810 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1811 .expect("a repository may be persistent"),
1812 )
1813 .expect("a repository policy accepts persistence");
1814
1815 let restored = ScalePolicy::from_persisted(policy.to_persisted())
1816 .expect("a policy this crate wrote must load");
1817 assert_eq!(restored, policy);
1818 assert_eq!(restored.workspace_policy(), policy.workspace_policy());
1819
1820 let ephemeral = repository_policy();
1821 assert_eq!(
1822 ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
1823 ephemeral
1824 );
1825 }
1826
1827 #[test]
1828 fn an_organization_row_claiming_persistence_fails_closed_on_load() {
1829 let mut fields = organization_policy().to_persisted();
1830 fields.workspace_kind = WorkspaceKind::Persistent;
1831 fields.workspace_root = Some(workspace_root());
1832
1833 assert_eq!(
1834 ScalePolicy::from_persisted(fields),
1835 Err(PolicyError::Workspace(
1836 WorkspaceError::PersistentRequiresRepositoryScope
1837 ))
1838 );
1839 }
1840
1841 #[test]
1842 fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
1843 let base = repository_policy().to_persisted();
1844
1845 let mut without_root = base.clone();
1846 without_root.workspace_kind = WorkspaceKind::Persistent;
1847 assert_eq!(
1848 ScalePolicy::from_persisted(without_root),
1849 Err(PolicyError::Workspace(
1850 WorkspaceError::PersistentWithoutRoot
1851 ))
1852 );
1853
1854 let mut stale_root = base;
1855 stale_root.workspace_root = Some(workspace_root());
1856 assert!(matches!(
1857 ScalePolicy::from_persisted(stale_root),
1858 Err(PolicyError::Workspace(
1859 WorkspaceError::EphemeralWithRoot { .. }
1860 ))
1861 ));
1862 }
1863
1864 #[test]
1865 fn workspace_retention_is_not_the_runner_package_cache_policy() {
1866 let mut policy = repository_policy();
1870 policy.cache_policy = CachePolicy::DiscardRunnerPackage;
1871 policy
1872 .set_workspace_policy(
1873 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1874 .expect("a repository may be persistent"),
1875 )
1876 .expect("a repository policy accepts persistence");
1877
1878 assert!(policy.workspace_policy().retains_job_workspace());
1879 assert!(!policy.cache_policy.retains_runner_package());
1880 assert!(!policy.cache_policy.retains_job_workspace());
1883 }
1884
1885 #[test]
1890 fn the_derived_label_has_the_shape_the_architecture_gives() {
1891 let labels =
1893 RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
1894 assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
1895 assert_eq!(labels.count().get(), 1);
1896 }
1897
1898 #[test]
1899 fn the_derived_label_is_host_scoped_by_construction() {
1900 let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
1904 let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
1905
1906 assert_ne!(
1907 a.host_label(),
1908 b.host_label(),
1909 "two hosts must not derive the same routing label; with no job \
1910 reservation, a shared label means both hosts start a runner for one job"
1911 );
1912 assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
1913 assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
1914
1915 let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
1917 assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
1918 assert_ne!(a.host_label(), mac.host_label());
1919 }
1920
1921 #[test]
1922 fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
1923 let labels =
1927 RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
1928 assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
1929 }
1930
1931 #[test]
1932 fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
1933 let mut labels = host_labels("home");
1934 let derived = labels.host_label().clone();
1935
1936 assert!(labels.add(label("gpu")));
1937 assert!(labels.add(label("self-hosted")));
1938 assert!(
1939 !labels.add(label("GPU")),
1940 "adding a label that differs only in case must be a no-op, not a duplicate"
1941 );
1942 assert_eq!(labels.count().get(), 3);
1943
1944 assert!(labels.remove(&label("gpu")).unwrap());
1945 assert_eq!(labels.count().get(), 2);
1946 assert!(
1947 !labels.remove(&label("never-added")).unwrap(),
1948 "removing an absent optional label is a no-op, not an error"
1949 );
1950
1951 assert!(
1953 matches!(
1954 labels.remove(&derived),
1955 Err(PolicyError::HostLabelNotRemovable { .. })
1956 ),
1957 "the derived host label must not be removable; it is the only thing \
1958 keeping two hosts from serving each other's jobs"
1959 );
1960 assert!(labels.contains(&derived));
1961
1962 assert!(matches!(
1964 labels.remove(&label("RM-HOME-WIN-X64")),
1965 Err(PolicyError::HostLabelNotRemovable { .. })
1966 ));
1967 }
1968
1969 #[test]
1970 fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
1971 let mut labels = host_labels("home");
1972 let derived = labels.host_label().clone();
1973 assert!(!labels.add(derived));
1974 assert_eq!(labels.count().get(), 1);
1975
1976 let rebuilt = RoutingLabels::from_parts(
1978 labels.host_label().clone(),
1979 vec![labels.host_label().clone(), label("gpu")],
1980 );
1981 assert_eq!(rebuilt.count().get(), 2);
1982 assert_eq!(
1983 rebuilt.as_registration_labels(),
1984 vec!["rm-home-win-x64", "gpu"]
1985 );
1986 }
1987
1988 #[test]
1989 fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
1990 let mut labels = host_labels("home");
1995 labels.add(label("gpu"));
1996 assert_eq!(
1997 labels.as_registration_labels(),
1998 vec!["rm-home-win-x64", "gpu"]
1999 );
2000 assert!(!labels.contains(&label("self-hosted")));
2001 }
2002
2003 #[test]
2004 fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
2005 let mut labels = host_labels("home");
2006 labels.add(label("gpu"));
2007 let json = serde_json::to_string(&labels).unwrap();
2008 let back: RoutingLabels = serde_json::from_str(&json).unwrap();
2009 assert_eq!(labels, back);
2010 assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
2011 }
2012
2013 #[test]
2014 fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
2015 let labels = host_labels("home");
2017 let non_empty = labels.to_non_empty();
2018 assert_eq!(non_empty.count().get(), 1);
2019 assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
2020 }
2021
2022 struct Row {
2029 name: &'static str,
2030 runs_on: RunsOn,
2031 expect: Expect,
2032 }
2033
2034 #[derive(Debug, PartialEq, Eq)]
2035 enum Expect {
2036 Match,
2037 NoMatch,
2038 Unresolvable,
2039 }
2040
2041 fn classify(m: &RunsOnMatch) -> Expect {
2042 match m {
2043 RunsOnMatch::Match { .. } => Expect::Match,
2044 RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
2045 RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
2046 }
2047 }
2048
2049 fn table_policy() -> RoutingLabels {
2050 let mut labels = host_labels("home");
2052 labels.add(label("self-hosted"));
2053 labels.add(label("gpu"));
2054 labels
2055 }
2056
2057 fn table() -> Vec<Row> {
2058 vec![
2059 Row {
2061 name: "string: the derived host label",
2062 runs_on: RunsOn::Single("rm-home-win-x64".into()),
2063 expect: Expect::Match,
2064 },
2065 Row {
2066 name: "string: the derived host label in the wrong case",
2067 runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
2068 expect: Expect::Match,
2069 },
2070 Row {
2071 name: "string: an optional label alone",
2072 runs_on: RunsOn::Single("gpu".into()),
2073 expect: Expect::Match,
2074 },
2075 Row {
2076 name: "string: another host's label",
2077 runs_on: RunsOn::Single("rm-office-win-x64".into()),
2078 expect: Expect::NoMatch,
2079 },
2080 Row {
2081 name: "string: a GitHub-hosted runner label",
2082 runs_on: RunsOn::Single("ubuntu-latest".into()),
2083 expect: Expect::NoMatch,
2084 },
2085 Row {
2087 name: "array: a strict subset of the policy's labels",
2088 runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
2089 expect: Expect::Match,
2090 },
2091 Row {
2092 name: "array: the whole set, out of order and mixed case",
2093 runs_on: RunsOn::Many(vec![
2094 "GPU".into(),
2095 "Rm-Home-Win-X64".into(),
2096 "Self-Hosted".into(),
2097 ]),
2098 expect: Expect::Match,
2099 },
2100 Row {
2101 name: "array: one label the policy does not carry",
2102 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
2103 expect: Expect::NoMatch,
2104 },
2105 Row {
2106 name: "array: an empty array names no labels",
2107 runs_on: RunsOn::Many(vec![]),
2108 expect: Expect::Unresolvable,
2109 },
2110 Row {
2112 name: "map: labels only",
2113 runs_on: RunsOn::Grouped {
2114 group: None,
2115 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
2116 },
2117 expect: Expect::Match,
2118 },
2119 Row {
2120 name: "map: a group plus labels the policy carries",
2121 runs_on: RunsOn::Grouped {
2122 group: Some("Default".into()),
2123 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
2124 },
2125 expect: Expect::Match,
2126 },
2127 Row {
2128 name: "map: labels as a scalar",
2129 runs_on: RunsOn::Grouped {
2130 group: Some("Default".into()),
2131 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2132 },
2133 expect: Expect::Match,
2134 },
2135 Row {
2136 name: "map: a group plus a label the policy does not carry",
2137 runs_on: RunsOn::Grouped {
2138 group: Some("Default".into()),
2139 labels: RunsOnLabels::Many(vec!["macos".into()]),
2140 },
2141 expect: Expect::NoMatch,
2142 },
2143 Row {
2144 name: "map: a group with no labels constrains something we cannot read",
2145 runs_on: RunsOn::Grouped {
2146 group: Some("Default".into()),
2147 labels: RunsOnLabels::Many(vec![]),
2148 },
2149 expect: Expect::Unresolvable,
2150 },
2151 Row {
2153 name: "expression: the whole value",
2154 runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
2155 expect: Expect::Unresolvable,
2156 },
2157 Row {
2158 name: "expression: one element of an array",
2159 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
2160 expect: Expect::Unresolvable,
2161 },
2162 Row {
2163 name: "expression: inside the map form",
2164 runs_on: RunsOn::Grouped {
2165 group: None,
2166 labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
2167 },
2168 expect: Expect::Unresolvable,
2169 },
2170 Row {
2171 name: "not a usable label at all",
2172 runs_on: RunsOn::Single("rm-home,win-x64".into()),
2173 expect: Expect::Unresolvable,
2174 },
2175 ]
2176 }
2177
2178 #[test]
2179 fn runs_on_matching_covers_every_documented_form() {
2180 let policy = table_policy();
2181 for row in table() {
2182 let got = policy.matches(&row.runs_on);
2183 assert_eq!(
2184 classify(&got),
2185 row.expect,
2186 "row {:?}: {:?} produced {got:?}",
2187 row.name,
2188 row.runs_on
2189 );
2190 }
2191 }
2192
2193 #[test]
2194 fn github_job_labels_are_a_case_insensitive_subset_without_a_selector() {
2195 let labels = RoutingLabels::from_parts(
2196 label("rm-macmini-osx-arm64"),
2197 [
2198 label("arm64"),
2199 label("macos"),
2200 label("self-hosted"),
2201 label("extra"),
2202 ],
2203 );
2204 assert!(
2205 labels
2206 .matches(&RunsOn::Many(vec![
2207 "self-hosted".into(),
2208 "macOS".into(),
2209 "ARM64".into(),
2210 ]))
2211 .is_match()
2212 );
2213 for missing in ["X64", "Windows", "Linux", "gpu", "rm-other-osx-arm64"] {
2214 let result = labels.matches(&RunsOn::Many(vec![
2215 "self-hosted".into(),
2216 "macOS".into(),
2217 "ARM64".into(),
2218 missing.into(),
2219 ]));
2220 assert_eq!(
2221 result,
2222 RunsOnMatch::NoMatch {
2223 missing: vec![label(missing)]
2224 }
2225 );
2226 }
2227 }
2228
2229 #[test]
2230 fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2231 let without = host_labels("home");
2235 assert!(
2236 !without
2237 .matches(&RunsOn::Single("self-hosted".into()))
2238 .is_match(),
2239 "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2240 );
2241
2242 let mut with = host_labels("home");
2243 with.add(label("self-hosted"));
2244 assert!(
2245 with.matches(&RunsOn::Single("self-hosted".into()))
2246 .is_match()
2247 );
2248 assert!(
2249 with.matches(&RunsOn::Many(vec![
2250 "rm-home-win-x64".into(),
2251 "self-hosted".into()
2252 ]))
2253 .is_match()
2254 );
2255 }
2256
2257 #[test]
2258 fn a_no_match_names_the_labels_that_were_missing() {
2259 let policy = host_labels("home");
2260 let got = policy.matches(&RunsOn::Many(vec![
2261 "rm-home-win-x64".into(),
2262 "self-hosted".into(),
2263 "GPU".into(),
2264 ]));
2265 match got {
2266 RunsOnMatch::NoMatch { missing } => {
2267 assert_eq!(
2268 missing,
2269 vec![label("self-hosted"), label("gpu")],
2270 "the operator needs to know which labels to add"
2271 );
2272 }
2273 other => panic!("expected NoMatch, got {other:?}"),
2274 }
2275 }
2276
2277 #[test]
2278 fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2279 let policy = host_labels("home");
2280 let got = policy.matches(&RunsOn::Grouped {
2281 group: Some("Default".into()),
2282 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2283 });
2284 assert_eq!(
2285 got,
2286 RunsOnMatch::Match {
2287 runner_group: Some("Default".into())
2288 },
2289 "a policy has no runner-group field, so the domain cannot evaluate \
2290 `group:`; returning it lets `c4`, which can, do so without re-parsing"
2291 );
2292 }
2293
2294 #[test]
2295 fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2296 let policy = host_labels("home");
2297
2298 let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2299 assert!(matches!(
2300 expr,
2301 RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2302 ));
2303
2304 let group = policy.matches(&RunsOn::Grouped {
2305 group: Some("g".into()),
2306 labels: RunsOnLabels::Many(vec![]),
2307 });
2308 assert!(matches!(
2309 group,
2310 RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2311 ));
2312
2313 let none = policy.matches(&RunsOn::Many(vec![]));
2314 assert!(matches!(
2315 none,
2316 RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2317 ));
2318
2319 let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2320 assert!(matches!(
2321 invalid,
2322 RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2323 ));
2324 }
2325
2326 #[test]
2327 fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2328 let policy = table_policy();
2332 let jobs = vec![
2333 RunsOn::Single("rm-home-win-x64".into()), RunsOn::Single("ubuntu-latest".into()), RunsOn::Single("${{ matrix.runner }}".into()), RunsOn::Single("${{ inputs.pool }}".into()), ];
2338 let tally = policy.tally(&jobs);
2339
2340 assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2341 assert_eq!(tally.not_matched, 1);
2342 assert_eq!(
2343 tally.unresolvable.len(),
2344 2,
2345 "and it must not vanish either -- `g2` shows these to the operator"
2346 );
2347 assert_eq!(
2348 tally.total_seen(),
2349 jobs.len() as u32,
2350 "every job seen is accounted for in exactly one bucket"
2351 );
2352 }
2353
2354 #[test]
2355 fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2356 let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2357 assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2358
2359 let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2360 assert_eq!(
2361 many,
2362 RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2363 );
2364
2365 let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2366 assert_eq!(
2367 grouped,
2368 RunsOn::Grouped {
2369 group: Some("g".into()),
2370 labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2371 }
2372 );
2373
2374 let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2375 assert_eq!(
2376 scalar_labels,
2377 RunsOn::Grouped {
2378 group: None,
2379 labels: RunsOnLabels::One("a".into()),
2380 }
2381 );
2382
2383 let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2384 assert_eq!(
2385 group_only,
2386 RunsOn::Grouped {
2387 group: Some("g".into()),
2388 labels: RunsOnLabels::Many(vec![]),
2389 }
2390 );
2391
2392 assert_eq!(
2394 RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2395 RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2396 );
2397 }
2398
2399 #[test]
2404 fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2405 let labels = host_labels("home");
2406
2407 assert!(matches!(
2410 PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2411 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2412 ));
2413 assert!(matches!(
2414 PolicyMode::from_persisted(None, 0, Some(nz(1))),
2415 Err(PolicyError::AutoscaleWithoutRoutingLabels)
2416 ));
2417
2418 assert!(
2420 PolicyMode::from_persisted(None, 0, None)
2421 .unwrap()
2422 .is_monitor_only()
2423 );
2424 assert!(
2425 PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2426 .unwrap()
2427 .is_autoscale()
2428 );
2429 }
2430
2431 #[test]
2432 fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2433 let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2440 assert!(autoscale.routing_labels().is_some());
2441 assert!(autoscale.max_capacity().is_some());
2442
2443 let monitor = PolicyMode::monitor_only();
2444 assert!(monitor.routing_labels().is_none());
2445 assert!(monitor.max_capacity().is_none());
2446 assert_eq!(monitor.min_capacity(), 0);
2447 }
2448
2449 #[test]
2450 fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2451 let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2454 assert!(matches!(
2455 err,
2456 PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2457 ));
2458 assert!(
2459 err.to_string().contains("MonitorOnly"),
2460 "the message must name the shape rule, got: {err}"
2461 );
2462 }
2463
2464 #[test]
2465 fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2466 assert!(matches!(
2472 PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2473 Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2474 ));
2475 assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2476 assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2477
2478 let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2480 assert!(matches!(
2481 cfg.set_max_capacity(nz(1)),
2482 Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2483 ));
2484 assert_eq!(
2485 cfg.max_capacity().get(),
2486 4,
2487 "a refused write changes nothing"
2488 );
2489 }
2490
2491 #[test]
2492 fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2493 for mode in [
2494 PolicyMode::monitor_only(),
2495 PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2496 ] {
2497 let json = serde_json::to_string(&mode).unwrap();
2498 let back: PolicyMode = serde_json::from_str(&json).unwrap();
2499 assert_eq!(mode, back, "{json} did not round-trip");
2500 }
2501
2502 let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2505 let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2506 assert!(
2507 err.to_string().contains("min_capacity"),
2508 "expected the shape error to survive into serde's message, got: {err}"
2509 );
2510 }
2511
2512 #[test]
2513 fn a_policy_round_trips_through_its_persisted_form() {
2514 let mut policy = autoscale_policy(
2522 ScaleTarget::repository("o/r").unwrap(),
2523 HostId::from_u128(7),
2524 3,
2525 );
2526 policy.add_routing_label(label("gpu")).unwrap();
2527 policy.activate().unwrap();
2533 assert_eq!(policy.state(), PolicyState::Active);
2534 assert!(policy.enabled());
2535
2536 let stored = policy.to_persisted();
2537 assert_ne!(
2538 stored.installation_id, stored.revision,
2539 "the fixture must distinguish the two u64 columns, or transposing \
2540 them is unobservable and this test proves nothing"
2541 );
2542 assert_eq!(stored.installation_id, 42);
2543 assert_eq!(stored.revision, policy.revision());
2544
2545 let restored =
2546 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2547 assert_eq!(restored, policy);
2548 assert_eq!(restored.installation_id, 42);
2549 assert_eq!(restored.revision(), policy.revision());
2550 assert_eq!(restored.state(), PolicyState::Active);
2551 assert!(restored.enabled());
2552 assert_eq!(
2553 restored.routing_labels().unwrap().count().get(),
2554 2,
2555 "the optional label survives alongside the host label"
2556 );
2557 }
2558
2559 #[test]
2560 fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
2561 let mut policy = ScalePolicy::new(
2569 PolicyId::from_u128(2),
2570 ScaleTarget::organization("acme").unwrap(),
2571 9,
2572 HostId::from_u128(7),
2573 PolicyMode::monitor_only(),
2574 CachePolicy::default(),
2575 );
2576 policy.activate().unwrap();
2577
2578 let stored = policy.to_persisted();
2579 assert!(stored.routing_labels.is_none());
2580 assert_eq!(stored.min_capacity, 0);
2581 assert!(stored.max_capacity.is_none());
2582 assert_ne!(stored.installation_id, stored.revision);
2583
2584 let restored =
2585 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2586 assert_eq!(restored, policy);
2587 assert!(
2588 restored.mode().is_monitor_only(),
2589 "the mode is inferred back from the three columns, not stored"
2590 );
2591 assert!(!restored.owns_runners());
2592 assert_eq!(restored.installation_id, 9);
2593 assert_eq!(restored.revision(), 1);
2594 }
2595
2596 fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
2616 use PolicyState::*;
2617 let mut edges = vec![
2618 (Pending, Active),
2619 (Pending, RepairRequired),
2620 (Active, Draining),
2621 (Draining, Disabled),
2622 (Disabled, Pending),
2623 (AuthenticationFailed, Pending),
2625 ];
2626 for from in PolicyState::ALL {
2628 if from != AuthenticationFailed {
2629 edges.push((from, AuthenticationFailed));
2630 }
2631 }
2632 edges
2633 }
2634
2635 #[test]
2636 fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
2637 let expected = diagram_edges();
2640 assert_eq!(
2641 expected.len(),
2642 11,
2643 "the transcription itself changed; check it against the diagram"
2644 );
2645
2646 let mut legal_seen = 0usize;
2647 let mut illegal_seen = 0usize;
2648
2649 for from in PolicyState::ALL {
2650 for to in PolicyState::ALL {
2651 let expected_legal = expected.contains(&(from, to));
2652 let mut policy = autoscale_policy(
2653 ScaleTarget::repository("o/r").unwrap(),
2654 HostId::from_u128(7),
2655 1,
2656 );
2657 policy.state = from;
2661
2662 let result = policy.transition_to(to);
2663 if expected_legal {
2664 legal_seen += 1;
2665 assert!(
2666 result.is_ok(),
2667 "{from} -> {to} is in the diagram and must be accepted"
2668 );
2669 assert_eq!(policy.state(), to);
2670 } else {
2671 illegal_seen += 1;
2672 assert!(
2673 matches!(result, Err(PolicyError::IllegalTransition { .. })),
2674 "{from} -> {to} is not in the diagram and must be rejected"
2675 );
2676 assert_eq!(policy.state(), from, "a refused transition changes nothing");
2677 }
2678 }
2679 }
2680
2681 assert_eq!(legal_seen, 11);
2682 assert_eq!(illegal_seen, 36 - 11);
2683
2684 let mut published = PolicyState::LEGAL.to_vec();
2687 let mut transcribed = expected;
2688 published.sort_unstable();
2689 transcribed.sort_unstable();
2690 assert_eq!(published, transcribed);
2691 }
2692
2693 #[test]
2694 fn a_policy_state_cannot_transition_to_itself() {
2695 for state in PolicyState::ALL {
2696 assert!(
2697 !state.can_transition_to(state),
2698 "{state} -> {state} is not an edge in the diagram; treating it as \
2699 one would let a repeated authentication failure look like progress"
2700 );
2701 }
2702 }
2703
2704 #[test]
2705 fn the_documented_happy_path_walks_pending_to_disabled() {
2706 let mut policy = autoscale_policy(
2707 ScaleTarget::repository("o/r").unwrap(),
2708 HostId::from_u128(7),
2709 2,
2710 );
2711
2712 assert_eq!(policy.state(), PolicyState::Pending);
2714 assert!(!policy.enabled());
2715 assert!(!policy.may_start_runners());
2716
2717 policy.activate().unwrap();
2718 assert_eq!(policy.state(), PolicyState::Active);
2719 assert!(policy.enabled());
2720 assert!(policy.may_start_runners());
2721
2722 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2724 assert_eq!(
2725 policy.drain_completed(1).unwrap(),
2726 PolicyState::Draining,
2727 "a policy with a runner still in flight stays draining"
2728 );
2729 assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
2730 }
2731
2732 #[test]
2733 fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
2734 let mut policy = autoscale_policy(
2737 ScaleTarget::repository("o/r").unwrap(),
2738 HostId::from_u128(7),
2739 5,
2740 );
2741 policy.activate().unwrap();
2742
2743 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
2746 assert_eq!(policy.tally(&jobs).demand(), 4);
2747
2748 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2749 assert!(!policy.enabled());
2750 assert!(
2751 !policy.may_start_runners(),
2752 "a draining policy must not be the reason a new runner starts, even \
2753 with four jobs queued for its labels"
2754 );
2755 assert_eq!(
2756 policy.tally(&jobs).demand(),
2757 4,
2758 "queued demand stays visible while draining (flow 5.2)"
2759 );
2760 }
2761
2762 #[test]
2763 fn re_authentication_is_the_only_way_out_of_authentication_failed() {
2764 for from in [
2765 PolicyState::Pending,
2766 PolicyState::Active,
2767 PolicyState::Draining,
2768 PolicyState::Disabled,
2769 PolicyState::RepairRequired,
2770 ] {
2771 let mut policy = autoscale_policy(
2772 ScaleTarget::repository("o/r").unwrap(),
2773 HostId::from_u128(7),
2774 1,
2775 );
2776 policy.state = from;
2777 policy.authentication_failed().unwrap();
2778 assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
2779
2780 assert!(matches!(
2782 policy.authentication_failed(),
2783 Err(PolicyError::IllegalTransition { .. })
2784 ));
2785
2786 policy.reauthenticated().unwrap();
2787 assert_eq!(policy.state(), PolicyState::Pending);
2788 }
2789 }
2790
2791 #[test]
2792 fn mutant_disabling_revoked_eligibility_gate_is_detected() {
2793 let mut policy = autoscale_policy(
2794 ScaleTarget::repository("o/r").unwrap(),
2795 HostId::from_u128(7),
2796 1,
2797 );
2798 policy.activate().unwrap();
2799 policy.authentication_failed().unwrap();
2800 assert!(!policy.may_start_runners());
2801
2802 let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
2806 assert!(
2807 mutant_may_start,
2808 "omitting revoked state must make the eligibility gate red"
2809 );
2810 }
2811
2812 #[test]
2813 fn a_refused_transition_leaves_the_revision_untouched() {
2814 let mut policy = autoscale_policy(
2815 ScaleTarget::repository("o/r").unwrap(),
2816 HostId::from_u128(7),
2817 1,
2818 );
2819 assert_eq!(policy.revision(), 0);
2820 policy.activate().unwrap();
2821 assert_eq!(policy.revision(), 1);
2822
2823 assert!(policy.activate().is_err());
2824 assert_eq!(
2825 policy.revision(),
2826 1,
2827 "a rejected write must not bump the optimistic-concurrency token, or \
2828 `b2`'s stale-revision check would reject the next honest write"
2829 );
2830 }
2831
2832 #[test]
2837 fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
2838 let mut policy = ScalePolicy::new(
2839 PolicyId::from_u128(2),
2840 ScaleTarget::organization("acme").unwrap(),
2841 9,
2842 HostId::from_u128(7),
2843 PolicyMode::monitor_only(),
2844 CachePolicy::default(),
2845 );
2846 policy.activate().unwrap();
2847
2848 assert!(!policy.owns_runners());
2849 assert!(
2850 !policy.may_start_runners(),
2851 "an active, enabled monitor-only policy still starts nothing (D19)"
2852 );
2853 assert!(policy.routing_labels().is_none());
2854 assert!(policy.max_capacity().is_none());
2855
2856 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
2858 assert_eq!(
2859 policy.tally(&jobs).demand(),
2860 0,
2861 "a monitor-only policy has no demand at all, rather than demand that \
2862 is computed and then ignored"
2863 );
2864
2865 assert!(matches!(
2868 policy.add_routing_label(label("gpu")),
2869 Err(PolicyError::NotAutoscale)
2870 ));
2871 assert!(matches!(
2872 policy.remove_routing_label(&label("gpu")),
2873 Err(PolicyError::NotAutoscale)
2874 ));
2875 assert!(matches!(
2883 PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
2884 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2885 ));
2886 }
2887
2888 #[test]
2889 fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
2890 let mut policy = ScalePolicy::new(
2893 PolicyId::from_u128(2),
2894 ScaleTarget::repository("o/r").unwrap(),
2895 9,
2896 HostId::from_u128(7),
2897 PolicyMode::monitor_only(),
2898 CachePolicy::default(),
2899 );
2900 assert!(policy.routing_labels().is_none());
2901
2902 policy
2903 .promote_to_autoscale(host_labels("home"), 0, nz(3))
2904 .unwrap();
2905
2906 assert!(policy.owns_runners());
2907 assert_eq!(
2908 policy.routing_labels().unwrap().host_label().as_str(),
2909 "rm-home-win-x64"
2910 );
2911 assert_eq!(policy.max_capacity().unwrap().get(), 3);
2912
2913 assert!(matches!(
2915 policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
2916 Err(PolicyError::AlreadyAutoscale)
2917 ));
2918 policy.set_max_capacity(nz(4)).unwrap();
2919 assert_eq!(policy.max_capacity().unwrap().get(), 4);
2920 }
2921
2922 #[test]
2923 fn a_named_monitor_can_only_be_promoted_with_its_derived_selector() {
2924 let mut named = ScalePolicy::new_named_monitor(
2925 PolicyId::from_u128(3),
2926 ScaleTarget::repository("o/r").unwrap(),
2927 9,
2928 HostId::from_u128(7),
2929 HostLabel::new("home").unwrap(),
2930 ProfileName::new("Py-Isolated").unwrap(),
2931 CachePolicy::default(),
2932 )
2933 .unwrap();
2934 assert!(matches!(
2935 named.promote_to_autoscale(host_labels("other"), 0, nz(3)),
2936 Err(PolicyError::NamedProfileNeedsDerivedSelector)
2937 ));
2938 assert!(named.routing_labels().is_none());
2939 named
2940 .promote_named_to_autoscale(Os::Windows, Arch::X64, 0, nz(3))
2941 .unwrap();
2942 assert_eq!(
2943 named.routing_labels().unwrap().host_label().as_str(),
2944 "rm-home-win-x64-py-isolated"
2945 );
2946 }
2947
2948 #[test]
2949 fn a_policy_is_owned_by_exactly_one_host() {
2950 let mine = HostId::from_u128(7);
2951 let theirs = HostId::from_u128(8);
2952 let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
2953 assert!(policy.is_owned_by(mine));
2954 assert!(!policy.is_owned_by(theirs));
2955 }
2956
2957 #[test]
2958 fn an_overridden_host_label_is_detectable_without_being_rejected() {
2959 assert!(host_labels("home").is_derived_shape());
2963 assert!(
2964 RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
2965 .is_derived_shape(),
2966 "a host label containing `-` still derives a four-plus-segment name"
2967 );
2968 assert_eq!(
2974 host_labels("home--pc").host_label().as_str(),
2975 "rm-home--pc-win-x64"
2976 );
2977 assert!(
2978 host_labels("home--pc").is_derived_shape(),
2979 "consecutive dashes are legal inside a host label; the empty middle \
2980 segment they produce is not evidence of an override"
2981 );
2982
2983 for raw in [
2987 "self-hosted",
2988 "ubuntu-latest",
2989 "rm-home-win",
2990 "rm-home-win-x64-extra",
2991 ] {
2992 assert!(
2993 !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
2994 "{raw:?} is not the derived shape"
2995 );
2996 }
2997 assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
2999 assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
3000 assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
3001
3002 let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
3004 overridden.add(label("rm-home-win-x64"));
3005 assert!(!overridden.is_derived_shape());
3006 }
3007
3008 #[test]
3009 fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
3010 let mut policy = autoscale_policy(
3014 ScaleTarget::repository("o/r").unwrap(),
3015 HostId::from_u128(7),
3016 3,
3017 );
3018
3019 assert!(policy.can_activate());
3020 assert!(
3021 !policy.can_request_disable(),
3022 "a pending policy cannot drain; it is also already not enabled, \
3023 which is what makes the command a no-op rather than a failure"
3024 );
3025 assert!(matches!(
3026 policy.request_disable(),
3027 Err(PolicyError::IllegalTransition {
3028 from: PolicyState::Pending,
3029 to: PolicyState::Draining,
3030 })
3031 ));
3032
3033 policy.activate().unwrap();
3034 assert!(!policy.can_activate(), "already active");
3035 assert!(policy.can_request_disable());
3036 assert!(matches!(
3037 policy.activate(),
3038 Err(PolicyError::IllegalTransition { .. })
3039 ));
3040
3041 policy.request_disable().unwrap();
3042 assert!(!policy.can_request_disable(), "already draining");
3043 assert!(!policy.enabled());
3044 }
3045
3046 fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
3070 let host = HostId::from_u128(7);
3071 let mut trace = Vec::new();
3072
3073 let mut policy = autoscale_policy(target.clone(), host, 3);
3074 trace.push(format!("owns_runners={}", policy.owns_runners()));
3075 trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
3076 trace.push(format!(
3077 "owned_by_other={}",
3078 policy.is_owned_by(HostId::from_u128(8))
3079 ));
3080 trace.push(format!("initial_state={}", policy.state()));
3081 trace.push(format!("initial_enabled={}", policy.enabled()));
3082 trace.push(format!(
3083 "may_start_initially={}",
3084 policy.may_start_runners()
3085 ));
3086 trace.push(format!("labels={}", policy.routing_labels().unwrap()));
3087 trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
3088 trace.push(format!("min_capacity={}", policy.min_capacity()));
3089
3090 policy.activate().unwrap();
3092 trace.push(format!("after_activate={}", policy.state()));
3093 trace.push(format!("may_start_active={}", policy.may_start_runners()));
3094
3095 let jobs = vec![
3097 RunsOn::Single("rm-home-win-x64".into()),
3098 RunsOn::Single("ubuntu-latest".into()),
3099 RunsOn::Single("${{ matrix.os }}".into()),
3100 ];
3101 let tally = policy.tally(&jobs);
3102 trace.push(format!(
3103 "demand={} not_matched={} unresolvable={}",
3104 tally.demand(),
3105 tally.not_matched,
3106 tally.unresolvable.len()
3107 ));
3108
3109 let host_record = crate::model::Host::new(
3113 host,
3114 "home-pc",
3115 Os::Windows,
3116 Arch::X64,
3117 nz(4),
3118 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3119 )
3120 .unwrap();
3121 let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
3122 let allocation = allocator.allocate(&policy, 3);
3123 trace.push(format!(
3124 "alloc demand={} desired={} active_owned={} headroom_before={} \
3125 to_start={} limiting={}",
3126 allocation.demand,
3127 allocation.desired,
3128 allocation.active_owned,
3129 allocation.headroom_before,
3130 allocation.to_start,
3131 allocation.limiting_factor
3132 ));
3133 trace.push(format!("headroom_after={}", allocator.headroom()));
3134 trace.push(format!(
3137 "alloc_zero_to_start={}",
3138 allocator.allocate(&policy, 0).to_start
3139 ));
3140
3141 let attempt = crate::attempt::RunnerAttempt::allocate(
3145 crate::model::AttemptId::from_u128(11),
3146 policy.id,
3147 "C:/runners/eq",
3148 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3149 );
3150 trace.push(format!(
3151 "authorize_own_host={:?}",
3152 crate::attempt::authorize(host, &policy, &attempt).is_ok()
3153 ));
3154 trace.push(format!(
3155 "authorize_other_host={}",
3156 crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
3157 .expect_err("an agent on another host must be refused")
3158 ));
3159 let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
3160 crate::model::AttemptId::from_u128(12),
3161 PolicyId::from_u128(999),
3162 "C:/runners/eq-other",
3163 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3164 );
3165 trace.push(format!(
3166 "authorize_other_policy={}",
3167 crate::attempt::authorize(host, &policy, &foreign_attempt)
3168 .expect_err("an attempt under another policy must be refused")
3169 ));
3170
3171 trace.push(format!("disable={}", policy.request_disable().unwrap()));
3173 trace.push(format!(
3174 "drain_with_1={}",
3175 policy.drain_completed(1).unwrap()
3176 ));
3177 trace.push(format!(
3178 "drain_with_0={}",
3179 policy.drain_completed(0).unwrap()
3180 ));
3181
3182 trace.push(format!(
3184 "reactivate_err={}",
3185 policy.transition_to(PolicyState::Active).is_err()
3186 ));
3187
3188 trace.push(format!(
3190 "registration_labels={:?}",
3191 autoscale_policy(target, host, 3)
3192 .routing_labels()
3193 .unwrap()
3194 .as_registration_labels()
3195 ));
3196
3197 trace
3198 }
3199
3200 #[test]
3201 fn repository_and_organization_targets_are_equivalent() {
3202 let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
3203 let organization =
3204 assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
3205
3206 assert_eq!(
3207 repository, organization,
3208 "D18: the two scopes differ only in which GitHub endpoint and which \
3209 App permission the gateway uses. Any difference here is a \
3210 scope-dependent domain rule that must not exist."
3211 );
3212
3213 assert_ne!(
3215 ScaleTarget::repository("o/r").unwrap().scope(),
3216 ScaleTarget::organization("o").unwrap().scope()
3217 );
3218 }
3219}