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)]
675pub struct NonEmpty<T> {
676 items: Vec<T>,
677}
678
679impl<T> NonEmpty<T> {
680 #[must_use]
681 pub fn of(first: T) -> Self {
682 Self { items: vec![first] }
683 }
684
685 pub fn try_from_vec(items: Vec<T>, what: &'static str) -> Result<Self, ValidationError> {
688 if items.is_empty() {
689 return Err(ValidationError::NonEmptyRequired { what });
690 }
691 Ok(Self { items })
692 }
693
694 #[must_use]
695 pub fn first(&self) -> &T {
696 &self.items[0]
698 }
699
700 #[must_use]
703 pub fn count(&self) -> NonZeroUsize {
704 NonZeroUsize::new(self.items.len()).expect("NonEmpty is never empty")
705 }
706
707 pub fn iter(&self) -> std::slice::Iter<'_, T> {
708 self.items.iter()
709 }
710
711 #[must_use]
712 pub fn as_slice(&self) -> &[T] {
713 &self.items
714 }
715
716 pub fn push(&mut self, item: T) {
717 self.items.push(item);
718 }
719
720 #[must_use]
721 pub fn into_vec(self) -> Vec<T> {
722 self.items
723 }
724
725 #[must_use]
726 pub fn contains(&self, needle: &T) -> bool
727 where
728 T: PartialEq,
729 {
730 self.items.contains(needle)
731 }
732}
733
734impl<'a, T> IntoIterator for &'a NonEmpty<T> {
735 type Item = &'a T;
736 type IntoIter = std::slice::Iter<'a, T>;
737
738 fn into_iter(self) -> Self::IntoIter {
739 self.items.iter()
740 }
741}
742
743impl<T> IntoIterator for NonEmpty<T> {
744 type Item = T;
745 type IntoIter = std::vec::IntoIter<T>;
746
747 fn into_iter(self) -> Self::IntoIter {
748 self.items.into_iter()
749 }
750}
751
752#[derive(Debug, Clone)]
763struct Name(String);
764
765impl Name {
766 fn as_str(&self) -> &str {
767 &self.0
768 }
769}
770
771impl PartialEq for Name {
772 fn eq(&self, other: &Self) -> bool {
773 self.0.eq_ignore_ascii_case(&other.0)
774 }
775}
776
777impl Eq for Name {}
778
779impl Ord for Name {
780 fn cmp(&self, other: &Self) -> Ordering {
781 self.0
782 .bytes()
783 .map(|b| b.to_ascii_lowercase())
784 .cmp(other.0.bytes().map(|b| b.to_ascii_lowercase()))
785 }
786}
787
788impl PartialOrd for Name {
789 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
790 Some(self.cmp(other))
791 }
792}
793
794impl Hash for Name {
795 fn hash<H: Hasher>(&self, state: &mut H) {
796 for byte in self.0.bytes() {
797 state.write_u8(byte.to_ascii_lowercase());
798 }
799 state.write_u8(0xff);
800 }
801}
802
803impl fmt::Display for Name {
804 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
805 f.write_str(&self.0)
806 }
807}
808
809fn validate_login(raw: &str, what: &'static str) -> Result<Name, ValidationError> {
810 let trimmed = raw.trim();
811 if trimmed.is_empty() {
812 return Err(ValidationError::Empty { what });
813 }
814 if trimmed.len() > 39 {
815 return Err(ValidationError::TooLong {
816 what,
817 max: 39,
818 actual: trimmed.len(),
819 });
820 }
821 if let Some(bad) = trimmed
822 .chars()
823 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
824 {
825 return Err(ValidationError::IllegalCharacter { what, found: bad });
826 }
827 if trimmed.starts_with('-') || trimmed.ends_with('-') {
828 return Err(ValidationError::IllegalEdge { what, edge: '-' });
829 }
830 Ok(Name(trimmed.to_string()))
831}
832
833fn validate_repo_name(raw: &str) -> Result<Name, ValidationError> {
834 const WHAT: &str = "a repository name";
835 let trimmed = raw.trim();
836 if trimmed.is_empty() {
837 return Err(ValidationError::Empty { what: WHAT });
838 }
839 if trimmed.len() > 100 {
840 return Err(ValidationError::TooLong {
841 what: WHAT,
842 max: 100,
843 actual: trimmed.len(),
844 });
845 }
846 if trimmed == "." || trimmed == ".." {
847 return Err(ValidationError::Unrecognised {
848 what: WHAT,
849 got: trimmed.to_string(),
850 });
851 }
852 if let Some(bad) = trimmed
853 .chars()
854 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
855 {
856 return Err(ValidationError::IllegalCharacter {
857 what: WHAT,
858 found: bad,
859 });
860 }
861 Ok(Name(trimmed.to_string()))
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
866#[serde(try_from = "String", into = "String")]
867pub struct OwnerRepo {
868 owner: Name,
869 repo: Name,
870}
871
872impl OwnerRepo {
873 pub fn new(owner: impl AsRef<str>, repo: impl AsRef<str>) -> Result<Self, ValidationError> {
876 Ok(Self {
877 owner: validate_login(owner.as_ref(), "a repository owner")?,
878 repo: validate_repo_name(repo.as_ref())?,
879 })
880 }
881
882 pub fn parse(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
885 let raw = raw.as_ref().trim();
886 let slashes = raw.matches('/').count();
887 if slashes != 1 {
888 return Err(ValidationError::MalformedOwnerRepo {
889 got: raw.to_string(),
890 slashes,
891 });
892 }
893 let (owner, repo) = raw.split_once('/').expect("exactly one separator");
894 Self::new(owner, repo)
895 }
896
897 #[must_use]
898 pub fn owner(&self) -> &str {
899 self.owner.as_str()
900 }
901
902 #[must_use]
903 pub fn repo(&self) -> &str {
904 self.repo.as_str()
905 }
906}
907
908impl fmt::Display for OwnerRepo {
909 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
910 write!(f, "{}/{}", self.owner, self.repo)
911 }
912}
913
914impl TryFrom<String> for OwnerRepo {
915 type Error = ValidationError;
916
917 fn try_from(value: String) -> Result<Self, Self::Error> {
918 Self::parse(value)
919 }
920}
921
922impl From<OwnerRepo> for String {
923 fn from(value: OwnerRepo) -> Self {
924 value.to_string()
925 }
926}
927
928impl FromStr for OwnerRepo {
929 type Err = ValidationError;
930
931 fn from_str(s: &str) -> Result<Self, Self::Err> {
932 Self::parse(s)
933 }
934}
935
936#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
938#[serde(try_from = "String", into = "String")]
939pub struct Org(Name);
940
941impl Org {
942 pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
945 Ok(Self(validate_login(raw.as_ref(), "an organization login")?))
946 }
947
948 #[must_use]
949 pub fn as_str(&self) -> &str {
950 self.0.as_str()
951 }
952}
953
954impl fmt::Display for Org {
955 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956 fmt::Display::fmt(&self.0, f)
957 }
958}
959
960impl TryFrom<String> for Org {
961 type Error = ValidationError;
962
963 fn try_from(value: String) -> Result<Self, Self::Error> {
964 Self::new(value)
965 }
966}
967
968impl From<Org> for String {
969 fn from(value: Org) -> Self {
970 value.to_string()
971 }
972}
973
974impl FromStr for Org {
975 type Err = ValidationError;
976
977 fn from_str(s: &str) -> Result<Self, Self::Err> {
978 Self::new(s)
979 }
980}
981
982#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
984#[serde(rename_all = "snake_case")]
985pub enum TargetScope {
986 Repository,
987 Organization,
988}
989
990#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1003#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
1004pub enum ScaleTarget {
1005 Repository(OwnerRepo),
1006 Organization(Org),
1007}
1008
1009impl ScaleTarget {
1010 pub fn repository(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1013 Ok(Self::Repository(OwnerRepo::parse(raw)?))
1014 }
1015
1016 pub fn organization(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1019 Ok(Self::Organization(Org::new(raw)?))
1020 }
1021
1022 #[must_use]
1023 pub const fn scope(&self) -> TargetScope {
1024 match self {
1025 ScaleTarget::Repository(_) => TargetScope::Repository,
1026 ScaleTarget::Organization(_) => TargetScope::Organization,
1027 }
1028 }
1029
1030 #[must_use]
1032 pub fn slug(&self) -> String {
1033 match self {
1034 ScaleTarget::Repository(r) => r.to_string(),
1035 ScaleTarget::Organization(o) => o.to_string(),
1036 }
1037 }
1038}
1039
1040impl fmt::Display for ScaleTarget {
1041 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1042 f.write_str(&self.slug())
1043 }
1044}
1045
1046#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1058pub struct Host {
1059 pub id: HostId,
1060 pub display_name: String,
1061 pub os: Os,
1062 pub architecture: Arch,
1063 pub host_capacity: NonZeroU16,
1064 pub service_start_mode: StartMode,
1065 pub refresh_interval: RefreshInterval,
1066 pub runner_root_override: Option<LocalAbsolutePath>,
1080 pub created_at: Timestamp,
1081}
1082
1083impl Host {
1084 pub fn new(
1087 id: HostId,
1088 display_name: impl AsRef<str>,
1089 os: Os,
1090 architecture: Arch,
1091 host_capacity: NonZeroU16,
1092 created_at: Timestamp,
1093 ) -> Result<Self, ValidationError> {
1094 let display_name = display_name.as_ref().trim();
1095 if display_name.is_empty() {
1096 return Err(ValidationError::Empty {
1097 what: "a host display name",
1098 });
1099 }
1100 Ok(Self {
1101 id,
1102 display_name: display_name.to_string(),
1103 os,
1104 architecture,
1105 host_capacity,
1106 service_start_mode: StartMode::default(),
1107 refresh_interval: RefreshInterval::default(),
1108 runner_root_override: None,
1112 created_at,
1113 })
1114 }
1115
1116 #[must_use]
1117 pub fn host_capacity(&self) -> u16 {
1118 self.host_capacity.get()
1119 }
1120
1121 #[must_use]
1127 pub const fn has_configured_runner_root(&self) -> bool {
1128 self.runner_root_override.is_some()
1129 }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134 use super::*;
1135
1136 fn ts(secs: i64) -> Timestamp {
1137 chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
1138 }
1139
1140 #[test]
1143 fn a_label_is_folded_to_lower_case_because_github_stores_it_that_way() {
1144 assert_eq!(Label::new("Windows").unwrap().as_str(), "windows");
1147 assert_eq!(Label::new("X64").unwrap().as_str(), "x64");
1148 assert_eq!(
1149 Label::new(" RM-Home-Win-X64 ").unwrap().as_str(),
1150 "rm-home-win-x64"
1151 );
1152 assert_eq!(
1153 Label::new("Windows").unwrap(),
1154 Label::new("windows").unwrap()
1155 );
1156 }
1157
1158 #[test]
1159 fn label_case_folding_reaches_eq_ord_and_hash_together() {
1160 use std::collections::BTreeSet;
1161 let mut set = BTreeSet::new();
1162 set.insert(Label::new("Windows").unwrap());
1163 set.insert(Label::new("windows").unwrap());
1164 set.insert(Label::new("WINDOWS").unwrap());
1165 assert_eq!(
1166 set.len(),
1167 1,
1168 "three spellings of one GitHub label must collapse to one member, \
1169 or a routing-label set can silently hold duplicates"
1170 );
1171 }
1172
1173 #[test]
1174 fn an_unusable_label_cannot_be_constructed() {
1175 assert!(matches!(Label::new(""), Err(ValidationError::Empty { .. })));
1176 assert!(matches!(
1177 Label::new(" "),
1178 Err(ValidationError::Empty { .. })
1179 ));
1180 assert!(
1181 matches!(
1182 Label::new("a,b"),
1183 Err(ValidationError::IllegalCharacter { found: ',', .. })
1184 ),
1185 "a comma separates labels in the runner's own configuration, so a \
1186 label containing one does not round-trip"
1187 );
1188 assert!(matches!(
1189 Label::new("a\nb"),
1190 Err(ValidationError::IllegalCharacter { .. })
1191 ));
1192 assert!(matches!(
1193 Label::new("x".repeat(Label::MAX_LEN + 1)),
1194 Err(ValidationError::TooLong { .. })
1195 ));
1196 assert!(Label::new("x".repeat(Label::MAX_LEN)).is_ok());
1197 }
1198
1199 #[test]
1200 fn the_label_character_rules_are_about_round_tripping_not_injection() {
1201 assert_eq!(Label::new(r#"say"hi"#).unwrap().as_str(), r#"say"hi"#);
1207 assert_eq!(Label::new("it's").unwrap().as_str(), "it's");
1208
1209 for raw in ["a<b", "a>b", "a$b", "a`b", "a;b", "a|b", "a&b", "a b"] {
1213 assert!(
1214 Label::new(raw).is_ok(),
1215 "{raw:?} must construct: the rule set is round-trippability, not \
1216 shell safety"
1217 );
1218 }
1219
1220 for raw in ["c#", ".net", "x86_64", "ubuntu-22.04"] {
1222 assert!(Label::new(raw).is_ok(), "{raw:?} is a real GitHub label");
1223 }
1224 }
1225
1226 #[test]
1227 fn label_folding_is_ascii_and_the_length_is_measured_after_folding() {
1228 assert_eq!(
1231 Label::new("RM-Home-Win-X64").unwrap().as_str(),
1232 "rm-home-win-x64"
1233 );
1234
1235 let mut raw = "x".repeat(Label::MAX_LEN - 1);
1240 raw.push('\u{0130}');
1241 let label = Label::new(&raw).expect("exactly MAX_LEN characters");
1242 assert_eq!(
1243 label.as_str().chars().count(),
1244 Label::MAX_LEN,
1245 "a constructed Label must never be longer than MAX_LEN"
1246 );
1247 }
1248
1249 #[test]
1250 fn a_host_label_is_narrower_than_a_label() {
1251 assert_eq!(HostLabel::new("Home-Win").unwrap().as_str(), "home-win");
1252 assert!(matches!(
1253 HostLabel::new("home win"),
1254 Err(ValidationError::IllegalCharacter { found: ' ', .. })
1255 ));
1256 assert!(matches!(
1257 HostLabel::new("-home"),
1258 Err(ValidationError::IllegalEdge { edge: '-', .. })
1259 ));
1260 assert!(matches!(
1261 HostLabel::new("home-"),
1262 Err(ValidationError::IllegalEdge { edge: '-', .. })
1263 ));
1264 assert!(matches!(
1265 HostLabel::new(""),
1266 Err(ValidationError::Empty { .. })
1267 ));
1268 assert!(HostLabel::new("home_win2").is_ok());
1269 }
1270
1271 #[test]
1274 fn non_empty_rejects_an_empty_vec_and_reports_count_as_non_zero() {
1275 assert!(matches!(
1276 NonEmpty::<Label>::try_from_vec(Vec::new(), "labels"),
1277 Err(ValidationError::NonEmptyRequired { what: "labels" })
1278 ));
1279
1280 let one = NonEmpty::of(Label::new("a").unwrap());
1281 assert_eq!(one.count().get(), 1);
1282 assert_eq!(one.first().as_str(), "a");
1283
1284 let mut two = one;
1285 two.push(Label::new("b").unwrap());
1286 assert_eq!(two.count().get(), 2);
1287 assert!(two.contains(&Label::new("B").unwrap()));
1288 }
1289
1290 #[test]
1293 fn the_refresh_interval_floor_is_unrepresentable_rather_than_validated() {
1294 assert_eq!(RefreshInterval::default().as_secs(), 60);
1297 assert_eq!(RefreshInterval::from_secs(30).unwrap().as_secs(), 30);
1298 assert!(matches!(
1299 RefreshInterval::from_secs(29),
1300 Err(ValidationError::BelowFloor {
1301 min: 30,
1302 actual: 29,
1303 ..
1304 })
1305 ));
1306 assert!(matches!(
1307 RefreshInterval::from_secs(0),
1308 Err(ValidationError::BelowFloor { .. })
1309 ));
1310 assert!(serde_json::from_str::<RefreshInterval>("29").is_err());
1313 assert_eq!(
1314 serde_json::from_str::<RefreshInterval>("45")
1315 .unwrap()
1316 .as_secs(),
1317 45
1318 );
1319 }
1320
1321 #[test]
1324 fn job_workspace_retention_has_no_representation_in_v1() {
1325 for policy in [
1327 CachePolicy::RetainRunnerPackage,
1328 CachePolicy::DiscardRunnerPackage,
1329 ] {
1330 assert!(
1331 !policy.retains_job_workspace(),
1332 "no CachePolicy value may retain a job workspace in v1; a \
1333 retained workspace is the two-job contamination path"
1334 );
1335 }
1336 assert!(CachePolicy::default().retains_runner_package());
1337 }
1338
1339 #[test]
1342 fn owner_repo_parsing_accepts_one_separator_and_nothing_else() {
1343 let ok = OwnerRepo::parse("IvanMurzak/GitHub-Runner-Scaler-UI").unwrap();
1344 assert_eq!(ok.owner(), "IvanMurzak");
1345 assert_eq!(ok.repo(), "GitHub-Runner-Scaler-UI");
1346 assert_eq!(ok.to_string(), "IvanMurzak/GitHub-Runner-Scaler-UI");
1347
1348 for bad in ["owner", "owner/repo/extra", "/repo", "owner/"] {
1349 assert!(
1350 OwnerRepo::parse(bad).is_err(),
1351 "{bad:?} must not parse as a repository target"
1352 );
1353 }
1354 }
1355
1356 #[test]
1357 fn github_names_compare_without_regard_to_case_but_display_as_typed() {
1358 use std::collections::HashSet;
1359
1360 let typed = OwnerRepo::parse("IvanMurzak/Repo").unwrap();
1361 let other = OwnerRepo::parse("ivanmurzak/repo").unwrap();
1362 assert_eq!(
1363 typed, other,
1364 "GitHub resolves these to one repository, so `f2`'s duplicate check \
1365 must too"
1366 );
1367 assert_eq!(
1368 typed.to_string(),
1369 "IvanMurzak/Repo",
1370 "the operator's spelling survives for display"
1371 );
1372
1373 let mut seen = HashSet::new();
1374 seen.insert(typed.clone());
1375 assert!(
1376 !seen.insert(other),
1377 "Hash must agree with Eq, or a HashSet-based duplicate check leaks"
1378 );
1379
1380 assert_eq!(
1381 Org::new("Tap-Top-Fun").unwrap(),
1382 Org::new("tap-top-fun").unwrap()
1383 );
1384 }
1385
1386 #[test]
1387 fn a_scale_target_exposes_its_scope_and_nothing_endpoint_shaped() {
1388 let repo = ScaleTarget::repository("o/r").unwrap();
1389 let org = ScaleTarget::organization("o").unwrap();
1390 assert_eq!(repo.scope(), TargetScope::Repository);
1391 assert_eq!(org.scope(), TargetScope::Organization);
1392 assert_eq!(repo.slug(), "o/r");
1393 assert_eq!(org.slug(), "o");
1394 }
1395
1396 #[test]
1397 fn a_scale_target_round_trips_through_serde_at_both_scopes() {
1398 for target in [
1399 ScaleTarget::repository("owner/repo").unwrap(),
1400 ScaleTarget::organization("org").unwrap(),
1401 ] {
1402 let json = serde_json::to_string(&target).unwrap();
1403 let back: ScaleTarget = serde_json::from_str(&json).unwrap();
1404 assert_eq!(target, back, "{json} did not round-trip");
1405 }
1406 }
1407
1408 #[test]
1411 fn os_and_arch_tokens_are_the_ones_the_worked_example_fixes() {
1412 assert_eq!(Os::Windows.label_token(), "win");
1415 assert_eq!(Arch::X64.label_token(), "x64");
1416 assert_eq!(Os::MacOs.label_token(), "osx");
1417 assert_eq!(Os::Linux.label_token(), "linux");
1418 assert_eq!(Arch::Arm64.label_token(), "arm64");
1419 assert_eq!(Arch::Arm32.label_token(), "arm");
1420
1421 for os in Os::ALL {
1422 assert_eq!(os.label_token().parse::<Os>().unwrap(), os);
1423 }
1424 for arch in Arch::ALL {
1425 assert_eq!(arch.label_token().parse::<Arch>().unwrap(), arch);
1426 }
1427 assert!("plan9".parse::<Os>().is_err());
1428 assert!("riscv".parse::<Arch>().is_err());
1429 }
1430
1431 #[test]
1432 fn only_linux_supports_container_actions_and_only_arm64_is_preview() {
1433 assert!(Os::Linux.supports_container_actions());
1435 assert!(!Os::Windows.supports_container_actions());
1436 assert!(!Os::MacOs.supports_container_actions());
1437
1438 assert!(Arch::Arm64.is_public_preview());
1439 assert!(!Arch::X64.is_public_preview());
1440 }
1441
1442 #[test]
1445 fn a_host_cannot_be_built_with_zero_capacity_or_a_blank_name() {
1446 assert!(
1447 NonZeroU16::new(0).is_none(),
1448 "zero host capacity is unrepresentable"
1449 );
1450 assert!(matches!(
1451 Host::new(
1452 HostId::from_u128(1),
1453 " ",
1454 Os::Windows,
1455 Arch::X64,
1456 NonZeroU16::new(2).unwrap(),
1457 ts(0),
1458 ),
1459 Err(ValidationError::Empty { .. })
1460 ));
1461
1462 let host = Host::new(
1463 HostId::from_u128(1),
1464 " home-pc ",
1465 Os::Windows,
1466 Arch::X64,
1467 NonZeroU16::new(2).unwrap(),
1468 ts(0),
1469 )
1470 .unwrap();
1471 assert_eq!(host.display_name, "home-pc");
1472 assert_eq!(host.host_capacity(), 2);
1473 assert_eq!(host.service_start_mode, StartMode::Boot);
1474 assert_eq!(host.refresh_interval.as_secs(), 60);
1475 }
1476
1477 fn a_host() -> Host {
1480 Host::new(
1481 HostId::from_u128(1),
1482 "home-pc",
1483 Os::Windows,
1484 Arch::X64,
1485 NonZeroU16::new(2).unwrap(),
1486 ts(0),
1487 )
1488 .expect("a valid host")
1489 }
1490
1491 #[test]
1492 fn a_new_host_uses_the_platform_default_runner_root() {
1493 let host = a_host();
1497 assert_eq!(host.runner_root_override, None);
1498 assert!(!host.has_configured_runner_root());
1499 }
1500
1501 #[test]
1502 fn a_configured_runner_root_round_trips_through_serde() {
1503 let root = LocalAbsolutePath::new(if cfg!(windows) {
1504 "C:\\rman"
1505 } else {
1506 "/srv/rman"
1507 })
1508 .expect("a valid native root");
1509 let mut host = a_host();
1510 host.runner_root_override = Some(root.clone());
1511 assert!(host.has_configured_runner_root());
1512
1513 let encoded = serde_json::to_string(&host).expect("serialisable");
1514 let decoded: Host = serde_json::from_str(&encoded).expect("deserialisable");
1515 assert_eq!(decoded, host);
1516 assert_eq!(decoded.runner_root_override, Some(root));
1517 for needle in ["token", "secret", "password"] {
1520 assert!(
1521 !encoded.to_ascii_lowercase().contains(needle),
1522 "the host record leaked {needle:?}: {encoded}"
1523 );
1524 }
1525 }
1526
1527 #[test]
1528 fn a_host_row_carrying_an_illegal_runner_root_fails_closed() {
1529 let host = a_host();
1533 let encoded = serde_json::to_string(&host).expect("serialisable");
1534 let corrupted = encoded.replace(
1535 "\"runner_root_override\":null",
1536 "\"runner_root_override\":\"rman\"",
1537 );
1538 assert_ne!(corrupted, encoded, "the fixture must actually be corrupted");
1539 assert!(serde_json::from_str::<Host>(&corrupted).is_err());
1540 }
1541
1542 #[test]
1543 fn a_fake_clock_is_a_complete_substitute_for_the_system_clock() {
1544 #[derive(Debug)]
1547 struct Fixed(Timestamp);
1548 impl Clock for Fixed {
1549 fn now(&self) -> Timestamp {
1550 self.0
1551 }
1552 }
1553 let clock: &dyn Clock = &Fixed(ts(1_700_000_000));
1554 assert_eq!(clock.now(), ts(1_700_000_000));
1555 }
1556}