use std::io::{self, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use axum::Json;
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use runnerd_core::api::{Busy, ConfigCheck, ConfigFile, Doctor, PauseQuery, ReplaceQuery};
use runnerd_core::config::{Config, revision};
use runnerd_core::scheduler::engine::{Commit, ControlError, RunningJobs, RunningSlots, Target};
use runnerd_core::sizing::Window;
use tracing::info;
use super::{AppState, Authenticated, ConfigText, MemoryMax, SyncDir, error};
pub(super) fn refused(control_error: &ControlError) -> Response {
let status = match control_error {
ControlError::NotFound(_) => StatusCode::NOT_FOUND,
ControlError::Busy { message, jobs } => {
return (
StatusCode::CONFLICT,
Json(Busy {
message: message.clone(),
jobs: jobs.clone(),
}),
)
.into_response();
}
ControlError::Conflict(_) => StatusCode::CONFLICT,
ControlError::Invalid(_) => StatusCode::UNPROCESSABLE_ENTITY,
ControlError::Failed(_) | ControlError::NotRecorded(_) => StatusCode::INTERNAL_SERVER_ERROR,
ControlError::Stopped => StatusCode::SERVICE_UNAVAILABLE,
};
error(status, &control_error.to_string())
}
pub(super) async fn pause(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
UrlPath((owner, name)): UrlPath<(String, String)>,
Query(query): Query<PauseQuery>,
) -> Response {
let repository = format!("{owner}/{name}");
info!(
"{repository}: pause (force: {}) requested by {principal:?}",
query.force
);
pause_target(&state, Target::Repository(repository), &query).await
}
pub(super) async fn pause_all(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
Query(query): Query<PauseQuery>,
) -> Response {
info!(
"pause of every repository (force: {}) requested by {principal:?}",
query.force
);
pause_target(&state, Target::All, &query).await
}
async fn pause_target(state: &AppState, target: Target, query: &PauseQuery) -> Response {
let jobs = if query.force {
RunningJobs::Kill
} else {
RunningJobs::Keep
};
match state.handle.control.pause(target, jobs).await {
Ok(paused) => Json(paused).into_response(),
Err(control_error) => refused(&control_error),
}
}
pub(super) async fn resume(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
UrlPath((owner, name)): UrlPath<(String, String)>,
) -> Response {
let repository = format!("{owner}/{name}");
let target = Target::Repository(repository.clone());
match state.handle.control.resume(target).await {
Ok(()) => {
info!("{repository} resumed by {principal:?}");
StatusCode::NO_CONTENT.into_response()
}
Err(control_error) => refused(&control_error),
}
}
pub(super) async fn resume_all(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
) -> Response {
match state.handle.control.resume(Target::All).await {
Ok(()) => {
info!("every repository resumed by {principal:?}");
StatusCode::NO_CONTENT.into_response()
}
Err(control_error) => refused(&control_error),
}
}
pub(super) async fn reseed(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
UrlPath(profile): UrlPath<String>,
) -> Response {
info!("{profile}: reseed requested by {principal:?}");
match state.handle.control.reseed(&profile).await {
Ok(()) => StatusCode::ACCEPTED.into_response(),
Err(control_error) => refused(&control_error),
}
}
pub(super) async fn clean_cache(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
UrlPath(name): UrlPath<String>,
) -> Response {
info!("{name}: clean requested by {principal:?}");
match state.handle.control.clean_cache(&name).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(control_error) => refused(&control_error),
}
}
pub(super) async fn config(_: Authenticated, State(state): State<AppState>) -> Json<ConfigFile> {
let file = state.config.lock().await;
Json(ConfigFile {
path: file.path.display().to_string(),
text: file.text.clone(),
revision: Some(revision(&file.text)),
})
}
async fn check(
memory_max: MemoryMax,
text: String,
) -> Result<(Option<Config>, ConfigCheck), tokio::task::JoinError> {
tokio::task::spawn_blocking(move || Config::check(&text, memory_max)).await
}
fn failed(join_error: &tokio::task::JoinError) -> Response {
error(StatusCode::INTERNAL_SERVER_ERROR, &join_error.to_string())
}
pub(super) async fn check_config(
_: Authenticated,
State(state): State<AppState>,
text: String,
) -> Response {
match check(state.memory_max, text).await {
Ok((_, checked)) => Json(checked).into_response(),
Err(join_error) => failed(&join_error),
}
}
pub(super) async fn replace_config(
Authenticated(principal): Authenticated,
State(state): State<AppState>,
Query(query): Query<ReplaceQuery>,
text: String,
) -> Response {
let mut file = state.config.lock().await;
let on_disk = match query.revision {
None => None,
Some(read) if read != revision(&file.text) => {
return error(
StatusCode::CONFLICT,
&format!(
"the config changed since revision {read} was read (now {}); read it again and redo the change",
revision(&file.text)
),
);
}
Some(_) => {
if std::fs::read_to_string(&file.path).ok().as_ref() != Some(&file.text) {
return error(
StatusCode::CONFLICT,
&format!(
"{} changed on disk since runnerd read it; saving would overwrite that edit",
file.path.display()
),
);
}
Some(file.text.clone())
}
};
match replace(&state, &mut file, text, RunningSlots::Drain, on_disk).await {
Ok(checked) => {
info!("{} replaced by {principal:?}", file.path.display());
Json(checked).into_response()
}
Err(Refusal::Invalid(checked)) => {
(StatusCode::UNPROCESSABLE_ENTITY, Json(checked)).into_response()
}
Err(Refusal::Control(control_error)) => refused(&control_error),
Err(Refusal::Join(join_error)) => failed(&join_error),
}
}
pub(super) enum Refusal {
Invalid(ConfigCheck),
Control(ControlError),
Join(tokio::task::JoinError),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::Invalid(checked) => f.write_str(&checked.message),
Refusal::Control(control_error) => control_error.fmt(f),
Refusal::Join(join_error) => join_error.fmt(f),
}
}
}
pub(super) async fn replace(
state: &AppState,
file: &mut ConfigText,
text: String,
running: RunningSlots,
on_disk: Option<String>,
) -> Result<ConfigCheck, Refusal> {
let (config, mut checked) = check(state.memory_max, text.clone())
.await
.map_err(Refusal::Join)?;
let Some(config) = config else {
return Err(Refusal::Invalid(checked));
};
let cpus = if config.host.cpu_overcommit.is_some() {
let (read, slice) = (state.cpus, config.host.slice.clone());
match tokio::task::spawn_blocking(move || read(&slice)).await {
Ok(Ok(cpus)) => Some(cpus),
Ok(Err(io_error)) => {
checked.valid = false;
checked.message = format!(
"cpu_overcommit needs the slice {}'s CPUs, and they cannot be read: {io_error}",
config.host.slice
);
return Err(Refusal::Invalid(checked));
}
Err(join_error) => return Err(Refusal::Join(join_error)),
}
} else {
None
};
let database = |config: &Config| config.history.database.clone();
if Config::parse(&file.text).ok().as_ref().map(database) != Some(database(&config)) {
checked
.warnings
.push("history.database takes effect when runnerd restarts".to_owned());
}
let (path, written, sync) = (file.path.clone(), text.clone(), state.sync_dir);
let commit = Commit::new(move || {
if let Some(expected) = on_disk {
match std::fs::read_to_string(&path) {
Ok(text) if text == expected => {}
Ok(_) => {
return Err(format!(
"{} changed on disk since it was read; not written",
path.display()
));
}
Err(io_error) => return Err(format!("cannot read {}: {io_error}", path.display())),
}
}
commit(&path, &written, sync)
});
let warning = state
.handle
.control
.replace_config(config, checked.memory_max, cpus, commit, running)
.await
.map_err(Refusal::Control)?;
checked.warnings.extend(warning);
checked.revision = Some(revision(&text));
file.text = text;
Ok(checked)
}
fn commit(path: &Path, text: &str, sync: SyncDir) -> Result<Option<String>, String> {
match write_atomically(path, text, sync) {
Ok(None) => Ok(None),
Ok(Some(io_error)) => Ok(Some(format!(
"{} is replaced, but flushing its directory to disk failed, so a crash may bring back the old file: {io_error}",
path.display()
))),
Err(io_error) => Err(format!("cannot write {}: {io_error}", path.display())),
}
}
pub(crate) fn sync_dir(dir: &Path) -> io::Result<()> {
std::fs::File::open(dir)?.sync_all()
}
fn write_atomically(path: &Path, text: &str, sync_dir: SyncDir) -> io::Result<Option<io::Error>> {
let target = std::fs::canonicalize(path)?;
let mode = std::fs::metadata(&target)?.permissions().mode();
let dir = target.parent().unwrap_or(Path::new("/"));
let name = target
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let temporary: PathBuf = dir.join(format!(".{name}.{}.tmp", std::process::id()));
let written = (|| {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(&temporary)?;
file.set_permissions(std::fs::Permissions::from_mode(mode))?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
std::fs::rename(&temporary, &target)
})();
if let Err(io_error) = written {
let _ = std::fs::remove_file(&temporary);
return Err(io_error);
}
Ok(sync_dir(dir).err())
}
pub(super) async fn doctor(_: Authenticated, State(state): State<AppState>) -> Response {
let sizing = super::history::sizing_now(&state, Window::new(None, super::history::now())).await;
let text = state.config.lock().await.text.clone();
let checked = tokio::task::spawn_blocking(move || {
let config = Config::parse(&text).ok();
let mut host = crate::doctor::Host::read(config.as_ref());
host.sizing = Some(sizing);
crate::doctor::checks(&host, config.as_ref())
})
.await;
match checked {
Ok(checks) => Json(Doctor { checks }).into_response(),
Err(join_error) => failed(&join_error),
}
}