feat: add guarded runtime removal operation
This commit is contained in:
@@ -1810,6 +1810,43 @@ pub struct RevokeRuntimeTrustKeyRequest {
|
|||||||
pub expected_revision: u64,
|
pub expected_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RemoveRuntimeRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub expected_binding_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeRemovalOperationState {
|
||||||
|
Pending,
|
||||||
|
CleanupPending,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeRemovalOperationResponse {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub state: RuntimeRemovalOperationState,
|
||||||
|
pub binding_removed: bool,
|
||||||
|
pub runtime_registration_removed: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub failure_category: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -3116,6 +3153,9 @@ pub fn catalog_typescript() -> String {
|
|||||||
WorkspaceRuntimeDetail::decl(&config),
|
WorkspaceRuntimeDetail::decl(&config),
|
||||||
RuntimeTrustKeyRevealResponse::decl(&config),
|
RuntimeTrustKeyRevealResponse::decl(&config),
|
||||||
RevokeRuntimeTrustKeyRequest::decl(&config),
|
RevokeRuntimeTrustKeyRequest::decl(&config),
|
||||||
|
RemoveRuntimeRequest::decl(&config),
|
||||||
|
RuntimeRemovalOperationState::decl(&config),
|
||||||
|
RuntimeRemovalOperationResponse::decl(&config),
|
||||||
RuntimeTrustConflictKind::decl(&config),
|
RuntimeTrustConflictKind::decl(&config),
|
||||||
RuntimeTrustConflictResponse::decl(&config),
|
RuntimeTrustConflictResponse::decl(&config),
|
||||||
RuntimePublicIdentityBundle::decl(&config),
|
RuntimePublicIdentityBundle::decl(&config),
|
||||||
|
|||||||
@@ -1057,6 +1057,56 @@ CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique
|
|||||||
WHERE unlinked_at IS NULL;
|
WHERE unlinked_at IS NULL;
|
||||||
CREATE INDEX worker_workdir_links_workdir
|
CREATE INDEX worker_workdir_links_workdir
|
||||||
ON worker_workdir_links(workspace_id, workdir_id);
|
ON worker_workdir_links(workspace_id, workdir_id);
|
||||||
|
CREATE TABLE runtime_removal_operations (
|
||||||
|
operation_id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
request_fingerprint TEXT NOT NULL,
|
||||||
|
expected_binding_revision INTEGER NOT NULL,
|
||||||
|
config_revision INTEGER NOT NULL,
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')),
|
||||||
|
failure_category TEXT,
|
||||||
|
binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)),
|
||||||
|
runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX runtime_removal_operations_one_active_runtime
|
||||||
|
ON runtime_removal_operations(runtime_id)
|
||||||
|
WHERE state IN ('pending', 'cleanup_pending');
|
||||||
|
|
||||||
|
CREATE INDEX runtime_removal_operations_workspace_state
|
||||||
|
ON runtime_removal_operations(workspace_id, state, updated_at);
|
||||||
|
|
||||||
|
CREATE TRIGGER runtime_binding_insert_blocked_by_removal
|
||||||
|
BEFORE INSERT ON workspace_runtime_bindings
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'runtime_removal_in_progress');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER runtime_binding_update_blocked_by_removal
|
||||||
|
BEFORE UPDATE ON workspace_runtime_bindings
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'runtime_removal_in_progress');
|
||||||
|
END;
|
||||||
|
|
||||||
CREATE TABLE workspace_deletion_operations (
|
CREATE TABLE workspace_deletion_operations (
|
||||||
operation_id TEXT PRIMARY KEY,
|
operation_id TEXT PRIMARY KEY,
|
||||||
request_fingerprint TEXT NOT NULL,
|
request_fingerprint TEXT NOT NULL,
|
||||||
|
|||||||
@@ -83,19 +83,20 @@ use workspace_api::{
|
|||||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
||||||
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
||||||
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
||||||
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
|
RemoveRuntimeRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
||||||
RepositoryLogResponse, RepositorySshConnectionProbeRequest,
|
RepositoryListResponse, RepositoryLogResponse, RepositorySshConnectionProbeRequest,
|
||||||
RepositorySshConnectionProbeResponse, RepositorySshConnectionTrustState,
|
RepositorySshConnectionProbeResponse, RepositorySshConnectionTrustState,
|
||||||
RepositorySshCredential, RepositorySshHostKeyCandidate, RepositorySshHostTrust,
|
RepositorySshCredential, RepositorySshHostKeyCandidate, RepositorySshHostTrust,
|
||||||
RepositorySshPublicKey, RequestActor, RevokeRuntimeTrustKeyRequest,
|
RepositorySshPublicKey, RequestActor, RevokeRuntimeTrustKeyRequest,
|
||||||
RotateRepositorySshCredentialRequest, RuntimeConnectionDisplayState,
|
RotateRepositorySshCredentialRequest, RuntimeConnectionDisplayState,
|
||||||
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
|
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
|
||||||
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
RuntimeManagementSummary, RuntimeRemovalOperationResponse,
|
||||||
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse,
|
RuntimeRemovalOperationState as ApiRuntimeRemovalOperationState, RuntimeTrustAuditAction,
|
||||||
RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse,
|
||||||
TICKET_RELATIONS_QUERY_PATH, UpdateRemoteRuntimeRequest, UpdateWorkspaceMetadataRequest,
|
RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus,
|
||||||
WhoamiResponse, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, UpdateRemoteRuntimeRequest,
|
||||||
WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
||||||
|
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
||||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||||
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
||||||
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
||||||
@@ -171,7 +172,8 @@ use crate::skills;
|
|||||||
use crate::store::{
|
use crate::store::{
|
||||||
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
||||||
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome,
|
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome,
|
||||||
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
RepositoryRecord, RuntimeRemovalOperation, RuntimeRemovalOperationState,
|
||||||
|
TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
||||||
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateCredentialCandidate,
|
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateCredentialCandidate,
|
||||||
WorkdirCreateCredentialCandidateRole, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
WorkdirCreateCredentialCandidateRole, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
||||||
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||||
@@ -2375,6 +2377,7 @@ impl WorkspaceApi {
|
|||||||
.map_err(|message| Error::Config(message.to_string()))?;
|
.map_err(|message| Error::Config(message.to_string()))?;
|
||||||
}
|
}
|
||||||
recover_workdir_removals(&api)?;
|
recover_workdir_removals(&api)?;
|
||||||
|
recover_runtime_removals(&api).await;
|
||||||
Ok(api)
|
Ok(api)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3422,7 +3425,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
||||||
get(scoped_get_runtime_detail)
|
get(scoped_get_runtime_detail)
|
||||||
.post(scoped_update_remote_runtime)
|
.post(scoped_update_remote_runtime)
|
||||||
.delete(scoped_delete_remote_runtime),
|
.delete(scoped_remove_remote_runtime),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
|
||||||
@@ -12257,14 +12260,15 @@ async fn runtime_trust_conflict_response(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_delete_remote_runtime(
|
async fn scoped_remove_remote_runtime(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
Extension(actor): Extension<RequestActor>,
|
Extension(actor): Extension<RequestActor>,
|
||||||
) -> ApiResult<StatusCode> {
|
Json(request): Json<RemoveRuntimeRequest>,
|
||||||
|
) -> ApiResult<Json<RuntimeRemovalOperationResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime removal").await?;
|
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime removal").await?;
|
||||||
delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await
|
remove_remote_runtime(State(api), AxumPath(path.runtime_id), Json(request)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_test_runtime_connection(
|
async fn scoped_test_runtime_connection(
|
||||||
@@ -13873,70 +13877,294 @@ async fn create_remote_runtime(
|
|||||||
Ok((status, Json(resource)))
|
Ok((status, Json(resource)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_remote_runtime(
|
fn runtime_removal_response(operation: RuntimeRemovalOperation) -> RuntimeRemovalOperationResponse {
|
||||||
State(api): State<WorkspaceApi>,
|
RuntimeRemovalOperationResponse {
|
||||||
AxumPath(runtime_id): AxumPath<String>,
|
operation_id: operation.operation_id,
|
||||||
) -> ApiResult<StatusCode> {
|
workspace_id: operation.workspace_id,
|
||||||
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
runtime_id: operation.runtime_id,
|
||||||
return Err(settings_bad_request(
|
state: match operation.state {
|
||||||
"embedded_runtime_not_config_managed",
|
RuntimeRemovalOperationState::Pending => ApiRuntimeRemovalOperationState::Pending,
|
||||||
"the embedded Runtime is built in and cannot be deleted",
|
RuntimeRemovalOperationState::CleanupPending => {
|
||||||
|
ApiRuntimeRemovalOperationState::CleanupPending
|
||||||
|
}
|
||||||
|
RuntimeRemovalOperationState::Succeeded => ApiRuntimeRemovalOperationState::Succeeded,
|
||||||
|
RuntimeRemovalOperationState::Failed => ApiRuntimeRemovalOperationState::Failed,
|
||||||
|
},
|
||||||
|
binding_removed: operation.binding_removed,
|
||||||
|
runtime_registration_removed: operation.runtime_registration_removed,
|
||||||
|
failure_category: operation.failure_category,
|
||||||
|
created_at: operation.created_at,
|
||||||
|
updated_at: operation.updated_at,
|
||||||
|
completed_at: operation.completed_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_removal_fingerprint(
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
expected_binding_revision: u64,
|
||||||
|
) -> String {
|
||||||
|
let digest = Sha256::digest(format!(
|
||||||
|
"runtime-removal-v1\0{workspace_id}\0{runtime_id}\0{expected_binding_revision}"
|
||||||
));
|
));
|
||||||
|
let digest = digest
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
format!("sha256:{digest}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_removal_config_guard(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
operation: &RuntimeRemovalOperation,
|
||||||
|
) -> Result<()> {
|
||||||
|
let config_state = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(&operation.workspace_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::RegistryInconsistency(format!(
|
||||||
|
"Workspace {} has no active configuration",
|
||||||
|
operation.workspace_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if config_state.snapshot.revision != operation.config_revision {
|
||||||
|
return Err(Error::RuntimeBindingConflict(
|
||||||
|
"runtime_removal_config_revision_changed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let projection = crate::runtime_settings::project_runtime_from_workspace_config(
|
||||||
|
&operation.workspace_id,
|
||||||
|
&config_state,
|
||||||
|
)?;
|
||||||
|
if projection.default_runtime_id.as_deref() == Some(operation.runtime_id.as_str()) {
|
||||||
|
return Err(Error::RuntimeBindingConflict(
|
||||||
|
"runtime_removal_config_reference_blocked".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_runtime_removal(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
operation: RuntimeRemovalOperation,
|
||||||
|
) -> ApiResult<RuntimeRemovalOperation> {
|
||||||
|
if operation.state == RuntimeRemovalOperationState::Succeeded {
|
||||||
|
return Ok(operation);
|
||||||
|
}
|
||||||
|
let operation = if operation.state == RuntimeRemovalOperationState::Pending {
|
||||||
|
if let Err(error) = runtime_removal_config_guard(api, &operation) {
|
||||||
|
let _ = api
|
||||||
|
.store
|
||||||
|
.mark_runtime_removal_failed(
|
||||||
|
&operation.operation_id,
|
||||||
|
"runtime_removal_config_guard_failed",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
let binding = api
|
let binding = api
|
||||||
.store
|
.store
|
||||||
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
.get_workspace_runtime_binding(&operation.workspace_id, &operation.runtime_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
.ok_or_else(|| Error::UnknownRuntime(operation.runtime_id.clone()))?;
|
||||||
if binding.revoked_at.is_none() {
|
|
||||||
return Err(Error::RuntimeBindingConflict(
|
|
||||||
"runtime trust is still active; revoke this Workspace's trust key with an expected revision before removing the inactive registration".to_string(),
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let has_other_active_binding = api
|
|
||||||
.store
|
|
||||||
.has_other_active_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
|
||||||
.await?;
|
|
||||||
if !has_other_active_binding {
|
|
||||||
match api
|
match api
|
||||||
.runtime
|
.runtime
|
||||||
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
|
.unregister_if_idle(&operation.runtime_id, api.config.max_records.min(200))
|
||||||
.map_err(|err| err.into_error())?
|
.map_err(|error| error.into_error())?
|
||||||
{
|
{
|
||||||
RuntimeRegistryUnregisterResult::Removed
|
RuntimeRegistryUnregisterResult::Removed
|
||||||
| RuntimeRegistryUnregisterResult::NotFound => {}
|
| RuntimeRegistryUnregisterResult::NotFound => {}
|
||||||
RuntimeRegistryUnregisterResult::BlockedByWorkers {
|
RuntimeRegistryUnregisterResult::BlockedByWorkers {
|
||||||
worker_count,
|
worker_count,
|
||||||
diagnostics,
|
mut diagnostics,
|
||||||
} => {
|
} => {
|
||||||
let mut diagnostics = diagnostics;
|
let _ = api
|
||||||
|
.store
|
||||||
|
.mark_runtime_removal_failed(
|
||||||
|
&operation.operation_id,
|
||||||
|
"runtime_removal_active_worker_blocked",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
diagnostics.push(settings_diagnostic(
|
diagnostics.push(settings_diagnostic(
|
||||||
"remote_runtime_delete_blocked",
|
"runtime_removal_active_worker_blocked",
|
||||||
DiagnosticSeverity::Error,
|
DiagnosticSeverity::Error,
|
||||||
format!(
|
format!(
|
||||||
"Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting its final Workspace registration."
|
"Remote Runtime '{}' has {worker_count} active Worker(s); stop and remove them before removing the Runtime.",
|
||||||
|
operation.runtime_id
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
return Err(ApiError::with_diagnostics(
|
return Err(ApiError::with_diagnostics(
|
||||||
Error::RuntimeOperationFailed {
|
Error::RuntimeBindingConflict(
|
||||||
runtime_id,
|
"runtime_removal_active_worker_blocked".to_string(),
|
||||||
code: "remote_runtime_delete_blocked".to_string(),
|
),
|
||||||
message: "Remote Runtime has active workers".to_string(),
|
|
||||||
},
|
|
||||||
diagnostics,
|
diagnostics,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
match api
|
||||||
if !api
|
|
||||||
.store
|
.store
|
||||||
.delete_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
.commit_runtime_binding_removal(&operation.operation_id)
|
||||||
.await?
|
.await
|
||||||
{
|
{
|
||||||
return Err(Error::UnknownRuntime(runtime_id).into());
|
Ok(operation) => operation,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = api
|
||||||
|
.store
|
||||||
|
.mark_runtime_removal_failed(
|
||||||
|
&operation.operation_id,
|
||||||
|
"runtime_removal_commit_failed",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let restore_result = api.register_workspace_runtime_binding(binding, true);
|
||||||
|
if let Err(restore_error) = restore_result {
|
||||||
|
tracing::warn!(
|
||||||
|
workspace_id = %operation.workspace_id,
|
||||||
|
runtime_id = %operation.runtime_id,
|
||||||
|
operation_id = %operation.operation_id,
|
||||||
|
error = %restore_error,
|
||||||
|
"failed to restore Runtime registration after removal commit failure"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
operation
|
||||||
|
};
|
||||||
|
|
||||||
|
if operation.state != RuntimeRemovalOperationState::CleanupPending {
|
||||||
|
return Err(Error::RuntimeBindingConflict(
|
||||||
|
"runtime_removal_operation_not_cleanup_pending".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
api.runtime_binding_expectations
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.remove(&(operation.workspace_id.clone(), operation.runtime_id.clone()));
|
||||||
|
api.runtime_subscription_broker
|
||||||
|
.unregister_runtime(&operation.runtime_id);
|
||||||
|
api.store
|
||||||
|
.complete_runtime_removal(&operation.operation_id, true)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recover_runtime_removals(api: &WorkspaceApi) {
|
||||||
|
let operations = match api
|
||||||
|
.store
|
||||||
|
.list_resumable_runtime_removals(&api.config.workspace_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(operations) => operations,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
workspace_id = %api.config.workspace_id,
|
||||||
|
error = %error,
|
||||||
|
"failed to list resumable Runtime removal operations"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for operation in operations {
|
||||||
|
let operation_id = operation.operation_id.clone();
|
||||||
|
let runtime_id = operation.runtime_id.clone();
|
||||||
|
if let Err(error) = execute_runtime_removal(api, operation).await {
|
||||||
|
tracing::warn!(
|
||||||
|
workspace_id = %api.config.workspace_id,
|
||||||
|
runtime_id = %runtime_id,
|
||||||
|
operation_id = %operation_id,
|
||||||
|
error = ?error,
|
||||||
|
"Runtime removal recovery remains incomplete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_remote_runtime(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(runtime_id): AxumPath<String>,
|
||||||
|
Json(request): Json<RemoveRuntimeRequest>,
|
||||||
|
) -> ApiResult<Json<RuntimeRemovalOperationResponse>> {
|
||||||
|
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"embedded_runtime_not_config_managed",
|
||||||
|
"the embedded Runtime is built in and cannot be removed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.operation_id.is_empty() || request.operation_id.len() > 128 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"operation_id must contain between 1 and 128 bytes".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let request_fingerprint = runtime_removal_fingerprint(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
&runtime_id,
|
||||||
|
request.expected_binding_revision,
|
||||||
|
);
|
||||||
|
if let Some(existing) = api.store.get_runtime_removal(&request.operation_id).await? {
|
||||||
|
if existing.workspace_id != api.config.workspace_id
|
||||||
|
|| existing.runtime_id != runtime_id
|
||||||
|
|| existing.request_fingerprint != request_fingerprint
|
||||||
|
|| existing.expected_binding_revision != request.expected_binding_revision
|
||||||
|
{
|
||||||
|
return Err(Error::RuntimeBindingConflict(
|
||||||
|
"runtime_removal_operation_id_reused".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let existing = if existing.state == RuntimeRemovalOperationState::Failed {
|
||||||
|
api.store
|
||||||
|
.reserve_runtime_removal(
|
||||||
|
&existing.workspace_id,
|
||||||
|
&existing.runtime_id,
|
||||||
|
&existing.operation_id,
|
||||||
|
&existing.request_fingerprint,
|
||||||
|
existing.expected_binding_revision,
|
||||||
|
existing.config_revision,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.operation
|
||||||
|
} else {
|
||||||
|
existing
|
||||||
|
};
|
||||||
|
let operation = execute_runtime_removal(&api, existing).await?;
|
||||||
|
return Ok(Json(runtime_removal_response(operation)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_state = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(&api.config.workspace_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::RegistryInconsistency(format!(
|
||||||
|
"Workspace {} has no active configuration",
|
||||||
|
api.config.workspace_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let runtime_projection = crate::runtime_settings::project_runtime_from_workspace_config(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
&config_state,
|
||||||
|
)?;
|
||||||
|
if runtime_projection.default_runtime_id.as_deref() == Some(runtime_id.as_str()) {
|
||||||
|
return Err(Error::RuntimeBindingConflict(
|
||||||
|
"runtime_removal_config_reference_blocked".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let reservation = api
|
||||||
|
.store
|
||||||
|
.reserve_runtime_removal(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
&runtime_id,
|
||||||
|
&request.operation_id,
|
||||||
|
&request_fingerprint,
|
||||||
|
request.expected_binding_revision,
|
||||||
|
config_state.snapshot.revision,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let operation = execute_runtime_removal(&api, reservation.operation).await?;
|
||||||
|
Ok(Json(runtime_removal_response(operation)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn perform_workspace_runtime_verification(
|
async fn perform_workspace_runtime_verification(
|
||||||
@@ -25778,6 +26006,38 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn register_test_runtime(api: &WorkspaceApi, runtime_id: &str) {
|
||||||
|
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
|
||||||
|
let binding = WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
display_name: format!("{runtime_id} display"),
|
||||||
|
base_url: "https://runtime.example.invalid".to_string(),
|
||||||
|
public_key: identity.public_key,
|
||||||
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
|
state: StoredRuntimeBindingState::Verified,
|
||||||
|
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||||
|
workspace_key_id: None,
|
||||||
|
workspace_key_generation: None,
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
api.store
|
||||||
|
.upsert_workspace_runtime_binding_record(binding.clone(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
api.runtime.register_or_replace(
|
||||||
|
RemoteWorkerRuntime::new(
|
||||||
|
remote_runtime_config_from_binding(&binding).unwrap(),
|
||||||
|
TEST_WORKSPACE_ID.to_string(),
|
||||||
|
"http://127.0.0.1:8787".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn assign_test_orchestrator(api: &WorkspaceApi, ticket_id: &str) {
|
fn assign_test_orchestrator(api: &WorkspaceApi, ticket_id: &str) {
|
||||||
api.store
|
api.store
|
||||||
.set_current_ticket_role_assignment(
|
.set_current_ticket_role_assignment(
|
||||||
@@ -28920,7 +29180,10 @@ mod tests {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
"DELETE",
|
"DELETE",
|
||||||
&format!("{runtimes_uri}/{EMBEDDED_WORKER_RUNTIME_ID}"),
|
&format!("{runtimes_uri}/{EMBEDDED_WORKER_RUNTIME_ID}"),
|
||||||
None,
|
Some(serde_json::json!({
|
||||||
|
"operation_id": "remove-embedded",
|
||||||
|
"expected_binding_revision": 1
|
||||||
|
})),
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -28988,25 +29251,30 @@ mod tests {
|
|||||||
.expect("team runtime launch option");
|
.expect("team runtime launch option");
|
||||||
assert_eq!(team_runtime["working_directory_required"], true);
|
assert_eq!(team_runtime["working_directory_required"], true);
|
||||||
|
|
||||||
api.store
|
let removal_request = serde_json::json!({
|
||||||
.revoke_workspace_runtime_binding_key(
|
"operation_id": "remove-team-runtime",
|
||||||
TEST_WORKSPACE_ID,
|
"expected_binding_revision": 1
|
||||||
"team-runtime",
|
});
|
||||||
1,
|
|
||||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
|
||||||
&Utc::now().to_rfc3339(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let deleted = request_json(
|
let deleted = request_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
"DELETE",
|
"DELETE",
|
||||||
&format!("{runtimes_uri}/team-runtime"),
|
&format!("{runtimes_uri}/team-runtime"),
|
||||||
None,
|
Some(removal_request.clone()),
|
||||||
StatusCode::NO_CONTENT,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(deleted["message"], "");
|
assert_eq!(deleted["state"], "succeeded");
|
||||||
|
assert_eq!(deleted["binding_removed"], true);
|
||||||
|
assert_eq!(deleted["runtime_registration_removed"], true);
|
||||||
|
let replay = request_json(
|
||||||
|
app.clone(),
|
||||||
|
"DELETE",
|
||||||
|
&format!("{runtimes_uri}/team-runtime"),
|
||||||
|
Some(removal_request),
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(replay, deleted);
|
||||||
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
||||||
assert!(
|
assert!(
|
||||||
!launch_options["runtimes"]
|
!launch_options["runtimes"]
|
||||||
@@ -29022,6 +29290,120 @@ mod tests {
|
|||||||
assert!(persisted.is_none());
|
assert!(persisted.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_removal_config_and_revision_guards_preserve_active_trust() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(root.path()).await;
|
||||||
|
register_test_runtime(&api, "guarded-runtime").await;
|
||||||
|
let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor()));
|
||||||
|
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
||||||
|
|
||||||
|
let stale = request_json(
|
||||||
|
app.clone(),
|
||||||
|
"DELETE",
|
||||||
|
&format!("{runtimes_uri}/guarded-runtime"),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"operation_id": "remove-guarded-stale",
|
||||||
|
"expected_binding_revision": 2
|
||||||
|
})),
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
stale.to_string().contains("revision conflict"),
|
||||||
|
"unexpected stale-revision response: {stale}"
|
||||||
|
);
|
||||||
|
let binding = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "guarded-runtime")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("stale removal must preserve binding");
|
||||||
|
assert_eq!(binding.binding_revision, 1);
|
||||||
|
assert!(binding.revoked_at.is_none());
|
||||||
|
|
||||||
|
set_test_default_runtime(&api, "guarded-runtime");
|
||||||
|
let referenced = request_json(
|
||||||
|
app,
|
||||||
|
"DELETE",
|
||||||
|
&format!("{runtimes_uri}/guarded-runtime"),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"operation_id": "remove-guarded-referenced",
|
||||||
|
"expected_binding_revision": 1
|
||||||
|
})),
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
referenced
|
||||||
|
.to_string()
|
||||||
|
.contains("runtime_removal_config_reference_blocked"),
|
||||||
|
"unexpected config-reference response: {referenced}"
|
||||||
|
);
|
||||||
|
let binding = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "guarded-runtime")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("config-blocked removal must preserve binding");
|
||||||
|
assert_eq!(binding.binding_revision, 1);
|
||||||
|
assert!(binding.revoked_at.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn startup_recovers_runtime_removal_after_binding_checkpoint() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(root.path()).await;
|
||||||
|
register_test_runtime(&api, "recover-runtime").await;
|
||||||
|
let config_revision = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.snapshot
|
||||||
|
.revision;
|
||||||
|
api.store
|
||||||
|
.reserve_runtime_removal(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
"recover-runtime",
|
||||||
|
"remove-recover-runtime",
|
||||||
|
&runtime_removal_fingerprint(TEST_WORKSPACE_ID, "recover-runtime", 1),
|
||||||
|
1,
|
||||||
|
config_revision,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = api
|
||||||
|
.store
|
||||||
|
.commit_runtime_binding_removal("remove-recover-runtime")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
checkpoint.state,
|
||||||
|
RuntimeRemovalOperationState::CleanupPending
|
||||||
|
);
|
||||||
|
drop(api);
|
||||||
|
|
||||||
|
let recovered = test_api(root.path()).await;
|
||||||
|
let operation = recovered
|
||||||
|
.store
|
||||||
|
.get_runtime_removal("remove-recover-runtime")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("recovered removal operation must remain auditable");
|
||||||
|
assert_eq!(operation.state, RuntimeRemovalOperationState::Succeeded);
|
||||||
|
assert!(operation.binding_removed);
|
||||||
|
assert_eq!(operation.runtime_registration_removed, Some(true));
|
||||||
|
assert!(
|
||||||
|
recovered
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "recover-runtime")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn runtime_connection_delete_rejects_active_remote_workers() {
|
async fn runtime_connection_delete_rejects_active_remote_workers() {
|
||||||
let (runtime, _worker_ref) = runtime_with_worker();
|
let (runtime, _worker_ref) = runtime_with_worker();
|
||||||
@@ -29069,17 +29451,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
api.store
|
let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor()));
|
||||||
.revoke_workspace_runtime_binding_key(
|
|
||||||
TEST_WORKSPACE_ID,
|
|
||||||
"busy-runtime",
|
|
||||||
1,
|
|
||||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
|
||||||
&Utc::now().to_rfc3339(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let app = build_inner_router(api).layer(Extension(test_owner_actor()));
|
|
||||||
let workers = get_json(app.clone(), "/api/workers").await;
|
let workers = get_json(app.clone(), "/api/workers").await;
|
||||||
assert!(
|
assert!(
|
||||||
workers["items"]
|
workers["items"]
|
||||||
@@ -29094,7 +29466,10 @@ mod tests {
|
|||||||
app,
|
app,
|
||||||
"DELETE",
|
"DELETE",
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/busy-runtime"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/busy-runtime"),
|
||||||
None,
|
Some(serde_json::json!({
|
||||||
|
"operation_id": "remove-busy-runtime",
|
||||||
|
"expected_binding_revision": 1
|
||||||
|
})),
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -29102,15 +29477,16 @@ mod tests {
|
|||||||
response["message"]
|
response["message"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("remote_runtime_delete_blocked")
|
.contains("runtime_removal_active_worker_blocked")
|
||||||
);
|
);
|
||||||
assert!(
|
let persisted = api
|
||||||
response["diagnostics"]
|
.store
|
||||||
.as_array()
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "busy-runtime")
|
||||||
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.expect("busy Runtime binding must remain after rejected removal");
|
||||||
.any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" })
|
assert_eq!(persisted.binding_revision, 1);
|
||||||
);
|
assert!(persisted.revoked_at.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_runtime_connection_test(
|
async fn run_runtime_connection_test(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"objective_ticket_links",
|
"objective_ticket_links",
|
||||||
"objectives",
|
"objectives",
|
||||||
"repositories",
|
"repositories",
|
||||||
|
"runtime_removal_operations",
|
||||||
"repository_secret_audit_events",
|
"repository_secret_audit_events",
|
||||||
"repository_secret_operations",
|
"repository_secret_operations",
|
||||||
"repository_ssh_credential_revisions",
|
"repository_ssh_credential_revisions",
|
||||||
|
|||||||
Reference in New Issue
Block a user