diff --git a/app/src-tauri/src/docker/gateway.rs b/app/src-tauri/src/docker/gateway.rs index af75326..e5a118c 100644 --- a/app/src-tauri/src/docker/gateway.rs +++ b/app/src-tauri/src/docker/gateway.rs @@ -79,10 +79,12 @@ fn pinned_litellm_version() -> &'static str { fn local_image_ref() -> String { static REF: OnceLock = OnceLock::new(); REF.get_or_init(|| { + // config.yaml is hashed whole: a `#` line can sit inside a block + // scalar there, and it is rarely edited. let context = sha256_hex(&format!( "{}\0{}", significant_lines(GATEWAY_DOCKERFILE), - significant_lines(GATEWAY_DEFAULT_CONFIG) + GATEWAY_DEFAULT_CONFIG )); format!( "{}:{}-{}", @@ -94,15 +96,25 @@ fn local_image_ref() -> String { .clone() } -/// The build context minus full-line comments and blank lines, so rewording a +/// The Dockerfile minus full-line comments and blank lines, so rewording a /// comment in a later release doesn't make every local build look outdated. -/// (Both files use `#` for comments.) +/// Parser directives (`# syntax=`, `# escape=`, `# check=`) look like comments +/// but change the build, so they are kept. fn significant_lines(text: &str) -> String { text.lines() .map(str::trim_end) .filter(|l| { let t = l.trim_start(); - !t.is_empty() && !t.starts_with('#') + if t.is_empty() { + return false; + } + match t.strip_prefix('#') { + None => true, + Some(rest) => rest + .split_once('=') + .map(|(key, _)| key.trim()) + .is_some_and(|key| matches!(key, "syntax" | "escape" | "check")), + } }) .collect::>() .join("\n") @@ -175,31 +187,36 @@ async fn pinned_images() -> Result { registry: None, }) } - (Err(e), Ok(Some(registry))) => { - log::warn!("{}", e); - Ok(PinnedImages { - local: None, - registry: Some(registry), - }) - } + // But a failed lookup of the preferred local build leaves the answer + // unknown even when the registry image is there: choosing the + // registry image would swap a working local-build gateway over. (Err(e), _) | (_, Err(e)) => Err(e), } } -/// Whether an existing gateway container runs something other than the image -/// `ensure` would start it on. With a pinned image present (`Some(Some(_))`) -/// that is exactly when the next start or settings save recreates it. With no -/// pinned image (`Some(None)`) it is outdated but `ensure` keeps it as is until -/// one is pulled or built. `None` means the image check failed: unknown is -/// reported as not outdated rather than guessed. +/// Whether the container should be moved onto `pinned` — the image half of +/// [`needs_recreate`]. A container whose image id is unknown counts as not on +/// it, in both places. +fn image_switch_pending(existing_image_id: Option<&str>, pinned: &ChosenImage) -> bool { + existing_image_id != Some(pinned.id.as_str()) +} + +/// Status's `image_outdated`: the container exists and isn't on a pinned +/// image. With a pinned image present this is exactly [`image_switch_pending`] +/// — what the next start or settings save acts on. With none (`Some(None)`) it +/// is outdated, but `ensure` keeps it as is until one is pulled or built. +/// `None` means the image check failed: unknown is reported as not outdated +/// rather than guessed. fn container_image_outdated( + container_exists: bool, container_image_id: Option<&str>, chosen: Option>, ) -> bool { - match (container_image_id, chosen) { - (Some(_), Some(None)) => true, - (Some(running), Some(Some(chosen))) => running != chosen.id, - _ => false, + match chosen { + _ if !container_exists => false, + Some(Some(pinned)) => image_switch_pending(container_image_id, pinned), + Some(None) => true, + None => false, } } @@ -370,7 +387,7 @@ fn needs_recreate( pinned: Option<&ChosenImage>, ) -> bool { existing_fingerprint != fingerprint - || pinned.is_some_and(|p| existing_image_id != Some(p.id.as_str())) + || pinned.is_some_and(|p| image_switch_pending(existing_image_id, p)) } fn sha256_hex(input: &str) -> String { @@ -395,6 +412,7 @@ pub async fn get_gateway_status(settings: &GatewaySettings) -> Result (false, false), }; let image_outdated = container_image_outdated( + container_exists, container.as_ref().and_then(|c| c.image_id.as_deref()), chosen.as_ref().map(|c| c.as_ref()), ); @@ -912,7 +930,8 @@ where // A pull by digest leaves the image untagged, which `docker image prune` // treats as dangling and deletes. Tag it locally; lookups still go by // digest, so a re-pushed upstream tag changes nothing. - get_docker()? + // Not fatal: the image is present and will be used either way. + if let Err(e) = get_docker()? .tag_image( ®istry_image_ref(), Some(bollard::image::TagImageOptions { @@ -921,7 +940,13 @@ where }), ) .await - .map_err(|e| format!("Pulled the gateway image but could not tag it: {}", e)) + { + log::warn!( + "Pulled the gateway image but could not tag it (a prune may remove it): {}", + e + ); + } + Ok(()) } pub async fn build_gateway_image(on_progress: F) -> Result<(), String> @@ -1075,13 +1100,17 @@ mod tests { #[test] fn outdated_matches_what_ensure_would_recreate() { let pinned = chosen(®istry_image_ref(), "sha256:new"); - assert!(!container_image_outdated(Some("sha256:new"), Some(Some(&pinned)))); - assert!(container_image_outdated(Some("sha256:old"), Some(Some(&pinned)))); + assert!(!container_image_outdated(true, Some("sha256:new"), Some(Some(&pinned)))); + assert!(container_image_outdated(true, Some("sha256:old"), Some(Some(&pinned)))); // No pinned image at all: whatever runs is not it. - assert!(container_image_outdated(Some("sha256:old"), Some(None))); + assert!(container_image_outdated(true, Some("sha256:old"), Some(None))); // No container, or the check failed: don't claim it. - assert!(!container_image_outdated(None, Some(Some(&pinned)))); - assert!(!container_image_outdated(Some("sha256:old"), None)); + assert!(!container_image_outdated(false, None, Some(Some(&pinned)))); + assert!(!container_image_outdated(true, Some("sha256:old"), None)); + // A container whose image id is unknown: status and ensure agree + // that it isn't on the pinned image. + assert!(container_image_outdated(true, None, Some(Some(&pinned)))); + assert!(needs_recreate("fp", "fp", None, Some(&pinned))); } #[test] @@ -1090,6 +1119,11 @@ mod tests { let b = "# reworded\nFROM x\nRUN y\n# trailing\n"; assert_eq!(significant_lines(a), significant_lines(b)); assert_ne!(significant_lines(a), significant_lines("FROM x\nRUN z\n")); + // A parser directive changes the build. + assert_ne!( + significant_lines(b), + significant_lines(&format!("# syntax=docker/dockerfile:1.7\n{b}")) + ); } #[test]