fix(server): retain trust-key revoke publication after cancellation

This commit is contained in:
2026-10-12 12:12:50 +09:00
parent 6d8c49b72d
commit 1a065db0fc
2 changed files with 290 additions and 54 deletions
+68 -54
View File
@@ -9403,62 +9403,70 @@ impl server_api::ServerApi for ServerApiContractService {
"expected_binding_id must contain between 1 and 256 bytes", "expected_binding_id must contain between 1 and 256 bytes",
))); )));
} }
let now = Utc::now().to_rfc3339(); // Own the whole revoke outcome through local publication and response.
if let Err(error) = api // The accepted DB mutation outlives a cancelled HTTP waiter; its Host
.store // parent must also remove expectations and terminate subscriptions.
.revoke_workspace_runtime_binding_key( // SQL keeps its independent owner, and response reads stay on this Host
&workspace_id, // workflow rather than attempting nested admission to the same lane.
&runtime_id, run_host_workflow(api.host_executors.host()?, async move {
&request.expected_binding_id, let now = Utc::now().to_rfc3339();
&actor.account_id, if let Err(error) = api
&now, .store
) .revoke_workspace_runtime_binding_key(
.await &workspace_id,
{ &runtime_id,
let kind = match error { &request.expected_binding_id,
Error::RuntimeBindingIdConflict { .. } => { &actor.account_id,
Some(server_api::RuntimeTrustConflictKind::StaleBinding) &now,
} )
Error::RuntimeBindingFingerprintConflict { .. } => { .await
Some(server_api::RuntimeTrustConflictKind::FingerprintInUse) {
} let kind = match error {
_ => None, Error::RuntimeBindingIdConflict { .. } => {
}; Some(server_api::RuntimeTrustConflictKind::StaleBinding)
if let Some(kind) = kind { }
let current = api Error::RuntimeBindingFingerprintConflict { .. } => {
.store Some(server_api::RuntimeTrustConflictKind::FingerprintInUse)
.get_workspace_runtime_binding(&workspace_id, &runtime_id) }
.await _ => None,
.map_err(|error| map_error(ApiError::from(error)))?; };
return Err(server_api::RuntimeManagementApiError::trust_conflict( if let Some(kind) = kind {
server_api::RuntimeTrustConflictResponse { let current = api
error: kind, .store
message: match kind { .get_workspace_runtime_binding(&workspace_id, &runtime_id)
server_api::RuntimeTrustConflictKind::StaleBinding => { .await
"the Runtime trust binding changed; reload before retrying".to_string() .map_err(|error| map_error(ApiError::from(error)))?;
} return Err(server_api::RuntimeManagementApiError::trust_conflict(
server_api::RuntimeTrustConflictKind::FingerprintInUse => { server_api::RuntimeTrustConflictResponse {
"the public key is already bound to another Runtime in this Workspace".to_string() error: kind,
} message: match kind {
server_api::RuntimeTrustConflictKind::StaleBinding => {
"the Runtime trust binding changed; reload before retrying".to_string()
}
server_api::RuntimeTrustConflictKind::FingerprintInUse => {
"the public key is already bound to another Runtime in this Workspace".to_string()
}
},
current_binding_id: current.as_ref().map(|binding| binding.binding_id.clone()),
current_fingerprint: current
.as_ref()
.map(|binding| binding.public_key_fingerprint.clone()),
}, },
current_binding_id: current.as_ref().map(|binding| binding.binding_id.clone()), ));
current_fingerprint: current }
.as_ref() return Err(map_error(ApiError::from(error)));
.map(|binding| binding.public_key_fingerprint.clone()),
},
));
} }
return Err(map_error(ApiError::from(error))); api.runtime_binding_expectations
} .write()
api.runtime_binding_expectations .unwrap_or_else(std::sync::PoisonError::into_inner)
.write() .remove(&(workspace_id.clone(), runtime_id.clone()));
.unwrap_or_else(std::sync::PoisonError::into_inner) api.runtime_subscription_broker
.remove(&(workspace_id.clone(), runtime_id.clone())); .unregister_runtime(&runtime_id);
api.runtime_subscription_broker workspace_runtime_detail_on_host(&api, &workspace_id, &runtime_id)
.unregister_runtime(&runtime_id); .await
workspace_runtime_detail(&api, &workspace_id, &runtime_id) .map_err(map_error)
.await })
.map_err(map_error) .await
} }
async fn runtime_connection_test( async fn runtime_connection_test(
@@ -34715,6 +34723,12 @@ impl From<Error> for server_api::RepositoryApiError {
} }
} }
impl From<Error> for server_api::RuntimeManagementApiError {
fn from(error: Error) -> Self {
Self::from(server_api::RepositoryApiError::from(error))
}
}
impl From<Error> for ApiError { impl From<Error> for ApiError {
fn from(error: Error) -> Self { fn from(error: Error) -> Self {
let diagnostics = match &error { let diagnostics = match &error {
@@ -1370,6 +1370,228 @@ async fn runtime_connection_post_ping_sql_waits_off_tokio_while_host_control_pro
fixture.executor.drain().await.unwrap(); fixture.executor.drain().await.unwrap();
} }
#[tokio::test(flavor = "current_thread")]
async fn saturated_host_rejects_runtime_key_revoke_without_mutating_trust() {
let fixture = Fixture::new().await;
fixture.api.worker_projection.shutdown_async().await;
observed(fixture.executor.drain()).await.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let binding = install_remote_fixture(
&fixture,
"revoke-runtime",
&format!("http://{}", listener.local_addr().unwrap()),
true,
);
fixture
.api
.runtime_binding_expectations
.write()
.unwrap()
.insert(
(WORKSPACE.into(), binding.runtime_id.clone()),
binding.clone(),
);
let initial_registrations = fixture.api.runtime_subscription_broker.runtime_ids();
// Sender Drop also releases each controlled job if a route assertion panics.
let mut releases: Vec<mpsc::Sender<()>> = Vec::new();
let (started, running) = oneshot::channel();
let (release, permit) = mpsc::channel();
releases.push(release);
fixture
.host_executor
.dispatch(move || {
let _ = started.send(());
let _ = permit.recv_timeout(WATCHDOG);
})
.unwrap();
observed(running).await.unwrap();
let (release, permit) = mpsc::channel();
releases.push(release);
fixture
.host_executor
.dispatch(move || {
let _ = permit.recv_timeout(WATCHDOG);
})
.unwrap();
let body = response_json(
observed(fixture.dispatch(Fixture::request(
"DELETE",
"/api/w/db-execution/runtimes/revoke-runtime/trust-key",
serde_json::json!({"expected_binding_id": binding.binding_id}),
)))
.await
.unwrap(),
StatusCode::SERVICE_UNAVAILABLE,
)
.await;
assert!(body.to_string().contains("db_capacity_exceeded"), "{body}");
assert_eq!(
fixture
.store
.get_workspace_runtime_binding(WORKSPACE, &binding.runtime_id)
.unwrap()
.unwrap(),
binding
);
assert!(
fixture
.store
.list_workspace_runtime_binding_audit(WORKSPACE, &binding.runtime_id, 10)
.unwrap()
.is_empty()
);
assert_eq!(
fixture.api.runtime_subscription_broker.runtime_ids(),
initial_registrations
);
assert_eq!(
fixture
.api
.runtime_binding_expectations
.read()
.unwrap()
.get(&(WORKSPACE.into(), binding.runtime_id.clone())),
Some(&binding)
);
drop(releases);
observed(fixture.host_executor.drain()).await.unwrap();
observed(fixture.executor.drain()).await.unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn cancelled_runtime_key_revoke_commits_audit_and_removes_broker_registration() {
let fixture = Fixture::new().await;
// Stop unrelated projection writes before observing this mutation's SQLite
// wait. Keep the original runtime alive through actual workflow cleanup.
fixture.api.worker_projection.shutdown_async().await;
observed(fixture.executor.drain()).await.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let binding = install_remote_fixture(
&fixture,
"revoke-runtime",
&format!("http://{}", listener.local_addr().unwrap()),
true,
);
fixture
.api
.runtime_binding_expectations
.write()
.unwrap()
.insert(
(WORKSPACE.into(), binding.runtime_id.clone()),
binding.clone(),
);
let mut config = remote_runtime_config_from_binding(&binding).unwrap();
config.workspace_authorization = Some(WorkspaceRuntimeAuthorization::new(
fixture.store.clone(),
fixture.api.signing_identities.clone(),
"http://localhost:8787",
binding.clone(),
));
fixture
.api
.runtime_subscription_broker
.register_remote_runtime(config);
let mut subscription = fixture
.api
.runtime_subscription_broker
.subscribe(
&binding.runtime_id,
protocol::subscription::EventSubscriptionSelector::RuntimeWorkers,
)
.unwrap();
let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone());
let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone());
let server = fixture.server.clone();
let request = Fixture::request(
"DELETE",
"/api/w/db-execution/runtimes/revoke-runtime/trust-key",
serde_json::json!({"expected_binding_id": binding.binding_id}),
);
let revoke =
tokio::spawn(async move { dispatch_workspace_request(State(server), request).await });
observed(waiting).await.unwrap(); // Actual authenticated revoke reached BEGIN IMMEDIATE.
runtime_progress_while_locked(&lock).await;
revoke.abort();
assert!(revoke.await.unwrap_err().is_cancelled());
let admission = fixture.server.admission_for_workspace(WORKSPACE).await;
let mut deletion = Box::pin(admission.lock_deletion());
assert!(
futures::poll!(&mut deletion).is_pending(),
"cancelled HTTP must retain actual revoke ownership until publication"
);
assert!(
fixture
.api
.runtime_subscription_broker
.runtime_ids()
.contains(&binding.runtime_id)
);
lock.release();
busy.resume();
observed(fixture.host_executor.drain()).await.unwrap();
observed(fixture.executor.drain()).await.unwrap();
let _deletion = observed(deletion).await;
let saved = fixture
.store
.get_workspace_runtime_binding(WORKSPACE, &binding.runtime_id)
.unwrap()
.unwrap();
assert!(saved.revoked_at.is_some());
assert_ne!(saved.binding_id, binding.binding_id);
let audit = fixture
.store
.list_workspace_runtime_binding_audit(WORKSPACE, &binding.runtime_id, 10)
.unwrap();
assert_eq!(
audit.len(),
1,
"cancelled mutation must commit one audit without retry"
);
assert_eq!(audit[0].action, "revoked");
assert_eq!(audit[0].actor_account_id, format!("account-{WORKSPACE}"));
assert_eq!(audit[0].binding_id, saved.binding_id);
assert_eq!(audit[0].at, saved.revoked_at.unwrap());
assert!(
!fixture
.api
.runtime_subscription_broker
.runtime_ids()
.contains(&binding.runtime_id),
"committed revoke must unregister even after HTTP waiter cancellation"
);
assert!(
!fixture
.api
.runtime_binding_expectations
.read()
.unwrap()
.contains_key(&(WORKSPACE.into(), binding.runtime_id.clone()))
);
// Exercise downstream termination at the broker protocol boundary, not a
// product process or complete browser/Runtime E2E flow.
observed(async {
loop {
match subscription.recv().await {
Some(crate::runtime_subscription::BrokerSubscriptionEvent::Closed {
code, ..
}) => {
assert_eq!(
code,
protocol::subscription::SubscriptionTerminationCode::ServerShutdown
);
break;
}
Some(crate::runtime_subscription::BrokerSubscriptionEvent::Disconnected {
..
}) => {}
event => panic!("expected revoke shutdown, got {event:?}"),
}
}
})
.await;
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn cancelled_runtime_metadata_mutation_keeps_commit_and_registration_publication_owned() { async fn cancelled_runtime_metadata_mutation_keeps_commit_and_registration_publication_owned() {
let fixture = Fixture::new().await; let fixture = Fixture::new().await;