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, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
472#[serde(try_from = "u16", into = "u16")]
473pub struct IdlePollInterval(u16);
474
475impl IdlePollInterval {
476 pub const MIN_SECS: u16 = 5;
477 pub const DEFAULT_SECS: u16 = 10;
478
479 pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
482 if secs < Self::MIN_SECS {
483 return Err(ValidationError::BelowFloor {
484 what: "idle poll interval (seconds)",
485 min: Self::MIN_SECS,
486 actual: secs,
487 });
488 }
489 Ok(Self(secs))
490 }
491
492 #[must_use]
493 pub const fn as_secs(self) -> u16 {
494 self.0
495 }
496
497 #[must_use]
498 pub const fn as_duration(self) -> std::time::Duration {
499 std::time::Duration::from_secs(self.0 as u64)
500 }
501}
502
503impl Default for IdlePollInterval {
504 fn default() -> Self {
505 Self(Self::DEFAULT_SECS)
506 }
507}
508
509impl TryFrom<u16> for IdlePollInterval {
510 type Error = ValidationError;
511
512 fn try_from(value: u16) -> Result<Self, Self::Error> {
513 Self::from_secs(value)
514 }
515}
516
517impl From<IdlePollInterval> for u16 {
518 fn from(value: IdlePollInterval) -> Self {
519 value.0
520 }
521}
522
523impl fmt::Display for IdlePollInterval {
524 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525 write!(f, "{}s", self.0)
526 }
527}
528
529#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
569#[serde(try_from = "String", into = "String")]
570pub struct Label(String);
571
572impl Label {
573 pub const MAX_LEN: usize = 256;
575
576 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
583 let trimmed = raw.as_ref().trim();
584 if trimmed.is_empty() {
585 return Err(ValidationError::Empty { what: "a label" });
586 }
587 if let Some(bad) = trimmed.chars().find(|c| *c == ',' || c.is_control()) {
588 return Err(ValidationError::IllegalCharacter {
589 what: "a label",
590 found: bad,
591 });
592 }
593 let folded = trimmed.to_ascii_lowercase();
596 if folded.chars().count() > Self::MAX_LEN {
597 return Err(ValidationError::TooLong {
598 what: "a label",
599 max: Self::MAX_LEN,
600 actual: folded.chars().count(),
601 });
602 }
603 Ok(Self(folded))
604 }
605
606 #[must_use]
607 pub fn as_str(&self) -> &str {
608 &self.0
609 }
610}
611
612impl fmt::Display for Label {
613 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614 f.write_str(&self.0)
615 }
616}
617
618impl TryFrom<String> for Label {
619 type Error = ValidationError;
620
621 fn try_from(value: String) -> Result<Self, Self::Error> {
622 Self::new(value)
623 }
624}
625
626impl TryFrom<&str> for Label {
627 type Error = ValidationError;
628
629 fn try_from(value: &str) -> Result<Self, Self::Error> {
630 Self::new(value)
631 }
632}
633
634impl From<Label> for String {
635 fn from(value: Label) -> Self {
636 value.0
637 }
638}
639
640impl FromStr for Label {
641 type Err = ValidationError;
642
643 fn from_str(s: &str) -> Result<Self, Self::Err> {
644 Self::new(s)
645 }
646}
647
648#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
656#[serde(try_from = "String", into = "String")]
657pub struct HostLabel(String);
658
659impl HostLabel {
660 pub const MAX_LEN: usize = 64;
661
662 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
666 let trimmed = raw.as_ref().trim();
667 if trimmed.is_empty() {
668 return Err(ValidationError::Empty {
669 what: "a host label",
670 });
671 }
672 if trimmed.len() > Self::MAX_LEN {
673 return Err(ValidationError::TooLong {
674 what: "a host label",
675 max: Self::MAX_LEN,
676 actual: trimmed.len(),
677 });
678 }
679 if let Some(bad) = trimmed
680 .chars()
681 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
682 {
683 return Err(ValidationError::IllegalCharacter {
684 what: "a host label",
685 found: bad,
686 });
687 }
688 if trimmed.starts_with('-') || trimmed.ends_with('-') {
689 return Err(ValidationError::IllegalEdge {
690 what: "a host label",
691 edge: '-',
692 });
693 }
694 Ok(Self(trimmed.to_ascii_lowercase()))
695 }
696
697 #[must_use]
698 pub fn as_str(&self) -> &str {
699 &self.0
700 }
701}
702
703impl fmt::Display for HostLabel {
704 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
705 f.write_str(&self.0)
706 }
707}
708
709impl TryFrom<String> for HostLabel {
710 type Error = ValidationError;
711
712 fn try_from(value: String) -> Result<Self, Self::Error> {
713 Self::new(value)
714 }
715}
716
717impl From<HostLabel> for String {
718 fn from(value: HostLabel) -> Self {
719 value.0
720 }
721}
722
723impl FromStr for HostLabel {
724 type Err = ValidationError;
725
726 fn from_str(s: &str) -> Result<Self, Self::Err> {
727 Self::new(s)
728 }
729}
730
731#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
734#[serde(try_from = "String", into = "String")]
735pub struct ProfileName(String);
736
737impl ProfileName {
738 pub const MAX_LEN: usize = 64;
739
740 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 profile name",
747 });
748 }
749 if trimmed.len() > Self::MAX_LEN {
750 return Err(ValidationError::TooLong {
751 what: "a profile name",
752 max: Self::MAX_LEN,
753 actual: trimmed.len(),
754 });
755 }
756 if let Some(found) = trimmed
757 .chars()
758 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
759 {
760 return Err(ValidationError::IllegalCharacter {
761 what: "a profile name",
762 found,
763 });
764 }
765 if trimmed.starts_with('-') || trimmed.ends_with('-') {
766 return Err(ValidationError::IllegalEdge {
767 what: "a profile name",
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 #[must_use]
780 pub fn default_profile() -> Self {
781 Self("default".to_string())
782 }
783
784 #[must_use]
785 pub fn is_default(&self) -> bool {
786 self.0 == "default"
787 }
788}
789
790impl fmt::Display for ProfileName {
791 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
792 f.write_str(&self.0)
793 }
794}
795
796impl TryFrom<String> for ProfileName {
797 type Error = ValidationError;
798 fn try_from(value: String) -> Result<Self, Self::Error> {
799 Self::new(value)
800 }
801}
802
803impl From<ProfileName> for String {
804 fn from(value: ProfileName) -> Self {
805 value.0
806 }
807}
808
809#[cfg(test)]
810mod profile_name_tests {
811 use super::*;
812
813 #[test]
814 fn names_are_case_insensitive_and_reject_selector_unsafe_input() {
815 assert_eq!(
816 ProfileName::new("Py-Isolated").unwrap(),
817 ProfileName::new("py-isolated").unwrap()
818 );
819 assert_eq!(ProfileName::default_profile().as_str(), "default");
820 assert!(matches!(
821 ProfileName::new(" "),
822 Err(ValidationError::Empty { .. })
823 ));
824 assert!(matches!(
825 ProfileName::new("py bad"),
826 Err(ValidationError::IllegalCharacter { .. })
827 ));
828 assert!(matches!(
829 ProfileName::new("-bad"),
830 Err(ValidationError::IllegalEdge { .. })
831 ));
832 assert!(matches!(
833 ProfileName::new("x".repeat(ProfileName::MAX_LEN + 1)),
834 Err(ValidationError::TooLong { .. })
835 ));
836 }
837}
838
839#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
858pub struct NonEmpty<T> {
859 items: Vec<T>,
860}
861
862impl<T> NonEmpty<T> {
863 #[must_use]
864 pub fn of(first: T) -> Self {
865 Self { items: vec![first] }
866 }
867
868 pub fn try_from_vec(items: Vec<T>, what: &'static str) -> Result<Self, ValidationError> {
871 if items.is_empty() {
872 return Err(ValidationError::NonEmptyRequired { what });
873 }
874 Ok(Self { items })
875 }
876
877 #[must_use]
878 pub fn first(&self) -> &T {
879 &self.items[0]
881 }
882
883 #[must_use]
886 pub fn count(&self) -> NonZeroUsize {
887 NonZeroUsize::new(self.items.len()).expect("NonEmpty is never empty")
888 }
889
890 pub fn iter(&self) -> std::slice::Iter<'_, T> {
891 self.items.iter()
892 }
893
894 #[must_use]
895 pub fn as_slice(&self) -> &[T] {
896 &self.items
897 }
898
899 pub fn push(&mut self, item: T) {
900 self.items.push(item);
901 }
902
903 #[must_use]
904 pub fn into_vec(self) -> Vec<T> {
905 self.items
906 }
907
908 #[must_use]
909 pub fn contains(&self, needle: &T) -> bool
910 where
911 T: PartialEq,
912 {
913 self.items.contains(needle)
914 }
915}
916
917impl<'a, T> IntoIterator for &'a NonEmpty<T> {
918 type Item = &'a T;
919 type IntoIter = std::slice::Iter<'a, T>;
920
921 fn into_iter(self) -> Self::IntoIter {
922 self.items.iter()
923 }
924}
925
926impl<T> IntoIterator for NonEmpty<T> {
927 type Item = T;
928 type IntoIter = std::vec::IntoIter<T>;
929
930 fn into_iter(self) -> Self::IntoIter {
931 self.items.into_iter()
932 }
933}
934
935#[derive(Debug, Clone)]
946struct Name(String);
947
948impl Name {
949 fn as_str(&self) -> &str {
950 &self.0
951 }
952}
953
954impl PartialEq for Name {
955 fn eq(&self, other: &Self) -> bool {
956 self.0.eq_ignore_ascii_case(&other.0)
957 }
958}
959
960impl Eq for Name {}
961
962impl Ord for Name {
963 fn cmp(&self, other: &Self) -> Ordering {
964 self.0
965 .bytes()
966 .map(|b| b.to_ascii_lowercase())
967 .cmp(other.0.bytes().map(|b| b.to_ascii_lowercase()))
968 }
969}
970
971impl PartialOrd for Name {
972 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
973 Some(self.cmp(other))
974 }
975}
976
977impl Hash for Name {
978 fn hash<H: Hasher>(&self, state: &mut H) {
979 for byte in self.0.bytes() {
980 state.write_u8(byte.to_ascii_lowercase());
981 }
982 state.write_u8(0xff);
983 }
984}
985
986impl fmt::Display for Name {
987 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
988 f.write_str(&self.0)
989 }
990}
991
992fn validate_login(raw: &str, what: &'static str) -> Result<Name, ValidationError> {
993 let trimmed = raw.trim();
994 if trimmed.is_empty() {
995 return Err(ValidationError::Empty { what });
996 }
997 if trimmed.len() > 39 {
998 return Err(ValidationError::TooLong {
999 what,
1000 max: 39,
1001 actual: trimmed.len(),
1002 });
1003 }
1004 if let Some(bad) = trimmed
1005 .chars()
1006 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
1007 {
1008 return Err(ValidationError::IllegalCharacter { what, found: bad });
1009 }
1010 if trimmed.starts_with('-') || trimmed.ends_with('-') {
1011 return Err(ValidationError::IllegalEdge { what, edge: '-' });
1012 }
1013 Ok(Name(trimmed.to_string()))
1014}
1015
1016fn validate_repo_name(raw: &str) -> Result<Name, ValidationError> {
1017 const WHAT: &str = "a repository name";
1018 let trimmed = raw.trim();
1019 if trimmed.is_empty() {
1020 return Err(ValidationError::Empty { what: WHAT });
1021 }
1022 if trimmed.len() > 100 {
1023 return Err(ValidationError::TooLong {
1024 what: WHAT,
1025 max: 100,
1026 actual: trimmed.len(),
1027 });
1028 }
1029 if trimmed == "." || trimmed == ".." {
1030 return Err(ValidationError::Unrecognised {
1031 what: WHAT,
1032 got: trimmed.to_string(),
1033 });
1034 }
1035 if let Some(bad) = trimmed
1036 .chars()
1037 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
1038 {
1039 return Err(ValidationError::IllegalCharacter {
1040 what: WHAT,
1041 found: bad,
1042 });
1043 }
1044 Ok(Name(trimmed.to_string()))
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1049#[serde(try_from = "String", into = "String")]
1050pub struct OwnerRepo {
1051 owner: Name,
1052 repo: Name,
1053}
1054
1055impl OwnerRepo {
1056 pub fn new(owner: impl AsRef<str>, repo: impl AsRef<str>) -> Result<Self, ValidationError> {
1059 Ok(Self {
1060 owner: validate_login(owner.as_ref(), "a repository owner")?,
1061 repo: validate_repo_name(repo.as_ref())?,
1062 })
1063 }
1064
1065 pub fn parse(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1068 let raw = raw.as_ref().trim();
1069 let slashes = raw.matches('/').count();
1070 if slashes != 1 {
1071 return Err(ValidationError::MalformedOwnerRepo {
1072 got: raw.to_string(),
1073 slashes,
1074 });
1075 }
1076 let (owner, repo) = raw.split_once('/').expect("exactly one separator");
1077 Self::new(owner, repo)
1078 }
1079
1080 #[must_use]
1081 pub fn owner(&self) -> &str {
1082 self.owner.as_str()
1083 }
1084
1085 #[must_use]
1086 pub fn repo(&self) -> &str {
1087 self.repo.as_str()
1088 }
1089}
1090
1091impl fmt::Display for OwnerRepo {
1092 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1093 write!(f, "{}/{}", self.owner, self.repo)
1094 }
1095}
1096
1097impl TryFrom<String> for OwnerRepo {
1098 type Error = ValidationError;
1099
1100 fn try_from(value: String) -> Result<Self, Self::Error> {
1101 Self::parse(value)
1102 }
1103}
1104
1105impl From<OwnerRepo> for String {
1106 fn from(value: OwnerRepo) -> Self {
1107 value.to_string()
1108 }
1109}
1110
1111impl FromStr for OwnerRepo {
1112 type Err = ValidationError;
1113
1114 fn from_str(s: &str) -> Result<Self, Self::Err> {
1115 Self::parse(s)
1116 }
1117}
1118
1119#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1121#[serde(try_from = "String", into = "String")]
1122pub struct Org(Name);
1123
1124impl Org {
1125 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1128 Ok(Self(validate_login(raw.as_ref(), "an organization login")?))
1129 }
1130
1131 #[must_use]
1132 pub fn as_str(&self) -> &str {
1133 self.0.as_str()
1134 }
1135}
1136
1137impl fmt::Display for Org {
1138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1139 fmt::Display::fmt(&self.0, f)
1140 }
1141}
1142
1143impl TryFrom<String> for Org {
1144 type Error = ValidationError;
1145
1146 fn try_from(value: String) -> Result<Self, Self::Error> {
1147 Self::new(value)
1148 }
1149}
1150
1151impl From<Org> for String {
1152 fn from(value: Org) -> Self {
1153 value.to_string()
1154 }
1155}
1156
1157impl FromStr for Org {
1158 type Err = ValidationError;
1159
1160 fn from_str(s: &str) -> Result<Self, Self::Err> {
1161 Self::new(s)
1162 }
1163}
1164
1165#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1167#[serde(rename_all = "snake_case")]
1168pub enum TargetScope {
1169 Repository,
1170 Organization,
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1186#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
1187pub enum ScaleTarget {
1188 Repository(OwnerRepo),
1189 Organization(Org),
1190}
1191
1192impl ScaleTarget {
1193 pub fn repository(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1196 Ok(Self::Repository(OwnerRepo::parse(raw)?))
1197 }
1198
1199 pub fn organization(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1202 Ok(Self::Organization(Org::new(raw)?))
1203 }
1204
1205 #[must_use]
1206 pub const fn scope(&self) -> TargetScope {
1207 match self {
1208 ScaleTarget::Repository(_) => TargetScope::Repository,
1209 ScaleTarget::Organization(_) => TargetScope::Organization,
1210 }
1211 }
1212
1213 #[must_use]
1215 pub fn slug(&self) -> String {
1216 match self {
1217 ScaleTarget::Repository(r) => r.to_string(),
1218 ScaleTarget::Organization(o) => o.to_string(),
1219 }
1220 }
1221}
1222
1223impl fmt::Display for ScaleTarget {
1224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1225 f.write_str(&self.slug())
1226 }
1227}
1228
1229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1241pub struct Host {
1242 pub id: HostId,
1243 pub display_name: String,
1244 pub os: Os,
1245 pub architecture: Arch,
1246 pub host_capacity: NonZeroU16,
1247 pub service_start_mode: StartMode,
1248 pub refresh_interval: RefreshInterval,
1249 pub runner_root_override: Option<LocalAbsolutePath>,
1263 pub created_at: Timestamp,
1264}
1265
1266impl Host {
1267 pub fn new(
1270 id: HostId,
1271 display_name: impl AsRef<str>,
1272 os: Os,
1273 architecture: Arch,
1274 host_capacity: NonZeroU16,
1275 created_at: Timestamp,
1276 ) -> Result<Self, ValidationError> {
1277 let display_name = display_name.as_ref().trim();
1278 if display_name.is_empty() {
1279 return Err(ValidationError::Empty {
1280 what: "a host display name",
1281 });
1282 }
1283 Ok(Self {
1284 id,
1285 display_name: display_name.to_string(),
1286 os,
1287 architecture,
1288 host_capacity,
1289 service_start_mode: StartMode::default(),
1290 refresh_interval: RefreshInterval::default(),
1291 runner_root_override: None,
1295 created_at,
1296 })
1297 }
1298
1299 #[must_use]
1300 pub fn host_capacity(&self) -> u16 {
1301 self.host_capacity.get()
1302 }
1303
1304 #[must_use]
1310 pub const fn has_configured_runner_root(&self) -> bool {
1311 self.runner_root_override.is_some()
1312 }
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317 use super::*;
1318
1319 fn ts(secs: i64) -> Timestamp {
1320 chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
1321 }
1322
1323 #[test]
1326 fn a_label_is_folded_to_lower_case_because_github_stores_it_that_way() {
1327 assert_eq!(Label::new("Windows").unwrap().as_str(), "windows");
1330 assert_eq!(Label::new("X64").unwrap().as_str(), "x64");
1331 assert_eq!(
1332 Label::new(" RM-Home-Win-X64 ").unwrap().as_str(),
1333 "rm-home-win-x64"
1334 );
1335 assert_eq!(
1336 Label::new("Windows").unwrap(),
1337 Label::new("windows").unwrap()
1338 );
1339 }
1340
1341 #[test]
1342 fn label_case_folding_reaches_eq_ord_and_hash_together() {
1343 use std::collections::BTreeSet;
1344 let mut set = BTreeSet::new();
1345 set.insert(Label::new("Windows").unwrap());
1346 set.insert(Label::new("windows").unwrap());
1347 set.insert(Label::new("WINDOWS").unwrap());
1348 assert_eq!(
1349 set.len(),
1350 1,
1351 "three spellings of one GitHub label must collapse to one member, \
1352 or a routing-label set can silently hold duplicates"
1353 );
1354 }
1355
1356 #[test]
1357 fn an_unusable_label_cannot_be_constructed() {
1358 assert!(matches!(Label::new(""), Err(ValidationError::Empty { .. })));
1359 assert!(matches!(
1360 Label::new(" "),
1361 Err(ValidationError::Empty { .. })
1362 ));
1363 assert!(
1364 matches!(
1365 Label::new("a,b"),
1366 Err(ValidationError::IllegalCharacter { found: ',', .. })
1367 ),
1368 "a comma separates labels in the runner's own configuration, so a \
1369 label containing one does not round-trip"
1370 );
1371 assert!(matches!(
1372 Label::new("a\nb"),
1373 Err(ValidationError::IllegalCharacter { .. })
1374 ));
1375 assert!(matches!(
1376 Label::new("x".repeat(Label::MAX_LEN + 1)),
1377 Err(ValidationError::TooLong { .. })
1378 ));
1379 assert!(Label::new("x".repeat(Label::MAX_LEN)).is_ok());
1380 }
1381
1382 #[test]
1383 fn the_label_character_rules_are_about_round_tripping_not_injection() {
1384 assert_eq!(Label::new(r#"say"hi"#).unwrap().as_str(), r#"say"hi"#);
1390 assert_eq!(Label::new("it's").unwrap().as_str(), "it's");
1391
1392 for raw in ["a<b", "a>b", "a$b", "a`b", "a;b", "a|b", "a&b", "a b"] {
1396 assert!(
1397 Label::new(raw).is_ok(),
1398 "{raw:?} must construct: the rule set is round-trippability, not \
1399 shell safety"
1400 );
1401 }
1402
1403 for raw in ["c#", ".net", "x86_64", "ubuntu-22.04"] {
1405 assert!(Label::new(raw).is_ok(), "{raw:?} is a real GitHub label");
1406 }
1407 }
1408
1409 #[test]
1410 fn label_folding_is_ascii_and_the_length_is_measured_after_folding() {
1411 assert_eq!(
1414 Label::new("RM-Home-Win-X64").unwrap().as_str(),
1415 "rm-home-win-x64"
1416 );
1417
1418 let mut raw = "x".repeat(Label::MAX_LEN - 1);
1423 raw.push('\u{0130}');
1424 let label = Label::new(&raw).expect("exactly MAX_LEN characters");
1425 assert_eq!(
1426 label.as_str().chars().count(),
1427 Label::MAX_LEN,
1428 "a constructed Label must never be longer than MAX_LEN"
1429 );
1430 }
1431
1432 #[test]
1433 fn a_host_label_is_narrower_than_a_label() {
1434 assert_eq!(HostLabel::new("Home-Win").unwrap().as_str(), "home-win");
1435 assert!(matches!(
1436 HostLabel::new("home win"),
1437 Err(ValidationError::IllegalCharacter { found: ' ', .. })
1438 ));
1439 assert!(matches!(
1440 HostLabel::new("-home"),
1441 Err(ValidationError::IllegalEdge { edge: '-', .. })
1442 ));
1443 assert!(matches!(
1444 HostLabel::new("home-"),
1445 Err(ValidationError::IllegalEdge { edge: '-', .. })
1446 ));
1447 assert!(matches!(
1448 HostLabel::new(""),
1449 Err(ValidationError::Empty { .. })
1450 ));
1451 assert!(HostLabel::new("home_win2").is_ok());
1452 }
1453
1454 #[test]
1457 fn non_empty_rejects_an_empty_vec_and_reports_count_as_non_zero() {
1458 assert!(matches!(
1459 NonEmpty::<Label>::try_from_vec(Vec::new(), "labels"),
1460 Err(ValidationError::NonEmptyRequired { what: "labels" })
1461 ));
1462
1463 let one = NonEmpty::of(Label::new("a").unwrap());
1464 assert_eq!(one.count().get(), 1);
1465 assert_eq!(one.first().as_str(), "a");
1466
1467 let mut two = one;
1468 two.push(Label::new("b").unwrap());
1469 assert_eq!(two.count().get(), 2);
1470 assert!(two.contains(&Label::new("B").unwrap()));
1471 }
1472
1473 #[test]
1476 fn the_idle_interval_defaults_to_ten_seconds_with_a_five_second_floor() {
1477 assert_eq!(IdlePollInterval::default().as_secs(), 10);
1478 assert_eq!(
1479 IdlePollInterval::from_secs(5).unwrap().as_duration(),
1480 std::time::Duration::from_secs(5)
1481 );
1482 assert!(matches!(
1483 IdlePollInterval::from_secs(4),
1484 Err(ValidationError::BelowFloor {
1485 min: 5,
1486 actual: 4,
1487 ..
1488 })
1489 ));
1490 assert!(serde_json::from_str::<IdlePollInterval>("4").is_err());
1491 }
1492
1493 #[test]
1494 fn the_refresh_interval_floor_is_unrepresentable_rather_than_validated() {
1495 assert_eq!(RefreshInterval::default().as_secs(), 60);
1498 assert_eq!(RefreshInterval::from_secs(30).unwrap().as_secs(), 30);
1499 assert!(matches!(
1500 RefreshInterval::from_secs(29),
1501 Err(ValidationError::BelowFloor {
1502 min: 30,
1503 actual: 29,
1504 ..
1505 })
1506 ));
1507 assert!(matches!(
1508 RefreshInterval::from_secs(0),
1509 Err(ValidationError::BelowFloor { .. })
1510 ));
1511 assert!(serde_json::from_str::<RefreshInterval>("29").is_err());
1514 assert_eq!(
1515 serde_json::from_str::<RefreshInterval>("45")
1516 .unwrap()
1517 .as_secs(),
1518 45
1519 );
1520 }
1521
1522 #[test]
1525 fn job_workspace_retention_has_no_representation_in_v1() {
1526 for policy in [
1528 CachePolicy::RetainRunnerPackage,
1529 CachePolicy::DiscardRunnerPackage,
1530 ] {
1531 assert!(
1532 !policy.retains_job_workspace(),
1533 "no CachePolicy value may retain a job workspace in v1; a \
1534 retained workspace is the two-job contamination path"
1535 );
1536 }
1537 assert!(CachePolicy::default().retains_runner_package());
1538 }
1539
1540 #[test]
1543 fn owner_repo_parsing_accepts_one_separator_and_nothing_else() {
1544 let ok = OwnerRepo::parse("IvanMurzak/GitHub-Runner-Scaler-UI").unwrap();
1545 assert_eq!(ok.owner(), "IvanMurzak");
1546 assert_eq!(ok.repo(), "GitHub-Runner-Scaler-UI");
1547 assert_eq!(ok.to_string(), "IvanMurzak/GitHub-Runner-Scaler-UI");
1548
1549 for bad in ["owner", "owner/repo/extra", "/repo", "owner/"] {
1550 assert!(
1551 OwnerRepo::parse(bad).is_err(),
1552 "{bad:?} must not parse as a repository target"
1553 );
1554 }
1555 }
1556
1557 #[test]
1558 fn github_names_compare_without_regard_to_case_but_display_as_typed() {
1559 use std::collections::HashSet;
1560
1561 let typed = OwnerRepo::parse("IvanMurzak/Repo").unwrap();
1562 let other = OwnerRepo::parse("ivanmurzak/repo").unwrap();
1563 assert_eq!(
1564 typed, other,
1565 "GitHub resolves these to one repository, so `f2`'s duplicate check \
1566 must too"
1567 );
1568 assert_eq!(
1569 typed.to_string(),
1570 "IvanMurzak/Repo",
1571 "the operator's spelling survives for display"
1572 );
1573
1574 let mut seen = HashSet::new();
1575 seen.insert(typed.clone());
1576 assert!(
1577 !seen.insert(other),
1578 "Hash must agree with Eq, or a HashSet-based duplicate check leaks"
1579 );
1580
1581 assert_eq!(
1582 Org::new("Tap-Top-Fun").unwrap(),
1583 Org::new("tap-top-fun").unwrap()
1584 );
1585 }
1586
1587 #[test]
1588 fn a_scale_target_exposes_its_scope_and_nothing_endpoint_shaped() {
1589 let repo = ScaleTarget::repository("o/r").unwrap();
1590 let org = ScaleTarget::organization("o").unwrap();
1591 assert_eq!(repo.scope(), TargetScope::Repository);
1592 assert_eq!(org.scope(), TargetScope::Organization);
1593 assert_eq!(repo.slug(), "o/r");
1594 assert_eq!(org.slug(), "o");
1595 }
1596
1597 #[test]
1598 fn a_scale_target_round_trips_through_serde_at_both_scopes() {
1599 for target in [
1600 ScaleTarget::repository("owner/repo").unwrap(),
1601 ScaleTarget::organization("org").unwrap(),
1602 ] {
1603 let json = serde_json::to_string(&target).unwrap();
1604 let back: ScaleTarget = serde_json::from_str(&json).unwrap();
1605 assert_eq!(target, back, "{json} did not round-trip");
1606 }
1607 }
1608
1609 #[test]
1612 fn os_and_arch_tokens_are_the_ones_the_worked_example_fixes() {
1613 assert_eq!(Os::Windows.label_token(), "win");
1616 assert_eq!(Arch::X64.label_token(), "x64");
1617 assert_eq!(Os::MacOs.label_token(), "osx");
1618 assert_eq!(Os::Linux.label_token(), "linux");
1619 assert_eq!(Arch::Arm64.label_token(), "arm64");
1620 assert_eq!(Arch::Arm32.label_token(), "arm");
1621
1622 for os in Os::ALL {
1623 assert_eq!(os.label_token().parse::<Os>().unwrap(), os);
1624 }
1625 for arch in Arch::ALL {
1626 assert_eq!(arch.label_token().parse::<Arch>().unwrap(), arch);
1627 }
1628 assert!("plan9".parse::<Os>().is_err());
1629 assert!("riscv".parse::<Arch>().is_err());
1630 }
1631
1632 #[test]
1633 fn only_linux_supports_container_actions_and_only_arm64_is_preview() {
1634 assert!(Os::Linux.supports_container_actions());
1636 assert!(!Os::Windows.supports_container_actions());
1637 assert!(!Os::MacOs.supports_container_actions());
1638
1639 assert!(Arch::Arm64.is_public_preview());
1640 assert!(!Arch::X64.is_public_preview());
1641 }
1642
1643 #[test]
1646 fn a_host_cannot_be_built_with_zero_capacity_or_a_blank_name() {
1647 assert!(
1648 NonZeroU16::new(0).is_none(),
1649 "zero host capacity is unrepresentable"
1650 );
1651 assert!(matches!(
1652 Host::new(
1653 HostId::from_u128(1),
1654 " ",
1655 Os::Windows,
1656 Arch::X64,
1657 NonZeroU16::new(2).unwrap(),
1658 ts(0),
1659 ),
1660 Err(ValidationError::Empty { .. })
1661 ));
1662
1663 let host = Host::new(
1664 HostId::from_u128(1),
1665 " home-pc ",
1666 Os::Windows,
1667 Arch::X64,
1668 NonZeroU16::new(2).unwrap(),
1669 ts(0),
1670 )
1671 .unwrap();
1672 assert_eq!(host.display_name, "home-pc");
1673 assert_eq!(host.host_capacity(), 2);
1674 assert_eq!(host.service_start_mode, StartMode::Boot);
1675 assert_eq!(host.refresh_interval.as_secs(), 60);
1676 }
1677
1678 fn a_host() -> Host {
1681 Host::new(
1682 HostId::from_u128(1),
1683 "home-pc",
1684 Os::Windows,
1685 Arch::X64,
1686 NonZeroU16::new(2).unwrap(),
1687 ts(0),
1688 )
1689 .expect("a valid host")
1690 }
1691
1692 #[test]
1693 fn a_new_host_uses_the_platform_default_runner_root() {
1694 let host = a_host();
1698 assert_eq!(host.runner_root_override, None);
1699 assert!(!host.has_configured_runner_root());
1700 }
1701
1702 #[test]
1703 fn a_configured_runner_root_round_trips_through_serde() {
1704 let root = LocalAbsolutePath::new(if cfg!(windows) {
1705 "C:\\rman"
1706 } else {
1707 "/srv/rman"
1708 })
1709 .expect("a valid native root");
1710 let mut host = a_host();
1711 host.runner_root_override = Some(root.clone());
1712 assert!(host.has_configured_runner_root());
1713
1714 let encoded = serde_json::to_string(&host).expect("serialisable");
1715 let decoded: Host = serde_json::from_str(&encoded).expect("deserialisable");
1716 assert_eq!(decoded, host);
1717 assert_eq!(decoded.runner_root_override, Some(root));
1718 for needle in ["token", "secret", "password"] {
1721 assert!(
1722 !encoded.to_ascii_lowercase().contains(needle),
1723 "the host record leaked {needle:?}: {encoded}"
1724 );
1725 }
1726 }
1727
1728 #[test]
1729 fn a_host_row_carrying_an_illegal_runner_root_fails_closed() {
1730 let host = a_host();
1734 let encoded = serde_json::to_string(&host).expect("serialisable");
1735 let corrupted = encoded.replace(
1736 "\"runner_root_override\":null",
1737 "\"runner_root_override\":\"rman\"",
1738 );
1739 assert_ne!(corrupted, encoded, "the fixture must actually be corrupted");
1740 assert!(serde_json::from_str::<Host>(&corrupted).is_err());
1741 }
1742
1743 #[test]
1744 fn a_fake_clock_is_a_complete_substitute_for_the_system_clock() {
1745 #[derive(Debug)]
1748 struct Fixed(Timestamp);
1749 impl Clock for Fixed {
1750 fn now(&self) -> Timestamp {
1751 self.0
1752 }
1753 }
1754 let clock: &dyn Clock = &Fixed(ts(1_700_000_000));
1755 assert_eq!(clock.now(), ts(1_700_000_000));
1756 }
1757}