1use std::fmt;
41use std::num::NonZeroU16;
42use std::str::FromStr;
43
44use serde::{Deserialize, Serialize};
45
46use crate::model::{TargetScope, ValidationError};
47use crate::path::{LocalAbsolutePath, LocalPathError};
48
49#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
63pub enum WorkspaceError {
64 #[error(transparent)]
65 InvalidPath(#[from] LocalPathError),
66
67 #[error(
68 "a persistent workspace is repository-scoped only; an organization runner \
69 can accept jobs from more than one repository, so a retained workspace \
70 would cross a repository boundary (D7)"
71 )]
72 PersistentRequiresRepositoryScope,
73
74 #[error("a persistent workspace policy requires a configured root path")]
75 PersistentWithoutRoot,
76
77 #[error(
78 "an ephemeral workspace policy has no root path; attempts are placed \
79 under the host runner root ({root})"
80 )]
81 EphemeralWithRoot { root: String },
82
83 #[error("a persistent attempt requires the slot it leased")]
84 PersistentWithoutSlot,
85
86 #[error("an ephemeral attempt holds no slot, but slot {slot} was stored")]
87 EphemeralWithSlot { slot: u16 },
88
89 #[error("a persistent slot number must be positive; slots are named s1, s2, and so on")]
90 SlotNotPositive,
91}
92
93#[derive(
105 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
106)]
107#[serde(rename_all = "snake_case")]
108pub enum WorkspaceKind {
109 #[default]
111 Ephemeral,
112 Persistent,
114}
115
116impl WorkspaceKind {
117 #[must_use]
118 pub const fn is_persistent(self) -> bool {
119 matches!(self, WorkspaceKind::Persistent)
120 }
121}
122
123impl fmt::Display for WorkspaceKind {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(match self {
126 WorkspaceKind::Ephemeral => "ephemeral",
127 WorkspaceKind::Persistent => "persistent",
128 })
129 }
130}
131
132impl FromStr for WorkspaceKind {
133 type Err = ValidationError;
134
135 fn from_str(s: &str) -> Result<Self, Self::Err> {
136 match s.trim().to_ascii_lowercase().as_str() {
137 "ephemeral" => Ok(WorkspaceKind::Ephemeral),
138 "persistent" => Ok(WorkspaceKind::Persistent),
139 other => Err(ValidationError::Unrecognised {
140 what: "a workspace mode",
141 got: other.to_string(),
142 }),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
158#[serde(tag = "mode", rename_all = "snake_case")]
159pub enum WorkspacePolicy {
160 #[default]
163 Ephemeral,
164 Persistent { root: LocalAbsolutePath },
167}
168
169impl WorkspacePolicy {
170 pub fn persistent(root: LocalAbsolutePath, scope: TargetScope) -> Result<Self, WorkspaceError> {
184 let policy = WorkspacePolicy::Persistent { root };
185 policy.permitted_for(scope)?;
186 Ok(policy)
187 }
188
189 pub(crate) const fn permitted_for(&self, scope: TargetScope) -> Result<(), WorkspaceError> {
200 match scope {
201 TargetScope::Repository => Ok(()),
202 TargetScope::Organization if self.is_persistent() => {
203 Err(WorkspaceError::PersistentRequiresRepositoryScope)
204 }
205 TargetScope::Organization => Ok(()),
206 }
207 }
208
209 pub fn from_persisted(
217 kind: WorkspaceKind,
218 root: Option<LocalAbsolutePath>,
219 scope: TargetScope,
220 ) -> Result<Self, WorkspaceError> {
221 match (kind, root) {
222 (WorkspaceKind::Ephemeral, None) => Ok(WorkspacePolicy::Ephemeral),
223 (WorkspaceKind::Ephemeral, Some(root)) => {
224 Err(WorkspaceError::EphemeralWithRoot { root: root.into() })
225 }
226 (WorkspaceKind::Persistent, None) => Err(WorkspaceError::PersistentWithoutRoot),
227 (WorkspaceKind::Persistent, Some(root)) => Self::persistent(root, scope),
228 }
229 }
230
231 #[must_use]
232 pub const fn kind(&self) -> WorkspaceKind {
233 match self {
234 WorkspacePolicy::Ephemeral => WorkspaceKind::Ephemeral,
235 WorkspacePolicy::Persistent { .. } => WorkspaceKind::Persistent,
236 }
237 }
238
239 #[must_use]
241 pub const fn root(&self) -> Option<&LocalAbsolutePath> {
242 match self {
243 WorkspacePolicy::Ephemeral => None,
244 WorkspacePolicy::Persistent { root } => Some(root),
245 }
246 }
247
248 #[must_use]
249 pub const fn is_persistent(&self) -> bool {
250 self.kind().is_persistent()
251 }
252
253 #[must_use]
261 pub const fn retains_job_workspace(&self) -> bool {
262 self.is_persistent()
263 }
264}
265
266impl fmt::Display for WorkspacePolicy {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 match self {
269 WorkspacePolicy::Ephemeral => f.write_str("ephemeral"),
270 WorkspacePolicy::Persistent { root } => write!(f, "persistent ({root})"),
271 }
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
292#[serde(tag = "mode", rename_all = "snake_case")]
293pub enum AttemptWorkspace {
294 Ephemeral,
296 PersistentSlot { slot: NonZeroU16 },
298}
299
300impl AttemptWorkspace {
301 #[must_use]
303 pub const fn persistent_slot(slot: NonZeroU16) -> Self {
304 AttemptWorkspace::PersistentSlot { slot }
305 }
306
307 pub fn from_persisted(kind: WorkspaceKind, slot: Option<u16>) -> Result<Self, WorkspaceError> {
318 match (kind, slot) {
319 (WorkspaceKind::Ephemeral, None) => Ok(AttemptWorkspace::Ephemeral),
320 (WorkspaceKind::Ephemeral, Some(slot)) => {
321 Err(WorkspaceError::EphemeralWithSlot { slot })
322 }
323 (WorkspaceKind::Persistent, None) => Err(WorkspaceError::PersistentWithoutSlot),
324 (WorkspaceKind::Persistent, Some(slot)) => NonZeroU16::new(slot)
325 .map(Self::persistent_slot)
326 .ok_or(WorkspaceError::SlotNotPositive),
327 }
328 }
329
330 #[must_use]
331 pub const fn kind(&self) -> WorkspaceKind {
332 match self {
333 AttemptWorkspace::Ephemeral => WorkspaceKind::Ephemeral,
334 AttemptWorkspace::PersistentSlot { .. } => WorkspaceKind::Persistent,
335 }
336 }
337
338 #[must_use]
340 pub const fn slot(&self) -> Option<NonZeroU16> {
341 match self {
342 AttemptWorkspace::Ephemeral => None,
343 AttemptWorkspace::PersistentSlot { slot } => Some(*slot),
344 }
345 }
346
347 #[must_use]
349 pub const fn slot_number(&self) -> Option<u16> {
350 match self {
351 AttemptWorkspace::Ephemeral => None,
352 AttemptWorkspace::PersistentSlot { slot } => Some(slot.get()),
353 }
354 }
355
356 #[must_use]
357 pub const fn is_persistent(&self) -> bool {
358 self.kind().is_persistent()
359 }
360
361 #[must_use]
367 pub fn slot_directory_name(&self) -> Option<String> {
368 self.slot().map(|slot| format!("s{slot}"))
369 }
370}
371
372impl fmt::Display for AttemptWorkspace {
373 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374 match self {
375 AttemptWorkspace::Ephemeral => f.write_str("ephemeral"),
376 AttemptWorkspace::PersistentSlot { slot } => write!(f, "persistent slot s{slot}"),
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 use crate::path::{LocalAbsolutePath, PathPlatform};
386
387 fn root() -> LocalAbsolutePath {
388 LocalAbsolutePath::parse_for("/srv/rman/acme", PathPlatform::Unix).expect("valid root")
389 }
390
391 fn nz(value: u16) -> NonZeroU16 {
392 NonZeroU16::new(value).expect("a positive slot")
393 }
394
395 #[test]
398 fn the_default_workspace_policy_is_ephemeral() {
399 assert_eq!(WorkspacePolicy::default(), WorkspacePolicy::Ephemeral);
401 assert_eq!(WorkspaceKind::default(), WorkspaceKind::Ephemeral);
402 assert!(!WorkspacePolicy::default().is_persistent());
403 assert!(!WorkspacePolicy::default().retains_job_workspace());
404 assert_eq!(WorkspacePolicy::default().root(), None);
405 }
406
407 #[test]
408 fn a_persistent_policy_retains_the_job_workspace() {
409 let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
410 .expect("a repository may be persistent");
411 assert!(policy.is_persistent());
412 assert!(policy.retains_job_workspace());
413 assert_eq!(policy.kind(), WorkspaceKind::Persistent);
414 assert_eq!(policy.root(), Some(&root()));
415 }
416
417 #[test]
420 fn an_organization_policy_cannot_be_constructed_as_persistent() {
421 assert_eq!(
422 WorkspacePolicy::persistent(root(), TargetScope::Organization),
423 Err(WorkspaceError::PersistentRequiresRepositoryScope)
424 );
425 }
426
427 #[test]
428 fn an_organization_policy_cannot_be_restored_as_persistent() {
429 assert_eq!(
430 WorkspacePolicy::from_persisted(
431 WorkspaceKind::Persistent,
432 Some(root()),
433 TargetScope::Organization,
434 ),
435 Err(WorkspaceError::PersistentRequiresRepositoryScope)
436 );
437 assert_eq!(
439 WorkspacePolicy::from_persisted(
440 WorkspaceKind::Ephemeral,
441 None,
442 TargetScope::Organization,
443 ),
444 Ok(WorkspacePolicy::Ephemeral)
445 );
446 }
447
448 #[test]
451 fn a_workspace_policy_round_trips_through_its_columns() {
452 for scope in [TargetScope::Repository, TargetScope::Organization] {
453 let policy = WorkspacePolicy::Ephemeral;
454 assert_eq!(
455 WorkspacePolicy::from_persisted(policy.kind(), policy.root().cloned(), scope),
456 Ok(policy)
457 );
458 }
459
460 let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
461 .expect("a repository may be persistent");
462 assert_eq!(
463 WorkspacePolicy::from_persisted(
464 policy.kind(),
465 policy.root().cloned(),
466 TargetScope::Repository,
467 ),
468 Ok(policy)
469 );
470 }
471
472 #[test]
473 fn mismatched_workspace_policy_columns_fail_closed() {
474 assert_eq!(
475 WorkspacePolicy::from_persisted(
476 WorkspaceKind::Persistent,
477 None,
478 TargetScope::Repository
479 ),
480 Err(WorkspaceError::PersistentWithoutRoot)
481 );
482 assert_eq!(
483 WorkspacePolicy::from_persisted(
484 WorkspaceKind::Ephemeral,
485 Some(root()),
486 TargetScope::Repository,
487 ),
488 Err(WorkspaceError::EphemeralWithRoot {
489 root: "/srv/rman/acme".to_string()
490 })
491 );
492 }
493
494 #[test]
497 fn an_ephemeral_attempt_holds_no_slot() {
498 let workspace = AttemptWorkspace::Ephemeral;
499 assert_eq!(workspace.kind(), WorkspaceKind::Ephemeral);
500 assert_eq!(workspace.slot(), None);
501 assert_eq!(workspace.slot_number(), None);
502 assert_eq!(workspace.slot_directory_name(), None);
503 assert!(!workspace.is_persistent());
504 }
505
506 #[test]
507 fn a_persistent_attempt_names_its_slot_directory() {
508 let workspace = AttemptWorkspace::persistent_slot(nz(1));
509 assert_eq!(workspace.kind(), WorkspaceKind::Persistent);
510 assert_eq!(workspace.slot(), Some(nz(1)));
511 assert_eq!(workspace.slot_number(), Some(1));
512 assert_eq!(workspace.slot_directory_name().as_deref(), Some("s1"));
513 assert!(workspace.is_persistent());
514 assert_eq!(
515 AttemptWorkspace::persistent_slot(nz(12))
516 .slot_directory_name()
517 .as_deref(),
518 Some("s12")
519 );
520 }
521
522 #[test]
523 fn the_slot_directory_name_is_a_single_path_component() {
524 let workspace = AttemptWorkspace::persistent_slot(nz(3));
527 let name = workspace
528 .slot_directory_name()
529 .expect("a persistent attempt names a slot");
530 assert_eq!(
531 root().join_child(name).expect("a valid child").as_str(),
532 "/srv/rman/acme/s3"
533 );
534 }
535
536 #[test]
537 fn an_attempt_workspace_round_trips_through_its_columns() {
538 for workspace in [
539 AttemptWorkspace::Ephemeral,
540 AttemptWorkspace::persistent_slot(nz(1)),
541 AttemptWorkspace::persistent_slot(nz(u16::MAX)),
542 ] {
543 assert_eq!(
544 AttemptWorkspace::from_persisted(workspace.kind(), workspace.slot_number()),
545 Ok(workspace)
546 );
547 }
548 }
549
550 #[test]
551 fn mismatched_attempt_workspace_columns_fail_closed() {
552 assert_eq!(
553 AttemptWorkspace::from_persisted(WorkspaceKind::Persistent, None),
554 Err(WorkspaceError::PersistentWithoutSlot)
555 );
556 assert_eq!(
557 AttemptWorkspace::from_persisted(WorkspaceKind::Ephemeral, Some(1)),
558 Err(WorkspaceError::EphemeralWithSlot { slot: 1 })
559 );
560 assert_eq!(
561 AttemptWorkspace::from_persisted(WorkspaceKind::Ephemeral, Some(0)),
562 Err(WorkspaceError::EphemeralWithSlot { slot: 0 })
563 );
564 assert_eq!(
565 AttemptWorkspace::from_persisted(WorkspaceKind::Persistent, Some(0)),
566 Err(WorkspaceError::SlotNotPositive)
567 );
568 }
569
570 #[test]
573 fn workspace_kind_tokens_are_stable() {
574 for (kind, token) in [
575 (WorkspaceKind::Ephemeral, "ephemeral"),
576 (WorkspaceKind::Persistent, "persistent"),
577 ] {
578 assert_eq!(kind.to_string(), token);
579 assert_eq!(token.parse::<WorkspaceKind>().expect("a known token"), kind);
580 assert_eq!(
581 serde_json::to_value(kind).expect("serialisable"),
582 serde_json::Value::String(token.to_string())
583 );
584 }
585 assert!("durable".parse::<WorkspaceKind>().is_err());
586 assert!(serde_json::from_str::<WorkspaceKind>("\"durable\"").is_err());
587 }
588
589 #[test]
590 fn workspace_values_serialise_without_credentials() {
591 let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
595 .expect("a repository may be persistent");
596 let attempt = AttemptWorkspace::persistent_slot(nz(2));
597
598 let rendered = format!(
599 "{policy}|{attempt}|{:?}|{:?}|{}|{}",
600 policy,
601 attempt,
602 serde_json::to_string(&policy).expect("serialisable"),
603 serde_json::to_string(&attempt).expect("serialisable"),
604 );
605 for needle in ["token", "secret", "jit", "password", "ghs_", "ghp_"] {
606 assert!(
607 !rendered.to_ascii_lowercase().contains(needle),
608 "workspace rendering leaked {needle:?}: {rendered}"
609 );
610 }
611 assert!(rendered.contains("/srv/rman/acme"));
612 assert!(rendered.contains("s2"));
613 }
614
615 #[test]
616 fn workspace_values_round_trip_through_serde() {
617 let attempt = AttemptWorkspace::persistent_slot(nz(7));
618 let encoded = serde_json::to_string(&attempt).expect("serialisable");
619 assert_eq!(
620 serde_json::from_str::<AttemptWorkspace>(&encoded).expect("deserialisable"),
621 attempt
622 );
623 assert_eq!(
624 serde_json::from_str::<AttemptWorkspace>("{\"mode\":\"ephemeral\"}")
625 .expect("deserialisable"),
626 AttemptWorkspace::Ephemeral
627 );
628 assert!(
631 serde_json::from_str::<AttemptWorkspace>("{\"mode\":\"persistent_slot\",\"slot\":0}")
632 .is_err()
633 );
634 }
635}