test(e2e): isolate worker server runtime environments

This commit is contained in:
2026-09-22 22:26:02 +09:00
parent 043cb7bf22
commit 0869ef0cc6
16 changed files with 1568 additions and 134 deletions
+12
View File
@@ -623,6 +623,16 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
}
}
#[cfg(feature = "e2e-test")]
pub(crate) async fn run_e2e_rewind(worker_name: String) -> Result<(), Box<dyn std::error::Error>> {
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(),
+47 -26
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>),
},
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<dyn std::error::Error>),
@@ -195,28 +239,5 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
};
// 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
}
}
}
+1 -1
View File
@@ -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
+57 -7
View File
@@ -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 {
+35
View File
@@ -139,6 +139,22 @@ pub enum RuntimeHttpStoreSelection {
},
}
pub async fn serve_runtime_http_with_shutdown<F>(
runtime: Runtime,
listener: TcpListener,
local_token: Option<String>,
shutdown: F,
) -> Result<(), RuntimeHttpServerError>
where
F: std::future::Future<Output = ()> + 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<F>(
runtime: Runtime,
listener: TcpListener,
local_token: Option<String>,
workspace_auth: WorkspaceRuntimeHttpAuth,
shutdown: F,
) -> Result<(), RuntimeHttpServerError>
where
F: std::future::Future<Output = ()> + 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,
+21 -2
View File
@@ -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::<Vec<_>>();
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
};
+1 -1
View File
@@ -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
+1
View File
@@ -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,
+184 -7
View File
@@ -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<String>,
label: Option<String>,
}
#[derive(Debug)]
struct SkillWorkspaceOptions {
workspace_id: String,
@@ -77,6 +85,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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<Command, CliError> {
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<Command, CliError> {
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<dyn std::error::Error>
Ok(())
}
fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box<dyn std::error::Error>> {
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<BootstrapAuthOptions, CliError> {
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<dyn std::error::Error>> {
init_serve_tracing();
let database_path = ServerConfig::default_server_database_path();
@@ -284,7 +436,8 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
database_path.display(),
local_addr
);
serve_workspace_catalog(resolved.server, store, listener).await?;
serve_workspace_catalog_with_shutdown(resolved.server, store, listener, shutdown_signal())
.await?;
Ok(())
}
@@ -490,7 +643,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
@@ -527,6 +680,28 @@ mod tests {
}
}
#[test]
fn parse_bootstrap_auth_requires_handle_and_accepts_host_setup_fields() {
let command = parse_command(&[
"bootstrap-auth".to_string(),
"--handle=e2e-owner".to_string(),
"--display-name".to_string(),
"E2E Owner".to_string(),
"--label".to_string(),
"e2e setup".to_string(),
])
.unwrap();
let Command::BootstrapAuth(options) = command else {
panic!("expected bootstrap-auth command");
};
assert_eq!(options.handle, "e2e-owner");
assert_eq!(options.display_name.as_deref(), Some("E2E Owner"));
assert_eq!(options.label.as_deref(), Some("e2e setup"));
let error = parse_bootstrap_auth_options(&[]).unwrap_err();
assert_eq!(error.to_string(), "bootstrap-auth requires --handle");
}
#[test]
fn parse_migrate_uses_the_shared_schema_path() {
let command = parse_command(&[
@@ -662,7 +837,9 @@ mod tests {
let error = parse_command(&[command.to_owned()]).unwrap_err();
assert_eq!(
error.to_string(),
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
format!(
"unknown command `{command}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`"
)
);
}
}
+21
View File
@@ -1995,6 +1995,10 @@ struct WorkspaceListQuery {
limit: Option<usize>,
}
async fn server_health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
async fn list_server_workspaces(
State(api): State<WorkspaceServerApi>,
headers: HeaderMap,
@@ -2663,6 +2667,7 @@ pub async fn build_workspace_server_router(
api.preload().await?;
api.recover_workspace_deletions().await?;
let catalog = Router::new()
.route("/health", get(server_health))
.route(
"/api/workspaces",
get(list_server_workspaces).post(create_server_workspace),
@@ -4565,6 +4570,22 @@ struct ApiFailureLogEvent<'a> {
diagnostics: Option<&'a [RuntimeDiagnostic]>,
}
pub async fn serve_workspace_catalog_with_shutdown<F>(
template: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
listener: TcpListener,
shutdown: F,
) -> Result<()>
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let router = build_workspace_server_router(template, store).await?;
axum::serve(listener, router)
.with_graceful_shutdown(shutdown)
.await?;
Ok(())
}
pub async fn serve_workspace_catalog(
template: ServerConfig,
store: Arc<dyn ControlPlaneStore>,