Migrate runtime management REST to generated API
This commit is contained in:
Generated
+15
@@ -3706,6 +3706,19 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "runtime-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"api-macros",
|
||||||
|
"manifest",
|
||||||
|
"protocol",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"workdir",
|
||||||
|
"workspace-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -6126,6 +6139,7 @@ dependencies = [
|
|||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
|
"runtime-api",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
@@ -6272,6 +6286,7 @@ dependencies = [
|
|||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
|
"runtime-api",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ members = [
|
|||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
"crates/worker",
|
"crates/worker",
|
||||||
"crates/worker-runtime",
|
"crates/worker-runtime",
|
||||||
|
"crates/runtime-api",
|
||||||
"crates/plugin-pdk",
|
"crates/plugin-pdk",
|
||||||
"crates/yoi",
|
"crates/yoi",
|
||||||
"crates/protocol",
|
"crates/protocol",
|
||||||
@@ -47,6 +48,7 @@ default-members = [
|
|||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
"crates/worker",
|
"crates/worker",
|
||||||
"crates/worker-runtime",
|
"crates/worker-runtime",
|
||||||
|
"crates/runtime-api",
|
||||||
"crates/plugin-pdk",
|
"crates/plugin-pdk",
|
||||||
"crates/yoi",
|
"crates/yoi",
|
||||||
"crates/protocol",
|
"crates/protocol",
|
||||||
@@ -101,6 +103,7 @@ session-metrics = { path = "crates/session-metrics" }
|
|||||||
session-analytics = { path = "crates/session-analytics" }
|
session-analytics = { path = "crates/session-analytics" }
|
||||||
session-store = { path = "crates/session-store" }
|
session-store = { path = "crates/session-store" }
|
||||||
standalone = { path = "crates/standalone" }
|
standalone = { path = "crates/standalone" }
|
||||||
|
runtime-api = { path = "crates/runtime-api" }
|
||||||
secrets = { path = "crates/secrets" }
|
secrets = { path = "crates/secrets" }
|
||||||
tools = { path = "crates/tools" }
|
tools = { path = "crates/tools" }
|
||||||
config-source = { path = "crates/config-source" }
|
config-source = { path = "crates/config-source" }
|
||||||
|
|||||||
@@ -949,18 +949,23 @@ fn reqwest_adapter_tokens(
|
|||||||
.map_err(#api_crate::reqwest::ClientError::from);
|
.map_err(#api_crate::reqwest::ClientError::from);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let public_error = match (operation.error_status, operation.error_body.as_ref()) {
|
let public_error = match operation.error_body.as_ref() {
|
||||||
(Some(status), Some(error)) => quote! {
|
Some(error) => quote! {
|
||||||
if __actual == #status {
|
if (400..=599).contains(&__actual) {
|
||||||
|
let __status = __response.status();
|
||||||
let __error = __response
|
let __error = __response
|
||||||
.decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError)
|
.decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError)
|
||||||
.map_err(#api_crate::reqwest::ClientError::from)?;
|
.map_err(|_| #api_crate::reqwest::ClientError::failure(
|
||||||
|
#api_crate::reqwest::ClientFailure::ErrorResponseDecode {
|
||||||
|
status: __status.as_u16(),
|
||||||
|
},
|
||||||
|
))?;
|
||||||
return ::core::result::Result::Err(
|
return ::core::result::Result::Err(
|
||||||
#api_crate::reqwest::ClientError::public(__response.status(), __error),
|
#api_crate::reqwest::ClientError::public(__status, __error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ => quote!(),
|
None => quote!(),
|
||||||
};
|
};
|
||||||
quote! {
|
quote! {
|
||||||
pub async fn #method_ident(
|
pub async fn #method_ident(
|
||||||
@@ -1192,7 +1197,10 @@ fn axum_adapter_tokens(
|
|||||||
let invocation = quote!(#trait_ident::#method_ident(&*__service, #(#call_arguments),*).await);
|
let invocation = quote!(#trait_ident::#method_ident(&*__service, #(#call_arguments),*).await);
|
||||||
let mapped = if operation.fallible {
|
let mapped = if operation.fallible {
|
||||||
let error = match operation.error_status {
|
let error = match operation.error_status {
|
||||||
Some(status) => quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), error)),
|
Some(_) => quote!({
|
||||||
|
let status = #api_crate::HttpError::status_code(&error);
|
||||||
|
#api_crate::axum::json_response(#api_crate::axum::status(status), error)
|
||||||
|
}),
|
||||||
None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }),
|
None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }),
|
||||||
};
|
};
|
||||||
quote! {
|
quote! {
|
||||||
|
|||||||
@@ -171,6 +171,15 @@ pub trait Operation {
|
|||||||
const METADATA: OperationMetadata;
|
const METADATA: OperationMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public JSON error that can select its concrete HTTP status at the server boundary.
|
||||||
|
///
|
||||||
|
/// `error_status` remains the contract's default/documented status. Implementations may return a
|
||||||
|
/// different 4xx/5xx status for one named error shape, allowing a generated client to preserve a
|
||||||
|
/// service's typed 401/403/404/409 responses without introducing duplicate route handlers.
|
||||||
|
pub trait HttpError {
|
||||||
|
fn status_code(&self) -> u16;
|
||||||
|
}
|
||||||
|
|
||||||
/// Marker used when an operation has no request, response, or public error body.
|
/// Marker used when an operation has no request, response, or public error body.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub enum NoBody {}
|
pub enum NoBody {}
|
||||||
|
|||||||
@@ -352,6 +352,8 @@ impl<A: RequestAuthorizer> ClientCore<A> {
|
|||||||
let response = request.send().await.map_err(|error| {
|
let response = request.send().await.map_err(|error| {
|
||||||
if error.is_timeout() {
|
if error.is_timeout() {
|
||||||
ClientFailure::Timeout
|
ClientFailure::Timeout
|
||||||
|
} else if error.is_connect() {
|
||||||
|
ClientFailure::Connect
|
||||||
} else {
|
} else {
|
||||||
ClientFailure::Transport
|
ClientFailure::Transport
|
||||||
}
|
}
|
||||||
@@ -461,10 +463,12 @@ pub enum DecodeKind {
|
|||||||
pub enum ClientFailure {
|
pub enum ClientFailure {
|
||||||
Authorization,
|
Authorization,
|
||||||
RequestEncoding,
|
RequestEncoding,
|
||||||
|
Connect,
|
||||||
Transport,
|
Transport,
|
||||||
Timeout,
|
Timeout,
|
||||||
ResponseTooLarge { limit: usize },
|
ResponseTooLarge { limit: usize },
|
||||||
UnexpectedStatus { expected: u16, actual: u16 },
|
UnexpectedStatus { expected: u16, actual: u16 },
|
||||||
|
ErrorResponseDecode { status: u16 },
|
||||||
Decode { kind: DecodeKind },
|
Decode { kind: DecodeKind },
|
||||||
UnexpectedBody,
|
UnexpectedBody,
|
||||||
}
|
}
|
||||||
@@ -480,6 +484,7 @@ impl fmt::Display for ClientFailure {
|
|||||||
match self {
|
match self {
|
||||||
Self::Authorization => formatter.write_str("request authorization failed"),
|
Self::Authorization => formatter.write_str("request authorization failed"),
|
||||||
Self::RequestEncoding => formatter.write_str("request encoding failed"),
|
Self::RequestEncoding => formatter.write_str("request encoding failed"),
|
||||||
|
Self::Connect => formatter.write_str("HTTP connection failed"),
|
||||||
Self::Transport => formatter.write_str("HTTP transport failed"),
|
Self::Transport => formatter.write_str("HTTP transport failed"),
|
||||||
Self::Timeout => formatter.write_str("HTTP request timed out"),
|
Self::Timeout => formatter.write_str("HTTP request timed out"),
|
||||||
Self::ResponseTooLarge { limit } => {
|
Self::ResponseTooLarge { limit } => {
|
||||||
@@ -491,6 +496,9 @@ impl fmt::Display for ClientFailure {
|
|||||||
"expected HTTP status {expected}, received {actual}"
|
"expected HTTP status {expected}, received {actual}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Self::ErrorResponseDecode { status } => {
|
||||||
|
write!(formatter, "HTTP {status} error response JSON was malformed")
|
||||||
|
}
|
||||||
Self::Decode {
|
Self::Decode {
|
||||||
kind: DecodeKind::Success,
|
kind: DecodeKind::Success,
|
||||||
} => formatter.write_str("success response JSON was malformed"),
|
} => formatter.write_str("success response JSON was malformed"),
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ pub struct Widget {
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub struct PublicError {
|
pub struct PublicError {
|
||||||
message: String,
|
message: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
status: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl api_macros::HttpError for PublicError {
|
||||||
|
fn status_code(&self) -> u16 {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[api(reqwest, axum)]
|
#[api(reqwest, axum)]
|
||||||
@@ -65,6 +73,7 @@ impl WidgetApi for WidgetService {
|
|||||||
if request.name == "bad" {
|
if request.name == "bad" {
|
||||||
return Err(PublicError {
|
return Err(PublicError {
|
||||||
message: "sensitive-public-detail".to_owned(),
|
message: "sensitive-public-detail".to_owned(),
|
||||||
|
status: 409,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Widget {
|
Ok(Widget {
|
||||||
@@ -210,10 +219,16 @@ async fn generated_client_and_router_follow_the_normalized_contract() {
|
|||||||
.await
|
.await
|
||||||
.expect_err("typed public error");
|
.expect_err("typed public error");
|
||||||
assert!(!format!("{public_error:?}").contains("sensitive-public-detail"));
|
assert!(!format!("{public_error:?}").contains("sensitive-public-detail"));
|
||||||
|
assert!(matches!(
|
||||||
|
&public_error,
|
||||||
|
api_reqwest::ClientError::Public { status, .. }
|
||||||
|
if *status == api_reqwest::framework::StatusCode::CONFLICT
|
||||||
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
public_error.into_public_error(),
|
public_error.into_public_error(),
|
||||||
Some(PublicError {
|
Some(PublicError {
|
||||||
message: "sensitive-public-detail".to_owned(),
|
message: "sensitive-public-detail".to_owned(),
|
||||||
|
status: 0,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "runtime-api"
|
||||||
|
description = "Typed JSON REST contract for Yoi Runtime Worker management"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
api-macros = { workspace = true, features = ["reqwest", "axum"] }
|
||||||
|
manifest.workspace = true
|
||||||
|
protocol.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
workspace-api.workspace = true
|
||||||
|
workdir.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] }
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
//! Typed Worker Runtime management HTTP contract.
|
||||||
|
//!
|
||||||
|
//! This crate owns the public REST DTOs and generated client/server adapters only. Runtime domain
|
||||||
|
//! state, authorization, body limits, and operation permission policy remain in their existing
|
||||||
|
//! authorities.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use api_macros::api;
|
||||||
|
pub use api_macros::reqwest as client_support;
|
||||||
|
pub use api_macros::{ApiContract, HttpMethod};
|
||||||
|
use protocol::{CompletionEntry, CompletionKind, Segment, WorkerId, WorkerStateSnapshot};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use workdir::workspace::{MaterializerKind, RuntimeWorkerRef, RuntimeWorkingDirectorySummary};
|
||||||
|
use workspace_api::{RepositoryAccessMode, RepositorySource};
|
||||||
|
|
||||||
|
pub const RUNTIME_API_VERSION: &str = "v1";
|
||||||
|
pub const RUNTIME_API_BASE_PATH: &str = "/v1";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RuntimeApiError {
|
||||||
|
pub error: RuntimeApiErrorDetail,
|
||||||
|
#[serde(skip)]
|
||||||
|
status: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RuntimeApiErrorDetail {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeApiError {
|
||||||
|
pub fn new(status: u16, code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
error: RuntimeApiErrorDetail {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> u16 {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl api_macros::HttpError for RuntimeApiError {
|
||||||
|
fn status_code(&self) -> u16 {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimePingResponse {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub protocol_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeBackendKind {
|
||||||
|
Memory,
|
||||||
|
FsStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeStatus {
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_platform_component() -> String {
|
||||||
|
"unknown".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RuntimeSummary {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub backend: RuntimeBackendKind,
|
||||||
|
pub status: RuntimeStatus,
|
||||||
|
pub worker_count: usize,
|
||||||
|
pub active_worker_count: usize,
|
||||||
|
pub stopped_worker_count: usize,
|
||||||
|
pub diagnostic_count: usize,
|
||||||
|
#[serde(default = "unknown_platform_component")]
|
||||||
|
pub os: String,
|
||||||
|
#[serde(default = "unknown_platform_component")]
|
||||||
|
pub arch: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub worker_creation_available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RuntimeSummaryResponse {
|
||||||
|
pub runtime: RuntimeSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||||
|
pub struct WorkerRef {
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkspaceApiRef {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkerStatus {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||||
|
pub enum ProfileSelector {
|
||||||
|
Builtin(String),
|
||||||
|
Named(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct ProfileSourceGraphSummary {
|
||||||
|
pub source_count: usize,
|
||||||
|
pub total_source_bytes: u64,
|
||||||
|
pub entrypoints: BTreeMap<String, String>,
|
||||||
|
pub import_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct ProfileSourceArchiveRef {
|
||||||
|
pub id: String,
|
||||||
|
pub digest: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub source_graph: ProfileSourceGraphSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct ProfileSourceArchive {
|
||||||
|
pub reference: ProfileSourceArchiveRef,
|
||||||
|
pub content: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ProfileSourceArchiveSource {
|
||||||
|
Embedded { archive: ProfileSourceArchive },
|
||||||
|
WorkspaceConfig { archive: ProfileSourceArchiveRef },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct ConfigBundleRef {
|
||||||
|
pub id: String,
|
||||||
|
pub digest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RepositorySelector(pub String);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkingDirectoryRepository {
|
||||||
|
pub id: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub source: RepositorySource,
|
||||||
|
pub source_revision: u64,
|
||||||
|
pub source_fingerprint: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub selector: Option<RepositorySelector>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct BackendResourceHandle {
|
||||||
|
pub resource_id: String,
|
||||||
|
pub resource_kind: String,
|
||||||
|
pub resource_version: u64,
|
||||||
|
pub resource_fingerprint: String,
|
||||||
|
pub secret_handle: String,
|
||||||
|
pub expires_at_epoch_seconds: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RepositorySshCredentialCandidate {
|
||||||
|
pub credential_id: String,
|
||||||
|
pub credential_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RepositorySshMaterializationAccess {
|
||||||
|
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||||
|
pub host_trust_id: String,
|
||||||
|
pub host_trust_revision: u64,
|
||||||
|
pub access: RepositoryAccessMode,
|
||||||
|
pub expires_at_epoch_seconds: u64,
|
||||||
|
pub repository_id: String,
|
||||||
|
pub repository_source_fingerprint: String,
|
||||||
|
pub repository_uri: String,
|
||||||
|
pub secret_resource: BackendResourceHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct RepositoryMaterializationContext {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub operation_id: String,
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub config_projection_digest: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkingDirectoryRequest {
|
||||||
|
pub repository: WorkingDirectoryRepository,
|
||||||
|
#[serde(default)]
|
||||||
|
pub materializer: MaterializerKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub backend_workdir_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub materialization: Option<RepositoryMaterializationContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkingDirectoryClaim {
|
||||||
|
pub working_directory_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub relative_cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkingDirectoryStatus {
|
||||||
|
pub summary: RuntimeWorkingDirectorySummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkerInputKind {
|
||||||
|
User,
|
||||||
|
Notify,
|
||||||
|
Compact,
|
||||||
|
ListRewindTargets,
|
||||||
|
RegisterPeer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerInput {
|
||||||
|
pub kind: WorkerInputKind,
|
||||||
|
pub content: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub submission_request_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub segments: Option<Vec<Segment>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct CreateWorkerRequest {
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
pub create_fingerprint: String,
|
||||||
|
pub profile: ProfileSelector,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub profile_source: ProfileSourceArchiveSource,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_bundle: Option<ConfigBundleRef>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub initial_input: Option<WorkerInput>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub working_directory_request: Option<WorkingDirectoryRequest>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub working_directory: Option<WorkingDirectoryClaim>,
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub worker_observation_enabled: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub worker_observation_grants: Vec<RuntimeWorkerRef>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_api: Option<WorkspaceApiRef>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerSummary {
|
||||||
|
pub worker_ref: WorkerRef,
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
pub status: WorkerStatus,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at_ms: Option<u64>,
|
||||||
|
pub execution_metadata_available: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worker_state: Option<WorkerStateSnapshot>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub working_directory: Option<WorkingDirectoryStatus>,
|
||||||
|
pub profile: ProfileSelector,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub profile_source: ProfileSourceArchiveRef,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_bundle: Option<ConfigBundleRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type WorkerDetail = WorkerSummary;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkersResponse {
|
||||||
|
pub workers: Vec<WorkerSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerResponse {
|
||||||
|
pub worker: WorkerDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkerStatusFilter {
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerListQuery {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status: Option<WorkerStatusFilter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerDeleteResult {
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
pub deleted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerDeleteResponse {
|
||||||
|
pub worker: WorkerDeleteResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerLifecycleRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct EmptyObjectRequest {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerLifecycleAck {
|
||||||
|
pub worker_ref: WorkerRef,
|
||||||
|
pub status: WorkerStatus,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worker_state: Option<WorkerStateSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerLifecycleResponse {
|
||||||
|
pub ack: WorkerLifecycleAck,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerSubmissionAck {
|
||||||
|
pub submission_request_id: String,
|
||||||
|
pub submission_id: String,
|
||||||
|
pub disposition: protocol::SubmissionDisposition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerInteractionAck {
|
||||||
|
pub worker_ref: WorkerRef,
|
||||||
|
pub status: WorkerStatus,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub submission: Option<WorkerSubmissionAck>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerInputResponse {
|
||||||
|
pub ack: WorkerInteractionAck,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct CompletionRequest {
|
||||||
|
pub kind: CompletionKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct CompletionResponse {
|
||||||
|
pub kind: CompletionKind,
|
||||||
|
pub prefix: String,
|
||||||
|
pub entries: Vec<CompletionEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SessionDisposition {
|
||||||
|
Archive,
|
||||||
|
Purge,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DiagnosticsDisposition {
|
||||||
|
Purge,
|
||||||
|
Retain,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerRetentionInventory {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub segment_ids: Vec<String>,
|
||||||
|
pub session_bytes: u64,
|
||||||
|
pub diagnostics_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerRetentionExecutionRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub input_fingerprint: String,
|
||||||
|
pub archive_id: Option<String>,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub source_runtime_id: String,
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
pub expected_worker_revision: String,
|
||||||
|
pub source_created_at: String,
|
||||||
|
pub removed_at: String,
|
||||||
|
pub effective_profile: Option<String>,
|
||||||
|
pub retention_class: Option<String>,
|
||||||
|
pub policy_id: String,
|
||||||
|
pub policy_revision: u64,
|
||||||
|
pub session_disposition: SessionDisposition,
|
||||||
|
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerSessionArchiveManifest {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub archive_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub source_runtime_id: String,
|
||||||
|
pub source_worker_id: WorkerId,
|
||||||
|
pub source_session_id: String,
|
||||||
|
pub segment_ids: Vec<String>,
|
||||||
|
pub source_created_at: String,
|
||||||
|
pub removed_at: String,
|
||||||
|
pub archived_at_unix_seconds: u64,
|
||||||
|
pub effective_profile: Option<String>,
|
||||||
|
pub retention_class: Option<String>,
|
||||||
|
pub content_checksum_sha256: String,
|
||||||
|
pub content_bytes: u64,
|
||||||
|
pub content_file_count: u64,
|
||||||
|
pub policy_id: String,
|
||||||
|
pub policy_revision: u64,
|
||||||
|
pub operation_id: String,
|
||||||
|
pub input_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WorkerRetentionExecutionResult {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub input_fingerprint: String,
|
||||||
|
pub expected_worker_revision: String,
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
pub session_disposition: SessionDisposition,
|
||||||
|
pub diagnostics_disposition: DiagnosticsDisposition,
|
||||||
|
pub archive: Option<WorkerSessionArchiveManifest>,
|
||||||
|
pub source_removed: bool,
|
||||||
|
pub diagnostics_retained: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(reqwest, axum)]
|
||||||
|
pub trait RuntimeApi {
|
||||||
|
#[get("/v1/ping", status = 200, error_status = 400)]
|
||||||
|
async fn ping(
|
||||||
|
&self,
|
||||||
|
#[header("x-yoi-workspace-id")] workspace_id: String,
|
||||||
|
) -> Result<RuntimePingResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[get("/v1/runtime", status = 200, error_status = 400)]
|
||||||
|
async fn runtime_summary(&self) -> Result<RuntimeSummaryResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[get("/v1/workers", status = 200, error_status = 400)]
|
||||||
|
async fn list_workers(
|
||||||
|
&self,
|
||||||
|
#[query] query: WorkerListQuery,
|
||||||
|
) -> Result<WorkersResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[get("/v1/workers/{worker_id}", status = 200, error_status = 400)]
|
||||||
|
async fn get_worker(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
) -> Result<WorkerResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post("/v1/workers", status = 200, error_status = 400)]
|
||||||
|
async fn create_worker(
|
||||||
|
&self,
|
||||||
|
#[body] request: CreateWorkerRequest,
|
||||||
|
) -> Result<WorkerResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[delete("/v1/workers/{worker_id}", status = 200, error_status = 400)]
|
||||||
|
async fn delete_worker(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
) -> Result<WorkerDeleteResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post("/v1/workers/{worker_id}/input", status = 200, error_status = 400)]
|
||||||
|
async fn send_worker_input(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[body] input: WorkerInput,
|
||||||
|
) -> Result<WorkerInputResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post("/v1/workers/{worker_id}/stop", status = 200, error_status = 400)]
|
||||||
|
async fn stop_worker(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[body] request: WorkerLifecycleRequest,
|
||||||
|
) -> Result<WorkerLifecycleResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post("/v1/workers/{worker_id}/cancel", status = 200, error_status = 400)]
|
||||||
|
async fn cancel_worker(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[body] request: WorkerLifecycleRequest,
|
||||||
|
) -> Result<WorkerLifecycleResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post("/v1/workers/{worker_id}/restore", status = 200, error_status = 400)]
|
||||||
|
async fn restore_worker(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[body] request: EmptyObjectRequest,
|
||||||
|
) -> Result<WorkerResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post(
|
||||||
|
"/v1/workers/{worker_id}/completions",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400
|
||||||
|
)]
|
||||||
|
async fn complete_worker_arguments(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[body] request: CompletionRequest,
|
||||||
|
) -> Result<CompletionResponse, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[get(
|
||||||
|
"/v1/workers/{worker_id}/retention/inventory",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400
|
||||||
|
)]
|
||||||
|
async fn retention_inventory(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
) -> Result<WorkerRetentionInventory, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post(
|
||||||
|
"/v1/workers/{worker_id}/retention/execute",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400
|
||||||
|
)]
|
||||||
|
async fn execute_retention(
|
||||||
|
&self,
|
||||||
|
#[path] worker_id: String,
|
||||||
|
#[body] request: WorkerRetentionExecutionRequest,
|
||||||
|
) -> Result<WorkerRetentionExecutionResult, RuntimeApiError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RemainingRuntimeRoute {
|
||||||
|
pub method: &'static str,
|
||||||
|
pub path: &'static str,
|
||||||
|
pub reason: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
|
||||||
|
/// than letting them be mistaken for management-contract omissions.
|
||||||
|
pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/bootstrap",
|
||||||
|
reason: "bootstrap lifecycle",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/workers/{worker_id}/workspace-api",
|
||||||
|
reason: "Workspace API rebinding",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "GET",
|
||||||
|
path: "/v1/protocol/ws",
|
||||||
|
reason: "WebSocket protocol transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "GET",
|
||||||
|
path: "/v1/workers/{worker_id}/protocol/ws",
|
||||||
|
reason: "WebSocket protocol transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/workers/{worker_id}/attachments",
|
||||||
|
reason: "binary attachment transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/v1/workers/{worker_id}/attachments/{artifact_id}",
|
||||||
|
reason: "binary attachment transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "GET",
|
||||||
|
path: "/v1/config-bundles",
|
||||||
|
reason: "Workspace Config transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/config-bundles",
|
||||||
|
reason: "Workspace Config transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "GET",
|
||||||
|
path: "/v1/config-bundles/{bundle_id}/availability",
|
||||||
|
reason: "Workspace Config transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/workspace-prompt-projections",
|
||||||
|
reason: "Workspace Config transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "GET",
|
||||||
|
path: "/v1/working-directories",
|
||||||
|
reason: "Workdir transport",
|
||||||
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/working-directories",
|
||||||
|
reason: "Workdir transport",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contract_inventory_is_complete_and_unique() {
|
||||||
|
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||||
|
assert_eq!(operations.len(), 13);
|
||||||
|
let mut routes = operations
|
||||||
|
.iter()
|
||||||
|
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
routes.sort_unstable();
|
||||||
|
routes.dedup();
|
||||||
|
assert_eq!(routes.len(), operations.len());
|
||||||
|
assert!(
|
||||||
|
operations
|
||||||
|
.iter()
|
||||||
|
.all(|operation| operation.path.starts_with(RUNTIME_API_BASE_PATH))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remaining_route_inventory_does_not_overlap_contract() {
|
||||||
|
for remaining in REMAINING_RUNTIME_ROUTES {
|
||||||
|
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
||||||
|
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
||||||
|
&& operation.path == remaining.path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ flow = { path = "../flow" }
|
|||||||
fs4 = { workspace = true, optional = true }
|
fs4 = { workspace = true, optional = true }
|
||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
protocol.workspace = true
|
protocol.workspace = true
|
||||||
|
runtime-api.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
session-store.workspace = true
|
session-store.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
//! Runtime process directly; a backend is expected to own any browser-facing
|
//! Runtime process directly; a backend is expected to own any browser-facing
|
||||||
//! credentials, registration, and policy.
|
//! credentials, registration, and policy.
|
||||||
|
|
||||||
|
mod runtime_management_api;
|
||||||
|
|
||||||
use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds};
|
use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
|
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
|
||||||
@@ -209,7 +211,6 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/v1/ping", get(get_runtime_ping))
|
|
||||||
.route(
|
.route(
|
||||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH,
|
WORKSPACE_VERIFICATION_CHALLENGE_PATH,
|
||||||
post(post_workspace_verification_challenge),
|
post(post_workspace_verification_challenge),
|
||||||
@@ -218,7 +219,6 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
WORKSPACE_VERIFICATION_ACK_PATH,
|
WORKSPACE_VERIFICATION_ACK_PATH,
|
||||||
post(post_workspace_verification_acknowledgement),
|
post(post_workspace_verification_acknowledgement),
|
||||||
)
|
)
|
||||||
.route("/v1/runtime", get(get_runtime))
|
|
||||||
.route(
|
.route(
|
||||||
"/v1/config-bundles",
|
"/v1/config-bundles",
|
||||||
get(list_config_bundles).post(store_config_bundle),
|
get(list_config_bundles).post(store_config_bundle),
|
||||||
@@ -260,20 +260,6 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
"/v1/working-directories/{working_directory_id}",
|
"/v1/working-directories/{working_directory_id}",
|
||||||
get(get_working_directory).delete(cleanup_working_directory),
|
get(get_working_directory).delete(cleanup_working_directory),
|
||||||
)
|
)
|
||||||
.route("/v1/workers", get(list_workers).post(create_worker))
|
|
||||||
.route(
|
|
||||||
"/v1/workers/{worker_id}",
|
|
||||||
get(get_worker).delete(delete_worker),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/v1/workers/{worker_id}/retention/inventory",
|
|
||||||
get(worker_retention_inventory),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/v1/workers/{worker_id}/retention/execute",
|
|
||||||
post(execute_worker_retention),
|
|
||||||
)
|
|
||||||
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
|
||||||
.route(
|
.route(
|
||||||
"/v1/workers/{worker_id}/attachments",
|
"/v1/workers/{worker_id}/attachments",
|
||||||
post(upload_worker_file).layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)),
|
post(upload_worker_file).layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)),
|
||||||
@@ -282,17 +268,10 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
"/v1/workers/{worker_id}/attachments/{artifact_id}",
|
"/v1/workers/{worker_id}/attachments/{artifact_id}",
|
||||||
delete(delete_worker_uploaded_file),
|
delete(delete_worker_uploaded_file),
|
||||||
)
|
)
|
||||||
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
|
|
||||||
.route(
|
.route(
|
||||||
"/v1/workers/{worker_id}/workspace-api",
|
"/v1/workers/{worker_id}/workspace-api",
|
||||||
post(replace_worker_workspace_api),
|
post(replace_worker_workspace_api),
|
||||||
)
|
);
|
||||||
.route(
|
|
||||||
"/v1/workers/{worker_id}/completions",
|
|
||||||
post(worker_completions),
|
|
||||||
)
|
|
||||||
.route("/v1/workers/{worker_id}/stop", post(stop_worker))
|
|
||||||
.route("/v1/workers/{worker_id}/cancel", post(cancel_worker));
|
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
let router = router
|
let router = router
|
||||||
@@ -304,6 +283,7 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
|
|
||||||
router
|
router
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
|
.merge(runtime_management_api::router(state.clone()))
|
||||||
.layer(middleware::from_fn_with_state(state, require_runtime_auth))
|
.layer(middleware::from_fn_with_state(state, require_runtime_auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2130,7 +2110,8 @@ async fn require_runtime_auth(
|
|||||||
token_id: verified.token_id,
|
token_id: verified.token_id,
|
||||||
expires_at: u64::try_from(verified.expires_at).unwrap_or(0),
|
expires_at: u64::try_from(verified.expires_at).unwrap_or(0),
|
||||||
});
|
});
|
||||||
return next.run(request).await;
|
let context = request.extensions().get::<RuntimeAuthContext>().cloned();
|
||||||
|
return runtime_management_api::scope_auth(context, next.run(request)).await;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return RuntimeHttpRestError::new(
|
return RuntimeHttpRestError::new(
|
||||||
@@ -2180,7 +2161,8 @@ async fn require_runtime_auth(
|
|||||||
expires_at: 0,
|
expires_at: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
next.run(request).await
|
let context = request.extensions().get::<RuntimeAuthContext>().cloned();
|
||||||
|
runtime_management_api::scope_auth(context, next.run(request)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_workspace_scope(
|
fn auth_workspace_scope(
|
||||||
@@ -2563,6 +2545,38 @@ mod tests {
|
|||||||
WorkdirSessionCapabilities,
|
WorkdirSessionCapabilities,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_runtime_routes_are_classified_by_permission_authority() {
|
||||||
|
for operation in <runtime_api::RuntimeApiMetadata as runtime_api::ApiContract>::OPERATIONS {
|
||||||
|
let method = match operation.method {
|
||||||
|
runtime_api::HttpMethod::Get => Method::GET,
|
||||||
|
runtime_api::HttpMethod::Post => Method::POST,
|
||||||
|
runtime_api::HttpMethod::Put => Method::PUT,
|
||||||
|
runtime_api::HttpMethod::Patch => Method::PATCH,
|
||||||
|
runtime_api::HttpMethod::Delete => Method::DELETE,
|
||||||
|
runtime_api::HttpMethod::Head | runtime_api::HttpMethod::Options => {
|
||||||
|
panic!("unexpected management contract method")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let path = operation.path.replace("{worker_id}", "worker-1");
|
||||||
|
let permission = required_runtime_permission(&method, &path);
|
||||||
|
if operation.operation_id == "runtime_summary" {
|
||||||
|
assert_eq!(
|
||||||
|
permission, None,
|
||||||
|
"{:?} {}",
|
||||||
|
operation.method, operation.path
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
permission.is_some(),
|
||||||
|
"unclassified generated route: {:?} {}",
|
||||||
|
operation.method,
|
||||||
|
operation.path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
|
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
|
||||||
let runtime = Runtime::new_memory();
|
let runtime = Runtime::new_memory();
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
const MANAGEMENT_BODY_LIMIT: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Axum request extensions remain the authorization authority. The generated adapter deliberately
|
||||||
|
// has no authentication model, so the verified extension is scoped across its service call.
|
||||||
|
tokio::task_local! {
|
||||||
|
static AUTH_CONTEXT: Option<RuntimeAuthContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn scope_auth<F>(context: Option<RuntimeAuthContext>, future: F) -> F::Output
|
||||||
|
where
|
||||||
|
F: Future,
|
||||||
|
{
|
||||||
|
AUTH_CONTEXT.scope(context, future).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_extension() -> Option<Extension<RuntimeAuthContext>> {
|
||||||
|
AUTH_CONTEXT
|
||||||
|
.try_with(Clone::clone)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(Extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_auth() -> Result<Extension<RuntimeAuthContext>, runtime_api::RuntimeApiError> {
|
||||||
|
auth_extension().ok_or_else(|| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
|
"workspace_scope_required",
|
||||||
|
"Runtime operation requires an authorization context",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RuntimeManagementApi {
|
||||||
|
state: RuntimeHttpState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn router(state: RuntimeHttpState) -> Router {
|
||||||
|
runtime_api::RuntimeApiAxum::router(RuntimeManagementApi { state })
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(MANAGEMENT_BODY_LIMIT))
|
||||||
|
.layer(middleware::from_fn(runtime_api_wire_compatibility))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn runtime_api_wire_compatibility(
|
||||||
|
mut request: axum::extract::Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
if !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||||
|
if !request.headers().contains_key(header::CONTENT_TYPE) {
|
||||||
|
request.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
axum::http::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let body = match axum::body::to_bytes(body, MANAGEMENT_BODY_LIMIT).await {
|
||||||
|
Ok(body) if body.is_empty() => Bytes::from_static(b"{}"),
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(_) => {
|
||||||
|
return RuntimeHttpRestError::new(
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"request_body_too_large",
|
||||||
|
"request body exceeds Runtime management limit",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request = axum::extract::Request::from_parts(parts, Body::from(body));
|
||||||
|
}
|
||||||
|
let response = next.run(request).await;
|
||||||
|
if response.status() == StatusCode::UNPROCESSABLE_ENTITY {
|
||||||
|
return RuntimeHttpRestError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_json",
|
||||||
|
"request body must be valid JSON",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request<T, U>(value: T) -> Result<U, runtime_api::RuntimeApiError>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
U: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
serde_json::to_value(value)
|
||||||
|
.and_then(serde_json::from_value)
|
||||||
|
.map_err(|error| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST.as_u16(),
|
||||||
|
"invalid_request",
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response<T, U>(value: T) -> Result<U, runtime_api::RuntimeApiError>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
U: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
serde_json::to_value(value)
|
||||||
|
.and_then(serde_json::from_value)
|
||||||
|
.map_err(|error| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||||
|
"runtime_response_conversion_failed",
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_error(error: RuntimeHttpRestError) -> runtime_api::RuntimeApiError {
|
||||||
|
runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||||
|
async fn ping(
|
||||||
|
&self,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<runtime_api::RuntimePingResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-yoi-workspace-id",
|
||||||
|
workspace_id.parse().map_err(|error| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST.as_u16(),
|
||||||
|
"invalid_workspace_header",
|
||||||
|
format!("invalid Workspace ping header: {error}"),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
let Json(value) = get_runtime_ping(State(self.state.clone()), required_auth()?, headers)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn runtime_summary(
|
||||||
|
&self,
|
||||||
|
) -> Result<runtime_api::RuntimeSummaryResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = get_runtime(State(self.state.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_workers(
|
||||||
|
&self,
|
||||||
|
query: runtime_api::WorkerListQuery,
|
||||||
|
) -> Result<runtime_api::WorkersResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = list_workers(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Ok(Query(request(query)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_worker(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = get_worker(State(self.state.clone()), auth_extension(), Path(worker_id))
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_worker(
|
||||||
|
&self,
|
||||||
|
value: runtime_api::CreateWorkerRequest,
|
||||||
|
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = create_worker(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Ok(Json(request(value)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_worker(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
) -> Result<runtime_api::WorkerDeleteResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) =
|
||||||
|
delete_worker(State(self.state.clone()), auth_extension(), Path(worker_id))
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_worker_input(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
input: runtime_api::WorkerInput,
|
||||||
|
) -> Result<runtime_api::WorkerInputResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = send_worker_input(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Path(worker_id),
|
||||||
|
Ok(Json(request(input)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_worker(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
value: runtime_api::WorkerLifecycleRequest,
|
||||||
|
) -> Result<runtime_api::WorkerLifecycleResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let body = serde_json::to_vec(&request::<_, RuntimeHttpWorkerLifecycleRequest>(value)?)
|
||||||
|
.map_err(|error| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
500,
|
||||||
|
"runtime_request_conversion_failed",
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let Json(value) = stop_worker(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Path(worker_id),
|
||||||
|
Bytes::from(body),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel_worker(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
value: runtime_api::WorkerLifecycleRequest,
|
||||||
|
) -> Result<runtime_api::WorkerLifecycleResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let body = serde_json::to_vec(&request::<_, RuntimeHttpWorkerLifecycleRequest>(value)?)
|
||||||
|
.map_err(|error| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
500,
|
||||||
|
"runtime_request_conversion_failed",
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let Json(value) = cancel_worker(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Path(worker_id),
|
||||||
|
Bytes::from(body),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_worker(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
_request: runtime_api::EmptyObjectRequest,
|
||||||
|
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) =
|
||||||
|
restore_worker(State(self.state.clone()), auth_extension(), Path(worker_id))
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_worker_arguments(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
value: runtime_api::CompletionRequest,
|
||||||
|
) -> Result<runtime_api::CompletionResponse, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = worker_completions(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Path(worker_id),
|
||||||
|
Ok(Json(request(value)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn retention_inventory(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
) -> Result<runtime_api::WorkerRetentionInventory, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = worker_retention_inventory(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Path(worker_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_retention(
|
||||||
|
&self,
|
||||||
|
worker_id: String,
|
||||||
|
value: runtime_api::WorkerRetentionExecutionRequest,
|
||||||
|
) -> Result<runtime_api::WorkerRetentionExecutionResult, runtime_api::RuntimeApiError> {
|
||||||
|
let Json(value) = execute_worker_retention(
|
||||||
|
State(self.state.clone()),
|
||||||
|
auth_extension(),
|
||||||
|
Path(worker_id),
|
||||||
|
Ok(Json(request(value)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(api_error)?;
|
||||||
|
response(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ config-source.workspace = true
|
|||||||
flow = { path = "../flow" }
|
flow = { path = "../flow" }
|
||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
|
runtime-api.workspace = true
|
||||||
project-record.workspace = true
|
project-record.workspace = true
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||||
ring.workspace = true
|
ring.workspace = true
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use serde::de::DeserializeOwned;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
error::Error as _,
|
|
||||||
future::Future,
|
future::Future,
|
||||||
io::Read as _,
|
io::Read as _,
|
||||||
net::{IpAddr, SocketAddr, ToSocketAddrs},
|
net::{IpAddr, SocketAddr, ToSocketAddrs},
|
||||||
@@ -48,10 +47,8 @@ use worker_runtime::http_server::{
|
|||||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
||||||
RuntimeHttpErrorResponse, RuntimeHttpPingResponse, RuntimeHttpRepositoryAccessResponse,
|
RuntimeHttpErrorResponse, RuntimeHttpPingResponse, RuntimeHttpRepositoryAccessResponse,
|
||||||
RuntimeHttpSummaryResponse, RuntimeHttpUploadedFileDeleteResponse,
|
RuntimeHttpSummaryResponse, RuntimeHttpUploadedFileDeleteResponse,
|
||||||
RuntimeHttpUploadedFileResponse, RuntimeHttpWorkerCompletionsRequest,
|
RuntimeHttpUploadedFileResponse, RuntimeHttpWorkerCompletionsResponse,
|
||||||
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
|
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
||||||
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
|
||||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
|
||||||
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||||
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
||||||
@@ -3401,49 +3398,7 @@ pub struct RemoteWorkerRuntime {
|
|||||||
resource_broker: BackendResourceBroker,
|
resource_broker: BackendResourceBroker,
|
||||||
http: BlockingHttpClient,
|
http: BlockingHttpClient,
|
||||||
async_http: AsyncHttpClient,
|
async_http: AsyncHttpClient,
|
||||||
}
|
request_timeout: Duration,
|
||||||
|
|
||||||
fn remote_runtime_ping_transport_failure(error: reqwest::Error) -> RuntimePingFailure {
|
|
||||||
if error.is_timeout() {
|
|
||||||
return RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::Timeout,
|
|
||||||
"runtime_ping_timeout",
|
|
||||||
"Runtime ping timed out",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut source = error.source();
|
|
||||||
let mut tls_error = false;
|
|
||||||
while let Some(current) = source {
|
|
||||||
let message = current.to_string().to_ascii_lowercase();
|
|
||||||
if message.contains("tls")
|
|
||||||
|| message.contains("certificate")
|
|
||||||
|| message.contains("unknownissuer")
|
|
||||||
|| message.contains("handshake")
|
|
||||||
{
|
|
||||||
tls_error = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
source = current.source();
|
|
||||||
}
|
|
||||||
if tls_error {
|
|
||||||
return RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::TlsOrTransport,
|
|
||||||
"runtime_ping_tls_failed",
|
|
||||||
"Runtime TLS connection failed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if error.is_connect() {
|
|
||||||
return RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::NetworkUnreachable,
|
|
||||||
"runtime_ping_network_unreachable",
|
|
||||||
"Runtime could not be reached",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::TlsOrTransport,
|
|
||||||
"runtime_ping_transport_failed",
|
|
||||||
"Runtime ping transport failed",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static str {
|
fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static str {
|
||||||
@@ -3509,6 +3464,54 @@ fn worker_id_from_remote_path(path_and_query: &str) -> Option<String> {
|
|||||||
(!worker_id.is_empty()).then(|| worker_id.to_string())
|
(!worker_id.is_empty()).then(|| worker_id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RemoteRuntimeApiAuthorizer {
|
||||||
|
workspace_id: String,
|
||||||
|
bearer_token: Option<String>,
|
||||||
|
workspace_authorization: Option<WorkspaceRuntimeAuthorization>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl runtime_api::client_support::RequestAuthorizer for RemoteRuntimeApiAuthorizer {
|
||||||
|
fn authorize(
|
||||||
|
&self,
|
||||||
|
request: runtime_api::client_support::AuthorizerRequest<'_>,
|
||||||
|
) -> Result<reqwest::header::HeaderMap, runtime_api::client_support::AuthorizationError> {
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-yoi-workspace-id",
|
||||||
|
self.workspace_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| runtime_api::client_support::AuthorizationError::new())?,
|
||||||
|
);
|
||||||
|
let authorization = if let Some(authorizer) = &self.workspace_authorization {
|
||||||
|
let operation =
|
||||||
|
workspace_runtime_operation(request.method.as_str(), request.path_and_query);
|
||||||
|
let worker_id = worker_id_from_remote_path(request.path_and_query);
|
||||||
|
let token = authorizer
|
||||||
|
.issue(
|
||||||
|
request.method.as_str(),
|
||||||
|
request.path_and_query,
|
||||||
|
operation,
|
||||||
|
worker_id.as_deref(),
|
||||||
|
request.body,
|
||||||
|
)
|
||||||
|
.map_err(|_| runtime_api::client_support::AuthorizationError::new())?;
|
||||||
|
format!("Bearer {token}")
|
||||||
|
} else if let Some(token) = self.bearer_token.as_deref() {
|
||||||
|
format!("Bearer {token}")
|
||||||
|
} else {
|
||||||
|
return Ok(headers);
|
||||||
|
};
|
||||||
|
headers.insert(
|
||||||
|
AUTHORIZATION,
|
||||||
|
authorization
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| runtime_api::client_support::AuthorizationError::new())?,
|
||||||
|
);
|
||||||
|
Ok(headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RemoteWorkerRuntime {
|
impl RemoteWorkerRuntime {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: RemoteRuntimeConfig,
|
config: RemoteRuntimeConfig,
|
||||||
@@ -3580,6 +3583,7 @@ impl RemoteWorkerRuntime {
|
|||||||
resource_broker: BackendResourceBroker::default(),
|
resource_broker: BackendResourceBroker::default(),
|
||||||
http,
|
http,
|
||||||
async_http,
|
async_http,
|
||||||
|
request_timeout: timeout,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3613,6 +3617,72 @@ impl RemoteWorkerRuntime {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_api_client(
|
||||||
|
&self,
|
||||||
|
timeout: Duration,
|
||||||
|
response_body_limit: usize,
|
||||||
|
) -> Result<runtime_api::RuntimeApiClient<RemoteRuntimeApiAuthorizer>, RuntimeDiagnostic> {
|
||||||
|
runtime_api::RuntimeApiClient::builder(&self.base_url)
|
||||||
|
.map_err(|error| {
|
||||||
|
diagnostic(
|
||||||
|
"remote_runtime_client_build_failed",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.client(self.async_http.clone())
|
||||||
|
.authorizer(RemoteRuntimeApiAuthorizer {
|
||||||
|
workspace_id: self.workspace_id.clone(),
|
||||||
|
bearer_token: self.bearer_token.clone(),
|
||||||
|
workspace_authorization: self.workspace_authorization.clone(),
|
||||||
|
})
|
||||||
|
.request_timeout(timeout)
|
||||||
|
.response_body_limit(response_body_limit)
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
diagnostic(
|
||||||
|
"remote_runtime_client_build_failed",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_runtime_api<T, F, Fut>(
|
||||||
|
&self,
|
||||||
|
timeout: Duration,
|
||||||
|
response_body_limit: usize,
|
||||||
|
operation: F,
|
||||||
|
) -> Result<T, RuntimeDiagnostic>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(runtime_api::RuntimeApiClient<RemoteRuntimeApiAuthorizer>) -> Fut
|
||||||
|
+ Send
|
||||||
|
+ 'static,
|
||||||
|
Fut: std::future::Future<
|
||||||
|
Output = Result<
|
||||||
|
T,
|
||||||
|
runtime_api::client_support::ClientError<runtime_api::RuntimeApiError>,
|
||||||
|
>,
|
||||||
|
> + Send
|
||||||
|
+ 'static,
|
||||||
|
{
|
||||||
|
let client = self.runtime_api_client(timeout, response_body_limit)?;
|
||||||
|
let runtime_id = self.runtime_id.clone();
|
||||||
|
run_blocking_http(move || {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(|_| {
|
||||||
|
runtime_api::client_support::ClientError::failure(
|
||||||
|
runtime_api::client_support::ClientFailure::Transport,
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.block_on(operation(client))
|
||||||
|
})
|
||||||
|
.map_err(|error| runtime_api_diagnostic(&runtime_id, error))
|
||||||
|
}
|
||||||
|
|
||||||
fn endpoint(&self, path: &str) -> String {
|
fn endpoint(&self, path: &str) -> String {
|
||||||
format!("{}{}", self.base_url, path)
|
format!("{}{}", self.base_url, path)
|
||||||
}
|
}
|
||||||
@@ -3743,88 +3813,6 @@ impl RemoteWorkerRuntime {
|
|||||||
self.send_json(path, "DELETE", &[], self.http.delete(self.endpoint(path)))
|
self.send_json(path, "DELETE", &[], self.http.delete(self.endpoint(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ping_http(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
|
|
||||||
const PATH: &str = "/v1/ping";
|
|
||||||
let workspace_id = self.workspace_id.clone();
|
|
||||||
let bearer_token = self.bearer_token.clone();
|
|
||||||
let capability_token = match &self.workspace_authorization {
|
|
||||||
Some(authorization) => Some(
|
|
||||||
authorization
|
|
||||||
.issue("GET", PATH, RUNTIME_PING_PERMISSION, None, &[])
|
|
||||||
.map_err(|diagnostic| {
|
|
||||||
RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::Authentication,
|
|
||||||
diagnostic.code,
|
|
||||||
diagnostic.message,
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let request = self
|
|
||||||
.http
|
|
||||||
.get(self.endpoint(PATH))
|
|
||||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, &workspace_id);
|
|
||||||
run_blocking_http(move || {
|
|
||||||
let request = match capability_token.as_deref().or(bearer_token.as_deref()) {
|
|
||||||
Some(token) => request.header(AUTHORIZATION, format!("Bearer {token}")),
|
|
||||||
None => request,
|
|
||||||
};
|
|
||||||
let response = request
|
|
||||||
.send()
|
|
||||||
.map_err(remote_runtime_ping_transport_failure)?;
|
|
||||||
match response.status() {
|
|
||||||
StatusCode::UNAUTHORIZED => {
|
|
||||||
return Err(RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::Authentication,
|
|
||||||
"runtime_ping_authentication_failed",
|
|
||||||
"Runtime rejected the connection-test credential",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
StatusCode::FORBIDDEN => {
|
|
||||||
return Err(RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::Authorization,
|
|
||||||
"runtime_ping_authorization_failed",
|
|
||||||
"Runtime rejected the connection-test scope or permission",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
status if !status.is_success() => {
|
|
||||||
return Err(RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::TlsOrTransport,
|
|
||||||
"runtime_ping_http_failed",
|
|
||||||
"Runtime ping returned an unsuccessful HTTP response",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
let mut body = Vec::new();
|
|
||||||
response
|
|
||||||
.take((MAX_RUNTIME_PING_RESPONSE_BYTES + 1) as u64)
|
|
||||||
.read_to_end(&mut body)
|
|
||||||
.map_err(|_| {
|
|
||||||
RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::TlsOrTransport,
|
|
||||||
"runtime_ping_response_read_failed",
|
|
||||||
"Runtime ping response could not be read",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if body.len() > MAX_RUNTIME_PING_RESPONSE_BYTES {
|
|
||||||
return Err(RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::MalformedResponse,
|
|
||||||
"runtime_ping_response_too_large",
|
|
||||||
"Runtime ping response exceeded the allowed size",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
serde_json::from_slice::<RuntimeHttpPingResponse>(&body).map_err(|_| {
|
|
||||||
RuntimePingFailure::new(
|
|
||||||
RuntimePingFailureKind::MalformedResponse,
|
|
||||||
"runtime_ping_malformed_response",
|
|
||||||
"Runtime ping returned an unrecognized response",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_json<T>(
|
fn send_json<T>(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -4016,7 +4004,14 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_summary(&self, limit: usize) -> RuntimeSummary {
|
fn runtime_summary(&self, limit: usize) -> RuntimeSummary {
|
||||||
match self.get_json::<RuntimeHttpSummaryResponse>("/v1/runtime") {
|
match self
|
||||||
|
.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
|client| async move { client.runtime_summary().await },
|
||||||
|
)
|
||||||
|
.and_then(|value| runtime_contract_convert::<_, RuntimeHttpSummaryResponse>(value))
|
||||||
|
{
|
||||||
Ok(response) => RuntimeSummary {
|
Ok(response) => RuntimeSummary {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
label: response
|
label: response
|
||||||
@@ -4056,7 +4051,50 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ping(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
|
fn ping(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
|
||||||
self.ping_http()
|
let workspace_id = self.workspace_id.clone();
|
||||||
|
self.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_RUNTIME_PING_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.ping(workspace_id).await },
|
||||||
|
)
|
||||||
|
.and_then(runtime_contract_convert)
|
||||||
|
.map_err(|diagnostic| {
|
||||||
|
let kind = if diagnostic.code.contains("auth") {
|
||||||
|
RuntimePingFailureKind::Authentication
|
||||||
|
} else if diagnostic.code.contains("network_unreachable") {
|
||||||
|
RuntimePingFailureKind::NetworkUnreachable
|
||||||
|
} else if diagnostic.code.contains("timeout") {
|
||||||
|
RuntimePingFailureKind::Timeout
|
||||||
|
} else if diagnostic.code.contains("invalid_response")
|
||||||
|
|| diagnostic.code.contains("response_too_large")
|
||||||
|
{
|
||||||
|
RuntimePingFailureKind::MalformedResponse
|
||||||
|
} else {
|
||||||
|
RuntimePingFailureKind::TlsOrTransport
|
||||||
|
};
|
||||||
|
let (code, message) = match kind {
|
||||||
|
RuntimePingFailureKind::Authentication => (
|
||||||
|
"runtime_ping_authentication_failed",
|
||||||
|
"Runtime rejected ping authentication",
|
||||||
|
),
|
||||||
|
RuntimePingFailureKind::NetworkUnreachable => (
|
||||||
|
"runtime_ping_network_unreachable",
|
||||||
|
"Runtime could not be reached",
|
||||||
|
),
|
||||||
|
RuntimePingFailureKind::Timeout => {
|
||||||
|
("runtime_ping_timeout", "Runtime ping timed out")
|
||||||
|
}
|
||||||
|
RuntimePingFailureKind::MalformedResponse => (
|
||||||
|
"runtime_ping_malformed_response",
|
||||||
|
"Runtime ping returned a malformed response",
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
"runtime_ping_transport_failed",
|
||||||
|
"Runtime ping transport failed",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
RuntimePingFailure::new(kind, code, message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) {
|
fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) {
|
||||||
@@ -4114,7 +4152,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
return RuntimeList::new(Vec::new(), Vec::new());
|
return RuntimeList::new(Vec::new(), Vec::new());
|
||||||
}
|
}
|
||||||
match self.get_json::<RuntimeHttpWorkersResponse>("/v1/workers") {
|
match self
|
||||||
|
.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
|client| async move {
|
||||||
|
client
|
||||||
|
.list_workers(runtime_api::WorkerListQuery::default())
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.and_then(|value| runtime_contract_convert::<_, RuntimeHttpWorkersResponse>(value))
|
||||||
|
{
|
||||||
Ok(response) => RuntimeList::new(
|
Ok(response) => RuntimeList::new(
|
||||||
response
|
response
|
||||||
.workers
|
.workers
|
||||||
@@ -4132,7 +4181,20 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
return RuntimeList::new(Vec::new(), Vec::new());
|
return RuntimeList::new(Vec::new(), Vec::new());
|
||||||
}
|
}
|
||||||
match self.get_json::<RuntimeHttpWorkersResponse>("/v1/workers?status=stopped") {
|
match self
|
||||||
|
.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
|client| async move {
|
||||||
|
client
|
||||||
|
.list_workers(runtime_api::WorkerListQuery {
|
||||||
|
status: Some(runtime_api::WorkerStatusFilter::Stopped),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.and_then(|value| runtime_contract_convert::<_, RuntimeHttpWorkersResponse>(value))
|
||||||
|
{
|
||||||
Ok(response) => RuntimeList::new(
|
Ok(response) => RuntimeList::new(
|
||||||
response
|
response
|
||||||
.workers
|
.workers
|
||||||
@@ -4147,7 +4209,15 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn worker(&self, worker_id: &str) -> WorkerLookupResult {
|
fn worker(&self, worker_id: &str) -> WorkerLookupResult {
|
||||||
match self.get_json::<RuntimeHttpWorkerResponse>(&format!("/v1/workers/{worker_id}")) {
|
let worker_id_owned = worker_id.to_string();
|
||||||
|
match self
|
||||||
|
.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.get_worker(worker_id_owned).await },
|
||||||
|
)
|
||||||
|
.and_then(|value| runtime_contract_convert::<_, RuntimeHttpWorkerResponse>(value))
|
||||||
|
{
|
||||||
Ok(response) => WorkerLookupResult {
|
Ok(response) => WorkerLookupResult {
|
||||||
worker: Some(self.map_worker_detail(response.worker)),
|
worker: Some(self.map_worker_detail(response.worker)),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
@@ -4164,10 +4234,19 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult {
|
fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult {
|
||||||
match self.post_json::<_, RuntimeHttpWorkerResponse>(
|
let worker_id_owned = worker_id.to_string();
|
||||||
&format!("/v1/workers/{worker_id}/restore"),
|
match self
|
||||||
&serde_json::json!({}),
|
.run_runtime_api(
|
||||||
) {
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move {
|
||||||
|
client
|
||||||
|
.restore_worker(worker_id_owned, runtime_api::EmptyObjectRequest::default())
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.and_then(|value| runtime_contract_convert::<_, RuntimeHttpWorkerResponse>(value))
|
||||||
|
{
|
||||||
Ok(response) => WorkerRestoreResult {
|
Ok(response) => WorkerRestoreResult {
|
||||||
state: WorkerOperationState::Accepted,
|
state: WorkerOperationState::Accepted,
|
||||||
worker: Some(self.map_worker_detail(response.worker)),
|
worker: Some(self.map_worker_detail(response.worker)),
|
||||||
@@ -4389,31 +4468,25 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
workspace_api: Some(workspace_api),
|
workspace_api: Some(workspace_api),
|
||||||
memory_settings: request.resolved_memory_settings.clone(),
|
memory_settings: request.resolved_memory_settings.clone(),
|
||||||
};
|
};
|
||||||
let create_body = match serde_json::to_vec(&create) {
|
let create = match runtime_contract_convert(create) {
|
||||||
Ok(body) => body,
|
Ok(create) => create,
|
||||||
Err(error) => {
|
Err(diagnostic) => {
|
||||||
return WorkerSpawnResult {
|
return WorkerSpawnResult {
|
||||||
state: WorkerOperationState::Rejected,
|
state: WorkerOperationState::Rejected,
|
||||||
worker: None,
|
worker: None,
|
||||||
acceptance_evidence: Vec::new(),
|
acceptance_evidence: Vec::new(),
|
||||||
diagnostics: vec![diagnostic(
|
diagnostics: vec![diagnostic],
|
||||||
"remote_runtime_request_encode_failed",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
error.to_string(),
|
|
||||||
)],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match self.send_json::<RuntimeHttpWorkerResponse>(
|
match self
|
||||||
"/v1/workers",
|
.run_runtime_api(
|
||||||
"POST",
|
REMOTE_WORKER_CREATE_TIMEOUT,
|
||||||
&create_body,
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
self.http
|
move |client| async move { client.create_worker(create).await },
|
||||||
.post(self.endpoint("/v1/workers"))
|
)
|
||||||
.timeout(REMOTE_WORKER_CREATE_TIMEOUT)
|
.and_then(|value| runtime_contract_convert::<_, RuntimeHttpWorkerResponse>(value))
|
||||||
.header(CONTENT_TYPE, "application/json")
|
{
|
||||||
.body(create_body.clone()),
|
|
||||||
) {
|
|
||||||
Ok(response) => WorkerSpawnResult {
|
Ok(response) => WorkerSpawnResult {
|
||||||
state: WorkerOperationState::Accepted,
|
state: WorkerOperationState::Accepted,
|
||||||
worker: Some(self.map_worker_detail(response.worker)),
|
worker: Some(self.map_worker_detail(response.worker)),
|
||||||
@@ -4484,13 +4557,19 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
request: WorkerLifecycleRequest,
|
request: WorkerLifecycleRequest,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
let body = RuntimeHttpWorkerLifecycleRequest {
|
let body = runtime_api::WorkerLifecycleRequest {
|
||||||
reason: request.reason,
|
reason: request.reason,
|
||||||
};
|
};
|
||||||
match self.post_json::<_, RuntimeHttpWorkerLifecycleResponse>(
|
let worker_id_owned = worker_id.to_string();
|
||||||
&format!("/v1/workers/{worker_id}/stop"),
|
match self
|
||||||
&body,
|
.run_runtime_api(
|
||||||
) {
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.stop_worker(worker_id_owned, body).await },
|
||||||
|
)
|
||||||
|
.and_then(|value| {
|
||||||
|
runtime_contract_convert::<_, RuntimeHttpWorkerLifecycleResponse>(value)
|
||||||
|
}) {
|
||||||
Ok(response) => self.lifecycle_result_from_response(worker_id, response),
|
Ok(response) => self.lifecycle_result_from_response(worker_id, response),
|
||||||
Err(diagnostic) => remote_lifecycle_rejected(&self.runtime_id, worker_id, diagnostic),
|
Err(diagnostic) => remote_lifecycle_rejected(&self.runtime_id, worker_id, diagnostic),
|
||||||
}
|
}
|
||||||
@@ -4501,21 +4580,33 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
request: WorkerLifecycleRequest,
|
request: WorkerLifecycleRequest,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
let body = RuntimeHttpWorkerLifecycleRequest {
|
let body = runtime_api::WorkerLifecycleRequest {
|
||||||
reason: request.reason,
|
reason: request.reason,
|
||||||
};
|
};
|
||||||
match self.post_json::<_, RuntimeHttpWorkerLifecycleResponse>(
|
let worker_id_owned = worker_id.to_string();
|
||||||
&format!("/v1/workers/{worker_id}/cancel"),
|
match self
|
||||||
&body,
|
.run_runtime_api(
|
||||||
) {
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.cancel_worker(worker_id_owned, body).await },
|
||||||
|
)
|
||||||
|
.and_then(|value| {
|
||||||
|
runtime_contract_convert::<_, RuntimeHttpWorkerLifecycleResponse>(value)
|
||||||
|
}) {
|
||||||
Ok(response) => self.lifecycle_result_from_response(worker_id, response),
|
Ok(response) => self.lifecycle_result_from_response(worker_id, response),
|
||||||
Err(diagnostic) => remote_lifecycle_rejected(&self.runtime_id, worker_id, diagnostic),
|
Err(diagnostic) => remote_lifecycle_rejected(&self.runtime_id, worker_id, diagnostic),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
|
fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult {
|
||||||
|
let worker_id_owned = worker_id.to_string();
|
||||||
match self
|
match self
|
||||||
.delete_json::<RuntimeHttpWorkerDeleteResponse>(&format!("/v1/workers/{worker_id}"))
|
.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.delete_worker(worker_id_owned).await },
|
||||||
|
)
|
||||||
|
.and_then(|value| runtime_contract_convert::<_, RuntimeHttpWorkerDeleteResponse>(value))
|
||||||
{
|
{
|
||||||
Ok(response) => WorkerDeleteResult {
|
Ok(response) => WorkerDeleteResult {
|
||||||
state: WorkerOperationState::Accepted,
|
state: WorkerOperationState::Accepted,
|
||||||
@@ -4539,9 +4630,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
) -> Result<WorkerRetentionInventory, String> {
|
) -> Result<WorkerRetentionInventory, String> {
|
||||||
self.get_json::<WorkerRetentionInventory>(&format!(
|
let worker_id = worker_id.to_string();
|
||||||
"/v1/workers/{worker_id}/retention/inventory"
|
self.run_runtime_api(
|
||||||
))
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.retention_inventory(worker_id).await },
|
||||||
|
)
|
||||||
|
.and_then(runtime_contract_convert)
|
||||||
.map_err(|diagnostic| diagnostic.message)
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4550,10 +4645,14 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
request: WorkerRetentionExecutionRequest,
|
request: WorkerRetentionExecutionRequest,
|
||||||
) -> Result<WorkerRetentionExecutionResult, String> {
|
) -> Result<WorkerRetentionExecutionResult, String> {
|
||||||
let worker_id = request.worker_id.to_string();
|
let worker_id = request.worker_id.to_string();
|
||||||
self.post_json::<_, WorkerRetentionExecutionResult>(
|
let request = runtime_contract_convert(request).map_err(|diagnostic| diagnostic.message)?;
|
||||||
&format!("/v1/workers/{worker_id}/retention/execute"),
|
let worker_id = worker_id.to_string();
|
||||||
&request,
|
self.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.execute_retention(worker_id, request).await },
|
||||||
)
|
)
|
||||||
|
.and_then(runtime_contract_convert)
|
||||||
.map_err(|diagnostic| diagnostic.message)
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4590,9 +4689,21 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
submission_request_id: None,
|
submission_request_id: None,
|
||||||
segments: request.segments,
|
segments: request.segments,
|
||||||
};
|
};
|
||||||
match self.post_json::<_, RuntimeHttpWorkerInputResponse>(
|
let input = match runtime_contract_convert(input) {
|
||||||
&format!("/v1/workers/{worker_id}/input"),
|
Ok(input) => input,
|
||||||
&input,
|
Err(diagnostic) => {
|
||||||
|
return WorkerInputResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||||
|
diagnostics: vec![diagnostic],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let worker_id_owned = worker_id.to_string();
|
||||||
|
match self.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.send_worker_input(worker_id_owned, input).await },
|
||||||
) {
|
) {
|
||||||
Ok(_) => WorkerInputResult {
|
Ok(_) => WorkerInputResult {
|
||||||
state: WorkerOperationState::Accepted,
|
state: WorkerOperationState::Accepted,
|
||||||
@@ -4660,14 +4771,26 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
request: WorkerCompletionsRequest,
|
request: WorkerCompletionsRequest,
|
||||||
) -> WorkerCompletionsResult {
|
) -> WorkerCompletionsResult {
|
||||||
let request = RuntimeHttpWorkerCompletionsRequest {
|
let request = runtime_api::CompletionRequest {
|
||||||
kind: request.kind,
|
kind: request.kind,
|
||||||
prefix: request.prefix,
|
prefix: request.prefix,
|
||||||
};
|
};
|
||||||
match self.post_json::<_, RuntimeHttpWorkerCompletionsResponse>(
|
let failure_kind = request.kind;
|
||||||
&format!("/v1/workers/{worker_id}/completions"),
|
let failure_prefix = request.prefix.clone();
|
||||||
&request,
|
let worker_id_owned = worker_id.to_string();
|
||||||
) {
|
match self
|
||||||
|
.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move {
|
||||||
|
client
|
||||||
|
.complete_worker_arguments(worker_id_owned, request)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.and_then(|value| {
|
||||||
|
runtime_contract_convert::<_, RuntimeHttpWorkerCompletionsResponse>(value)
|
||||||
|
}) {
|
||||||
Ok(response) => WorkerCompletionsResult {
|
Ok(response) => WorkerCompletionsResult {
|
||||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||||
kind: response.kind,
|
kind: response.kind,
|
||||||
@@ -4677,8 +4800,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
},
|
},
|
||||||
Err(diagnostic) => WorkerCompletionsResult {
|
Err(diagnostic) => WorkerCompletionsResult {
|
||||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||||
kind: request.kind,
|
kind: failure_kind,
|
||||||
prefix: request.prefix,
|
prefix: failure_prefix,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
diagnostics: vec![diagnostic],
|
diagnostics: vec![diagnostic],
|
||||||
},
|
},
|
||||||
@@ -5191,6 +5314,84 @@ fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_contract_convert<T, U>(value: T) -> Result<U, RuntimeDiagnostic>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
U: DeserializeOwned,
|
||||||
|
{
|
||||||
|
serde_json::to_value(value)
|
||||||
|
.and_then(serde_json::from_value)
|
||||||
|
.map_err(|error| {
|
||||||
|
diagnostic(
|
||||||
|
"remote_runtime_contract_conversion_failed",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_api_diagnostic(
|
||||||
|
runtime_id: &str,
|
||||||
|
error: runtime_api::client_support::ClientError<runtime_api::RuntimeApiError>,
|
||||||
|
) -> RuntimeDiagnostic {
|
||||||
|
match error {
|
||||||
|
runtime_api::client_support::ClientError::Public { status, error } => {
|
||||||
|
let remote_code = error.error.code;
|
||||||
|
let message = sanitize_remote_runtime_message(&remote_code, &error.error.message);
|
||||||
|
let (code, severity) = match status {
|
||||||
|
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => (
|
||||||
|
"remote_runtime_auth_failed".to_string(),
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
remote_code,
|
||||||
|
if status.is_server_error() {
|
||||||
|
DiagnosticSeverity::Error
|
||||||
|
} else {
|
||||||
|
DiagnosticSeverity::Warning
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
diagnostic(
|
||||||
|
code,
|
||||||
|
severity,
|
||||||
|
format!(
|
||||||
|
"Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {message}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
runtime_api::client_support::ClientError::Failure(failure) => {
|
||||||
|
let code = match failure {
|
||||||
|
runtime_api::client_support::ClientFailure::Timeout => "remote_runtime_timeout",
|
||||||
|
runtime_api::client_support::ClientFailure::Connect => {
|
||||||
|
"remote_runtime_network_unreachable"
|
||||||
|
}
|
||||||
|
runtime_api::client_support::ClientFailure::Authorization => {
|
||||||
|
"remote_runtime_authorization_failed"
|
||||||
|
}
|
||||||
|
runtime_api::client_support::ClientFailure::ErrorResponseDecode { status }
|
||||||
|
if status == StatusCode::UNAUTHORIZED.as_u16()
|
||||||
|
|| status == StatusCode::FORBIDDEN.as_u16() =>
|
||||||
|
{
|
||||||
|
"remote_runtime_auth_failed"
|
||||||
|
}
|
||||||
|
runtime_api::client_support::ClientFailure::ResponseTooLarge { .. } => {
|
||||||
|
"remote_runtime_response_too_large"
|
||||||
|
}
|
||||||
|
runtime_api::client_support::ClientFailure::Decode { .. } => {
|
||||||
|
"remote_runtime_invalid_response"
|
||||||
|
}
|
||||||
|
_ => "remote_runtime_client_error",
|
||||||
|
};
|
||||||
|
diagnostic(
|
||||||
|
code,
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
format!("Remote Runtime client error for '{runtime_id}': {failure}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sanitize_remote_runtime_message(code: &str, message: &str) -> String {
|
fn sanitize_remote_runtime_message(code: &str, message: &str) -> String {
|
||||||
if message.contains('/') || message.contains('\\') {
|
if message.contains('/') || message.contains('\\') {
|
||||||
format!("remote Runtime returned {code}; backend-private details were omitted")
|
format!("remote Runtime returned {code}; backend-private details were omitted")
|
||||||
|
|||||||
Reference in New Issue
Block a user