1use std::collections::BTreeSet;
25use std::fmt;
26use std::num::{NonZeroU16, NonZeroUsize};
27
28use serde::{Deserialize, Serialize};
29
30use crate::model::{
31 Arch, CachePolicy, HostId, HostLabel, Label, NonEmpty, Os, PolicyId, ScaleTarget,
32 ValidationError,
33};
34use crate::path::LocalAbsolutePath;
35use crate::workspace::{WorkspaceError, WorkspaceKind, WorkspacePolicy};
36
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42pub enum PolicyError {
43 #[error(transparent)]
44 Invalid(#[from] ValidationError),
45
46 #[error(transparent)]
49 Workspace(#[from] WorkspaceError),
50
51 #[error(
52 "an Autoscale policy requires routing labels; a policy with none is a \
53 MonitorOnly policy (D19)"
54 )]
55 AutoscaleWithoutRoutingLabels,
56
57 #[error(
58 "an Autoscale policy requires max_capacity; without a ceiling it could \
59 oversubscribe the host (D7, D19)"
60 )]
61 AutoscaleWithoutMaxCapacity,
62
63 #[error(
75 "a MonitorOnly policy must not carry a non-zero min_capacity ({min}); it \
76 never starts a runner (D19)"
77 )]
78 MonitorOnlyWithMinCapacity { min: u16 },
79
80 #[error("min_capacity ({min}) must not exceed max_capacity ({max})")]
81 InvertedCapacityRange { min: u16, max: u16 },
82
83 #[error("{to} is not a legal transition from {from}")]
84 IllegalTransition { from: PolicyState, to: PolicyState },
85
86 #[error(
87 "only a MonitorOnly policy can be promoted to Autoscale; this one is already Autoscale"
88 )]
89 AlreadyAutoscale,
90
91 #[error(
92 "this operation needs an Autoscale policy; a MonitorOnly policy has no \
93 capacity and no routing labels to change (D19)"
94 )]
95 NotAutoscale,
96
97 #[error("the host label {label} is the routing identity of this policy and cannot be removed")]
98 HostLabelNotRemovable { label: Label },
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
134pub struct RoutingLabels {
135 host_label: Label,
136 additional: BTreeSet<Label>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140struct RoutingLabelsRepr {
141 host_label: Label,
142 #[serde(default)]
143 additional: BTreeSet<Label>,
144}
145
146impl From<RoutingLabelsRepr> for RoutingLabels {
147 fn from(repr: RoutingLabelsRepr) -> Self {
148 Self::from_parts(repr.host_label, repr.additional)
153 }
154}
155
156impl From<RoutingLabels> for RoutingLabelsRepr {
157 fn from(value: RoutingLabels) -> Self {
158 Self {
159 host_label: value.host_label,
160 additional: value.additional,
161 }
162 }
163}
164
165impl RoutingLabels {
166 pub const PREFIX: &'static str = "rm";
168
169 #[must_use]
183 pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
184 let derived = format!(
185 "{}-{}-{}-{}",
186 Self::PREFIX,
187 host_label.as_str(),
188 os.label_token(),
189 arch.label_token()
190 );
191 Self {
192 host_label: Label::new(derived).expect(
193 "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
194 segments are fixed tokens, so the concatenation is always a valid Label",
195 ),
196 additional: BTreeSet::new(),
197 }
198 }
199
200 #[must_use]
213 pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
214 let additional = additional
215 .into_iter()
216 .filter(|l| *l != host_label)
217 .collect();
218 Self {
219 host_label,
220 additional,
221 }
222 }
223
224 #[must_use]
226 pub fn from_host_label(host_label: Label) -> Self {
227 Self::from_parts(host_label, Vec::new())
228 }
229
230 #[must_use]
232 pub fn host_label(&self) -> &Label {
233 &self.host_label
234 }
235
236 #[must_use]
267 pub fn is_derived_shape(&self) -> bool {
268 let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
269 let [prefix, middle @ .., os, arch] = segments.as_slice() else {
274 return false;
275 };
276 *prefix == Self::PREFIX
279 && !middle.is_empty()
280 && Os::ALL
281 .iter()
282 .any(|candidate| candidate.label_token() == *os)
283 && Arch::ALL
284 .iter()
285 .any(|candidate| candidate.label_token() == *arch)
286 }
287
288 pub fn additional(&self) -> impl Iterator<Item = &Label> {
290 self.additional.iter()
291 }
292
293 pub fn add(&mut self, label: Label) -> bool {
296 if label == self.host_label {
297 return false;
298 }
299 self.additional.insert(label)
300 }
301
302 pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
309 if *label == self.host_label {
310 return Err(PolicyError::HostLabelNotRemovable {
311 label: label.clone(),
312 });
313 }
314 Ok(self.additional.remove(label))
315 }
316
317 #[must_use]
318 pub fn contains(&self, label: &Label) -> bool {
319 self.host_label == *label || self.additional.contains(label)
320 }
321
322 pub fn iter(&self) -> impl Iterator<Item = &Label> {
324 std::iter::once(&self.host_label).chain(self.additional.iter())
325 }
326
327 #[must_use]
329 pub fn count(&self) -> NonZeroUsize {
330 NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
331 }
332
333 #[must_use]
335 pub fn to_non_empty(&self) -> NonEmpty<Label> {
336 let mut out = NonEmpty::of(self.host_label.clone());
337 for label in &self.additional {
338 out.push(label.clone());
339 }
340 out
341 }
342
343 #[must_use]
353 pub fn as_registration_labels(&self) -> Vec<String> {
354 self.iter().map(|l| l.as_str().to_string()).collect()
355 }
356
357 #[must_use]
363 pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
364 let required = match runs_on.required_labels() {
365 Ok(required) => required,
366 Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
367 };
368
369 let missing: Vec<Label> = required
370 .iter()
371 .filter(|label| !self.contains(label))
372 .cloned()
373 .collect();
374
375 if missing.is_empty() {
376 RunsOnMatch::Match {
377 runner_group: runs_on.runner_group().map(str::to_string),
378 }
379 } else {
380 RunsOnMatch::NoMatch { missing }
381 }
382 }
383
384 #[must_use]
392 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
393 let mut tally = DemandTally::default();
394 for job in jobs {
395 match self.matches(job) {
396 RunsOnMatch::Match { .. } => tally.matched += 1,
397 RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
398 RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
399 }
400 }
401 tally
402 }
403}
404
405impl fmt::Display for RoutingLabels {
406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407 let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
408 f.write_str(&joined.join(","))
409 }
410}
411
412#[derive(Debug, Clone, Default, PartialEq, Eq)]
414pub struct DemandTally {
415 pub matched: u32,
417 pub not_matched: u32,
419 pub unresolvable: Vec<UnresolvableRunsOn>,
423}
424
425impl DemandTally {
426 #[must_use]
427 pub fn demand(&self) -> u32 {
428 self.matched
429 }
430
431 #[must_use]
432 pub fn total_seen(&self) -> u32 {
433 self.matched + self.not_matched + self.unresolvable.len() as u32
434 }
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum RunsOnMatch {
440 Match { runner_group: Option<String> },
448 NoMatch { missing: Vec<Label> },
451 Unresolvable(UnresolvableRunsOn),
453}
454
455impl RunsOnMatch {
456 #[must_use]
457 pub const fn is_match(&self) -> bool {
458 matches!(self, RunsOnMatch::Match { .. })
459 }
460
461 #[must_use]
462 pub const fn is_unresolvable(&self) -> bool {
463 matches!(self, RunsOnMatch::Unresolvable(_))
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
469pub enum UnresolvableRunsOn {
470 #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
473 Expression { raw: String },
474
475 #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
478 RunnerGroupWithoutLabels { group: String },
479
480 #[error("`runs-on` names no labels")]
482 NoLabels,
483
484 #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
486 InvalidLabel {
487 raw: String,
488 #[source]
489 source: ValidationError,
490 },
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500#[serde(untagged)]
501pub enum RunsOn {
502 Single(String),
504 Many(Vec<String>),
506 Grouped {
508 #[serde(default)]
509 group: Option<String>,
510 #[serde(default)]
511 labels: RunsOnLabels,
512 },
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(untagged)]
518pub enum RunsOnLabels {
519 One(String),
520 Many(Vec<String>),
521}
522
523impl Default for RunsOnLabels {
524 fn default() -> Self {
525 Self::Many(Vec::new())
526 }
527}
528
529impl RunsOnLabels {
530 fn as_slice(&self) -> &[String] {
531 match self {
532 RunsOnLabels::One(one) => std::slice::from_ref(one),
533 RunsOnLabels::Many(many) => many,
534 }
535 }
536}
537
538impl RunsOn {
539 #[must_use]
541 pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
542 Self::Many(labels.into_iter().map(Into::into).collect())
543 }
544
545 #[must_use]
547 pub fn runner_group(&self) -> Option<&str> {
548 match self {
549 RunsOn::Grouped { group, .. } => group.as_deref(),
550 _ => None,
551 }
552 }
553
554 fn raw_labels(&self) -> &[String] {
555 match self {
556 RunsOn::Single(one) => std::slice::from_ref(one),
557 RunsOn::Many(many) => many,
558 RunsOn::Grouped { labels, .. } => labels.as_slice(),
559 }
560 }
561
562 pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
579 let raws = self.raw_labels();
580
581 if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
582 return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
583 }
584
585 let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
586
587 if usable.is_empty() {
588 return match self.runner_group() {
589 Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
590 group: group.to_string(),
591 }),
592 None => Err(UnresolvableRunsOn::NoLabels),
593 };
594 }
595
596 usable
597 .into_iter()
598 .map(|raw| {
599 Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
600 raw: raw.clone(),
601 source,
602 })
603 })
604 .collect()
605 }
606}
607
608fn is_expression(raw: &str) -> bool {
609 raw.contains("${{")
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(try_from = "AutoscaleConfigRepr")]
624pub struct AutoscaleConfig {
625 routing_labels: RoutingLabels,
626 min_capacity: u16,
627 max_capacity: NonZeroU16,
628}
629
630#[derive(Debug, Deserialize)]
631struct AutoscaleConfigRepr {
632 routing_labels: RoutingLabels,
633 min_capacity: u16,
634 max_capacity: NonZeroU16,
635}
636
637impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
638 type Error = PolicyError;
639
640 fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
641 Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
642 }
643}
644
645impl AutoscaleConfig {
646 pub fn new(
652 routing_labels: RoutingLabels,
653 min_capacity: u16,
654 max_capacity: NonZeroU16,
655 ) -> Result<Self, PolicyError> {
656 if min_capacity > max_capacity.get() {
657 return Err(PolicyError::InvertedCapacityRange {
658 min: min_capacity,
659 max: max_capacity.get(),
660 });
661 }
662 Ok(Self {
663 routing_labels,
664 min_capacity,
665 max_capacity,
666 })
667 }
668
669 pub fn v1(
675 routing_labels: RoutingLabels,
676 max_capacity: NonZeroU16,
677 ) -> Result<Self, PolicyError> {
678 Self::new(routing_labels, 0, max_capacity)
679 }
680
681 #[must_use]
682 pub fn routing_labels(&self) -> &RoutingLabels {
683 &self.routing_labels
684 }
685
686 #[must_use]
687 pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
688 &mut self.routing_labels
689 }
690
691 #[must_use]
692 pub const fn min_capacity(&self) -> u16 {
693 self.min_capacity
694 }
695
696 #[must_use]
697 pub const fn max_capacity(&self) -> NonZeroU16 {
698 self.max_capacity
699 }
700
701 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
705 if self.min_capacity > max_capacity.get() {
706 return Err(PolicyError::InvertedCapacityRange {
707 min: self.min_capacity,
708 max: max_capacity.get(),
709 });
710 }
711 self.max_capacity = max_capacity;
712 Ok(())
713 }
714}
715
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
736#[serde(tag = "mode", rename_all = "snake_case")]
737pub enum PolicyMode {
738 MonitorOnly,
741 Autoscale(AutoscaleConfig),
743}
744
745impl PolicyMode {
746 #[must_use]
747 pub const fn monitor_only() -> Self {
748 Self::MonitorOnly
749 }
750
751 pub fn autoscale(
754 routing_labels: RoutingLabels,
755 min_capacity: u16,
756 max_capacity: NonZeroU16,
757 ) -> Result<Self, PolicyError> {
758 Ok(Self::Autoscale(AutoscaleConfig::new(
759 routing_labels,
760 min_capacity,
761 max_capacity,
762 )?))
763 }
764
765 pub fn from_persisted(
775 routing_labels: Option<RoutingLabels>,
776 min_capacity: u16,
777 max_capacity: Option<NonZeroU16>,
778 ) -> Result<Self, PolicyError> {
779 match (routing_labels, max_capacity) {
780 (None, None) => {
781 if min_capacity != 0 {
782 return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
787 }
788 Ok(Self::MonitorOnly)
789 }
790 (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
791 (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
792 (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
793 }
794 }
795
796 #[must_use]
799 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
800 match self {
801 PolicyMode::MonitorOnly => None,
802 PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
803 }
804 }
805
806 #[must_use]
807 pub const fn min_capacity(&self) -> u16 {
808 match self {
809 PolicyMode::MonitorOnly => 0,
810 PolicyMode::Autoscale(cfg) => cfg.min_capacity,
811 }
812 }
813
814 #[must_use]
815 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
816 match self {
817 PolicyMode::MonitorOnly => None,
818 PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
819 }
820 }
821
822 #[must_use]
823 pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
824 match self {
825 PolicyMode::MonitorOnly => None,
826 PolicyMode::Autoscale(cfg) => Some(cfg),
827 }
828 }
829
830 #[must_use]
831 pub const fn is_autoscale(&self) -> bool {
832 matches!(self, PolicyMode::Autoscale(_))
833 }
834
835 #[must_use]
836 pub const fn is_monitor_only(&self) -> bool {
837 matches!(self, PolicyMode::MonitorOnly)
838 }
839}
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
868#[serde(rename_all = "snake_case")]
869pub enum PolicyState {
870 Pending,
871 Active,
872 Draining,
873 Disabled,
874 RepairRequired,
875 AuthenticationFailed,
876}
877
878impl PolicyState {
879 pub const ALL: [PolicyState; 6] = [
880 PolicyState::Pending,
881 PolicyState::Active,
882 PolicyState::Draining,
883 PolicyState::Disabled,
884 PolicyState::RepairRequired,
885 PolicyState::AuthenticationFailed,
886 ];
887
888 pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
891 (PolicyState::Pending, PolicyState::Active),
892 (PolicyState::Pending, PolicyState::RepairRequired),
893 (PolicyState::Active, PolicyState::Draining),
894 (PolicyState::Draining, PolicyState::Disabled),
895 (PolicyState::Disabled, PolicyState::Pending),
896 (PolicyState::Pending, PolicyState::AuthenticationFailed),
898 (PolicyState::Active, PolicyState::AuthenticationFailed),
899 (PolicyState::Draining, PolicyState::AuthenticationFailed),
900 (PolicyState::Disabled, PolicyState::AuthenticationFailed),
901 (
902 PolicyState::RepairRequired,
903 PolicyState::AuthenticationFailed,
904 ),
905 (PolicyState::AuthenticationFailed, PolicyState::Pending),
907 ];
908
909 #[must_use]
910 pub fn can_transition_to(self, next: PolicyState) -> bool {
911 Self::LEGAL.contains(&(self, next))
912 }
913
914 #[must_use]
916 pub const fn admits_new_runners(self) -> bool {
917 matches!(self, PolicyState::Active)
918 }
919}
920
921impl fmt::Display for PolicyState {
922 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
923 f.write_str(match self {
924 PolicyState::Pending => "pending",
925 PolicyState::Active => "active",
926 PolicyState::Draining => "draining",
927 PolicyState::Disabled => "disabled",
928 PolicyState::RepairRequired => "repair_required",
929 PolicyState::AuthenticationFailed => "authentication_failed",
930 })
931 }
932}
933
934#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
945pub struct ScalePolicy {
946 pub id: PolicyId,
947 pub target: ScaleTarget,
948 pub installation_id: u64,
949 pub host_id: HostId,
950 pub requested_host_label: HostLabel,
952 mode: PolicyMode,
953 enabled: bool,
954 state: PolicyState,
955 pub cache_policy: CachePolicy,
956 workspace_policy: WorkspacePolicy,
964 revision: u64,
965}
966
967#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct PersistedPolicy {
994 pub id: PolicyId,
995 pub target: ScaleTarget,
996 pub installation_id: u64,
998 pub host_id: HostId,
999 pub requested_host_label: HostLabel,
1000 pub routing_labels: Option<RoutingLabels>,
1002 pub min_capacity: u16,
1003 pub max_capacity: Option<NonZeroU16>,
1004 pub enabled: bool,
1006 pub state: PolicyState,
1007 pub cache_policy: CachePolicy,
1008 pub workspace_kind: WorkspaceKind,
1013 pub workspace_root: Option<LocalAbsolutePath>,
1016 pub revision: u64,
1018}
1019
1020impl ScalePolicy {
1021 #[must_use]
1034 pub fn new(
1035 id: PolicyId,
1036 target: ScaleTarget,
1037 installation_id: u64,
1038 host_id: HostId,
1039 mode: PolicyMode,
1040 cache_policy: CachePolicy,
1041 ) -> Self {
1042 Self::new_for_host_label(
1043 id,
1044 target,
1045 installation_id,
1046 host_id,
1047 HostLabel::new("host").expect("the compatibility host label is valid"),
1048 mode,
1049 cache_policy,
1050 )
1051 }
1052
1053 #[must_use]
1055 pub fn new_for_host_label(
1056 id: PolicyId,
1057 target: ScaleTarget,
1058 installation_id: u64,
1059 host_id: HostId,
1060 requested_host_label: HostLabel,
1061 mode: PolicyMode,
1062 cache_policy: CachePolicy,
1063 ) -> Self {
1064 Self {
1065 id,
1066 target,
1067 installation_id,
1068 host_id,
1069 requested_host_label,
1070 mode,
1071 enabled: false,
1072 state: PolicyState::Pending,
1073 cache_policy,
1074 workspace_policy: WorkspacePolicy::Ephemeral,
1078 revision: 0,
1079 }
1080 }
1081
1082 pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1090 let PersistedPolicy {
1091 id,
1092 target,
1093 installation_id,
1094 host_id,
1095 requested_host_label,
1096 routing_labels,
1097 min_capacity,
1098 max_capacity,
1099 enabled,
1100 state,
1101 cache_policy,
1102 workspace_kind,
1103 workspace_root,
1104 revision,
1105 } = fields;
1106
1107 let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1108 let workspace_policy =
1112 WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1113 Ok(Self {
1114 id,
1115 target,
1116 installation_id,
1117 host_id,
1118 requested_host_label,
1119 mode,
1120 enabled,
1121 state,
1122 cache_policy,
1123 workspace_policy,
1124 revision,
1125 })
1126 }
1127
1128 #[must_use]
1134 pub fn to_persisted(&self) -> PersistedPolicy {
1135 PersistedPolicy {
1136 id: self.id,
1137 target: self.target.clone(),
1138 installation_id: self.installation_id,
1139 host_id: self.host_id,
1140 requested_host_label: self.requested_host_label.clone(),
1141 routing_labels: self.routing_labels().cloned(),
1142 min_capacity: self.min_capacity(),
1143 max_capacity: self.max_capacity(),
1144 enabled: self.enabled,
1145 state: self.state,
1146 cache_policy: self.cache_policy,
1147 workspace_kind: self.workspace_policy.kind(),
1148 workspace_root: self.workspace_policy.root().cloned(),
1149 revision: self.revision,
1150 }
1151 }
1152
1153 #[must_use]
1154 pub const fn mode(&self) -> &PolicyMode {
1155 &self.mode
1156 }
1157
1158 #[must_use]
1159 pub const fn state(&self) -> PolicyState {
1160 self.state
1161 }
1162
1163 #[must_use]
1167 pub const fn enabled(&self) -> bool {
1168 self.enabled
1169 }
1170
1171 #[must_use]
1174 pub const fn revision(&self) -> u64 {
1175 self.revision
1176 }
1177
1178 #[must_use]
1179 pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1180 self.mode.routing_labels()
1181 }
1182
1183 #[must_use]
1185 pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1186 &self.workspace_policy
1187 }
1188
1189 pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1207 workspace.permitted_for(self.target.scope())?;
1212 if self.workspace_policy != workspace {
1213 self.workspace_policy = workspace;
1214 self.revision = self.revision.saturating_add(1);
1215 }
1216 Ok(())
1217 }
1218
1219 #[must_use]
1220 pub const fn min_capacity(&self) -> u16 {
1221 self.mode.min_capacity()
1222 }
1223
1224 #[must_use]
1225 pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1226 self.mode.max_capacity()
1227 }
1228
1229 #[must_use]
1232 pub fn is_owned_by(&self, host_id: HostId) -> bool {
1233 self.host_id == host_id
1234 }
1235
1236 #[must_use]
1243 pub const fn owns_runners(&self) -> bool {
1244 self.mode.is_autoscale()
1245 }
1246
1247 #[must_use]
1253 pub const fn may_start_runners(&self) -> bool {
1254 self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1255 }
1256
1257 pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1261 if !self.state.can_transition_to(next) {
1262 return Err(PolicyError::IllegalTransition {
1263 from: self.state,
1264 to: next,
1265 });
1266 }
1267 self.state = next;
1268 self.revision = self.revision.saturating_add(1);
1269 Ok(())
1270 }
1271
1272 #[must_use]
1278 pub fn can_activate(&self) -> bool {
1279 self.state.can_transition_to(PolicyState::Active)
1280 }
1281
1282 #[must_use]
1284 pub fn can_request_disable(&self) -> bool {
1285 self.state.can_transition_to(PolicyState::Draining)
1286 }
1287
1288 pub fn activate(&mut self) -> Result<(), PolicyError> {
1309 self.transition_to(PolicyState::Active)?;
1310 self.enabled = true;
1311 Ok(())
1312 }
1313
1314 pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1333 self.transition_to(PolicyState::Draining)?;
1334 self.enabled = false;
1335 Ok(self.state)
1336 }
1337
1338 pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1348 if self.state != PolicyState::Draining {
1349 return Err(PolicyError::IllegalTransition {
1350 from: self.state,
1351 to: PolicyState::Disabled,
1352 });
1353 }
1354 if active_attempts == 0 {
1355 self.transition_to(PolicyState::Disabled)?;
1356 }
1357 Ok(self.state)
1358 }
1359
1360 pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1366 self.transition_to(PolicyState::AuthenticationFailed)
1367 }
1368
1369 pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1375 self.transition_to(PolicyState::Pending)
1376 }
1377
1378 pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1383 self.transition_to(PolicyState::RepairRequired)
1384 }
1385
1386 pub fn promote_to_autoscale(
1395 &mut self,
1396 routing_labels: RoutingLabels,
1397 min_capacity: u16,
1398 max_capacity: NonZeroU16,
1399 ) -> Result<(), PolicyError> {
1400 if self.mode.is_autoscale() {
1401 return Err(PolicyError::AlreadyAutoscale);
1402 }
1403 self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1404 self.revision = self.revision.saturating_add(1);
1405 Ok(())
1406 }
1407
1408 pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1416 match &mut self.mode {
1417 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1418 PolicyMode::Autoscale(cfg) => {
1419 cfg.set_max_capacity(max_capacity)?;
1420 self.revision = self.revision.saturating_add(1);
1421 Ok(())
1422 }
1423 }
1424 }
1425
1426 pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
1436 match &mut self.mode {
1437 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1438 PolicyMode::Autoscale(cfg) => {
1439 let added = cfg.routing_labels_mut().add(label);
1440 if added {
1441 self.revision = self.revision.saturating_add(1);
1442 }
1443 Ok(added)
1444 }
1445 }
1446 }
1447
1448 pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
1455 match &mut self.mode {
1456 PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1457 PolicyMode::Autoscale(cfg) => {
1458 let removed = cfg.routing_labels_mut().remove(label)?;
1459 if removed {
1460 self.revision = self.revision.saturating_add(1);
1461 }
1462 Ok(removed)
1463 }
1464 }
1465 }
1466
1467 #[must_use]
1473 pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
1474 match self.routing_labels() {
1475 Some(labels) => labels.tally(jobs),
1476 None => DemandTally::default(),
1477 }
1478 }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483 use super::*;
1484 use crate::model::{HostId, PolicyId, TargetScope};
1485
1486 fn nz(v: u16) -> NonZeroU16 {
1487 NonZeroU16::new(v).expect("test capacity is non-zero")
1488 }
1489
1490 fn label(s: &str) -> Label {
1491 Label::new(s).expect("test label is valid")
1492 }
1493
1494 fn host_labels(host: &str) -> RoutingLabels {
1495 RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
1496 }
1497
1498 fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
1499 ScalePolicy::new(
1500 PolicyId::from_u128(1),
1501 target,
1502 42,
1503 host,
1504 PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
1505 CachePolicy::default(),
1506 )
1507 }
1508
1509 fn workspace_root() -> LocalAbsolutePath {
1514 LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
1515 .expect("a valid persistent root")
1516 }
1517
1518 fn repository_policy() -> ScalePolicy {
1519 autoscale_policy(
1520 ScaleTarget::repository("acme/api").unwrap(),
1521 HostId::from_u128(1),
1522 4,
1523 )
1524 }
1525
1526 fn organization_policy() -> ScalePolicy {
1527 autoscale_policy(
1528 ScaleTarget::organization("acme").unwrap(),
1529 HostId::from_u128(1),
1530 4,
1531 )
1532 }
1533
1534 #[test]
1535 fn every_constructor_produces_an_ephemeral_workspace() {
1536 for policy in [repository_policy(), organization_policy()] {
1539 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1540 assert!(!policy.workspace_policy().retains_job_workspace());
1541 assert_eq!(
1542 policy.to_persisted().workspace_kind,
1543 WorkspaceKind::Ephemeral
1544 );
1545 assert_eq!(policy.to_persisted().workspace_root, None);
1546 }
1547
1548 let monitor_only = ScalePolicy::new(
1549 PolicyId::from_u128(2),
1550 ScaleTarget::repository("acme/api").unwrap(),
1551 42,
1552 HostId::from_u128(1),
1553 PolicyMode::MonitorOnly,
1554 CachePolicy::default(),
1555 );
1556 assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
1557 }
1558
1559 #[test]
1560 fn a_repository_policy_can_opt_into_a_persistent_workspace() {
1561 let mut policy = repository_policy();
1562 let before = policy.revision();
1563
1564 policy
1565 .set_workspace_policy(
1566 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1567 .expect("a repository may be persistent"),
1568 )
1569 .expect("a repository policy accepts persistence");
1570
1571 assert!(policy.workspace_policy().is_persistent());
1572 assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
1573 assert_eq!(
1574 policy.revision(),
1575 before + 1,
1576 "a workspace change must bump the optimistic token, or `a2`'s guard \
1577 cannot refuse a write built from a stale read"
1578 );
1579
1580 let unchanged = policy.revision();
1583 policy
1584 .set_workspace_policy(policy.workspace_policy().clone())
1585 .expect("re-setting the same policy is accepted");
1586 assert_eq!(policy.revision(), unchanged);
1587 }
1588
1589 #[test]
1590 fn an_organization_policy_cannot_be_made_persistent() {
1591 let mut policy = organization_policy();
1594 let before = policy.revision();
1595
1596 assert_eq!(
1597 policy.set_workspace_policy(WorkspacePolicy::Persistent {
1598 root: workspace_root()
1599 }),
1600 Err(PolicyError::Workspace(
1601 WorkspaceError::PersistentRequiresRepositoryScope
1602 ))
1603 );
1604 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1605 assert_eq!(
1606 policy.revision(),
1607 before,
1608 "a refused write consumes nothing"
1609 );
1610
1611 assert_eq!(
1614 WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
1615 Err(WorkspaceError::PersistentRequiresRepositoryScope)
1616 );
1617 }
1618
1619 #[test]
1620 fn a_workspace_policy_round_trips_through_the_persisted_struct() {
1621 let mut policy = repository_policy();
1622 policy
1623 .set_workspace_policy(
1624 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1625 .expect("a repository may be persistent"),
1626 )
1627 .expect("a repository policy accepts persistence");
1628
1629 let restored = ScalePolicy::from_persisted(policy.to_persisted())
1630 .expect("a policy this crate wrote must load");
1631 assert_eq!(restored, policy);
1632 assert_eq!(restored.workspace_policy(), policy.workspace_policy());
1633
1634 let ephemeral = repository_policy();
1635 assert_eq!(
1636 ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
1637 ephemeral
1638 );
1639 }
1640
1641 #[test]
1642 fn an_organization_row_claiming_persistence_fails_closed_on_load() {
1643 let mut fields = organization_policy().to_persisted();
1644 fields.workspace_kind = WorkspaceKind::Persistent;
1645 fields.workspace_root = Some(workspace_root());
1646
1647 assert_eq!(
1648 ScalePolicy::from_persisted(fields),
1649 Err(PolicyError::Workspace(
1650 WorkspaceError::PersistentRequiresRepositoryScope
1651 ))
1652 );
1653 }
1654
1655 #[test]
1656 fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
1657 let base = repository_policy().to_persisted();
1658
1659 let mut without_root = base.clone();
1660 without_root.workspace_kind = WorkspaceKind::Persistent;
1661 assert_eq!(
1662 ScalePolicy::from_persisted(without_root),
1663 Err(PolicyError::Workspace(
1664 WorkspaceError::PersistentWithoutRoot
1665 ))
1666 );
1667
1668 let mut stale_root = base;
1669 stale_root.workspace_root = Some(workspace_root());
1670 assert!(matches!(
1671 ScalePolicy::from_persisted(stale_root),
1672 Err(PolicyError::Workspace(
1673 WorkspaceError::EphemeralWithRoot { .. }
1674 ))
1675 ));
1676 }
1677
1678 #[test]
1679 fn workspace_retention_is_not_the_runner_package_cache_policy() {
1680 let mut policy = repository_policy();
1684 policy.cache_policy = CachePolicy::DiscardRunnerPackage;
1685 policy
1686 .set_workspace_policy(
1687 WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1688 .expect("a repository may be persistent"),
1689 )
1690 .expect("a repository policy accepts persistence");
1691
1692 assert!(policy.workspace_policy().retains_job_workspace());
1693 assert!(!policy.cache_policy.retains_runner_package());
1694 assert!(!policy.cache_policy.retains_job_workspace());
1697 }
1698
1699 #[test]
1704 fn the_derived_label_has_the_shape_the_architecture_gives() {
1705 let labels =
1707 RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
1708 assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
1709 assert_eq!(labels.count().get(), 1);
1710 }
1711
1712 #[test]
1713 fn the_derived_label_is_host_scoped_by_construction() {
1714 let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
1718 let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
1719
1720 assert_ne!(
1721 a.host_label(),
1722 b.host_label(),
1723 "two hosts must not derive the same routing label; with no job \
1724 reservation, a shared label means both hosts start a runner for one job"
1725 );
1726 assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
1727 assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
1728
1729 let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
1731 assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
1732 assert_ne!(a.host_label(), mac.host_label());
1733 }
1734
1735 #[test]
1736 fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
1737 let labels =
1741 RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
1742 assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
1743 }
1744
1745 #[test]
1746 fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
1747 let mut labels = host_labels("home");
1748 let derived = labels.host_label().clone();
1749
1750 assert!(labels.add(label("gpu")));
1751 assert!(labels.add(label("self-hosted")));
1752 assert!(
1753 !labels.add(label("GPU")),
1754 "adding a label that differs only in case must be a no-op, not a duplicate"
1755 );
1756 assert_eq!(labels.count().get(), 3);
1757
1758 assert!(labels.remove(&label("gpu")).unwrap());
1759 assert_eq!(labels.count().get(), 2);
1760 assert!(
1761 !labels.remove(&label("never-added")).unwrap(),
1762 "removing an absent optional label is a no-op, not an error"
1763 );
1764
1765 assert!(
1767 matches!(
1768 labels.remove(&derived),
1769 Err(PolicyError::HostLabelNotRemovable { .. })
1770 ),
1771 "the derived host label must not be removable; it is the only thing \
1772 keeping two hosts from serving each other's jobs"
1773 );
1774 assert!(labels.contains(&derived));
1775
1776 assert!(matches!(
1778 labels.remove(&label("RM-HOME-WIN-X64")),
1779 Err(PolicyError::HostLabelNotRemovable { .. })
1780 ));
1781 }
1782
1783 #[test]
1784 fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
1785 let mut labels = host_labels("home");
1786 let derived = labels.host_label().clone();
1787 assert!(!labels.add(derived));
1788 assert_eq!(labels.count().get(), 1);
1789
1790 let rebuilt = RoutingLabels::from_parts(
1792 labels.host_label().clone(),
1793 vec![labels.host_label().clone(), label("gpu")],
1794 );
1795 assert_eq!(rebuilt.count().get(), 2);
1796 assert_eq!(
1797 rebuilt.as_registration_labels(),
1798 vec!["rm-home-win-x64", "gpu"]
1799 );
1800 }
1801
1802 #[test]
1803 fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
1804 let mut labels = host_labels("home");
1809 labels.add(label("gpu"));
1810 assert_eq!(
1811 labels.as_registration_labels(),
1812 vec!["rm-home-win-x64", "gpu"]
1813 );
1814 assert!(!labels.contains(&label("self-hosted")));
1815 }
1816
1817 #[test]
1818 fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
1819 let mut labels = host_labels("home");
1820 labels.add(label("gpu"));
1821 let json = serde_json::to_string(&labels).unwrap();
1822 let back: RoutingLabels = serde_json::from_str(&json).unwrap();
1823 assert_eq!(labels, back);
1824 assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
1825 }
1826
1827 #[test]
1828 fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
1829 let labels = host_labels("home");
1831 let non_empty = labels.to_non_empty();
1832 assert_eq!(non_empty.count().get(), 1);
1833 assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
1834 }
1835
1836 struct Row {
1843 name: &'static str,
1844 runs_on: RunsOn,
1845 expect: Expect,
1846 }
1847
1848 #[derive(Debug, PartialEq, Eq)]
1849 enum Expect {
1850 Match,
1851 NoMatch,
1852 Unresolvable,
1853 }
1854
1855 fn classify(m: &RunsOnMatch) -> Expect {
1856 match m {
1857 RunsOnMatch::Match { .. } => Expect::Match,
1858 RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
1859 RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
1860 }
1861 }
1862
1863 fn table_policy() -> RoutingLabels {
1864 let mut labels = host_labels("home");
1866 labels.add(label("self-hosted"));
1867 labels.add(label("gpu"));
1868 labels
1869 }
1870
1871 fn table() -> Vec<Row> {
1872 vec![
1873 Row {
1875 name: "string: the derived host label",
1876 runs_on: RunsOn::Single("rm-home-win-x64".into()),
1877 expect: Expect::Match,
1878 },
1879 Row {
1880 name: "string: the derived host label in the wrong case",
1881 runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
1882 expect: Expect::Match,
1883 },
1884 Row {
1885 name: "string: an optional label alone",
1886 runs_on: RunsOn::Single("gpu".into()),
1887 expect: Expect::Match,
1888 },
1889 Row {
1890 name: "string: another host's label",
1891 runs_on: RunsOn::Single("rm-office-win-x64".into()),
1892 expect: Expect::NoMatch,
1893 },
1894 Row {
1895 name: "string: a GitHub-hosted runner label",
1896 runs_on: RunsOn::Single("ubuntu-latest".into()),
1897 expect: Expect::NoMatch,
1898 },
1899 Row {
1901 name: "array: a strict subset of the policy's labels",
1902 runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
1903 expect: Expect::Match,
1904 },
1905 Row {
1906 name: "array: the whole set, out of order and mixed case",
1907 runs_on: RunsOn::Many(vec![
1908 "GPU".into(),
1909 "Rm-Home-Win-X64".into(),
1910 "Self-Hosted".into(),
1911 ]),
1912 expect: Expect::Match,
1913 },
1914 Row {
1915 name: "array: one label the policy does not carry",
1916 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
1917 expect: Expect::NoMatch,
1918 },
1919 Row {
1920 name: "array: an empty array names no labels",
1921 runs_on: RunsOn::Many(vec![]),
1922 expect: Expect::Unresolvable,
1923 },
1924 Row {
1926 name: "map: labels only",
1927 runs_on: RunsOn::Grouped {
1928 group: None,
1929 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
1930 },
1931 expect: Expect::Match,
1932 },
1933 Row {
1934 name: "map: a group plus labels the policy carries",
1935 runs_on: RunsOn::Grouped {
1936 group: Some("Default".into()),
1937 labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
1938 },
1939 expect: Expect::Match,
1940 },
1941 Row {
1942 name: "map: labels as a scalar",
1943 runs_on: RunsOn::Grouped {
1944 group: Some("Default".into()),
1945 labels: RunsOnLabels::One("rm-home-win-x64".into()),
1946 },
1947 expect: Expect::Match,
1948 },
1949 Row {
1950 name: "map: a group plus a label the policy does not carry",
1951 runs_on: RunsOn::Grouped {
1952 group: Some("Default".into()),
1953 labels: RunsOnLabels::Many(vec!["macos".into()]),
1954 },
1955 expect: Expect::NoMatch,
1956 },
1957 Row {
1958 name: "map: a group with no labels constrains something we cannot read",
1959 runs_on: RunsOn::Grouped {
1960 group: Some("Default".into()),
1961 labels: RunsOnLabels::Many(vec![]),
1962 },
1963 expect: Expect::Unresolvable,
1964 },
1965 Row {
1967 name: "expression: the whole value",
1968 runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
1969 expect: Expect::Unresolvable,
1970 },
1971 Row {
1972 name: "expression: one element of an array",
1973 runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
1974 expect: Expect::Unresolvable,
1975 },
1976 Row {
1977 name: "expression: inside the map form",
1978 runs_on: RunsOn::Grouped {
1979 group: None,
1980 labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
1981 },
1982 expect: Expect::Unresolvable,
1983 },
1984 Row {
1985 name: "not a usable label at all",
1986 runs_on: RunsOn::Single("rm-home,win-x64".into()),
1987 expect: Expect::Unresolvable,
1988 },
1989 ]
1990 }
1991
1992 #[test]
1993 fn runs_on_matching_covers_every_documented_form() {
1994 let policy = table_policy();
1995 for row in table() {
1996 let got = policy.matches(&row.runs_on);
1997 assert_eq!(
1998 classify(&got),
1999 row.expect,
2000 "row {:?}: {:?} produced {got:?}",
2001 row.name,
2002 row.runs_on
2003 );
2004 }
2005 }
2006
2007 #[test]
2008 fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2009 let without = host_labels("home");
2013 assert!(
2014 !without
2015 .matches(&RunsOn::Single("self-hosted".into()))
2016 .is_match(),
2017 "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2018 );
2019
2020 let mut with = host_labels("home");
2021 with.add(label("self-hosted"));
2022 assert!(
2023 with.matches(&RunsOn::Single("self-hosted".into()))
2024 .is_match(),
2025 "and it must claim it once the operator adds the label explicitly"
2026 );
2027 }
2028
2029 #[test]
2030 fn a_no_match_names_the_labels_that_were_missing() {
2031 let policy = host_labels("home");
2032 let got = policy.matches(&RunsOn::Many(vec![
2033 "rm-home-win-x64".into(),
2034 "self-hosted".into(),
2035 "GPU".into(),
2036 ]));
2037 match got {
2038 RunsOnMatch::NoMatch { missing } => {
2039 assert_eq!(
2040 missing,
2041 vec![label("self-hosted"), label("gpu")],
2042 "the operator needs to know which labels to add"
2043 );
2044 }
2045 other => panic!("expected NoMatch, got {other:?}"),
2046 }
2047 }
2048
2049 #[test]
2050 fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2051 let policy = host_labels("home");
2052 let got = policy.matches(&RunsOn::Grouped {
2053 group: Some("Default".into()),
2054 labels: RunsOnLabels::One("rm-home-win-x64".into()),
2055 });
2056 assert_eq!(
2057 got,
2058 RunsOnMatch::Match {
2059 runner_group: Some("Default".into())
2060 },
2061 "a policy has no runner-group field, so the domain cannot evaluate \
2062 `group:`; returning it lets `c4`, which can, do so without re-parsing"
2063 );
2064 }
2065
2066 #[test]
2067 fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2068 let policy = host_labels("home");
2069
2070 let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2071 assert!(matches!(
2072 expr,
2073 RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2074 ));
2075
2076 let group = policy.matches(&RunsOn::Grouped {
2077 group: Some("g".into()),
2078 labels: RunsOnLabels::Many(vec![]),
2079 });
2080 assert!(matches!(
2081 group,
2082 RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2083 ));
2084
2085 let none = policy.matches(&RunsOn::Many(vec![]));
2086 assert!(matches!(
2087 none,
2088 RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2089 ));
2090
2091 let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2092 assert!(matches!(
2093 invalid,
2094 RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2095 ));
2096 }
2097
2098 #[test]
2099 fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2100 let policy = table_policy();
2104 let jobs = vec![
2105 RunsOn::Single("rm-home-win-x64".into()), RunsOn::Single("ubuntu-latest".into()), RunsOn::Single("${{ matrix.runner }}".into()), RunsOn::Single("${{ inputs.pool }}".into()), ];
2110 let tally = policy.tally(&jobs);
2111
2112 assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2113 assert_eq!(tally.not_matched, 1);
2114 assert_eq!(
2115 tally.unresolvable.len(),
2116 2,
2117 "and it must not vanish either -- `g2` shows these to the operator"
2118 );
2119 assert_eq!(
2120 tally.total_seen(),
2121 jobs.len() as u32,
2122 "every job seen is accounted for in exactly one bucket"
2123 );
2124 }
2125
2126 #[test]
2127 fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2128 let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2129 assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2130
2131 let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2132 assert_eq!(
2133 many,
2134 RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2135 );
2136
2137 let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2138 assert_eq!(
2139 grouped,
2140 RunsOn::Grouped {
2141 group: Some("g".into()),
2142 labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2143 }
2144 );
2145
2146 let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2147 assert_eq!(
2148 scalar_labels,
2149 RunsOn::Grouped {
2150 group: None,
2151 labels: RunsOnLabels::One("a".into()),
2152 }
2153 );
2154
2155 let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2156 assert_eq!(
2157 group_only,
2158 RunsOn::Grouped {
2159 group: Some("g".into()),
2160 labels: RunsOnLabels::Many(vec![]),
2161 }
2162 );
2163
2164 assert_eq!(
2166 RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2167 RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2168 );
2169 }
2170
2171 #[test]
2176 fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2177 let labels = host_labels("home");
2178
2179 assert!(matches!(
2182 PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2183 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2184 ));
2185 assert!(matches!(
2186 PolicyMode::from_persisted(None, 0, Some(nz(1))),
2187 Err(PolicyError::AutoscaleWithoutRoutingLabels)
2188 ));
2189
2190 assert!(
2192 PolicyMode::from_persisted(None, 0, None)
2193 .unwrap()
2194 .is_monitor_only()
2195 );
2196 assert!(
2197 PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2198 .unwrap()
2199 .is_autoscale()
2200 );
2201 }
2202
2203 #[test]
2204 fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2205 let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2212 assert!(autoscale.routing_labels().is_some());
2213 assert!(autoscale.max_capacity().is_some());
2214
2215 let monitor = PolicyMode::monitor_only();
2216 assert!(monitor.routing_labels().is_none());
2217 assert!(monitor.max_capacity().is_none());
2218 assert_eq!(monitor.min_capacity(), 0);
2219 }
2220
2221 #[test]
2222 fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2223 let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2226 assert!(matches!(
2227 err,
2228 PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2229 ));
2230 assert!(
2231 err.to_string().contains("MonitorOnly"),
2232 "the message must name the shape rule, got: {err}"
2233 );
2234 }
2235
2236 #[test]
2237 fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2238 assert!(matches!(
2244 PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2245 Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2246 ));
2247 assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2248 assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2249
2250 let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2252 assert!(matches!(
2253 cfg.set_max_capacity(nz(1)),
2254 Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2255 ));
2256 assert_eq!(
2257 cfg.max_capacity().get(),
2258 4,
2259 "a refused write changes nothing"
2260 );
2261 }
2262
2263 #[test]
2264 fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2265 for mode in [
2266 PolicyMode::monitor_only(),
2267 PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2268 ] {
2269 let json = serde_json::to_string(&mode).unwrap();
2270 let back: PolicyMode = serde_json::from_str(&json).unwrap();
2271 assert_eq!(mode, back, "{json} did not round-trip");
2272 }
2273
2274 let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2277 let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2278 assert!(
2279 err.to_string().contains("min_capacity"),
2280 "expected the shape error to survive into serde's message, got: {err}"
2281 );
2282 }
2283
2284 #[test]
2285 fn a_policy_round_trips_through_its_persisted_form() {
2286 let mut policy = autoscale_policy(
2294 ScaleTarget::repository("o/r").unwrap(),
2295 HostId::from_u128(7),
2296 3,
2297 );
2298 policy.add_routing_label(label("gpu")).unwrap();
2299 policy.activate().unwrap();
2305 assert_eq!(policy.state(), PolicyState::Active);
2306 assert!(policy.enabled());
2307
2308 let stored = policy.to_persisted();
2309 assert_ne!(
2310 stored.installation_id, stored.revision,
2311 "the fixture must distinguish the two u64 columns, or transposing \
2312 them is unobservable and this test proves nothing"
2313 );
2314 assert_eq!(stored.installation_id, 42);
2315 assert_eq!(stored.revision, policy.revision());
2316
2317 let restored =
2318 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2319 assert_eq!(restored, policy);
2320 assert_eq!(restored.installation_id, 42);
2321 assert_eq!(restored.revision(), policy.revision());
2322 assert_eq!(restored.state(), PolicyState::Active);
2323 assert!(restored.enabled());
2324 assert_eq!(
2325 restored.routing_labels().unwrap().count().get(),
2326 2,
2327 "the optional label survives alongside the host label"
2328 );
2329 }
2330
2331 #[test]
2332 fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
2333 let mut policy = ScalePolicy::new(
2341 PolicyId::from_u128(2),
2342 ScaleTarget::organization("acme").unwrap(),
2343 9,
2344 HostId::from_u128(7),
2345 PolicyMode::monitor_only(),
2346 CachePolicy::default(),
2347 );
2348 policy.activate().unwrap();
2349
2350 let stored = policy.to_persisted();
2351 assert!(stored.routing_labels.is_none());
2352 assert_eq!(stored.min_capacity, 0);
2353 assert!(stored.max_capacity.is_none());
2354 assert_ne!(stored.installation_id, stored.revision);
2355
2356 let restored =
2357 ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2358 assert_eq!(restored, policy);
2359 assert!(
2360 restored.mode().is_monitor_only(),
2361 "the mode is inferred back from the three columns, not stored"
2362 );
2363 assert!(!restored.owns_runners());
2364 assert_eq!(restored.installation_id, 9);
2365 assert_eq!(restored.revision(), 1);
2366 }
2367
2368 fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
2388 use PolicyState::*;
2389 let mut edges = vec![
2390 (Pending, Active),
2391 (Pending, RepairRequired),
2392 (Active, Draining),
2393 (Draining, Disabled),
2394 (Disabled, Pending),
2395 (AuthenticationFailed, Pending),
2397 ];
2398 for from in PolicyState::ALL {
2400 if from != AuthenticationFailed {
2401 edges.push((from, AuthenticationFailed));
2402 }
2403 }
2404 edges
2405 }
2406
2407 #[test]
2408 fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
2409 let expected = diagram_edges();
2412 assert_eq!(
2413 expected.len(),
2414 11,
2415 "the transcription itself changed; check it against the diagram"
2416 );
2417
2418 let mut legal_seen = 0usize;
2419 let mut illegal_seen = 0usize;
2420
2421 for from in PolicyState::ALL {
2422 for to in PolicyState::ALL {
2423 let expected_legal = expected.contains(&(from, to));
2424 let mut policy = autoscale_policy(
2425 ScaleTarget::repository("o/r").unwrap(),
2426 HostId::from_u128(7),
2427 1,
2428 );
2429 policy.state = from;
2433
2434 let result = policy.transition_to(to);
2435 if expected_legal {
2436 legal_seen += 1;
2437 assert!(
2438 result.is_ok(),
2439 "{from} -> {to} is in the diagram and must be accepted"
2440 );
2441 assert_eq!(policy.state(), to);
2442 } else {
2443 illegal_seen += 1;
2444 assert!(
2445 matches!(result, Err(PolicyError::IllegalTransition { .. })),
2446 "{from} -> {to} is not in the diagram and must be rejected"
2447 );
2448 assert_eq!(policy.state(), from, "a refused transition changes nothing");
2449 }
2450 }
2451 }
2452
2453 assert_eq!(legal_seen, 11);
2454 assert_eq!(illegal_seen, 36 - 11);
2455
2456 let mut published = PolicyState::LEGAL.to_vec();
2459 let mut transcribed = expected;
2460 published.sort_unstable();
2461 transcribed.sort_unstable();
2462 assert_eq!(published, transcribed);
2463 }
2464
2465 #[test]
2466 fn a_policy_state_cannot_transition_to_itself() {
2467 for state in PolicyState::ALL {
2468 assert!(
2469 !state.can_transition_to(state),
2470 "{state} -> {state} is not an edge in the diagram; treating it as \
2471 one would let a repeated authentication failure look like progress"
2472 );
2473 }
2474 }
2475
2476 #[test]
2477 fn the_documented_happy_path_walks_pending_to_disabled() {
2478 let mut policy = autoscale_policy(
2479 ScaleTarget::repository("o/r").unwrap(),
2480 HostId::from_u128(7),
2481 2,
2482 );
2483
2484 assert_eq!(policy.state(), PolicyState::Pending);
2486 assert!(!policy.enabled());
2487 assert!(!policy.may_start_runners());
2488
2489 policy.activate().unwrap();
2490 assert_eq!(policy.state(), PolicyState::Active);
2491 assert!(policy.enabled());
2492 assert!(policy.may_start_runners());
2493
2494 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2496 assert_eq!(
2497 policy.drain_completed(1).unwrap(),
2498 PolicyState::Draining,
2499 "a policy with a runner still in flight stays draining"
2500 );
2501 assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
2502 }
2503
2504 #[test]
2505 fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
2506 let mut policy = autoscale_policy(
2509 ScaleTarget::repository("o/r").unwrap(),
2510 HostId::from_u128(7),
2511 5,
2512 );
2513 policy.activate().unwrap();
2514
2515 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
2518 assert_eq!(policy.tally(&jobs).demand(), 4);
2519
2520 assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2521 assert!(!policy.enabled());
2522 assert!(
2523 !policy.may_start_runners(),
2524 "a draining policy must not be the reason a new runner starts, even \
2525 with four jobs queued for its labels"
2526 );
2527 assert_eq!(
2528 policy.tally(&jobs).demand(),
2529 4,
2530 "queued demand stays visible while draining (flow 5.2)"
2531 );
2532 }
2533
2534 #[test]
2535 fn re_authentication_is_the_only_way_out_of_authentication_failed() {
2536 for from in [
2537 PolicyState::Pending,
2538 PolicyState::Active,
2539 PolicyState::Draining,
2540 PolicyState::Disabled,
2541 PolicyState::RepairRequired,
2542 ] {
2543 let mut policy = autoscale_policy(
2544 ScaleTarget::repository("o/r").unwrap(),
2545 HostId::from_u128(7),
2546 1,
2547 );
2548 policy.state = from;
2549 policy.authentication_failed().unwrap();
2550 assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
2551
2552 assert!(matches!(
2554 policy.authentication_failed(),
2555 Err(PolicyError::IllegalTransition { .. })
2556 ));
2557
2558 policy.reauthenticated().unwrap();
2559 assert_eq!(policy.state(), PolicyState::Pending);
2560 }
2561 }
2562
2563 #[test]
2564 fn mutant_disabling_revoked_eligibility_gate_is_detected() {
2565 let mut policy = autoscale_policy(
2566 ScaleTarget::repository("o/r").unwrap(),
2567 HostId::from_u128(7),
2568 1,
2569 );
2570 policy.activate().unwrap();
2571 policy.authentication_failed().unwrap();
2572 assert!(!policy.may_start_runners());
2573
2574 let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
2578 assert!(
2579 mutant_may_start,
2580 "omitting revoked state must make the eligibility gate red"
2581 );
2582 }
2583
2584 #[test]
2585 fn a_refused_transition_leaves_the_revision_untouched() {
2586 let mut policy = autoscale_policy(
2587 ScaleTarget::repository("o/r").unwrap(),
2588 HostId::from_u128(7),
2589 1,
2590 );
2591 assert_eq!(policy.revision(), 0);
2592 policy.activate().unwrap();
2593 assert_eq!(policy.revision(), 1);
2594
2595 assert!(policy.activate().is_err());
2596 assert_eq!(
2597 policy.revision(),
2598 1,
2599 "a rejected write must not bump the optimistic-concurrency token, or \
2600 `b2`'s stale-revision check would reject the next honest write"
2601 );
2602 }
2603
2604 #[test]
2609 fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
2610 let mut policy = ScalePolicy::new(
2611 PolicyId::from_u128(2),
2612 ScaleTarget::organization("acme").unwrap(),
2613 9,
2614 HostId::from_u128(7),
2615 PolicyMode::monitor_only(),
2616 CachePolicy::default(),
2617 );
2618 policy.activate().unwrap();
2619
2620 assert!(!policy.owns_runners());
2621 assert!(
2622 !policy.may_start_runners(),
2623 "an active, enabled monitor-only policy still starts nothing (D19)"
2624 );
2625 assert!(policy.routing_labels().is_none());
2626 assert!(policy.max_capacity().is_none());
2627
2628 let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
2630 assert_eq!(
2631 policy.tally(&jobs).demand(),
2632 0,
2633 "a monitor-only policy has no demand at all, rather than demand that \
2634 is computed and then ignored"
2635 );
2636
2637 assert!(matches!(
2640 policy.add_routing_label(label("gpu")),
2641 Err(PolicyError::NotAutoscale)
2642 ));
2643 assert!(matches!(
2644 policy.remove_routing_label(&label("gpu")),
2645 Err(PolicyError::NotAutoscale)
2646 ));
2647 assert!(matches!(
2655 PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
2656 Err(PolicyError::AutoscaleWithoutMaxCapacity)
2657 ));
2658 }
2659
2660 #[test]
2661 fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
2662 let mut policy = ScalePolicy::new(
2665 PolicyId::from_u128(2),
2666 ScaleTarget::repository("o/r").unwrap(),
2667 9,
2668 HostId::from_u128(7),
2669 PolicyMode::monitor_only(),
2670 CachePolicy::default(),
2671 );
2672 assert!(policy.routing_labels().is_none());
2673
2674 policy
2675 .promote_to_autoscale(host_labels("home"), 0, nz(3))
2676 .unwrap();
2677
2678 assert!(policy.owns_runners());
2679 assert_eq!(
2680 policy.routing_labels().unwrap().host_label().as_str(),
2681 "rm-home-win-x64"
2682 );
2683 assert_eq!(policy.max_capacity().unwrap().get(), 3);
2684
2685 assert!(matches!(
2687 policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
2688 Err(PolicyError::AlreadyAutoscale)
2689 ));
2690 policy.set_max_capacity(nz(4)).unwrap();
2691 assert_eq!(policy.max_capacity().unwrap().get(), 4);
2692 }
2693
2694 #[test]
2695 fn a_policy_is_owned_by_exactly_one_host() {
2696 let mine = HostId::from_u128(7);
2697 let theirs = HostId::from_u128(8);
2698 let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
2699 assert!(policy.is_owned_by(mine));
2700 assert!(!policy.is_owned_by(theirs));
2701 }
2702
2703 #[test]
2704 fn an_overridden_host_label_is_detectable_without_being_rejected() {
2705 assert!(host_labels("home").is_derived_shape());
2709 assert!(
2710 RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
2711 .is_derived_shape(),
2712 "a host label containing `-` still derives a four-plus-segment name"
2713 );
2714 assert_eq!(
2720 host_labels("home--pc").host_label().as_str(),
2721 "rm-home--pc-win-x64"
2722 );
2723 assert!(
2724 host_labels("home--pc").is_derived_shape(),
2725 "consecutive dashes are legal inside a host label; the empty middle \
2726 segment they produce is not evidence of an override"
2727 );
2728
2729 for raw in [
2733 "self-hosted",
2734 "ubuntu-latest",
2735 "rm-home-win",
2736 "rm-home-win-x64-extra",
2737 ] {
2738 assert!(
2739 !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
2740 "{raw:?} is not the derived shape"
2741 );
2742 }
2743 assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
2745 assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
2746 assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
2747
2748 let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
2750 overridden.add(label("rm-home-win-x64"));
2751 assert!(!overridden.is_derived_shape());
2752 }
2753
2754 #[test]
2755 fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
2756 let mut policy = autoscale_policy(
2760 ScaleTarget::repository("o/r").unwrap(),
2761 HostId::from_u128(7),
2762 3,
2763 );
2764
2765 assert!(policy.can_activate());
2766 assert!(
2767 !policy.can_request_disable(),
2768 "a pending policy cannot drain; it is also already not enabled, \
2769 which is what makes the command a no-op rather than a failure"
2770 );
2771 assert!(matches!(
2772 policy.request_disable(),
2773 Err(PolicyError::IllegalTransition {
2774 from: PolicyState::Pending,
2775 to: PolicyState::Draining,
2776 })
2777 ));
2778
2779 policy.activate().unwrap();
2780 assert!(!policy.can_activate(), "already active");
2781 assert!(policy.can_request_disable());
2782 assert!(matches!(
2783 policy.activate(),
2784 Err(PolicyError::IllegalTransition { .. })
2785 ));
2786
2787 policy.request_disable().unwrap();
2788 assert!(!policy.can_request_disable(), "already draining");
2789 assert!(!policy.enabled());
2790 }
2791
2792 fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
2816 let host = HostId::from_u128(7);
2817 let mut trace = Vec::new();
2818
2819 let mut policy = autoscale_policy(target.clone(), host, 3);
2820 trace.push(format!("owns_runners={}", policy.owns_runners()));
2821 trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
2822 trace.push(format!(
2823 "owned_by_other={}",
2824 policy.is_owned_by(HostId::from_u128(8))
2825 ));
2826 trace.push(format!("initial_state={}", policy.state()));
2827 trace.push(format!("initial_enabled={}", policy.enabled()));
2828 trace.push(format!(
2829 "may_start_initially={}",
2830 policy.may_start_runners()
2831 ));
2832 trace.push(format!("labels={}", policy.routing_labels().unwrap()));
2833 trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
2834 trace.push(format!("min_capacity={}", policy.min_capacity()));
2835
2836 policy.activate().unwrap();
2838 trace.push(format!("after_activate={}", policy.state()));
2839 trace.push(format!("may_start_active={}", policy.may_start_runners()));
2840
2841 let jobs = vec![
2843 RunsOn::Single("rm-home-win-x64".into()),
2844 RunsOn::Single("ubuntu-latest".into()),
2845 RunsOn::Single("${{ matrix.os }}".into()),
2846 ];
2847 let tally = policy.tally(&jobs);
2848 trace.push(format!(
2849 "demand={} not_matched={} unresolvable={}",
2850 tally.demand(),
2851 tally.not_matched,
2852 tally.unresolvable.len()
2853 ));
2854
2855 let host_record = crate::model::Host::new(
2859 host,
2860 "home-pc",
2861 Os::Windows,
2862 Arch::X64,
2863 nz(4),
2864 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
2865 )
2866 .unwrap();
2867 let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
2868 let allocation = allocator.allocate(&policy, 3);
2869 trace.push(format!(
2870 "alloc demand={} desired={} active_owned={} headroom_before={} \
2871 to_start={} limiting={}",
2872 allocation.demand,
2873 allocation.desired,
2874 allocation.active_owned,
2875 allocation.headroom_before,
2876 allocation.to_start,
2877 allocation.limiting_factor
2878 ));
2879 trace.push(format!("headroom_after={}", allocator.headroom()));
2880 trace.push(format!(
2883 "alloc_zero_to_start={}",
2884 allocator.allocate(&policy, 0).to_start
2885 ));
2886
2887 let attempt = crate::attempt::RunnerAttempt::allocate(
2891 crate::model::AttemptId::from_u128(11),
2892 policy.id,
2893 "C:/runners/eq",
2894 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
2895 );
2896 trace.push(format!(
2897 "authorize_own_host={:?}",
2898 crate::attempt::authorize(host, &policy, &attempt).is_ok()
2899 ));
2900 trace.push(format!(
2901 "authorize_other_host={}",
2902 crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
2903 .expect_err("an agent on another host must be refused")
2904 ));
2905 let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
2906 crate::model::AttemptId::from_u128(12),
2907 PolicyId::from_u128(999),
2908 "C:/runners/eq-other",
2909 crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
2910 );
2911 trace.push(format!(
2912 "authorize_other_policy={}",
2913 crate::attempt::authorize(host, &policy, &foreign_attempt)
2914 .expect_err("an attempt under another policy must be refused")
2915 ));
2916
2917 trace.push(format!("disable={}", policy.request_disable().unwrap()));
2919 trace.push(format!(
2920 "drain_with_1={}",
2921 policy.drain_completed(1).unwrap()
2922 ));
2923 trace.push(format!(
2924 "drain_with_0={}",
2925 policy.drain_completed(0).unwrap()
2926 ));
2927
2928 trace.push(format!(
2930 "reactivate_err={}",
2931 policy.transition_to(PolicyState::Active).is_err()
2932 ));
2933
2934 trace.push(format!(
2936 "registration_labels={:?}",
2937 autoscale_policy(target, host, 3)
2938 .routing_labels()
2939 .unwrap()
2940 .as_registration_labels()
2941 ));
2942
2943 trace
2944 }
2945
2946 #[test]
2947 fn repository_and_organization_targets_are_equivalent() {
2948 let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
2949 let organization =
2950 assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
2951
2952 assert_eq!(
2953 repository, organization,
2954 "D18: the two scopes differ only in which GitHub endpoint and which \
2955 App permission the gateway uses. Any difference here is a \
2956 scope-dependent domain rule that must not exist."
2957 );
2958
2959 assert_ne!(
2961 ScaleTarget::repository("o/r").unwrap().scope(),
2962 ScaleTarget::organization("o").unwrap().scope()
2963 );
2964 }
2965}