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 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 RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
|
||||||
|
|
||||||
export type RuntimeTrustConflictResponse = {
|
export type RuntimeTrustConflictResponse = {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
CreateRemoteRuntimeRequest,
|
CreateRemoteRuntimeRequest,
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
|
RemoveRuntimeRequest,
|
||||||
RevokeRuntimeTrustKeyRequest,
|
RevokeRuntimeTrustKeyRequest,
|
||||||
RuntimeConnectionDisplayState,
|
RuntimeConnectionDisplayState,
|
||||||
RuntimeIdentityAuthority,
|
RuntimeIdentityAuthority,
|
||||||
RuntimeManagementSummary,
|
RuntimeManagementSummary,
|
||||||
|
RuntimeRemovalOperationResponse,
|
||||||
|
RuntimeRemovalOperationState,
|
||||||
RuntimeSourceKind,
|
RuntimeSourceKind,
|
||||||
RuntimeSourceStatus,
|
RuntimeSourceStatus,
|
||||||
RuntimeSourceSummary,
|
RuntimeSourceSummary,
|
||||||
@@ -83,6 +86,12 @@ const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
|
|||||||
"unavailable",
|
"unavailable",
|
||||||
"revoked",
|
"revoked",
|
||||||
]);
|
]);
|
||||||
|
const REMOVAL_OPERATION_STATES = new Set<RuntimeRemovalOperationState>([
|
||||||
|
"pending",
|
||||||
|
"cleanup_pending",
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
]);
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
type JsonObject = Record<string, unknown>;
|
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 {
|
function revisionForJson(revision: number | null): number | null {
|
||||||
if (revision === null) return null;
|
if (revision === null) return null;
|
||||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||||
@@ -940,28 +1026,51 @@ export async function updateRemoteRuntime(
|
|||||||
return finishMutation(response, workspaceId, runtimeId);
|
return finishMutation(response, workspaceId, runtimeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRemoteRuntime(
|
export async function removeRemoteRuntime(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
|
request: RemoveRuntimeRequest,
|
||||||
fetchImpl: typeof fetch = fetch,
|
fetchImpl: typeof fetch = fetch,
|
||||||
): Promise<void> {
|
): Promise<RuntimeRemovalOperationResponse> {
|
||||||
const response = await fetchImpl(
|
const response = await fetchImpl(
|
||||||
workspaceApiPath(
|
workspaceApiPath(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
`/runtimes/${encodeURIComponent(runtimeId)}`,
|
`/runtimes/${encodeURIComponent(runtimeId)}`,
|
||||||
),
|
),
|
||||||
{ method: "DELETE" },
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (response.ok) return;
|
|
||||||
let payload: unknown;
|
let payload: unknown;
|
||||||
try {
|
try {
|
||||||
payload = await readBoundedJson(response);
|
payload = await readBoundedJson(response);
|
||||||
} catch {
|
} catch {
|
||||||
throw new RuntimeTrustRequestError(
|
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(
|
export async function revealRuntimeTrustKey(
|
||||||
|
|||||||
+21
-28
@@ -6,7 +6,7 @@
|
|||||||
} from '$lib/generated/workspace-api';
|
} from '$lib/generated/workspace-api';
|
||||||
import {
|
import {
|
||||||
createRemoteRuntime,
|
createRemoteRuntime,
|
||||||
deleteRemoteRuntime,
|
removeRemoteRuntime,
|
||||||
previewRuntimePublicKeyFingerprint,
|
previewRuntimePublicKeyFingerprint,
|
||||||
revealRuntimeTrustKey,
|
revealRuntimeTrustKey,
|
||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
@@ -284,36 +284,34 @@
|
|||||||
return;
|
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';
|
busyAction = 'delete';
|
||||||
deleteRuntimeError = null;
|
deleteRuntimeError = null;
|
||||||
try {
|
try {
|
||||||
if (data.runtimeDetail.trust_key.status !== 'revoked') {
|
await removeRemoteRuntime(
|
||||||
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(
|
|
||||||
data.workspaceId,
|
data.workspaceId,
|
||||||
operation.runtimeId,
|
routeOperation.runtimeId,
|
||||||
{ expected_revision: trust.revision },
|
{
|
||||||
trust.fingerprint,
|
operation_id: crypto.randomUUID(),
|
||||||
trust.fingerprint,
|
expected_binding_revision: trust.revision,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (!isCurrentRoute(operation)) return;
|
if (!isCurrentRoute(routeOperation)) return;
|
||||||
}
|
|
||||||
await deleteRemoteRuntime(data.workspaceId, operation.runtimeId);
|
|
||||||
if (!isCurrentRoute(operation)) return;
|
|
||||||
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
|
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
|
||||||
replaceState: true,
|
replaceState: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCurrentRoute(operation)) return;
|
if (!isCurrentRoute(routeOperation)) return;
|
||||||
deleteRuntimeError = error instanceof Error
|
deleteRuntimeError = error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Runtime registration deletion failed.';
|
: 'Runtime registration deletion failed.';
|
||||||
} finally {
|
} 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">
|
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
|
||||||
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
|
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
|
||||||
<p>
|
<p>
|
||||||
Remove this Runtime binding from the current Workspace. This does not stop the Runtime process,
|
The Backend removes Workspace trust and this Runtime registration as one guarded operation.
|
||||||
delete its Workers or Workdirs, or revoke this Workspace on the Runtime host.
|
Active Workers, Workdirs, assignments, pending create or removal work, configuration references,
|
||||||
|
or another Workspace binding block the operation without changing trust.
|
||||||
</p>
|
</p>
|
||||||
{#if trust.status !== 'revoked'}
|
|
||||||
<p class="section-state warning">
|
<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>
|
</p>
|
||||||
{/if}
|
|
||||||
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
|
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
|
||||||
<input
|
<input
|
||||||
id="runtime-delete-confirmation"
|
id="runtime-delete-confirmation"
|
||||||
@@ -616,11 +613,7 @@
|
|||||||
class="danger"
|
class="danger"
|
||||||
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
|
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
|
||||||
onclick={deleteRegistration}
|
onclick={deleteRegistration}
|
||||||
>{busyAction === 'delete'
|
>{busyAction === 'delete' ? 'Removing…' : 'Remove Runtime registration'}</button>
|
||||||
? 'Deleting…'
|
|
||||||
: trust.status === 'revoked'
|
|
||||||
? 'Delete registration'
|
|
||||||
: 'Revoke trust and delete registration'}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -177,12 +177,13 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
"Revoke Workspace trust",
|
"Revoke Workspace trust",
|
||||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||||
"await revokeRuntimeTrustKey(",
|
"await revokeRuntimeTrustKey(",
|
||||||
"deleteRemoteRuntime(data.workspaceId, operation.runtimeId)",
|
"await removeRemoteRuntime(",
|
||||||
"Revoke trust and delete registration",
|
"operation_id: crypto.randomUUID()",
|
||||||
"trust.status !== 'revoked'",
|
"expected_binding_revision: trust.revision",
|
||||||
|
"The Backend removes Workspace trust and this Runtime registration as one guarded operation.",
|
||||||
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
|
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
|
||||||
"Delete Runtime registration",
|
"Delete Runtime registration",
|
||||||
"Delete registration",
|
"Remove Runtime registration",
|
||||||
"This does not stop the Runtime process",
|
"This does not stop the Runtime process",
|
||||||
"RuntimeTrustConflictError",
|
"RuntimeTrustConflictError",
|
||||||
"RuntimeTrustRouteFence",
|
"RuntimeTrustRouteFence",
|
||||||
@@ -211,6 +212,9 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
"fingerprintConfirmation",
|
"fingerprintConfirmation",
|
||||||
"revokeFingerprintConfirmation",
|
"revokeFingerprintConfirmation",
|
||||||
"Confirm current fingerprint",
|
"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}`);
|
assert(!page.includes(token), `Runtime detail must not require ${token}`);
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ declare const Deno: {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createRemoteRuntime,
|
createRemoteRuntime,
|
||||||
deleteRemoteRuntime,
|
parseRuntimeRemovalOperationResponse,
|
||||||
parseRuntimeTrustConflict,
|
parseRuntimeTrustConflict,
|
||||||
parseRuntimeTrustKeyRevealResponse,
|
parseRuntimeTrustKeyRevealResponse,
|
||||||
parseWorkspaceRuntimeDetail,
|
parseWorkspaceRuntimeDetail,
|
||||||
parseWorkspaceRuntimeList,
|
parseWorkspaceRuntimeList,
|
||||||
previewRuntimePublicKeyFingerprint,
|
previewRuntimePublicKeyFingerprint,
|
||||||
|
removeRemoteRuntime,
|
||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
RuntimeTrustRouteFence,
|
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");
|
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 requestedUrl = "";
|
||||||
let requestedMethod = "";
|
let requestedMethod = "";
|
||||||
|
let requestedBody: unknown = null;
|
||||||
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
|
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
|
||||||
requestedUrl = String(input);
|
requestedUrl = String(input);
|
||||||
requestedMethod = init?.method ?? "GET";
|
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;
|
}) 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(
|
assert(
|
||||||
requestedUrl === "/api/w/workspace%20a/runtimes/runtime%2Fa",
|
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 () => {
|
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user