fix: use authoritative runtime removal result
This commit is contained in:
@@ -474,6 +474,30 @@ export type RuntimeTrustKeyRevealResponse = { public_key: string };
|
||||
|
||||
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
|
||||
|
||||
export type RemoveRuntimeRequest = {
|
||||
operation_id: string;
|
||||
expected_binding_revision: number;
|
||||
};
|
||||
|
||||
export type RuntimeRemovalOperationState =
|
||||
| "pending"
|
||||
| "cleanup_pending"
|
||||
| "succeeded"
|
||||
| "failed";
|
||||
|
||||
export type RuntimeRemovalOperationResponse = {
|
||||
operation_id: string;
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
state: RuntimeRemovalOperationState;
|
||||
binding_removed: boolean;
|
||||
runtime_registration_removed: boolean | null;
|
||||
failure_category?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
|
||||
|
||||
export type RuntimeTrustConflictResponse = {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type {
|
||||
CreateRemoteRuntimeRequest,
|
||||
Diagnostic,
|
||||
RemoveRuntimeRequest,
|
||||
RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeConnectionDisplayState,
|
||||
RuntimeIdentityAuthority,
|
||||
RuntimeManagementSummary,
|
||||
RuntimeRemovalOperationResponse,
|
||||
RuntimeRemovalOperationState,
|
||||
RuntimeSourceKind,
|
||||
RuntimeSourceStatus,
|
||||
RuntimeSourceSummary,
|
||||
@@ -83,6 +86,12 @@ const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
|
||||
"unavailable",
|
||||
"revoked",
|
||||
]);
|
||||
const REMOVAL_OPERATION_STATES = new Set<RuntimeRemovalOperationState>([
|
||||
"pending",
|
||||
"cleanup_pending",
|
||||
"succeeded",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
type JsonObject = Record<string, unknown>;
|
||||
@@ -770,6 +779,83 @@ export function parseRuntimeTrustConflict(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeRemovalOperationResponse(
|
||||
value: unknown,
|
||||
): RuntimeRemovalOperationResponse {
|
||||
const response = object(value, "Runtime removal response");
|
||||
exactKeys(
|
||||
response,
|
||||
[
|
||||
"operation_id",
|
||||
"workspace_id",
|
||||
"runtime_id",
|
||||
"state",
|
||||
"binding_removed",
|
||||
"runtime_registration_removed",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
["failure_category", "completed_at"],
|
||||
"Runtime removal response",
|
||||
);
|
||||
const runtimeRegistrationRemoved = response.runtime_registration_removed;
|
||||
if (
|
||||
runtimeRegistrationRemoved !== null &&
|
||||
typeof runtimeRegistrationRemoved !== "boolean"
|
||||
) {
|
||||
return fail(
|
||||
"Runtime removal response.runtime_registration_removed",
|
||||
"must be a boolean or null",
|
||||
);
|
||||
}
|
||||
return {
|
||||
operation_id: boundedString(
|
||||
response.operation_id,
|
||||
"Runtime removal response.operation_id",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
workspace_id: boundedString(
|
||||
response.workspace_id,
|
||||
"Runtime removal response.workspace_id",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
runtime_id: boundedString(
|
||||
response.runtime_id,
|
||||
"Runtime removal response.runtime_id",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
state: enumValue(
|
||||
response.state,
|
||||
"Runtime removal response.state",
|
||||
REMOVAL_OPERATION_STATES,
|
||||
),
|
||||
binding_removed: boolean(
|
||||
response.binding_removed,
|
||||
"Runtime removal response.binding_removed",
|
||||
),
|
||||
runtime_registration_removed: runtimeRegistrationRemoved,
|
||||
failure_category: optionalNullableString(
|
||||
response.failure_category,
|
||||
"Runtime removal response.failure_category",
|
||||
LIMITS.diagnosticCodeBytes,
|
||||
),
|
||||
created_at: boundedString(
|
||||
response.created_at,
|
||||
"Runtime removal response.created_at",
|
||||
LIMITS.timestampBytes,
|
||||
),
|
||||
updated_at: boundedString(
|
||||
response.updated_at,
|
||||
"Runtime removal response.updated_at",
|
||||
LIMITS.timestampBytes,
|
||||
),
|
||||
completed_at: optionalNullableTimestamp(
|
||||
response.completed_at,
|
||||
"Runtime removal response.completed_at",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function revisionForJson(revision: number | null): number | null {
|
||||
if (revision === null) return null;
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
@@ -940,28 +1026,51 @@ export async function updateRemoteRuntime(
|
||||
return finishMutation(response, workspaceId, runtimeId);
|
||||
}
|
||||
|
||||
export async function deleteRemoteRuntime(
|
||||
export async function removeRemoteRuntime(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
request: RemoveRuntimeRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
): Promise<RuntimeRemovalOperationResponse> {
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}`,
|
||||
),
|
||||
{ method: "DELETE" },
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
if (response.ok) return;
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await readBoundedJson(response);
|
||||
} catch {
|
||||
throw new RuntimeTrustRequestError(
|
||||
`Runtime registration delete failed (${response.status})`,
|
||||
`Runtime removal failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
throw requestErrorFrom(payload, response.status);
|
||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||
const operation = parseRuntimeRemovalOperationResponse(payload);
|
||||
if (
|
||||
operation.workspace_id !== workspaceId ||
|
||||
operation.runtime_id !== runtimeId
|
||||
) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime removal response did not match the selected Runtime",
|
||||
);
|
||||
}
|
||||
if (
|
||||
operation.state !== "succeeded" ||
|
||||
!operation.binding_removed ||
|
||||
operation.runtime_registration_removed !== true
|
||||
) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime removal did not reach authoritative completion",
|
||||
);
|
||||
}
|
||||
return operation;
|
||||
}
|
||||
|
||||
export async function revealRuntimeTrustKey(
|
||||
|
||||
+21
-28
@@ -6,7 +6,7 @@
|
||||
} from '$lib/generated/workspace-api';
|
||||
import {
|
||||
createRemoteRuntime,
|
||||
deleteRemoteRuntime,
|
||||
removeRemoteRuntime,
|
||||
previewRuntimePublicKeyFingerprint,
|
||||
revealRuntimeTrustKey,
|
||||
revokeRuntimeTrustKey,
|
||||
@@ -284,36 +284,34 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const operation = routeFence.capture(data.runtimeId);
|
||||
const routeOperation = routeFence.capture(data.runtimeId);
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
if (trust.revision == null) {
|
||||
deleteRuntimeError = 'The authoritative Runtime binding revision is unavailable. Reload before removal.';
|
||||
return;
|
||||
}
|
||||
busyAction = 'delete';
|
||||
deleteRuntimeError = null;
|
||||
try {
|
||||
if (data.runtimeDetail.trust_key.status !== 'revoked') {
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
if (trust.revision == null || !trust.fingerprint) {
|
||||
throw new Error('Runtime trust revision and fingerprint are required before deletion.');
|
||||
}
|
||||
await revokeRuntimeTrustKey(
|
||||
await removeRemoteRuntime(
|
||||
data.workspaceId,
|
||||
operation.runtimeId,
|
||||
{ expected_revision: trust.revision },
|
||||
trust.fingerprint,
|
||||
trust.fingerprint,
|
||||
routeOperation.runtimeId,
|
||||
{
|
||||
operation_id: crypto.randomUUID(),
|
||||
expected_binding_revision: trust.revision,
|
||||
},
|
||||
);
|
||||
if (!isCurrentRoute(operation)) return;
|
||||
}
|
||||
await deleteRemoteRuntime(data.workspaceId, operation.runtimeId);
|
||||
if (!isCurrentRoute(operation)) return;
|
||||
if (!isCurrentRoute(routeOperation)) return;
|
||||
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
|
||||
replaceState: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentRoute(operation)) return;
|
||||
if (!isCurrentRoute(routeOperation)) return;
|
||||
deleteRuntimeError = error instanceof Error
|
||||
? error.message
|
||||
: 'Runtime registration deletion failed.';
|
||||
} finally {
|
||||
if (isCurrentRoute(operation)) busyAction = null;
|
||||
if (isCurrentRoute(routeOperation)) busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,14 +587,13 @@
|
||||
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
|
||||
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
|
||||
<p>
|
||||
Remove this Runtime binding from the current Workspace. This does not stop the Runtime process,
|
||||
delete its Workers or Workdirs, or revoke this Workspace on the Runtime host.
|
||||
The Backend removes Workspace trust and this Runtime registration as one guarded operation.
|
||||
Active Workers, Workdirs, assignments, pending create or removal work, configuration references,
|
||||
or another Workspace binding block the operation without changing trust.
|
||||
</p>
|
||||
{#if trust.status !== 'revoked'}
|
||||
<p class="section-state warning">
|
||||
Deletion will revoke this Workspace trust first. Stop or move active Workers before continuing.
|
||||
This does not stop the Runtime process or delete its Workers or Workdirs.
|
||||
</p>
|
||||
{/if}
|
||||
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
|
||||
<input
|
||||
id="runtime-delete-confirmation"
|
||||
@@ -616,11 +613,7 @@
|
||||
class="danger"
|
||||
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
|
||||
onclick={deleteRegistration}
|
||||
>{busyAction === 'delete'
|
||||
? 'Deleting…'
|
||||
: trust.status === 'revoked'
|
||||
? 'Delete registration'
|
||||
: 'Revoke trust and delete registration'}</button>
|
||||
>{busyAction === 'delete' ? 'Removing…' : 'Remove Runtime registration'}</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -177,12 +177,13 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
"Revoke Workspace trust",
|
||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||
"await revokeRuntimeTrustKey(",
|
||||
"deleteRemoteRuntime(data.workspaceId, operation.runtimeId)",
|
||||
"Revoke trust and delete registration",
|
||||
"trust.status !== 'revoked'",
|
||||
"await removeRemoteRuntime(",
|
||||
"operation_id: crypto.randomUUID()",
|
||||
"expected_binding_revision: trust.revision",
|
||||
"The Backend removes Workspace trust and this Runtime registration as one guarded operation.",
|
||||
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
|
||||
"Delete Runtime registration",
|
||||
"Delete registration",
|
||||
"Remove Runtime registration",
|
||||
"This does not stop the Runtime process",
|
||||
"RuntimeTrustConflictError",
|
||||
"RuntimeTrustRouteFence",
|
||||
@@ -211,6 +212,9 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
"fingerprintConfirmation",
|
||||
"revokeFingerprintConfirmation",
|
||||
"Confirm current fingerprint",
|
||||
"deleteRemoteRuntime",
|
||||
"if (data.runtimeDetail.trust_key.status !== 'revoked')",
|
||||
"Revoke trust and delete registration",
|
||||
]
|
||||
) {
|
||||
assert(!page.includes(token), `Runtime detail must not require ${token}`);
|
||||
|
||||
@@ -4,12 +4,13 @@ declare const Deno: {
|
||||
|
||||
import {
|
||||
createRemoteRuntime,
|
||||
deleteRemoteRuntime,
|
||||
parseRuntimeRemovalOperationResponse,
|
||||
parseRuntimeTrustConflict,
|
||||
parseRuntimeTrustKeyRevealResponse,
|
||||
parseWorkspaceRuntimeDetail,
|
||||
parseWorkspaceRuntimeList,
|
||||
previewRuntimePublicKeyFingerprint,
|
||||
removeRemoteRuntime,
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRouteFence,
|
||||
@@ -313,22 +314,56 @@ Deno.test("Runtime metadata update never sends public key authority", async () =
|
||||
assert(!("public_key" in body), "metadata update sent public_key");
|
||||
});
|
||||
|
||||
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
|
||||
Deno.test("Runtime removal uses the Workspace-scoped operation route", async () => {
|
||||
let requestedUrl = "";
|
||||
let requestedMethod = "";
|
||||
let requestedBody: unknown = null;
|
||||
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
|
||||
requestedUrl = String(input);
|
||||
requestedMethod = init?.method ?? "GET";
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
requestedBody = JSON.parse(String(init?.body));
|
||||
return Promise.resolve(Response.json({
|
||||
operation_id: "remove-runtime-a",
|
||||
workspace_id: "workspace a",
|
||||
runtime_id: "runtime/a",
|
||||
state: "succeeded",
|
||||
binding_removed: true,
|
||||
runtime_registration_removed: true,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:01Z",
|
||||
completed_at: "2026-01-01T00:00:01Z",
|
||||
}));
|
||||
}) as typeof fetch;
|
||||
|
||||
await deleteRemoteRuntime("workspace a", "runtime/a", fetchImpl);
|
||||
const operation = await removeRemoteRuntime(
|
||||
"workspace a",
|
||||
"runtime/a",
|
||||
{ operation_id: "remove-runtime-a", expected_binding_revision: 7 },
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
assert(
|
||||
requestedUrl === "/api/w/workspace%20a/runtimes/runtime%2Fa",
|
||||
`unexpected delete URL: ${requestedUrl}`,
|
||||
`unexpected removal URL: ${requestedUrl}`,
|
||||
);
|
||||
assert(requestedMethod === "DELETE", "Runtime removal must use DELETE");
|
||||
assert(
|
||||
JSON.stringify(requestedBody) === JSON.stringify({
|
||||
operation_id: "remove-runtime-a",
|
||||
expected_binding_revision: 7,
|
||||
}),
|
||||
"Runtime removal request body drifted",
|
||||
);
|
||||
assert(operation.state === "succeeded", "Runtime removal did not complete");
|
||||
|
||||
assertThrows(
|
||||
() =>
|
||||
parseRuntimeRemovalOperationResponse({
|
||||
...operation,
|
||||
unknown: "rejected",
|
||||
}),
|
||||
"not part of the wire contract",
|
||||
);
|
||||
assert(requestedMethod === "DELETE", "Runtime delete must use DELETE");
|
||||
});
|
||||
|
||||
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
|
||||
|
||||
Reference in New Issue
Block a user