Gateway upgrade: tag local builds with the pin; pull outside the lock
Build App (Preview) / compute-version (pull_request) Successful in 8s
Secret Scan / scan (push) Successful in 12s
Secret Scan / scan (pull_request) Successful in 9s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / test (pull_request) Successful in 4m18s
Build App (Preview) / build-macos (pull_request) Successful in 3m15s
Build App (Preview) / build-windows (pull_request) Successful in 6m51s
Build App (Preview) / build-linux (pull_request) Successful in 9m31s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 8s
Secret Scan / scan (push) Successful in 12s
Secret Scan / scan (pull_request) Successful in 9s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / test (pull_request) Successful in 4m18s
Build App (Preview) / build-macos (pull_request) Successful in 3m15s
Build App (Preview) / build-windows (pull_request) Successful in 6m51s
Build App (Preview) / build-linux (pull_request) Successful in 9m31s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Second-review follow-ups, replacing the label + probe approach: - Local builds are tagged `triple-c-gateway:<pin>` instead of `latest`, so a build for an older pin simply doesn't match. Drops the version label, the probe container, its cache and version normalisation — and with them the case where a failed probe made a current build look stale. - The pinned image is pulled before taking the gateway lock, so a long pull no longer blocks Stop, shutdown or settings saves; skipped when the start would fail on configuration anyway. - If no pinned image is available, the old container is removed and the error keeps the pull failure, reporting a removal failure alongside it. - Status gains `image_outdated` (container image id != pinned image id). Settings uses it for the label, keeps Stop for any running gateway, and always offers Start, since Start pulls the image. - Reuse `migration::image_id` and one container-removal helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00b504063f
commit
03c77c5f55
5 files changed
+215
-339
No files matched your search
+175
-317
@@ -50,7 +50,76 @@ const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config";
|
||||
/// see the long comment in `gateway-container/Dockerfile`, and keep the two in
|
||||
/// lockstep.
|
||||
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.106.6";
|
||||
const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest";
|
||||
|
||||
/// Local builds are tagged with the pin (`triple-c-gateway:v1.106.6`), never a
|
||||
/// floating tag. A build made for an older pin then simply doesn't match after
|
||||
/// an app update, instead of quietly winning over the new pinned image the way
|
||||
/// the old `triple-c-gateway:latest` tag did. Builds tagged `latest` by earlier
|
||||
/// app versions are no longer used.
|
||||
const GATEWAY_LOCAL_REPO: &str = "triple-c-gateway";
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
fn local_image_ref() -> String {
|
||||
format!("{}:{}", GATEWAY_LOCAL_REPO, pinned_litellm_version())
|
||||
}
|
||||
|
||||
/// 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: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Local build first (same precedence as the STT container), then the pinned
|
||||
/// upstream image. Both references name the pin, so either is current.
|
||||
fn choose_gateway_image(local_id: Option<String>, registry_id: Option<String>) -> Option<ChosenImage> {
|
||||
local_id
|
||||
.map(|id| ChosenImage {
|
||||
reference: local_image_ref(),
|
||||
id,
|
||||
})
|
||||
.or_else(|| {
|
||||
registry_id.map(|id| ChosenImage {
|
||||
reference: GATEWAY_REGISTRY_IMAGE.to_string(),
|
||||
id,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `Ok(None)` when neither pinned image is present; other Docker errors are
|
||||
/// returned so "Docker is unreachable" is not mistaken for "image missing".
|
||||
async fn resolve_gateway_image() -> Result<Option<ChosenImage>, String> {
|
||||
let local_ref = local_image_ref();
|
||||
let (local, registry) = tokio::join!(
|
||||
super::migration::image_id(&local_ref),
|
||||
super::migration::image_id(GATEWAY_REGISTRY_IMAGE)
|
||||
);
|
||||
Ok(choose_gateway_image(local?, registry?))
|
||||
}
|
||||
|
||||
/// Whether an existing gateway container is running something other than the
|
||||
/// pinned image. Unknown (the image check failed) is reported as not outdated
|
||||
/// rather than guessed.
|
||||
fn container_image_outdated(
|
||||
container_image_id: Option<&str>,
|
||||
chosen: Result<Option<&ChosenImage>, ()>,
|
||||
) -> bool {
|
||||
match (container_image_id, chosen) {
|
||||
(None, _) | (_, Err(())) => false,
|
||||
(Some(_), Ok(None)) => true,
|
||||
(Some(running), Ok(Some(chosen))) => running != chosen.id,
|
||||
}
|
||||
}
|
||||
|
||||
const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile");
|
||||
const GATEWAY_DEFAULT_CONFIG: &str = include_str!("../../../../gateway-container/config.yaml");
|
||||
@@ -66,180 +135,6 @@ 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()
|
||||
}
|
||||
|
||||
/// `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<String>,
|
||||
}
|
||||
|
||||
/// 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<ImageFacts>,
|
||||
registry: Option<ImageFacts>,
|
||||
) -> Option<ChosenImage> {
|
||||
if let Some(local) = local {
|
||||
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::debug!(
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
/// One locally present image: its id and the version its build label claims.
|
||||
struct InspectedImage {
|
||||
id: String,
|
||||
version_label: Option<String>,
|
||||
}
|
||||
|
||||
/// `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<Option<InspectedImage>, String> {
|
||||
let docker = get_docker()?;
|
||||
match docker.inspect_image(reference).await {
|
||||
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),
|
||||
Err(e) => Err(format!("Failed to inspect image {}: {}", reference, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<HashMap<String, String>> {
|
||||
static CACHE: OnceLock<std::sync::Mutex<HashMap<String, String>>> = 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<String> {
|
||||
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<Option<ChosenImage>, String> {
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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";
|
||||
@@ -386,26 +281,27 @@ fn sha256_hex(input: &str) -> String {
|
||||
}
|
||||
|
||||
pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
|
||||
// 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 chosen = resolve_gateway_image().await.map_err(|e| {
|
||||
log::warn!("Gateway image check failed: {}", e);
|
||||
});
|
||||
let image_exists = matches!(chosen, Ok(Some(_)));
|
||||
|
||||
let (container_exists, running) = match find_gateway_container().await? {
|
||||
Some((_, state, _)) => (true, state == "running"),
|
||||
let container = find_gateway_container().await?;
|
||||
let (container_exists, running) = match &container {
|
||||
Some((_, state, _, _)) => (true, state == "running"),
|
||||
None => (false, false),
|
||||
};
|
||||
let image_outdated = container_image_outdated(
|
||||
container.as_ref().and_then(|c| c.3.as_deref()),
|
||||
chosen.as_ref().map(|c| c.as_ref()).map_err(|_| ()),
|
||||
);
|
||||
|
||||
Ok(GatewayStatus {
|
||||
container_exists,
|
||||
running,
|
||||
port: settings.port,
|
||||
image_exists,
|
||||
image_outdated,
|
||||
model_count: settings.valid_models().len(),
|
||||
has_api_key: secure::has_gateway_api_key(),
|
||||
base_url: gateway_binding().await.base_url(settings.port),
|
||||
@@ -416,7 +312,7 @@ pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewaySta
|
||||
/// settings reconcile, which must not start anything the user never started.
|
||||
pub async fn gateway_container_presence() -> Result<(bool, bool), String> {
|
||||
Ok(match find_gateway_container().await? {
|
||||
Some((_, state, _)) => (true, state == "running"),
|
||||
Some((_, state, _, _)) => (true, state == "running"),
|
||||
None => (false, false),
|
||||
})
|
||||
}
|
||||
@@ -434,7 +330,8 @@ fn is_gateway_container(names: Option<&Vec<String>>) -> bool {
|
||||
}
|
||||
|
||||
/// `(id, state, config fingerprint label)` for the gateway container, if any.
|
||||
async fn find_gateway_container() -> Result<Option<(String, String, String)>, String> {
|
||||
/// `(id, state, config fingerprint label, image id)` for the gateway container.
|
||||
async fn find_gateway_container() -> Result<Option<(String, String, String, Option<String>)>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
@@ -465,7 +362,9 @@ async fn find_gateway_container() -> Result<Option<(String, String, String)>, St
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
return Ok(Some((id, state, fingerprint)));
|
||||
let image_id = container.image_id.clone().filter(|s| !s.is_empty());
|
||||
|
||||
return Ok(Some((id, state, fingerprint, image_id)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
@@ -655,7 +554,7 @@ async fn create_gateway_container(
|
||||
);
|
||||
|
||||
let config = Config {
|
||||
image: Some(image.reference.to_string()),
|
||||
image: Some(image.reference.clone()),
|
||||
// The upstream entrypoint (`docker/prod_entrypoint.sh`) execs
|
||||
// `litellm "$@"`. Passed explicitly so the pulled upstream image and
|
||||
// our locally built one behave identically.
|
||||
@@ -696,11 +595,6 @@ async fn create_gateway_container(
|
||||
/// refuse, because the second caller then observes the first's container, finds
|
||||
/// a matching fingerprint, and returns its status — which is exactly what it
|
||||
/// asked for.
|
||||
fn gateway_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
async fn remove_gateway_container(docker: &Docker, id: &str, state: &str) -> Result<(), String> {
|
||||
if state == "running" {
|
||||
docker
|
||||
@@ -720,13 +614,40 @@ async fn remove_gateway_container(docker: &Docker, id: &str, state: &str) -> Res
|
||||
.map_err(|e| format!("Failed to remove gateway container: {}", e))
|
||||
}
|
||||
|
||||
fn gateway_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
pub async fn ensure_gateway_running(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
|
||||
// Typically the first start after an app update moved the pin: fetch the
|
||||
// pinned image so the user isn't left on the old LiteLLM. Done *before*
|
||||
// taking the lock — a multi-hundred-MB download must not hold up Stop or
|
||||
// shutdown, which take the same lock. Skipped when the start would fail on
|
||||
// configuration anyway.
|
||||
let mut pull_error = None;
|
||||
if !settings.valid_models().is_empty()
|
||||
&& secure::has_gateway_api_key()
|
||||
&& resolve_gateway_image().await?.is_none()
|
||||
{
|
||||
log::info!(
|
||||
"No gateway image for LiteLLM {} — pulling {}",
|
||||
pinned_litellm_version(),
|
||||
GATEWAY_REGISTRY_IMAGE
|
||||
);
|
||||
if let Err(e) = super::image::pull_image(GATEWAY_REGISTRY_IMAGE, |_| {}).await {
|
||||
log::warn!("Gateway image pull failed: {}", e);
|
||||
pull_error = Some(e);
|
||||
}
|
||||
}
|
||||
|
||||
let _guard = gateway_lock().lock().await;
|
||||
ensure_gateway_running_locked(settings).await
|
||||
ensure_gateway_running_locked(settings, pull_error).await
|
||||
}
|
||||
|
||||
async fn ensure_gateway_running_locked(
|
||||
settings: &GatewaySettings,
|
||||
pull_error: Option<String>,
|
||||
) -> Result<GatewayStatus, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -748,57 +669,39 @@ 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 = 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
|
||||
));
|
||||
}
|
||||
let Some(image) = resolve_gateway_image().await? else {
|
||||
let mut message = format!(
|
||||
"The gateway needs LiteLLM {} and {}. Pull or build the image in Settings.",
|
||||
pinned_litellm_version(),
|
||||
match &pull_error {
|
||||
Some(e) => format!("pulling it failed: {}", e),
|
||||
None => "that image is not available locally".to_string(),
|
||||
}
|
||||
);
|
||||
// Neither pinned image exists, so any existing container runs an old
|
||||
// LiteLLM — and, after a settings change or key rotation, an old
|
||||
// config and old keys. Never leave it serving.
|
||||
if let Some((id, state, _, _)) = find_gateway_container().await? {
|
||||
match remove_gateway_container(&docker, &id, &state).await {
|
||||
Ok(()) => message.push_str(" The out-of-date gateway container was removed."),
|
||||
Err(e) => message.push_str(&format!(
|
||||
" Removing the out-of-date gateway container also failed: {}",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
return Err(message);
|
||||
};
|
||||
|
||||
// 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.
|
||||
// The image id is part of the fingerprint: once the new pinned image is
|
||||
// present, the old container no longer matches and is recreated on it.
|
||||
let fingerprint = gateway_fingerprint(
|
||||
&config_shape(settings, &binding),
|
||||
&secret_version,
|
||||
&image.id,
|
||||
);
|
||||
|
||||
if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? {
|
||||
if let Some((id, state, existing_fingerprint, _)) = find_gateway_container().await? {
|
||||
if existing_fingerprint == fingerprint {
|
||||
if state == "running" {
|
||||
return get_gateway_status(settings).await;
|
||||
@@ -811,7 +714,7 @@ async fn ensure_gateway_running_locked(
|
||||
}
|
||||
|
||||
// Config, a secret or the image changed — recreate so the new config
|
||||
// is uploaded and the new image is used.
|
||||
// is uploaded and the pinned image is used.
|
||||
remove_gateway_container(&docker, &id, &state).await?;
|
||||
}
|
||||
|
||||
@@ -822,15 +725,10 @@ async fn ensure_gateway_running_locked(
|
||||
if let Err(e) = upload_config(&id, &rendered).await {
|
||||
// Don't leave a half-configured container behind for the next run to
|
||||
// mistake for a good one.
|
||||
let _ = docker
|
||||
.remove_container(
|
||||
&id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Never started, so "created" skips the stop.
|
||||
if let Err(cleanup) = remove_gateway_container(&docker, &id, "created").await {
|
||||
log::warn!("{}", cleanup);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
@@ -859,7 +757,7 @@ pub async fn stop_gateway_container() -> Result<(), String> {
|
||||
let _guard = gateway_lock().lock().await;
|
||||
let docker = get_docker()?;
|
||||
|
||||
if let Some((id, state, _)) = find_gateway_container().await? {
|
||||
if let Some((id, state, _, _)) = find_gateway_container().await? {
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(
|
||||
@@ -915,11 +813,9 @@ where
|
||||
let tar_bytes = create_gateway_build_context()
|
||||
.map_err(|e| format!("Failed to create gateway build context: {}", e))?;
|
||||
|
||||
let tag = local_image_ref();
|
||||
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())]),
|
||||
t: tag.as_str(),
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
..Default::default()
|
||||
@@ -1001,26 +897,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn facts(id: &str, version: Option<&str>) -> ImageFacts {
|
||||
ImageFacts {
|
||||
fn chosen(reference: &str, id: &str) -> ChosenImage {
|
||||
ChosenImage {
|
||||
reference: reference.to_string(),
|
||||
id: id.to_string(),
|
||||
litellm_version: version.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_version_is_the_registry_tag() {
|
||||
fn local_builds_are_tagged_with_the_pin() {
|
||||
let v = pinned_litellm_version();
|
||||
assert!(
|
||||
v.starts_with('v') && !v.contains(['/', '@', ':']),
|
||||
"bad pin tag {v:?}"
|
||||
);
|
||||
assert!(v.starts_with('v') && !v.contains(['/', '@', ':']), "bad pin tag {v:?}");
|
||||
assert_eq!(local_image_ref(), format!("triple-c-gateway:{}", 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.
|
||||
// Local builds are tagged with the pin; that is only truthful while the
|
||||
// Dockerfile builds FROM the same tag.
|
||||
let from = GATEWAY_DOCKERFILE
|
||||
.lines()
|
||||
.find(|l| l.starts_with("FROM "))
|
||||
@@ -1034,70 +928,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_local_build_wins() {
|
||||
let chosen = choose_gateway_image(
|
||||
Some(facts("sha256:local", Some(pinned_litellm_version()))),
|
||||
Some(facts("sha256:registry", None)),
|
||||
fn local_build_wins_then_registry_then_nothing() {
|
||||
assert_eq!(
|
||||
choose_gateway_image(Some("sha256:local".into()), Some("sha256:reg".into())),
|
||||
Some(chosen(&local_image_ref(), "sha256:local"))
|
||||
);
|
||||
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 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!(
|
||||
choose_gateway_image(Some(facts("sha256:old", Some("v1.82.6"))), None),
|
||||
None
|
||||
choose_gateway_image(None, Some("sha256:reg".into())),
|
||||
Some(chosen(GATEWAY_REGISTRY_IMAGE, "sha256:reg"))
|
||||
);
|
||||
assert_eq!(choose_gateway_image(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdated_means_the_container_runs_another_image() {
|
||||
let pinned = chosen(GATEWAY_REGISTRY_IMAGE, "sha256:new");
|
||||
assert!(!container_image_outdated(Some("sha256:new"), Ok(Some(&pinned))));
|
||||
assert!(container_image_outdated(Some("sha256:old"), Ok(Some(&pinned))));
|
||||
// No pinned image at all: whatever runs is not it.
|
||||
assert!(container_image_outdated(Some("sha256:old"), Ok(None)));
|
||||
// No container, or the check failed: don't claim it.
|
||||
assert!(!container_image_outdated(None, Ok(Some(&pinned))));
|
||||
assert!(!container_image_outdated(Some("sha256:old"), Err(())));
|
||||
}
|
||||
|
||||
#[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_ne!(a, gateway_fingerprint("shape", "1", "sha256:new"));
|
||||
assert_eq!(a, gateway_fingerprint("shape", "1", "sha256:old"));
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ pub struct GatewayStatus {
|
||||
pub running: bool,
|
||||
pub port: u16,
|
||||
pub image_exists: bool,
|
||||
/// The gateway container exists but runs something other than the pinned
|
||||
/// image (e.g. after an app update moved the pin). Stop and Start
|
||||
/// recreates it on the pinned image.
|
||||
pub image_outdated: bool,
|
||||
/// Number of fully-specified models in the current settings.
|
||||
pub model_count: usize,
|
||||
/// Whether a provider API key is present in the keychain.
|
||||
|
||||
@@ -39,6 +39,7 @@ const status = (over: Partial<GatewayStatus> = {}): GatewayStatus => ({
|
||||
running: true,
|
||||
port: 4000,
|
||||
image_exists: true,
|
||||
image_outdated: false,
|
||||
model_count: 0,
|
||||
has_api_key: false,
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
@@ -102,15 +103,26 @@ describe("GatewaySettings", () => {
|
||||
);
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
it("can still stop a running gateway whose image is out of date", async () => {
|
||||
it("can stop a gateway running an outdated image, and says so", async () => {
|
||||
appSettings = settingsWithGateway(true);
|
||||
getGatewayStatus.mockResolvedValue(status({ image_exists: false }));
|
||||
getGatewayStatus.mockResolvedValue(status({ image_outdated: true }));
|
||||
render(<GatewaySettings />);
|
||||
|
||||
const stop = await screen.findByRole("button", { name: "Stop" });
|
||||
expect(screen.getByText(/image out of date/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/outdated LiteLLM/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(stop);
|
||||
await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("offers Start without an image, since Start pulls it", async () => {
|
||||
appSettings = settingsWithGateway(true);
|
||||
getGatewayStatus.mockResolvedValue(
|
||||
status({ running: false, container_exists: false, image_exists: false }),
|
||||
);
|
||||
render(<GatewaySettings />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Start" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Start will pull it/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -178,29 +178,29 @@ export default function GatewaySettings() {
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
// 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.
|
||||
// A running container is shown (and stoppable) whatever its image. Start
|
||||
// pulls the pinned image itself when it is missing, so it is always offered.
|
||||
const tone: StatusTone = status?.running
|
||||
? healthy === false
|
||||
? healthy === false || status.image_outdated
|
||||
? "busy"
|
||||
: "running"
|
||||
: !status?.image_exists
|
||||
? "off"
|
||||
: status.container_exists
|
||||
? "stopped"
|
||||
: "off";
|
||||
: status?.container_exists
|
||||
? "stopped"
|
||||
: "off";
|
||||
|
||||
const statusLabel = status?.running
|
||||
? !status.image_exists
|
||||
? `Running on port ${status.port} (image out of date)`
|
||||
: healthy === false
|
||||
const statusLabel = !status
|
||||
? "Checking…"
|
||||
: status.running
|
||||
? healthy === false
|
||||
? "Starting…"
|
||||
: `Running on port ${status.port}`
|
||||
: !status?.image_exists
|
||||
? "No image"
|
||||
: status.container_exists
|
||||
? "Stopped"
|
||||
: "Image ready";
|
||||
: status.image_outdated
|
||||
? `Running on port ${status.port} — outdated LiteLLM, Stop and Start to update`
|
||||
: `Running on port ${status.port}`
|
||||
: !status.image_exists
|
||||
? "No image — Start will pull it"
|
||||
: status.container_exists
|
||||
? "Stopped"
|
||||
: "Image ready";
|
||||
|
||||
// Rendered in whichever branch is live — only one of them ever mounts.
|
||||
const errorLine = error ? (
|
||||
@@ -258,7 +258,7 @@ export default function GatewaySettings() {
|
||||
{/* ── Container ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||
{(status?.image_exists || status?.running) && (
|
||||
{status && (
|
||||
<Button
|
||||
variant={status.running ? "danger" : "primary"}
|
||||
disabled={loading}
|
||||
|
||||
@@ -486,6 +486,8 @@ export interface GatewayStatus {
|
||||
running: boolean;
|
||||
port: number;
|
||||
image_exists: boolean;
|
||||
/** The container runs something other than the pinned image; Stop + Start recreates it. */
|
||||
image_outdated: boolean;
|
||||
model_count: number;
|
||||
/** Presence only — the provider API key never leaves the keychain. */
|
||||
has_api_key: boolean;
|
||||
|
||||
Reference in new issue
Block a user