From 0869ef0cc6cf7de9c58a54af8c82ea8a4bab1dfb Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 22 Sep 2026 22:26:02 +0900 Subject: [PATCH 1/2] test(e2e): isolate worker server runtime environments --- Cargo.lock | 2 + crates/tui/src/console/mod.rs | 12 + crates/tui/src/lib.rs | 73 +- crates/worker-runtime/Cargo.toml | 2 +- crates/worker-runtime/src/fs_store.rs | 64 +- crates/worker-runtime/src/http_server.rs | 35 + crates/worker-runtime/src/main.rs | 23 +- crates/workspace-server/Cargo.toml | 2 +- crates/workspace-server/src/lib.rs | 1 + crates/workspace-server/src/main.rs | 191 ++++- crates/workspace-server/src/server.rs | 21 + tests/e2e/Cargo.toml | 7 + tests/e2e/src/lib.rs | 217 ++--- tests/e2e/src/worker_environment.rs | 978 +++++++++++++++++++++++ tests/e2e/tests/rewind.rs | 2 +- tests/e2e/tests/worker_environment.rs | 72 ++ 16 files changed, 1568 insertions(+), 134 deletions(-) create mode 100644 tests/e2e/src/worker_environment.rs create mode 100644 tests/e2e/tests/worker_environment.rs diff --git a/Cargo.lock b/Cargo.lock index 0e9528999..7d941aafb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6286,8 +6286,10 @@ name = "yoi-e2e" version = "0.0.0" dependencies = [ "libc", + "reqwest", "serde", "serde_json", + "sha2 0.11.0", "tempfile", ] diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index a3459b7c7..35ea1438e 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -623,6 +623,16 @@ fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender Result<(), Box> { + let mut terminal = enter_fullscreen()?; + let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await; + let leave_result = leave_fullscreen(&mut terminal); + result?; + leave_result?; + Ok(()) +} + #[cfg(feature = "e2e-test")] async fn run_e2e_rewind_fixture( terminal: &mut ConsoleTerminal, @@ -637,6 +647,8 @@ async fn run_e2e_rewind_fixture( entries: Vec::new(), }, state: WorkerStatus::Idle.into(), + in_flight: protocol::InFlightSnapshot::default(), + internal_workers: Vec::new(), greeting: Greeting { worker_name: worker_name.clone(), cwd: workspace_root.display().to_string(), diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 437e554b2..c9c48bd8e 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -137,7 +137,51 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } let mut terminal_mode = TerminalModeGuard::new(); - let result = match mode { + #[cfg(feature = "e2e-test")] + let result = if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { + let worker_name = match &mode { + LaunchMode::Spawn { worker_name, .. } => worker_name + .clone() + .unwrap_or_else(|| "e2e-rewind".to_string()), + _ => "e2e-rewind".to_string(), + }; + console::run_e2e_rewind(worker_name).await + } else { + launch_mode(target.as_ref(), mode, &workspace_root).await + }; + #[cfg(not(feature = "e2e-test"))] + let result = launch_mode(target.as_ref(), mode, &workspace_root).await; + + // Always restore the terminal first so any pending eprintln below + // shows up cleanly in scrollback rather than inside an active + // alternate-screen buffer. + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); + let _ = terminal_mode.restore(); + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); + + match result { + Ok(()) => { + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("yoi: {e}"); + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); + ExitCode::FAILURE + } + } +} + +async fn launch_mode( + target: &dyn Target, + mode: LaunchMode, + workspace_root: &std::path::Path, +) -> Result<(), Box> { + match mode { LaunchMode::Spawn { worker_name, profile, @@ -145,7 +189,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Ok(Some(selection)) => match target.spawn_worker() { Ok(spawn) => { console::run_standalone( - workspace_root.clone(), + workspace_root.to_path_buf(), spawn.state_dir, Some(selection.worker_name), Some(selection.profile), @@ -158,7 +202,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(error) => Err(Box::new(error) as Box), }, LaunchMode::StandaloneResume { include_all } => { - match standalone_picker::pick(target.as_ref(), include_all) { + match standalone_picker::pick(target, include_all) { Ok(Some(intent)) => console::run_standalone_restore(intent).await, Ok(None) => Ok(()), Err(error) => Err(Box::new(error) as Box), @@ -195,28 +239,5 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, - }; - - // Always restore the terminal first so any pending eprintln below - // shows up cleanly in scrollback rather than inside an active - // alternate-screen buffer. - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); - let _ = terminal_mode.restore(); - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); - - match result { - Ok(()) => { - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("yoi: {e}"); - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); - ExitCode::FAILURE - } } } diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index e07dde7ef..3f4a327c3 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -41,7 +41,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature ring.workspace = true tar.workspace = true thiserror = { workspace = true } -tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["net", "process", "rt", "signal", "sync", "time"] } tracing.workspace = true tracing-subscriber.workspace = true toml.workspace = true diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index ffb129bfa..731d97113 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -123,8 +123,8 @@ impl FsRuntimeStore { })?; let owner_lock = acquire_runtime_store_owner_lock(&root)?; recover_runtime_store_migration(&root)?; - let existed = root.exists(); - if existed && !root.is_dir() { + let root_existed = root.exists(); + if root_existed && !root.is_dir() { return Err(RuntimeError::StoreCorrupt { operation: "open runtime store", path: root, @@ -132,17 +132,36 @@ impl FsRuntimeStore { }); } - if !existed { + if !root_existed { fs::create_dir_all(&root).map_err(|source| RuntimeError::StoreIo { operation: "create runtime store root", path: root.clone(), source, })?; } + let snapshot_exists = root.join(RUNTIME_FILE).is_file(); + let workers_dir = root.join(WORKERS_DIR); + if !snapshot_exists + && workers_dir.is_dir() + && fs::read_dir(&workers_dir) + .map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store", + path: workers_dir.clone(), + source, + })? + .next() + .is_some() + { + return Err(RuntimeError::StoreCorrupt { + operation: "open runtime store", + path: root, + message: "Worker records exist without a Runtime snapshot".to_string(), + }); + } - fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo { + fs::create_dir_all(&workers_dir).map_err(|source| RuntimeError::StoreIo { operation: "create runtime store", - path: root.join(WORKERS_DIR), + path: workers_dir, source, })?; let legacy_events = root.join("events.jsonl"); @@ -158,14 +177,14 @@ impl FsRuntimeStore { } } - if existed { + if snapshot_exists { migrate_runtime_store(&root, runtime_id)?; } let store = Self { root, _owner_lock: Some(owner_lock), }; - let state = if existed { + let state = if snapshot_exists { Some(store.load_runtime_state()?) } else { None @@ -2137,6 +2156,37 @@ mod tests { document["schema_version"].as_u64().unwrap() } + #[test] + fn auth_initialized_directory_without_snapshot_is_a_fresh_store() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("auth.toml"), b"fixture auth material").unwrap(); + + let opened = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap(); + + assert!(opened.state.is_none()); + assert!(root.join(WORKERS_DIR).is_dir()); + assert_eq!( + fs::read(root.join("auth.toml")).unwrap(), + b"fixture auth material" + ); + } + + #[test] + fn worker_records_without_runtime_snapshot_are_rejected() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(root.join(WORKERS_DIR).join("orphan")).unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + } + #[test] fn runtime_store_owner_lock_child_probe() { let Some(root) = std::env::var_os("YOI_TEST_RUNTIME_STORE_LOCK_ROOT") else { diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 5462a09f5..1ffdae919 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -139,6 +139,22 @@ pub enum RuntimeHttpStoreSelection { }, } +pub async fn serve_runtime_http_with_shutdown( + runtime: Runtime, + listener: TcpListener, + local_token: Option, + shutdown: F, +) -> Result<(), RuntimeHttpServerError> +where + F: std::future::Future + Send + 'static, +{ + let local_token = local_token.ok_or(RuntimeHttpServerError::AuthRequired)?; + axum::serve(listener, runtime_http_router(runtime, local_token)) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + /// Serve an existing Runtime on a pre-bound listener. pub async fn serve_runtime_http( runtime: Runtime, @@ -150,6 +166,25 @@ pub async fn serve_runtime_http( Ok(()) } +pub async fn serve_runtime_http_with_workspace_auth_shutdown( + runtime: Runtime, + listener: TcpListener, + local_token: Option, + workspace_auth: WorkspaceRuntimeHttpAuth, + shutdown: F, +) -> Result<(), RuntimeHttpServerError> +where + F: std::future::Future + Send + 'static, +{ + axum::serve( + listener, + runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)), + ) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + pub async fn serve_runtime_http_with_workspace_auth( runtime: Runtime, listener: TcpListener, diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 835efde8b..c09b0f149 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -75,6 +75,23 @@ fn init_serve_tracing() { .try_init(); } +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + fn run() -> Result<(), ProcessError> { let args = env::args().skip(1).collect::>(); if matches!(args.first().map(String::as_str), Some("migrate")) { @@ -104,11 +121,12 @@ fn run() -> Result<(), ProcessError> { "yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser" ); let server = if let Some(workspace_auth) = workspace_http_auth { - worker_runtime::http_server::serve_runtime_http_with_workspace_auth( + worker_runtime::http_server::serve_runtime_http_with_workspace_auth_shutdown( worker_runtime, listener, config.http.local_token, workspace_auth, + shutdown_signal(), ) .await } else { @@ -117,10 +135,11 @@ fn run() -> Result<(), ProcessError> { "Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(), ) })?; - worker_runtime::http_server::serve_runtime_http( + worker_runtime::http_server::serve_runtime_http_with_shutdown( worker_runtime, listener, Some(local_token), + shutdown_signal(), ) .await }; diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 39b41e9c4..5b7330acc 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -38,7 +38,7 @@ thiserror.workspace = true ticket.workspace = true memory.workspace = true merge-request.workspace = true -tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] } +tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tower.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } worker.workspace = true diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index a1ebc50e5..efab8ecbb 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -48,6 +48,7 @@ pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegist pub use server::{ AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, build_workspace_server_router, serve, serve_workspace_catalog, + serve_workspace_catalog_with_shutdown, }; pub use server_api::{ GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary, diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 08a3d9c36..b712941e8 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -9,18 +9,19 @@ use yoi_workspace_server::hosts::{ EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin, }; use yoi_workspace_server::store::{ - SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, - WorkspaceRuntimeBindingState, + AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord, + WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState, }; use yoi_workspace_server::{ ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, - WorkspaceRecord, serve_workspace_catalog, + WorkspaceRecord, serve_workspace_catalog_with_shutdown, }; #[derive(Debug)] enum Command { Serve(ServeOptions), Migrate(MigrateOptions), + BootstrapAuth(BootstrapAuthOptions), Skills(SkillsCommand), Help, } @@ -38,6 +39,13 @@ struct MigrateOptions { help: bool, } +#[derive(Debug)] +struct BootstrapAuthOptions { + handle: String, + display_name: Option, + label: Option, +} + #[derive(Debug)] struct SkillWorkspaceOptions { workspace_id: String, @@ -77,6 +85,7 @@ async fn run() -> Result<(), Box> { match parse_command(&args)? { Command::Serve(options) => run_serve(options).await, Command::Migrate(options) => run_migrate(options), + Command::BootstrapAuth(options) => run_bootstrap_auth(options), Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } @@ -90,6 +99,7 @@ fn parse_command(args: &[String]) -> Result { match command.as_str() { "migrate" => parse_migrate_options(rest).map(Command::Migrate), + "bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth), "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -103,7 +113,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`" ))), } } @@ -214,6 +224,131 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box Ok(()) } +fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box> { + let database_path = ServerConfig::default_server_database_path(); + if let Some(parent) = database_path.parent() { + std::fs::create_dir_all(parent)?; + } + let store = SqliteWorkspaceStore::open(&database_path)?; + if !store.list_workspaces()?.is_empty() { + return Err(Box::new(CliError( + "bootstrap-auth is available only before the first Workspace is created".to_string(), + ))); + } + + let handle = yoi_workspace_server::auth::normalize_handle(&options.handle)?; + let display_name = options + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&handle) + .to_string(); + let now = yoi_workspace_server::auth::now_rfc3339(); + let user = match store.any_user()? { + Some(user) if user.handle == handle => user, + Some(_) => { + return Err(Box::new(CliError( + "bootstrap-auth refuses to replace an existing bootstrap user".to_string(), + ))); + } + None => { + let account_id = yoi_workspace_server::auth::new_id("acct-user"); + store.upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_string(), + handle: handle.clone(), + display_name: display_name.clone(), + created_at: now.clone(), + updated_at: now.clone(), + })?; + let user = UserRecord { + user_id: yoi_workspace_server::auth::new_id("user"), + account_id, + handle, + display_name, + created_at: now.clone(), + updated_at: now.clone(), + }; + store.upsert_user(&user)?; + user + } + }; + + let access_token = yoi_workspace_server::auth::mint_secret("yoi_api"); + store.create_api_token(&ApiTokenRecord { + token_id: yoi_workspace_server::auth::new_id("api-token"), + token_hash: yoi_workspace_server::auth::token_hash(&access_token), + user_id: user.user_id.clone(), + label: options + .label + .unwrap_or_else(|| "initial host setup".to_string()), + created_at: now, + expires_at: None, + revoked_at: None, + last_used_at: None, + })?; + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "access_token": access_token, + "token_type": "bearer", + "user": { + "user_id": user.user_id, + "account_id": user.account_id, + "handle": user.handle, + "display_name": user.display_name, + } + }))? + ); + Ok(()) +} + +fn parse_bootstrap_auth_options(args: &[String]) -> Result { + let mut handle = None; + let mut display_name = None; + let mut label = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + let (field, target) = match arg.as_str() { + "--handle" => ("--handle", &mut handle), + "--display-name" => ("--display-name", &mut display_name), + "--label" => ("--label", &mut label), + _ if arg.starts_with("--handle=") => { + handle = Some(value_after_equals(arg, "--handle")?.to_string()); + index += 1; + continue; + } + _ if arg.starts_with("--display-name=") => { + display_name = Some(value_after_equals(arg, "--display-name")?.to_string()); + index += 1; + continue; + } + _ if arg.starts_with("--label=") => { + label = Some(value_after_equals(arg, "--label")?.to_string()); + index += 1; + continue; + } + other => return Err(CliError(format!("unknown bootstrap-auth option `{other}`"))), + }; + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError(format!("{field} requires a value")))?; + if target.replace(value.clone()).is_some() { + return Err(CliError(format!("{field} must be provided only once"))); + } + index += 1; + } + let handle = handle.ok_or_else(|| CliError("bootstrap-auth requires --handle".to_string()))?; + Ok(BootstrapAuthOptions { + handle, + display_name, + label, + }) +} + fn init_serve_tracing() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); @@ -226,6 +361,23 @@ fn init_serve_tracing() { .try_init(); } +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + async fn run_serve(options: ServeOptions) -> Result<(), Box> { init_serve_tracing(); let database_path = ServerConfig::default_server_database_path(); @@ -284,7 +436,8 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database ]\n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle [--display-name ] [--label