use std::collections::BTreeSet;
use std::future::Future;
use std::io::Write;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[cfg(test)]
use runner_manager_agent::lifecycle::NativeProcesses;
use runner_manager_agent::lifecycle::{
CachedRuntimePackages, LifecycleGithub, LifecycleGithubObservation, LifecycleLauncher,
LifecyclePorts, PersistentDemand, PlatformExecutionProvider, RetryPolicy, TokioRetryDelay,
TracingAttemptEvents,
};
use runner_manager_agent::package::{
CachePorts, ExponentialBackoff, GatewayCatalog, HttpFetcher, PackageCache,
};
use runner_manager_agent::reconcile::{
AllocationLock, FileAllocationLock, FitnessGatedLauncher, GatewayDemand, RandomJitter,
ReconcileReport, Reconciler, ReconcilerPorts, RepositoryDirectory, TeeEvents, TracingEvents,
WslRecoveryAllocationLock,
};
use runner_manager_domain::attempt::{AttemptState, FailureReason, active_count, active_count_for};
use runner_manager_domain::model::{AttemptId, Clock, Org, OwnerRepo, ScaleTarget, StartMode};
use runner_manager_domain::policy::{PolicyState, ScalePolicy};
use runner_manager_domain::store::Store;
use runner_manager_github::demand::RestDemand;
use runner_manager_github::device_flow::DeviceFlow;
use runner_manager_github::jit::{JitError, JitGateway, JitRunnerRequest, RestJit};
use runner_manager_github::rest::{
CancelToken, InventoryError, InventoryGateway, RefreshState, RestInventory,
};
use runner_manager_github::{
AppRegistration, AuthenticatedClient, CredentialRenewal, GithubError, UserAccessToken,
};
use runner_manager_platform::lock::{HostLock, LockError, LockKind};
use runner_manager_platform::service::{
InstallRecord, clear_credential_unreadable, clear_github_credential_rejection,
record_credential_unreadable, record_github_contact, record_github_credential_rejected,
sign_in_instruction,
};
use runner_manager_platform::wsl::fence::{
DrainRequest, GuestHeartbeat, GuestRecoveryConfig,
SCHEMA_VERSION as WSL_RECOVERY_SCHEMA_VERSION, unmanaged_runner_service_count,
};
use runner_manager_platform::secrets::SecretStore;
use secrecy::{ExposeSecret as _, SecretString};
use super::{CliError, Context, DaemonCommand, Failure, write_failed};
pub fn dispatch(
context: &Context,
command: &DaemonCommand,
out: &mut dyn Write,
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
) -> Result<(), CliError> {
match command {
DaemonCommand::Run(args) => {
#[cfg(windows)]
if std::env::var_os(super::service::SUPERVISED_ENVIRONMENT).is_none() {
match super::service::migrate_legacy_windows_login_registration(context, out) {
Ok(true) => return Ok(()),
Ok(false) => {}
Err(error) => tracing::warn!(
%error,
"the legacy Windows login task could not start its restart supervisor; continuing under Task Scheduler"
),
}
}
let runtime = super::runtime()?;
runtime.block_on(run(
context,
out,
service_shutdown,
args.windows_service_host,
))
}
DaemonCommand::AdoptCredential(args) => {
super::auth::adopt_credential(context, args.start_at, out)
}
}
}
async fn run(
context: &Context,
out: &mut dyn Write,
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
windows_service_host: bool,
) -> Result<(), CliError> {
loop {
#[cfg(target_os = "macos")]
if converge_launchd_definition(context, out, service_shutdown.clone()).await? {
return Ok(());
}
match run_generation(context, out, service_shutdown.clone(), windows_service_host).await? {
DaemonOutcome::Stopped => return Ok(()),
DaemonOutcome::Reload => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DaemonOutcome {
Stopped,
Reload,
}
async fn run_generation(
context: &Context,
out: &mut dyn Write,
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
windows_service_host: bool,
) -> Result<DaemonOutcome, CliError> {
let instance = acquire_instance(context)?;
let _heartbeat = Heartbeat::start(context.paths().clone());
reset_launch_state(context);
let store = Arc::new(context.store()?);
let host = super::host::local_host_or_create(context, store.as_ref())?;
let targets = active_autoscale_targets(store.policies().map_err(local_store_failure)?);
let failed = write_failed("the daemon state");
let own_binary = InstallRecord::read(context.paths())
.ok()
.flatten()
.and_then(|record| record.source_binary);
writeln!(out, "daemon running (pid {})", std::process::id()).map_err(failed)?;
if targets.is_empty() {
let heartbeat_store = Arc::clone(&store) as Arc<dyn Store>;
let heartbeat_paths = context.paths().clone();
tokio::select! {
signal = wait_for_shutdown(service_shutdown) => {
signal.map_err(signal_failure)?;
writeln!(out, "daemon stopped; no runner was terminated").map_err(failed)?;
return Ok(DaemonOutcome::Stopped);
}
version = async {
match own_binary.clone() {
Some(path) => wait_for_upgrade(path).await,
None => std::future::pending().await,
}
} => {
return stop_for_upgrade(context, host.service_start_mode, own_binary.as_deref(), &version, out)
.map(|()| DaemonOutcome::Stopped);
}
() = wait_for_policy_set_change(Arc::clone(&store) as Arc<dyn Store>, BTreeSet::new()) => {
writeln!(out, "a repository policy was added; reloading").map_err(failed)?;
return Ok(DaemonOutcome::Reload);
}
() = maintain_idle_credential(context, host.service_start_mode) => {
unreachable!("credential maintenance runs until the daemon is stopped")
},
() = answer_github_requests(context, host.service_start_mode) => {
unreachable!("GitHub requests are answered until the daemon is stopped")
},
() = maintain_diagnostics(context.paths().logs_dir().to_path_buf()) => {
unreachable!("diagnostics maintenance runs until the daemon is stopped")
},
() = maintain_docker_cleanup(
runner_manager_agent::docker_cleanup::DockerCleanup::new(host.id, context.paths().config_dir(), context.paths().state_dir()),
Arc::clone(&store) as Arc<dyn Store>,
) => {
unreachable!("Docker cleanup maintenance runs until the daemon is stopped")
},
() = maintain_dependency_caches(
super::cache::daemon_caches(context, Arc::clone(&store) as Arc<dyn Store>, host.id),
context.paths().state_dir().to_path_buf(),
false,
) => {
unreachable!("dependency cache maintenance runs until the daemon is stopped")
},
() = maintain_wsl_guest_heartbeat(heartbeat_paths, heartbeat_store) => {
unreachable!("WSL heartbeat maintenance runs until the daemon is stopped")
},
() = maintain_wsl_recovery_without_local_policies(
context,
host.service_start_mode,
windows_service_host,
) => {
unreachable!("WSL recovery watchdog runs until the daemon is stopped")
},
}
}
let mode = host.service_start_mode;
let secrets = context.secret_store(mode)?;
let loaded = secrets.load();
note_credential_readability(context, loaded.is_ok());
let secret = loaded
.map_err(|source| {
CliError::with_remedy(
Failure::SecretStore,
format!("cannot read the stored GitHub credential: {source}"),
sign_in_instruction(mode),
)
})?
.ok_or_else(|| {
CliError::with_remedy(
Failure::NotAuthenticated,
"no GitHub credential is stored for this daemon's start mode",
sign_in_instruction(mode),
)
})?;
let app = context.app_registration()?;
let renewal: Arc<dyn CredentialRenewal> = Arc::new(super::auth::StoringRenewal::new(
DeviceFlow::new(app.clone(), context.endpoints().clone()).map_err(|source| {
CliError::new(
Failure::GithubUnavailable,
format!("cannot prepare credential renewal: {source}"),
)
})?,
Arc::clone(&secrets),
context
.paths()
.state_dir()
.join(LockKind::CredentialRenewal.file_name()),
));
let client = Arc::new(
AuthenticatedClient::new(
context.endpoints().clone(),
UserAccessToken::from_stored(secret),
context.clock(),
)
.map_err(github_failure)?
.with_renewal(renewal)
.with_credential_source(Arc::new(super::auth::StoredCredential::new(Arc::clone(
&secrets,
)))),
);
let clock = context.clock();
let inventory = Arc::new(RestInventory::new(Arc::clone(&client), Arc::clone(&clock)));
let wsl_inventory = Arc::clone(&inventory) as Arc<dyn InventoryGateway>;
let jit = Arc::new(RestJit::new(Arc::clone(&client)));
let lifecycle_github = Arc::new(GithubLifecycle {
jit,
inventory: Arc::clone(&inventory),
clock: Arc::clone(&clock),
});
let directory = Arc::new(GithubDirectory {
client: Arc::clone(&client),
app,
});
let paths = Arc::new(context.paths().clone());
let events: Arc<dyn runner_manager_agent::reconcile::EventSink> = Arc::new(TeeEvents(
Arc::new(TracingEvents),
Arc::new(runner_manager_agent::reconcile::EventLog::new()),
));
let base_lock: Arc<dyn AllocationLock> = Arc::new(FileAllocationLock::new(Arc::clone(&paths)));
let shared_lock = Arc::new(WslRecoveryAllocationLock::new(
Arc::clone(&paths),
base_lock,
));
let host_unfit = Arc::new(AtomicBool::new(false));
observe_host_fitness(context, &host_unfit, true).await;
let dependency_caches =
super::cache::daemon_caches(context, Arc::clone(&store) as Arc<dyn Store>, host.id);
let intervals = match super::polling::configured(context, store.as_ref()) {
Ok((intervals, None)) => intervals,
Ok((intervals, Some(reason))) => {
tracing::warn!(%reason, "the idle poll interval could not be read; using the default");
intervals
}
Err(error) => {
tracing::warn!(%error, "the poll intervals could not be read; using the defaults");
super::polling::Intervals::with_default_idle(host.refresh_interval)
}
};
let demand_gateway = Arc::new(RestDemand::new(Arc::clone(&client), Arc::clone(&clock)));
let polling = Arc::new(super::polling::PollingMonitor::new(
context.paths().clone(),
Arc::clone(&store) as Arc<dyn Store>,
host.id,
Arc::clone(&demand_gateway),
Arc::clone(&clock),
intervals,
));
let mut managed_targets = Vec::with_capacity(targets.len());
for policies in targets {
let package_target = policies[0].target.clone();
let catalog = Arc::new(GatewayCatalog::new(
RestInventory::new(Arc::clone(&client), Arc::clone(&clock)),
package_target.clone(),
));
let cache = Arc::new(PackageCache::new(
context.paths(),
host.os,
host.architecture,
CachePorts {
catalog,
fetcher: Arc::new(HttpFetcher::default()),
backoff: Arc::new(ExponentialBackoff::default()),
clock: Arc::clone(&clock),
},
));
let lifecycle_store = Arc::new(TargetRecoveryStore::new(
Arc::clone(&store) as Arc<dyn Store>,
&policies,
));
let launcher = Arc::new(LifecycleLauncher::new(
host.id,
context.paths().clone(),
context.paths().logs_dir(),
1,
runner_manager_domain::attempt::RecoveryTimeouts::provisional(),
RetryPolicy::bounded(3, Duration::from_secs(2), Duration::from_secs(30)),
LifecyclePorts {
store: Arc::clone(&lifecycle_store) as Arc<dyn Store>,
github: Arc::clone(&lifecycle_github) as Arc<dyn LifecycleGithub>,
packages: Arc::new(CachedRuntimePackages::new(cache)),
processes: Arc::new(
PlatformExecutionProvider::new(host.id)
.with_runner_env_file(runner_manager_platform::runner_env::path_in(
context.paths().config_dir(),
))
.with_dependency_caches(dependency_caches.clone())
.with_docker_cleanup(
runner_manager_agent::docker_cleanup::DockerCleanup::new(
host.id,
context.paths().config_dir(),
context.paths().state_dir(),
),
),
),
clock: Arc::clone(&clock),
demand: Arc::new(PersistentDemand),
delay: Arc::new(TokioRetryDelay),
events: Arc::new(TracingAttemptEvents),
reconcile_events: Arc::clone(&events),
},
));
launcher
.recover_startup(&policies)
.await
.map_err(|source| {
CliError::new(
Failure::LocalState,
format!(
"startup recovery for {} did not complete: {source}",
package_target
),
)
})?;
lifecycle_store.finish_recovery();
let cancel = CancelToken::new();
let demand = Arc::new(GatewayDemand::new(
Arc::clone(&demand_gateway),
cancel.clone(),
));
managed_targets.push(ManagedTarget {
policies,
store: Arc::clone(&store) as Arc<dyn Store>,
reconciler: Reconciler::new(
host.clone(),
ReconcilerPorts {
demand,
launcher: Arc::new(FitnessGatedLauncher::new(
launcher,
Arc::clone(&host_unfit),
)),
lock: Arc::clone(&shared_lock) as Arc<_>,
directory: Arc::clone(&directory) as Arc<_>,
clock: Arc::clone(&clock),
jitter: Arc::new(RandomJitter),
events: Arc::clone(&events),
},
),
cancel,
polling: Arc::clone(&polling),
});
}
let (shutdown, _) = tokio::sync::watch::channel(false);
let (upgrade, _) = tokio::sync::watch::channel(false);
let mut loops = tokio::task::JoinSet::new();
let contacts: Arc<dyn ContactRecorder> = Arc::new(FileContactRecorder {
paths: context.paths().clone(),
clock: Arc::clone(&clock),
write: Mutex::new(None),
});
let served: BTreeSet<String> = managed_targets
.iter()
.filter_map(|target| target.policies.first())
.map(|policy| policy.target.to_string())
.collect();
for target in managed_targets {
loops.spawn(run_target_loop(
target,
shutdown.subscribe(),
upgrade.subscribe(),
Arc::clone(&contacts),
));
}
let mut upgraded_to = None;
let mut restart_reason: Option<&'static str> = None;
let heartbeat_store = Arc::clone(&store) as Arc<dyn Store>;
let heartbeat_paths = context.paths().clone();
let watchdog_paths = context.paths().clone();
let early = tokio::select! {
signal = wait_for_shutdown(service_shutdown.clone()) => {
signal.map_err(signal_failure)?;
None
}
version = async {
match own_binary.clone() {
Some(path) => wait_for_upgrade(path).await,
None => std::future::pending().await,
}
} => {
writeln!(
out,
"a newer runner-manager ({version}) was installed; finishing every running job before handing over"
)
.map_err(failed)?;
tracing::info!(
version = %version,
"a newer binary was installed; draining before restart"
);
upgraded_to = Some(version);
None
}
() = wait_for_policy_set_change(Arc::clone(&store) as Arc<dyn Store>, served) => {
writeln!(
out,
"the set of repositories this host serves changed; finishing every running job before reloading"
)
.map_err(failed)?;
tracing::info!("the policy set changed; draining before restart");
restart_reason = Some("the set of repositories this host serves changed");
None
}
() = maintain_credential(Arc::clone(&client)) => {
unreachable!("credential maintenance runs until the daemon is stopped")
}
() = maintain_host_fitness(context, Arc::clone(&host_unfit)) => {
unreachable!("host fitness checks run until the daemon is stopped")
}
() = maintain_wsl_guest_heartbeat(heartbeat_paths, heartbeat_store) => {
unreachable!("WSL heartbeat maintenance runs until the daemon is stopped")
}
() = maintain_dependency_caches(
dependency_caches.clone(),
context.paths().state_dir().to_path_buf(),
true,
) => {
unreachable!("dependency cache pruning runs until the daemon is stopped")
}
() = answer_github_requests(context, mode) => {
unreachable!("GitHub requests are answered until the daemon is stopped")
}
() = maintain_diagnostics(context.paths().logs_dir().to_path_buf()) => {
unreachable!("diagnostics maintenance runs until the daemon is stopped")
}
() = maintain_docker_cleanup(
runner_manager_agent::docker_cleanup::DockerCleanup::new(host.id, context.paths().config_dir(), context.paths().state_dir()),
Arc::clone(&store) as Arc<dyn Store>,
) => {
unreachable!("Docker cleanup maintenance runs until the daemon is stopped")
}
() = maintain_wsl_recovery(watchdog_paths, wsl_inventory, windows_service_host) => {
unreachable!("WSL recovery watchdog runs until the daemon is stopped")
}
result = loops.join_next() => result,
};
if upgraded_to.is_some() || restart_reason.is_some() {
let _ = upgrade.send(true);
while let Some(result) = loops.join_next().await {
result.map_err(|source| {
CliError::new(
Failure::LocalState,
format!("a daemon target loop failed: {source}"),
)
})??;
}
if let Some(version) = upgraded_to {
return stop_for_upgrade(context, mode, own_binary.as_deref(), &version, out)
.map(|()| DaemonOutcome::Stopped);
}
let reason = restart_reason.unwrap_or("this daemon was asked to reload");
writeln!(out, "every runner finished; reloading").map_err(failed)?;
tracing::info!(%reason, "reloading the daemon in-process after the drain");
drop(instance);
return Ok(DaemonOutcome::Reload);
}
let _ = shutdown.send(true);
if let Some(result) = early {
let outcome = result.map_err(|source| {
CliError::new(
Failure::LocalState,
format!("a daemon target loop failed: {source}"),
)
})?;
outcome?;
return Err(CliError::new(
Failure::LocalState,
"a daemon target loop stopped before shutdown",
));
}
while let Some(result) = loops.join_next().await {
result.map_err(|source| {
CliError::new(
Failure::LocalState,
format!("a daemon target loop failed: {source}"),
)
})??;
}
writeln!(out, "daemon stopped; no busy runner was terminated").map_err(failed)?;
Ok(DaemonOutcome::Stopped)
}
async fn maintain_wsl_recovery_without_local_policies(
context: &Context,
mode: StartMode,
windows_service_host: bool,
) {
if windows_service_host {
super::wsl_watchdog::maintain_lifecycle_tasks(context.paths().clone()).await;
return;
}
if !wsl_recovery_is_available(windows_service_host) {
std::future::pending::<()>().await;
return;
}
loop {
let inventory = context
.secret_store(mode)
.ok()
.and_then(|store| store.load().ok().flatten())
.and_then(|secret| {
AuthenticatedClient::new(
context.endpoints().clone(),
UserAccessToken::from_stored(secret),
context.clock(),
)
.ok()
})
.map(|client| {
Arc::new(RestInventory::new(Arc::new(client), context.clock()))
as Arc<dyn InventoryGateway>
});
if let Some(inventory) = inventory {
super::wsl_watchdog::maintain(context.paths().clone(), inventory).await;
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
async fn maintain_wsl_recovery(
paths: runner_manager_platform::paths::AppPaths,
inventory: Arc<dyn InventoryGateway>,
windows_service_host: bool,
) {
if windows_service_host {
super::wsl_watchdog::maintain_lifecycle_tasks(paths).await;
return;
}
if !wsl_recovery_is_available(windows_service_host) {
std::future::pending::<()>().await;
return;
}
super::wsl_watchdog::maintain(paths, inventory).await;
}
const fn wsl_recovery_is_available(windows_service_host: bool) -> bool {
cfg!(windows) && !windows_service_host
}
const WSL_GUEST_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
async fn maintain_docker_cleanup(
cleanup: runner_manager_agent::docker_cleanup::DockerCleanup,
store: Arc<dyn Store>,
) {
if !runner_manager_agent::docker_cleanup::DockerCleanup::supported() {
std::future::pending::<()>().await;
}
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
let cleanup = cleanup.clone();
let store = Arc::clone(&store);
let result = tokio::task::spawn_blocking(move || {
cleanup.sweep_with_lookup(
&|id| store.attempt(id).map_err(std::io::Error::other),
false,
)
})
.await;
match result {
Ok(Ok(report))
if report.candidates > 0 || report.pending > 0 || report.protected > 0 =>
{
tracing::info!(
event = "docker_cleanup",
count = report.removed,
reason = "attempt_scoped",
"candidates={} pending={} protected={}",
report.candidates,
report.pending,
report.protected
);
}
Ok(Ok(_)) => {}
_ => tracing::warn!(
event = "docker_cleanup_failed",
"Docker cleanup deferred; no unverified resources were selected"
),
}
}
}
async fn maintain_diagnostics(directory: std::path::PathBuf) {
let mut interval = tokio::time::interval(Duration::from_secs(60 * 60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
let directory = directory.clone();
match tokio::task::spawn_blocking(move || {
runner_manager_platform::log_retention::prune_attempt_logs(
&directory,
std::time::SystemTime::now(),
)
})
.await
{
Ok(Ok(report)) if report.failed == 0 => {
if report.removed > 0 {
tracing::info!(event = "diagnostics_pruned", count = report.removed);
}
}
_ => tracing::warn!(
event = "diagnostics_prune_failed",
"some old attempt diagnostics could not be removed; retrying next hour"
),
}
}
}
async fn maintain_dependency_caches(
caches: runner_manager_agent::dependency_caches::DependencyCaches,
state_dir: std::path::PathBuf,
scheduled: bool,
) {
use runner_manager_platform::dependency_cache::{
PRUNE_INTERVAL, PRUNE_REQUEST_POLL, prune_request_path, take_prune_request,
};
let request_file = prune_request_path(&state_dir);
let mut next_scheduled = scheduled.then(std::time::Instant::now);
let mut pending: Option<String> = None;
let mut running: Option<tokio::task::JoinHandle<()>> = None;
loop {
if pending.is_none() && request_file.exists() {
pending = take_prune_request(&state_dir);
}
if running
.as_ref()
.is_some_and(tokio::task::JoinHandle::is_finished)
{
running = None;
}
if running.is_none() {
let pass = caches.clone();
if let Some(request) = pending.take() {
let state = state_dir.clone();
running = Some(tokio::task::spawn_blocking(move || {
pass.answer_prune_request(&state, request);
}));
} else if next_scheduled.is_some_and(|at| std::time::Instant::now() >= at) {
running = Some(tokio::task::spawn_blocking(move || pass.prune_scheduled()));
next_scheduled = Some(std::time::Instant::now() + PRUNE_INTERVAL);
}
}
tokio::time::sleep(PRUNE_REQUEST_POLL).await;
}
}
async fn answer_github_requests(context: &Context, mode: StartMode) {
use runner_manager_platform::service_request::{GITHUB_DISCOVERY, POLL};
let state_dir = context.paths().state_dir().to_path_buf();
const REUSE: Duration = Duration::from_secs(30);
let mut last: Option<(
std::time::Instant,
Result<super::auth::CredentialState, String>,
)> = None;
loop {
let requests = GITHUB_DISCOVERY.take(&state_dir);
if !requests.is_empty() {
let outcome = match &last {
Some((at, outcome)) if at.elapsed() < REUSE => outcome.clone(),
_ => {
let outcome = super::auth::credential_state_for_service(context, mode).await;
last = Some((std::time::Instant::now(), outcome.clone()));
outcome
}
};
for request in requests {
if let Err(error) = GITHUB_DISCOVERY.answer(&state_dir, &request, outcome.clone()) {
tracing::warn!(
%error,
reason = "github_request_unanswered",
"could not answer a command waiting for what the credential reaches"
);
}
}
}
tokio::time::sleep(POLL).await;
}
}
async fn maintain_wsl_guest_heartbeat(
paths: runner_manager_platform::paths::AppPaths,
store: Arc<dyn Store>,
) {
loop {
match GuestRecoveryConfig::read(&paths) {
Ok(Some(config)) => {
let request = DrainRequest::read(&config.shared_root);
let attempts = store.attempts();
let policies = store.policies();
let heartbeat = GuestHeartbeat {
schema_version: WSL_RECOVERY_SCHEMA_VERSION,
observed_at: chrono::Utc::now(),
acknowledged_generation: request
.as_ref()
.ok()
.and_then(|request| request.as_ref().map(|request| request.generation)),
local_active_attempts: attempts
.as_ref()
.ok()
.map(|attempts| u32::from(active_count(attempts))),
local_busy_attempts: attempts.as_ref().ok().and_then(|attempts| {
u32::try_from(
attempts
.iter()
.filter(|attempt| attempt.state() == AttemptState::Busy)
.count(),
)
.ok()
}),
managed_targets: policies
.map(|policies| {
policies
.into_iter()
.map(|policy| policy.target)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
})
.unwrap_or_default(),
unmanaged_runner_services: unmanaged_runner_service_count(),
};
if let Err(error) = request {
tracing::warn!(
reason = "wsl_recovery_state_unreadable",
%error,
"WSL recovery drain request is unavailable"
);
}
if let Err(error) = heartbeat.write(&config.shared_root) {
tracing::warn!(
reason = "wsl_recovery_state_unwritable",
%error,
"WSL recovery heartbeat cannot be published"
);
}
}
Ok(None) => {}
Err(_) => tracing::warn!(
reason = "wsl_recovery_config_unreadable",
"WSL recovery configuration is unavailable"
),
}
tokio::time::sleep(WSL_GUEST_HEARTBEAT_INTERVAL).await;
}
}
const CREDENTIAL_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(15 * 60);
const CREDENTIAL_RENEWAL_WINDOW: Duration = Duration::from_secs(30 * 60);
async fn observe_host_fitness(context: &Context, unfit: &AtomicBool, first: bool) {
let verdict = bounded_preflight(
super::doctor::daemon_preflight(context),
PREFLIGHT_DEADLINE,
runner_manager_platform::host_fitness::probes_in_flight,
context,
)
.await;
match verdict {
Ok(verdict) => {
let now_unfit = !verdict.required.is_empty();
let was_unfit = unfit.swap(now_unfit, Ordering::AcqRel);
if now_unfit && (first || !was_unfit) {
tracing::warn!(
event = "host_unfit",
reason = %verdict.required.join(","),
count = verdict.required.len(),
"required host checks fail; no runner is started until `runner-manager host doctor` passes"
);
} else if was_unfit && !now_unfit {
tracing::warn!(
event = "host_fit",
"required host checks pass again; runners may start"
);
}
if first && !verdict.recommended.is_empty() {
tracing::warn!(
event = "host_doctor",
reason = %verdict.recommended.join(","),
count = verdict.recommended.len(),
"recommended host preparation is missing; run `runner-manager host doctor`"
);
}
}
Err(error) => {
unfit.store(false, Ordering::Release);
tracing::warn!(
event = "host_doctor_unavailable",
"host checks could not run, so no runner is held back: {error}"
);
}
}
}
const PREFLIGHT_DEADLINE: Duration = Duration::from_secs(120);
use super::doctor::BLOCKED_CHECKS_ID;
async fn bounded_preflight(
preflight: impl Future<Output = Result<super::doctor::DaemonVerdict, String>>,
deadline: Duration,
stuck: impl FnOnce() -> Vec<std::path::PathBuf>,
context: &Context,
) -> Result<super::doctor::DaemonVerdict, String> {
match tokio::time::timeout(deadline, preflight).await {
Ok(Err(error)) if error == super::doctor::PREFLIGHT_STILL_RUNNING => {}
Ok(verdict) => return verdict,
Err(_) => {}
}
let stuck = stuck();
if stuck.is_empty() {
return Err(format!(
"the host checks did not finish within {} seconds",
deadline.as_secs()
));
}
let stuck = stuck
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
let finding = runner_manager_platform::host_fitness::UnfitFinding {
id: BLOCKED_CHECKS_ID.to_owned(),
detail: format!(
"the host checks did not finish within {} seconds: {stuck} does not answer",
deadline.as_secs()
),
remedy: Some(
if cfg!(target_os = "macos") {
"look for a dialog on this Mac's desktop asking whether runner-manager may \
access files on a volume, and click Allow; otherwise check the volume holding \
the runner root"
} else {
"check the volume holding the runner root (a stalled external disk or network \
mount); `runner-manager host doctor` names it"
}
.to_owned(),
),
};
if let Err(error) = runner_manager_platform::host_fitness::record_host_unfit(
context.paths(),
std::slice::from_ref(&finding),
context.clock().now(),
) {
tracing::warn!(
event = "host_unfit_unrecorded",
"the host-fitness record could not be written: {error}"
);
}
Ok(super::doctor::DaemonVerdict {
required: vec![finding.id],
recommended: Vec::new(),
})
}
struct Heartbeat {
task: tokio::task::JoinHandle<()>,
paths: runner_manager_platform::paths::AppPaths,
}
impl Heartbeat {
fn start(paths: runner_manager_platform::paths::AppPaths) -> Self {
let beating = paths.clone();
let task = tokio::spawn(async move {
use runner_manager_platform::daemon_heartbeat::{HEARTBEAT_INTERVAL, beat};
let mut failing = false;
loop {
match beat(&beating, chrono::Utc::now()) {
Err(error) if !failing => {
failing = true;
tracing::warn!(
%error,
reason = "heartbeat_unwritten",
"the daemon heartbeat could not be written, so `status` will call \
this daemon stalled"
);
}
Err(_) => {}
Ok(()) => failing = false,
}
tokio::time::sleep(HEARTBEAT_INTERVAL).await;
}
});
Self { task, paths }
}
}
impl Drop for Heartbeat {
fn drop(&mut self) {
self.task.abort();
let _ = runner_manager_platform::daemon_heartbeat::stop(&self.paths);
}
}
async fn maintain_host_fitness(context: &Context, unfit: Arc<AtomicBool>) {
loop {
tokio::time::sleep(super::doctor::DAEMON_RECHECK).await;
observe_host_fitness(context, &unfit, false).await;
}
}
async fn maintain_credential(client: Arc<AuthenticatedClient>) {
loop {
client
.renew_if_expiring_within(CREDENTIAL_RENEWAL_WINDOW)
.await;
tokio::time::sleep(CREDENTIAL_MAINTENANCE_INTERVAL).await;
}
}
async fn maintain_idle_credential(context: &Context, mode: StartMode) {
let mut client = None;
loop {
if client.is_none() {
client = idle_credential_client(context, mode);
}
if let Some(client) = &client {
client
.renew_if_expiring_within(CREDENTIAL_RENEWAL_WINDOW)
.await;
}
tokio::time::sleep(CREDENTIAL_MAINTENANCE_INTERVAL).await;
}
}
fn idle_credential_client(context: &Context, mode: StartMode) -> Option<Arc<AuthenticatedClient>> {
let secrets = match context.secret_store(mode) {
Ok(secrets) => secrets,
Err(error) => {
tracing::debug!(%error, "idle credential maintenance could not reach the store");
return None;
}
};
let loaded = secrets.load();
if loaded.is_ok() {
note_credential_readability(context, true);
}
let secret = match loaded {
Ok(Some(secret)) => secret,
Ok(None) => return None,
Err(error) => {
tracing::debug!(%error, "idle credential maintenance could not read the store");
return None;
}
};
let app = match context.app_registration() {
Ok(app) => app,
Err(error) => {
tracing::debug!(%error, "idle credential maintenance could not load the App registration");
return None;
}
};
let flow = match DeviceFlow::new(app, context.endpoints().clone()) {
Ok(flow) => flow,
Err(error) => {
tracing::debug!(%error, "idle credential maintenance could not prepare renewal");
return None;
}
};
let renewal: Arc<dyn CredentialRenewal> = Arc::new(super::auth::StoringRenewal::new(
flow,
Arc::clone(&secrets),
context
.paths()
.state_dir()
.join(LockKind::CredentialRenewal.file_name()),
));
let client = match AuthenticatedClient::new(
context.endpoints().clone(),
UserAccessToken::from_stored(secret),
context.clock(),
) {
Ok(client) => client,
Err(error) => {
tracing::debug!(%error, "idle credential maintenance could not prepare the GitHub client");
return None;
}
};
Some(Arc::new(
client
.with_renewal(renewal)
.with_credential_source(Arc::new(super::auth::StoredCredential::new(secrets))),
))
}
fn stop_for_upgrade(
context: &Context,
mode: StartMode,
source: Option<&std::path::Path>,
version: &str,
out: &mut dyn Write,
) -> Result<(), CliError> {
match source.map(replace_own_binary) {
Some(Ok(())) if hands_over_credential(context, mode) => {
hand_over_credential_to_new_binary(context, mode);
}
Some(Err(error)) => {
tracing::warn!(
%error,
"the new binary could not be put in place; the service manager will restart the version already there"
);
writeln!(out, "warning: {error}").map_err(write_failed("the daemon state"))?;
}
_ => {}
}
writeln!(
out,
"every runner finished; stopping so {version} can take over"
)
.map_err(write_failed("the daemon state"))?;
Err(CliError::with_remedy(
Failure::UpgradePending,
format!(
"a newer runner-manager ({version}) is installed and every runner this daemon held has finished; stopping so the service manager starts the new one"
),
"runner-manager service status",
))
}
fn note_credential_readability(context: &Context, readable: bool) {
let marked = if readable {
clear_credential_unreadable(context.paths())
} else {
record_credential_unreadable(context.paths(), context.clock().now())
};
if let Err(error) = marked {
tracing::warn!(%error, "cannot record whether the stored credential is readable");
}
}
fn hands_over_credential(context: &Context, mode: StartMode) -> bool {
cfg!(target_os = "macos") && mode == StartMode::Login && context.data_root.is_none()
}
const HANDOVER_LOCK_WAIT: Duration = Duration::from_secs(30);
const HANDOVER_CHILD_DEADLINE: Duration = Duration::from_secs(60);
#[derive(Debug, PartialEq, Eq)]
enum Handover {
NothingStored,
Unreadable,
HandedOver,
Failed { restored: bool },
}
fn hand_over_credential_to_new_binary(context: &Context, mode: StartMode) {
let binary = match std::env::current_exe() {
Ok(binary) => binary,
Err(error) => {
tracing::warn!(%error, "the credential cannot be handed over: this binary's own path is unknown");
return;
}
};
let secrets = match context.secret_store(mode) {
Ok(secrets) => secrets,
Err(error) => {
tracing::warn!(error = %error.message(), "the credential cannot be handed over: the store is unreachable");
return;
}
};
let _renewal = HostLock::acquire(
context.paths(),
LockKind::CredentialRenewal,
HANDOVER_LOCK_WAIT,
)
.map_err(|error| tracing::warn!(%error, "handing the credential over without the renewal lock"))
.ok();
let outcome = hand_over(secrets.as_ref(), |document| {
adopt_through(&binary, context, mode, document)
});
match outcome {
Handover::HandedOver => tracing::info!("the credential was handed over to the new binary"),
Handover::NothingStored => tracing::info!("no credential is stored; nothing to hand over"),
Handover::Unreadable | Handover::Failed { .. } => tracing::warn!(
?outcome,
remedy = %sign_in_instruction(mode),
"the credential was not handed over; the new daemon may need a sign-in"
),
}
}
fn hand_over(
secrets: &dyn SecretStore,
adopt: impl FnOnce(&SecretString) -> Result<(), String>,
) -> Handover {
let document = match secrets.load() {
Ok(Some(secret)) => UserAccessToken::from_stored(secret).to_stored_document(),
Ok(None) => return Handover::NothingStored,
Err(error) => {
tracing::warn!(%error, "this daemon cannot read its own credential to hand it over");
return Handover::Unreadable;
}
};
let Err(reason) = adopt(&document) else {
return Handover::HandedOver;
};
tracing::warn!(%reason, "the new binary did not store the credential");
let restored = matches!(secrets.load(), Ok(None)) && secrets.store(&document).is_ok();
Handover::Failed { restored }
}
fn adopt_through(
binary: &std::path::Path,
context: &Context,
mode: StartMode,
document: &SecretString,
) -> Result<(), String> {
use std::io::{Read as _, Write as _};
use std::process::{Command, Stdio};
let mut child = Command::new(binary)
.args([
"daemon",
"adopt-credential",
"--start-at",
&mode.to_string(),
])
.args(super::service::service_directory_arguments(context.paths()))
.env_remove(super::DATA_DIR_VARIABLE)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("{} could not be started: {error}", binary.display()))?;
let mut stderr = child
.stderr
.take()
.expect("stderr was configured as a pipe");
let stderr = std::thread::spawn(move || {
let mut text = Vec::new();
let _ = stderr.read_to_end(&mut text);
text
});
let written = child
.stdin
.take()
.expect("stdin was configured as a pipe")
.write_all(document.expose_secret().as_bytes())
.map_err(|error| format!("the credential could not be written to it: {error}"));
let deadline = std::time::Instant::now() + HANDOVER_CHILD_DEADLINE;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(50));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"it did not finish within {} seconds and was stopped",
HANDOVER_CHILD_DEADLINE.as_secs()
));
}
Err(error) => {
let _ = child.kill();
return Err(format!("the new binary could not be waited for: {error}"));
}
}
};
written?;
if status.success() {
return Ok(());
}
let stderr = stderr.join().unwrap_or_default();
Err(format!(
"it exited with {status}: {}",
String::from_utf8_lossy(&stderr).trim()
))
}
#[cfg(target_os = "macos")]
const LAUNCHD_RELOAD_GRACE: Duration = Duration::from_secs(90);
#[cfg(target_os = "macos")]
async fn converge_launchd_definition(
context: &Context,
out: &mut dyn Write,
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
) -> Result<bool, CliError> {
let operations = super::service::operations(context);
let label = super::service::identity().launchd_label();
let launchd_job = std::env::var("XPC_SERVICE_NAME").is_ok_and(|name| name == label)
&& std::os::unix::process::parent_id() == 1;
if !launchd_job {
return Ok(false);
}
let drift = match operations.definition_drift() {
Ok(drift) => drift.filter(|drift| {
drift.manager == runner_manager_platform::service::DefinitionKind::LaunchdPlist
}),
Err(error) => {
tracing::warn!(%error, "cannot compare the installed launchd plist");
return Ok(false);
}
};
let Some(drift) = drift else {
if runner_manager_platform::process::runs_at_background_priority()
&& let Ok(Some(record)) = InstallRecord::read(context.paths())
&& let Some(plist) = record.definition_path
{
let (sudo, domain) = match record.start_mode {
StartMode::Boot => ("sudo ", "system".to_string()),
StartMode::Login => ("", "gui/$(id -u)".to_string()),
};
tracing::warn!(
reason = "launchd_job_outdated",
"launchd runs this daemon at background priority although its plist is current, \
so the plist was never reloaded and every runner is throttled; reload it while no \
job is running: {sudo}launchctl bootout {domain}/{label} && {sudo}launchctl \
bootstrap {domain} '{}'",
plist.display()
);
}
return Ok(false);
};
let active = match context
.store()
.and_then(|store| store.attempts().map_err(local_store_failure))
{
Ok(attempts) => active_count(&attempts),
Err(error) => {
tracing::warn!(
%error,
"cannot count this host's runners, so the outdated launchd plist is left for now"
);
return Ok(false);
}
};
if active > 0 {
tracing::warn!(
path = %drift.path.display(),
active_runners = active,
reason = "launchd_plist_outdated",
"the launchd plist throttles every runner; it is rewritten when the daemon next starts with no runner, or restarts with none after a policy change"
);
return Ok(false);
}
if let Err(error) = operations.reload_launchd_definition(&drift) {
tracing::warn!(%error, "the outdated launchd plist could not be replaced");
return Ok(false);
}
tracing::info!(
path = %drift.path.display(),
process_type = runner_manager_platform::service::LAUNCHD_PROCESS_TYPE,
"rewrote the launchd plist written by {}; launchd is loading the job again",
drift.installed_by_version
);
tokio::select! {
signal = wait_for_shutdown(service_shutdown) => {
signal.map_err(signal_failure)?;
writeln!(out, "daemon stopped so launchd can load the rewritten plist; no runner was held")
.map_err(write_failed("the daemon state"))?;
Ok(true)
}
() = tokio::time::sleep(LAUNCHD_RELOAD_GRACE) => {
tracing::warn!(
"launchd did not reload the job after the plist was rewritten; continuing under the old ProcessType"
);
Ok(false)
}
}
}
const UPGRADE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BinaryStamp {
len: u64,
modified: Option<std::time::SystemTime>,
}
impl BinaryStamp {
fn of(path: &std::path::Path) -> Option<Self> {
let meta = std::fs::metadata(path).ok()?;
Some(Self {
len: meta.len(),
modified: meta.modified().ok(),
})
}
}
fn upgraded_version(path: &std::path::Path) -> Option<String> {
let output = std::process::Command::new(path)
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
changed_build_version(&String::from_utf8(output.stdout).ok()?)
}
fn changed_build_version(reported: &str) -> Option<String> {
let reported = reported.split_whitespace().last()?.to_string();
(reported != env!("RUNNER_MANAGER_BUILD_VERSION")).then_some(reported)
}
async fn wait_for_upgrade(path: std::path::PathBuf) -> String {
if let Some(version) = upgraded_version(&path) {
return version;
}
let mut known = BinaryStamp::of(&path);
loop {
tokio::time::sleep(UPGRADE_CHECK_INTERVAL).await;
let current = BinaryStamp::of(&path);
if current == known {
continue;
}
known = current;
if current.is_none() {
continue;
}
if let Some(version) = upgraded_version(&path) {
return version;
}
}
}
fn replace_own_binary(source: &std::path::Path) -> Result<(), String> {
let own = std::env::current_exe().map_err(|error| format!("own path unknown: {error}"))?;
let aside = own.with_extension("old");
let _ = std::fs::remove_file(&aside);
std::fs::rename(&own, &aside)
.map_err(|error| format!("the running binary could not be moved aside: {error}"))?;
if let Err(error) = std::fs::copy(source, &own) {
let _ = std::fs::rename(&aside, &own);
return Err(format!(
"the new binary could not be copied into place: {error}"
));
}
Ok(())
}
async fn wait_for_policy_set_change(store: Arc<dyn Store>, initial: BTreeSet<String>) {
loop {
tokio::time::sleep(POLICY_SET_CHECK_INTERVAL).await;
let Ok(policies) = store.policies() else {
continue;
};
let current: BTreeSet<String> = active_autoscale_targets(policies)
.iter()
.map(|group| group[0].target.to_string())
.collect();
if current != initial {
return;
}
}
}
const POLICY_SET_CHECK_INTERVAL: Duration = Duration::from_secs(30);
async fn wait_for_shutdown(
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
) -> std::io::Result<()> {
match service_shutdown {
Some(shutdown) => {
shutdown.wait().await;
Ok(())
}
None => shutdown_signal().await,
}
}
#[derive(Debug)]
struct TargetRecoveryStore {
inner: Arc<dyn Store>,
policies: BTreeSet<runner_manager_domain::model::PolicyId>,
recovering: AtomicBool,
}
impl TargetRecoveryStore {
fn new(inner: Arc<dyn Store>, policies: &[ScalePolicy]) -> Self {
Self {
inner,
policies: policies.iter().map(|policy| policy.id).collect(),
recovering: AtomicBool::new(true),
}
}
fn finish_recovery(&self) {
self.recovering.store(false, Ordering::Release);
}
}
impl Store for TargetRecoveryStore {
fn put_host(
&self,
host: &runner_manager_domain::model::Host,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner.put_host(host)
}
fn host(
&self,
id: runner_manager_domain::model::HostId,
) -> Result<Option<runner_manager_domain::model::Host>, runner_manager_domain::store::StoreError>
{
self.inner.host(id)
}
fn hosts(
&self,
) -> Result<Vec<runner_manager_domain::model::Host>, runner_manager_domain::store::StoreError>
{
self.inner.hosts()
}
fn set_runner_root_override(
&self,
id: runner_manager_domain::model::HostId,
expected: Option<&runner_manager_domain::path::LocalAbsolutePath>,
new_root: Option<&runner_manager_domain::path::LocalAbsolutePath>,
expected_uncleaned: u16,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner
.set_runner_root_override(id, expected, new_root, expected_uncleaned)
}
fn insert_policy(
&self,
policy: &ScalePolicy,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner.insert_policy(policy)
}
fn update_policy(
&self,
policy: &ScalePolicy,
expected_revision: u64,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner.update_policy(policy, expected_revision)
}
fn update_policy_confirming_active_count(
&self,
policy: &ScalePolicy,
expected_revision: u64,
expected_active: u16,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner
.update_policy_confirming_active_count(policy, expected_revision, expected_active)
}
fn update_policy_confirming_uncleaned_count(
&self,
policy: &ScalePolicy,
expected_revision: u64,
expected_uncleaned: u16,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner.update_policy_confirming_uncleaned_count(
policy,
expected_revision,
expected_uncleaned,
)
}
fn remove_policy(
&self,
id: runner_manager_domain::model::PolicyId,
expected_revision: u64,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner.remove_policy(id, expected_revision)
}
fn policy(
&self,
id: runner_manager_domain::model::PolicyId,
) -> Result<Option<ScalePolicy>, runner_manager_domain::store::StoreError> {
self.inner.policy(id)
}
fn policies(&self) -> Result<Vec<ScalePolicy>, runner_manager_domain::store::StoreError> {
self.inner.policies()
}
fn record_attempt(
&self,
attempt: &runner_manager_domain::attempt::RunnerAttempt,
) -> Result<(), runner_manager_domain::store::StoreError> {
self.inner.record_attempt(attempt)
}
fn attempt(
&self,
id: AttemptId,
) -> Result<
Option<runner_manager_domain::attempt::RunnerAttempt>,
runner_manager_domain::store::StoreError,
> {
self.inner.attempt(id)
}
fn attempts(
&self,
) -> Result<
Vec<runner_manager_domain::attempt::RunnerAttempt>,
runner_manager_domain::store::StoreError,
> {
let mut attempts = self.inner.attempts()?;
if self.recovering.load(Ordering::Acquire) {
attempts.retain(|attempt| self.policies.contains(&attempt.policy_id));
}
Ok(attempts)
}
fn attempts_for_policy(
&self,
policy_id: runner_manager_domain::model::PolicyId,
) -> Result<
Vec<runner_manager_domain::attempt::RunnerAttempt>,
runner_manager_domain::store::StoreError,
> {
self.inner.attempts_for_policy(policy_id)
}
fn active_attempts_for_policy(
&self,
policy_id: runner_manager_domain::model::PolicyId,
) -> Result<
Vec<runner_manager_domain::attempt::RunnerAttempt>,
runner_manager_domain::store::StoreError,
> {
self.inner.active_attempts_for_policy(policy_id)
}
fn uncleaned_attempts_for_policy(
&self,
policy_id: runner_manager_domain::model::PolicyId,
) -> Result<
Vec<runner_manager_domain::attempt::RunnerAttempt>,
runner_manager_domain::store::StoreError,
> {
self.inner.uncleaned_attempts_for_policy(policy_id)
}
fn slot_leases_for_policy(
&self,
policy_id: runner_manager_domain::model::PolicyId,
) -> Result<
Vec<runner_manager_domain::attempt::RunnerAttempt>,
runner_manager_domain::store::StoreError,
> {
self.inner.slot_leases_for_policy(policy_id)
}
fn uncleaned_ephemeral_attempts(
&self,
) -> Result<
Vec<runner_manager_domain::attempt::RunnerAttempt>,
runner_manager_domain::store::StoreError,
> {
self.inner.uncleaned_ephemeral_attempts()
}
fn remove_attempt(
&self,
id: AttemptId,
) -> Result<bool, runner_manager_domain::store::StoreError> {
self.inner.remove_attempt(id)
}
}
fn active_autoscale_targets(mut policies: Vec<ScalePolicy>) -> Vec<Vec<ScalePolicy>> {
policies.retain(|policy| policy.may_start_runners() || policy.state() == PolicyState::Draining);
policies.sort_by(|left, right| left.target.cmp(&right.target));
let mut targets: Vec<Vec<ScalePolicy>> = Vec::new();
for policy in policies {
match targets.last_mut() {
Some(group) if group[0].target == policy.target => group.push(policy),
_ => targets.push(vec![policy]),
}
}
targets
}
trait TargetReconciler: Send + 'static {
fn policies(&self) -> &[ScalePolicy];
fn begin_drain(&mut self);
fn reconcile(&mut self) -> Pin<Box<dyn Future<Output = ReconcileReport> + Send + '_>>;
fn refresh_intervals(&mut self) {}
fn observe(&self, _report: &ReconcileReport) {}
fn active_owned(&self, report: &ReconcileReport) -> Option<u16> {
active_owned(report, self.policies())
}
fn refresh_policies(&mut self);
fn local_active(&self) -> Option<u16>;
}
struct ManagedTarget {
policies: Vec<ScalePolicy>,
reconciler: Reconciler,
cancel: CancelToken,
store: Arc<dyn Store>,
polling: Arc<super::polling::PollingMonitor>,
}
impl TargetReconciler for ManagedTarget {
fn policies(&self) -> &[ScalePolicy] {
&self.policies
}
fn begin_drain(&mut self) {
self.cancel.cancel();
begin_drain(&mut self.policies);
}
fn reconcile(&mut self) -> Pin<Box<dyn Future<Output = ReconcileReport> + Send + '_>> {
Box::pin(self.reconciler.reconcile(&self.policies))
}
fn refresh_intervals(&mut self) {
let intervals = self.polling.intervals();
self.reconciler
.set_intervals(intervals.active, intervals.effective_idle());
}
fn observe(&self, report: &ReconcileReport) {
if let Some(policy) = self.policies.first() {
self.polling.observe(&policy.target, &report.next_poll);
}
}
fn refresh_policies(&mut self) {
let Some(target) = self.policies.first().map(|policy| policy.target.clone()) else {
return;
};
let Ok(all) = self.store.policies() else {
tracing::warn!(
%target,
"the policy journal could not be read this pass; continuing with the set already loaded"
);
return;
};
let refreshed: Vec<ScalePolicy> = all
.into_iter()
.filter(|policy| {
policy.target == target
&& (policy.may_start_runners() || policy.state() == PolicyState::Draining)
})
.collect();
if !refreshed.is_empty() {
self.policies = refreshed;
}
}
fn local_active(&self) -> Option<u16> {
let mut total = 0_u16;
for policy in &self.policies {
let attempts = self.store.attempts_for_policy(policy.id).ok()?;
total = total.saturating_add(active_count_for(policy.id, attempts.iter()));
}
Some(total)
}
}
trait ContactRecorder: Send + Sync + 'static {
fn record(&self) -> Result<(), CliError>;
fn rejected(&self) {}
fn accepted(&self) {}
}
struct FileContactRecorder {
paths: runner_manager_platform::paths::AppPaths,
clock: Arc<dyn Clock>,
write: Mutex<Option<std::time::Instant>>,
}
const CONTACT_RECORD_EVERY: Duration = Duration::from_secs(30);
impl ContactRecorder for FileContactRecorder {
fn record(&self) -> Result<(), CliError> {
let mut written_at = self.write.lock().map_err(|_| {
CliError::new(
Failure::LocalState,
"cannot lock the last successful GitHub contact record",
)
})?;
if written_at.is_some_and(|at| at.elapsed() < CONTACT_RECORD_EVERY) {
return Ok(());
}
record_github_contact(&self.paths, self.clock.now()).map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot record the last successful GitHub contact: {source}"),
)
})?;
*written_at = Some(std::time::Instant::now());
Ok(())
}
fn rejected(&self) {
let Ok(_write) = self.write.lock() else {
return;
};
if let Err(error) = record_github_credential_rejected(&self.paths, self.clock.now()) {
tracing::warn!(%error, "cannot record that GitHub rejected the credential");
}
}
fn accepted(&self) {
let Ok(_write) = self.write.lock() else {
return;
};
if let Err(error) = clear_github_credential_rejection(&self.paths) {
tracing::warn!(%error, "cannot clear the recorded credential rejection");
}
}
}
const DRAIN_DEADLINE: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DrainKind {
Shutdown,
Upgrade,
}
async fn run_target_loop<T: TargetReconciler>(
mut target: T,
mut shutdown: tokio::sync::watch::Receiver<bool>,
mut upgrade: tokio::sync::watch::Receiver<bool>,
contacts: Arc<dyn ContactRecorder>,
) -> Result<(), CliError> {
let mut draining = shutdown_kind(&shutdown, &upgrade);
let mut drain_deadline = match draining {
Some(DrainKind::Shutdown) => Some(tokio::time::Instant::now() + DRAIN_DEADLINE),
_ => None,
};
if draining.is_some() {
target.begin_drain();
}
loop {
if drain_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
return Ok(());
}
target.refresh_policies();
target.refresh_intervals();
let report = target.reconcile().await;
if draining.is_none() {
target.observe(&report);
}
if draining.is_none() && report.reached_github() {
contacts.record()?;
}
if draining.is_none() {
if matches!(report.failure, Some(RefreshState::Unauthorized)) {
contacts.rejected();
} else if report.reached_github() {
contacts.accepted();
}
}
match draining {
Some(DrainKind::Shutdown) if target.active_owned(&report) == Some(0) => {
return Ok(());
}
Some(DrainKind::Upgrade) if target.local_active() == Some(0) => {
return Ok(());
}
_ => {}
}
let delay = drain_deadline.map_or(report.next_poll.delay, |deadline| {
report
.next_poll
.delay
.min(deadline.saturating_duration_since(tokio::time::Instant::now()))
});
tokio::select! {
() = tokio::time::sleep(delay) => {}
changed = shutdown.changed(), if draining != Some(DrainKind::Shutdown) => {
if changed.is_err() || *shutdown.borrow() {
target.begin_drain();
draining = Some(DrainKind::Shutdown);
drain_deadline = Some(tokio::time::Instant::now() + DRAIN_DEADLINE);
}
}
changed = upgrade.changed(), if draining.is_none() => {
if changed.is_err() || *upgrade.borrow() {
target.begin_drain();
draining = Some(DrainKind::Upgrade);
}
}
}
}
}
fn shutdown_kind(
shutdown: &tokio::sync::watch::Receiver<bool>,
upgrade: &tokio::sync::watch::Receiver<bool>,
) -> Option<DrainKind> {
if *shutdown.borrow() {
Some(DrainKind::Shutdown)
} else if *upgrade.borrow() {
Some(DrainKind::Upgrade)
} else {
None
}
}
fn active_owned(report: &ReconcileReport, policies: &[ScalePolicy]) -> Option<u16> {
policies.iter().try_fold(0_u16, |total, policy| {
report
.allocations
.iter()
.find(|allocation| allocation.policy_id == policy.id)
.map(|allocation| total.saturating_add(allocation.active_owned))
})
}
fn begin_drain(policies: &mut [runner_manager_domain::policy::ScalePolicy]) {
for policy in policies {
if policy.can_request_disable() {
let _ = policy.request_disable();
}
}
}
const SINGLE_INSTANCE_SETTLE: Duration = Duration::from_millis(250);
fn acquire_instance(context: &Context) -> Result<HostLock, CliError> {
HostLock::acquire(
context.paths(),
LockKind::SingleInstance,
SINGLE_INSTANCE_SETTLE,
)
.map_err(|source| match source {
held @ LockError::Held { .. } => CliError::with_remedy(
Failure::Conflict,
format!("another daemon already owns this host: {held}"),
"runner-manager service status",
),
other => CliError::new(
Failure::LocalState,
format!("cannot acquire the daemon's single-instance lock: {other}"),
),
})
}
fn reset_launch_state(context: &Context) {
use runner_manager_platform::launch_health::clear_launches_blocked;
use runner_manager_platform::wsl::fence::reclaim_stale_guest_claim;
if let Err(error) = clear_launches_blocked(context.paths()) {
tracing::warn!(%error, "the previous daemon's blocked-launch record cannot be cleared");
}
let Ok(Some(config)) = GuestRecoveryConfig::read(context.paths()) else {
return;
};
match reclaim_stale_guest_claim(&config.shared_root) {
Ok(Some(reclaimed)) => tracing::warn!(
shared_root = %config.shared_root.display(),
%reclaimed,
"reclaimed a WSL launch fence claim whose owner is gone"
),
Ok(None) => {}
Err(error) => tracing::warn!(
shared_root = %config.shared_root.display(),
%error,
"the WSL launch fence cannot be inspected at daemon start"
),
}
}
fn local_store_failure(source: runner_manager_domain::store::StoreError) -> CliError {
CliError::new(
Failure::LocalState,
format!("cannot read the daemon's local database: {source}"),
)
}
fn github_failure(source: GithubError) -> CliError {
CliError::with_remedy(
Failure::GithubUnavailable,
source.to_string(),
"runner-manager auth status",
)
}
fn signal_failure(source: std::io::Error) -> CliError {
CliError::new(
Failure::UnsupportedHost,
format!("cannot listen for the daemon shutdown signal: {source}"),
)
}
#[cfg(unix)]
async fn shutdown_signal() -> std::io::Result<()> {
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! {
result = tokio::signal::ctrl_c() => result,
_ = terminate.recv() => Ok(()),
}
}
#[cfg(not(unix))]
async fn shutdown_signal() -> std::io::Result<()> {
tokio::signal::ctrl_c().await
}
#[derive(Debug)]
struct GithubLifecycle {
jit: Arc<RestJit>,
inventory: Arc<RestInventory>,
clock: Arc<dyn Clock>,
}
impl LifecycleGithub for GithubLifecycle {
fn register<'life0, 'life1, 'life2, 'life3, 'async_trait>(
&'life0 self,
target: &'life1 ScaleTarget,
request: &'life2 JitRunnerRequest,
cancel: &'life3 CancelToken,
) -> Pin<
Box<
dyn Future<
Output = Result<
runner_manager_github::jit::JitRegistration,
runner_manager_agent::lifecycle::JitRequestFailure,
>,
> + Send
+ 'async_trait,
>,
>
where
'life0: 'async_trait,
'life1: 'async_trait,
'life2: 'async_trait,
'life3: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
self.jit
.generate_jit_config(target, request, cancel)
.await
.map_err(|error| runner_manager_agent::lifecycle::JitRequestFailure {
terminal: error.is_terminal(),
retry_after: error.rate_limited().map(|limit| limit.delay_from(self.clock.now())),
reason: if matches!(error, JitError::Forbidden { .. }) {
FailureReason::Other("GitHub refused JIT registration; check the App runner permission and runner-group access".into())
} else {
FailureReason::JitRequestFailed
},
})
})
}
fn observe<'life0, 'life1, 'life2, 'async_trait>(
&'life0 self,
target: &'life1 ScaleTarget,
attempt: AttemptId,
cancel: &'life2 CancelToken,
) -> Pin<Box<dyn Future<Output = LifecycleGithubObservation> + Send + 'async_trait>>
where
'life0: 'async_trait,
'life1: 'async_trait,
'life2: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
let name = format!("runner-manager-{attempt}");
match self.inventory.list_runners(target, cancel).await {
Ok(inventory) => inventory
.runners()
.iter()
.find(|runner| runner.name == name)
.map_or(LifecycleGithubObservation::not_registered(), |runner| {
LifecycleGithubObservation::registered(runner.id, runner.busy)
}),
Err(_) => LifecycleGithubObservation::unreachable(),
}
})
}
fn deregister<'life0, 'life1, 'life2, 'async_trait>(
&'life0 self,
target: &'life1 ScaleTarget,
runner_id: u64,
cancel: &'life2 CancelToken,
) -> Pin<Box<dyn Future<Output = bool> + Send + 'async_trait>>
where
'life0: 'async_trait,
'life1: 'async_trait,
'life2: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
self.inventory
.remove_runner(target, runner_id, cancel)
.await
.is_ok()
})
}
}
#[derive(Debug)]
struct GithubDirectory {
client: Arc<AuthenticatedClient>,
app: AppRegistration,
}
impl RepositoryDirectory for GithubDirectory {
fn repositories<'life0, 'life1, 'async_trait>(
&'life0 self,
org: &'life1 Org,
) -> Pin<Box<dyn Future<Output = Result<Vec<OwnerRepo>, InventoryError>> + Send + 'async_trait>>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
let discovery = self
.client
.discover_installations(&self.app)
.await
.map_err(InventoryError::from)?;
Ok(discovery
.targets()
.map(|targets| {
targets
.repositories()
.into_iter()
.filter(|repository| repository.owner().eq_ignore_ascii_case(org.as_str()))
.collect()
})
.unwrap_or_default())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::SecretString;
#[tokio::test]
async fn diagnostics_maintenance_expires_history_without_waiting_for_a_job() {
let root = tempfile::tempdir().unwrap();
let summary = root.path().join(format!("{}.log", AttemptId::from_u128(1)));
let file = std::fs::File::create(&summary).unwrap();
file.set_modified(
std::time::SystemTime::now()
- runner_manager_platform::log_retention::ATTEMPT_LOG_MAX_AGE
- Duration::from_secs(1),
)
.unwrap();
drop(file);
let service = root.path().join("runner-manager.service.log.2020-01-01");
std::fs::write(&service, b"not an attempt summary").unwrap();
let maintenance = tokio::spawn(maintain_diagnostics(root.path().to_path_buf()));
let expired = tokio::time::timeout(Duration::from_secs(10), async {
while summary.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
maintenance.abort();
let _ = maintenance.await;
expired.expect("the first maintenance pass runs at startup without any job");
assert!(service.exists());
}
fn rooted(root: &std::path::Path) -> Context {
let endpoints =
runner_manager_github::Endpoints::for_test_server("http://127.0.0.1:9").unwrap();
Context::rooted_against(root, endpoints).unwrap()
}
#[tokio::test(start_paused = true)]
async fn host_checks_that_never_finish_make_the_host_unfit() {
let root = tempfile::tempdir().unwrap();
let context = rooted(root.path());
context.paths().create_all().unwrap();
let stuck_root = std::path::PathBuf::from("/Volumes/NVME/runners");
let verdict = tokio::time::timeout(
Duration::from_secs(600),
bounded_preflight(
std::future::pending(),
Duration::from_secs(120),
|| vec![stuck_root.clone()],
&context,
),
)
.await
.expect("host checks that never finish are given up on")
.expect("a verdict");
assert_eq!(verdict.required, [BLOCKED_CHECKS_ID]);
let record = runner_manager_platform::host_fitness::host_unfit(context.paths())
.unwrap()
.expect("recorded for status");
assert!(
record
.describe()
.contains("/Volumes/NVME/runners does not answer"),
"{record:?}"
);
let again = bounded_preflight(
async { Err(super::super::doctor::PREFLIGHT_STILL_RUNNING.to_owned()) },
Duration::from_secs(120),
|| vec![stuck_root.clone()],
&context,
)
.await
.unwrap();
assert_eq!(again.required, [BLOCKED_CHECKS_ID]);
let slow = bounded_preflight(
std::future::pending(),
Duration::from_secs(120),
Vec::new,
&context,
)
.await;
assert!(slow.is_err(), "{slow:?}");
let answered = bounded_preflight(
async { Ok(super::super::doctor::DaemonVerdict::default()) },
Duration::from_secs(120),
Vec::new,
&context,
)
.await
.unwrap();
assert!(answered.required.is_empty());
}
#[tokio::test]
async fn the_heartbeat_beats_until_the_generation_ends() {
use runner_manager_platform::daemon_heartbeat::{Liveness, liveness};
let root = tempfile::tempdir().unwrap();
let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
paths.create_all().unwrap();
let heartbeat = Heartbeat::start(paths.clone());
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(matches!(
liveness(&paths, chrono::Utc::now()),
Liveness::Beating { .. }
));
drop(heartbeat);
assert_eq!(liveness(&paths, chrono::Utc::now()), Liveness::Unknown);
}
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use runner_manager_agent::lifecycle::{
JitRequestFailure, NoAttemptEvents, PruneAuthority, RuntimePackages,
};
use runner_manager_agent::package::RunnerVersion;
use runner_manager_domain::attempt::RunnerAttempt;
use runner_manager_domain::model::PolicyId;
use runner_manager_domain::store::SqliteStore;
use runner_manager_github::RenewalError;
use runner_manager_github::jit::JitRegistration;
use runner_manager_github::rest::RefreshState;
use runner_manager_testkit::clock::FakeClock;
use runner_manager_testkit::fixtures;
#[test]
fn scm_service_never_claims_authority_over_user_owned_wsl() {
assert!(!wsl_recovery_is_available(true));
assert_eq!(wsl_recovery_is_available(false), cfg!(windows));
}
#[derive(Debug)]
struct CountingRenewal {
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl CredentialRenewal for CountingRenewal {
async fn renew(
&self,
_refresh_token: &SecretString,
) -> Result<UserAccessToken, RenewalError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(UserAccessToken::from_stored(SecretString::from(
serde_json::json!({
"access_token": "ghu_daemon_renewed_fixture",
"refresh_token": "ghr_daemon_renewed_fixture",
"access_expires_at": "2027-08-21T00:00:00Z",
"refresh_expires_at": "2028-02-21T00:00:00Z"
})
.to_string(),
)))
}
}
#[derive(Debug)]
struct RecoveryGithub {
expected: ScaleTarget,
}
impl LifecycleGithub for RecoveryGithub {
fn register<'life0, 'life1, 'life2, 'life3, 'async_trait>(
&'life0 self,
_target: &'life1 ScaleTarget,
_request: &'life2 JitRunnerRequest,
_cancel: &'life3 CancelToken,
) -> Pin<
Box<
dyn Future<Output = Result<JitRegistration, JitRequestFailure>>
+ Send
+ 'async_trait,
>,
>
where
'life0: 'async_trait,
'life1: 'async_trait,
'life2: 'async_trait,
'life3: 'async_trait,
Self: 'async_trait,
{
Box::pin(async { panic!("startup recovery must not register a runner") })
}
fn observe<'life0, 'life1, 'life2, 'async_trait>(
&'life0 self,
target: &'life1 ScaleTarget,
_attempt: AttemptId,
_cancel: &'life2 CancelToken,
) -> Pin<Box<dyn Future<Output = LifecycleGithubObservation> + Send + 'async_trait>>
where
'life0: 'async_trait,
'life1: 'async_trait,
'life2: 'async_trait,
Self: 'async_trait,
{
assert_eq!(
target, &self.expected,
"a target launcher observed another target's startup attempt"
);
Box::pin(std::future::ready(LifecycleGithubObservation::registered(
73, false,
)))
}
fn deregister<'life0, 'life1, 'life2, 'async_trait>(
&'life0 self,
target: &'life1 ScaleTarget,
_runner_id: u64,
_cancel: &'life2 CancelToken,
) -> Pin<Box<dyn Future<Output = bool> + Send + 'async_trait>>
where
'life0: 'async_trait,
'life1: 'async_trait,
'life2: 'async_trait,
Self: 'async_trait,
{
assert_eq!(
target, &self.expected,
"a target launcher deregistered another target's runner"
);
Box::pin(std::future::ready(true))
}
}
#[derive(Debug)]
struct UnusedPackages;
impl RuntimePackages for UnusedPackages {
fn materialize<'life0, 'life1, 'async_trait>(
&'life0 self,
_attempt: &'life1 RunnerAttempt,
) -> Pin<Box<dyn Future<Output = Result<RunnerVersion, FailureReason>> + Send + 'async_trait>>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait,
{
Box::pin(async { panic!("startup recovery must not materialize a package") })
}
fn release(&self, _attempt: AttemptId) -> Result<(), FailureReason> {
Ok(())
}
fn prune_obsolete_guarded(
&self,
_authority: PruneAuthority<'_>,
_current: &RunnerVersion,
_attempts: &[RunnerAttempt],
) -> Result<(), FailureReason> {
Ok(())
}
}
#[derive(Debug)]
struct FakeTarget {
policy: ScalePolicy,
reports: VecDeque<ReconcileReport>,
calls: Arc<AtomicUsize>,
active: u16,
draining: bool,
busy_was_terminated: Arc<AtomicUsize>,
unreadable: bool,
refreshes: Arc<AtomicUsize>,
}
impl FakeTarget {
fn repeating(policy: ScalePolicy, report: ReconcileReport) -> (Self, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
(
Self {
policy,
reports: VecDeque::from([report]),
calls: Arc::clone(&calls),
active: 0,
draining: false,
busy_was_terminated: Arc::new(AtomicUsize::new(0)),
unreadable: false,
refreshes: Arc::new(AtomicUsize::new(0)),
},
calls,
)
}
fn busy_then_finished(policy: ScalePolicy) -> Self {
Self {
policy,
reports: VecDeque::new(),
calls: Arc::new(AtomicUsize::new(0)),
active: 1,
draining: false,
busy_was_terminated: Arc::new(AtomicUsize::new(0)),
unreadable: false,
refreshes: Arc::new(AtomicUsize::new(0)),
}
}
fn never_readable(policy: ScalePolicy) -> Self {
Self {
policy,
reports: VecDeque::new(),
calls: Arc::new(AtomicUsize::new(0)),
active: 1,
draining: false,
busy_was_terminated: Arc::new(AtomicUsize::new(0)),
unreadable: true,
refreshes: Arc::new(AtomicUsize::new(0)),
}
}
}
impl TargetReconciler for FakeTarget {
fn policies(&self) -> &[ScalePolicy] {
std::slice::from_ref(&self.policy)
}
fn begin_drain(&mut self) {
self.draining = true;
begin_drain(std::slice::from_mut(&mut self.policy));
}
fn reconcile(&mut self) -> Pin<Box<dyn Future<Output = ReconcileReport> + Send + '_>> {
self.calls.fetch_add(1, Ordering::SeqCst);
if self.draining && self.active > 0 {
if self.calls.load(Ordering::SeqCst) >= 3 {
self.active = 0;
}
}
let report = self.reports.front().cloned().unwrap_or_else(|| {
let mut report = ReconcileReport::default();
report.next_poll.delay = Duration::from_secs(1);
report
});
Box::pin(std::future::ready(report))
}
fn active_owned(&self, _report: &ReconcileReport) -> Option<u16> {
if self.unreadable {
return None;
}
Some(self.active)
}
fn refresh_policies(&mut self) {
self.refreshes.fetch_add(1, Ordering::SeqCst);
}
fn local_active(&self) -> Option<u16> {
Some(self.active)
}
}
fn never_upgraded() -> tokio::sync::watch::Receiver<bool> {
let (sender, receiver) = tokio::sync::watch::channel(false);
Box::leak(Box::new(sender));
receiver
}
#[tokio::test(start_paused = true)]
async fn an_upgrade_waits_for_a_running_job_however_long_it_takes() {
let target = FakeTarget::busy_then_finished(
fixtures::policy()
.repository("acme/repo")
.autoscale("home", 1)
.active()
.build(),
);
let terminations = Arc::clone(&target.busy_was_terminated);
let contacts = Arc::new(CountingContacts::default());
let (stop, _) = tokio::sync::watch::channel(false);
let (upgrade, _) = tokio::sync::watch::channel(false);
let daemon = tokio::spawn(run_target_loop(
target,
stop.subscribe(),
upgrade.subscribe(),
contacts as Arc<dyn ContactRecorder>,
));
tokio::task::yield_now().await;
upgrade.send(true).unwrap();
for _ in 0..DRAIN_DEADLINE.as_secs() {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
tokio::time::timeout(Duration::from_secs(1), daemon)
.await
.expect("the upgrade drain ends once the job is done")
.unwrap()
.unwrap();
assert_eq!(
terminations.load(Ordering::SeqCst),
0,
"an upgrade must never terminate a running job"
);
}
#[tokio::test(start_paused = true)]
async fn an_upgrade_completes_even_when_github_cannot_be_read() {
let mut target = FakeTarget::never_readable(
fixtures::policy()
.repository("acme/repo")
.autoscale("home", 1)
.active()
.build(),
);
target.active = 0;
let contacts = Arc::new(CountingContacts::default());
let (stop, _) = tokio::sync::watch::channel(false);
let (upgrade, _) = tokio::sync::watch::channel(false);
let daemon = tokio::spawn(run_target_loop(
target,
stop.subscribe(),
upgrade.subscribe(),
contacts as Arc<dyn ContactRecorder>,
));
tokio::task::yield_now().await;
upgrade.send(true).unwrap();
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
tokio::time::timeout(Duration::from_secs(1), daemon)
.await
.expect("an unreadable target must not block an upgrade")
.unwrap()
.unwrap();
}
#[test]
fn the_running_version_is_not_an_upgrade_of_itself() {
let own = std::env::current_exe().expect("the test binary's own path");
assert!(
BinaryStamp::of(&own).is_some(),
"a running binary must be stat-able"
);
assert!(
BinaryStamp::of(std::path::Path::new("no-such-binary")).is_none(),
"a missing file has no stamp, and is not mistaken for a new one"
);
assert!(
upgraded_version(std::path::Path::new("no-such-binary")).is_none(),
"a path that cannot be executed is never reported as an upgrade"
);
assert_eq!(
changed_build_version(concat!(
"runner-manager ",
env!("RUNNER_MANAGER_BUILD_VERSION"),
"\n"
)),
None,
"a commit-qualified source build must recognize its own identity"
);
assert_eq!(
changed_build_version("runner-manager 9.9.9\n").as_deref(),
Some("9.9.9"),
"a genuinely different build must still trigger handover"
);
}
#[test]
fn an_idle_host_uses_the_normal_upgrade_handover() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let mut output = Vec::new();
let error = stop_for_upgrade(&context, StartMode::Login, None, "9.9.9", &mut output)
.expect_err("an upgrade exits for the service manager to restart it");
assert_eq!(error.class(), Failure::UpgradePending);
let output = String::from_utf8(output).unwrap();
assert!(output.contains("9.9.9"), "{output}");
assert!(output.contains("every runner finished"), "{output}");
}
#[tokio::test(start_paused = true)]
async fn daemon_maintenance_renews_an_expired_pair_without_tui_traffic() {
let calls = Arc::new(AtomicUsize::new(0));
let renewal: Arc<dyn CredentialRenewal> = Arc::new(CountingRenewal {
calls: Arc::clone(&calls),
});
let expired = UserAccessToken::from_stored(SecretString::from(
serde_json::json!({
"access_token": "ghu_daemon_expired_fixture",
"refresh_token": "ghr_daemon_expired_fixture",
"access_expires_at": "2026-08-20T23:59:59Z",
"refresh_expires_at": "2027-02-21T00:00:00Z"
})
.to_string(),
));
let client = Arc::new(
AuthenticatedClient::new(
runner_manager_github::Endpoints::production(),
expired,
Arc::new(FakeClock::default()),
)
.unwrap()
.with_renewal(renewal),
);
let maintenance = tokio::spawn(maintain_credential(client));
tokio::task::yield_now().await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
tokio::time::advance(CREDENTIAL_MAINTENANCE_INTERVAL).await;
tokio::task::yield_now().await;
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"the fresh pair must not be rotated again on every maintenance tick"
);
maintenance.abort();
}
#[derive(Default)]
struct CountingContacts(AtomicUsize);
impl ContactRecorder for CountingContacts {
fn record(&self) -> Result<(), CliError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[test]
fn a_second_daemon_names_the_holder_and_uses_the_conflict_exit_class() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let held = acquire_instance(&context).expect("first daemon acquires the lock");
let error = acquire_instance(&context).expect_err("second daemon must be refused");
assert_eq!(error.class(), Failure::Conflict);
assert!(error.message().contains(&std::process::id().to_string()));
drop(held);
acquire_instance(&context).expect("dropping the daemon releases the lock");
}
#[test]
fn a_refused_lock_is_retried_before_the_refusal_is_believed() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let _held = acquire_instance(&context).expect("first daemon acquires the lock");
let started = std::time::Instant::now();
let error = acquire_instance(&context).expect_err("a lock still held is still a conflict");
let waited = started.elapsed();
assert_eq!(error.class(), Failure::Conflict);
assert!(
waited >= SINGLE_INSTANCE_SETTLE,
"a refusal must be retried across the settle window before it is believed, or a \
child's fork window reads as a second daemon. Gave up after {waited:?}, which is \
less than {SINGLE_INSTANCE_SETTLE:?}"
);
}
#[test]
fn only_active_autoscale_policies_are_loaded_in_stable_target_order() {
let active_z = fixtures::policy()
.repository("zeta/repo")
.autoscale("home", 1)
.active()
.build();
let active_a = fixtures::policy()
.repository("alpha/repo")
.autoscale("home", 1)
.active()
.build();
let pending = fixtures::policy()
.repository("pending/repo")
.autoscale("home", 1)
.build();
let monitor = fixtures::policy()
.repository("monitor/repo")
.monitor_only()
.active()
.build();
let mut draining = fixtures::policy()
.repository("draining/repo")
.autoscale("home", 1)
.active()
.build();
draining.request_disable().unwrap();
let mut disabled = fixtures::policy()
.repository("disabled/repo")
.autoscale("home", 1)
.active()
.build();
disabled.request_disable().unwrap();
disabled.drain_completed(0).unwrap();
let active_a_second = fixtures::policy()
.repository("alpha/repo")
.autoscale("home", 1)
.active()
.build();
let selected = active_autoscale_targets(vec![
active_z,
pending,
disabled,
monitor,
active_a,
draining,
active_a_second,
]);
let targets: Vec<_> = selected
.iter()
.map(|policies| policies[0].target.to_string())
.collect();
assert_eq!(
targets,
["alpha/repo", "draining/repo", "zeta/repo"],
"a draining policy is supervised until its last runner ends"
);
assert_eq!(selected[0].len(), 2, "same-target policies share one loop");
assert!(selected.iter().flatten().all(|policy| {
policy.may_start_runners() || policy.state() == PolicyState::Draining
}));
assert!(
!targets
.iter()
.any(|t| t == "pending/repo" || t == "monitor/repo"),
"{targets:?}"
);
assert!(!targets.iter().any(|t| t == "disabled/repo"), "{targets:?}");
}
#[test]
fn named_native_and_isolated_profiles_share_one_target_loop() {
let native = fixtures::policy()
.id(PolicyId::from_u128(1))
.active()
.build();
let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
isolated.activate().unwrap();
let monitor = fixtures::policy()
.id(PolicyId::from_u128(3))
.monitor_only()
.active()
.build();
let groups = active_autoscale_targets(vec![isolated, monitor, native]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
assert_eq!(groups[0][0].target, groups[0][1].target);
assert_ne!(groups[0][0].profile_name(), groups[0][1].profile_name());
}
#[test]
fn case_variant_targets_stay_in_one_loop_when_another_target_interleaves() {
let upper = fixtures::policy()
.id(PolicyId::from_u128(1))
.repository("A/repo")
.active()
.build();
let middle = fixtures::policy()
.id(PolicyId::from_u128(2))
.repository("B/repo")
.active()
.build();
let lower = fixtures::policy()
.id(PolicyId::from_u128(3))
.repository("a/repo")
.active()
.build();
let groups = active_autoscale_targets(vec![upper, middle, lower]);
assert_eq!(groups.len(), 2, "equal targets must share a daemon loop");
assert_eq!(groups[0].len(), 2);
assert_eq!(groups[0][0].target, groups[0][1].target);
assert_eq!(groups[1].len(), 1);
}
#[test]
fn a_draining_policy_is_still_supervised_or_its_last_runner_is_abandoned() {
let mut draining = fixtures::policy()
.repository("acme/repo")
.autoscale("home", 1)
.active()
.build();
draining.request_disable().expect("an active policy drains");
assert_eq!(draining.state(), PolicyState::Draining);
assert!(
!draining.may_start_runners(),
"the discriminator: it admits no new runners, which is why the old filter dropped it"
);
let selected = active_autoscale_targets(vec![draining]);
assert_eq!(
selected.len(),
1,
"without this the drain can never finish and the policy is stuck forever"
);
assert_eq!(selected[0][0].state(), PolicyState::Draining);
}
#[tokio::test]
async fn startup_recovery_keeps_each_targets_replacement_intent_in_its_launcher() {
let temporary = tempfile::tempdir().unwrap();
let store = Arc::new(SqliteStore::open_in_memory().unwrap());
let policy_a = fixtures::policy()
.id(PolicyId::from_u128(1))
.repository("acme/alpha")
.autoscale("home", 1)
.active()
.build();
let policy_b = fixtures::policy()
.id(PolicyId::from_u128(2))
.repository("acme/beta")
.autoscale("home", 1)
.active()
.build();
store.insert_policy(&policy_a).unwrap();
store.insert_policy(&policy_b).unwrap();
let attempt_for = |id: u128, policy: &ScalePolicy, directory: &str| {
let runtime = temporary.path().join(directory);
std::fs::create_dir_all(&runtime).unwrap();
fixtures::attempt()
.id(AttemptId::from_u128(id))
.policy_id(policy.id)
.runtime_path(runtime.to_string_lossy())
.build()
};
let attempt_a = attempt_for(11, &policy_a, "alpha");
let attempt_b = attempt_for(22, &policy_b, "beta");
store.record_attempt(&attempt_a).unwrap();
store.record_attempt(&attempt_b).unwrap();
let build_launcher = |policy: &ScalePolicy| {
let scoped = Arc::new(TargetRecoveryStore::new(
Arc::clone(&store) as Arc<dyn Store>,
std::slice::from_ref(policy),
));
let app_paths = runner_manager_platform::paths::AppPaths::rooted_at(temporary.path());
let launcher = LifecycleLauncher::new(
policy.to_persisted().host_id,
app_paths,
temporary.path().join("logs"),
1,
runner_manager_domain::attempt::RecoveryTimeouts::provisional(),
RetryPolicy::bounded(1, Duration::from_millis(1), Duration::from_millis(1)),
LifecyclePorts {
store: Arc::clone(&scoped) as Arc<dyn Store>,
github: Arc::new(RecoveryGithub {
expected: policy.target.clone(),
}),
packages: Arc::new(UnusedPackages),
processes: Arc::new(NativeProcesses::new()),
clock: Arc::new(FakeClock::default()),
demand: Arc::new(PersistentDemand),
delay: Arc::new(TokioRetryDelay),
events: Arc::new(NoAttemptEvents),
reconcile_events: Arc::new(runner_manager_agent::reconcile::EventLog::new()),
},
);
(launcher, scoped)
};
let (launcher_a, scoped_a) = build_launcher(&policy_a);
let (launcher_b, scoped_b) = build_launcher(&policy_b);
let placed_a = launcher_a
.recover_startup(std::slice::from_ref(&policy_a))
.await
.unwrap();
scoped_a.finish_recovery();
let placed_b = launcher_b
.recover_startup(std::slice::from_ref(&policy_b))
.await
.unwrap();
scoped_b.finish_recovery();
assert_eq!(placed_a.len(), 1);
assert_eq!(placed_a[0].policy, policy_a.id);
assert_eq!(placed_a[0].previous_attempt, attempt_a.id);
assert_eq!(placed_b.len(), 1);
assert_eq!(placed_b[0].policy, policy_b.id);
assert_eq!(placed_b[0].previous_attempt, attempt_b.id);
let consumed_a = launcher_a.supervise(&policy_a).await.unwrap();
let consumed_b = launcher_b.supervise(&policy_b).await.unwrap();
assert_eq!(consumed_a, placed_a);
assert_eq!(consumed_b, placed_b);
assert!(launcher_a.supervise(&policy_a).await.unwrap().is_empty());
assert!(launcher_b.supervise(&policy_b).await.unwrap().is_empty());
assert_eq!(scoped_a.attempts().unwrap().len(), 2);
assert_eq!(scoped_b.attempts().unwrap().len(), 2);
}
#[tokio::test(start_paused = true)]
async fn an_offline_target_neither_suppresses_contacts_nor_backs_off_a_healthy_target() {
let policy = |repository| {
fixtures::policy()
.repository(repository)
.autoscale("home", 1)
.active()
.build()
};
let healthy_report = ReconcileReport {
targets_read: 1,
next_poll: runner_manager_agent::reconcile::NextPoll {
delay: Duration::from_secs(1),
..Default::default()
},
..Default::default()
};
let offline_report = ReconcileReport {
failure: Some(RefreshState::Offline),
next_poll: runner_manager_agent::reconcile::NextPoll {
delay: Duration::from_secs(60),
..Default::default()
},
..Default::default()
};
let (healthy, healthy_calls) =
FakeTarget::repeating(policy("acme/healthy"), healthy_report);
let (offline, offline_calls) =
FakeTarget::repeating(policy("acme/offline"), offline_report);
let contacts = Arc::new(CountingContacts::default());
let (stop, _) = tokio::sync::watch::channel(false);
let healthy_loop = tokio::spawn(run_target_loop(
healthy,
stop.subscribe(),
never_upgraded(),
Arc::clone(&contacts) as Arc<dyn ContactRecorder>,
));
let offline_loop = tokio::spawn(run_target_loop(
offline,
stop.subscribe(),
never_upgraded(),
Arc::clone(&contacts) as Arc<dyn ContactRecorder>,
));
tokio::task::yield_now().await;
for _ in 0..3 {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
assert!(healthy_calls.load(Ordering::SeqCst) >= 3);
assert_eq!(offline_calls.load(Ordering::SeqCst), 1);
assert!(contacts.0.load(Ordering::SeqCst) >= 3);
stop.send(true).unwrap();
tokio::time::advance(Duration::from_secs(60)).await;
healthy_loop.await.unwrap().unwrap();
offline_loop.await.unwrap().unwrap();
}
#[derive(Default)]
struct AuthContacts {
rejected: AtomicUsize,
accepted: AtomicUsize,
}
impl ContactRecorder for AuthContacts {
fn record(&self) -> Result<(), CliError> {
Ok(())
}
fn rejected(&self) {
self.rejected.fetch_add(1, Ordering::SeqCst);
}
fn accepted(&self) {
self.accepted.fetch_add(1, Ordering::SeqCst);
}
}
#[tokio::test(start_paused = true)]
async fn a_rejected_credential_is_published_and_an_accepted_one_clears_it() {
let mut rejected = ReconcileReport {
failure: Some(RefreshState::Unauthorized),
..Default::default()
};
rejected.next_poll.delay = Duration::from_secs(1);
let mut accepted = ReconcileReport {
targets_read: 1,
..Default::default()
};
accepted.next_poll.delay = Duration::from_secs(1);
for (report, rejections, acceptances) in [(rejected, true, false), (accepted, false, true)]
{
let (target, _calls) = FakeTarget::repeating(
fixtures::policy()
.repository("acme/repo")
.autoscale("home", 1)
.active()
.build(),
report,
);
let contacts = Arc::new(AuthContacts::default());
let (stop, _) = tokio::sync::watch::channel(false);
let daemon = tokio::spawn(run_target_loop(
target,
stop.subscribe(),
never_upgraded(),
Arc::clone(&contacts) as Arc<dyn ContactRecorder>,
));
for _ in 0..3 {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
assert_eq!(contacts.rejected.load(Ordering::SeqCst) > 0, rejections);
assert_eq!(contacts.accepted.load(Ordering::SeqCst) > 0, acceptances);
stop.send(true).unwrap();
tokio::time::advance(Duration::from_secs(60)).await;
let _ = tokio::time::timeout(Duration::from_secs(1), daemon).await;
}
}
#[tokio::test(start_paused = true)]
async fn every_pass_re_reads_the_policy_before_deciding_anything() {
let mut report = ReconcileReport::default();
report.next_poll.delay = Duration::from_secs(1);
let (target, _calls) = FakeTarget::repeating(
fixtures::policy()
.repository("acme/repo")
.autoscale("home", 1)
.active()
.build(),
report,
);
let refreshes = Arc::clone(&target.refreshes);
let contacts = Arc::new(CountingContacts::default());
let (stop, _) = tokio::sync::watch::channel(false);
let daemon = tokio::spawn(run_target_loop(
target,
stop.subscribe(),
never_upgraded(),
contacts as Arc<dyn ContactRecorder>,
));
for _ in 0..4 {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
let seen = refreshes.load(Ordering::SeqCst);
assert!(
seen >= 2,
"the loop must ask for a fresh policy set on every pass, not once at startup: {seen}"
);
stop.send(true).unwrap();
tokio::time::advance(Duration::from_secs(60)).await;
let _ = tokio::time::timeout(Duration::from_secs(1), daemon).await;
}
#[tokio::test(start_paused = true)]
async fn a_drain_whose_target_never_reports_a_count_still_ends() {
let target = FakeTarget::never_readable(
fixtures::policy()
.repository("acme/repo")
.autoscale("home", 1)
.active()
.build(),
);
let calls = Arc::clone(&target.calls);
let terminations = Arc::clone(&target.busy_was_terminated);
let contacts = Arc::new(CountingContacts::default());
let (stop, _) = tokio::sync::watch::channel(false);
let daemon = tokio::spawn(run_target_loop(
target,
stop.subscribe(),
never_upgraded(),
contacts as Arc<dyn ContactRecorder>,
));
tokio::task::yield_now().await;
stop.send(true).unwrap();
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
assert!(
!daemon.is_finished(),
"the drain gave up before its deadline"
);
for _ in 0..DRAIN_DEADLINE.as_secs() {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
tokio::time::timeout(Duration::from_secs(1), daemon)
.await
.expect("an unreadable target must not hold the daemon open forever")
.unwrap()
.unwrap();
assert!(calls.load(Ordering::SeqCst) >= 2, "the drain never polled");
assert_eq!(
terminations.load(Ordering::SeqCst),
0,
"the deadline must not terminate a runner; startup recovery adopts it"
);
}
#[tokio::test(start_paused = true)]
async fn shutdown_loop_supervises_a_busy_child_to_completion_without_terminating_it() {
let target = FakeTarget::busy_then_finished(
fixtures::policy()
.repository("acme/repo")
.autoscale("home", 1)
.active()
.build(),
);
let calls = Arc::clone(&target.calls);
let terminations = Arc::clone(&target.busy_was_terminated);
let contacts = Arc::new(CountingContacts::default());
let (stop, _) = tokio::sync::watch::channel(false);
let daemon = tokio::spawn(run_target_loop(
target,
stop.subscribe(),
never_upgraded(),
contacts as Arc<dyn ContactRecorder>,
));
tokio::task::yield_now().await;
stop.send(true).unwrap();
for _ in 0..3 {
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
}
tokio::time::timeout(Duration::from_secs(1), daemon)
.await
.expect("the daemon exits after supervised completion")
.unwrap()
.unwrap();
assert!(
calls.load(Ordering::SeqCst) >= 3,
"shutdown skipped supervision"
);
assert_eq!(
terminations.load(Ordering::SeqCst),
0,
"busy child was terminated"
);
}
use runner_manager_platform::secrets::{
PlatformSecretStore, Removal, SecretScope, SecretStoreError,
};
fn handover_document() -> String {
format!(
r#"{{"access_token":"{}","refresh_token":"{}","access_expires_at":"2026-09-06T20:00:00Z","refresh_expires_at":"2027-03-05T12:00:00Z"}}"#,
format_args!("{}{}", "ghu_", "handoverAccessCanary0000000000"),
format_args!("{}{}", "ghr_", "handoverRefreshCanary000000000"),
)
}
fn handover_store(root: &std::path::Path) -> PlatformSecretStore {
PlatformSecretStore::rooted_at(SecretScope::User, root).expect("a rooted store resolves")
}
fn held(store: &dyn SecretStore) -> Option<String> {
store
.load()
.expect("the store is readable")
.map(|secret| secret.expose_secret().to_string())
}
#[test]
fn the_new_binary_is_handed_exactly_the_stored_credential() {
let root = tempfile::tempdir().unwrap();
let store = handover_store(root.path());
store
.store(&SecretString::from(handover_document()))
.unwrap();
let mut handed = None;
let outcome = hand_over(&store, |document| {
handed = Some(document.expose_secret().to_string());
Ok(())
});
assert_eq!(outcome, Handover::HandedOver);
let handed: serde_json::Value =
serde_json::from_str(&handed.expect("the new binary was run")).unwrap();
let original: serde_json::Value = serde_json::from_str(&handover_document()).unwrap();
assert_eq!(
handed, original,
"both halves of the pair reach the new binary"
);
}
#[test]
fn a_bare_token_is_handed_over_as_the_envelope() {
let root = tempfile::tempdir().unwrap();
let store = handover_store(root.path());
let bare = format!("{}{}", "ghu_", "handoverBareToken000000000000");
store.store(&SecretString::from(bare.clone())).unwrap();
let mut handed = None;
hand_over(&store, |document| {
handed = Some(document.expose_secret().to_string());
Ok(())
});
let handed: serde_json::Value =
serde_json::from_str(&handed.expect("the new binary was run")).unwrap();
assert_eq!(handed["access_token"], serde_json::Value::String(bare));
}
#[test]
fn nothing_is_run_when_nothing_is_stored() {
let root = tempfile::tempdir().unwrap();
let store = handover_store(root.path());
let outcome = hand_over(&store, |_| panic!("there was nothing to hand over"));
assert_eq!(outcome, Handover::NothingStored);
}
#[test]
fn nothing_is_run_when_this_daemon_cannot_read_its_own_credential() {
let outcome = hand_over(&RefusingStore, |_| panic!("there was nothing to hand over"));
assert_eq!(outcome, Handover::Unreadable);
}
#[test]
fn a_credential_the_failed_attempt_removed_is_put_back() {
let root = tempfile::tempdir().unwrap();
let store = handover_store(root.path());
store
.store(&SecretString::from(handover_document()))
.unwrap();
let outcome = hand_over(&store, |_| {
store.delete().unwrap();
Err("the keychain refused the write".to_string())
});
assert_eq!(outcome, Handover::Failed { restored: true });
let restored: serde_json::Value =
serde_json::from_str(&held(&store).expect("the credential is back")).unwrap();
let original: serde_json::Value = serde_json::from_str(&handover_document()).unwrap();
assert_eq!(restored, original);
}
#[test]
fn a_credential_still_in_place_after_a_failure_is_left_alone() {
let root = tempfile::tempdir().unwrap();
let store = handover_store(root.path());
store
.store(&SecretString::from(handover_document()))
.unwrap();
let replacement = format!("{}{}", "ghu_", "handoverWrittenByTheNewBuild00");
let outcome = hand_over(&store, |_| {
store
.store(&SecretString::from(replacement.clone()))
.unwrap();
Err("the new binary exited non-zero after writing".to_string())
});
assert_eq!(outcome, Handover::Failed { restored: false });
assert_eq!(held(&store), Some(replacement));
}
#[derive(Debug)]
struct RefusingStore;
impl RefusingStore {
fn refusal() -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "refused")
}
}
impl SecretStore for RefusingStore {
fn scope(&self) -> SecretScope {
SecretScope::User
}
fn location(&self) -> String {
"a store that refuses".to_string()
}
fn store(&self, _secret: &SecretString) -> Result<(), SecretStoreError> {
panic!("the handover must not write a store it could not read")
}
fn ensure_writable(&self) -> Result<(), SecretStoreError> {
Ok(())
}
fn load(&self) -> Result<Option<SecretString>, SecretStoreError> {
Err(SecretStoreError::Load {
scope: self.scope(),
location: self.location(),
source: Self::refusal(),
})
}
fn delete(&self) -> Result<Removal, SecretStoreError> {
panic!("the handover never deletes")
}
fn protection(
&self,
) -> Result<runner_manager_platform::secrets::Protection, SecretStoreError> {
panic!("not asked")
}
}
}