use std::io::{self, Write};
use std::num::NonZeroU16;
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_root::RootOwner;
use super::workspace;
use super::{CliError, Context, Failure, HostCommand, 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_policies: usize,
}
impl HostBudget {
#[must_use]
pub fn of(interval: RefreshInterval, targets: &[ScaleTarget]) -> Self {
let mut costs = Vec::with_capacity(targets.len());
let mut organization_targets = 0;
for target in targets {
costs.push(measured(match target {
ScaleTarget::Repository(_) => TargetCost::repository(),
ScaleTarget::Organization(_) => {
organization_targets += 1;
TargetCost::organization(1)
}
}));
}
Self {
interval,
projection: BudgetProjection::new(interval, costs),
organization_targets,
priced_policies: targets.len(),
}
}
#[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, " policies priced {}", self.priced_policies)?;
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 policy is priced as if it were polling, whatever its"
)?;
writeln!(
out,
" state, so this total is never an under-estimate of the set you have."
)?;
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),
}
}
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 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"))
}
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("policies priced 3"),
"the output must say how many policies the total covers, or an operator cannot tell an under-count from a cheap set"
);
}
#[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"
);
}
}