mod server;
mod state;
use std::fmt;
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::sync::Notify;
use crate::auth::Credentials;
use crate::client::{Client, ClientBuilder};
use crate::retry::RetryPolicy;
use crate::types::{JobRequestId, RunnerId, ScaleSetId, ScaleSetStatistics};
pub use state::{EmulatedJob, EmulatedRunner, EmulatedSession, EmulatorState, JobState};
pub const DEFAULT_PAT: &str = "ghp_emulator";
pub const ORG: &str = "org";
#[derive(Debug, Clone)]
pub(crate) struct EmulatorConfig {
pub pat: String,
pub app_installation_id: Option<i64>,
pub admin_token_ttl: Duration,
pub long_poll: Duration,
pub auto_run: Option<Duration>,
pub runner_groups: Vec<String>,
}
impl Default for EmulatorConfig {
fn default() -> Self {
Self {
pat: DEFAULT_PAT.into(),
app_installation_id: None,
admin_token_ttl: Duration::from_secs(3600),
long_poll: Duration::from_secs(50),
auto_run: None,
runner_groups: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default)]
#[must_use]
pub struct EmulatorBuilder {
config: EmulatorConfig,
addr: Option<SocketAddr>,
}
impl EmulatorBuilder {
pub fn pat(mut self, token: impl Into<String>) -> Self {
self.config.pat = token.into();
self
}
pub fn github_app(mut self, installation_id: i64) -> Self {
self.config.app_installation_id = Some(installation_id);
self
}
pub fn admin_token_ttl(mut self, ttl: Duration) -> Self {
self.config.admin_token_ttl = ttl;
self
}
pub fn long_poll(mut self, hold: Duration) -> Self {
self.config.long_poll = hold;
self
}
pub fn auto_run_jobs(mut self, duration: Duration) -> Self {
self.config.auto_run = Some(duration);
self
}
pub fn runner_group(mut self, name: impl Into<String>) -> Self {
self.config.runner_groups.push(name.into());
self
}
pub fn bind(mut self, addr: SocketAddr) -> Self {
self.addr = Some(addr);
self
}
pub async fn start(self) -> Emulator {
self.try_start()
.await
.expect("failed to start the emulator")
}
pub async fn try_start(self) -> std::io::Result<Emulator> {
let addr = self
.addr
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0)));
let listener = tokio::net::TcpListener::bind(addr).await?;
let addr = listener.local_addr()?;
let base_url = format!("http://{addr}");
let inner = Arc::new(Inner {
state: Mutex::new(state::State::new(self.config, base_url)),
generation: std::sync::atomic::AtomicU64::new(0),
changed: Notify::new(),
requests: Mutex::new(Vec::new()),
});
let server = tokio::spawn(server::serve(listener, Arc::clone(&inner)));
Ok(Emulator {
inner,
addr,
server,
})
}
}
pub(crate) struct Inner {
pub state: Mutex<state::State>,
pub generation: std::sync::atomic::AtomicU64,
pub changed: Notify,
pub requests: Mutex<Vec<RecordedRequest>>,
}
impl Inner {
pub fn lock(&self) -> MutexGuard<'_, state::State> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn on_change(self: &Arc<Self>) {
let (started, duration, generation) = {
let mut state = self.lock();
let generation = self.generation.load(Ordering::SeqCst);
match state.config.auto_run {
Some(d) => (state.match_jobs(), d, generation),
None => (Vec::new(), Duration::ZERO, generation),
}
};
for job in started {
let inner = Arc::downgrade(self);
tokio::spawn(async move {
tokio::time::sleep(duration).await;
let Some(inner) = inner.upgrade() else { return };
{
let mut state = inner.lock();
if inner.generation.load(Ordering::SeqCst) != generation {
return;
}
let _ = state.complete_job(job, None);
}
inner.on_change();
});
}
self.changed.notify_waiters();
}
pub fn reset(self: &Arc<Self>) {
{
let mut state = self.lock();
self.generation.fetch_add(1, Ordering::SeqCst);
let config = state.config.clone();
let base = state.base_url.clone();
*state = state::State::new(config, base);
}
self.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
self.on_change();
}
}
pub struct Emulator {
inner: Arc<Inner>,
addr: SocketAddr,
server: tokio::task::JoinHandle<()>,
}
impl fmt::Debug for Emulator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Emulator")
.field("addr", &self.addr)
.finish_non_exhaustive()
}
}
impl Drop for Emulator {
fn drop(&mut self) {
self.server.abort();
}
}
impl Emulator {
pub async fn start() -> Self {
Self::builder().start().await
}
pub fn builder() -> EmulatorBuilder {
EmulatorBuilder::default()
}
pub fn uri(&self) -> String {
format!("http://{}", self.addr)
}
pub fn addr(&self) -> SocketAddr {
self.addr
}
pub fn config_url(&self) -> String {
format!("{}/{ORG}", self.uri())
}
pub fn credentials(&self) -> Credentials {
Credentials::personal_access_token(self.inner.lock().config.pat.clone())
.expect("non-empty PAT")
}
pub fn client_builder(&self) -> ClientBuilder {
Client::builder(self.config_url(), self.credentials())
.allow_http(true)
.retry_policy(
RetryPolicy::default()
.min_wait(Duration::from_millis(1))
.max_wait(Duration::from_millis(10)),
)
}
pub fn client(&self) -> Client {
self.client_builder()
.build()
.expect("valid emulator client")
}
pub fn state(&self) -> EmulatorState {
self.inner.lock().snapshot()
}
pub fn statistics(&self, scale_set_id: ScaleSetId) -> Option<ScaleSetStatistics> {
self.state().statistics(scale_set_id)
}
pub fn queue_job<I, S>(&self, scale_set_id: ScaleSetId, labels: I) -> JobRequestId
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let labels = labels.into_iter().map(Into::into).collect();
let id = self
.inner
.lock()
.queue_job(scale_set_id, labels)
.unwrap_or_else(|e| panic!("queue_job: {}", e.message));
self.inner.on_change();
id
}
pub fn start_job(&self, job: JobRequestId, runner_name: Option<&str>) -> RunnerId {
let runner = self
.inner
.lock()
.start_job(job, runner_name)
.unwrap_or_else(|e| panic!("start_job: {}", e.message));
self.inner.on_change();
runner
}
pub fn complete_job(&self, job: JobRequestId, result: Option<&str>) {
self.inner
.lock()
.complete_job(job, result)
.unwrap_or_else(|e| panic!("complete_job: {}", e.message));
self.inner.on_change();
}
pub fn expire_admin_tokens(&self) {
self.inner.lock().expire_admin_tokens();
}
pub fn expire_queue_token(&self, scale_set_id: ScaleSetId) {
assert!(
self.inner.lock().expire_queue_token(scale_set_id),
"scale set {scale_set_id} has no session"
);
}
pub fn expire_session(&self, scale_set_id: ScaleSetId) {
assert!(
self.inner.lock().expire_session(scale_set_id),
"scale set {scale_set_id} has no session"
);
self.inner.on_change();
}
pub fn inject(&self, fault: Fault) {
self.inner.lock().faults.push(fault);
}
pub fn clear_faults(&self) {
self.inner.lock().faults.clear();
}
pub fn requests(&self) -> Vec<RecordedRequest> {
self.inner
.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn requests_to(&self, endpoint: Endpoint) -> Vec<RecordedRequest> {
self.requests()
.into_iter()
.filter(|r| r.endpoint == endpoint)
.collect()
}
pub fn reset(&self) {
self.inner.reset();
}
pub async fn wait_until<F>(&self, timeout: Duration, mut condition: F) -> EmulatorState
where
F: FnMut(&EmulatorState) -> bool,
{
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.inner.changed.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let state = self.state();
if condition(&state) {
return state;
}
let tick = tokio::time::sleep(Duration::from_millis(50));
tokio::select! {
() = notified => {}
() = tick => {}
() = tokio::time::sleep_until(deadline) => {
panic!("condition not met within {timeout:?}; state: {state:#?}");
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum Endpoint {
Any,
AccessToken,
RegistrationToken,
AdminConnection,
RunnerGroups,
ScaleSets,
GenerateJitConfig,
Runners,
Sessions,
GetMessage,
DeleteMessage,
AcquireJobs,
Control,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[non_exhaustive]
pub enum FaultResponse {
Status {
status: u16,
},
#[serde(rename_all = "camelCase")]
RateLimit {
status: u16,
retry_after_secs: Option<u64>,
primary: bool,
},
#[serde(rename_all = "camelCase")]
Delay {
millis: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fault {
pub endpoint: Endpoint,
pub response: FaultResponse,
#[serde(default)]
pub times: Option<u32>,
}
impl Fault {
pub fn status(endpoint: Endpoint, status: u16) -> Self {
Self::new(endpoint, FaultResponse::Status { status })
}
pub fn primary_rate_limit(endpoint: Endpoint, retry_after: Option<Duration>) -> Self {
Self::new(
endpoint,
FaultResponse::RateLimit {
status: 403,
retry_after_secs: retry_after.map(|d| d.as_secs()),
primary: true,
},
)
}
pub fn secondary_rate_limit(endpoint: Endpoint, retry_after: Option<Duration>) -> Self {
Self::new(
endpoint,
FaultResponse::RateLimit {
status: 403,
retry_after_secs: retry_after.map(|d| d.as_secs()),
primary: false,
},
)
}
pub fn too_many_requests(endpoint: Endpoint, retry_after: Option<Duration>) -> Self {
Self::new(
endpoint,
FaultResponse::RateLimit {
status: 429,
retry_after_secs: retry_after.map(|d| d.as_secs()),
primary: false,
},
)
}
pub fn delay(endpoint: Endpoint, delay: Duration) -> Self {
Self::new(
endpoint,
FaultResponse::Delay {
millis: u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
},
)
}
fn new(endpoint: Endpoint, response: FaultResponse) -> Self {
Self {
endpoint,
response,
times: None,
}
}
#[must_use]
pub fn times(mut self, n: u32) -> Self {
self.times = Some(n);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RecordedRequest {
pub method: String,
pub path: String,
pub query: Option<String>,
pub endpoint: Endpoint,
pub status: u16,
pub user_agent: Option<String>,
pub max_capacity: Option<u32>,
}