worker: add guarded WorkerRemove lifecycle
This commit is contained in:
@@ -21,6 +21,9 @@ use crate::interaction::{WorkerInput, WorkerInteractionAck};
|
||||
use crate::management::{RuntimeSummary, WorkerDeleteResult};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::observation::WorkerObservationCursor;
|
||||
use crate::retention::{
|
||||
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
|
||||
};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::runtime::RuntimeSubscriptionRecvError;
|
||||
use crate::{Runtime, RuntimeWorkspaceScope};
|
||||
@@ -217,6 +220,14 @@ fn runtime_http_router_with_optional_auth(
|
||||
"/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("/v1/workers/{worker_id}/restore", post(restore_worker))
|
||||
.route(
|
||||
@@ -1220,6 +1231,61 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
|
||||
}
|
||||
}
|
||||
|
||||
async fn worker_retention_inventory(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
Path(worker_id): Path<String>,
|
||||
) -> RestResult<WorkerRetentionInventory> {
|
||||
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_scope_required",
|
||||
"Worker retention inventory requires workspace-scoped authorization",
|
||||
)
|
||||
})?;
|
||||
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||
state
|
||||
.runtime
|
||||
.worker_retention_inventory(&scope.workspace_id, &worker_ref)
|
||||
.map(Json)
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
}
|
||||
|
||||
async fn execute_worker_retention(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
Path(worker_id): Path<String>,
|
||||
body: Result<Json<WorkerRetentionExecutionRequest>, JsonRejection>,
|
||||
) -> RestResult<WorkerRetentionExecutionResult> {
|
||||
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||
if request.worker_id.to_string() != worker_id {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"worker_id_mismatch",
|
||||
"Retention request worker_id does not match the route",
|
||||
));
|
||||
}
|
||||
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_scope_required",
|
||||
"Worker retention execution requires workspace-scoped authorization",
|
||||
)
|
||||
})?;
|
||||
if request.workspace_id != scope.workspace_id {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"worker_not_found",
|
||||
"Worker was not found in the authenticated Workspace",
|
||||
));
|
||||
}
|
||||
state
|
||||
.runtime
|
||||
.execute_worker_retention(&request)
|
||||
.map(Json)
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
}
|
||||
|
||||
async fn send_worker_input(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1473,6 +1539,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
if path.ends_with("/completions") {
|
||||
return Some("workers:read");
|
||||
}
|
||||
if path.contains("/retention/") {
|
||||
return Some("workers:delete");
|
||||
}
|
||||
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
||||
return Some("workers:delete");
|
||||
}
|
||||
@@ -2066,6 +2135,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_routes_require_worker_delete_permission() {
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::GET, "/v1/workers/worker-1/retention/inventory",),
|
||||
Some("workers:delete")
|
||||
);
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, "/v1/workers/worker-1/retention/execute",),
|
||||
Some("workers:delete")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_routes_require_dedicated_operation_permission() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -117,6 +117,7 @@ pub struct WorkerRetentionExecutionRequest {
|
||||
pub workspace_id: String,
|
||||
pub source_runtime_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub expected_worker_revision: String,
|
||||
pub expected_run_generation: u64,
|
||||
pub source_created_at: String,
|
||||
pub removed_at: String,
|
||||
@@ -155,6 +156,7 @@ pub struct WorkerSessionArchiveManifest {
|
||||
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,
|
||||
@@ -521,6 +523,7 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
||||
let mut result = WorkerRetentionExecutionResult {
|
||||
operation_id: request.operation_id.clone(),
|
||||
input_fingerprint: request.input_fingerprint.clone(),
|
||||
expected_worker_revision: request.expected_worker_revision.clone(),
|
||||
worker_id: request.worker_id,
|
||||
session_disposition: request.session_disposition,
|
||||
diagnostics_disposition: request.diagnostics_disposition,
|
||||
@@ -1291,6 +1294,7 @@ mod tests {
|
||||
WorkerRetentionExecutionRequest {
|
||||
operation_id: "operation-a".to_string(),
|
||||
input_fingerprint: "fingerprint-a".to_string(),
|
||||
expected_worker_revision: "revision-a".to_string(),
|
||||
archive_id: (disposition == SessionDisposition::Archive)
|
||||
.then(|| "archive-a".to_string()),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
|
||||
@@ -124,6 +124,8 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
|
||||
proof: InProcessWorkerMutationProof,
|
||||
target_runtime_id: &str,
|
||||
target_worker_id: &str,
|
||||
expected_worker_revision: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError>;
|
||||
}
|
||||
|
||||
@@ -186,6 +188,8 @@ impl RuntimeWorkerMutationForwarder {
|
||||
&self,
|
||||
target_runtime_id: &str,
|
||||
target_worker_id: &str,
|
||||
expected_worker_revision: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||
let proof = self.authority.issue_worker_remove(
|
||||
&self.scope,
|
||||
@@ -205,6 +209,8 @@ impl RuntimeWorkerMutationForwarder {
|
||||
let body = serde_json::json!({
|
||||
"target_runtime_id": target_runtime_id,
|
||||
"target_worker_id": target_worker_id,
|
||||
"expected_worker_revision": expected_worker_revision,
|
||||
"reason": reason,
|
||||
});
|
||||
let response = client
|
||||
.post(url)
|
||||
@@ -223,7 +229,13 @@ impl RuntimeWorkerMutationForwarder {
|
||||
(
|
||||
RuntimeWorkerMutationTransport::Embedded { dispatcher },
|
||||
RuntimeOwnedWorkerMutationProof::InProcess(claims),
|
||||
) => dispatcher.execute_worker_remove(claims, target_runtime_id, target_worker_id),
|
||||
) => dispatcher.execute_worker_remove(
|
||||
claims,
|
||||
target_runtime_id,
|
||||
target_worker_id,
|
||||
expected_worker_revision,
|
||||
reason,
|
||||
),
|
||||
_ => Err(RuntimeWorkerMutationForwardError::AuthorityTransportMismatch),
|
||||
}
|
||||
}
|
||||
@@ -316,6 +328,8 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
&self,
|
||||
target_runtime_id: &str,
|
||||
target_worker_id: &str,
|
||||
expected_worker_revision: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.worker_remove
|
||||
.as_ref()
|
||||
@@ -324,7 +338,12 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
|
||||
)
|
||||
})?
|
||||
.execute_worker_remove(target_runtime_id, target_worker_id)
|
||||
.execute_worker_remove(
|
||||
target_runtime_id,
|
||||
target_worker_id,
|
||||
expected_worker_revision,
|
||||
reason,
|
||||
)
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -500,16 +519,22 @@ mod tests {
|
||||
format!("http://{address}"),
|
||||
);
|
||||
let response = forwarder
|
||||
.execute_worker_remove("runtime-target", "worker-target")
|
||||
.execute_worker_remove(
|
||||
"runtime-target",
|
||||
"worker-target",
|
||||
"revision-7",
|
||||
"retire obsolete Worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 204);
|
||||
server.join().unwrap();
|
||||
|
||||
let request = received.lock().unwrap().clone();
|
||||
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
|
||||
assert!(request.contains(
|
||||
r#"{"target_runtime_id":"runtime-target","target_worker_id":"worker-target"}"#
|
||||
));
|
||||
assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
|
||||
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
|
||||
assert!(request.contains("\"expected_worker_revision\":\"revision-7\""));
|
||||
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
|
||||
let token = request
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
@@ -541,7 +566,7 @@ mod tests {
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingDispatcher {
|
||||
seen: Mutex<Option<(WorkerMutationSourceClaims, String, String)>>,
|
||||
seen: Mutex<Option<(WorkerMutationSourceClaims, String, String, String, String)>>,
|
||||
}
|
||||
impl EmbeddedWorkerMutationDispatcher for RecordingDispatcher {
|
||||
fn execute_worker_remove(
|
||||
@@ -549,11 +574,15 @@ mod tests {
|
||||
proof: InProcessWorkerMutationProof,
|
||||
target_runtime_id: &str,
|
||||
target_worker_id: &str,
|
||||
expected_worker_revision: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||
*self.seen.lock().unwrap() = Some((
|
||||
proof.into_claims(),
|
||||
target_runtime_id.to_string(),
|
||||
target_worker_id.to_string(),
|
||||
expected_worker_revision.to_string(),
|
||||
reason.to_string(),
|
||||
));
|
||||
Ok(WorkspaceResponse {
|
||||
status: 202,
|
||||
@@ -571,10 +600,15 @@ mod tests {
|
||||
dispatcher.clone(),
|
||||
);
|
||||
let response = forwarder
|
||||
.execute_worker_remove("runtime-target", "worker-target")
|
||||
.execute_worker_remove(
|
||||
"runtime-target",
|
||||
"worker-target",
|
||||
"revision-7",
|
||||
"retire obsolete Worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 202);
|
||||
let (claims, target_runtime_id, target_worker_id) =
|
||||
let (claims, target_runtime_id, target_worker_id, expected_revision, reason) =
|
||||
dispatcher.seen.lock().unwrap().take().unwrap();
|
||||
assert_eq!(claims.iss, "runtime-embedded");
|
||||
assert_eq!(claims.worker_id, "worker-source");
|
||||
@@ -582,6 +616,8 @@ mod tests {
|
||||
assert_eq!(claims.target_worker_id, "worker-target");
|
||||
assert_eq!(target_runtime_id, "runtime-target");
|
||||
assert_eq!(target_worker_id, "worker-target");
|
||||
assert_eq!(expected_revision, "revision-7");
|
||||
assert_eq!(reason, "retire obsolete Worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user