From 73a35599d2c36091610c963648fd8ffc0f58aae6 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 8 Sep 2026 05:15:14 +0900 Subject: [PATCH] fix: complete configured Runtime onboarding --- crates/workspace-server/src/server.rs | 64 +++++++++++++++++++ .../lib/workspace/api/runtime-management.ts | 31 ++++++++- .../settings/runtimes/+page.svelte | 61 +++++++++++++++++- .../tests/runtime-management.test.ts | 37 +++++++++++ 4 files changed, 189 insertions(+), 4 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 87ff4ed5..a8f5b3b5 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -13548,6 +13548,33 @@ async fn create_remote_runtime( "an active Workspace signing identity is required before registering a Runtime", )); } + let existing = api + .store + .get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id) + .await?; + if existing + .as_ref() + .is_some_and(|binding| binding.state == StoredRuntimeBindingState::Verified) + { + return Err(Error::RuntimeBindingConflict( + "a verified Runtime binding must be revoked before it can be replaced as configured" + .to_string(), + ) + .into()); + } + match api + .runtime + .unregister_if_idle(&runtime_id, api.config.max_records.min(200)) + .map_err(|err| err.into_error())? + { + RuntimeRegistryUnregisterResult::Removed | RuntimeRegistryUnregisterResult::NotFound => {} + RuntimeRegistryUnregisterResult::BlockedByWorkers { worker_count, .. } => { + return Err(Error::RuntimeBindingConflict(format!( + "Runtime `{runtime_id}` still has {worker_count} active worker(s) and cannot become a configured-only binding" + )) + .into()); + } + } let now = Utc::now().to_rfc3339(); let record = WorkspaceRuntimeBinding { workspace_id: api.config.workspace_id.clone(), @@ -13575,6 +13602,12 @@ async fn create_remote_runtime( .store .put_workspace_runtime_binding_key(record, request.expected_revision, &actor.account_id) .await?; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(api.config.workspace_id.clone(), runtime_id.clone())); + api.runtime_subscription_broker + .unregister_runtime(&runtime_id); let resource = workspace_runtime_resources_response(&api, &api.config.workspace_id) .await? .items @@ -19948,6 +19981,19 @@ mod tests { api.signing_identities .provision_existing(&api.config.workspace_id, &actor.account_id) .unwrap(); + api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + RemoteRuntimeConfig::new( + "configured-runtime", + "Stale Runtime", + "https://8.8.8.8", + None, + ), + api.config.workspace_id.clone(), + "http://127.0.0.1:1".to_string(), + ) + .unwrap(), + ); let runtime_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap(); let request = CreateRemoteRuntimeRequest { public_bundle: workspace_api::RuntimePublicIdentityBundle { @@ -19989,6 +20035,24 @@ mod tests { assert!(binding.workspace_key_id.is_some()); assert_eq!(binding.workspace_key_generation, Some(1)); assert!(!created.runtime.worker_creation_available); + assert!( + api.runtime + .list_runtimes(100) + .items + .iter() + .all(|runtime| runtime.runtime_id != "configured-runtime"), + "configured binding must remove a stale active Runtime projection" + ); + assert!( + !api.runtime_binding_expectations + .read() + .unwrap() + .contains_key(&( + api.config.workspace_id.clone(), + "configured-runtime".to_string(), + )), + "configured binding must not remain a control expectation" + ); let replacement_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap(); let generic_put = scoped_put_runtime_trust_key( State(api.clone()), diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 8a086e4e..ced20e5a 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -1,6 +1,6 @@ import type { - Diagnostic, CreateRemoteRuntimeRequest, + Diagnostic, PutRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest, RuntimeIdentityAuthority, @@ -334,13 +334,18 @@ function runtimeBinding( authenticationMode === "legacy_server_issuer" && (workspaceKeyId != null || workspaceKeyGeneration != null) ) { - return fail(path, "must not attach Workspace key metadata to legacy authority"); + return fail( + path, + "must not attach Workspace key metadata to legacy authority", + ); } return { state: enumValue(item.state, `${path}.state`, BINDING_STATES), authentication_mode: authenticationMode, revision: safeRevision(item.revision, `${path}.revision`), - ...(workspaceKeyId === undefined ? {} : { workspace_key_id: workspaceKeyId }), + ...(workspaceKeyId === undefined + ? {} + : { workspace_key_id: workspaceKeyId }), ...(workspaceKeyGeneration === undefined ? {} : { workspace_key_generation: workspaceKeyGeneration }), @@ -713,6 +718,26 @@ function requestErrorFrom( ): RuntimeTrustRequestError { try { const response = object(value, "Runtime trust error"); + if ("details" in response) { + exactKeys( + response, + ["error", "details"], + [], + "Runtime trust error", + ); + boundedString( + response.error, + "Runtime trust error.error", + LIMITS.idBytes, + ); + return new RuntimeTrustRequestError( + boundedString( + response.details, + "Runtime trust error.details", + LIMITS.conflictMessageBytes, + ), + ); + } exactKeys( response, ["error", "message", "diagnostics"], diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte index 25e022e4..9053c242 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -7,6 +7,7 @@ } from '$lib/generated/workspace-api'; import { createRemoteRuntime, + previewRuntimePublicKeyFingerprint, RuntimeTrustRequestError, } from '$lib/workspace/api/runtime-management'; import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection'; @@ -19,6 +20,8 @@ let runtimePublicBundle = $state(''); let displayName = $state(''); let endpoint = $state(''); + let runtimeFingerprint = $state(null); + let fingerprintConfirmation = $state(''); let showAddRuntime = $state(false); let busyRuntimeId = $state(null); let requestError = $state(null); @@ -82,12 +85,43 @@ : ''; } + async function copyWorkspaceBundle(): Promise { + requestError = null; + try { + await navigator.clipboard.writeText(workspacePublicBundle()); + } catch { + requestError = 'Workspace public bundle could not be copied'; + } + } + + async function previewRuntimeFingerprint(): Promise { + requestError = null; + runtimeFingerprint = null; + fingerprintConfirmation = ''; + busyRuntimeId = 'preview'; + try { + const bundle = parseRuntimePublicBundle(runtimePublicBundle); + runtimeFingerprint = await previewRuntimePublicKeyFingerprint(bundle.public_key); + } catch (error) { + requestError = error instanceof Error ? error.message : String(error); + } finally { + busyRuntimeId = null; + } + } + async function addRuntime(event: SubmitEvent): Promise { event.preventDefault(); requestError = null; busyRuntimeId = 'create'; try { const publicBundle = parseRuntimePublicBundle(runtimePublicBundle); + const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key); + if ( + runtimeFingerprint !== currentFingerprint || + fingerprintConfirmation.trim() !== currentFingerprint + ) { + throw new Error('Preview and confirm the exact Runtime public key fingerprint before registration'); + } await createRemoteRuntime(data.workspaceId, { public_bundle: publicBundle, display_name: displayName || null, @@ -95,6 +129,8 @@ expected_revision: null, }); runtimePublicBundle = ''; + runtimeFingerprint = null; + fingerprintConfirmation = ''; displayName = ''; endpoint = ''; showAddRuntime = false; @@ -149,12 +185,31 @@ Run yoi-runtime identity show --json on the Runtime host and paste the result. + + {#if runtimeFingerprint} + + {/if}