use std::collections::BTreeSet;
use std::io::{self, Write};
use std::num::NonZeroU16;
use std::path::Path;
use runner_manager_agent::lifecycle::ProviderCapability;
#[cfg(not(test))]
use runner_manager_agent::lifecycle::WindowsHyperVContainers;
#[cfg(test)]
use runner_manager_agent::lifecycle::WindowsHyperVHostState;
use runner_manager_domain::capacity::HostAllocator;
use runner_manager_domain::model::{Host, RefreshInterval, ScaleTarget, StartMode};
use runner_manager_domain::store::{Store, StoreError};
use runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL;
use runner_manager_github::rest::{
BudgetProjection, TargetCost, budget_allowance, refreshes_per_hour,
};
use runner_manager_platform::runner_env::{self, RunnerEnv, RunnerEnvError, RunnerPlatform};
use runner_manager_platform::runner_root::RootOwner;
use serde::Serialize;
use super::workspace;
use super::{
CliError, Context, Failure, HostCommand, HostEnvCommand, HostSetCapacityArgs, Styling,
write_failed,
};
pub const FALLBACK_COST_MULTIPLE: u32 = 4;
#[must_use]
pub fn measured(cost: TargetCost) -> TargetCost {
cost.with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
}
#[must_use]
pub fn max_repository_targets(interval: RefreshInterval) -> u32 {
let per_target = measured(TargetCost::repository()).requests_per_hour(interval);
if per_target == 0 {
return 0;
}
budget_allowance() / per_target
}
#[derive(Debug, Clone)]
pub struct HostBudget {
interval: RefreshInterval,
projection: BudgetProjection,
organization_targets: usize,
priced_targets: usize,
}
impl HostBudget {
#[must_use]
pub fn of(interval: RefreshInterval, targets: &[ScaleTarget]) -> Self {
let unique: BTreeSet<&ScaleTarget> = targets.iter().collect();
let mut costs = Vec::with_capacity(unique.len());
let mut organization_targets = 0;
for target in unique {
costs.push(measured(match target {
ScaleTarget::Repository(_) => TargetCost::repository(),
ScaleTarget::Organization(_) => {
organization_targets += 1;
TargetCost::organization(1)
}
}));
}
let priced_targets = costs.len();
Self {
interval,
projection: BudgetProjection::new(interval, costs),
organization_targets,
priced_targets,
}
}
#[must_use]
pub fn requests_per_hour(&self) -> u32 {
self.projection.requests_per_hour()
}
#[must_use]
pub fn allowance(&self) -> u32 {
self.projection.allowance()
}
#[must_use]
pub fn ceiling(&self) -> u32 {
self.projection.ceiling()
}
#[must_use]
pub fn headroom(&self) -> u32 {
self.projection.headroom()
}
#[must_use]
pub fn exceeds_allowance(&self) -> bool {
self.projection.exceeds_allowance()
}
#[must_use]
pub fn max_repository_targets(&self) -> u32 {
max_repository_targets(self.interval)
}
#[must_use]
pub const fn is_floor(&self) -> bool {
self.organization_targets > 0
}
pub fn write(&self, out: &mut dyn Write) -> io::Result<()> {
writeln!(out, "Shared REST budget")?;
writeln!(out, " refresh interval {}", self.interval)?;
writeln!(
out,
" refreshes per hour {}",
refreshes_per_hour(self.interval)
)?;
writeln!(
out,
" projected requests/hour {}{}",
self.requests_per_hour(),
if self.is_floor() { " (a floor)" } else { "" }
)?;
writeln!(
out,
" this host may spend {} per hour (half of GitHub's {} ceiling)",
self.allowance(),
self.ceiling()
)?;
writeln!(out, " headroom {}", self.headroom())?;
writeln!(out, " targets priced {}", self.priced_targets)?;
writeln!(
out,
" repository targets that fit at this interval: about {}",
self.max_repository_targets()
)?;
writeln!(out)?;
if self.exceeds_allowance() {
writeln!(
out,
" OVER BUDGET: this host's configured targets already project more than it"
)?;
writeln!(
out,
" may plan to spend. Lengthen the refresh interval or remove a target."
)?;
writeln!(out)?;
}
writeln!(
out,
" About: every configured target is priced as if it were polling, whatever its"
)?;
writeln!(
out,
" policies' states; profiles sharing a target use one poll and one charge."
)?;
if self.is_floor() {
writeln!(
out,
" {} of them are organization targets, whose cost grows with the number of",
self.organization_targets
)?;
writeln!(
out,
" repositories the App is installed on. That count is not known without"
)?;
writeln!(
out,
" contacting GitHub, so each is priced as one repository and the total above"
)?;
writeln!(out, " is a floor rather than an estimate.")?;
}
write_best_case_caveat(out)?;
Ok(())
}
}
pub fn write_best_case_caveat(out: &mut dyn Write) -> io::Result<()> {
writeln!(
out,
" About: these are BEST-CASE costs. Each repository is priced at one request for"
)?;
writeln!(
out,
" its in-progress count and one for its queued-run count, which is what GitHub"
)?;
writeln!(
out,
" charges when it sends a total with the first page. When it does not, each of"
)?;
writeln!(
out,
" those counts walks pages instead and costs up to {FALLBACK_COST_MULTIPLE}x as much. Treat the"
)?;
writeln!(
out,
" target figure as approximate, not as a threshold: the other half of GitHub's"
)?;
writeln!(
out,
" hourly ceiling is deliberately left unplanned to absorb exactly this."
)?;
Ok(())
}
pub fn local_host(store: &dyn Store) -> Result<Option<Host>, CliError> {
let mut hosts = store.hosts().map_err(store_failure)?;
match hosts.len() {
0 => Ok(None),
1 => Ok(hosts.pop()),
n => Err(CliError::new(
Failure::LocalState,
format!(
"this host's database records {n} hosts, and it should record one. It was \
probably copied from another machine. Point --data-dir at a fresh directory, \
or remove the database and run `runner-manager auth login` again."
),
)),
}
}
pub fn local_host_or_create(context: &Context, store: &dyn Store) -> Result<Host, CliError> {
match local_host(store)? {
Some(host) => Ok(host),
None => super::create_local_host(store, context.clock().as_ref()),
}
}
fn store_failure(source: StoreError) -> CliError {
CliError::with_remedy(
Failure::LocalState,
format!("cannot read this host's local database: {source}"),
"runner-manager host show",
)
}
pub fn dispatch(
context: &Context,
command: &HostCommand,
styling: Styling,
out: &mut dyn Write,
) -> Result<(), CliError> {
match command {
HostCommand::SetCapacity(args) => set_capacity(context, args, out),
HostCommand::SetRuntimeRoot(args) => runtime_root(context, Some(&args.path), styling, out),
HostCommand::ResetRuntimeRoot => runtime_root(context, None, styling, out),
HostCommand::Show => show(context, out),
HostCommand::Isolation(super::HostIsolationCommand::Status(args)) => {
isolation_status(args.json, out)
}
HostCommand::Env(command) => runner_env_command(context, command, out),
HostCommand::Doctor(args) => super::doctor::doctor(context, args, out),
HostCommand::Prepare(args) => super::doctor::prepare_command(context, args, out),
HostCommand::RequiredTools(args) => {
super::doctor::required_tools_command(context, args, out)
}
}
}
fn runner_env_failure(error: RunnerEnvError) -> CliError {
match error {
RunnerEnvError::Io { .. } => CliError::new(Failure::LocalState, error.to_string()),
_ => CliError::new(Failure::InvalidArgument, error.to_string()),
}
}
pub fn runner_env_command(
context: &Context,
command: &HostEnvCommand,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("the runner environment");
let path = runner_env::path_in(context.paths().config_dir());
let mut env = RunnerEnv::load(&path).map_err(|error| match error {
RunnerEnvError::Io { .. } => runner_env_failure(error),
_ => CliError::with_remedy(
Failure::InvalidArgument,
format!("{} cannot be used: {error}", path.display()),
format!("edit or delete that line in {}", path.display()),
),
})?;
match command {
HostEnvCommand::Show => {
write_runner_env(out, &path, &env).map_err(failed)?;
}
HostEnvCommand::Set(args) => {
let (name, value) =
runner_env::split_assignment(&args.assignment).map_err(runner_env_failure)?;
let replaced = env.set(name, value).map_err(runner_env_failure)?;
env.save(&path).map_err(runner_env_failure)?;
let verb = if replaced { "replaced" } else { "set" };
writeln!(
out,
"{name} {verb} in {}; native runners started from now on get it.",
path.display()
)
.map_err(failed)?;
}
HostEnvCommand::Unset(args) => {
if env.unset(&args.name) {
env.save(&path).map_err(runner_env_failure)?;
writeln!(
out,
"{} removed from {}; native runners started from now on do not get it.",
args.name,
path.display()
)
.map_err(failed)?;
} else {
writeln!(out, "{} is not set in {}.", args.name, path.display()).map_err(failed)?;
}
}
}
Ok(())
}
fn write_runner_env(out: &mut dyn Write, path: &Path, env: &RunnerEnv) -> io::Result<()> {
writeln!(out, "runner.env: {}", path.display())?;
if env.is_empty() {
writeln!(out, " (no variables set)")?;
}
for (name, value) in env.entries() {
writeln!(out, " {name}={value}")?;
}
writeln!(out)?;
writeln!(
out,
"Added by this platform unless runner.env sets the same name:"
)?;
match RunnerPlatform::current() {
RunnerPlatform::MacOs => {
writeln!(
out,
" PATH {} ahead of the service's PATH, those that exist",
runner_env::MACOS_TOOL_DIRECTORIES.join(", ")
)?;
writeln!(
out,
" LANG {} when the service has no locale",
runner_env::MACOS_DEFAULT_LANG
)?;
}
RunnerPlatform::Windows => {
writeln!(
out,
" {} a profile inside each runner's attempt, removed with it",
runner_env::WINDOWS_PROFILE_VARIABLES.join(", ")
)?;
writeln!(
out,
" (.NET known-folder APIs still report the service account's profile)"
)?;
}
RunnerPlatform::Linux => writeln!(out, " nothing")?,
}
writeln!(out)?;
writeln!(
out,
"Always per attempt and not changeable: {}, {}*",
runner_env::RESERVED_NAMES.join(", "),
runner_env::RESERVED_PREFIX
)?;
writeln!(
out,
"Isolated runners do not get these; their environment is the image's."
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IsolationBackend {
Auto,
Native,
Oci,
WindowsHyperVContainer,
VirtualMachine,
}
impl IsolationBackend {
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::Auto => "Automatic",
Self::Native => "Native",
Self::Oci => "OCI",
Self::WindowsHyperVContainer => "Hyper-V container",
Self::VirtualMachine => "Virtual machine",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IsolationReadiness {
Ready,
NotInstalled,
Unsupported,
PermissionDenied,
ImageUnavailableOrIncompatible,
DiskQuotaUnavailable,
Degraded,
}
impl IsolationReadiness {
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::NotInstalled => "not installed",
Self::Unsupported => "unsupported",
Self::PermissionDenied => "permission denied",
Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
Self::DiskQuotaUnavailable => "disk quota unavailable",
Self::Degraded => "degraded",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct IsolationCapability {
pub backend: IsolationBackend,
pub state: IsolationReadiness,
pub remedy: Option<&'static str>,
pub support_notice: Option<&'static str>,
pub unsupported_workflow_capabilities: &'static [&'static str],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct IsolationObservation<'a> {
pub backend: IsolationBackend,
pub state: IsolationReadiness,
pub raw_output: Option<&'a str>,
}
#[must_use]
pub(crate) const fn sanitize_isolation_observation(
observation: IsolationObservation<'_>,
) -> IsolationCapability {
let _ = observation.raw_output;
let remedy = match (observation.backend, observation.state) {
(_, IsolationReadiness::Ready) => None,
(IsolationBackend::Oci, IsolationReadiness::NotInstalled) => {
Some("install and configure an OCI execution provider")
}
(IsolationBackend::Oci, IsolationReadiness::DiskQuotaUnavailable) => {
Some("configure the required bounded OCI storage quota")
}
(IsolationBackend::WindowsHyperVContainer, IsolationReadiness::Unsupported) => {
Some("use a supported host and provider")
}
(IsolationBackend::VirtualMachine, IsolationReadiness::NotInstalled) => {
Some("install and configure a virtual machine execution provider")
}
_ => Some("install and configure an integrated isolation provider"),
};
IsolationCapability {
backend: observation.backend,
state: observation.state,
remedy,
support_notice: if matches!(
observation.backend,
IsolationBackend::WindowsHyperVContainer
) {
Some(
"preview: native acceptance is pending for Windows 11 Pro/Enterprise with Docker Desktop and Windows Server Standard/Datacenter with a supported server runtime, including job, restart, reboot, and resource-exhaustion cases",
)
} else {
None
},
unsupported_workflow_capabilities: if matches!(
observation.backend,
IsolationBackend::WindowsHyperVContainer
) {
&[
"desktop",
"devices",
"container_actions",
"service_containers",
]
} else {
&[]
},
}
}
fn windows_hyper_v_capability() -> IsolationCapability {
#[cfg(test)]
let host = WindowsHyperVHostState::UnsupportedHost;
#[cfg(not(test))]
let host = WindowsHyperVContainers::host_state();
let state = readiness_from_provider_capability(host.capability());
let mut capability = sanitize_isolation_observation(IsolationObservation {
backend: IsolationBackend::WindowsHyperVContainer,
state,
raw_output: None,
});
capability.remedy = host.remedy();
capability
}
const fn readiness_from_provider_capability(capability: ProviderCapability) -> IsolationReadiness {
match capability {
ProviderCapability::Ready => IsolationReadiness::Ready,
ProviderCapability::Unsupported => IsolationReadiness::Unsupported,
ProviderCapability::NotInstalled => IsolationReadiness::NotInstalled,
ProviderCapability::PermissionDenied => IsolationReadiness::PermissionDenied,
ProviderCapability::ImageUnavailableOrIncompatible => {
IsolationReadiness::ImageUnavailableOrIncompatible
}
ProviderCapability::DiskQuotaUnavailable => IsolationReadiness::DiskQuotaUnavailable,
ProviderCapability::Degraded => IsolationReadiness::Degraded,
}
}
#[must_use]
pub fn isolation_capabilities() -> Vec<IsolationCapability> {
let capability = |backend, state| {
sanitize_isolation_observation(IsolationObservation {
backend,
state,
raw_output: None,
})
};
vec![
capability(IsolationBackend::Native, IsolationReadiness::Ready),
capability(IsolationBackend::Oci, IsolationReadiness::NotInstalled),
windows_hyper_v_capability(),
capability(
IsolationBackend::VirtualMachine,
IsolationReadiness::NotInstalled,
),
]
}
pub fn isolation_status(json: bool, out: &mut dyn Write) -> Result<(), CliError> {
let providers = isolation_capabilities();
let document = serde_json::json!({
"schema_version": 1,
"providers": providers,
});
if json {
writeln!(
out,
"{}",
serde_json::to_string_pretty(&document)
.map_err(|error| CliError::new(Failure::LocalState, error.to_string()))?
)
.map_err(write_failed("this provider status"))?;
} else {
for provider in providers {
writeln!(
out,
"{} provider: {}",
provider.backend.display_name(),
provider.state.display_name()
)
.map_err(write_failed("this provider status"))?;
if let Some(remedy) = provider.remedy {
writeln!(out, " remedy: {remedy}")
.map_err(write_failed("this provider status"))?;
}
if let Some(notice) = provider.support_notice {
writeln!(out, " support: {notice}")
.map_err(write_failed("this provider status"))?;
}
if !provider.unsupported_workflow_capabilities.is_empty() {
writeln!(
out,
" unsupported workflow capabilities: {}",
provider.unsupported_workflow_capabilities.join(", ")
)
.map_err(write_failed("this provider status"))?;
}
}
writeln!(out, "Provider readiness is checked again, including the pinned image, before JIT registration.")
.map_err(write_failed("this provider status"))?;
}
Ok(())
}
pub fn runtime_root(
context: &Context,
raw: Option<&str>,
styling: Styling,
out: &mut dyn Write,
) -> Result<(), CliError> {
let store = context.store()?;
let root = raw
.map(|raw| workspace::parse_root(raw, &RootOwner::Host))
.transpose()?;
let change = workspace::set_host_runner_root(context, &store, root)?;
workspace::write_root_change(out, &change)?;
let Some(warning) = &change.service_access else {
return Ok(());
};
let opened = super::open_in_browser(
runner_manager_platform::os::FULL_DISK_ACCESS_SETTINGS_URL,
Styling::for_stdout(),
);
workspace::write_service_access_warning(out, styling, warning, opened)
}
pub fn set_capacity(
context: &Context,
args: &HostSetCapacityArgs,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this host's new capacity");
let capacity = NonZeroU16::new(args.capacity).ok_or_else(|| {
CliError::with_remedy(
Failure::InvalidArgument,
"a host capacity of 0 is not a configured host, it is a disabled one. Set at \
least 1, or drain the policies you do not want running.",
"runner-manager host set-capacity 1",
)
})?;
let store = context.store()?;
let mut host = local_host_or_create(context, &store)?;
let previous = host.host_capacity();
host.host_capacity = capacity;
store.put_host(&host).map_err(store_failure)?;
let attempts = store.attempts().map_err(store_failure)?;
let allocator = HostAllocator::from_attempts(&host, attempts.iter());
let in_use = allocator.active_total();
writeln!(
out,
"host_capacity: {previous} -> {} (in use right now: {in_use})",
capacity.get()
)
.map_err(failed)?;
if in_use > capacity.get() {
writeln!(out).map_err(failed)?;
writeln!(
out,
"This host already holds {in_use} runner attempts, which is more than the ceiling\n\
you just set. Nothing was terminated: a busy runner is never stopped to scale\n\
down. No new attempt will start until the total falls below {}.",
capacity.get()
)
.map_err(failed)?;
}
Ok(())
}
pub fn show(context: &Context, out: &mut dyn Write) -> Result<(), CliError> {
let failed = write_failed("this host's settings");
let store = context.store()?;
let host = local_host(&store)?;
match &host {
None => {
writeln!(
out,
"This machine has no host record yet, so the values below are the defaults a\n\
host would be created with. `host set-capacity` creates one."
)
.map_err(failed)?;
writeln!(out).map_err(failed)?;
}
Some(host) => {
writeln!(
out,
"Host: {} ({} {})",
host.display_name, host.os, host.architecture
)
.map_err(failed)?;
writeln!(out, " id {}", host.id).map_err(failed)?;
}
}
let start_mode = host
.as_ref()
.map_or_else(StartMode::default, |h| h.service_start_mode);
let interval = host
.as_ref()
.map_or_else(RefreshInterval::default, |h| h.refresh_interval);
let capacity = host
.as_ref()
.map_or(super::DEFAULT_HOST_CAPACITY, Host::host_capacity);
let attempts = store.attempts().map_err(store_failure)?;
let in_use = match &host {
Some(host) => HostAllocator::from_attempts(host, attempts.iter()).active_total(),
None => 0,
};
writeln!(out, " host_capacity {capacity}").map_err(failed)?;
writeln!(out, " in use across policies {in_use}").map_err(failed)?;
writeln!(
out,
" headroom {}",
capacity.saturating_sub(in_use)
)
.map_err(failed)?;
writeln!(out, " service start mode {start_mode}").map_err(failed)?;
let runner_root = workspace::host_root(context.paths(), host.as_ref());
let affected = workspace::host_affected_attempts(&store)?;
writeln!(
out,
" runner root {}",
runner_root.rendered()
)
.map_err(failed)?;
writeln!(out, " runner root source {}", runner_root.source()).map_err(failed)?;
writeln!(out, " active ephemeral paths {}", affected.active).map_err(failed)?;
writeln!(
out,
" cleanup-blocked paths {}",
affected.cleanup_blocked
)
.map_err(failed)?;
let env_path = runner_env::path_in(context.paths().config_dir());
let env_state = match RunnerEnv::load(&env_path) {
Ok(env) => format!("{} variable(s); `host env show` lists them", env.len()),
Err(error) => format!("unusable, so no runner starts: {error}"),
};
writeln!(out, " runner env file {}", env_path.display()).map_err(failed)?;
writeln!(out, " runner env {env_state}").map_err(failed)?;
let secrets = context.secret_store(start_mode)?;
writeln!(
out,
" secret store {}-scoped",
secrets.scope()
)
.map_err(failed)?;
writeln!(out, " store location {}", secrets.location()).map_err(failed)?;
match secrets.protection() {
Ok(protection) => {
writeln!(out, " protected by {protection}").map_err(failed)?;
}
Err(source) => {
writeln!(
out,
" protected by not readable yet ({source})"
)
.map_err(failed)?;
}
}
writeln!(out).map_err(failed)?;
let targets: Vec<ScaleTarget> = store
.policies()
.map_err(store_failure)?
.into_iter()
.map(|policy| policy.target)
.collect();
HostBudget::of(interval, &targets)
.write(out)
.map_err(failed)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use runner_manager_domain::model::{Org, OwnerRepo};
use runner_manager_github::demand::{self, max_demand_requests_per_repository_per_poll};
use runner_manager_github::rest::{
ACTIVITY_REQUESTS_PER_REPOSITORY_PER_REFRESH, ActivityScope, MAX_ACTIVITY_FALLBACK_PAGES,
};
fn repository(slug: &str) -> ScaleTarget {
ScaleTarget::Repository(OwnerRepo::parse(slug).expect("a valid slug"))
}
fn organization(name: &str) -> ScaleTarget {
ScaleTarget::Organization(Org::new(name).expect("a valid organization"))
}
#[test]
fn isolation_status_keeps_windows_client_and_server_requirements_distinct() {
let mut text = Vec::new();
isolation_status(false, &mut text).expect("text status");
let text = String::from_utf8(text).expect("UTF-8 status");
assert!(
text.contains(
"use Windows 11 Pro or Enterprise with Docker Desktop in Windows-container mode"
),
"{text}"
);
assert!(
text.contains(
"Windows Server Standard or Datacenter with Moby or Mirantis Container Runtime"
),
"{text}"
);
assert!(!text.contains("Education"), "{text}");
let mut json = Vec::new();
isolation_status(true, &mut json).expect("JSON status");
let document: serde_json::Value =
serde_json::from_slice(&json).expect("parseable JSON status");
let windows = document["providers"]
.as_array()
.unwrap()
.iter()
.find(|provider| provider["backend"] == "windows_hyper_v_container")
.expect("Windows provider");
assert_eq!(windows["state"], "unsupported");
assert!(
windows["remedy"]
.as_str()
.unwrap()
.starts_with("use Windows 11 Pro or Enterprise")
);
assert!(!windows.to_string().contains("Education"));
}
#[test]
fn oci_disk_quota_refusal_remains_distinct_in_operator_status() {
assert_eq!(
readiness_from_provider_capability(ProviderCapability::DiskQuotaUnavailable),
IsolationReadiness::DiskQuotaUnavailable
);
let capability = sanitize_isolation_observation(IsolationObservation {
backend: IsolationBackend::Oci,
state: IsolationReadiness::DiskQuotaUnavailable,
raw_output: Some("untrusted runtime output"),
});
assert_eq!(capability.state.display_name(), "disk quota unavailable");
assert_eq!(
capability.remedy,
Some("configure the required bounded OCI storage quota")
);
}
fn budget_text(budget: &HostBudget) -> String {
let mut rendered = Vec::new();
budget.write(&mut rendered).expect("writing to a Vec");
String::from_utf8(rendered).expect("ASCII")
}
#[test]
fn the_measured_cost_is_the_one_c4_reports() {
let repository = ActivityScope::repository(OwnerRepo::parse("o/r").unwrap());
assert_eq!(
measured(TargetCost::from_activity_scope(&repository)),
demand::target_cost(&repository),
"the CLI must price a target the way `c4` reports it, through \
`TargetCost::with_demand_requests_per_repository`"
);
let org = ActivityScope::organization(
Org::new("acme").unwrap(),
[
OwnerRepo::parse("acme/one").unwrap(),
OwnerRepo::parse("acme/two").unwrap(),
],
);
assert_eq!(
measured(TargetCost::from_activity_scope(&org)),
demand::target_cost(&org)
);
}
#[test]
fn the_printed_ceiling_is_the_number_admit_actually_takes() {
for secs in [
RefreshInterval::MIN_SECS,
RefreshInterval::DEFAULT_SECS,
120,
] {
let interval = RefreshInterval::from_secs(secs).expect("at or above the floor");
let printed = max_repository_targets(interval);
let mut admitted = 0_u32;
let mut costs: Vec<TargetCost> = Vec::new();
loop {
let projection = BudgetProjection::new(interval, costs.clone());
let candidate = measured(TargetCost::repository());
if !projection.admit(candidate).is_admitted() {
break;
}
costs.push(candidate);
admitted += 1;
assert!(admitted < 10_000, "the loop must terminate");
}
assert_eq!(
printed, admitted,
"at a {interval} interval `host show` would print {printed} repository \
targets while `admit` takes {admitted}. Those two numbers appear in the \
same product, one in host settings and one in the refusal an operator gets \
when they add a target, and they must be the same number."
);
}
}
#[test]
fn c3s_ceiling_still_prices_demand_at_its_own_estimate() {
let interval = RefreshInterval::default();
assert_eq!(
TargetCost::repository().requests_per_hour(interval),
240,
"c3's estimate: 1 inventory + 1 activity + 2 demand, 60 times an hour"
);
assert_eq!(
measured(TargetCost::repository()).requests_per_hour(interval),
360,
"the measured cost: 1 inventory + 1 activity + 4 demand, 60 times an hour"
);
assert_eq!(
BudgetProjection::max_repository_targets(interval),
10,
"c3's printed ceiling, computed from its estimate"
);
assert_eq!(
max_repository_targets(interval),
6,
"this CLI's ceiling, computed from the cost `c4` actually issues"
);
assert!(
BudgetProjection::max_repository_targets(interval) > max_repository_targets(interval),
"c3's figure is the optimistic one now that demand is counted in jobs. \
`BUDGET_SHARE_DIVISOR` is what absorbs the difference; this file printing \
only its own number is what keeps an operator from seeing both."
);
}
#[test]
fn the_stated_fallback_multiple_is_the_one_the_gateways_can_spend() {
assert_eq!(
ACTIVITY_REQUESTS_PER_REPOSITORY_PER_REFRESH, 1,
"the projection prices the activity count at one request"
);
let activity_multiple = u32::try_from(MAX_ACTIVITY_FALLBACK_PAGES).unwrap()
/ ACTIVITY_REQUESTS_PER_REPOSITORY_PER_REFRESH;
assert_eq!(
DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL, 4,
"the projection prices the demand poll at its steady state: two run listings \
plus a job listing for each of the couple of runs a repository has under way"
);
let demand_multiple =
max_demand_requests_per_repository_per_poll() / DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL;
assert_eq!(
FALLBACK_COST_MULTIPLE,
activity_multiple.max(demand_multiple),
"the caveat states one multiple for both classes, so it must be the larger of \
the two: activity can spend {activity_multiple}x its price and demand \
{demand_multiple}x its own"
);
}
#[test]
fn the_target_ceiling_is_never_presented_as_exact() {
let budget = HostBudget::of(RefreshInterval::default(), &[repository("o/r")]);
let rendered = budget_text(&budget);
let ceiling = budget.max_repository_targets();
assert!(
rendered.contains(&format!("about {ceiling}")),
"the ceiling must be hedged where it is printed: {rendered}"
);
assert!(
rendered.contains("BEST-CASE"),
"the caveat must be in the same output as the number: {rendered}"
);
assert!(
rendered.contains(&format!("{FALLBACK_COST_MULTIPLE}x")),
"the caveat must state by how much the best case can be exceeded: {rendered}"
);
assert!(
rendered.contains("not as a threshold"),
"the caveat must say what the number is not: {rendered}"
);
}
#[test]
fn an_empty_host_projects_nothing_and_has_the_whole_allowance() {
let budget = HostBudget::of(RefreshInterval::default(), &[]);
assert_eq!(budget.requests_per_hour(), 0);
assert_eq!(budget.headroom(), budget_allowance());
assert!(!budget.exceeds_allowance());
assert!(!budget.is_floor(), "no organization target, no floor");
}
#[test]
fn each_repository_target_costs_the_measured_amount() {
let interval = RefreshInterval::default();
let one = HostBudget::of(interval, &[repository("o/one")]);
let three = HostBudget::of(
interval,
&[
repository("o/one"),
repository("o/two"),
repository("o/three"),
],
);
assert_eq!(one.requests_per_hour(), 360);
assert_eq!(three.requests_per_hour(), 1_080);
assert!(
budget_text(&three).contains("targets priced 3"),
"the output must say how many targets the total covers, or an operator cannot tell an under-count from a cheap set"
);
}
#[test]
fn equal_profile_targets_are_charged_once_even_with_case_variants() {
let interval = RefreshInterval::default();
let targets = [
repository("o/one"),
repository("O/ONE"),
repository("o/two"),
organization("acme"),
organization("ACME"),
];
let budget = HostBudget::of(interval, &targets);
let unique = HostBudget::of(
interval,
&[
repository("o/one"),
repository("o/two"),
organization("acme"),
],
);
assert_eq!(budget.requests_per_hour(), unique.requests_per_hour());
assert_eq!(budget.headroom(), unique.headroom());
assert!(budget_text(&budget).contains("targets priced 3"));
}
#[test]
fn an_organization_target_makes_the_total_a_floor_and_says_so() {
let budget = HostBudget::of(RefreshInterval::default(), &[organization("acme")]);
assert!(budget.is_floor());
let rendered = budget_text(&budget);
assert!(rendered.contains("(a floor)"), "{rendered}");
assert!(
rendered.contains("priced as one repository"),
"the output must say why it is a floor, not merely that it is: {rendered}"
);
let repositories_only = HostBudget::of(RefreshInterval::default(), &[repository("o/r")]);
assert!(
!budget_text(&repositories_only).contains("(a floor)"),
"a set with no organization target must not be labelled a floor, or the label \
means nothing"
);
}
#[test]
fn halving_the_interval_doubles_the_spend_and_lowers_the_ceiling() {
let default = RefreshInterval::default();
let floor = RefreshInterval::from_secs(RefreshInterval::MIN_SECS).unwrap();
let targets = [repository("o/r")];
assert_eq!(
HostBudget::of(floor, &targets).requests_per_hour(),
2 * HostBudget::of(default, &targets).requests_per_hour()
);
assert!(
max_repository_targets(floor) < max_repository_targets(default),
"a 30-second interval must fit fewer targets than a 60-second one: {} against {}",
max_repository_targets(floor),
max_repository_targets(default)
);
assert_eq!(max_repository_targets(floor), 3);
assert_eq!(max_repository_targets(default), 6);
}
#[test]
fn a_host_over_its_allowance_says_so_rather_than_only_showing_a_zero_headroom() {
let interval = RefreshInterval::from_secs(RefreshInterval::MIN_SECS).unwrap();
let targets: Vec<ScaleTarget> = (0..20)
.map(|n| repository(&format!("owner/repo{n}")))
.collect();
let budget = HostBudget::of(interval, &targets);
assert!(budget.exceeds_allowance());
assert_eq!(budget.headroom(), 0);
let rendered = budget_text(&budget);
assert!(rendered.contains("OVER BUDGET"), "{rendered}");
}
#[test]
fn a_target_set_at_the_printed_ceiling_still_fits() {
let interval = RefreshInterval::default();
let ceiling = max_repository_targets(interval);
let targets: Vec<ScaleTarget> = (0..ceiling)
.map(|n| repository(&format!("owner/repo{n}")))
.collect();
let budget = HostBudget::of(interval, &targets);
assert!(
!budget.exceeds_allowance(),
"the printed ceiling promised {ceiling} targets fit, and the projection says \
{} requests/hour against an allowance of {}",
budget.requests_per_hour(),
budget.allowance()
);
let one_more: Vec<ScaleTarget> = (0..=ceiling)
.map(|n| repository(&format!("owner/repo{n}")))
.collect();
assert!(
HostBudget::of(interval, &one_more).exceeds_allowance(),
"and one target past the ceiling must not fit, or the ceiling is not a ceiling"
);
}
}