mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-20 00:11:34 +00:00
Move subprocess workers fully behind the server-owned run store by switching worker/server coordination to HTTP-backed run events and control state. Reconcile stale in-flight runs on boot, terminate live workers during shutdown, and update process titles to reflect server and worker lifecycle phases.
200 lines
7.3 KiB
Rust
200 lines
7.3 KiB
Rust
use std::time::Duration;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use fabro_config::Storage;
|
|
use fabro_types::{RunId, Settings};
|
|
use http_body_util::BodyExt;
|
|
use tempfile::tempdir;
|
|
use tokio::time::timeout;
|
|
use tower::ServiceExt;
|
|
|
|
use crate::helpers::{
|
|
MINIMAL_DOT, api, body_json, minimal_manifest_json_with_dry_run, test_app_state_with_options,
|
|
test_app_with_scheduler, test_settings, wait_for_run_status,
|
|
};
|
|
|
|
fn temp_storage_settings() -> (tempfile::TempDir, Settings) {
|
|
let temp = tempdir().expect("tempdir should create");
|
|
let mut settings = test_settings();
|
|
settings.dry_run = Some(true);
|
|
settings.storage_dir = Some(temp.path().join("storage"));
|
|
(temp, settings)
|
|
}
|
|
|
|
async fn create_run(app: &axum::Router, manifest: serde_json::Value) -> String {
|
|
let request = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(&manifest).unwrap()))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
body["id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
async fn start_run(app: &axum::Router, run_id: &str) {
|
|
let request = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/start")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_system_info_returns_runtime_fields() {
|
|
let (_temp, settings) = temp_storage_settings();
|
|
let expected_storage_dir = settings.storage_dir.clone().unwrap();
|
|
let app = fabro_server::server::build_router(
|
|
test_app_state_with_options(settings, 5),
|
|
fabro_server::jwt_auth::AuthMode::Disabled,
|
|
);
|
|
|
|
let request = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/system/info"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(request).await.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = body_json(response.into_body()).await;
|
|
assert!(body["version"].as_str().is_some());
|
|
assert_eq!(body["storage_engine"], "slatedb");
|
|
assert_eq!(
|
|
body["storage_dir"],
|
|
expected_storage_dir.display().to_string()
|
|
);
|
|
assert_eq!(body["runs"]["total"], 0);
|
|
assert_eq!(body["runs"]["active"], 0);
|
|
assert!(body["uptime_secs"].as_i64().is_some());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn get_system_disk_usage_returns_summary_and_verbose_rows() {
|
|
let (_temp, settings) = temp_storage_settings();
|
|
let storage_dir = settings.storage_dir.clone().unwrap();
|
|
let app = test_app_with_scheduler(test_app_state_with_options(settings, 5));
|
|
|
|
let run_id = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await;
|
|
start_run(&app, &run_id).await;
|
|
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
|
assert_eq!(status, "succeeded");
|
|
|
|
let logs_dir = storage_dir.join("logs");
|
|
std::fs::create_dir_all(&logs_dir).unwrap();
|
|
std::fs::write(logs_dir.join("server.log"), b"log line\n").unwrap();
|
|
|
|
let request = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/system/df?verbose=true"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(request).await.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = body_json(response.into_body()).await;
|
|
assert!(body["summary"].is_array());
|
|
assert!(body["total_size_bytes"].as_i64().unwrap_or_default() > 0);
|
|
assert!(
|
|
body["runs"]
|
|
.as_array()
|
|
.is_some_and(|runs| runs.iter().any(|entry| entry["run_id"] == run_id))
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn prune_runs_supports_dry_run_and_deletion() {
|
|
let (_temp, settings) = temp_storage_settings();
|
|
let storage_dir = settings.storage_dir.clone().unwrap();
|
|
let app = test_app_with_scheduler(test_app_state_with_options(settings, 5));
|
|
|
|
let run_id = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await;
|
|
start_run(&app, &run_id).await;
|
|
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
|
assert_eq!(status, "succeeded");
|
|
|
|
let run_id_parsed: RunId = run_id.parse().unwrap();
|
|
let run_dir = Storage::new(&storage_dir)
|
|
.run_scratch(&run_id_parsed)
|
|
.root()
|
|
.to_path_buf();
|
|
assert!(run_dir.exists());
|
|
|
|
let dry_run_request = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/system/prune/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"before":"9999"}"#))
|
|
.unwrap();
|
|
let dry_run_response = app.clone().oneshot(dry_run_request).await.unwrap();
|
|
assert_eq!(dry_run_response.status(), StatusCode::OK);
|
|
let dry_run_body = body_json(dry_run_response.into_body()).await;
|
|
assert_eq!(dry_run_body["dry_run"], true);
|
|
assert_eq!(dry_run_body["total_count"], 1);
|
|
assert_eq!(dry_run_body["runs"][0]["run_id"], run_id);
|
|
assert!(run_dir.exists());
|
|
|
|
let delete_request = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/system/prune/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"dry_run":false,"before":"9999"}"#))
|
|
.unwrap();
|
|
let delete_response = app.clone().oneshot(delete_request).await.unwrap();
|
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
|
let delete_body = body_json(delete_response.into_body()).await;
|
|
assert_eq!(delete_body["dry_run"], false);
|
|
assert_eq!(delete_body["deleted_count"], 1);
|
|
assert!(!run_dir.exists());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn attach_events_streams_only_matching_run_ids() {
|
|
let (_temp, settings) = temp_storage_settings();
|
|
let app = test_app_with_scheduler(test_app_state_with_options(settings, 5));
|
|
|
|
let run_one = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await;
|
|
let run_two = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await;
|
|
|
|
let request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/attach?run_id={run_one}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.expect("content-type should be present")
|
|
.to_str()
|
|
.unwrap();
|
|
assert!(content_type.contains("text/event-stream"));
|
|
|
|
start_run(&app, &run_one).await;
|
|
start_run(&app, &run_two).await;
|
|
|
|
let mut body = response.into_body();
|
|
let mut sse_data = String::new();
|
|
while let Ok(Some(Ok(frame))) = timeout(Duration::from_secs(2), body.frame()).await {
|
|
if let Some(data) = frame.data_ref() {
|
|
sse_data.push_str(&String::from_utf8_lossy(data));
|
|
if sse_data.contains(&run_one) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
sse_data.contains(&run_one),
|
|
"expected filtered stream data: {sse_data}"
|
|
);
|
|
assert!(
|
|
!sse_data.contains(&run_two),
|
|
"filtered stream should exclude non-matching run ids: {sse_data}"
|
|
);
|
|
}
|