chore: merge develop into hare/develop
This commit is contained in:
@@ -209,7 +209,9 @@ pub struct LogicalWorkdirAttachment {
|
||||
}
|
||||
|
||||
fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities {
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
// Capability-less wire and persisted records fail closed instead of
|
||||
// silently regaining write or command authority.
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -218,6 +220,9 @@ pub struct WorkingDirectoryAttachmentClaim {
|
||||
pub working_directory_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub relative_cwd: Option<String>,
|
||||
/// Backend-authoritative maximum capabilities for the attachment session.
|
||||
#[serde(default = "default_workdir_session_capabilities")]
|
||||
pub capabilities: workdir::WorkdirSessionCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use crate::catalog::{ConfigBundleRef, ProfileSelector};
|
||||
use crate::error::RuntimeError;
|
||||
use crate::profile_archive::{
|
||||
ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef,
|
||||
ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary,
|
||||
VerifiedProfileSourceArchive,
|
||||
};
|
||||
use crate::resource::{BackendResourceHandle, validate_resource_handle_text};
|
||||
use crate::resource::{
|
||||
BackendResourceHandle, BackendResourceKind, BackendResourceOperation, ResourceRedactionPolicy,
|
||||
validate_resource_handle_text,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -162,6 +165,181 @@ impl ConfigBundle {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigBundle> for server_api::WorkspaceRuntimeConfigResponse {
|
||||
fn from(bundle: ConfigBundle) -> Self {
|
||||
let ConfigBundle {
|
||||
metadata,
|
||||
profiles,
|
||||
declarations,
|
||||
prompt_catalog,
|
||||
profile_source_archive,
|
||||
profile_source_archive_handle,
|
||||
} = bundle;
|
||||
Self {
|
||||
metadata: metadata.into(),
|
||||
profiles: profiles.into_iter().map(Into::into).collect(),
|
||||
declarations: declarations.into_iter().map(Into::into).collect(),
|
||||
prompt_catalog: prompt_catalog.map(|catalog| {
|
||||
server_api::WorkspaceRuntimePromptCatalog {
|
||||
templates: catalog.templates,
|
||||
config_revision: catalog.config_revision,
|
||||
source_digest: catalog.source_digest,
|
||||
schema_fingerprint: catalog.schema_fingerprint,
|
||||
toolchain_fingerprint: catalog.toolchain_fingerprint,
|
||||
catalog_digest: catalog.catalog_digest,
|
||||
}
|
||||
}),
|
||||
profile_source_archive: profile_source_archive.map(Into::into),
|
||||
profile_source_archive_handle: profile_source_archive_handle.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigBundleMetadata> for server_api::WorkspaceRuntimeConfigMetadata {
|
||||
fn from(metadata: ConfigBundleMetadata) -> Self {
|
||||
Self {
|
||||
id: metadata.id,
|
||||
digest: metadata.digest,
|
||||
revision: metadata.revision,
|
||||
workspace_id: metadata.workspace_id,
|
||||
created_at: metadata.created_at,
|
||||
provenance: metadata.provenance.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigBundleProvenance> for server_api::WorkspaceRuntimeConfigProvenance {
|
||||
fn from(provenance: ConfigBundleProvenance) -> Self {
|
||||
Self {
|
||||
source: provenance.source,
|
||||
detail: provenance.detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigProfileDescriptor> for server_api::WorkspaceRuntimeConfigProfile {
|
||||
fn from(profile: ConfigProfileDescriptor) -> Self {
|
||||
Self {
|
||||
selector: profile.selector.into(),
|
||||
label: profile.label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileSelector> for server_api::WorkspaceRuntimeProfileSelector {
|
||||
fn from(selector: ProfileSelector) -> Self {
|
||||
match selector {
|
||||
ProfileSelector::Builtin(value) => Self::Builtin(value),
|
||||
ProfileSelector::Named(value) => Self::Named(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigDeclaration> for server_api::WorkspaceRuntimeConfigDeclaration {
|
||||
fn from(declaration: ConfigDeclaration) -> Self {
|
||||
Self {
|
||||
kind: declaration.kind.into(),
|
||||
name: declaration.name,
|
||||
reference: declaration.reference,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigDeclarationKind> for server_api::WorkspaceRuntimeConfigDeclarationKind {
|
||||
fn from(kind: ConfigDeclarationKind) -> Self {
|
||||
match kind {
|
||||
ConfigDeclarationKind::SecretRef => Self::SecretRef,
|
||||
ConfigDeclarationKind::MountGrant => Self::MountGrant,
|
||||
ConfigDeclarationKind::NetworkPolicy => Self::NetworkPolicy,
|
||||
ConfigDeclarationKind::ShellPolicy => Self::ShellPolicy,
|
||||
ConfigDeclarationKind::GitPolicy => Self::GitPolicy,
|
||||
ConfigDeclarationKind::CapabilityGrant => Self::CapabilityGrant,
|
||||
ConfigDeclarationKind::Unsupported => Self::Unsupported,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileSourceArchive> for server_api::WorkspaceRuntimeProfileSourceArchive {
|
||||
fn from(archive: ProfileSourceArchive) -> Self {
|
||||
Self {
|
||||
reference: archive.reference.into(),
|
||||
content: archive.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileSourceArchiveRef> for server_api::WorkspaceRuntimeProfileSourceArchiveRef {
|
||||
fn from(reference: ProfileSourceArchiveRef) -> Self {
|
||||
Self {
|
||||
id: reference.id,
|
||||
digest: reference.digest,
|
||||
size_bytes: reference.size_bytes,
|
||||
source_graph: reference.source_graph.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileSourceGraphSummary> for server_api::WorkspaceRuntimeProfileSourceGraph {
|
||||
fn from(summary: ProfileSourceGraphSummary) -> Self {
|
||||
Self {
|
||||
source_count: summary.source_count,
|
||||
total_source_bytes: summary.total_source_bytes,
|
||||
entrypoints: summary.entrypoints,
|
||||
import_count: summary.import_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BackendResourceHandle> for server_api::WorkspaceRuntimeResourceHandle {
|
||||
fn from(handle: BackendResourceHandle) -> Self {
|
||||
Self {
|
||||
kind: handle.kind.into(),
|
||||
workspace_id: handle.workspace_id,
|
||||
scope_id: handle.scope_id,
|
||||
runtime_id: handle.runtime_id,
|
||||
worker_id: handle.worker_id,
|
||||
resource_id: handle.resource_id,
|
||||
digest: handle.digest,
|
||||
operation: handle.operation.into(),
|
||||
expires_at_unix_seconds: handle.expires_at_unix_seconds,
|
||||
nonce: handle.nonce,
|
||||
revision: handle.revision,
|
||||
generation: handle.generation,
|
||||
max_bytes: handle.max_bytes,
|
||||
content_type: handle.content_type,
|
||||
redaction: handle.redaction.into(),
|
||||
audit_correlation_id: handle.audit_correlation_id,
|
||||
profile_source_graph: handle.profile_source_graph.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BackendResourceKind> for server_api::WorkspaceRuntimeResourceKind {
|
||||
fn from(kind: BackendResourceKind) -> Self {
|
||||
match kind {
|
||||
BackendResourceKind::ProfileSourceArchive => Self::ProfileSourceArchive,
|
||||
BackendResourceKind::RepositorySshAccess => Self::RepositorySshAccess,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BackendResourceOperation> for server_api::WorkspaceRuntimeResourceOperation {
|
||||
fn from(operation: BackendResourceOperation) -> Self {
|
||||
match operation {
|
||||
BackendResourceOperation::FetchArchive => Self::FetchArchive,
|
||||
BackendResourceOperation::FetchOnce => Self::FetchOnce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ResourceRedactionPolicy> for server_api::WorkspaceRuntimeResourceRedactionPolicy {
|
||||
fn from(policy: ResourceRedactionPolicy) -> Self {
|
||||
match policy {
|
||||
ResourceRedactionPolicy::RuntimeInternalOnly => Self::RuntimeInternalOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigBundleMetadata {
|
||||
pub id: String,
|
||||
|
||||
@@ -1801,6 +1801,11 @@ impl<'de> Deserialize<'de> for WorkerExecutionRecord {
|
||||
alias,
|
||||
working_directory_id: claim.working_directory_id,
|
||||
relative_cwd: claim.relative_cwd,
|
||||
// The removed singular field represented a full local
|
||||
// Workdir binding. Preserve that known legacy contract
|
||||
// explicitly; missing capabilities on the current claim
|
||||
// shape default to read-only instead.
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2474,10 +2479,41 @@ mod tests {
|
||||
assert_eq!(attachment.alias.as_str(), "workdir");
|
||||
assert_eq!(attachment.working_directory_id, "wd-legacy");
|
||||
assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi"));
|
||||
assert_eq!(
|
||||
attachment.capabilities,
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
let serialized = serde_json::to_value(execution).unwrap();
|
||||
assert!(serialized["request"].get("working_directory").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_attachment_claim_without_capabilities_defaults_to_read_only() {
|
||||
let worker_id = WorkerId::now_v7();
|
||||
let mut request = schema_v7_worker_document(worker_id)["request"].clone();
|
||||
request["workdir_attachments"] = serde_json::json!([{
|
||||
"alias": "docs",
|
||||
"working_directory_id": "wd-docs"
|
||||
}]);
|
||||
let execution: WorkerExecutionRecord = serde_json::from_value(serde_json::json!({
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"request": request,
|
||||
"binding": {},
|
||||
"restore_intent": "automatic"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
execution.request.workdir_attachments[0].capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
let serialized = serde_json::to_value(execution).unwrap();
|
||||
assert_eq!(
|
||||
serialized["request"]["workdir_attachments"][0]["capabilities"],
|
||||
serde_json::to_value(workdir::WorkdirSessionCapabilities::READ_ONLY).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_identity_round_trips_logical_workdir_attachments() {
|
||||
let worker_id = WorkerId::now_v7();
|
||||
@@ -2512,7 +2548,7 @@ mod tests {
|
||||
[LogicalWorkdirAttachment {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
|
||||
working_directory_id: "remote-workdir".to_string(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION;
|
||||
use crate::identity::WorkerId;
|
||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
||||
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||
use crate::workspace_request::RuntimeWorkspaceRequestClient;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -219,11 +219,6 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
&self,
|
||||
request: BackendResourceFetchRequest,
|
||||
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
|
||||
let body = serde_json::to_vec(&request).map_err(|error| {
|
||||
BackendResourceError::InvalidResponse {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let endpoint = reqwest::Url::parse(&self.endpoint).map_err(|error| {
|
||||
BackendResourceError::Transport {
|
||||
message: error.to_string(),
|
||||
@@ -248,17 +243,17 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
.to_string(),
|
||||
}
|
||||
})?;
|
||||
if !endpoint_suffix.starts_with('/') {
|
||||
let expected_path = format!(
|
||||
"/api/runtime/v1/workspaces/{}/resources/fetch",
|
||||
client.workspace_id()
|
||||
);
|
||||
if endpoint_suffix != expected_path {
|
||||
return Err(BackendResourceError::Unauthorized {
|
||||
message: "Workspace resource endpoint does not match its request client"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if let Some(token) = self.bearer_token.as_deref() {
|
||||
let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.map_err(|error| BackendResourceError::Transport {
|
||||
@@ -266,41 +261,57 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
})?;
|
||||
headers.insert(reqwest::header::AUTHORIZATION, value);
|
||||
}
|
||||
let response = client
|
||||
.execute(RuntimeWorkspaceRequest {
|
||||
method: reqwest::Method::POST,
|
||||
path_and_query: endpoint_suffix.to_string(),
|
||||
body,
|
||||
let request: server_api::RuntimeResourceFetchRequest =
|
||||
serde_json::from_value(serde_json::to_value(&request).map_err(|error| {
|
||||
BackendResourceError::InvalidResponse {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?)
|
||||
.map_err(|error| BackendResourceError::InvalidResponse {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let generated = client
|
||||
.server_api_client(
|
||||
BACKEND_RESOURCE_FETCH_PERMISSION,
|
||||
None,
|
||||
headers,
|
||||
permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(),
|
||||
worker_id: None,
|
||||
timeout: Some(self.request_timeout),
|
||||
max_response_bytes: 8 * 1024 * 1024,
|
||||
})
|
||||
self.request_timeout,
|
||||
8 * 1024 * 1024,
|
||||
)
|
||||
.map_err(|error| BackendResourceError::Transport {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
match generated
|
||||
.runtime_resource_fetch(client.workspace_id().to_string(), request)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
BackendResourceError::Timeout
|
||||
} else {
|
||||
BackendResourceError::Transport {
|
||||
{
|
||||
Ok(response) => {
|
||||
serde_json::from_value(serde_json::to_value(&response).map_err(|error| {
|
||||
BackendResourceError::InvalidResponse {
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if response.status.is_success() {
|
||||
serde_json::from_slice::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
|
||||
BackendResourceError::InvalidResponse {
|
||||
message: err.to_string(),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let status = response.status;
|
||||
match serde_json::from_slice::<BackendResourceError>(&response.body) {
|
||||
Ok(error) => Err(error),
|
||||
Err(err) => Err(BackendResourceError::Transport {
|
||||
message: format!("backend resource fetch failed with HTTP {status}: {err}"),
|
||||
}),
|
||||
})?)
|
||||
.map_err(|error| BackendResourceError::InvalidResponse {
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
Err(server_api::client_support::ClientError::Public { error, .. }) => {
|
||||
serde_json::from_value(serde_json::to_value(error).map_err(|error| {
|
||||
BackendResourceError::InvalidResponse {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?)
|
||||
.map_err(|error| BackendResourceError::InvalidResponse {
|
||||
message: error.to_string(),
|
||||
})
|
||||
.and_then(Err)
|
||||
}
|
||||
Err(server_api::client_support::ClientError::Failure(
|
||||
server_api::client_support::ClientFailure::Timeout,
|
||||
)) => Err(BackendResourceError::Timeout),
|
||||
Err(error) => Err(BackendResourceError::Transport {
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,16 +448,15 @@ mod tests {
|
||||
let base_url = format!("http://{address}");
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let handle = handle_for(b"archive-bytes");
|
||||
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
|
||||
.with_request_timeout(std::time::Duration::from_millis(25))
|
||||
.with_workspace_request_client(
|
||||
RuntimeWorkspaceRequestClient::new(
|
||||
"workspace-test",
|
||||
base_url.clone(),
|
||||
"runtime-test",
|
||||
)
|
||||
let client = HttpBackendResourceClient::new(
|
||||
format!("{base_url}/api/runtime/v1/workspaces/workspace-test/resources/fetch"),
|
||||
None,
|
||||
)
|
||||
.with_request_timeout(std::time::Duration::from_millis(25))
|
||||
.with_workspace_request_client(
|
||||
RuntimeWorkspaceRequestClient::new("workspace-test", base_url.clone(), "runtime-test")
|
||||
.with_runtime_request_source(&identity, base_url),
|
||||
);
|
||||
);
|
||||
|
||||
let error = client
|
||||
.fetch_resource(BackendResourceFetchRequest {
|
||||
|
||||
@@ -2217,8 +2217,13 @@ impl Runtime {
|
||||
let _ = worker.apply_worker_state(snapshot);
|
||||
}
|
||||
worker.restore_intent = restore_intent_for_status(worker.status);
|
||||
let claims = worker
|
||||
.request
|
||||
.as_ref()
|
||||
.map(|request| request.workdir_attachments.as_slice())
|
||||
.unwrap_or_default();
|
||||
worker.logical_workdir_attachments =
|
||||
logical_workdir_attachments_from_statuses(&workdir_attachments);
|
||||
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
|
||||
worker.workdir_attachments = workdir_attachments;
|
||||
worker.detail()
|
||||
};
|
||||
@@ -2869,8 +2874,13 @@ impl Runtime {
|
||||
candidate.execution_bound = true;
|
||||
candidate.status = WorkerStatus::Idle;
|
||||
candidate.restore_intent = WorkerRestoreIntent::Explicit;
|
||||
let claims = candidate
|
||||
.request
|
||||
.as_ref()
|
||||
.map(|request| request.workdir_attachments.as_slice())
|
||||
.unwrap_or_default();
|
||||
candidate.logical_workdir_attachments =
|
||||
logical_workdir_attachments_from_statuses(&workdir_attachments);
|
||||
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
|
||||
candidate.workdir_attachments = workdir_attachments;
|
||||
state.workers.insert(worker_ref.worker_id, candidate);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
@@ -3215,7 +3225,11 @@ impl RuntimeState {
|
||||
}
|
||||
};
|
||||
let logical_workdir_attachments = if worker.logical_workdir_attachments.is_empty() {
|
||||
logical_workdir_attachments_from_statuses(&worker.workdir_attachments)
|
||||
let claims = request
|
||||
.as_ref()
|
||||
.map(|request| request.workdir_attachments.as_slice())
|
||||
.unwrap_or_default();
|
||||
logical_workdir_attachments_from_statuses(&worker.workdir_attachments, claims)
|
||||
} else {
|
||||
worker.logical_workdir_attachments
|
||||
};
|
||||
@@ -4285,17 +4299,29 @@ fn requested_workdir_ids(request: &CreateWorkerRequest) -> Vec<&str> {
|
||||
|
||||
fn logical_workdir_attachments_from_statuses(
|
||||
attachments: &[crate::catalog::WorkingDirectoryAttachmentStatus],
|
||||
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
|
||||
) -> Vec<LogicalWorkdirAttachment> {
|
||||
attachments
|
||||
.iter()
|
||||
.map(|attachment| LogicalWorkdirAttachment {
|
||||
alias: attachment.alias.clone(),
|
||||
working_directory_id: attachment
|
||||
.working_directory
|
||||
.summary
|
||||
.working_directory_id
|
||||
.clone(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
.map(|attachment| {
|
||||
let working_directory_id = &attachment.working_directory.summary.working_directory_id;
|
||||
let capabilities = match claims.iter().find(|claim| claim.alias == attachment.alias) {
|
||||
Some(claim) if claim.working_directory_id == *working_directory_id => {
|
||||
claim.capabilities
|
||||
}
|
||||
// An alias/id mismatch is invalid at the operation boundary. If
|
||||
// corrupted persisted state reaches this compatibility path,
|
||||
// fail closed rather than silently restoring write authority.
|
||||
Some(_) => workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
// Runtime-materialized attachment requests predate capability
|
||||
// claims and retain their existing full local-session behavior.
|
||||
None => workdir::WorkdirSessionCapabilities::ALL,
|
||||
};
|
||||
LogicalWorkdirAttachment {
|
||||
alias: attachment.alias.clone(),
|
||||
working_directory_id: working_directory_id.clone(),
|
||||
capabilities,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -6586,6 +6612,7 @@ mod tests {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
||||
working_directory_id: "workdir-idempotent".to_string(),
|
||||
relative_cwd: None,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
}];
|
||||
|
||||
let first = runtime.create_worker(request.clone()).unwrap();
|
||||
@@ -6976,7 +7003,13 @@ mod tests {
|
||||
.unwrap(),
|
||||
}],
|
||||
});
|
||||
let request = task_request("uncertain create cleanup");
|
||||
let mut request = task_request("uncertain create cleanup");
|
||||
request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
|
||||
working_directory_id: "wd-uncertain".to_string(),
|
||||
relative_cwd: None,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
}];
|
||||
let worker_ref = WorkerRef::new(request.worker_id);
|
||||
|
||||
let error = runtime.create_worker(request).unwrap_err();
|
||||
@@ -6988,6 +7021,16 @@ mod tests {
|
||||
let retained = runtime.worker_detail(&worker_ref).unwrap();
|
||||
assert_eq!(retained.status, WorkerStatus::Idle);
|
||||
assert!(!retained.execution_metadata_available);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.lock()
|
||||
.unwrap()
|
||||
.worker(&worker_ref)
|
||||
.unwrap()
|
||||
.logical_workdir_attachments[0]
|
||||
.capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert!(matches!(
|
||||
runtime.delete_worker(&worker_ref),
|
||||
Err(RuntimeError::InvalidRequest(_))
|
||||
|
||||
@@ -755,11 +755,25 @@ async fn fetch_workspace_config_http<T>(
|
||||
Err("Workspace Config fetch requires the worker-runtime http-server feature".to_string())
|
||||
}
|
||||
|
||||
fn initial_workdir_session_capabilities(
|
||||
alias: &WorkdirAttachmentAlias,
|
||||
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
|
||||
) -> WorkdirSessionCapabilities {
|
||||
claims
|
||||
.iter()
|
||||
.find(|claim| &claim.alias == alias)
|
||||
.map(|claim| claim.capabilities)
|
||||
// New Runtime materialization requests have no claim and retain their
|
||||
// established full local-session behavior.
|
||||
.unwrap_or(WorkdirSessionCapabilities::ALL)
|
||||
}
|
||||
|
||||
fn runtime_local_workdir_session(
|
||||
workdir_id: &str,
|
||||
root: &Path,
|
||||
cwd: &Path,
|
||||
scope: manifest::SharedScope,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
command_environment: std::collections::BTreeMap<String, String>,
|
||||
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
|
||||
) -> WorkdirSessionHandle {
|
||||
@@ -768,7 +782,7 @@ fn runtime_local_workdir_session(
|
||||
root.to_path_buf(),
|
||||
cwd.to_path_buf(),
|
||||
scope,
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
capabilities,
|
||||
command_environment,
|
||||
resources,
|
||||
))
|
||||
@@ -776,6 +790,7 @@ fn runtime_local_workdir_session(
|
||||
|
||||
fn runtime_local_workdir_router(
|
||||
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
||||
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
|
||||
scope: manifest::SharedScope,
|
||||
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
@@ -788,6 +803,10 @@ fn runtime_local_workdir_router(
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
scope.clone(),
|
||||
capabilities
|
||||
.get(alias)
|
||||
.copied()
|
||||
.unwrap_or(WorkdirSessionCapabilities::READ_ONLY),
|
||||
binding.command_environment(),
|
||||
binding.session_resources(),
|
||||
),
|
||||
@@ -800,10 +819,25 @@ fn runtime_local_workdir_router(
|
||||
fn restored_workdir_router(
|
||||
local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
||||
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
|
||||
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
|
||||
scope: manifest::SharedScope,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
||||
let router = runtime_local_workdir_router(local_attachments, scope)?;
|
||||
let mut local_capabilities = local_attachments
|
||||
.keys()
|
||||
.map(|alias| {
|
||||
(
|
||||
alias.clone(),
|
||||
initial_workdir_session_capabilities(alias, claims),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for attachment in logical_attachments {
|
||||
if local_attachments.contains_key(&attachment.alias) {
|
||||
local_capabilities.insert(attachment.alias.clone(), attachment.capabilities);
|
||||
}
|
||||
}
|
||||
let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?;
|
||||
for attachment in logical_attachments {
|
||||
if let Some(local) = local_attachments.get(&attachment.alias) {
|
||||
if local.working_directory.id != attachment.working_directory_id {
|
||||
@@ -1068,8 +1102,22 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
})?;
|
||||
let worker = prepared.worker_mut();
|
||||
validate_worker_memory_settings(worker.manifest(), &request.request)?;
|
||||
let workdir_capabilities = request
|
||||
.workdir_attachments
|
||||
.keys()
|
||||
.map(|alias| {
|
||||
(
|
||||
alias.clone(),
|
||||
initial_workdir_session_capabilities(
|
||||
alias,
|
||||
&request.request.workdir_attachments,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
worker.bind_workdir_sessions(runtime_local_workdir_router(
|
||||
&request.workdir_attachments,
|
||||
&workdir_capabilities,
|
||||
worker.scope().clone(),
|
||||
)?);
|
||||
if let (Some(runtime_id), Some(workspace_id)) =
|
||||
@@ -1300,6 +1348,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
let workdir_sessions = restored_workdir_router(
|
||||
&request.workdir_attachments,
|
||||
&request.logical_workdir_attachments,
|
||||
&request.request.workdir_attachments,
|
||||
worker.scope().clone(),
|
||||
worker.workspace_client_handle(),
|
||||
)?;
|
||||
@@ -2177,6 +2226,7 @@ where
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
manifest::SharedScope::new(scope),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
binding.command_environment(),
|
||||
binding.session_resources(),
|
||||
))
|
||||
@@ -2979,6 +3029,7 @@ mod tests {
|
||||
working_directory_id: "remote-workdir".to_string(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
}],
|
||||
&[],
|
||||
scope,
|
||||
client,
|
||||
)
|
||||
@@ -2992,6 +3043,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_router_preserves_read_only_capabilities_for_local_attachment() {
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let repo = create_clean_repo();
|
||||
let materializer = RuntimeGitMaterializer::new(runtime_base.path());
|
||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::now_v7());
|
||||
let binding = materializer
|
||||
.materialize(&worker_ref, &working_directory_request(repo.path()))
|
||||
.unwrap();
|
||||
let alias = WorkdirAttachmentAlias::new("docs").unwrap();
|
||||
let workdir_id = binding.working_directory.id.clone();
|
||||
let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap());
|
||||
let attachments = BTreeMap::from([(alias.clone(), binding)]);
|
||||
let logical = [LogicalWorkdirAttachment {
|
||||
alias: alias.clone(),
|
||||
working_directory_id: workdir_id,
|
||||
capabilities: WorkdirSessionCapabilities::READ_ONLY,
|
||||
}];
|
||||
let client = WorkerWorkspaceContext::local_filesystem(Some(
|
||||
WorkspaceId::new("workspace-a").unwrap(),
|
||||
))
|
||||
.client_handle();
|
||||
|
||||
let router = restored_workdir_router(&attachments, &logical, &[], scope, client).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
router.session(&alias).unwrap().capabilities(),
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_factory_routes_workspace_requests_by_workspace_id() {
|
||||
let profiles = tempfile::tempdir().unwrap();
|
||||
@@ -3949,13 +4031,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_opens_a_fresh_session_for_the_same_workdir_identity() {
|
||||
fn initial_claim_capabilities_override_full_materialization_default() {
|
||||
let checkout = WorkdirAttachmentAlias::new("checkout").unwrap();
|
||||
let docs = WorkdirAttachmentAlias::new("docs").unwrap();
|
||||
let claims = [WorkingDirectoryAttachmentClaim {
|
||||
alias: docs.clone(),
|
||||
working_directory_id: "workdir-docs".to_string(),
|
||||
relative_cwd: None,
|
||||
capabilities: WorkdirSessionCapabilities::READ_ONLY,
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
initial_workdir_session_capabilities(&docs, &claims),
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
initial_workdir_session_capabilities(&checkout, &claims),
|
||||
WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_opens_a_fresh_session_with_the_same_read_only_capabilities() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let spawned = runtime_local_workdir_session(
|
||||
"working-directory-42",
|
||||
root.path(),
|
||||
root.path(),
|
||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
Default::default(),
|
||||
Vec::new(),
|
||||
);
|
||||
@@ -3964,12 +4068,21 @@ mod tests {
|
||||
root.path(),
|
||||
root.path(),
|
||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
Default::default(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
|
||||
assert_eq!(restored.workdir().id().as_str(), "working-directory-42");
|
||||
assert_eq!(
|
||||
spawned.capabilities(),
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
restored.capabilities(),
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert!(!Arc::ptr_eq(&spawned, &restored));
|
||||
}
|
||||
|
||||
@@ -4961,6 +5074,7 @@ mod tests {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
||||
working_directory_id: workdir_id.clone(),
|
||||
relative_cwd: None,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
}];
|
||||
|
||||
let error = runtime.create_worker(request).unwrap_err();
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestC
|
||||
use worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
||||
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -254,45 +255,53 @@ fn execute_remote_worker_remove_http(
|
||||
fn execute_remote_worker_remove_http_blocking(
|
||||
request: RemoteWorkerRemoveHttpRequest,
|
||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||
let path = format!(
|
||||
"/api/w/{}/workers/remove",
|
||||
request.request_client.workspace_id()
|
||||
);
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"target_runtime_id": request.target_runtime_id,
|
||||
"target_worker_id": request.target_worker_id,
|
||||
"reason": request.reason,
|
||||
}))
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||
reqwest::header::HeaderValue::from_str(&request.token)
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
let response = request
|
||||
let client = request
|
||||
.request_client
|
||||
.execute_blocking(RuntimeWorkspaceRequest {
|
||||
method: reqwest::Method::POST,
|
||||
path_and_query: path,
|
||||
body,
|
||||
.server_api_client(
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
Some(request.source_worker_id),
|
||||
headers,
|
||||
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||
worker_id: Some(request.source_worker_id),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
max_response_bytes: 8 * 1024 * 1024,
|
||||
})
|
||||
Duration::from_secs(5),
|
||||
8 * 1024 * 1024,
|
||||
)
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let body = String::from_utf8(response.body)
|
||||
let workspace_id = request.request_client.workspace_id().to_string();
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
Ok(WorkspaceResponse {
|
||||
status: response.status.as_u16(),
|
||||
body,
|
||||
})
|
||||
let result = runtime.block_on(client.workspace_worker_remove(
|
||||
workspace_id,
|
||||
server_api::WorkerRemoveRequest {
|
||||
target_runtime_id: request.target_runtime_id,
|
||||
target_worker_id: request.target_worker_id,
|
||||
reason: request.reason,
|
||||
},
|
||||
));
|
||||
match result {
|
||||
Ok(response) => Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&response)
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
|
||||
}),
|
||||
Err(server_api::client_support::ClientError::Public { status, error }) => {
|
||||
Ok(WorkspaceResponse {
|
||||
status: status.as_u16(),
|
||||
body: serde_json::to_string(&error).map_err(|error| {
|
||||
RuntimeWorkerMutationForwardError::Transport(error.to_string())
|
||||
})?,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(RuntimeWorkerMutationForwardError::Transport(
|
||||
error.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -432,6 +441,26 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn generated_workspace_response<T: serde::Serialize>(
|
||||
response: Result<T, server_api::client_support::ClientError<server_api::RepositoryApiError>>,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
match response {
|
||||
Ok(response) => Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&response)
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
|
||||
}),
|
||||
Err(server_api::client_support::ClientError::Public { status, error }) => {
|
||||
Ok(WorkspaceResponse {
|
||||
status: status.as_u16(),
|
||||
body: serde_json::to_string(&error)
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(WorkspaceClientError::Request(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some(self.request_client.workspace_id())
|
||||
@@ -452,36 +481,143 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION)
|
||||
}
|
||||
|
||||
fn execute_server_operation(
|
||||
&self,
|
||||
operation: WorkspaceServerOperation,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let request_client = self.request_client.clone();
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let worker_id = self.worker_id.clone();
|
||||
let timeout = self.request_timeout.unwrap_or(Duration::from_secs(5));
|
||||
std::thread::Builder::new()
|
||||
.name("yoi-workspace-server-api".to_string())
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
let client = request_client
|
||||
.server_api_client(
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
Some(worker_id),
|
||||
reqwest::header::HeaderMap::new(),
|
||||
timeout,
|
||||
8 * 1024 * 1024,
|
||||
)
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
runtime.block_on(async move {
|
||||
match operation {
|
||||
WorkspaceServerOperation::WorkerControlList => {
|
||||
generated_workspace_response(
|
||||
client.worker_control_list(workspace_id).await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::WorkerControlSpawn(request) => {
|
||||
generated_workspace_response(
|
||||
client.worker_control_spawn(workspace_id, request).await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::WorkerControlInput {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
request,
|
||||
} => generated_workspace_response(
|
||||
client
|
||||
.worker_control_input(workspace_id, runtime_id, worker_id, request)
|
||||
.await,
|
||||
),
|
||||
WorkspaceServerOperation::WorkerControlCancel {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
request,
|
||||
} => generated_workspace_response(
|
||||
client
|
||||
.worker_control_cancel(workspace_id, runtime_id, worker_id, request)
|
||||
.await,
|
||||
),
|
||||
WorkspaceServerOperation::WorkerControlStop {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
request,
|
||||
} => generated_workspace_response(
|
||||
client
|
||||
.worker_control_stop(workspace_id, runtime_id, worker_id, request)
|
||||
.await,
|
||||
),
|
||||
WorkspaceServerOperation::WorkerControlRestore {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => generated_workspace_response(
|
||||
client
|
||||
.worker_control_restore(workspace_id, runtime_id, worker_id)
|
||||
.await,
|
||||
),
|
||||
WorkspaceServerOperation::WorkerObservationSessions => {
|
||||
generated_workspace_response(
|
||||
client.worker_observation_sessions(workspace_id).await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::WorkerObservationCapture(request) => {
|
||||
generated_workspace_response(
|
||||
client
|
||||
.worker_observation_capture(workspace_id, request)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?
|
||||
.join()
|
||||
.map_err(|_| {
|
||||
WorkspaceClientError::Request(
|
||||
"Workspace Server API request thread panicked".to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: worker::WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<server_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
let mut path = format!(
|
||||
"/api/w/{}/worker-discovery/workers?limit={}",
|
||||
self.workspace_id, request.limit
|
||||
);
|
||||
if let Some(cursor) = request.cursor.as_deref() {
|
||||
path.push_str("&cursor=");
|
||||
path.push_str(&percent_encode_query(cursor));
|
||||
}
|
||||
if let Some(query) = request.query.as_deref() {
|
||||
path.push_str("&query=");
|
||||
path.push_str(&percent_encode_query(query));
|
||||
}
|
||||
let response = self.execute_with_permission(
|
||||
WorkspaceRequest::get(path),
|
||||
WORKSPACE_WORKER_DISCOVERY_PERMISSION,
|
||||
)?;
|
||||
if !(200..300).contains(&response.status) {
|
||||
return Err(WorkspaceClientError::Request(format!(
|
||||
"Workspace Worker discovery failed with HTTP {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
WorkspaceClientError::Request(format!(
|
||||
"invalid Workspace Worker discovery response: {error}"
|
||||
))
|
||||
let request_client = self.request_client.clone();
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let worker_id = self.worker_id.clone();
|
||||
let timeout = self.request_timeout.unwrap_or(Duration::from_secs(5));
|
||||
let query = server_api::WorkspaceWorkerDiscoveryQuery {
|
||||
cursor: request.cursor,
|
||||
limit: Some(request.limit),
|
||||
query: request.query,
|
||||
};
|
||||
let result = std::thread::Builder::new()
|
||||
.name("yoi-worker-discovery-http".to_string())
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let client = request_client
|
||||
.server_api_client(
|
||||
WORKSPACE_WORKER_DISCOVERY_PERMISSION,
|
||||
Some(worker_id),
|
||||
reqwest::header::HeaderMap::new(),
|
||||
timeout,
|
||||
8 * 1024 * 1024,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
runtime
|
||||
.block_on(client.workspace_worker_discovery(workspace_id, query))
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?
|
||||
.join()
|
||||
.map_err(|_| {
|
||||
WorkspaceClientError::Request(
|
||||
"Workspace Worker discovery HTTP thread panicked".to_string(),
|
||||
)
|
||||
})?;
|
||||
result.map_err(|error| {
|
||||
WorkspaceClientError::Request(format!("Workspace Worker discovery failed: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -532,18 +668,29 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
let projection: WorkspacePromptProjection =
|
||||
let wire_projection: server_api::WorkspacePromptProjection =
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
WorkspaceClientError::Request(format!(
|
||||
"invalid active Workspace Prompt projection response: {error}"
|
||||
))
|
||||
})?;
|
||||
if projection.workspace_id != self.workspace_id {
|
||||
if wire_projection.workspace_id != self.workspace_id {
|
||||
return Err(WorkspaceClientError::Request(format!(
|
||||
"active Workspace Prompt projection scope mismatch: expected {}, got {}",
|
||||
self.workspace_id, projection.workspace_id
|
||||
self.workspace_id, wire_projection.workspace_id
|
||||
)));
|
||||
}
|
||||
let projection: WorkspacePromptProjection =
|
||||
serde_json::from_value(serde_json::to_value(wire_projection).map_err(|error| {
|
||||
WorkspaceClientError::Request(format!(
|
||||
"project active Workspace Prompt projection response: {error}"
|
||||
))
|
||||
})?)
|
||||
.map_err(|error| {
|
||||
WorkspaceClientError::Request(format!(
|
||||
"convert active Workspace Prompt projection response: {error}"
|
||||
))
|
||||
})?;
|
||||
let resolution = cache
|
||||
.observe(projection)
|
||||
.map_err(WorkspaceClientError::Request)?;
|
||||
@@ -576,19 +723,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_encode_query(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(encoded, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeWorkerMutationForwardError {
|
||||
#[error(transparent)]
|
||||
@@ -936,9 +1070,12 @@ mod tests {
|
||||
server.join().unwrap();
|
||||
|
||||
let request = received.lock().unwrap().clone();
|
||||
assert!(request.contains(
|
||||
"GET /api/w/workspace-a/worker-discovery/workers?limit=1&cursor=v1%3A0&query=coder%20two "
|
||||
));
|
||||
assert!(
|
||||
request.contains(
|
||||
"GET /api/w/workspace-a/worker-discovery/workers?cursor=v1%3A0&limit=1&query=coder+two "
|
||||
),
|
||||
"unexpected discovery request: {request}"
|
||||
);
|
||||
let token = request
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
@@ -952,7 +1089,7 @@ mod tests {
|
||||
assert_eq!(claims.permission, WORKSPACE_WORKER_DISCOVERY_PERMISSION);
|
||||
assert_eq!(
|
||||
claims.path,
|
||||
"/api/w/workspace-a/worker-discovery/workers?limit=1&cursor=v1%3A0&query=coder%20two"
|
||||
"/api/w/workspace-a/worker-discovery/workers?cursor=v1%3A0&limit=1&query=coder+two"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1011,9 +1148,15 @@ mod tests {
|
||||
let count = stream.read(&mut bytes).unwrap();
|
||||
*received_for_server.lock().unwrap() =
|
||||
String::from_utf8_lossy(&bytes[..count]).into_owned();
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
let body =
|
||||
r#"{"removed":true,"runtime_id":"runtime-target","worker_id":"worker-target"}"#;
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
@@ -1032,7 +1175,7 @@ mod tests {
|
||||
let response = forwarder
|
||||
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 204);
|
||||
assert_eq!(response.status, 200);
|
||||
server.join().unwrap();
|
||||
|
||||
let request = received.lock().unwrap().clone();
|
||||
|
||||
@@ -62,9 +62,57 @@ impl RuntimeWorkspaceRequestError {
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_timeout(&self) -> bool {
|
||||
matches!(self, Self::Transport { timeout: true, .. })
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RuntimeWorkspaceRequestAuthorizer {
|
||||
workspace_id: String,
|
||||
runtime_id: String,
|
||||
request_source: Option<(RuntimeRequestSourceSigner, String)>,
|
||||
permission: String,
|
||||
worker_id: Option<String>,
|
||||
extra_headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl server_api::client_support::RequestAuthorizer for RuntimeWorkspaceRequestAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
request: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> Result<HeaderMap, server_api::client_support::AuthorizationError> {
|
||||
let mut headers = self.extra_headers.clone();
|
||||
headers.insert(
|
||||
RUNTIME_ID_HEADER,
|
||||
reqwest::header::HeaderValue::from_str(&self.runtime_id)
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
if let Some(worker_id) = self.worker_id.as_deref() {
|
||||
headers.insert(
|
||||
WORKER_ID_HEADER,
|
||||
reqwest::header::HeaderValue::from_str(worker_id)
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
}
|
||||
if let Some((signer, audience)) = self.request_source.as_ref() {
|
||||
let proof = signer
|
||||
.issue(
|
||||
audience,
|
||||
&self.workspace_id,
|
||||
self.worker_id.as_deref(),
|
||||
&self.permission,
|
||||
request.method.as_str(),
|
||||
request.path_and_query,
|
||||
request.body,
|
||||
unix_now_seconds(),
|
||||
DEFAULT_REQUEST_PROOF_TTL_SECONDS,
|
||||
)
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?;
|
||||
headers.insert(
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
reqwest::header::HeaderValue::from_str(&proof)
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +165,34 @@ impl RuntimeWorkspaceRequestClient {
|
||||
&& self.base_url.trim_end_matches('/') == base_url.trim_end_matches('/')
|
||||
}
|
||||
|
||||
pub(crate) fn server_api_client(
|
||||
&self,
|
||||
permission: impl Into<String>,
|
||||
worker_id: Option<String>,
|
||||
extra_headers: HeaderMap,
|
||||
timeout: Duration,
|
||||
response_body_limit: usize,
|
||||
) -> Result<
|
||||
server_api::ServerApiClient<RuntimeWorkspaceRequestAuthorizer>,
|
||||
RuntimeWorkspaceRequestError,
|
||||
> {
|
||||
let authorizer = RuntimeWorkspaceRequestAuthorizer {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
request_source: self.request_source.clone(),
|
||||
permission: permission.into(),
|
||||
worker_id,
|
||||
extra_headers,
|
||||
};
|
||||
server_api::ServerApiClient::builder(&self.base_url)
|
||||
.map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))?
|
||||
.authorizer(authorizer)
|
||||
.request_timeout(timeout)
|
||||
.response_body_limit(response_body_limit)
|
||||
.build()
|
||||
.map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
&self,
|
||||
request: RuntimeWorkspaceRequest,
|
||||
|
||||
Reference in New Issue
Block a user