test(e2e): isolate worker server runtime environments
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user