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 supports_container_actions(self) -> bool {
218 matches!(self, Os::Linux)
219 }
220}
221
222impl fmt::Display for Os {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 f.write_str(self.label_token())
225 }
226}
227
228impl FromStr for Os {
229 type Err = ValidationError;
230
231 fn from_str(s: &str) -> Result<Self, Self::Err> {
232 match s.trim().to_ascii_lowercase().as_str() {
233 "win" | "windows" => Ok(Os::Windows),
234 "osx" | "macos" | "mac" | "darwin" => Ok(Os::MacOs),
235 "linux" => Ok(Os::Linux),
236 other => Err(ValidationError::Unrecognised {
237 what: "host operating system",
238 got: other.to_string(),
239 }),
240 }
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum Arch {
252 X64,
253 Arm64,
254 Arm32,
255}
256
257impl Arch {
258 pub const ALL: [Arch; 3] = [Arch::X64, Arch::Arm64, Arch::Arm32];
259
260 #[must_use]
264 pub const fn label_token(self) -> &'static str {
265 match self {
266 Arch::X64 => "x64",
267 Arch::Arm64 => "arm64",
268 Arch::Arm32 => "arm",
269 }
270 }
271
272 #[must_use]
274 pub const fn is_public_preview(self) -> bool {
275 matches!(self, Arch::Arm64)
276 }
277}
278
279impl fmt::Display for Arch {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 f.write_str(self.label_token())
282 }
283}
284
285impl FromStr for Arch {
286 type Err = ValidationError;
287
288 fn from_str(s: &str) -> Result<Self, Self::Err> {
289 match s.trim().to_ascii_lowercase().as_str() {
290 "x64" | "x86_64" | "amd64" => Ok(Arch::X64),
291 "arm64" | "aarch64" => Ok(Arch::Arm64),
292 "arm" | "arm32" | "armv7" => Ok(Arch::Arm32),
293 other => Err(ValidationError::Unrecognised {
294 what: "host architecture",
295 got: other.to_string(),
296 }),
297 }
298 }
299}
300
301#[derive(
309 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
310)]
311#[serde(rename_all = "snake_case")]
312pub enum StartMode {
313 #[default]
314 Boot,
315 Login,
316}
317
318impl fmt::Display for StartMode {
319 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320 f.write_str(match self {
321 StartMode::Boot => "boot",
322 StartMode::Login => "login",
323 })
324 }
325}
326
327impl FromStr for StartMode {
328 type Err = ValidationError;
329
330 fn from_str(s: &str) -> Result<Self, Self::Err> {
331 match s.trim().to_ascii_lowercase().as_str() {
332 "boot" => Ok(StartMode::Boot),
333 "login" => Ok(StartMode::Login),
334 other => Err(ValidationError::Unrecognised {
335 what: "service start mode",
336 got: other.to_string(),
337 }),
338 }
339 }
340}
341
342#[derive(
355 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
356)]
357#[serde(rename_all = "snake_case")]
358pub enum CachePolicy {
359 #[default]
362 RetainRunnerPackage,
363 DiscardRunnerPackage,
365}
366
367impl CachePolicy {
368 #[must_use]
369 pub const fn retains_runner_package(self) -> bool {
370 matches!(self, CachePolicy::RetainRunnerPackage)
371 }
372
373 #[must_use]
383 pub const fn retains_job_workspace(self) -> bool {
384 false
385 }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
401#[serde(try_from = "u16", into = "u16")]
402pub struct RefreshInterval(u16);
403
404impl RefreshInterval {
405 pub const MIN_SECS: u16 = 30;
406 pub const DEFAULT_SECS: u16 = 60;
407
408 pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
412 if secs < Self::MIN_SECS {
413 return Err(ValidationError::BelowFloor {
414 what: "refresh interval (seconds)",
415 min: Self::MIN_SECS,
416 actual: secs,
417 });
418 }
419 Ok(Self(secs))
420 }
421
422 #[must_use]
423 pub const fn as_secs(self) -> u16 {
424 self.0
425 }
426}
427
428impl Default for RefreshInterval {
429 fn default() -> Self {
430 Self(Self::DEFAULT_SECS)
431 }
432}
433
434impl TryFrom<u16> for RefreshInterval {
435 type Error = ValidationError;
436
437 fn try_from(value: u16) -> Result<Self, Self::Error> {
438 Self::from_secs(value)
439 }
440}
441
442impl From<RefreshInterval> for u16 {
443 fn from(value: RefreshInterval) -> Self {
444 value.0
445 }
446}
447
448impl fmt::Display for RefreshInterval {
449 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450 write!(f, "{}s", self.0)
451 }
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
494#[serde(try_from = "String", into = "String")]
495pub struct Label(String);
496
497impl Label {
498 pub const MAX_LEN: usize = 256;
500
501 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
508 let trimmed = raw.as_ref().trim();
509 if trimmed.is_empty() {
510 return Err(ValidationError::Empty { what: "a label" });
511 }
512 if let Some(bad) = trimmed.chars().find(|c| *c == ',' || c.is_control()) {
513 return Err(ValidationError::IllegalCharacter {
514 what: "a label",
515 found: bad,
516 });
517 }
518 let folded = trimmed.to_ascii_lowercase();
521 if folded.chars().count() > Self::MAX_LEN {
522 return Err(ValidationError::TooLong {
523 what: "a label",
524 max: Self::MAX_LEN,
525 actual: folded.chars().count(),
526 });
527 }
528 Ok(Self(folded))
529 }
530
531 #[must_use]
532 pub fn as_str(&self) -> &str {
533 &self.0
534 }
535}
536
537impl fmt::Display for Label {
538 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539 f.write_str(&self.0)
540 }
541}
542
543impl TryFrom<String> for Label {
544 type Error = ValidationError;
545
546 fn try_from(value: String) -> Result<Self, Self::Error> {
547 Self::new(value)
548 }
549}
550
551impl TryFrom<&str> for Label {
552 type Error = ValidationError;
553
554 fn try_from(value: &str) -> Result<Self, Self::Error> {
555 Self::new(value)
556 }
557}
558
559impl From<Label> for String {
560 fn from(value: Label) -> Self {
561 value.0
562 }
563}
564
565impl FromStr for Label {
566 type Err = ValidationError;
567
568 fn from_str(s: &str) -> Result<Self, Self::Err> {
569 Self::new(s)
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
581#[serde(try_from = "String", into = "String")]
582pub struct HostLabel(String);
583
584impl HostLabel {
585 pub const MAX_LEN: usize = 64;
586
587 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
591 let trimmed = raw.as_ref().trim();
592 if trimmed.is_empty() {
593 return Err(ValidationError::Empty {
594 what: "a host label",
595 });
596 }
597 if trimmed.len() > Self::MAX_LEN {
598 return Err(ValidationError::TooLong {
599 what: "a host label",
600 max: Self::MAX_LEN,
601 actual: trimmed.len(),
602 });
603 }
604 if let Some(bad) = trimmed
605 .chars()
606 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
607 {
608 return Err(ValidationError::IllegalCharacter {
609 what: "a host label",
610 found: bad,
611 });
612 }
613 if trimmed.starts_with('-') || trimmed.ends_with('-') {
614 return Err(ValidationError::IllegalEdge {
615 what: "a host label",
616 edge: '-',
617 });
618 }
619 Ok(Self(trimmed.to_ascii_lowercase()))
620 }
621
622 #[must_use]
623 pub fn as_str(&self) -> &str {
624 &self.0
625 }
626}
627
628impl fmt::Display for HostLabel {
629 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
630 f.write_str(&self.0)
631 }
632}
633
634impl TryFrom<String> for HostLabel {
635 type Error = ValidationError;
636
637 fn try_from(value: String) -> Result<Self, Self::Error> {
638 Self::new(value)
639 }
640}
641
642impl From<HostLabel> for String {
643 fn from(value: HostLabel) -> Self {
644 value.0
645 }
646}
647
648impl FromStr for HostLabel {
649 type Err = ValidationError;
650
651 fn from_str(s: &str) -> Result<Self, Self::Err> {
652 Self::new(s)
653 }
654}
655
656#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
659#[serde(try_from = "String", into = "String")]
660pub struct ProfileName(String);
661
662impl ProfileName {
663 pub const MAX_LEN: usize = 64;
664
665 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
668 let trimmed = raw.as_ref().trim();
669 if trimmed.is_empty() {
670 return Err(ValidationError::Empty {
671 what: "a profile name",
672 });
673 }
674 if trimmed.len() > Self::MAX_LEN {
675 return Err(ValidationError::TooLong {
676 what: "a profile name",
677 max: Self::MAX_LEN,
678 actual: trimmed.len(),
679 });
680 }
681 if let Some(found) = trimmed
682 .chars()
683 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
684 {
685 return Err(ValidationError::IllegalCharacter {
686 what: "a profile name",
687 found,
688 });
689 }
690 if trimmed.starts_with('-') || trimmed.ends_with('-') {
691 return Err(ValidationError::IllegalEdge {
692 what: "a profile name",
693 edge: '-',
694 });
695 }
696 Ok(Self(trimmed.to_ascii_lowercase()))
697 }
698
699 #[must_use]
700 pub fn as_str(&self) -> &str {
701 &self.0
702 }
703
704 #[must_use]
705 pub fn default_profile() -> Self {
706 Self("default".to_string())
707 }
708
709 #[must_use]
710 pub fn is_default(&self) -> bool {
711 self.0 == "default"
712 }
713}
714
715impl fmt::Display for ProfileName {
716 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
717 f.write_str(&self.0)
718 }
719}
720
721impl TryFrom<String> for ProfileName {
722 type Error = ValidationError;
723 fn try_from(value: String) -> Result<Self, Self::Error> {
724 Self::new(value)
725 }
726}
727
728impl From<ProfileName> for String {
729 fn from(value: ProfileName) -> Self {
730 value.0
731 }
732}
733
734#[cfg(test)]
735mod profile_name_tests {
736 use super::*;
737
738 #[test]
739 fn names_are_case_insensitive_and_reject_selector_unsafe_input() {
740 assert_eq!(
741 ProfileName::new("Py-Isolated").unwrap(),
742 ProfileName::new("py-isolated").unwrap()
743 );
744 assert_eq!(ProfileName::default_profile().as_str(), "default");
745 assert!(matches!(
746 ProfileName::new(" "),
747 Err(ValidationError::Empty { .. })
748 ));
749 assert!(matches!(
750 ProfileName::new("py bad"),
751 Err(ValidationError::IllegalCharacter { .. })
752 ));
753 assert!(matches!(
754 ProfileName::new("-bad"),
755 Err(ValidationError::IllegalEdge { .. })
756 ));
757 assert!(matches!(
758 ProfileName::new("x".repeat(ProfileName::MAX_LEN + 1)),
759 Err(ValidationError::TooLong { .. })
760 ));
761 }
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
783pub struct NonEmpty<T> {
784 items: Vec<T>,
785}
786
787impl<T> NonEmpty<T> {
788 #[must_use]
789 pub fn of(first: T) -> Self {
790 Self { items: vec![first] }
791 }
792
793 pub fn try_from_vec(items: Vec<T>, what: &'static str) -> Result<Self, ValidationError> {
796 if items.is_empty() {
797 return Err(ValidationError::NonEmptyRequired { what });
798 }
799 Ok(Self { items })
800 }
801
802 #[must_use]
803 pub fn first(&self) -> &T {
804 &self.items[0]
806 }
807
808 #[must_use]
811 pub fn count(&self) -> NonZeroUsize {
812 NonZeroUsize::new(self.items.len()).expect("NonEmpty is never empty")
813 }
814
815 pub fn iter(&self) -> std::slice::Iter<'_, T> {
816 self.items.iter()
817 }
818
819 #[must_use]
820 pub fn as_slice(&self) -> &[T] {
821 &self.items
822 }
823
824 pub fn push(&mut self, item: T) {
825 self.items.push(item);
826 }
827
828 #[must_use]
829 pub fn into_vec(self) -> Vec<T> {
830 self.items
831 }
832
833 #[must_use]
834 pub fn contains(&self, needle: &T) -> bool
835 where
836 T: PartialEq,
837 {
838 self.items.contains(needle)
839 }
840}
841
842impl<'a, T> IntoIterator for &'a NonEmpty<T> {
843 type Item = &'a T;
844 type IntoIter = std::slice::Iter<'a, T>;
845
846 fn into_iter(self) -> Self::IntoIter {
847 self.items.iter()
848 }
849}
850
851impl<T> IntoIterator for NonEmpty<T> {
852 type Item = T;
853 type IntoIter = std::vec::IntoIter<T>;
854
855 fn into_iter(self) -> Self::IntoIter {
856 self.items.into_iter()
857 }
858}
859
860#[derive(Debug, Clone)]
871struct Name(String);
872
873impl Name {
874 fn as_str(&self) -> &str {
875 &self.0
876 }
877}
878
879impl PartialEq for Name {
880 fn eq(&self, other: &Self) -> bool {
881 self.0.eq_ignore_ascii_case(&other.0)
882 }
883}
884
885impl Eq for Name {}
886
887impl Ord for Name {
888 fn cmp(&self, other: &Self) -> Ordering {
889 self.0
890 .bytes()
891 .map(|b| b.to_ascii_lowercase())
892 .cmp(other.0.bytes().map(|b| b.to_ascii_lowercase()))
893 }
894}
895
896impl PartialOrd for Name {
897 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
898 Some(self.cmp(other))
899 }
900}
901
902impl Hash for Name {
903 fn hash<H: Hasher>(&self, state: &mut H) {
904 for byte in self.0.bytes() {
905 state.write_u8(byte.to_ascii_lowercase());
906 }
907 state.write_u8(0xff);
908 }
909}
910
911impl fmt::Display for Name {
912 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
913 f.write_str(&self.0)
914 }
915}
916
917fn validate_login(raw: &str, what: &'static str) -> Result<Name, ValidationError> {
918 let trimmed = raw.trim();
919 if trimmed.is_empty() {
920 return Err(ValidationError::Empty { what });
921 }
922 if trimmed.len() > 39 {
923 return Err(ValidationError::TooLong {
924 what,
925 max: 39,
926 actual: trimmed.len(),
927 });
928 }
929 if let Some(bad) = trimmed
930 .chars()
931 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
932 {
933 return Err(ValidationError::IllegalCharacter { what, found: bad });
934 }
935 if trimmed.starts_with('-') || trimmed.ends_with('-') {
936 return Err(ValidationError::IllegalEdge { what, edge: '-' });
937 }
938 Ok(Name(trimmed.to_string()))
939}
940
941fn validate_repo_name(raw: &str) -> Result<Name, ValidationError> {
942 const WHAT: &str = "a repository name";
943 let trimmed = raw.trim();
944 if trimmed.is_empty() {
945 return Err(ValidationError::Empty { what: WHAT });
946 }
947 if trimmed.len() > 100 {
948 return Err(ValidationError::TooLong {
949 what: WHAT,
950 max: 100,
951 actual: trimmed.len(),
952 });
953 }
954 if trimmed == "." || trimmed == ".." {
955 return Err(ValidationError::Unrecognised {
956 what: WHAT,
957 got: trimmed.to_string(),
958 });
959 }
960 if let Some(bad) = trimmed
961 .chars()
962 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
963 {
964 return Err(ValidationError::IllegalCharacter {
965 what: WHAT,
966 found: bad,
967 });
968 }
969 Ok(Name(trimmed.to_string()))
970}
971
972#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
974#[serde(try_from = "String", into = "String")]
975pub struct OwnerRepo {
976 owner: Name,
977 repo: Name,
978}
979
980impl OwnerRepo {
981 pub fn new(owner: impl AsRef<str>, repo: impl AsRef<str>) -> Result<Self, ValidationError> {
984 Ok(Self {
985 owner: validate_login(owner.as_ref(), "a repository owner")?,
986 repo: validate_repo_name(repo.as_ref())?,
987 })
988 }
989
990 pub fn parse(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
993 let raw = raw.as_ref().trim();
994 let slashes = raw.matches('/').count();
995 if slashes != 1 {
996 return Err(ValidationError::MalformedOwnerRepo {
997 got: raw.to_string(),
998 slashes,
999 });
1000 }
1001 let (owner, repo) = raw.split_once('/').expect("exactly one separator");
1002 Self::new(owner, repo)
1003 }
1004
1005 #[must_use]
1006 pub fn owner(&self) -> &str {
1007 self.owner.as_str()
1008 }
1009
1010 #[must_use]
1011 pub fn repo(&self) -> &str {
1012 self.repo.as_str()
1013 }
1014}
1015
1016impl fmt::Display for OwnerRepo {
1017 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1018 write!(f, "{}/{}", self.owner, self.repo)
1019 }
1020}
1021
1022impl TryFrom<String> for OwnerRepo {
1023 type Error = ValidationError;
1024
1025 fn try_from(value: String) -> Result<Self, Self::Error> {
1026 Self::parse(value)
1027 }
1028}
1029
1030impl From<OwnerRepo> for String {
1031 fn from(value: OwnerRepo) -> Self {
1032 value.to_string()
1033 }
1034}
1035
1036impl FromStr for OwnerRepo {
1037 type Err = ValidationError;
1038
1039 fn from_str(s: &str) -> Result<Self, Self::Err> {
1040 Self::parse(s)
1041 }
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1046#[serde(try_from = "String", into = "String")]
1047pub struct Org(Name);
1048
1049impl Org {
1050 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1053 Ok(Self(validate_login(raw.as_ref(), "an organization login")?))
1054 }
1055
1056 #[must_use]
1057 pub fn as_str(&self) -> &str {
1058 self.0.as_str()
1059 }
1060}
1061
1062impl fmt::Display for Org {
1063 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1064 fmt::Display::fmt(&self.0, f)
1065 }
1066}
1067
1068impl TryFrom<String> for Org {
1069 type Error = ValidationError;
1070
1071 fn try_from(value: String) -> Result<Self, Self::Error> {
1072 Self::new(value)
1073 }
1074}
1075
1076impl From<Org> for String {
1077 fn from(value: Org) -> Self {
1078 value.to_string()
1079 }
1080}
1081
1082impl FromStr for Org {
1083 type Err = ValidationError;
1084
1085 fn from_str(s: &str) -> Result<Self, Self::Err> {
1086 Self::new(s)
1087 }
1088}
1089
1090#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1092#[serde(rename_all = "snake_case")]
1093pub enum TargetScope {
1094 Repository,
1095 Organization,
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1111#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
1112pub enum ScaleTarget {
1113 Repository(OwnerRepo),
1114 Organization(Org),
1115}
1116
1117impl ScaleTarget {
1118 pub fn repository(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1121 Ok(Self::Repository(OwnerRepo::parse(raw)?))
1122 }
1123
1124 pub fn organization(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1127 Ok(Self::Organization(Org::new(raw)?))
1128 }
1129
1130 #[must_use]
1131 pub const fn scope(&self) -> TargetScope {
1132 match self {
1133 ScaleTarget::Repository(_) => TargetScope::Repository,
1134 ScaleTarget::Organization(_) => TargetScope::Organization,
1135 }
1136 }
1137
1138 #[must_use]
1140 pub fn slug(&self) -> String {
1141 match self {
1142 ScaleTarget::Repository(r) => r.to_string(),
1143 ScaleTarget::Organization(o) => o.to_string(),
1144 }
1145 }
1146}
1147
1148impl fmt::Display for ScaleTarget {
1149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1150 f.write_str(&self.slug())
1151 }
1152}
1153
1154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1166pub struct Host {
1167 pub id: HostId,
1168 pub display_name: String,
1169 pub os: Os,
1170 pub architecture: Arch,
1171 pub host_capacity: NonZeroU16,
1172 pub service_start_mode: StartMode,
1173 pub refresh_interval: RefreshInterval,
1174 pub runner_root_override: Option<LocalAbsolutePath>,
1188 pub created_at: Timestamp,
1189}
1190
1191impl Host {
1192 pub fn new(
1195 id: HostId,
1196 display_name: impl AsRef<str>,
1197 os: Os,
1198 architecture: Arch,
1199 host_capacity: NonZeroU16,
1200 created_at: Timestamp,
1201 ) -> Result<Self, ValidationError> {
1202 let display_name = display_name.as_ref().trim();
1203 if display_name.is_empty() {
1204 return Err(ValidationError::Empty {
1205 what: "a host display name",
1206 });
1207 }
1208 Ok(Self {
1209 id,
1210 display_name: display_name.to_string(),
1211 os,
1212 architecture,
1213 host_capacity,
1214 service_start_mode: StartMode::default(),
1215 refresh_interval: RefreshInterval::default(),
1216 runner_root_override: None,
1220 created_at,
1221 })
1222 }
1223
1224 #[must_use]
1225 pub fn host_capacity(&self) -> u16 {
1226 self.host_capacity.get()
1227 }
1228
1229 #[must_use]
1235 pub const fn has_configured_runner_root(&self) -> bool {
1236 self.runner_root_override.is_some()
1237 }
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243
1244 fn ts(secs: i64) -> Timestamp {
1245 chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
1246 }
1247
1248 #[test]
1251 fn a_label_is_folded_to_lower_case_because_github_stores_it_that_way() {
1252 assert_eq!(Label::new("Windows").unwrap().as_str(), "windows");
1255 assert_eq!(Label::new("X64").unwrap().as_str(), "x64");
1256 assert_eq!(
1257 Label::new(" RM-Home-Win-X64 ").unwrap().as_str(),
1258 "rm-home-win-x64"
1259 );
1260 assert_eq!(
1261 Label::new("Windows").unwrap(),
1262 Label::new("windows").unwrap()
1263 );
1264 }
1265
1266 #[test]
1267 fn label_case_folding_reaches_eq_ord_and_hash_together() {
1268 use std::collections::BTreeSet;
1269 let mut set = BTreeSet::new();
1270 set.insert(Label::new("Windows").unwrap());
1271 set.insert(Label::new("windows").unwrap());
1272 set.insert(Label::new("WINDOWS").unwrap());
1273 assert_eq!(
1274 set.len(),
1275 1,
1276 "three spellings of one GitHub label must collapse to one member, \
1277 or a routing-label set can silently hold duplicates"
1278 );
1279 }
1280
1281 #[test]
1282 fn an_unusable_label_cannot_be_constructed() {
1283 assert!(matches!(Label::new(""), Err(ValidationError::Empty { .. })));
1284 assert!(matches!(
1285 Label::new(" "),
1286 Err(ValidationError::Empty { .. })
1287 ));
1288 assert!(
1289 matches!(
1290 Label::new("a,b"),
1291 Err(ValidationError::IllegalCharacter { found: ',', .. })
1292 ),
1293 "a comma separates labels in the runner's own configuration, so a \
1294 label containing one does not round-trip"
1295 );
1296 assert!(matches!(
1297 Label::new("a\nb"),
1298 Err(ValidationError::IllegalCharacter { .. })
1299 ));
1300 assert!(matches!(
1301 Label::new("x".repeat(Label::MAX_LEN + 1)),
1302 Err(ValidationError::TooLong { .. })
1303 ));
1304 assert!(Label::new("x".repeat(Label::MAX_LEN)).is_ok());
1305 }
1306
1307 #[test]
1308 fn the_label_character_rules_are_about_round_tripping_not_injection() {
1309 assert_eq!(Label::new(r#"say"hi"#).unwrap().as_str(), r#"say"hi"#);
1315 assert_eq!(Label::new("it's").unwrap().as_str(), "it's");
1316
1317 for raw in ["a<b", "a>b", "a$b", "a`b", "a;b", "a|b", "a&b", "a b"] {
1321 assert!(
1322 Label::new(raw).is_ok(),
1323 "{raw:?} must construct: the rule set is round-trippability, not \
1324 shell safety"
1325 );
1326 }
1327
1328 for raw in ["c#", ".net", "x86_64", "ubuntu-22.04"] {
1330 assert!(Label::new(raw).is_ok(), "{raw:?} is a real GitHub label");
1331 }
1332 }
1333
1334 #[test]
1335 fn label_folding_is_ascii_and_the_length_is_measured_after_folding() {
1336 assert_eq!(
1339 Label::new("RM-Home-Win-X64").unwrap().as_str(),
1340 "rm-home-win-x64"
1341 );
1342
1343 let mut raw = "x".repeat(Label::MAX_LEN - 1);
1348 raw.push('\u{0130}');
1349 let label = Label::new(&raw).expect("exactly MAX_LEN characters");
1350 assert_eq!(
1351 label.as_str().chars().count(),
1352 Label::MAX_LEN,
1353 "a constructed Label must never be longer than MAX_LEN"
1354 );
1355 }
1356
1357 #[test]
1358 fn a_host_label_is_narrower_than_a_label() {
1359 assert_eq!(HostLabel::new("Home-Win").unwrap().as_str(), "home-win");
1360 assert!(matches!(
1361 HostLabel::new("home win"),
1362 Err(ValidationError::IllegalCharacter { found: ' ', .. })
1363 ));
1364 assert!(matches!(
1365 HostLabel::new("-home"),
1366 Err(ValidationError::IllegalEdge { edge: '-', .. })
1367 ));
1368 assert!(matches!(
1369 HostLabel::new("home-"),
1370 Err(ValidationError::IllegalEdge { edge: '-', .. })
1371 ));
1372 assert!(matches!(
1373 HostLabel::new(""),
1374 Err(ValidationError::Empty { .. })
1375 ));
1376 assert!(HostLabel::new("home_win2").is_ok());
1377 }
1378
1379 #[test]
1382 fn non_empty_rejects_an_empty_vec_and_reports_count_as_non_zero() {
1383 assert!(matches!(
1384 NonEmpty::<Label>::try_from_vec(Vec::new(), "labels"),
1385 Err(ValidationError::NonEmptyRequired { what: "labels" })
1386 ));
1387
1388 let one = NonEmpty::of(Label::new("a").unwrap());
1389 assert_eq!(one.count().get(), 1);
1390 assert_eq!(one.first().as_str(), "a");
1391
1392 let mut two = one;
1393 two.push(Label::new("b").unwrap());
1394 assert_eq!(two.count().get(), 2);
1395 assert!(two.contains(&Label::new("B").unwrap()));
1396 }
1397
1398 #[test]
1401 fn the_refresh_interval_floor_is_unrepresentable_rather_than_validated() {
1402 assert_eq!(RefreshInterval::default().as_secs(), 60);
1405 assert_eq!(RefreshInterval::from_secs(30).unwrap().as_secs(), 30);
1406 assert!(matches!(
1407 RefreshInterval::from_secs(29),
1408 Err(ValidationError::BelowFloor {
1409 min: 30,
1410 actual: 29,
1411 ..
1412 })
1413 ));
1414 assert!(matches!(
1415 RefreshInterval::from_secs(0),
1416 Err(ValidationError::BelowFloor { .. })
1417 ));
1418 assert!(serde_json::from_str::<RefreshInterval>("29").is_err());
1421 assert_eq!(
1422 serde_json::from_str::<RefreshInterval>("45")
1423 .unwrap()
1424 .as_secs(),
1425 45
1426 );
1427 }
1428
1429 #[test]
1432 fn job_workspace_retention_has_no_representation_in_v1() {
1433 for policy in [
1435 CachePolicy::RetainRunnerPackage,
1436 CachePolicy::DiscardRunnerPackage,
1437 ] {
1438 assert!(
1439 !policy.retains_job_workspace(),
1440 "no CachePolicy value may retain a job workspace in v1; a \
1441 retained workspace is the two-job contamination path"
1442 );
1443 }
1444 assert!(CachePolicy::default().retains_runner_package());
1445 }
1446
1447 #[test]
1450 fn owner_repo_parsing_accepts_one_separator_and_nothing_else() {
1451 let ok = OwnerRepo::parse("IvanMurzak/GitHub-Runner-Scaler-UI").unwrap();
1452 assert_eq!(ok.owner(), "IvanMurzak");
1453 assert_eq!(ok.repo(), "GitHub-Runner-Scaler-UI");
1454 assert_eq!(ok.to_string(), "IvanMurzak/GitHub-Runner-Scaler-UI");
1455
1456 for bad in ["owner", "owner/repo/extra", "/repo", "owner/"] {
1457 assert!(
1458 OwnerRepo::parse(bad).is_err(),
1459 "{bad:?} must not parse as a repository target"
1460 );
1461 }
1462 }
1463
1464 #[test]
1465 fn github_names_compare_without_regard_to_case_but_display_as_typed() {
1466 use std::collections::HashSet;
1467
1468 let typed = OwnerRepo::parse("IvanMurzak/Repo").unwrap();
1469 let other = OwnerRepo::parse("ivanmurzak/repo").unwrap();
1470 assert_eq!(
1471 typed, other,
1472 "GitHub resolves these to one repository, so `f2`'s duplicate check \
1473 must too"
1474 );
1475 assert_eq!(
1476 typed.to_string(),
1477 "IvanMurzak/Repo",
1478 "the operator's spelling survives for display"
1479 );
1480
1481 let mut seen = HashSet::new();
1482 seen.insert(typed.clone());
1483 assert!(
1484 !seen.insert(other),
1485 "Hash must agree with Eq, or a HashSet-based duplicate check leaks"
1486 );
1487
1488 assert_eq!(
1489 Org::new("Tap-Top-Fun").unwrap(),
1490 Org::new("tap-top-fun").unwrap()
1491 );
1492 }
1493
1494 #[test]
1495 fn a_scale_target_exposes_its_scope_and_nothing_endpoint_shaped() {
1496 let repo = ScaleTarget::repository("o/r").unwrap();
1497 let org = ScaleTarget::organization("o").unwrap();
1498 assert_eq!(repo.scope(), TargetScope::Repository);
1499 assert_eq!(org.scope(), TargetScope::Organization);
1500 assert_eq!(repo.slug(), "o/r");
1501 assert_eq!(org.slug(), "o");
1502 }
1503
1504 #[test]
1505 fn a_scale_target_round_trips_through_serde_at_both_scopes() {
1506 for target in [
1507 ScaleTarget::repository("owner/repo").unwrap(),
1508 ScaleTarget::organization("org").unwrap(),
1509 ] {
1510 let json = serde_json::to_string(&target).unwrap();
1511 let back: ScaleTarget = serde_json::from_str(&json).unwrap();
1512 assert_eq!(target, back, "{json} did not round-trip");
1513 }
1514 }
1515
1516 #[test]
1519 fn os_and_arch_tokens_are_the_ones_the_worked_example_fixes() {
1520 assert_eq!(Os::Windows.label_token(), "win");
1523 assert_eq!(Arch::X64.label_token(), "x64");
1524 assert_eq!(Os::MacOs.label_token(), "osx");
1525 assert_eq!(Os::Linux.label_token(), "linux");
1526 assert_eq!(Arch::Arm64.label_token(), "arm64");
1527 assert_eq!(Arch::Arm32.label_token(), "arm");
1528
1529 for os in Os::ALL {
1530 assert_eq!(os.label_token().parse::<Os>().unwrap(), os);
1531 }
1532 for arch in Arch::ALL {
1533 assert_eq!(arch.label_token().parse::<Arch>().unwrap(), arch);
1534 }
1535 assert!("plan9".parse::<Os>().is_err());
1536 assert!("riscv".parse::<Arch>().is_err());
1537 }
1538
1539 #[test]
1540 fn only_linux_supports_container_actions_and_only_arm64_is_preview() {
1541 assert!(Os::Linux.supports_container_actions());
1543 assert!(!Os::Windows.supports_container_actions());
1544 assert!(!Os::MacOs.supports_container_actions());
1545
1546 assert!(Arch::Arm64.is_public_preview());
1547 assert!(!Arch::X64.is_public_preview());
1548 }
1549
1550 #[test]
1553 fn a_host_cannot_be_built_with_zero_capacity_or_a_blank_name() {
1554 assert!(
1555 NonZeroU16::new(0).is_none(),
1556 "zero host capacity is unrepresentable"
1557 );
1558 assert!(matches!(
1559 Host::new(
1560 HostId::from_u128(1),
1561 " ",
1562 Os::Windows,
1563 Arch::X64,
1564 NonZeroU16::new(2).unwrap(),
1565 ts(0),
1566 ),
1567 Err(ValidationError::Empty { .. })
1568 ));
1569
1570 let host = Host::new(
1571 HostId::from_u128(1),
1572 " home-pc ",
1573 Os::Windows,
1574 Arch::X64,
1575 NonZeroU16::new(2).unwrap(),
1576 ts(0),
1577 )
1578 .unwrap();
1579 assert_eq!(host.display_name, "home-pc");
1580 assert_eq!(host.host_capacity(), 2);
1581 assert_eq!(host.service_start_mode, StartMode::Boot);
1582 assert_eq!(host.refresh_interval.as_secs(), 60);
1583 }
1584
1585 fn a_host() -> Host {
1588 Host::new(
1589 HostId::from_u128(1),
1590 "home-pc",
1591 Os::Windows,
1592 Arch::X64,
1593 NonZeroU16::new(2).unwrap(),
1594 ts(0),
1595 )
1596 .expect("a valid host")
1597 }
1598
1599 #[test]
1600 fn a_new_host_uses_the_platform_default_runner_root() {
1601 let host = a_host();
1605 assert_eq!(host.runner_root_override, None);
1606 assert!(!host.has_configured_runner_root());
1607 }
1608
1609 #[test]
1610 fn a_configured_runner_root_round_trips_through_serde() {
1611 let root = LocalAbsolutePath::new(if cfg!(windows) {
1612 "C:\\rman"
1613 } else {
1614 "/srv/rman"
1615 })
1616 .expect("a valid native root");
1617 let mut host = a_host();
1618 host.runner_root_override = Some(root.clone());
1619 assert!(host.has_configured_runner_root());
1620
1621 let encoded = serde_json::to_string(&host).expect("serialisable");
1622 let decoded: Host = serde_json::from_str(&encoded).expect("deserialisable");
1623 assert_eq!(decoded, host);
1624 assert_eq!(decoded.runner_root_override, Some(root));
1625 for needle in ["token", "secret", "password"] {
1628 assert!(
1629 !encoded.to_ascii_lowercase().contains(needle),
1630 "the host record leaked {needle:?}: {encoded}"
1631 );
1632 }
1633 }
1634
1635 #[test]
1636 fn a_host_row_carrying_an_illegal_runner_root_fails_closed() {
1637 let host = a_host();
1641 let encoded = serde_json::to_string(&host).expect("serialisable");
1642 let corrupted = encoded.replace(
1643 "\"runner_root_override\":null",
1644 "\"runner_root_override\":\"rman\"",
1645 );
1646 assert_ne!(corrupted, encoded, "the fixture must actually be corrupted");
1647 assert!(serde_json::from_str::<Host>(&corrupted).is_err());
1648 }
1649
1650 #[test]
1651 fn a_fake_clock_is_a_complete_substitute_for_the_system_clock() {
1652 #[derive(Debug)]
1655 struct Fixed(Timestamp);
1656 impl Clock for Fixed {
1657 fn now(&self) -> Timestamp {
1658 self.0
1659 }
1660 }
1661 let clock: &dyn Clock = &Fixed(ts(1_700_000_000));
1662 assert_eq!(clock.now(), ts(1_700_000_000));
1663 }
1664}