From 9d2e5734b6671aa4f1ef49208474cd3e80daf144 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 15:00:40 -0800 Subject: [PATCH 1/9] Move existing gateways onto the pinned LiteLLM after an app update An app update that bumps the LiteLLM pin left existing gateways on the old version: the container fingerprint covered only config and secrets, so the old container kept matching, and a local `triple-c-gateway:latest` from an earlier app version always won over the new pinned image. - Resolve the gateway image once, and put its image id in the container fingerprint so a newly pulled or rebuilt image recreates the container. - Label local builds with the pinned LiteLLM version and ignore a local build whose label doesn't match (older or unlabelled builds), falling back to the pinned upstream image; status reports no usable image so the UI offers a pull/build. - Test that the gateway Dockerfile's FROM stays in lockstep with the pin, since the build label relies on it. Every gateway is recreated once on its next start after this ships. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/docker/gateway.rs | 231 ++++++++++++++++++++++++---- 1 file changed, 200 insertions(+), 31 deletions(-) diff --git a/app/src-tauri/src/docker/gateway.rs b/app/src-tauri/src/docker/gateway.rs index f9d69ab..87ac5bf 100644 --- a/app/src-tauri/src/docker/gateway.rs +++ b/app/src-tauri/src/docker/gateway.rs @@ -66,6 +66,89 @@ const GATEWAY_INTERNAL_PORT: u16 = 4000; const CONFIG_FINGERPRINT_LABEL: &str = "triple-c.gateway.config-fingerprint"; +/// Stamped on local builds so a build of an *older* pin can be told apart from +/// a current one. `triple-c-gateway:latest` is a floating tag: without this, a +/// build made by a previous app version would keep winning over the new pin +/// forever, because the local build takes precedence. +const LITELLM_VERSION_LABEL: &str = "triple-c.gateway.litellm-version"; + +/// The LiteLLM release this app is pinned to — the tag of +/// [`GATEWAY_REGISTRY_IMAGE`], e.g. `v1.106.6`. +fn pinned_litellm_version() -> &'static str { + GATEWAY_REGISTRY_IMAGE + .rsplit_once(':') + .map(|(_, tag)| tag) + .unwrap_or_default() +} + +/// What we need to know about one locally present gateway image. +#[derive(Debug, Clone, PartialEq)] +struct ImageFacts { + id: String, + litellm_version: Option, +} + +/// The image the gateway container should run, and its content id. The id goes +/// into the container fingerprint so that a newly pulled or rebuilt image +/// recreates the container instead of leaving the old one running. +#[derive(Debug, Clone, PartialEq)] +struct ChosenImage { + reference: &'static str, + id: String, +} + +/// Local build first (same precedence as the STT container), but only a build +/// of the currently pinned LiteLLM — an older or unlabelled build is ignored so +/// the pinned upstream image is used, or the user is asked to pull/build. +fn choose_gateway_image( + local: Option, + registry: Option, +) -> Option { + if let Some(local) = local { + if local.litellm_version.as_deref() == Some(pinned_litellm_version()) { + return Some(ChosenImage { + reference: GATEWAY_LOCAL_IMAGE, + id: local.id, + }); + } + log::info!( + "Ignoring local gateway build {} (LiteLLM {}, app pins {})", + GATEWAY_LOCAL_IMAGE, + local.litellm_version.as_deref().unwrap_or("unknown"), + pinned_litellm_version() + ); + } + registry.map(|r| ChosenImage { + reference: GATEWAY_REGISTRY_IMAGE, + id: r.id, + }) +} + +/// `Ok(None)` when the image is not present locally; other Docker errors are +/// returned so "Docker is unreachable" is not mistaken for "image missing". +async fn inspect_gateway_image(reference: &str) -> Result, String> { + let docker = get_docker()?; + match docker.inspect_image(reference).await { + Ok(img) => Ok(Some(ImageFacts { + id: img.id.unwrap_or_default(), + litellm_version: img + .config + .and_then(|c| c.labels) + .and_then(|l| l.get(LITELLM_VERSION_LABEL).cloned()), + })), + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => Ok(None), + Err(e) => Err(format!("Failed to inspect image {}: {}", reference, e)), + } +} + +async fn resolve_gateway_image() -> Result, String> { + let local = inspect_gateway_image(GATEWAY_LOCAL_IMAGE).await?; + let registry = inspect_gateway_image(GATEWAY_REGISTRY_IMAGE).await?; + Ok(choose_gateway_image(local, registry)) +} + /// The default bridge gateway address on a stock native-Linux engine. Only a /// fallback: the real value is read from the `bridge` network's IPAM config. const DEFAULT_BRIDGE_GATEWAY: &str = "172.17.0.1"; @@ -201,6 +284,10 @@ async fn bridge_gateway_ip(docker: &Docker) -> Option { .find_map(|c| c.gateway.filter(|g| !g.trim().is_empty())) } +fn gateway_fingerprint(config_shape: &str, secret_version: &str, image_id: &str) -> String { + sha256_hex(&format!("{}|{}|{}", config_shape, secret_version, image_id)) +} + fn sha256_hex(input: &str) -> String { let mut hasher = Sha256::new(); hasher.update(input.as_bytes()); @@ -208,12 +295,15 @@ fn sha256_hex(input: &str) -> String { } pub async fn get_gateway_status(settings: &GatewaySettings) -> Result { - let image_exists = super::image::image_exists(GATEWAY_REGISTRY_IMAGE) - .await - .unwrap_or(false) - || super::image::image_exists(GATEWAY_LOCAL_IMAGE) - .await - .unwrap_or(false); + // Only a *usable* image counts: an outdated local build reports false so + // the UI offers a pull/build of the pinned version. + let image_exists = match resolve_gateway_image().await { + Ok(chosen) => chosen.is_some(), + Err(e) => { + log::warn!("Gateway image check failed: {}", e); + false + } + }; let (container_exists, running) = match find_gateway_container().await? { Some((_, state, _)) => (true, state == "running"), @@ -429,28 +519,11 @@ async fn upload_config(container_id: &str, config: &str) -> Result<(), String> { async fn create_gateway_container( settings: &GatewaySettings, binding: &GatewayBinding, + image: &ChosenImage, fingerprint: &str, ) -> Result { let docker = get_docker()?; - // Local build first, then the pinned upstream image — same precedence as - // the STT container. - let image = if super::image::image_exists(GATEWAY_LOCAL_IMAGE) - .await - .unwrap_or(false) - { - GATEWAY_LOCAL_IMAGE.to_string() - } else if super::image::image_exists(GATEWAY_REGISTRY_IMAGE) - .await - .unwrap_or(false) - { - GATEWAY_REGISTRY_IMAGE.to_string() - } else { - return Err( - "Gateway image not found. Please pull or build the image first.".to_string(), - ); - }; - let mut port_bindings = HashMap::new(); port_bindings.insert( format!("{}/tcp", GATEWAY_INTERNAL_PORT), @@ -491,7 +564,7 @@ async fn create_gateway_container( ); let config = Config { - image: Some(image), + image: Some(image.reference.to_string()), // The upstream entrypoint (`docker/prod_entrypoint.sh`) execs // `litellm "$@"`. Passed explicitly so the pulled upstream image and // our locally built one behave identically. @@ -565,11 +638,21 @@ async fn ensure_gateway_running_locked( // Rotation id, not a hash of either secret — see `storage::secure`. let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default(); - let fingerprint = sha256_hex(&format!( - "{}|{}", - config_shape(settings, &binding), - secret_version - )); + let image = resolve_gateway_image().await?.ok_or_else(|| { + format!( + "Gateway image for LiteLLM {} not found. Please pull or build the image first.", + pinned_litellm_version() + ) + })?; + + // The image id is part of the fingerprint: after an app update moves the + // pin (and the new image is pulled or built), the old container no longer + // matches and is recreated on the new image. + let fingerprint = gateway_fingerprint( + &config_shape(settings, &binding), + &secret_version, + &image.id, + ); if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? { if existing_fingerprint == fingerprint { @@ -602,7 +685,7 @@ async fn ensure_gateway_running_locked( .map_err(|e| format!("Failed to remove gateway container: {}", e))?; } - let id = create_gateway_container(settings, &binding, &fingerprint).await?; + let id = create_gateway_container(settings, &binding, &image, &fingerprint).await?; // Upload before the first start: LiteLLM reads the config once at boot. let rendered = render_config(settings, &api_key, &master_key); @@ -704,6 +787,9 @@ where let options = BuildImageOptions { t: GATEWAY_LOCAL_IMAGE, + // The Dockerfile's FROM is held to the same pin by a test, so the + // label is truthful. See `LITELLM_VERSION_LABEL`. + labels: HashMap::from([(LITELLM_VERSION_LABEL, pinned_litellm_version())]), rm: true, forcerm: true, ..Default::default() @@ -785,6 +871,89 @@ mod tests { } } + fn facts(id: &str, version: Option<&str>) -> ImageFacts { + ImageFacts { + id: id.to_string(), + litellm_version: version.map(str::to_string), + } + } + + #[test] + fn pinned_version_is_the_registry_tag() { + let v = pinned_litellm_version(); + assert!( + v.starts_with('v') && !v.contains(['/', '@', ':']), + "bad pin tag {v:?}" + ); + } + + #[test] + fn dockerfile_from_matches_the_registry_pin() { + // The local-build label claims the pinned version; that is only true + // while the Dockerfile builds FROM the same tag. + let from = GATEWAY_DOCKERFILE + .lines() + .find(|l| l.starts_with("FROM ")) + .expect("Dockerfile has a FROM line"); + assert!( + from.starts_with(&format!("FROM {}@sha256:", GATEWAY_REGISTRY_IMAGE)), + "gateway Dockerfile FROM ({}) is out of lockstep with {}", + from, + GATEWAY_REGISTRY_IMAGE + ); + } + + #[test] + fn current_local_build_wins() { + let chosen = choose_gateway_image( + Some(facts("sha256:local", Some(pinned_litellm_version()))), + Some(facts("sha256:registry", None)), + ); + assert_eq!( + chosen, + Some(ChosenImage { + reference: GATEWAY_LOCAL_IMAGE, + id: "sha256:local".into() + }) + ); + } + + #[test] + fn outdated_or_unlabelled_local_build_falls_back_to_registry() { + for version in [Some("v1.82.6"), None] { + let chosen = choose_gateway_image( + Some(facts("sha256:local", version)), + Some(facts("sha256:registry", None)), + ); + assert_eq!( + chosen, + Some(ChosenImage { + reference: GATEWAY_REGISTRY_IMAGE, + id: "sha256:registry".into() + }), + "local build labelled {:?}", + version + ); + } + } + + #[test] + fn outdated_local_build_alone_is_not_usable() { + assert_eq!( + choose_gateway_image(Some(facts("sha256:old", Some("v1.82.6"))), None), + None + ); + assert_eq!(choose_gateway_image(None, None), None); + } + + #[test] + fn new_image_changes_the_fingerprint() { + let a = gateway_fingerprint("shape", "1", "sha256:old"); + let b = gateway_fingerprint("shape", "1", "sha256:new"); + assert_ne!(a, b); + assert_eq!(a, gateway_fingerprint("shape", "1", "sha256:old")); + } + #[test] fn valid_models_skips_incomplete_rows() { assert_eq!(settings().valid_models().len(), 1); -- 2.52.0 From 00b504063fd8c0565675a301a06a4708d626a835 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 15:08:49 -0800 Subject: [PATCH 2/9] Gateway upgrade: auto-pull, never leave a stale gateway, probe old builds Review follow-ups: - When no usable image is present, pull the pinned image instead of failing. If the pull fails, stop and remove the existing gateway rather than leave an old LiteLLM (and, after a settings change or key rotation, an old config and old keys) serving. - A local build without the version label is probed for its LiteLLM version (cached by image id) instead of being treated as stale. - An engine that reports no image id still gets a fingerprint component that changes on rebuild/re-pull. - Inspect the two candidate images concurrently; log the ignored-build line at debug. - Settings shows a running gateway, and its Stop button, even when its image is no longer the pinned one. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/docker/gateway.rs | 217 +++++++++++++++--- .../settings/GatewaySettings.test.tsx | 11 + .../components/settings/GatewaySettings.tsx | 26 ++- 3 files changed, 208 insertions(+), 46 deletions(-) diff --git a/app/src-tauri/src/docker/gateway.rs b/app/src-tauri/src/docker/gateway.rs index 87ac5bf..6032a9e 100644 --- a/app/src-tauri/src/docker/gateway.rs +++ b/app/src-tauri/src/docker/gateway.rs @@ -81,10 +81,18 @@ fn pinned_litellm_version() -> &'static str { .unwrap_or_default() } +/// `v1.106.6` and `1.106.6` name the same release: the pin is a Docker tag, +/// a probe reports the Python package version. +fn same_litellm_version(a: &str, b: &str) -> bool { + a.trim().trim_start_matches('v') == b.trim().trim_start_matches('v') +} + /// What we need to know about one locally present gateway image. #[derive(Debug, Clone, PartialEq)] struct ImageFacts { id: String, + /// From the build label, or — for a build that predates the label — from + /// probing the image. `None` only when neither could tell. litellm_version: Option, } @@ -105,13 +113,17 @@ fn choose_gateway_image( registry: Option, ) -> Option { if let Some(local) = local { - if local.litellm_version.as_deref() == Some(pinned_litellm_version()) { + if local + .litellm_version + .as_deref() + .is_some_and(|v| same_litellm_version(v, pinned_litellm_version())) + { return Some(ChosenImage { reference: GATEWAY_LOCAL_IMAGE, id: local.id, }); } - log::info!( + log::debug!( "Ignoring local gateway build {} (LiteLLM {}, app pins {})", GATEWAY_LOCAL_IMAGE, local.litellm_version.as_deref().unwrap_or("unknown"), @@ -124,18 +136,36 @@ fn choose_gateway_image( }) } +/// One locally present image: its id and the version its build label claims. +struct InspectedImage { + id: String, + version_label: Option, +} + /// `Ok(None)` when the image is not present locally; other Docker errors are /// returned so "Docker is unreachable" is not mistaken for "image missing". -async fn inspect_gateway_image(reference: &str) -> Result, String> { +async fn inspect_gateway_image(reference: &str) -> Result, String> { let docker = get_docker()?; match docker.inspect_image(reference).await { - Ok(img) => Ok(Some(ImageFacts { - id: img.id.unwrap_or_default(), - litellm_version: img - .config - .and_then(|c| c.labels) - .and_then(|l| l.get(LITELLM_VERSION_LABEL).cloned()), - })), + Ok(img) => { + // An engine that reports no id still gets a component that moves + // when the image is rebuilt or re-pulled, so the fingerprint does + // not freeze on "". + let id = img + .id + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + format!("{}@{}", reference, img.created.clone().unwrap_or_default()) + }); + Ok(Some(InspectedImage { + id, + version_label: img + .config + .and_then(|c| c.labels) + .and_then(|l| l.get(LITELLM_VERSION_LABEL).cloned()), + })) + } Err(bollard::errors::Error::DockerResponseServerError { status_code: 404, .. }) => Ok(None), @@ -143,9 +173,70 @@ async fn inspect_gateway_image(reference: &str) -> Result, St } } +/// Successful probes, by image id. An image's contents never change under its +/// id, so a result is good for the life of the process; failures are not +/// cached, so a transient one is retried. +fn probe_cache() -> &'static std::sync::Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +/// Ask the image which LiteLLM it contains. Only used for a local build with +/// no version label — one made before builds were labelled. A missing label +/// means "unknown, probe instead", never "stale" (see CLAUDE.md). +async fn probe_litellm_version(reference: &str, id: &str) -> Option { + if let Some(v) = probe_cache().lock().ok().and_then(|c| c.get(id).cloned()) { + return Some(v); + } + let script = + "python -c \"import importlib.metadata as m; print(m.version('litellm'))\""; + match super::migration::run_throwaway(reference, script).await { + Ok(out) if out.exit_code == 0 && !out.stdout.trim().is_empty() => { + let version = out.stdout.trim().to_string(); + if let Ok(mut cache) = probe_cache().lock() { + cache.insert(id.to_string(), version.clone()); + } + Some(version) + } + Ok(out) => { + log::warn!( + "Could not read the LiteLLM version of {} (exit {}): {}", + reference, + out.exit_code, + out.stderr.trim() + ); + None + } + Err(e) => { + log::warn!("Could not probe {} for its LiteLLM version: {}", reference, e); + None + } + } +} + async fn resolve_gateway_image() -> Result, String> { - let local = inspect_gateway_image(GATEWAY_LOCAL_IMAGE).await?; - let registry = inspect_gateway_image(GATEWAY_REGISTRY_IMAGE).await?; + let (local, registry) = tokio::join!( + inspect_gateway_image(GATEWAY_LOCAL_IMAGE), + inspect_gateway_image(GATEWAY_REGISTRY_IMAGE) + ); + let local = match local? { + Some(img) => { + let litellm_version = match img.version_label { + Some(v) => Some(v), + None => probe_litellm_version(GATEWAY_LOCAL_IMAGE, &img.id).await, + }; + Some(ImageFacts { + id: img.id, + litellm_version, + }) + } + None => None, + }; + let registry = registry?.map(|img| ImageFacts { + id: img.id, + // The registry reference *is* the pin. + litellm_version: Some(pinned_litellm_version().to_string()), + }); Ok(choose_gateway_image(local, registry)) } @@ -610,6 +701,25 @@ fn gateway_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +async fn remove_gateway_container(docker: &Docker, id: &str, state: &str) -> Result<(), String> { + if state == "running" { + docker + .stop_container(id, None::) + .await + .map_err(|e| format!("Failed to stop gateway container: {}", e))?; + } + docker + .remove_container( + id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + .map_err(|e| format!("Failed to remove gateway container: {}", e)) +} + pub async fn ensure_gateway_running(settings: &GatewaySettings) -> Result { let _guard = gateway_lock().lock().await; ensure_gateway_running_locked(settings).await @@ -638,12 +748,46 @@ async fn ensure_gateway_running_locked( // Rotation id, not a hash of either secret — see `storage::secure`. let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default(); - let image = resolve_gateway_image().await?.ok_or_else(|| { - format!( - "Gateway image for LiteLLM {} not found. Please pull or build the image first.", - pinned_litellm_version() - ) - })?; + let image = match resolve_gateway_image().await? { + Some(image) => image, + // Typically the first start after an app update moved the pin. Pull + // the pinned image rather than leave the user on the old LiteLLM. + None => { + log::info!( + "No gateway image for LiteLLM {} — pulling {}", + pinned_litellm_version(), + GATEWAY_REGISTRY_IMAGE + ); + let pulled = super::image::pull_image(GATEWAY_REGISTRY_IMAGE, |msg| { + log::debug!("gateway pull: {}", msg.trim()) + }) + .await; + match pulled { + Ok(()) => resolve_gateway_image().await?.ok_or_else(|| { + format!("Pulled {} but it is not usable", GATEWAY_REGISTRY_IMAGE) + })?, + Err(e) => { + // Whatever is running is on an old image and, after a + // settings change or key rotation, an old config and old + // keys — never leave it serving. + let stopped = match find_gateway_container().await? { + Some((id, state, _)) => { + remove_gateway_container(&docker, &id, &state).await?; + " The old gateway was stopped." + } + None => "", + }; + return Err(format!( + "The gateway needs LiteLLM {} and pulling it failed: {}.{} \ + Pull or build the image in Settings.", + pinned_litellm_version(), + e, + stopped + )); + } + } + } + }; // The image id is part of the fingerprint: after an app update moves the // pin (and the new image is pulled or built), the old container no longer @@ -666,23 +810,9 @@ async fn ensure_gateway_running_locked( return get_gateway_status(settings).await; } - // Config or a secret changed — recreate so the new config is uploaded. - if state == "running" { - docker - .stop_container(&id, None::) - .await - .map_err(|e| format!("Failed to stop gateway container: {}", e))?; - } - docker - .remove_container( - &id, - Some(RemoveContainerOptions { - force: true, - ..Default::default() - }), - ) - .await - .map_err(|e| format!("Failed to remove gateway container: {}", e))?; + // Config, a secret or the image changed — recreate so the new config + // is uploaded and the new image is used. + remove_gateway_container(&docker, &id, &state).await?; } let id = create_gateway_container(settings, &binding, &image, &fingerprint).await?; @@ -937,6 +1067,23 @@ mod tests { } } + #[test] + fn tag_and_package_versions_compare_equal() { + assert!(same_litellm_version("v1.106.6", "1.106.6")); + assert!(same_litellm_version("1.106.6\n", "v1.106.6")); + assert!(!same_litellm_version("1.106.60", "v1.106.6")); + } + + #[test] + fn probed_current_local_build_still_wins() { + // A pre-label build of the current pin, as reported by the probe. + let chosen = choose_gateway_image( + Some(facts("sha256:local", Some("1.106.6"))), + Some(facts("sha256:registry", Some(pinned_litellm_version()))), + ); + assert_eq!(chosen.map(|c| c.reference), Some(GATEWAY_LOCAL_IMAGE)); + } + #[test] fn outdated_local_build_alone_is_not_usable() { assert_eq!( diff --git a/app/src/components/settings/GatewaySettings.test.tsx b/app/src/components/settings/GatewaySettings.test.tsx index d64fb30..2391340 100644 --- a/app/src/components/settings/GatewaySettings.test.tsx +++ b/app/src/components/settings/GatewaySettings.test.tsx @@ -102,4 +102,15 @@ describe("GatewaySettings", () => { ); await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2)); }); + it("can still stop a running gateway whose image is out of date", async () => { + appSettings = settingsWithGateway(true); + getGatewayStatus.mockResolvedValue(status({ image_exists: false })); + render(); + + const stop = await screen.findByRole("button", { name: "Stop" }); + expect(screen.getByText(/image out of date/i)).toBeInTheDocument(); + + fireEvent.click(stop); + await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1)); + }); }); diff --git a/app/src/components/settings/GatewaySettings.tsx b/app/src/components/settings/GatewaySettings.tsx index 5150aef..330028f 100644 --- a/app/src/components/settings/GatewaySettings.tsx +++ b/app/src/components/settings/GatewaySettings.tsx @@ -178,22 +178,26 @@ export default function GatewaySettings() { setTimeout(() => setCopied(null), 2000); }; - const tone: StatusTone = !status?.image_exists - ? "off" - : status.running - ? healthy === false - ? "busy" - : "running" + // A running container is reported (and stoppable) even when its image is no + // longer the pinned one — e.g. right after an app update moved the pin. + const tone: StatusTone = status?.running + ? healthy === false + ? "busy" + : "running" + : !status?.image_exists + ? "off" : status.container_exists ? "stopped" : "off"; - const statusLabel = !status?.image_exists - ? "No image" - : status.running - ? healthy === false + const statusLabel = status?.running + ? !status.image_exists + ? `Running on port ${status.port} (image out of date)` + : healthy === false ? "Starting…" : `Running on port ${status.port}` + : !status?.image_exists + ? "No image" : status.container_exists ? "Stopped" : "Image ready"; @@ -254,7 +258,7 @@ export default function GatewaySettings() { {/* ── Container ─────────────────────────────────────────────── */}
- {status?.image_exists && ( + {(status?.image_exists || status?.running) && (