use std::collections::BTreeSet;
use std::fmt;
use std::num::{NonZeroU16, NonZeroUsize};
use serde::{Deserialize, Serialize};
use crate::model::{
Arch, CachePolicy, HostId, HostLabel, Label, NonEmpty, Os, PolicyId, ScaleTarget,
ValidationError,
};
use crate::path::LocalAbsolutePath;
use crate::workspace::{WorkspaceError, WorkspaceKind, WorkspacePolicy};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PolicyError {
#[error(transparent)]
Invalid(#[from] ValidationError),
#[error(transparent)]
Workspace(#[from] WorkspaceError),
#[error(
"an Autoscale policy requires routing labels; a policy with none is a \
MonitorOnly policy (D19)"
)]
AutoscaleWithoutRoutingLabels,
#[error(
"an Autoscale policy requires max_capacity; without a ceiling it could \
oversubscribe the host (D7, D19)"
)]
AutoscaleWithoutMaxCapacity,
#[error(
"a MonitorOnly policy must not carry a non-zero min_capacity ({min}); it \
never starts a runner (D19)"
)]
MonitorOnlyWithMinCapacity { min: u16 },
#[error("min_capacity ({min}) must not exceed max_capacity ({max})")]
InvertedCapacityRange { min: u16, max: u16 },
#[error("{to} is not a legal transition from {from}")]
IllegalTransition { from: PolicyState, to: PolicyState },
#[error(
"only a MonitorOnly policy can be promoted to Autoscale; this one is already Autoscale"
)]
AlreadyAutoscale,
#[error(
"this operation needs an Autoscale policy; a MonitorOnly policy has no \
capacity and no routing labels to change (D19)"
)]
NotAutoscale,
#[error("the host label {label} is the routing identity of this policy and cannot be removed")]
HostLabelNotRemovable { label: Label },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
pub struct RoutingLabels {
host_label: Label,
additional: BTreeSet<Label>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RoutingLabelsRepr {
host_label: Label,
#[serde(default)]
additional: BTreeSet<Label>,
}
impl From<RoutingLabelsRepr> for RoutingLabels {
fn from(repr: RoutingLabelsRepr) -> Self {
Self::from_parts(repr.host_label, repr.additional)
}
}
impl From<RoutingLabels> for RoutingLabelsRepr {
fn from(value: RoutingLabels) -> Self {
Self {
host_label: value.host_label,
additional: value.additional,
}
}
}
impl RoutingLabels {
pub const PREFIX: &'static str = "rm";
#[must_use]
pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
let derived = format!(
"{}-{}-{}-{}",
Self::PREFIX,
host_label.as_str(),
os.label_token(),
arch.label_token()
);
Self {
host_label: Label::new(derived).expect(
"a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
segments are fixed tokens, so the concatenation is always a valid Label",
),
additional: BTreeSet::new(),
}
}
#[must_use]
pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
let additional = additional
.into_iter()
.filter(|l| *l != host_label)
.collect();
Self {
host_label,
additional,
}
}
#[must_use]
pub fn from_host_label(host_label: Label) -> Self {
Self::from_parts(host_label, Vec::new())
}
#[must_use]
pub fn host_label(&self) -> &Label {
&self.host_label
}
#[must_use]
pub fn is_derived_shape(&self) -> bool {
let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
let [prefix, middle @ .., os, arch] = segments.as_slice() else {
return false;
};
*prefix == Self::PREFIX
&& !middle.is_empty()
&& Os::ALL
.iter()
.any(|candidate| candidate.label_token() == *os)
&& Arch::ALL
.iter()
.any(|candidate| candidate.label_token() == *arch)
}
pub fn additional(&self) -> impl Iterator<Item = &Label> {
self.additional.iter()
}
pub fn add(&mut self, label: Label) -> bool {
if label == self.host_label {
return false;
}
self.additional.insert(label)
}
pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
if *label == self.host_label {
return Err(PolicyError::HostLabelNotRemovable {
label: label.clone(),
});
}
Ok(self.additional.remove(label))
}
#[must_use]
pub fn contains(&self, label: &Label) -> bool {
self.host_label == *label || self.additional.contains(label)
}
pub fn iter(&self) -> impl Iterator<Item = &Label> {
std::iter::once(&self.host_label).chain(self.additional.iter())
}
#[must_use]
pub fn count(&self) -> NonZeroUsize {
NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
}
#[must_use]
pub fn to_non_empty(&self) -> NonEmpty<Label> {
let mut out = NonEmpty::of(self.host_label.clone());
for label in &self.additional {
out.push(label.clone());
}
out
}
#[must_use]
pub fn as_registration_labels(&self) -> Vec<String> {
self.iter().map(|l| l.as_str().to_string()).collect()
}
#[must_use]
pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
let required = match runs_on.required_labels() {
Ok(required) => required,
Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
};
let missing: Vec<Label> = required
.iter()
.filter(|label| !self.contains(label))
.cloned()
.collect();
if missing.is_empty() {
RunsOnMatch::Match {
runner_group: runs_on.runner_group().map(str::to_string),
}
} else {
RunsOnMatch::NoMatch { missing }
}
}
#[must_use]
pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
let mut tally = DemandTally::default();
for job in jobs {
match self.matches(job) {
RunsOnMatch::Match { .. } => tally.matched += 1,
RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
}
}
tally
}
}
impl fmt::Display for RoutingLabels {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
f.write_str(&joined.join(","))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DemandTally {
pub matched: u32,
pub not_matched: u32,
pub unresolvable: Vec<UnresolvableRunsOn>,
}
impl DemandTally {
#[must_use]
pub fn demand(&self) -> u32 {
self.matched
}
#[must_use]
pub fn total_seen(&self) -> u32 {
self.matched + self.not_matched + self.unresolvable.len() as u32
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunsOnMatch {
Match { runner_group: Option<String> },
NoMatch { missing: Vec<Label> },
Unresolvable(UnresolvableRunsOn),
}
impl RunsOnMatch {
#[must_use]
pub const fn is_match(&self) -> bool {
matches!(self, RunsOnMatch::Match { .. })
}
#[must_use]
pub const fn is_unresolvable(&self) -> bool {
matches!(self, RunsOnMatch::Unresolvable(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UnresolvableRunsOn {
#[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
Expression { raw: String },
#[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
RunnerGroupWithoutLabels { group: String },
#[error("`runs-on` names no labels")]
NoLabels,
#[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
InvalidLabel {
raw: String,
#[source]
source: ValidationError,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RunsOn {
Single(String),
Many(Vec<String>),
Grouped {
#[serde(default)]
group: Option<String>,
#[serde(default)]
labels: RunsOnLabels,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RunsOnLabels {
One(String),
Many(Vec<String>),
}
impl Default for RunsOnLabels {
fn default() -> Self {
Self::Many(Vec::new())
}
}
impl RunsOnLabels {
fn as_slice(&self) -> &[String] {
match self {
RunsOnLabels::One(one) => std::slice::from_ref(one),
RunsOnLabels::Many(many) => many,
}
}
}
impl RunsOn {
#[must_use]
pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self::Many(labels.into_iter().map(Into::into).collect())
}
#[must_use]
pub fn runner_group(&self) -> Option<&str> {
match self {
RunsOn::Grouped { group, .. } => group.as_deref(),
_ => None,
}
}
fn raw_labels(&self) -> &[String] {
match self {
RunsOn::Single(one) => std::slice::from_ref(one),
RunsOn::Many(many) => many,
RunsOn::Grouped { labels, .. } => labels.as_slice(),
}
}
pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
let raws = self.raw_labels();
if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
}
let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
if usable.is_empty() {
return match self.runner_group() {
Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
group: group.to_string(),
}),
None => Err(UnresolvableRunsOn::NoLabels),
};
}
usable
.into_iter()
.map(|raw| {
Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
raw: raw.clone(),
source,
})
})
.collect()
}
}
fn is_expression(raw: &str) -> bool {
raw.contains("${{")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "AutoscaleConfigRepr")]
pub struct AutoscaleConfig {
routing_labels: RoutingLabels,
min_capacity: u16,
max_capacity: NonZeroU16,
}
#[derive(Debug, Deserialize)]
struct AutoscaleConfigRepr {
routing_labels: RoutingLabels,
min_capacity: u16,
max_capacity: NonZeroU16,
}
impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
type Error = PolicyError;
fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
}
}
impl AutoscaleConfig {
pub fn new(
routing_labels: RoutingLabels,
min_capacity: u16,
max_capacity: NonZeroU16,
) -> Result<Self, PolicyError> {
if min_capacity > max_capacity.get() {
return Err(PolicyError::InvertedCapacityRange {
min: min_capacity,
max: max_capacity.get(),
});
}
Ok(Self {
routing_labels,
min_capacity,
max_capacity,
})
}
pub fn v1(
routing_labels: RoutingLabels,
max_capacity: NonZeroU16,
) -> Result<Self, PolicyError> {
Self::new(routing_labels, 0, max_capacity)
}
#[must_use]
pub fn routing_labels(&self) -> &RoutingLabels {
&self.routing_labels
}
#[must_use]
pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
&mut self.routing_labels
}
#[must_use]
pub const fn min_capacity(&self) -> u16 {
self.min_capacity
}
#[must_use]
pub const fn max_capacity(&self) -> NonZeroU16 {
self.max_capacity
}
pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
if self.min_capacity > max_capacity.get() {
return Err(PolicyError::InvertedCapacityRange {
min: self.min_capacity,
max: max_capacity.get(),
});
}
self.max_capacity = max_capacity;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum PolicyMode {
MonitorOnly,
Autoscale(AutoscaleConfig),
}
impl PolicyMode {
#[must_use]
pub const fn monitor_only() -> Self {
Self::MonitorOnly
}
pub fn autoscale(
routing_labels: RoutingLabels,
min_capacity: u16,
max_capacity: NonZeroU16,
) -> Result<Self, PolicyError> {
Ok(Self::Autoscale(AutoscaleConfig::new(
routing_labels,
min_capacity,
max_capacity,
)?))
}
pub fn from_persisted(
routing_labels: Option<RoutingLabels>,
min_capacity: u16,
max_capacity: Option<NonZeroU16>,
) -> Result<Self, PolicyError> {
match (routing_labels, max_capacity) {
(None, None) => {
if min_capacity != 0 {
return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
}
Ok(Self::MonitorOnly)
}
(Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
(None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
(Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
}
}
#[must_use]
pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
match self {
PolicyMode::MonitorOnly => None,
PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
}
}
#[must_use]
pub const fn min_capacity(&self) -> u16 {
match self {
PolicyMode::MonitorOnly => 0,
PolicyMode::Autoscale(cfg) => cfg.min_capacity,
}
}
#[must_use]
pub const fn max_capacity(&self) -> Option<NonZeroU16> {
match self {
PolicyMode::MonitorOnly => None,
PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
}
}
#[must_use]
pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
match self {
PolicyMode::MonitorOnly => None,
PolicyMode::Autoscale(cfg) => Some(cfg),
}
}
#[must_use]
pub const fn is_autoscale(&self) -> bool {
matches!(self, PolicyMode::Autoscale(_))
}
#[must_use]
pub const fn is_monitor_only(&self) -> bool {
matches!(self, PolicyMode::MonitorOnly)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyState {
Pending,
Active,
Draining,
Disabled,
RepairRequired,
AuthenticationFailed,
}
impl PolicyState {
pub const ALL: [PolicyState; 6] = [
PolicyState::Pending,
PolicyState::Active,
PolicyState::Draining,
PolicyState::Disabled,
PolicyState::RepairRequired,
PolicyState::AuthenticationFailed,
];
pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
(PolicyState::Pending, PolicyState::Active),
(PolicyState::Pending, PolicyState::RepairRequired),
(PolicyState::Active, PolicyState::Draining),
(PolicyState::Draining, PolicyState::Disabled),
(PolicyState::Disabled, PolicyState::Pending),
(PolicyState::Pending, PolicyState::AuthenticationFailed),
(PolicyState::Active, PolicyState::AuthenticationFailed),
(PolicyState::Draining, PolicyState::AuthenticationFailed),
(PolicyState::Disabled, PolicyState::AuthenticationFailed),
(
PolicyState::RepairRequired,
PolicyState::AuthenticationFailed,
),
(PolicyState::AuthenticationFailed, PolicyState::Pending),
];
#[must_use]
pub fn can_transition_to(self, next: PolicyState) -> bool {
Self::LEGAL.contains(&(self, next))
}
#[must_use]
pub const fn admits_new_runners(self) -> bool {
matches!(self, PolicyState::Active)
}
}
impl fmt::Display for PolicyState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
PolicyState::Pending => "pending",
PolicyState::Active => "active",
PolicyState::Draining => "draining",
PolicyState::Disabled => "disabled",
PolicyState::RepairRequired => "repair_required",
PolicyState::AuthenticationFailed => "authentication_failed",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScalePolicy {
pub id: PolicyId,
pub target: ScaleTarget,
pub installation_id: u64,
pub host_id: HostId,
pub requested_host_label: HostLabel,
mode: PolicyMode,
enabled: bool,
state: PolicyState,
pub cache_policy: CachePolicy,
workspace_policy: WorkspacePolicy,
revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPolicy {
pub id: PolicyId,
pub target: ScaleTarget,
pub installation_id: u64,
pub host_id: HostId,
pub requested_host_label: HostLabel,
pub routing_labels: Option<RoutingLabels>,
pub min_capacity: u16,
pub max_capacity: Option<NonZeroU16>,
pub enabled: bool,
pub state: PolicyState,
pub cache_policy: CachePolicy,
pub workspace_kind: WorkspaceKind,
pub workspace_root: Option<LocalAbsolutePath>,
pub revision: u64,
}
impl ScalePolicy {
#[must_use]
pub fn new(
id: PolicyId,
target: ScaleTarget,
installation_id: u64,
host_id: HostId,
mode: PolicyMode,
cache_policy: CachePolicy,
) -> Self {
Self::new_for_host_label(
id,
target,
installation_id,
host_id,
HostLabel::new("host").expect("the compatibility host label is valid"),
mode,
cache_policy,
)
}
#[must_use]
pub fn new_for_host_label(
id: PolicyId,
target: ScaleTarget,
installation_id: u64,
host_id: HostId,
requested_host_label: HostLabel,
mode: PolicyMode,
cache_policy: CachePolicy,
) -> Self {
Self {
id,
target,
installation_id,
host_id,
requested_host_label,
mode,
enabled: false,
state: PolicyState::Pending,
cache_policy,
workspace_policy: WorkspacePolicy::Ephemeral,
revision: 0,
}
}
pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
let PersistedPolicy {
id,
target,
installation_id,
host_id,
requested_host_label,
routing_labels,
min_capacity,
max_capacity,
enabled,
state,
cache_policy,
workspace_kind,
workspace_root,
revision,
} = fields;
let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
let workspace_policy =
WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
Ok(Self {
id,
target,
installation_id,
host_id,
requested_host_label,
mode,
enabled,
state,
cache_policy,
workspace_policy,
revision,
})
}
#[must_use]
pub fn to_persisted(&self) -> PersistedPolicy {
PersistedPolicy {
id: self.id,
target: self.target.clone(),
installation_id: self.installation_id,
host_id: self.host_id,
requested_host_label: self.requested_host_label.clone(),
routing_labels: self.routing_labels().cloned(),
min_capacity: self.min_capacity(),
max_capacity: self.max_capacity(),
enabled: self.enabled,
state: self.state,
cache_policy: self.cache_policy,
workspace_kind: self.workspace_policy.kind(),
workspace_root: self.workspace_policy.root().cloned(),
revision: self.revision,
}
}
#[must_use]
pub const fn mode(&self) -> &PolicyMode {
&self.mode
}
#[must_use]
pub const fn state(&self) -> PolicyState {
self.state
}
#[must_use]
pub const fn enabled(&self) -> bool {
self.enabled
}
#[must_use]
pub const fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
self.mode.routing_labels()
}
#[must_use]
pub const fn workspace_policy(&self) -> &WorkspacePolicy {
&self.workspace_policy
}
pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
workspace.permitted_for(self.target.scope())?;
if self.workspace_policy != workspace {
self.workspace_policy = workspace;
self.revision = self.revision.saturating_add(1);
}
Ok(())
}
#[must_use]
pub const fn min_capacity(&self) -> u16 {
self.mode.min_capacity()
}
#[must_use]
pub const fn max_capacity(&self) -> Option<NonZeroU16> {
self.mode.max_capacity()
}
#[must_use]
pub fn is_owned_by(&self, host_id: HostId) -> bool {
self.host_id == host_id
}
#[must_use]
pub const fn owns_runners(&self) -> bool {
self.mode.is_autoscale()
}
#[must_use]
pub const fn may_start_runners(&self) -> bool {
self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
}
pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
if !self.state.can_transition_to(next) {
return Err(PolicyError::IllegalTransition {
from: self.state,
to: next,
});
}
self.state = next;
self.revision = self.revision.saturating_add(1);
Ok(())
}
#[must_use]
pub fn can_activate(&self) -> bool {
self.state.can_transition_to(PolicyState::Active)
}
#[must_use]
pub fn can_request_disable(&self) -> bool {
self.state.can_transition_to(PolicyState::Draining)
}
pub fn activate(&mut self) -> Result<(), PolicyError> {
self.transition_to(PolicyState::Active)?;
self.enabled = true;
Ok(())
}
pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
self.transition_to(PolicyState::Draining)?;
self.enabled = false;
Ok(self.state)
}
pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
if self.state != PolicyState::Draining {
return Err(PolicyError::IllegalTransition {
from: self.state,
to: PolicyState::Disabled,
});
}
if active_attempts == 0 {
self.transition_to(PolicyState::Disabled)?;
}
Ok(self.state)
}
pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
self.transition_to(PolicyState::AuthenticationFailed)
}
pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
self.transition_to(PolicyState::Pending)
}
pub fn repair_required(&mut self) -> Result<(), PolicyError> {
self.transition_to(PolicyState::RepairRequired)
}
pub fn promote_to_autoscale(
&mut self,
routing_labels: RoutingLabels,
min_capacity: u16,
max_capacity: NonZeroU16,
) -> Result<(), PolicyError> {
if self.mode.is_autoscale() {
return Err(PolicyError::AlreadyAutoscale);
}
self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
self.revision = self.revision.saturating_add(1);
Ok(())
}
pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
match &mut self.mode {
PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
PolicyMode::Autoscale(cfg) => {
cfg.set_max_capacity(max_capacity)?;
self.revision = self.revision.saturating_add(1);
Ok(())
}
}
}
pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
match &mut self.mode {
PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
PolicyMode::Autoscale(cfg) => {
let added = cfg.routing_labels_mut().add(label);
if added {
self.revision = self.revision.saturating_add(1);
}
Ok(added)
}
}
}
pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
match &mut self.mode {
PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
PolicyMode::Autoscale(cfg) => {
let removed = cfg.routing_labels_mut().remove(label)?;
if removed {
self.revision = self.revision.saturating_add(1);
}
Ok(removed)
}
}
}
#[must_use]
pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
match self.routing_labels() {
Some(labels) => labels.tally(jobs),
None => DemandTally::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{HostId, PolicyId, TargetScope};
fn nz(v: u16) -> NonZeroU16 {
NonZeroU16::new(v).expect("test capacity is non-zero")
}
fn label(s: &str) -> Label {
Label::new(s).expect("test label is valid")
}
fn host_labels(host: &str) -> RoutingLabels {
RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
}
fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
ScalePolicy::new(
PolicyId::from_u128(1),
target,
42,
host,
PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
CachePolicy::default(),
)
}
fn workspace_root() -> LocalAbsolutePath {
LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
.expect("a valid persistent root")
}
fn repository_policy() -> ScalePolicy {
autoscale_policy(
ScaleTarget::repository("acme/api").unwrap(),
HostId::from_u128(1),
4,
)
}
fn organization_policy() -> ScalePolicy {
autoscale_policy(
ScaleTarget::organization("acme").unwrap(),
HostId::from_u128(1),
4,
)
}
#[test]
fn every_constructor_produces_an_ephemeral_workspace() {
for policy in [repository_policy(), organization_policy()] {
assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
assert!(!policy.workspace_policy().retains_job_workspace());
assert_eq!(
policy.to_persisted().workspace_kind,
WorkspaceKind::Ephemeral
);
assert_eq!(policy.to_persisted().workspace_root, None);
}
let monitor_only = ScalePolicy::new(
PolicyId::from_u128(2),
ScaleTarget::repository("acme/api").unwrap(),
42,
HostId::from_u128(1),
PolicyMode::MonitorOnly,
CachePolicy::default(),
);
assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
}
#[test]
fn a_repository_policy_can_opt_into_a_persistent_workspace() {
let mut policy = repository_policy();
let before = policy.revision();
policy
.set_workspace_policy(
WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
.expect("a repository may be persistent"),
)
.expect("a repository policy accepts persistence");
assert!(policy.workspace_policy().is_persistent());
assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
assert_eq!(
policy.revision(),
before + 1,
"a workspace change must bump the optimistic token, or `a2`'s guard \
cannot refuse a write built from a stale read"
);
let unchanged = policy.revision();
policy
.set_workspace_policy(policy.workspace_policy().clone())
.expect("re-setting the same policy is accepted");
assert_eq!(policy.revision(), unchanged);
}
#[test]
fn an_organization_policy_cannot_be_made_persistent() {
let mut policy = organization_policy();
let before = policy.revision();
assert_eq!(
policy.set_workspace_policy(WorkspacePolicy::Persistent {
root: workspace_root()
}),
Err(PolicyError::Workspace(
WorkspaceError::PersistentRequiresRepositoryScope
))
);
assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
assert_eq!(
policy.revision(),
before,
"a refused write consumes nothing"
);
assert_eq!(
WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
Err(WorkspaceError::PersistentRequiresRepositoryScope)
);
}
#[test]
fn a_workspace_policy_round_trips_through_the_persisted_struct() {
let mut policy = repository_policy();
policy
.set_workspace_policy(
WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
.expect("a repository may be persistent"),
)
.expect("a repository policy accepts persistence");
let restored = ScalePolicy::from_persisted(policy.to_persisted())
.expect("a policy this crate wrote must load");
assert_eq!(restored, policy);
assert_eq!(restored.workspace_policy(), policy.workspace_policy());
let ephemeral = repository_policy();
assert_eq!(
ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
ephemeral
);
}
#[test]
fn an_organization_row_claiming_persistence_fails_closed_on_load() {
let mut fields = organization_policy().to_persisted();
fields.workspace_kind = WorkspaceKind::Persistent;
fields.workspace_root = Some(workspace_root());
assert_eq!(
ScalePolicy::from_persisted(fields),
Err(PolicyError::Workspace(
WorkspaceError::PersistentRequiresRepositoryScope
))
);
}
#[test]
fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
let base = repository_policy().to_persisted();
let mut without_root = base.clone();
without_root.workspace_kind = WorkspaceKind::Persistent;
assert_eq!(
ScalePolicy::from_persisted(without_root),
Err(PolicyError::Workspace(
WorkspaceError::PersistentWithoutRoot
))
);
let mut stale_root = base;
stale_root.workspace_root = Some(workspace_root());
assert!(matches!(
ScalePolicy::from_persisted(stale_root),
Err(PolicyError::Workspace(
WorkspaceError::EphemeralWithRoot { .. }
))
));
}
#[test]
fn workspace_retention_is_not_the_runner_package_cache_policy() {
let mut policy = repository_policy();
policy.cache_policy = CachePolicy::DiscardRunnerPackage;
policy
.set_workspace_policy(
WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
.expect("a repository may be persistent"),
)
.expect("a repository policy accepts persistence");
assert!(policy.workspace_policy().retains_job_workspace());
assert!(!policy.cache_policy.retains_runner_package());
assert!(!policy.cache_policy.retains_job_workspace());
}
#[test]
fn the_derived_label_has_the_shape_the_architecture_gives() {
let labels =
RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
assert_eq!(labels.count().get(), 1);
}
#[test]
fn the_derived_label_is_host_scoped_by_construction() {
let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
assert_ne!(
a.host_label(),
b.host_label(),
"two hosts must not derive the same routing label; with no job \
reservation, a shared label means both hosts start a runner for one job"
);
assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
assert_ne!(a.host_label(), mac.host_label());
}
#[test]
fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
let labels =
RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
}
#[test]
fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
let mut labels = host_labels("home");
let derived = labels.host_label().clone();
assert!(labels.add(label("gpu")));
assert!(labels.add(label("self-hosted")));
assert!(
!labels.add(label("GPU")),
"adding a label that differs only in case must be a no-op, not a duplicate"
);
assert_eq!(labels.count().get(), 3);
assert!(labels.remove(&label("gpu")).unwrap());
assert_eq!(labels.count().get(), 2);
assert!(
!labels.remove(&label("never-added")).unwrap(),
"removing an absent optional label is a no-op, not an error"
);
assert!(
matches!(
labels.remove(&derived),
Err(PolicyError::HostLabelNotRemovable { .. })
),
"the derived host label must not be removable; it is the only thing \
keeping two hosts from serving each other's jobs"
);
assert!(labels.contains(&derived));
assert!(matches!(
labels.remove(&label("RM-HOME-WIN-X64")),
Err(PolicyError::HostLabelNotRemovable { .. })
));
}
#[test]
fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
let mut labels = host_labels("home");
let derived = labels.host_label().clone();
assert!(!labels.add(derived));
assert_eq!(labels.count().get(), 1);
let rebuilt = RoutingLabels::from_parts(
labels.host_label().clone(),
vec![labels.host_label().clone(), label("gpu")],
);
assert_eq!(rebuilt.count().get(), 2);
assert_eq!(
rebuilt.as_registration_labels(),
vec!["rm-home-win-x64", "gpu"]
);
}
#[test]
fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
let mut labels = host_labels("home");
labels.add(label("gpu"));
assert_eq!(
labels.as_registration_labels(),
vec!["rm-home-win-x64", "gpu"]
);
assert!(!labels.contains(&label("self-hosted")));
}
#[test]
fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
let mut labels = host_labels("home");
labels.add(label("gpu"));
let json = serde_json::to_string(&labels).unwrap();
let back: RoutingLabels = serde_json::from_str(&json).unwrap();
assert_eq!(labels, back);
assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
}
#[test]
fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
let labels = host_labels("home");
let non_empty = labels.to_non_empty();
assert_eq!(non_empty.count().get(), 1);
assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
}
struct Row {
name: &'static str,
runs_on: RunsOn,
expect: Expect,
}
#[derive(Debug, PartialEq, Eq)]
enum Expect {
Match,
NoMatch,
Unresolvable,
}
fn classify(m: &RunsOnMatch) -> Expect {
match m {
RunsOnMatch::Match { .. } => Expect::Match,
RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
}
}
fn table_policy() -> RoutingLabels {
let mut labels = host_labels("home");
labels.add(label("self-hosted"));
labels.add(label("gpu"));
labels
}
fn table() -> Vec<Row> {
vec![
Row {
name: "string: the derived host label",
runs_on: RunsOn::Single("rm-home-win-x64".into()),
expect: Expect::Match,
},
Row {
name: "string: the derived host label in the wrong case",
runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
expect: Expect::Match,
},
Row {
name: "string: an optional label alone",
runs_on: RunsOn::Single("gpu".into()),
expect: Expect::Match,
},
Row {
name: "string: another host's label",
runs_on: RunsOn::Single("rm-office-win-x64".into()),
expect: Expect::NoMatch,
},
Row {
name: "string: a GitHub-hosted runner label",
runs_on: RunsOn::Single("ubuntu-latest".into()),
expect: Expect::NoMatch,
},
Row {
name: "array: a strict subset of the policy's labels",
runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
expect: Expect::Match,
},
Row {
name: "array: the whole set, out of order and mixed case",
runs_on: RunsOn::Many(vec![
"GPU".into(),
"Rm-Home-Win-X64".into(),
"Self-Hosted".into(),
]),
expect: Expect::Match,
},
Row {
name: "array: one label the policy does not carry",
runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
expect: Expect::NoMatch,
},
Row {
name: "array: an empty array names no labels",
runs_on: RunsOn::Many(vec![]),
expect: Expect::Unresolvable,
},
Row {
name: "map: labels only",
runs_on: RunsOn::Grouped {
group: None,
labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
},
expect: Expect::Match,
},
Row {
name: "map: a group plus labels the policy carries",
runs_on: RunsOn::Grouped {
group: Some("Default".into()),
labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
},
expect: Expect::Match,
},
Row {
name: "map: labels as a scalar",
runs_on: RunsOn::Grouped {
group: Some("Default".into()),
labels: RunsOnLabels::One("rm-home-win-x64".into()),
},
expect: Expect::Match,
},
Row {
name: "map: a group plus a label the policy does not carry",
runs_on: RunsOn::Grouped {
group: Some("Default".into()),
labels: RunsOnLabels::Many(vec!["macos".into()]),
},
expect: Expect::NoMatch,
},
Row {
name: "map: a group with no labels constrains something we cannot read",
runs_on: RunsOn::Grouped {
group: Some("Default".into()),
labels: RunsOnLabels::Many(vec![]),
},
expect: Expect::Unresolvable,
},
Row {
name: "expression: the whole value",
runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
expect: Expect::Unresolvable,
},
Row {
name: "expression: one element of an array",
runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
expect: Expect::Unresolvable,
},
Row {
name: "expression: inside the map form",
runs_on: RunsOn::Grouped {
group: None,
labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
},
expect: Expect::Unresolvable,
},
Row {
name: "not a usable label at all",
runs_on: RunsOn::Single("rm-home,win-x64".into()),
expect: Expect::Unresolvable,
},
]
}
#[test]
fn runs_on_matching_covers_every_documented_form() {
let policy = table_policy();
for row in table() {
let got = policy.matches(&row.runs_on);
assert_eq!(
classify(&got),
row.expect,
"row {:?}: {:?} produced {got:?}",
row.name,
row.runs_on
);
}
}
#[test]
fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
let without = host_labels("home");
assert!(
!without
.matches(&RunsOn::Single("self-hosted".into()))
.is_match(),
"a policy that does not carry `self-hosted` must not claim a job that asks for it"
);
let mut with = host_labels("home");
with.add(label("self-hosted"));
assert!(
with.matches(&RunsOn::Single("self-hosted".into()))
.is_match(),
"and it must claim it once the operator adds the label explicitly"
);
}
#[test]
fn a_no_match_names_the_labels_that_were_missing() {
let policy = host_labels("home");
let got = policy.matches(&RunsOn::Many(vec![
"rm-home-win-x64".into(),
"self-hosted".into(),
"GPU".into(),
]));
match got {
RunsOnMatch::NoMatch { missing } => {
assert_eq!(
missing,
vec![label("self-hosted"), label("gpu")],
"the operator needs to know which labels to add"
);
}
other => panic!("expected NoMatch, got {other:?}"),
}
}
#[test]
fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
let policy = host_labels("home");
let got = policy.matches(&RunsOn::Grouped {
group: Some("Default".into()),
labels: RunsOnLabels::One("rm-home-win-x64".into()),
});
assert_eq!(
got,
RunsOnMatch::Match {
runner_group: Some("Default".into())
},
"a policy has no runner-group field, so the domain cannot evaluate \
`group:`; returning it lets `c4`, which can, do so without re-parsing"
);
}
#[test]
fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
let policy = host_labels("home");
let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
assert!(matches!(
expr,
RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
));
let group = policy.matches(&RunsOn::Grouped {
group: Some("g".into()),
labels: RunsOnLabels::Many(vec![]),
});
assert!(matches!(
group,
RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
));
let none = policy.matches(&RunsOn::Many(vec![]));
assert!(matches!(
none,
RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
));
let invalid = policy.matches(&RunsOn::Single("a,b".into()));
assert!(matches!(
invalid,
RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
));
}
#[test]
fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
let policy = table_policy();
let jobs = vec![
RunsOn::Single("rm-home-win-x64".into()), RunsOn::Single("ubuntu-latest".into()), RunsOn::Single("${{ matrix.runner }}".into()), RunsOn::Single("${{ inputs.pool }}".into()), ];
let tally = policy.tally(&jobs);
assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
assert_eq!(tally.not_matched, 1);
assert_eq!(
tally.unresolvable.len(),
2,
"and it must not vanish either -- `g2` shows these to the operator"
);
assert_eq!(
tally.total_seen(),
jobs.len() as u32,
"every job seen is accounted for in exactly one bucket"
);
}
#[test]
fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
assert_eq!(
many,
RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
);
let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
assert_eq!(
grouped,
RunsOn::Grouped {
group: Some("g".into()),
labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
}
);
let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
assert_eq!(
scalar_labels,
RunsOn::Grouped {
group: None,
labels: RunsOnLabels::One("a".into()),
}
);
let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
assert_eq!(
group_only,
RunsOn::Grouped {
group: Some("g".into()),
labels: RunsOnLabels::Many(vec![]),
}
);
assert_eq!(
RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
);
}
#[test]
fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
let labels = host_labels("home");
assert!(matches!(
PolicyMode::from_persisted(Some(labels.clone()), 0, None),
Err(PolicyError::AutoscaleWithoutMaxCapacity)
));
assert!(matches!(
PolicyMode::from_persisted(None, 0, Some(nz(1))),
Err(PolicyError::AutoscaleWithoutRoutingLabels)
));
assert!(
PolicyMode::from_persisted(None, 0, None)
.unwrap()
.is_monitor_only()
);
assert!(
PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
.unwrap()
.is_autoscale()
);
}
#[test]
fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
assert!(autoscale.routing_labels().is_some());
assert!(autoscale.max_capacity().is_some());
let monitor = PolicyMode::monitor_only();
assert!(monitor.routing_labels().is_none());
assert!(monitor.max_capacity().is_none());
assert_eq!(monitor.min_capacity(), 0);
}
#[test]
fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
assert!(matches!(
err,
PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
));
assert!(
err.to_string().contains("MonitorOnly"),
"the message must name the shape rule, got: {err}"
);
}
#[test]
fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
assert!(matches!(
PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
));
assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
assert!(matches!(
cfg.set_max_capacity(nz(1)),
Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
));
assert_eq!(
cfg.max_capacity().get(),
4,
"a refused write changes nothing"
);
}
#[test]
fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
for mode in [
PolicyMode::monitor_only(),
PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
] {
let json = serde_json::to_string(&mode).unwrap();
let back: PolicyMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, back, "{json} did not round-trip");
}
let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
assert!(
err.to_string().contains("min_capacity"),
"expected the shape error to survive into serde's message, got: {err}"
);
}
#[test]
fn a_policy_round_trips_through_its_persisted_form() {
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
3,
);
policy.add_routing_label(label("gpu")).unwrap();
policy.activate().unwrap();
assert_eq!(policy.state(), PolicyState::Active);
assert!(policy.enabled());
let stored = policy.to_persisted();
assert_ne!(
stored.installation_id, stored.revision,
"the fixture must distinguish the two u64 columns, or transposing \
them is unobservable and this test proves nothing"
);
assert_eq!(stored.installation_id, 42);
assert_eq!(stored.revision, policy.revision());
let restored =
ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
assert_eq!(restored, policy);
assert_eq!(restored.installation_id, 42);
assert_eq!(restored.revision(), policy.revision());
assert_eq!(restored.state(), PolicyState::Active);
assert!(restored.enabled());
assert_eq!(
restored.routing_labels().unwrap().count().get(),
2,
"the optional label survives alongside the host label"
);
}
#[test]
fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
let mut policy = ScalePolicy::new(
PolicyId::from_u128(2),
ScaleTarget::organization("acme").unwrap(),
9,
HostId::from_u128(7),
PolicyMode::monitor_only(),
CachePolicy::default(),
);
policy.activate().unwrap();
let stored = policy.to_persisted();
assert!(stored.routing_labels.is_none());
assert_eq!(stored.min_capacity, 0);
assert!(stored.max_capacity.is_none());
assert_ne!(stored.installation_id, stored.revision);
let restored =
ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
assert_eq!(restored, policy);
assert!(
restored.mode().is_monitor_only(),
"the mode is inferred back from the three columns, not stored"
);
assert!(!restored.owns_runners());
assert_eq!(restored.installation_id, 9);
assert_eq!(restored.revision(), 1);
}
fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
use PolicyState::*;
let mut edges = vec![
(Pending, Active),
(Pending, RepairRequired),
(Active, Draining),
(Draining, Disabled),
(Disabled, Pending),
(AuthenticationFailed, Pending),
];
for from in PolicyState::ALL {
if from != AuthenticationFailed {
edges.push((from, AuthenticationFailed));
}
}
edges
}
#[test]
fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
let expected = diagram_edges();
assert_eq!(
expected.len(),
11,
"the transcription itself changed; check it against the diagram"
);
let mut legal_seen = 0usize;
let mut illegal_seen = 0usize;
for from in PolicyState::ALL {
for to in PolicyState::ALL {
let expected_legal = expected.contains(&(from, to));
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
1,
);
policy.state = from;
let result = policy.transition_to(to);
if expected_legal {
legal_seen += 1;
assert!(
result.is_ok(),
"{from} -> {to} is in the diagram and must be accepted"
);
assert_eq!(policy.state(), to);
} else {
illegal_seen += 1;
assert!(
matches!(result, Err(PolicyError::IllegalTransition { .. })),
"{from} -> {to} is not in the diagram and must be rejected"
);
assert_eq!(policy.state(), from, "a refused transition changes nothing");
}
}
}
assert_eq!(legal_seen, 11);
assert_eq!(illegal_seen, 36 - 11);
let mut published = PolicyState::LEGAL.to_vec();
let mut transcribed = expected;
published.sort_unstable();
transcribed.sort_unstable();
assert_eq!(published, transcribed);
}
#[test]
fn a_policy_state_cannot_transition_to_itself() {
for state in PolicyState::ALL {
assert!(
!state.can_transition_to(state),
"{state} -> {state} is not an edge in the diagram; treating it as \
one would let a repeated authentication failure look like progress"
);
}
}
#[test]
fn the_documented_happy_path_walks_pending_to_disabled() {
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
2,
);
assert_eq!(policy.state(), PolicyState::Pending);
assert!(!policy.enabled());
assert!(!policy.may_start_runners());
policy.activate().unwrap();
assert_eq!(policy.state(), PolicyState::Active);
assert!(policy.enabled());
assert!(policy.may_start_runners());
assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
assert_eq!(
policy.drain_completed(1).unwrap(),
PolicyState::Draining,
"a policy with a runner still in flight stays draining"
);
assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
}
#[test]
fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
5,
);
policy.activate().unwrap();
let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
assert_eq!(policy.tally(&jobs).demand(), 4);
assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
assert!(!policy.enabled());
assert!(
!policy.may_start_runners(),
"a draining policy must not be the reason a new runner starts, even \
with four jobs queued for its labels"
);
assert_eq!(
policy.tally(&jobs).demand(),
4,
"queued demand stays visible while draining (flow 5.2)"
);
}
#[test]
fn re_authentication_is_the_only_way_out_of_authentication_failed() {
for from in [
PolicyState::Pending,
PolicyState::Active,
PolicyState::Draining,
PolicyState::Disabled,
PolicyState::RepairRequired,
] {
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
1,
);
policy.state = from;
policy.authentication_failed().unwrap();
assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
assert!(matches!(
policy.authentication_failed(),
Err(PolicyError::IllegalTransition { .. })
));
policy.reauthenticated().unwrap();
assert_eq!(policy.state(), PolicyState::Pending);
}
}
#[test]
fn mutant_disabling_revoked_eligibility_gate_is_detected() {
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
1,
);
policy.activate().unwrap();
policy.authentication_failed().unwrap();
assert!(!policy.may_start_runners());
let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
assert!(
mutant_may_start,
"omitting revoked state must make the eligibility gate red"
);
}
#[test]
fn a_refused_transition_leaves_the_revision_untouched() {
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
1,
);
assert_eq!(policy.revision(), 0);
policy.activate().unwrap();
assert_eq!(policy.revision(), 1);
assert!(policy.activate().is_err());
assert_eq!(
policy.revision(),
1,
"a rejected write must not bump the optimistic-concurrency token, or \
`b2`'s stale-revision check would reject the next honest write"
);
}
#[test]
fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
let mut policy = ScalePolicy::new(
PolicyId::from_u128(2),
ScaleTarget::organization("acme").unwrap(),
9,
HostId::from_u128(7),
PolicyMode::monitor_only(),
CachePolicy::default(),
);
policy.activate().unwrap();
assert!(!policy.owns_runners());
assert!(
!policy.may_start_runners(),
"an active, enabled monitor-only policy still starts nothing (D19)"
);
assert!(policy.routing_labels().is_none());
assert!(policy.max_capacity().is_none());
let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
assert_eq!(
policy.tally(&jobs).demand(),
0,
"a monitor-only policy has no demand at all, rather than demand that \
is computed and then ignored"
);
assert!(matches!(
policy.add_routing_label(label("gpu")),
Err(PolicyError::NotAutoscale)
));
assert!(matches!(
policy.remove_routing_label(&label("gpu")),
Err(PolicyError::NotAutoscale)
));
assert!(matches!(
PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
Err(PolicyError::AutoscaleWithoutMaxCapacity)
));
}
#[test]
fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
let mut policy = ScalePolicy::new(
PolicyId::from_u128(2),
ScaleTarget::repository("o/r").unwrap(),
9,
HostId::from_u128(7),
PolicyMode::monitor_only(),
CachePolicy::default(),
);
assert!(policy.routing_labels().is_none());
policy
.promote_to_autoscale(host_labels("home"), 0, nz(3))
.unwrap();
assert!(policy.owns_runners());
assert_eq!(
policy.routing_labels().unwrap().host_label().as_str(),
"rm-home-win-x64"
);
assert_eq!(policy.max_capacity().unwrap().get(), 3);
assert!(matches!(
policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
Err(PolicyError::AlreadyAutoscale)
));
policy.set_max_capacity(nz(4)).unwrap();
assert_eq!(policy.max_capacity().unwrap().get(), 4);
}
#[test]
fn a_policy_is_owned_by_exactly_one_host() {
let mine = HostId::from_u128(7);
let theirs = HostId::from_u128(8);
let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
assert!(policy.is_owned_by(mine));
assert!(!policy.is_owned_by(theirs));
}
#[test]
fn an_overridden_host_label_is_detectable_without_being_rejected() {
assert!(host_labels("home").is_derived_shape());
assert!(
RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
.is_derived_shape(),
"a host label containing `-` still derives a four-plus-segment name"
);
assert_eq!(
host_labels("home--pc").host_label().as_str(),
"rm-home--pc-win-x64"
);
assert!(
host_labels("home--pc").is_derived_shape(),
"consecutive dashes are legal inside a host label; the empty middle \
segment they produce is not evidence of an override"
);
for raw in [
"self-hosted",
"ubuntu-latest",
"rm-home-win",
"rm-home-win-x64-extra",
] {
assert!(
!RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
"{raw:?} is not the derived shape"
);
}
assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
overridden.add(label("rm-home-win-x64"));
assert!(!overridden.is_derived_shape());
}
#[test]
fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
let mut policy = autoscale_policy(
ScaleTarget::repository("o/r").unwrap(),
HostId::from_u128(7),
3,
);
assert!(policy.can_activate());
assert!(
!policy.can_request_disable(),
"a pending policy cannot drain; it is also already not enabled, \
which is what makes the command a no-op rather than a failure"
);
assert!(matches!(
policy.request_disable(),
Err(PolicyError::IllegalTransition {
from: PolicyState::Pending,
to: PolicyState::Draining,
})
));
policy.activate().unwrap();
assert!(!policy.can_activate(), "already active");
assert!(policy.can_request_disable());
assert!(matches!(
policy.activate(),
Err(PolicyError::IllegalTransition { .. })
));
policy.request_disable().unwrap();
assert!(!policy.can_request_disable(), "already draining");
assert!(!policy.enabled());
}
fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
let host = HostId::from_u128(7);
let mut trace = Vec::new();
let mut policy = autoscale_policy(target.clone(), host, 3);
trace.push(format!("owns_runners={}", policy.owns_runners()));
trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
trace.push(format!(
"owned_by_other={}",
policy.is_owned_by(HostId::from_u128(8))
));
trace.push(format!("initial_state={}", policy.state()));
trace.push(format!("initial_enabled={}", policy.enabled()));
trace.push(format!(
"may_start_initially={}",
policy.may_start_runners()
));
trace.push(format!("labels={}", policy.routing_labels().unwrap()));
trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
trace.push(format!("min_capacity={}", policy.min_capacity()));
policy.activate().unwrap();
trace.push(format!("after_activate={}", policy.state()));
trace.push(format!("may_start_active={}", policy.may_start_runners()));
let jobs = vec![
RunsOn::Single("rm-home-win-x64".into()),
RunsOn::Single("ubuntu-latest".into()),
RunsOn::Single("${{ matrix.os }}".into()),
];
let tally = policy.tally(&jobs);
trace.push(format!(
"demand={} not_matched={} unresolvable={}",
tally.demand(),
tally.not_matched,
tally.unresolvable.len()
));
let host_record = crate::model::Host::new(
host,
"home-pc",
Os::Windows,
Arch::X64,
nz(4),
crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
)
.unwrap();
let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
let allocation = allocator.allocate(&policy, 3);
trace.push(format!(
"alloc demand={} desired={} active_owned={} headroom_before={} \
to_start={} limiting={}",
allocation.demand,
allocation.desired,
allocation.active_owned,
allocation.headroom_before,
allocation.to_start,
allocation.limiting_factor
));
trace.push(format!("headroom_after={}", allocator.headroom()));
trace.push(format!(
"alloc_zero_to_start={}",
allocator.allocate(&policy, 0).to_start
));
let attempt = crate::attempt::RunnerAttempt::allocate(
crate::model::AttemptId::from_u128(11),
policy.id,
"C:/runners/eq",
crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
);
trace.push(format!(
"authorize_own_host={:?}",
crate::attempt::authorize(host, &policy, &attempt).is_ok()
));
trace.push(format!(
"authorize_other_host={}",
crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
.expect_err("an agent on another host must be refused")
));
let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
crate::model::AttemptId::from_u128(12),
PolicyId::from_u128(999),
"C:/runners/eq-other",
crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
);
trace.push(format!(
"authorize_other_policy={}",
crate::attempt::authorize(host, &policy, &foreign_attempt)
.expect_err("an attempt under another policy must be refused")
));
trace.push(format!("disable={}", policy.request_disable().unwrap()));
trace.push(format!(
"drain_with_1={}",
policy.drain_completed(1).unwrap()
));
trace.push(format!(
"drain_with_0={}",
policy.drain_completed(0).unwrap()
));
trace.push(format!(
"reactivate_err={}",
policy.transition_to(PolicyState::Active).is_err()
));
trace.push(format!(
"registration_labels={:?}",
autoscale_policy(target, host, 3)
.routing_labels()
.unwrap()
.as_registration_labels()
));
trace
}
#[test]
fn repository_and_organization_targets_are_equivalent() {
let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
let organization =
assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
assert_eq!(
repository, organization,
"D18: the two scopes differ only in which GitHub endpoint and which \
App permission the gateway uses. Any difference here is a \
scope-dependent domain rule that must not exist."
);
assert_ne!(
ScaleTarget::repository("o/r").unwrap().scope(),
ScaleTarget::organization("o").unwrap().scope()
);
}
}