use std::fmt;
use std::num::NonZeroU16;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::model::{TargetScope, ValidationError};
use crate::path::{LocalAbsolutePath, LocalPathError};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum WorkspaceError {
#[error(transparent)]
InvalidPath(#[from] LocalPathError),
#[error(
"a persistent workspace is repository-scoped only; an organization runner \
can accept jobs from more than one repository, so a retained workspace \
would cross a repository boundary (D7)"
)]
PersistentRequiresRepositoryScope,
#[error("a persistent workspace policy requires a configured root path")]
PersistentWithoutRoot,
#[error(
"an ephemeral workspace policy has no root path; attempts are placed \
under the host runner root ({root})"
)]
EphemeralWithRoot { root: String },
#[error("a persistent attempt requires the slot it leased")]
PersistentWithoutSlot,
#[error("an ephemeral attempt holds no slot, but slot {slot} was stored")]
EphemeralWithSlot { slot: u16 },
#[error("a persistent slot number must be positive; slots are named s1, s2, and so on")]
SlotNotPositive,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceKind {
#[default]
Ephemeral,
Persistent,
}
impl WorkspaceKind {
#[must_use]
pub const fn is_persistent(self) -> bool {
matches!(self, WorkspaceKind::Persistent)
}
}
impl fmt::Display for WorkspaceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
WorkspaceKind::Ephemeral => "ephemeral",
WorkspaceKind::Persistent => "persistent",
})
}
}
impl FromStr for WorkspaceKind {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" => Ok(WorkspaceKind::Ephemeral),
"persistent" => Ok(WorkspaceKind::Persistent),
other => Err(ValidationError::Unrecognised {
what: "a workspace mode",
got: other.to_string(),
}),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum WorkspacePolicy {
#[default]
Ephemeral,
Persistent { root: LocalAbsolutePath },
}
impl WorkspacePolicy {
pub fn persistent(root: LocalAbsolutePath, scope: TargetScope) -> Result<Self, WorkspaceError> {
let policy = WorkspacePolicy::Persistent { root };
policy.permitted_for(scope)?;
Ok(policy)
}
pub(crate) const fn permitted_for(&self, scope: TargetScope) -> Result<(), WorkspaceError> {
match scope {
TargetScope::Repository => Ok(()),
TargetScope::Organization if self.is_persistent() => {
Err(WorkspaceError::PersistentRequiresRepositoryScope)
}
TargetScope::Organization => Ok(()),
}
}
pub fn from_persisted(
kind: WorkspaceKind,
root: Option<LocalAbsolutePath>,
scope: TargetScope,
) -> Result<Self, WorkspaceError> {
match (kind, root) {
(WorkspaceKind::Ephemeral, None) => Ok(WorkspacePolicy::Ephemeral),
(WorkspaceKind::Ephemeral, Some(root)) => {
Err(WorkspaceError::EphemeralWithRoot { root: root.into() })
}
(WorkspaceKind::Persistent, None) => Err(WorkspaceError::PersistentWithoutRoot),
(WorkspaceKind::Persistent, Some(root)) => Self::persistent(root, scope),
}
}
#[must_use]
pub const fn kind(&self) -> WorkspaceKind {
match self {
WorkspacePolicy::Ephemeral => WorkspaceKind::Ephemeral,
WorkspacePolicy::Persistent { .. } => WorkspaceKind::Persistent,
}
}
#[must_use]
pub const fn root(&self) -> Option<&LocalAbsolutePath> {
match self {
WorkspacePolicy::Ephemeral => None,
WorkspacePolicy::Persistent { root } => Some(root),
}
}
#[must_use]
pub const fn is_persistent(&self) -> bool {
self.kind().is_persistent()
}
#[must_use]
pub const fn retains_job_workspace(&self) -> bool {
self.is_persistent()
}
}
impl fmt::Display for WorkspacePolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WorkspacePolicy::Ephemeral => f.write_str("ephemeral"),
WorkspacePolicy::Persistent { root } => write!(f, "persistent ({root})"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum AttemptWorkspace {
Ephemeral,
PersistentSlot { slot: NonZeroU16 },
}
impl AttemptWorkspace {
#[must_use]
pub const fn persistent_slot(slot: NonZeroU16) -> Self {
AttemptWorkspace::PersistentSlot { slot }
}
pub fn from_persisted(kind: WorkspaceKind, slot: Option<u16>) -> Result<Self, WorkspaceError> {
match (kind, slot) {
(WorkspaceKind::Ephemeral, None) => Ok(AttemptWorkspace::Ephemeral),
(WorkspaceKind::Ephemeral, Some(slot)) => {
Err(WorkspaceError::EphemeralWithSlot { slot })
}
(WorkspaceKind::Persistent, None) => Err(WorkspaceError::PersistentWithoutSlot),
(WorkspaceKind::Persistent, Some(slot)) => NonZeroU16::new(slot)
.map(Self::persistent_slot)
.ok_or(WorkspaceError::SlotNotPositive),
}
}
#[must_use]
pub const fn kind(&self) -> WorkspaceKind {
match self {
AttemptWorkspace::Ephemeral => WorkspaceKind::Ephemeral,
AttemptWorkspace::PersistentSlot { .. } => WorkspaceKind::Persistent,
}
}
#[must_use]
pub const fn slot(&self) -> Option<NonZeroU16> {
match self {
AttemptWorkspace::Ephemeral => None,
AttemptWorkspace::PersistentSlot { slot } => Some(*slot),
}
}
#[must_use]
pub const fn slot_number(&self) -> Option<u16> {
match self {
AttemptWorkspace::Ephemeral => None,
AttemptWorkspace::PersistentSlot { slot } => Some(slot.get()),
}
}
#[must_use]
pub const fn is_persistent(&self) -> bool {
self.kind().is_persistent()
}
#[must_use]
pub fn slot_directory_name(&self) -> Option<String> {
self.slot().map(|slot| format!("s{slot}"))
}
}
impl fmt::Display for AttemptWorkspace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AttemptWorkspace::Ephemeral => f.write_str("ephemeral"),
AttemptWorkspace::PersistentSlot { slot } => write!(f, "persistent slot s{slot}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::path::{LocalAbsolutePath, PathPlatform};
fn root() -> LocalAbsolutePath {
LocalAbsolutePath::parse_for("/srv/rman/acme", PathPlatform::Unix).expect("valid root")
}
fn nz(value: u16) -> NonZeroU16 {
NonZeroU16::new(value).expect("a positive slot")
}
#[test]
fn the_default_workspace_policy_is_ephemeral() {
assert_eq!(WorkspacePolicy::default(), WorkspacePolicy::Ephemeral);
assert_eq!(WorkspaceKind::default(), WorkspaceKind::Ephemeral);
assert!(!WorkspacePolicy::default().is_persistent());
assert!(!WorkspacePolicy::default().retains_job_workspace());
assert_eq!(WorkspacePolicy::default().root(), None);
}
#[test]
fn a_persistent_policy_retains_the_job_workspace() {
let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
.expect("a repository may be persistent");
assert!(policy.is_persistent());
assert!(policy.retains_job_workspace());
assert_eq!(policy.kind(), WorkspaceKind::Persistent);
assert_eq!(policy.root(), Some(&root()));
}
#[test]
fn an_organization_policy_cannot_be_constructed_as_persistent() {
assert_eq!(
WorkspacePolicy::persistent(root(), TargetScope::Organization),
Err(WorkspaceError::PersistentRequiresRepositoryScope)
);
}
#[test]
fn an_organization_policy_cannot_be_restored_as_persistent() {
assert_eq!(
WorkspacePolicy::from_persisted(
WorkspaceKind::Persistent,
Some(root()),
TargetScope::Organization,
),
Err(WorkspaceError::PersistentRequiresRepositoryScope)
);
assert_eq!(
WorkspacePolicy::from_persisted(
WorkspaceKind::Ephemeral,
None,
TargetScope::Organization,
),
Ok(WorkspacePolicy::Ephemeral)
);
}
#[test]
fn a_workspace_policy_round_trips_through_its_columns() {
for scope in [TargetScope::Repository, TargetScope::Organization] {
let policy = WorkspacePolicy::Ephemeral;
assert_eq!(
WorkspacePolicy::from_persisted(policy.kind(), policy.root().cloned(), scope),
Ok(policy)
);
}
let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
.expect("a repository may be persistent");
assert_eq!(
WorkspacePolicy::from_persisted(
policy.kind(),
policy.root().cloned(),
TargetScope::Repository,
),
Ok(policy)
);
}
#[test]
fn mismatched_workspace_policy_columns_fail_closed() {
assert_eq!(
WorkspacePolicy::from_persisted(
WorkspaceKind::Persistent,
None,
TargetScope::Repository
),
Err(WorkspaceError::PersistentWithoutRoot)
);
assert_eq!(
WorkspacePolicy::from_persisted(
WorkspaceKind::Ephemeral,
Some(root()),
TargetScope::Repository,
),
Err(WorkspaceError::EphemeralWithRoot {
root: "/srv/rman/acme".to_string()
})
);
}
#[test]
fn an_ephemeral_attempt_holds_no_slot() {
let workspace = AttemptWorkspace::Ephemeral;
assert_eq!(workspace.kind(), WorkspaceKind::Ephemeral);
assert_eq!(workspace.slot(), None);
assert_eq!(workspace.slot_number(), None);
assert_eq!(workspace.slot_directory_name(), None);
assert!(!workspace.is_persistent());
}
#[test]
fn a_persistent_attempt_names_its_slot_directory() {
let workspace = AttemptWorkspace::persistent_slot(nz(1));
assert_eq!(workspace.kind(), WorkspaceKind::Persistent);
assert_eq!(workspace.slot(), Some(nz(1)));
assert_eq!(workspace.slot_number(), Some(1));
assert_eq!(workspace.slot_directory_name().as_deref(), Some("s1"));
assert!(workspace.is_persistent());
assert_eq!(
AttemptWorkspace::persistent_slot(nz(12))
.slot_directory_name()
.as_deref(),
Some("s12")
);
}
#[test]
fn the_slot_directory_name_is_a_single_path_component() {
let workspace = AttemptWorkspace::persistent_slot(nz(3));
let name = workspace
.slot_directory_name()
.expect("a persistent attempt names a slot");
assert_eq!(
root().join_child(name).expect("a valid child").as_str(),
"/srv/rman/acme/s3"
);
}
#[test]
fn an_attempt_workspace_round_trips_through_its_columns() {
for workspace in [
AttemptWorkspace::Ephemeral,
AttemptWorkspace::persistent_slot(nz(1)),
AttemptWorkspace::persistent_slot(nz(u16::MAX)),
] {
assert_eq!(
AttemptWorkspace::from_persisted(workspace.kind(), workspace.slot_number()),
Ok(workspace)
);
}
}
#[test]
fn mismatched_attempt_workspace_columns_fail_closed() {
assert_eq!(
AttemptWorkspace::from_persisted(WorkspaceKind::Persistent, None),
Err(WorkspaceError::PersistentWithoutSlot)
);
assert_eq!(
AttemptWorkspace::from_persisted(WorkspaceKind::Ephemeral, Some(1)),
Err(WorkspaceError::EphemeralWithSlot { slot: 1 })
);
assert_eq!(
AttemptWorkspace::from_persisted(WorkspaceKind::Ephemeral, Some(0)),
Err(WorkspaceError::EphemeralWithSlot { slot: 0 })
);
assert_eq!(
AttemptWorkspace::from_persisted(WorkspaceKind::Persistent, Some(0)),
Err(WorkspaceError::SlotNotPositive)
);
}
#[test]
fn workspace_kind_tokens_are_stable() {
for (kind, token) in [
(WorkspaceKind::Ephemeral, "ephemeral"),
(WorkspaceKind::Persistent, "persistent"),
] {
assert_eq!(kind.to_string(), token);
assert_eq!(token.parse::<WorkspaceKind>().expect("a known token"), kind);
assert_eq!(
serde_json::to_value(kind).expect("serialisable"),
serde_json::Value::String(token.to_string())
);
}
assert!("durable".parse::<WorkspaceKind>().is_err());
assert!(serde_json::from_str::<WorkspaceKind>("\"durable\"").is_err());
}
#[test]
fn workspace_values_serialise_without_credentials() {
let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
.expect("a repository may be persistent");
let attempt = AttemptWorkspace::persistent_slot(nz(2));
let rendered = format!(
"{policy}|{attempt}|{:?}|{:?}|{}|{}",
policy,
attempt,
serde_json::to_string(&policy).expect("serialisable"),
serde_json::to_string(&attempt).expect("serialisable"),
);
for needle in ["token", "secret", "jit", "password", "ghs_", "ghp_"] {
assert!(
!rendered.to_ascii_lowercase().contains(needle),
"workspace rendering leaked {needle:?}: {rendered}"
);
}
assert!(rendered.contains("/srv/rman/acme"));
assert!(rendered.contains("s2"));
}
#[test]
fn workspace_values_round_trip_through_serde() {
let attempt = AttemptWorkspace::persistent_slot(nz(7));
let encoded = serde_json::to_string(&attempt).expect("serialisable");
assert_eq!(
serde_json::from_str::<AttemptWorkspace>(&encoded).expect("deserialisable"),
attempt
);
assert_eq!(
serde_json::from_str::<AttemptWorkspace>("{\"mode\":\"ephemeral\"}")
.expect("deserialisable"),
AttemptWorkspace::Ephemeral
);
assert!(
serde_json::from_str::<AttemptWorkspace>("{\"mode\":\"persistent_slot\",\"slot\":0}")
.is_err()
);
}
}