use std::io;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::extract::connect_info::Connected;
use axum::extract::{ConnectInfo, FromRequestParts, Query, State};
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::serve::IncomingStream;
use axum::{Json, Router};
use runnerd_core::api::{
ErrorResponse, Pairing, PairingQuery, Pairings, Principal, ShutdownRequest, Status,
};
use runnerd_core::config::ByteSize;
use runnerd_core::scheduler::engine::{Handle, RunningJobs};
use runnerd_web::{PAIRING_LIFETIME, PAIRING_MAX, PairError, Sessions};
use tokio::net::UnixListener;
use tokio::sync::{Mutex, watch};
use tracing::info;
mod auto;
mod control;
mod events;
mod history;
mod records;
pub(crate) fn default_socket() -> Option<PathBuf> {
std::env::var_os("XDG_RUNTIME_DIR").map(|dir| Path::new(&dir).join("runnerd.sock"))
}
#[derive(Debug, Clone, Copy)]
struct Peer {
uid: Option<u32>,
}
impl Connected<IncomingStream<'_, UnixListener>> for Peer {
fn connect_info(stream: IncomingStream<'_, UnixListener>) -> Self {
Peer {
uid: stream.io().peer_cred().ok().map(|cred| cred.uid()),
}
}
}
pub(crate) type MemoryMax = fn(&str) -> io::Result<Option<ByteSize>>;
pub(crate) type SliceCpus = fn(&str) -> io::Result<u64>;
pub(crate) type SyncDir = fn(&Path) -> io::Result<()>;
pub(crate) use control::sync_dir;
pub(crate) struct ConfigSource {
pub path: PathBuf,
pub text: String,
pub memory_max: MemoryMax,
pub cpus: SliceCpus,
pub sync_dir: SyncDir,
pub auto_every: std::time::Duration,
}
pub(crate) use auto::EVERY as AUTO_SIZING_EVERY;
struct ConfigText {
path: PathBuf,
text: String,
}
#[derive(Clone)]
struct AppState {
handle: Handle,
owner: u32,
stopping: watch::Receiver<bool>,
config: Arc<Mutex<ConfigText>>,
memory_max: MemoryMax,
cpus: SliceCpus,
sync_dir: SyncDir,
sessions: Arc<Sessions>,
}
struct Authenticated(Principal);
impl FromRequestParts<AppState> for Authenticated {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> {
let peer = parts
.extensions
.get::<ConnectInfo<Peer>>()
.map(|info| info.0);
match peer.and_then(|peer| peer.uid) {
Some(uid) if uid == state.owner => Ok(Authenticated(Principal::LocalOwner { uid })),
_ => Err(error(
StatusCode::FORBIDDEN,
"only the user runnerd runs as may use its socket",
)),
}
}
}
fn error(status: StatusCode, message: &str) -> Response {
(
status,
Json(ErrorResponse {
message: message.to_owned(),
}),
)
.into_response()
}
fn router(state: AppState) -> Router {
Router::new()
.route("/v1/status", get(status))
.route("/v1/repositories/pause", post(control::pause_all))
.route("/v1/repositories/resume", post(control::resume_all))
.route(
"/v1/repositories/{owner}/{name}/pause",
post(control::pause),
)
.route(
"/v1/repositories/{owner}/{name}/resume",
post(control::resume),
)
.route("/v1/profiles/{name}/reseed", post(control::reseed))
.route("/v1/caches/{name}/clean", post(control::clean_cache))
.route("/v1/config", get(control::config))
.route("/v1/config", put(control::replace_config))
.route("/v1/config/check", post(control::check_config))
.route("/v1/doctor", get(control::doctor))
.route("/v1/shutdown", post(shutdown))
.route("/v1/dashboard/sign-out", post(sign_out_dashboard))
.route("/v1/dashboard/pairings", post(pair_dashboard))
.route("/v1/jobs", get(history::jobs))
.route("/v1/jobs/{id}", get(history::job))
.route("/v1/jobs/{id}/samples", get(history::samples))
.route("/v1/jobs/{id}/logs", get(history::logs))
.route("/v1/host/samples", get(history::host_samples))
.route("/v1/stats", get(history::stats))
.route("/v1/sizing", get(history::sizing))
.route("/v1/caches", get(history::caches))
.route("/v1/caches/history", get(history::cache_history))
.route("/v1/events", get(events::events))
.route("/v1/records", get(records::list))
.route("/v1/records", delete(records::remove_many))
.route("/v1/records/{job}", delete(records::remove))
.route("/v1/records/{job}/shell", post(records::shell))
.fallback(|| async { error(StatusCode::NOT_FOUND, "no such endpoint") })
.with_state(state)
}
async fn status(_: Authenticated, State(state): State<AppState>) -> Json<Status> {
Json(state.handle.status.borrow().clone())
}
async fn shutdown(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
request: Option<Json<ShutdownRequest>>,
) -> StatusCode {
let Json(request) = request.unwrap_or_default();
info!(
"shutdown (force: {}) requested by {principal:?}",
request.force
);
state.handle.control.shutdown(if request.force {
RunningJobs::Kill
} else {
RunningJobs::Keep
});
StatusCode::ACCEPTED
}
async fn sign_out_dashboard(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
) -> Response {
match state.sessions.sign_out_all() {
Ok(()) => {
info!("every browser signed out of the dashboard by {principal:?}");
StatusCode::NO_CONTENT.into_response()
}
Err(failure) => error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("cannot write a new dashboard session secret: {failure}"),
),
}
}
async fn pair_dashboard(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
Query(query): Query<PairingQuery>,
) -> Response {
let refused = |failure: &PairError| {
let status = match failure {
PairError::DashboardOff | PairError::NoOrigins => StatusCode::CONFLICT,
PairError::NotListed(_) => StatusCode::BAD_REQUEST,
PairError::SignedOut => StatusCode::UNAUTHORIZED,
PairError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
error(status, &failure.to_string())
};
let origins = match (query.origin, state.sessions.origins()) {
(Some(origin), _) => vec![origin],
(None, None) => return refused(&PairError::DashboardOff),
(None, Some(origins)) if origins.is_empty() => return refused(&PairError::NoOrigins),
(None, Some(origins)) => origins,
};
if origins.len() > PAIRING_MAX {
return error(
StatusCode::CONFLICT,
&format!(
"[web] origins lists {} addresses, more than the {PAIRING_MAX} pairing codes alive at once; pick \
one with `runnerctl web --qr --base <origin>`",
origins.len()
),
);
}
let now = std::time::Instant::now();
let mut links = Vec::new();
for origin in origins {
match state.sessions.pair(&origin, now) {
Ok(url) => links.push(Pairing {
origin,
url,
lifetime: PAIRING_LIFETIME.as_secs(),
}),
Err(failure) => return refused(&failure),
}
}
info!(
"{} dashboard pairing link(s) made for {principal:?}",
links.len()
);
Json(Pairings { links }).into_response()
}
pub(crate) async fn bind(path: &Path) -> io::Result<UnixListener> {
if path.exists() {
if tokio::net::UnixStream::connect(path).await.is_ok() {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("{} is in use: runnerd runs already", path.display()),
));
}
std::fs::remove_file(path)?;
}
let listener = UnixListener::bind(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(listener)
}
pub(crate) async fn serve(
listener: UnixListener,
handle: Handle,
config: ConfigSource,
sessions: Arc<Sessions>,
stop: impl Future<Output = ()> + Send + 'static,
) -> io::Result<()> {
let owner = std::fs::metadata("/proc/self")?.uid();
serve_for(listener, handle, config, sessions, owner, stop).await
}
async fn serve_for(
listener: UnixListener,
handle: Handle,
config: ConfigSource,
sessions: Arc<Sessions>,
owner: u32,
stop: impl Future<Output = ()> + Send + 'static,
) -> io::Result<()> {
let (stopping, stopping_receiver) = watch::channel(false);
let state = AppState {
handle,
owner,
stopping: stopping_receiver,
config: Arc::new(Mutex::new(ConfigText {
path: config.path,
text: config.text,
})),
memory_max: config.memory_max,
cpus: config.cpus,
sync_dir: config.sync_dir,
sessions,
};
tokio::spawn(auto::run(state.clone(), config.auto_every));
let app = router(state).into_make_service_with_connect_info::<Peer>();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
stop.await;
let _ = stopping.send(true);
})
.await
}
#[cfg(test)]
mod tests;