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;
use runner_manager_agent::lifecycle::{
CachedRuntimePackages, LifecycleGithub, LifecycleGithubObservation, LifecycleLauncher,
LifecyclePorts, NativeProcesses, NoAttemptEvents, PersistentDemand, RetryPolicy,
TokioRetryDelay,
};
use runner_manager_agent::package::{
CachePorts, ExponentialBackoff, GatewayCatalog, HttpFetcher, PackageCache,
};
use runner_manager_agent::reconcile::{
FileAllocationLock, GatewayDemand, RandomJitter, ReconcileReport, Reconciler, ReconcilerPorts,
RepositoryDirectory, TeeEvents, TracingEvents,
};
use runner_manager_domain::attempt::{FailureReason, active_count_for};
use runner_manager_domain::model::{AttemptId, Clock, Org, OwnerRepo, ScaleTarget};
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, RestInventory};
use runner_manager_github::{
AppRegistration, AuthenticatedClient, CredentialRenewal, GithubError, UserAccessToken,
};
use runner_manager_platform::lock::{HostLock, LockError, LockKind};
use runner_manager_platform::service::{InstallRecord, record_github_contact};
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(_) => {
let runtime = super::runtime()?;
runtime.block_on(run(context, out, service_shutdown))
}
}
}
async fn run(
context: &Context,
out: &mut dyn Write,
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
) -> Result<(), CliError> {
let _instance = acquire_instance(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() {
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(());
}
version = async {
match own_binary.clone() {
Some(path) => wait_for_upgrade(path).await,
None => std::future::pending().await,
}
} => {
return stop_for_upgrade(own_binary.as_deref(), &version, out);
}
}
}
let mode = host.service_start_mode;
let secrets = context.secret_store(mode)?;
let secret = secrets
.load()
.map_err(|source| {
CliError::with_remedy(
Failure::SecretStore,
format!("cannot read the stored GitHub credential: {source}"),
"runner-manager auth login",
)
})?
.ok_or_else(|| {
CliError::with_remedy(
Failure::NotAuthenticated,
"no GitHub credential is stored for this daemon's start mode",
"runner-manager auth login",
)
})?;
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),
));
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 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 shared_lock = Arc::new(FileAllocationLock::new(paths));
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(NativeProcesses::new()),
clock: Arc::clone(&clock),
demand: Arc::new(PersistentDemand),
delay: Arc::new(TokioRetryDelay),
events: Arc::new(NoAttemptEvents),
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(
RestDemand::new(Arc::clone(&client), Arc::clone(&clock)),
cancel.clone(),
));
managed_targets.push(ManagedTarget {
policies,
store: Arc::clone(&store) as Arc<dyn Store>,
reconciler: Reconciler::new(
host.clone(),
ReconcilerPorts {
demand,
launcher,
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,
});
}
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(()),
});
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 early = tokio::select! {
signal = wait_for_shutdown(service_shutdown) => {
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
}
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(own_binary.as_deref(), &version, out);
}
let reason = restart_reason.unwrap_or("this daemon was asked to reload");
writeln!(out, "every runner finished; reloading").map_err(failed)?;
return Err(CliError::with_remedy(
Failure::UpgradePending,
format!(
"{reason}, and every runner this daemon held has finished; stopping so the service manager starts one that reads the new set"
),
"runner-manager service status",
));
}
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(())
}
fn stop_for_upgrade(
source: Option<&std::path::Path>,
version: &str,
out: &mut dyn Write,
) -> Result<(), CliError> {
if let Some(source) = source
&& let Err(error) = replace_own_binary(source)
{
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",
))
}
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;
}
let reported = String::from_utf8(output.stdout).ok()?;
let reported = reported.split_whitespace().last()?.to_string();
(reported != env!("CARGO_PKG_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.to_string().cmp(&right.target.to_string()));
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 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>,
}
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_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)
.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>;
}
struct FileContactRecorder {
paths: runner_manager_platform::paths::AppPaths,
clock: Arc<dyn Clock>,
write: Mutex<()>,
}
impl ContactRecorder for FileContactRecorder {
fn record(&self) -> Result<(), CliError> {
let _write = self.write.lock().map_err(|_| {
CliError::new(
Failure::LocalState,
"cannot lock the last successful GitHub contact record",
)
})?;
record_github_contact(&self.paths, self.clock.now()).map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot record the last successful GitHub contact: {source}"),
)
})
}
}
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();
let report = target.reconcile().await;
if draining.is_none() && report.reached_github() {
contacts.record()?;
}
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 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 std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use runner_manager_agent::lifecycle::{JitRequestFailure, 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::jit::JitRegistration;
use runner_manager_github::rest::RefreshState;
use runner_manager_testkit::clock::FakeClock;
use runner_manager_testkit::fixtures;
#[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"
);
}
#[test]
fn an_idle_host_uses_the_normal_upgrade_handover() {
let mut output = Vec::new();
let error = stop_for_upgrade(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}");
}
#[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 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();
}
#[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"
);
}
}