1use std::cmp::Ordering;
23use std::fmt;
24use std::hash::{Hash, Hasher};
25use std::num::{NonZeroU16, NonZeroUsize};
26use std::str::FromStr;
27
28use serde::{Deserialize, Serialize};
29use uuid::Uuid;
30
31use crate::path::LocalAbsolutePath;
32
33pub type Timestamp = chrono::DateTime<chrono::Utc>;
39
40pub type Elapsed = chrono::TimeDelta;
42
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
50pub enum ValidationError {
51 #[error("{what} must not be empty")]
52 Empty { what: &'static str },
53
54 #[error("{what} must be at most {max} characters, got {actual}")]
55 TooLong {
56 what: &'static str,
57 max: usize,
58 actual: usize,
59 },
60
61 #[error("{what} contains a character that is not allowed here: {found:?}")]
62 IllegalCharacter { what: &'static str, found: char },
63
64 #[error("{what} must not start or end with {edge:?}")]
65 IllegalEdge { what: &'static str, edge: char },
66
67 #[error(
68 "a repository target must be written as OWNER/REPO; got {got:?} with {slashes} separator(s)"
69 )]
70 MalformedOwnerRepo { got: String, slashes: usize },
71
72 #[error("{what} must be at least {min}, got {actual}")]
73 BelowFloor {
74 what: &'static str,
75 min: u16,
76 actual: u16,
77 },
78
79 #[error("a non-empty collection of {what} was required, but none were supplied")]
80 NonEmptyRequired { what: &'static str },
81
82 #[error("{what} is not a recognised value: {got:?}")]
83 Unrecognised { what: &'static str, got: String },
84}
85
86pub trait Clock: fmt::Debug + Send + Sync {
96 fn now(&self) -> Timestamp;
97}
98
99#[derive(Debug, Clone, Copy, Default)]
107pub struct SystemClock;
108
109impl Clock for SystemClock {
110 fn now(&self) -> Timestamp {
111 chrono::Utc::now()
112 }
113}
114
115impl<T: Clock + ?Sized> Clock for &T {
116 fn now(&self) -> Timestamp {
117 (**self).now()
118 }
119}
120
121impl<T: Clock + ?Sized> Clock for std::sync::Arc<T> {
122 fn now(&self) -> Timestamp {
123 (**self).now()
124 }
125}
126
127macro_rules! uuid_newtype {
132 ($name:ident, $doc:literal) => {
133 #[doc = $doc]
134 #[derive(
135 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
136 )]
137 #[serde(transparent)]
138 pub struct $name(Uuid);
139
140 impl $name {
141 #[must_use]
143 pub fn new_random() -> Self {
144 Self(Uuid::new_v4())
145 }
146
147 #[must_use]
149 pub const fn from_u128(value: u128) -> Self {
150 Self(Uuid::from_u128(value))
151 }
152
153 #[must_use]
154 pub const fn from_uuid(value: Uuid) -> Self {
155 Self(value)
156 }
157
158 #[must_use]
159 pub const fn as_uuid(&self) -> &Uuid {
160 &self.0
161 }
162 }
163
164 impl fmt::Display for $name {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 fmt::Display::fmt(&self.0, f)
167 }
168 }
169 };
170}
171
172uuid_newtype!(HostId, "Identifies one physical machine running one agent.");
173uuid_newtype!(PolicyId, "Identifies one `ScalePolicy`.");
174uuid_newtype!(AttemptId, "Identifies one `RunnerAttempt`.");
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum Os {
190 Windows,
191 MacOs,
192 Linux,
193}
194
195impl Os {
196 pub const ALL: [Os; 3] = [Os::Windows, Os::MacOs, Os::Linux];
197
198 #[must_use]
205 pub const fn label_token(self) -> &'static str {
206 match self {
207 Os::Windows => "win",
208 Os::MacOs => "osx",
209 Os::Linux => "linux",
210 }
211 }
212
213 #[must_use]
217 pub const fn pool_token(self) -> &'static str {
218 match self {
219 Os::Windows => "windows",
220 Os::MacOs => "macos",
221 Os::Linux => "linux",
222 }
223 }
224
225 #[must_use]
229 pub const fn supports_container_actions(self) -> bool {
230 matches!(self, Os::Linux)
231 }
232}
233
234impl fmt::Display for Os {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 f.write_str(self.label_token())
237 }
238}
239
240impl FromStr for Os {
241 type Err = ValidationError;
242
243 fn from_str(s: &str) -> Result<Self, Self::Err> {
244 match s.trim().to_ascii_lowercase().as_str() {
245 "win" | "windows" => Ok(Os::Windows),
246 "osx" | "macos" | "mac" | "darwin" => Ok(Os::MacOs),
247 "linux" => Ok(Os::Linux),
248 other => Err(ValidationError::Unrecognised {
249 what: "host operating system",
250 got: other.to_string(),
251 }),
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum Arch {
264 X64,
265 Arm64,
266 Arm32,
267}
268
269impl Arch {
270 pub const ALL: [Arch; 3] = [Arch::X64, Arch::Arm64, Arch::Arm32];
271
272 #[must_use]
276 pub const fn label_token(self) -> &'static str {
277 match self {
278 Arch::X64 => "x64",
279 Arch::Arm64 => "arm64",
280 Arch::Arm32 => "arm",
281 }
282 }
283
284 #[must_use]
286 pub const fn is_public_preview(self) -> bool {
287 matches!(self, Arch::Arm64)
288 }
289}
290
291impl fmt::Display for Arch {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 f.write_str(self.label_token())
294 }
295}
296
297impl FromStr for Arch {
298 type Err = ValidationError;
299
300 fn from_str(s: &str) -> Result<Self, Self::Err> {
301 match s.trim().to_ascii_lowercase().as_str() {
302 "x64" | "x86_64" | "amd64" => Ok(Arch::X64),
303 "arm64" | "aarch64" => Ok(Arch::Arm64),
304 "arm" | "arm32" | "armv7" => Ok(Arch::Arm32),
305 other => Err(ValidationError::Unrecognised {
306 what: "host architecture",
307 got: other.to_string(),
308 }),
309 }
310 }
311}
312
313#[derive(
321 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
322)]
323#[serde(rename_all = "snake_case")]
324pub enum StartMode {
325 #[default]
326 Boot,
327 Login,
328}
329
330impl fmt::Display for StartMode {
331 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332 f.write_str(match self {
333 StartMode::Boot => "boot",
334 StartMode::Login => "login",
335 })
336 }
337}
338
339impl FromStr for StartMode {
340 type Err = ValidationError;
341
342 fn from_str(s: &str) -> Result<Self, Self::Err> {
343 match s.trim().to_ascii_lowercase().as_str() {
344 "boot" => Ok(StartMode::Boot),
345 "login" => Ok(StartMode::Login),
346 other => Err(ValidationError::Unrecognised {
347 what: "service start mode",
348 got: other.to_string(),
349 }),
350 }
351 }
352}
353
354#[derive(
367 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
368)]
369#[serde(rename_all = "snake_case")]
370pub enum CachePolicy {
371 #[default]
374 RetainRunnerPackage,
375 DiscardRunnerPackage,
377}
378
379impl CachePolicy {
380 #[must_use]
381 pub const fn retains_runner_package(self) -> bool {
382 matches!(self, CachePolicy::RetainRunnerPackage)
383 }
384
385 #[must_use]
395 pub const fn retains_job_workspace(self) -> bool {
396 false
397 }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
413#[serde(try_from = "u16", into = "u16")]
414pub struct RefreshInterval(u16);
415
416impl RefreshInterval {
417 pub const MIN_SECS: u16 = 30;
418 pub const DEFAULT_SECS: u16 = 60;
419
420 pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
424 if secs < Self::MIN_SECS {
425 return Err(ValidationError::BelowFloor {
426 what: "refresh interval (seconds)",
427 min: Self::MIN_SECS,
428 actual: secs,
429 });
430 }
431 Ok(Self(secs))
432 }
433
434 #[must_use]
435 pub const fn as_secs(self) -> u16 {
436 self.0
437 }
438}
439
440impl Default for RefreshInterval {
441 fn default() -> Self {
442 Self(Self::DEFAULT_SECS)
443 }
444}
445
446impl TryFrom<u16> for RefreshInterval {
447 type Error = ValidationError;
448
449 fn try_from(value: u16) -> Result<Self, Self::Error> {
450 Self::from_secs(value)
451 }
452}
453
454impl From<RefreshInterval> for u16 {
455 fn from(value: RefreshInterval) -> Self {
456 value.0
457 }
458}
459
460impl fmt::Display for RefreshInterval {
461 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462 write!(f, "{}s", self.0)
463 }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
484#[serde(try_from = "u16", into = "u16")]
485pub struct IdlePollInterval(u16);
486
487impl IdlePollInterval {
488 pub const MIN_SECS: u16 = 5;
489 pub const DEFAULT_SECS: u16 = 10;
490
491 pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
494 if secs < Self::MIN_SECS {
495 return Err(ValidationError::BelowFloor {
496 what: "idle poll interval (seconds)",
497 min: Self::MIN_SECS,
498 actual: secs,
499 });
500 }
501 Ok(Self(secs))
502 }
503
504 #[must_use]
505 pub const fn as_secs(self) -> u16 {
506 self.0
507 }
508
509 #[must_use]
510 pub const fn as_duration(self) -> std::time::Duration {
511 std::time::Duration::from_secs(self.0 as u64)
512 }
513}
514
515impl Default for IdlePollInterval {
516 fn default() -> Self {
517 Self(Self::DEFAULT_SECS)
518 }
519}
520
521impl TryFrom<u16> for IdlePollInterval {
522 type Error = ValidationError;
523
524 fn try_from(value: u16) -> Result<Self, Self::Error> {
525 Self::from_secs(value)
526 }
527}
528
529impl From<IdlePollInterval> for u16 {
530 fn from(value: IdlePollInterval) -> Self {
531 value.0
532 }
533}
534
535impl fmt::Display for IdlePollInterval {
536 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537 write!(f, "{}s", self.0)
538 }
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
549#[serde(try_from = "u16", into = "u16")]
550pub struct CoordinatedPollInterval(u16);
551
552impl CoordinatedPollInterval {
553 pub const MIN_SECS: u16 = 60;
554 pub const DEFAULT_SECS: u16 = 300;
555
556 pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
559 if secs < Self::MIN_SECS {
560 return Err(ValidationError::BelowFloor {
561 what: "coordinated poll interval (seconds)",
562 min: Self::MIN_SECS,
563 actual: secs,
564 });
565 }
566 Ok(Self(secs))
567 }
568
569 #[must_use]
570 pub const fn as_secs(self) -> u16 {
571 self.0
572 }
573
574 #[must_use]
575 pub const fn as_duration(self) -> std::time::Duration {
576 std::time::Duration::from_secs(self.0 as u64)
577 }
578}
579
580impl Default for CoordinatedPollInterval {
581 fn default() -> Self {
582 Self(Self::DEFAULT_SECS)
583 }
584}
585
586impl TryFrom<u16> for CoordinatedPollInterval {
587 type Error = ValidationError;
588
589 fn try_from(value: u16) -> Result<Self, Self::Error> {
590 Self::from_secs(value)
591 }
592}
593
594impl From<CoordinatedPollInterval> for u16 {
595 fn from(value: CoordinatedPollInterval) -> Self {
596 value.0
597 }
598}
599
600impl fmt::Display for CoordinatedPollInterval {
601 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
602 write!(f, "{}s", self.0)
603 }
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
646#[serde(try_from = "String", into = "String")]
647pub struct Label(String);
648
649impl Label {
650 pub const MAX_LEN: usize = 256;
652
653 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
660 let trimmed = raw.as_ref().trim();
661 if trimmed.is_empty() {
662 return Err(ValidationError::Empty { what: "a label" });
663 }
664 if let Some(bad) = trimmed.chars().find(|c| *c == ',' || c.is_control()) {
665 return Err(ValidationError::IllegalCharacter {
666 what: "a label",
667 found: bad,
668 });
669 }
670 let folded = trimmed.to_ascii_lowercase();
673 if folded.chars().count() > Self::MAX_LEN {
674 return Err(ValidationError::TooLong {
675 what: "a label",
676 max: Self::MAX_LEN,
677 actual: folded.chars().count(),
678 });
679 }
680 Ok(Self(folded))
681 }
682
683 #[must_use]
684 pub fn as_str(&self) -> &str {
685 &self.0
686 }
687}
688
689impl fmt::Display for Label {
690 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691 f.write_str(&self.0)
692 }
693}
694
695impl TryFrom<String> for Label {
696 type Error = ValidationError;
697
698 fn try_from(value: String) -> Result<Self, Self::Error> {
699 Self::new(value)
700 }
701}
702
703impl TryFrom<&str> for Label {
704 type Error = ValidationError;
705
706 fn try_from(value: &str) -> Result<Self, Self::Error> {
707 Self::new(value)
708 }
709}
710
711impl From<Label> for String {
712 fn from(value: Label) -> Self {
713 value.0
714 }
715}
716
717impl FromStr for Label {
718 type Err = ValidationError;
719
720 fn from_str(s: &str) -> Result<Self, Self::Err> {
721 Self::new(s)
722 }
723}
724
725#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
733#[serde(try_from = "String", into = "String")]
734pub struct HostLabel(String);
735
736impl HostLabel {
737 pub const MAX_LEN: usize = 64;
738
739 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
743 let trimmed = raw.as_ref().trim();
744 if trimmed.is_empty() {
745 return Err(ValidationError::Empty {
746 what: "a host label",
747 });
748 }
749 if trimmed.len() > Self::MAX_LEN {
750 return Err(ValidationError::TooLong {
751 what: "a host label",
752 max: Self::MAX_LEN,
753 actual: trimmed.len(),
754 });
755 }
756 if let Some(bad) = trimmed
757 .chars()
758 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
759 {
760 return Err(ValidationError::IllegalCharacter {
761 what: "a host label",
762 found: bad,
763 });
764 }
765 if trimmed.starts_with('-') || trimmed.ends_with('-') {
766 return Err(ValidationError::IllegalEdge {
767 what: "a host label",
768 edge: '-',
769 });
770 }
771 Ok(Self(trimmed.to_ascii_lowercase()))
772 }
773
774 #[must_use]
775 pub fn as_str(&self) -> &str {
776 &self.0
777 }
778}
779
780impl fmt::Display for HostLabel {
781 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
782 f.write_str(&self.0)
783 }
784}
785
786impl TryFrom<String> for HostLabel {
787 type Error = ValidationError;
788
789 fn try_from(value: String) -> Result<Self, Self::Error> {
790 Self::new(value)
791 }
792}
793
794impl From<HostLabel> for String {
795 fn from(value: HostLabel) -> Self {
796 value.0
797 }
798}
799
800impl FromStr for HostLabel {
801 type Err = ValidationError;
802
803 fn from_str(s: &str) -> Result<Self, Self::Err> {
804 Self::new(s)
805 }
806}
807
808#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
811#[serde(try_from = "String", into = "String")]
812pub struct ProfileName(String);
813
814impl ProfileName {
815 pub const MAX_LEN: usize = 64;
816
817 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
820 let trimmed = raw.as_ref().trim();
821 if trimmed.is_empty() {
822 return Err(ValidationError::Empty {
823 what: "a profile name",
824 });
825 }
826 if trimmed.len() > Self::MAX_LEN {
827 return Err(ValidationError::TooLong {
828 what: "a profile name",
829 max: Self::MAX_LEN,
830 actual: trimmed.len(),
831 });
832 }
833 if let Some(found) = trimmed
834 .chars()
835 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
836 {
837 return Err(ValidationError::IllegalCharacter {
838 what: "a profile name",
839 found,
840 });
841 }
842 if trimmed.starts_with('-') || trimmed.ends_with('-') {
843 return Err(ValidationError::IllegalEdge {
844 what: "a profile name",
845 edge: '-',
846 });
847 }
848 Ok(Self(trimmed.to_ascii_lowercase()))
849 }
850
851 #[must_use]
852 pub fn as_str(&self) -> &str {
853 &self.0
854 }
855
856 #[must_use]
857 pub fn default_profile() -> Self {
858 Self("default".to_string())
859 }
860
861 #[must_use]
862 pub fn is_default(&self) -> bool {
863 self.0 == "default"
864 }
865}
866
867impl fmt::Display for ProfileName {
868 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
869 f.write_str(&self.0)
870 }
871}
872
873impl TryFrom<String> for ProfileName {
874 type Error = ValidationError;
875 fn try_from(value: String) -> Result<Self, Self::Error> {
876 Self::new(value)
877 }
878}
879
880impl From<ProfileName> for String {
881 fn from(value: ProfileName) -> Self {
882 value.0
883 }
884}
885
886#[cfg(test)]
887mod profile_name_tests {
888 use super::*;
889
890 #[test]
891 fn names_are_case_insensitive_and_reject_selector_unsafe_input() {
892 assert_eq!(
893 ProfileName::new("Py-Isolated").unwrap(),
894 ProfileName::new("py-isolated").unwrap()
895 );
896 assert_eq!(ProfileName::default_profile().as_str(), "default");
897 assert!(matches!(
898 ProfileName::new(" "),
899 Err(ValidationError::Empty { .. })
900 ));
901 assert!(matches!(
902 ProfileName::new("py bad"),
903 Err(ValidationError::IllegalCharacter { .. })
904 ));
905 assert!(matches!(
906 ProfileName::new("-bad"),
907 Err(ValidationError::IllegalEdge { .. })
908 ));
909 assert!(matches!(
910 ProfileName::new("x".repeat(ProfileName::MAX_LEN + 1)),
911 Err(ValidationError::TooLong { .. })
912 ));
913 }
914}
915
916#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
935pub struct NonEmpty<T> {
936 items: Vec<T>,
937}
938
939impl<T> NonEmpty<T> {
940 #[must_use]
941 pub fn of(first: T) -> Self {
942 Self { items: vec![first] }
943 }
944
945 pub fn try_from_vec(items: Vec<T>, what: &'static str) -> Result<Self, ValidationError> {
948 if items.is_empty() {
949 return Err(ValidationError::NonEmptyRequired { what });
950 }
951 Ok(Self { items })
952 }
953
954 #[must_use]
955 pub fn first(&self) -> &T {
956 &self.items[0]
958 }
959
960 #[must_use]
963 pub fn count(&self) -> NonZeroUsize {
964 NonZeroUsize::new(self.items.len()).expect("NonEmpty is never empty")
965 }
966
967 pub fn iter(&self) -> std::slice::Iter<'_, T> {
968 self.items.iter()
969 }
970
971 #[must_use]
972 pub fn as_slice(&self) -> &[T] {
973 &self.items
974 }
975
976 pub fn push(&mut self, item: T) {
977 self.items.push(item);
978 }
979
980 #[must_use]
981 pub fn into_vec(self) -> Vec<T> {
982 self.items
983 }
984
985 #[must_use]
986 pub fn contains(&self, needle: &T) -> bool
987 where
988 T: PartialEq,
989 {
990 self.items.contains(needle)
991 }
992}
993
994impl<'a, T> IntoIterator for &'a NonEmpty<T> {
995 type Item = &'a T;
996 type IntoIter = std::slice::Iter<'a, T>;
997
998 fn into_iter(self) -> Self::IntoIter {
999 self.items.iter()
1000 }
1001}
1002
1003impl<T> IntoIterator for NonEmpty<T> {
1004 type Item = T;
1005 type IntoIter = std::vec::IntoIter<T>;
1006
1007 fn into_iter(self) -> Self::IntoIter {
1008 self.items.into_iter()
1009 }
1010}
1011
1012#[derive(Debug, Clone)]
1023struct Name(String);
1024
1025impl Name {
1026 fn as_str(&self) -> &str {
1027 &self.0
1028 }
1029}
1030
1031impl PartialEq for Name {
1032 fn eq(&self, other: &Self) -> bool {
1033 self.0.eq_ignore_ascii_case(&other.0)
1034 }
1035}
1036
1037impl Eq for Name {}
1038
1039impl Ord for Name {
1040 fn cmp(&self, other: &Self) -> Ordering {
1041 self.0
1042 .bytes()
1043 .map(|b| b.to_ascii_lowercase())
1044 .cmp(other.0.bytes().map(|b| b.to_ascii_lowercase()))
1045 }
1046}
1047
1048impl PartialOrd for Name {
1049 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1050 Some(self.cmp(other))
1051 }
1052}
1053
1054impl Hash for Name {
1055 fn hash<H: Hasher>(&self, state: &mut H) {
1056 for byte in self.0.bytes() {
1057 state.write_u8(byte.to_ascii_lowercase());
1058 }
1059 state.write_u8(0xff);
1060 }
1061}
1062
1063impl fmt::Display for Name {
1064 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1065 f.write_str(&self.0)
1066 }
1067}
1068
1069fn validate_login(raw: &str, what: &'static str) -> Result<Name, ValidationError> {
1070 let trimmed = raw.trim();
1071 if trimmed.is_empty() {
1072 return Err(ValidationError::Empty { what });
1073 }
1074 if trimmed.len() > 39 {
1075 return Err(ValidationError::TooLong {
1076 what,
1077 max: 39,
1078 actual: trimmed.len(),
1079 });
1080 }
1081 if let Some(bad) = trimmed
1082 .chars()
1083 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
1084 {
1085 return Err(ValidationError::IllegalCharacter { what, found: bad });
1086 }
1087 if trimmed.starts_with('-') || trimmed.ends_with('-') {
1088 return Err(ValidationError::IllegalEdge { what, edge: '-' });
1089 }
1090 Ok(Name(trimmed.to_string()))
1091}
1092
1093fn validate_repo_name(raw: &str) -> Result<Name, ValidationError> {
1094 const WHAT: &str = "a repository name";
1095 let trimmed = raw.trim();
1096 if trimmed.is_empty() {
1097 return Err(ValidationError::Empty { what: WHAT });
1098 }
1099 if trimmed.len() > 100 {
1100 return Err(ValidationError::TooLong {
1101 what: WHAT,
1102 max: 100,
1103 actual: trimmed.len(),
1104 });
1105 }
1106 if trimmed == "." || trimmed == ".." {
1107 return Err(ValidationError::Unrecognised {
1108 what: WHAT,
1109 got: trimmed.to_string(),
1110 });
1111 }
1112 if let Some(bad) = trimmed
1113 .chars()
1114 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
1115 {
1116 return Err(ValidationError::IllegalCharacter {
1117 what: WHAT,
1118 found: bad,
1119 });
1120 }
1121 Ok(Name(trimmed.to_string()))
1122}
1123
1124#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1126#[serde(try_from = "String", into = "String")]
1127pub struct OwnerRepo {
1128 owner: Name,
1129 repo: Name,
1130}
1131
1132impl OwnerRepo {
1133 pub fn new(owner: impl AsRef<str>, repo: impl AsRef<str>) -> Result<Self, ValidationError> {
1136 Ok(Self {
1137 owner: validate_login(owner.as_ref(), "a repository owner")?,
1138 repo: validate_repo_name(repo.as_ref())?,
1139 })
1140 }
1141
1142 pub fn parse(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1145 let raw = raw.as_ref().trim();
1146 let slashes = raw.matches('/').count();
1147 if slashes != 1 {
1148 return Err(ValidationError::MalformedOwnerRepo {
1149 got: raw.to_string(),
1150 slashes,
1151 });
1152 }
1153 let (owner, repo) = raw.split_once('/').expect("exactly one separator");
1154 Self::new(owner, repo)
1155 }
1156
1157 #[must_use]
1158 pub fn owner(&self) -> &str {
1159 self.owner.as_str()
1160 }
1161
1162 #[must_use]
1163 pub fn repo(&self) -> &str {
1164 self.repo.as_str()
1165 }
1166}
1167
1168impl fmt::Display for OwnerRepo {
1169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1170 write!(f, "{}/{}", self.owner, self.repo)
1171 }
1172}
1173
1174impl TryFrom<String> for OwnerRepo {
1175 type Error = ValidationError;
1176
1177 fn try_from(value: String) -> Result<Self, Self::Error> {
1178 Self::parse(value)
1179 }
1180}
1181
1182impl From<OwnerRepo> for String {
1183 fn from(value: OwnerRepo) -> Self {
1184 value.to_string()
1185 }
1186}
1187
1188impl FromStr for OwnerRepo {
1189 type Err = ValidationError;
1190
1191 fn from_str(s: &str) -> Result<Self, Self::Err> {
1192 Self::parse(s)
1193 }
1194}
1195
1196#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1198#[serde(try_from = "String", into = "String")]
1199pub struct Org(Name);
1200
1201impl Org {
1202 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1205 Ok(Self(validate_login(raw.as_ref(), "an organization login")?))
1206 }
1207
1208 #[must_use]
1209 pub fn as_str(&self) -> &str {
1210 self.0.as_str()
1211 }
1212}
1213
1214impl fmt::Display for Org {
1215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1216 fmt::Display::fmt(&self.0, f)
1217 }
1218}
1219
1220impl TryFrom<String> for Org {
1221 type Error = ValidationError;
1222
1223 fn try_from(value: String) -> Result<Self, Self::Error> {
1224 Self::new(value)
1225 }
1226}
1227
1228impl From<Org> for String {
1229 fn from(value: Org) -> Self {
1230 value.to_string()
1231 }
1232}
1233
1234impl FromStr for Org {
1235 type Err = ValidationError;
1236
1237 fn from_str(s: &str) -> Result<Self, Self::Err> {
1238 Self::new(s)
1239 }
1240}
1241
1242#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1244#[serde(rename_all = "snake_case")]
1245pub enum TargetScope {
1246 Repository,
1247 Organization,
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1263#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
1264pub enum ScaleTarget {
1265 Repository(OwnerRepo),
1266 Organization(Org),
1267}
1268
1269impl ScaleTarget {
1270 pub fn repository(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1273 Ok(Self::Repository(OwnerRepo::parse(raw)?))
1274 }
1275
1276 pub fn organization(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1279 Ok(Self::Organization(Org::new(raw)?))
1280 }
1281
1282 #[must_use]
1283 pub const fn scope(&self) -> TargetScope {
1284 match self {
1285 ScaleTarget::Repository(_) => TargetScope::Repository,
1286 ScaleTarget::Organization(_) => TargetScope::Organization,
1287 }
1288 }
1289
1290 #[must_use]
1292 pub fn slug(&self) -> String {
1293 match self {
1294 ScaleTarget::Repository(r) => r.to_string(),
1295 ScaleTarget::Organization(o) => o.to_string(),
1296 }
1297 }
1298}
1299
1300impl fmt::Display for ScaleTarget {
1301 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1302 f.write_str(&self.slug())
1303 }
1304}
1305
1306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1318pub struct Host {
1319 pub id: HostId,
1320 pub display_name: String,
1321 pub os: Os,
1322 pub architecture: Arch,
1323 pub host_capacity: NonZeroU16,
1324 pub service_start_mode: StartMode,
1325 pub refresh_interval: RefreshInterval,
1326 pub runner_root_override: Option<LocalAbsolutePath>,
1340 pub created_at: Timestamp,
1341}
1342
1343impl Host {
1344 pub fn new(
1347 id: HostId,
1348 display_name: impl AsRef<str>,
1349 os: Os,
1350 architecture: Arch,
1351 host_capacity: NonZeroU16,
1352 created_at: Timestamp,
1353 ) -> Result<Self, ValidationError> {
1354 let display_name = display_name.as_ref().trim();
1355 if display_name.is_empty() {
1356 return Err(ValidationError::Empty {
1357 what: "a host display name",
1358 });
1359 }
1360 Ok(Self {
1361 id,
1362 display_name: display_name.to_string(),
1363 os,
1364 architecture,
1365 host_capacity,
1366 service_start_mode: StartMode::default(),
1367 refresh_interval: RefreshInterval::default(),
1368 runner_root_override: None,
1372 created_at,
1373 })
1374 }
1375
1376 #[must_use]
1377 pub fn host_capacity(&self) -> u16 {
1378 self.host_capacity.get()
1379 }
1380
1381 #[must_use]
1387 pub const fn has_configured_runner_root(&self) -> bool {
1388 self.runner_root_override.is_some()
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395
1396 fn ts(secs: i64) -> Timestamp {
1397 chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
1398 }
1399
1400 #[test]
1403 fn a_label_is_folded_to_lower_case_because_github_stores_it_that_way() {
1404 assert_eq!(Label::new("Windows").unwrap().as_str(), "windows");
1407 assert_eq!(Label::new("X64").unwrap().as_str(), "x64");
1408 assert_eq!(
1409 Label::new(" RM-Home-Win-X64 ").unwrap().as_str(),
1410 "rm-home-win-x64"
1411 );
1412 assert_eq!(
1413 Label::new("Windows").unwrap(),
1414 Label::new("windows").unwrap()
1415 );
1416 }
1417
1418 #[test]
1419 fn label_case_folding_reaches_eq_ord_and_hash_together() {
1420 use std::collections::BTreeSet;
1421 let mut set = BTreeSet::new();
1422 set.insert(Label::new("Windows").unwrap());
1423 set.insert(Label::new("windows").unwrap());
1424 set.insert(Label::new("WINDOWS").unwrap());
1425 assert_eq!(
1426 set.len(),
1427 1,
1428 "three spellings of one GitHub label must collapse to one member, \
1429 or a routing-label set can silently hold duplicates"
1430 );
1431 }
1432
1433 #[test]
1434 fn an_unusable_label_cannot_be_constructed() {
1435 assert!(matches!(Label::new(""), Err(ValidationError::Empty { .. })));
1436 assert!(matches!(
1437 Label::new(" "),
1438 Err(ValidationError::Empty { .. })
1439 ));
1440 assert!(
1441 matches!(
1442 Label::new("a,b"),
1443 Err(ValidationError::IllegalCharacter { found: ',', .. })
1444 ),
1445 "a comma separates labels in the runner's own configuration, so a \
1446 label containing one does not round-trip"
1447 );
1448 assert!(matches!(
1449 Label::new("a\nb"),
1450 Err(ValidationError::IllegalCharacter { .. })
1451 ));
1452 assert!(matches!(
1453 Label::new("x".repeat(Label::MAX_LEN + 1)),
1454 Err(ValidationError::TooLong { .. })
1455 ));
1456 assert!(Label::new("x".repeat(Label::MAX_LEN)).is_ok());
1457 }
1458
1459 #[test]
1460 fn the_label_character_rules_are_about_round_tripping_not_injection() {
1461 assert_eq!(Label::new(r#"say"hi"#).unwrap().as_str(), r#"say"hi"#);
1467 assert_eq!(Label::new("it's").unwrap().as_str(), "it's");
1468
1469 for raw in ["a<b", "a>b", "a$b", "a`b", "a;b", "a|b", "a&b", "a b"] {
1473 assert!(
1474 Label::new(raw).is_ok(),
1475 "{raw:?} must construct: the rule set is round-trippability, not \
1476 shell safety"
1477 );
1478 }
1479
1480 for raw in ["c#", ".net", "x86_64", "ubuntu-22.04"] {
1482 assert!(Label::new(raw).is_ok(), "{raw:?} is a real GitHub label");
1483 }
1484 }
1485
1486 #[test]
1487 fn label_folding_is_ascii_and_the_length_is_measured_after_folding() {
1488 assert_eq!(
1491 Label::new("RM-Home-Win-X64").unwrap().as_str(),
1492 "rm-home-win-x64"
1493 );
1494
1495 let mut raw = "x".repeat(Label::MAX_LEN - 1);
1500 raw.push('\u{0130}');
1501 let label = Label::new(&raw).expect("exactly MAX_LEN characters");
1502 assert_eq!(
1503 label.as_str().chars().count(),
1504 Label::MAX_LEN,
1505 "a constructed Label must never be longer than MAX_LEN"
1506 );
1507 }
1508
1509 #[test]
1510 fn a_host_label_is_narrower_than_a_label() {
1511 assert_eq!(HostLabel::new("Home-Win").unwrap().as_str(), "home-win");
1512 assert!(matches!(
1513 HostLabel::new("home win"),
1514 Err(ValidationError::IllegalCharacter { found: ' ', .. })
1515 ));
1516 assert!(matches!(
1517 HostLabel::new("-home"),
1518 Err(ValidationError::IllegalEdge { edge: '-', .. })
1519 ));
1520 assert!(matches!(
1521 HostLabel::new("home-"),
1522 Err(ValidationError::IllegalEdge { edge: '-', .. })
1523 ));
1524 assert!(matches!(
1525 HostLabel::new(""),
1526 Err(ValidationError::Empty { .. })
1527 ));
1528 assert!(HostLabel::new("home_win2").is_ok());
1529 }
1530
1531 #[test]
1534 fn non_empty_rejects_an_empty_vec_and_reports_count_as_non_zero() {
1535 assert!(matches!(
1536 NonEmpty::<Label>::try_from_vec(Vec::new(), "labels"),
1537 Err(ValidationError::NonEmptyRequired { what: "labels" })
1538 ));
1539
1540 let one = NonEmpty::of(Label::new("a").unwrap());
1541 assert_eq!(one.count().get(), 1);
1542 assert_eq!(one.first().as_str(), "a");
1543
1544 let mut two = one;
1545 two.push(Label::new("b").unwrap());
1546 assert_eq!(two.count().get(), 2);
1547 assert!(two.contains(&Label::new("B").unwrap()));
1548 }
1549
1550 #[test]
1553 fn the_idle_interval_defaults_to_ten_seconds_with_a_five_second_floor() {
1554 assert_eq!(IdlePollInterval::default().as_secs(), 10);
1555 assert_eq!(
1556 IdlePollInterval::from_secs(5).unwrap().as_duration(),
1557 std::time::Duration::from_secs(5)
1558 );
1559 assert!(matches!(
1560 IdlePollInterval::from_secs(4),
1561 Err(ValidationError::BelowFloor {
1562 min: 5,
1563 actual: 4,
1564 ..
1565 })
1566 ));
1567 assert!(serde_json::from_str::<IdlePollInterval>("4").is_err());
1568 }
1569
1570 #[test]
1571 fn the_refresh_interval_floor_is_unrepresentable_rather_than_validated() {
1572 assert_eq!(RefreshInterval::default().as_secs(), 60);
1575 assert_eq!(RefreshInterval::from_secs(30).unwrap().as_secs(), 30);
1576 assert!(matches!(
1577 RefreshInterval::from_secs(29),
1578 Err(ValidationError::BelowFloor {
1579 min: 30,
1580 actual: 29,
1581 ..
1582 })
1583 ));
1584 assert!(matches!(
1585 RefreshInterval::from_secs(0),
1586 Err(ValidationError::BelowFloor { .. })
1587 ));
1588 assert!(serde_json::from_str::<RefreshInterval>("29").is_err());
1591 assert_eq!(
1592 serde_json::from_str::<RefreshInterval>("45")
1593 .unwrap()
1594 .as_secs(),
1595 45
1596 );
1597 }
1598
1599 #[test]
1602 fn job_workspace_retention_has_no_representation_in_v1() {
1603 for policy in [
1605 CachePolicy::RetainRunnerPackage,
1606 CachePolicy::DiscardRunnerPackage,
1607 ] {
1608 assert!(
1609 !policy.retains_job_workspace(),
1610 "no CachePolicy value may retain a job workspace in v1; a \
1611 retained workspace is the two-job contamination path"
1612 );
1613 }
1614 assert!(CachePolicy::default().retains_runner_package());
1615 }
1616
1617 #[test]
1620 fn owner_repo_parsing_accepts_one_separator_and_nothing_else() {
1621 let ok = OwnerRepo::parse("IvanMurzak/GitHub-Runner-Scaler-UI").unwrap();
1622 assert_eq!(ok.owner(), "IvanMurzak");
1623 assert_eq!(ok.repo(), "GitHub-Runner-Scaler-UI");
1624 assert_eq!(ok.to_string(), "IvanMurzak/GitHub-Runner-Scaler-UI");
1625
1626 for bad in ["owner", "owner/repo/extra", "/repo", "owner/"] {
1627 assert!(
1628 OwnerRepo::parse(bad).is_err(),
1629 "{bad:?} must not parse as a repository target"
1630 );
1631 }
1632 }
1633
1634 #[test]
1635 fn github_names_compare_without_regard_to_case_but_display_as_typed() {
1636 use std::collections::HashSet;
1637
1638 let typed = OwnerRepo::parse("IvanMurzak/Repo").unwrap();
1639 let other = OwnerRepo::parse("ivanmurzak/repo").unwrap();
1640 assert_eq!(
1641 typed, other,
1642 "GitHub resolves these to one repository, so `f2`'s duplicate check \
1643 must too"
1644 );
1645 assert_eq!(
1646 typed.to_string(),
1647 "IvanMurzak/Repo",
1648 "the operator's spelling survives for display"
1649 );
1650
1651 let mut seen = HashSet::new();
1652 seen.insert(typed.clone());
1653 assert!(
1654 !seen.insert(other),
1655 "Hash must agree with Eq, or a HashSet-based duplicate check leaks"
1656 );
1657
1658 assert_eq!(
1659 Org::new("Tap-Top-Fun").unwrap(),
1660 Org::new("tap-top-fun").unwrap()
1661 );
1662 }
1663
1664 #[test]
1665 fn a_scale_target_exposes_its_scope_and_nothing_endpoint_shaped() {
1666 let repo = ScaleTarget::repository("o/r").unwrap();
1667 let org = ScaleTarget::organization("o").unwrap();
1668 assert_eq!(repo.scope(), TargetScope::Repository);
1669 assert_eq!(org.scope(), TargetScope::Organization);
1670 assert_eq!(repo.slug(), "o/r");
1671 assert_eq!(org.slug(), "o");
1672 }
1673
1674 #[test]
1675 fn a_scale_target_round_trips_through_serde_at_both_scopes() {
1676 for target in [
1677 ScaleTarget::repository("owner/repo").unwrap(),
1678 ScaleTarget::organization("org").unwrap(),
1679 ] {
1680 let json = serde_json::to_string(&target).unwrap();
1681 let back: ScaleTarget = serde_json::from_str(&json).unwrap();
1682 assert_eq!(target, back, "{json} did not round-trip");
1683 }
1684 }
1685
1686 #[test]
1689 fn os_and_arch_tokens_are_the_ones_the_worked_example_fixes() {
1690 assert_eq!(Os::Windows.label_token(), "win");
1693 assert_eq!(Arch::X64.label_token(), "x64");
1694 assert_eq!(Os::MacOs.label_token(), "osx");
1695 assert_eq!(Os::Linux.label_token(), "linux");
1696 assert_eq!(Arch::Arm64.label_token(), "arm64");
1697 assert_eq!(Arch::Arm32.label_token(), "arm");
1698
1699 for os in Os::ALL {
1700 assert_eq!(os.label_token().parse::<Os>().unwrap(), os);
1701 }
1702 for arch in Arch::ALL {
1703 assert_eq!(arch.label_token().parse::<Arch>().unwrap(), arch);
1704 }
1705 assert!("plan9".parse::<Os>().is_err());
1706 assert!("riscv".parse::<Arch>().is_err());
1707 }
1708
1709 #[test]
1710 fn only_linux_supports_container_actions_and_only_arm64_is_preview() {
1711 assert!(Os::Linux.supports_container_actions());
1713 assert!(!Os::Windows.supports_container_actions());
1714 assert!(!Os::MacOs.supports_container_actions());
1715
1716 assert!(Arch::Arm64.is_public_preview());
1717 assert!(!Arch::X64.is_public_preview());
1718 }
1719
1720 #[test]
1723 fn a_host_cannot_be_built_with_zero_capacity_or_a_blank_name() {
1724 assert!(
1725 NonZeroU16::new(0).is_none(),
1726 "zero host capacity is unrepresentable"
1727 );
1728 assert!(matches!(
1729 Host::new(
1730 HostId::from_u128(1),
1731 " ",
1732 Os::Windows,
1733 Arch::X64,
1734 NonZeroU16::new(2).unwrap(),
1735 ts(0),
1736 ),
1737 Err(ValidationError::Empty { .. })
1738 ));
1739
1740 let host = Host::new(
1741 HostId::from_u128(1),
1742 " home-pc ",
1743 Os::Windows,
1744 Arch::X64,
1745 NonZeroU16::new(2).unwrap(),
1746 ts(0),
1747 )
1748 .unwrap();
1749 assert_eq!(host.display_name, "home-pc");
1750 assert_eq!(host.host_capacity(), 2);
1751 assert_eq!(host.service_start_mode, StartMode::Boot);
1752 assert_eq!(host.refresh_interval.as_secs(), 60);
1753 }
1754
1755 fn a_host() -> Host {
1758 Host::new(
1759 HostId::from_u128(1),
1760 "home-pc",
1761 Os::Windows,
1762 Arch::X64,
1763 NonZeroU16::new(2).unwrap(),
1764 ts(0),
1765 )
1766 .expect("a valid host")
1767 }
1768
1769 #[test]
1770 fn a_new_host_uses_the_platform_default_runner_root() {
1771 let host = a_host();
1775 assert_eq!(host.runner_root_override, None);
1776 assert!(!host.has_configured_runner_root());
1777 }
1778
1779 #[test]
1780 fn a_configured_runner_root_round_trips_through_serde() {
1781 let root = LocalAbsolutePath::new(if cfg!(windows) {
1782 "C:\\rman"
1783 } else {
1784 "/srv/rman"
1785 })
1786 .expect("a valid native root");
1787 let mut host = a_host();
1788 host.runner_root_override = Some(root.clone());
1789 assert!(host.has_configured_runner_root());
1790
1791 let encoded = serde_json::to_string(&host).expect("serialisable");
1792 let decoded: Host = serde_json::from_str(&encoded).expect("deserialisable");
1793 assert_eq!(decoded, host);
1794 assert_eq!(decoded.runner_root_override, Some(root));
1795 for needle in ["token", "secret", "password"] {
1798 assert!(
1799 !encoded.to_ascii_lowercase().contains(needle),
1800 "the host record leaked {needle:?}: {encoded}"
1801 );
1802 }
1803 }
1804
1805 #[test]
1806 fn a_host_row_carrying_an_illegal_runner_root_fails_closed() {
1807 let host = a_host();
1811 let encoded = serde_json::to_string(&host).expect("serialisable");
1812 let corrupted = encoded.replace(
1813 "\"runner_root_override\":null",
1814 "\"runner_root_override\":\"rman\"",
1815 );
1816 assert_ne!(corrupted, encoded, "the fixture must actually be corrupted");
1817 assert!(serde_json::from_str::<Host>(&corrupted).is_err());
1818 }
1819
1820 #[test]
1821 fn a_fake_clock_is_a_complete_substitute_for_the_system_clock() {
1822 #[derive(Debug)]
1825 struct Fixed(Timestamp);
1826 impl Clock for Fixed {
1827 fn now(&self) -> Timestamp {
1828 self.0
1829 }
1830 }
1831 let clock: &dyn Clock = &Fixed(ts(1_700_000_000));
1832 assert_eq!(clock.now(), ts(1_700_000_000));
1833 }
1834}