workdir: add authenticated runtime session transport

This commit is contained in:
2026-08-03 23:37:15 +09:00
parent 05e8b00bf0
commit e5f0c4168f
10 changed files with 1220 additions and 5 deletions
+1
View File
@@ -34,6 +34,7 @@ memory.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
tokio-tungstenite.workspace = true
worker.workspace = true
workdir = { workspace = true, features = ["http-client"] }
worker-runtime.workspace = true
toml.workspace = true
tracing.workspace = true
+199 -1
View File
@@ -1,18 +1,24 @@
use crate::Error;
use crate::resource_broker::BackendResourceBroker;
use chrono::Utc;
use reqwest::StatusCode;
use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Client as AsyncHttpClient, StatusCode, Url};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
future::Future,
path::PathBuf,
pin::Pin,
sync::{Arc, RwLock},
time::Duration,
};
use workdir::{
Workdir, WorkdirError,
http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization},
};
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
@@ -690,6 +696,20 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}
}
fn open_workdir_session<'a>(
&'a self,
_working_directory_id: &'a str,
_owner_worker_id: Option<&'a str>,
) -> Pin<
Box<dyn Future<Output = Result<workdir::WorkdirSessionHandle, WorkdirError>> + Send + 'a>,
> {
Box::pin(async {
Err(WorkdirError::Unavailable(
"Runtime does not expose Workdir operation sessions".to_string(),
))
})
}
fn cleanup_working_directory(
&self,
working_directory_id: &str,
@@ -2249,6 +2269,52 @@ impl RemoteRuntimeConfig {
}
}
#[derive(Clone)]
struct RemoteWorkdirAuthorization {
runtime_id: String,
workspace_id: String,
auth: Option<RemoteRuntimeAuthConfig>,
fallback_bearer_token: Option<String>,
}
impl std::fmt::Debug for RemoteWorkdirAuthorization {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RemoteWorkdirAuthorization")
.field("runtime_id", &self.runtime_id)
.field("workspace_id", &self.workspace_id)
.field("auth", &self.auth.as_ref().map(|_| "capability_token"))
.field(
"fallback_bearer_token",
&self.fallback_bearer_token.as_ref().map(|_| "configured"),
)
.finish()
}
}
impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization {
fn bearer_token(&self) -> Result<String, WorkdirError> {
if let Some(auth) = self.auth.as_ref() {
let claims = capability_claims(
&auth.server_id,
&self.runtime_id,
&self.workspace_id,
all_remote_runtime_permissions(),
300,
)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
return CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key)
.sign(&claims)
.map_err(|error| WorkdirError::Unavailable(error.to_string()));
}
self.fallback_bearer_token.clone().ok_or_else(|| {
WorkdirError::Unavailable(
"remote Runtime does not have bearer authorization configured".to_string(),
)
})
}
}
#[derive(Clone)]
pub struct RemoteWorkerRuntime {
runtime_id: String,
@@ -2263,6 +2329,7 @@ pub struct RemoteWorkerRuntime {
host_id: String,
resource_broker: BackendResourceBroker,
http: BlockingHttpClient,
async_http: AsyncHttpClient,
}
fn all_remote_runtime_permissions() -> Vec<String> {
@@ -2274,6 +2341,7 @@ fn all_remote_runtime_permissions() -> Vec<String> {
"workers:input",
"workers:stop",
"workers:protocol",
"workdirs:operate",
]
.into_iter()
.map(str::to_string)
@@ -2296,6 +2364,17 @@ impl RemoteWorkerRuntime {
code: "remote_runtime_client_build_failed".to_string(),
message: err.to_string(),
})?;
// Workdir command-output waits are bounded to 20 seconds by Runtime;
// leave transport margin while retaining a finite client timeout.
let workdir_timeout = timeout.max(Duration::from_secs(30));
let async_http = AsyncHttpClient::builder()
.timeout(workdir_timeout)
.build()
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: config.runtime_id.clone(),
code: "remote_runtime_async_client_build_failed".to_string(),
message: err.to_string(),
})?;
Ok(Self {
host_id: host_id_for_remote_runtime(&config.runtime_id),
runtime_id: config.runtime_id,
@@ -2309,6 +2388,7 @@ impl RemoteWorkerRuntime {
cached_status: config.cached_status,
resource_broker: BackendResourceBroker::default(),
http,
async_http,
})
}
@@ -2317,6 +2397,33 @@ impl RemoteWorkerRuntime {
self
}
pub async fn open_workdir_session(
&self,
working_directory_id: &str,
owner_worker_id: Option<&str>,
) -> Result<RemoteWorkdirSession, WorkdirError> {
let base_url = Url::parse(&self.base_url)
.map_err(|error| WorkdirError::InvalidArgument(error.to_string()))?;
let workdir_id = Workdir::new(working_directory_id).id().clone();
let authorization: Arc<dyn WorkdirHttpAuthorization> =
Arc::new(RemoteWorkdirAuthorization {
runtime_id: self.runtime_id.clone(),
workspace_id: self.workspace_id.clone(),
auth: self.auth.clone(),
fallback_bearer_token: self.bearer_token.clone(),
});
RemoteWorkdirSession::open_with_authorization(
self.async_http.clone(),
base_url,
authorization,
workdir_id,
OpenWorkdirSessionRequest {
owner_worker_id: owner_worker_id.map(str::to_string),
},
)
.await
}
fn endpoint(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
@@ -2739,6 +2846,24 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn open_workdir_session<'a>(
&'a self,
working_directory_id: &'a str,
owner_worker_id: Option<&'a str>,
) -> Pin<
Box<dyn Future<Output = Result<workdir::WorkdirSessionHandle, WorkdirError>> + Send + 'a>,
> {
Box::pin(async move {
let session = RemoteWorkerRuntime::open_workdir_session(
self,
working_directory_id,
owner_worker_id,
)
.await?;
Ok(Arc::new(session) as workdir::WorkdirSessionHandle)
})
}
fn cleanup_working_directory(
&self,
working_directory_id: &str,
@@ -4867,6 +4992,79 @@ mod tests {
server.join().expect("mock remote server finished");
}
#[tokio::test(flavor = "multi_thread")]
async fn remote_workdir_session_uses_authenticated_http_operations_and_closes() {
use workdir::{EntryKind, StatRequest, StatResult, WorkdirPath};
let opened = workdir::http::OpenWorkdirSessionResponse {
session_id: workdir::http::WorkdirSessionId::new("session-1").unwrap(),
workdir_id: Workdir::new("wd-1").id().clone(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
};
let stat = workdir::http::WorkdirSessionOperationResult::Stat(StatResult {
path: WorkdirPath::new("hello.txt").unwrap(),
kind: EntryKind::File,
size: 5,
});
let (base_url, server) = serve_mock_http(vec![
mock_response(
"POST",
"/v1/working-directories/wd-1/sessions",
true,
200,
serde_json::to_string(&opened).unwrap(),
),
mock_response(
"POST",
"/v1/workdir-sessions/session-1/operations",
true,
200,
serde_json::to_string(&stat).unwrap(),
),
mock_response(
"DELETE",
"/v1/workdir-sessions/session-1",
true,
204,
String::new(),
),
]);
let runtime = RemoteWorkerRuntime::new(
RemoteRuntimeConfig::new(
"runtime-a",
"Runtime A",
base_url,
Some("secret-token".to_string()),
),
"workspace-a".to_string(),
"http://backend.invalid".to_string(),
)
.unwrap();
let runtime: Arc<dyn WorkspaceWorkerRuntime> = Arc::new(runtime);
let session = runtime
.open_workdir_session("wd-1", Some("1"))
.await
.expect("open remote Workdir session");
let result = session
.stat(StatRequest {
path: WorkdirPath::new("hello.txt").unwrap(),
})
.await
.expect("remote stat");
assert_eq!(result.size, 5);
session.close().await.expect("close remote session");
session.close().await.expect("idempotent close");
let error = session
.stat(StatRequest {
path: WorkdirPath::new("hello.txt").unwrap(),
})
.await
.expect_err("closed session must reject local operation without another request");
assert!(matches!(error, WorkdirError::Unavailable(_)));
server.join().expect("mock remote server finished");
}
#[test]
fn remote_runtime_auth_errors_map_to_typed_backend_error() {
let (base_url, server) = serve_mock_http(vec![mock_response(