From 50559bb646c42370a23b715c80715a856a69334d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Thu, 1 Oct 2026 09:40:22 +0200 Subject: [PATCH 1/3] fix: show logs for compose services with replicas For compose services with `deploy.replicas > 1`, Komodo lists each replica as its own service (`web-1`, `web-2`, ...). Stack log requests passed these names straight to `docker compose logs`, which silently returns nothing for unknown services, so the service Log tab was empty. - Add `compose_service` to `StackServiceNames`, set by Periphery on replica entries to the actual compose service name. - GetStackLog / SearchStackLog: a single replica request reads that replica's container log directly; otherwise replica names are mapped back to the compose service before calling `docker compose logs`. Stacks deployed before this change need a redeploy to record the mapping. Closes #1660 Co-Authored-By: Claude Opus 5.5 --- bin/core/src/api/read/stack.rs | 89 +++++++++++++++++++------- bin/core/src/stack/services.rs | 96 ++++++++++++++++++++++++++++ bin/periphery/src/api/swarm/stack.rs | 1 + bin/periphery/src/docker/compose.rs | 42 ++++++++++++ client/core/rs/src/entities/stack.rs | 6 ++ client/core/ts/src/types.ts | 7 ++ ui/public/client/types.d.ts | 7 ++ 7 files changed, 224 insertions(+), 24 deletions(-) diff --git a/bin/core/src/api/read/stack.rs b/bin/core/src/api/read/stack.rs index 4d66d04d75..8e40773cad 100644 --- a/bin/core/src/api/read/stack.rs +++ b/bin/core/src/api/read/stack.rs @@ -19,7 +19,9 @@ use mogh_error::AddStatusCodeError as _; use mogh_resolver::Resolve; use periphery_client::api::{ compose::{GetComposeLog, GetComposeLogSearch}, - container::InspectContainer, + container::{ + GetContainerLog, GetContainerLogSearch, InspectContainer, + }, }; use reqwest::StatusCode; @@ -31,7 +33,10 @@ use crate::{ }, permission::get_check_permissions, resource, - stack::setup_stack_execution, + stack::{ + services::{single_replica_container, to_compose_services}, + setup_stack_execution, + }, state::{action_states, stack_status_cache}, }; @@ -312,16 +317,33 @@ impl Resolve for GetStackLog { .await .context("Failed to get stack service log from swarm")? } - SwarmOrServer::Server(server) => periphery_client(&server) - .await? - .request(GetComposeLog { - project: stack.project_name(false), - services, - tail, - timestamps, - }) - .await - .context("Failed to get stack log from periphery")?, + SwarmOrServer::Server(server) => { + let periphery = periphery_client(&server).await?; + // A single replica of a compose service with `deploy.replicas > 1` + // is not a compose service, so get its container log directly. + if let Some(name) = + single_replica_container(&stack, &services) + { + periphery + .request(GetContainerLog { + name, + tail, + timestamps, + }) + .await + .context("Failed to get stack log from periphery")? + } else { + periphery + .request(GetComposeLog { + project: stack.project_name(false), + services: to_compose_services(&stack, services), + tail, + timestamps, + }) + .await + .context("Failed to get stack log from periphery")? + } + } }; Ok(log) @@ -372,18 +394,37 @@ impl Resolve for SearchStackLog { .await .context("Failed to get stack service log from swarm")? } - SwarmOrServer::Server(server) => periphery_client(&server) - .await? - .request(GetComposeLogSearch { - project: stack.project_name(false), - services, - terms, - combinator, - invert, - timestamps, - }) - .await - .context("Failed to search stack log from periphery")?, + SwarmOrServer::Server(server) => { + let periphery = periphery_client(&server).await?; + // A single replica of a compose service with `deploy.replicas > 1` + // is not a compose service, so search its container log directly. + if let Some(name) = + single_replica_container(&stack, &services) + { + periphery + .request(GetContainerLogSearch { + name, + terms, + combinator, + invert, + timestamps, + }) + .await + .context("Failed to search stack log from periphery")? + } else { + periphery + .request(GetComposeLogSearch { + project: stack.project_name(false), + services: to_compose_services(&stack, services), + terms, + combinator, + invert, + timestamps, + }) + .await + .context("Failed to search stack log from periphery")? + } + } }; Ok(log) diff --git a/bin/core/src/stack/services.rs b/bin/core/src/stack/services.rs index e50afc09b3..0af883850a 100644 --- a/bin/core/src/stack/services.rs +++ b/bin/core/src/stack/services.rs @@ -26,6 +26,46 @@ pub fn extract_services_from_stack( } } +/// If `services` is a single entry Komodo lists for one replica +/// of a compose service with `deploy.replicas > 1` (eg `web-2`), +/// returns the exact container name of that replica. +pub fn single_replica_container( + stack: &Stack, + services: &[String], +) -> Option { + let [service] = services else { + return None; + }; + extract_services_from_stack(stack) + .into_iter() + .find(|s| { + &s.service_name == service && s.compose_service.is_some() + }) + .map(|s| s.container_name) +} + +/// Maps the names Komodo lists for replicas of a compose service +/// with `deploy.replicas > 1` (eg `web-2`) back to the actual +/// compose service name (`web`), for use with `docker compose`. +pub fn to_compose_services( + stack: &Stack, + services: Vec, +) -> Vec { + let known = extract_services_from_stack(stack); + let mut res = Vec::with_capacity(services.len()); + for service in services { + let service = known + .iter() + .find(|s| s.service_name == service) + .and_then(|s| s.compose_service.clone()) + .unwrap_or(service); + if !res.contains(&service) { + res.push(service); + } + } + res +} + pub fn extract_services_into_res( project_name: &str, compose_contents: &str, @@ -67,9 +107,65 @@ pub fn extract_services_into_res( .cloned(), image: image.unwrap_or_default(), service_name, + compose_service: None, }); } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn replica_stack() -> Stack { + let mut stack = Stack::default(); + let service = + |name: &str, compose_service: Option<&str>| StackServiceNames { + service_name: name.to_string(), + container_name: format!("proj-{name}"), + compose_service: compose_service.map(str::to_string), + ..Default::default() + }; + stack.info.deployed_services = Some(vec![ + service("web-1", Some("web")), + service("web-2", Some("web")), + service("db", None), + ]); + stack + } + + #[test] + fn single_replica_resolves_container() { + let stack = replica_stack(); + assert_eq!( + single_replica_container(&stack, &["web-2".to_string()]) + .as_deref(), + Some("proj-web-2") + ); + assert_eq!( + single_replica_container(&stack, &["db".to_string()]), + None + ); + assert_eq!( + single_replica_container( + &stack, + &["web-1".to_string(), "web-2".to_string()] + ), + None + ); + } + + #[test] + fn replicas_map_to_compose_service() { + let stack = replica_stack(); + let services = ["web-1", "web-2", "db", "unknown"] + .map(str::to_string) + .to_vec(); + assert_eq!( + to_compose_services(&stack, services), + ["web", "db", "unknown"] + ); + } +} diff --git a/bin/periphery/src/api/swarm/stack.rs b/bin/periphery/src/api/swarm/stack.rs index 8ce8db0933..4120fe4857 100644 --- a/bin/periphery/src/api/swarm/stack.rs +++ b/bin/periphery/src/api/swarm/stack.rs @@ -283,6 +283,7 @@ impl Resolve for DeploySwarmStack { service_name, image, image_digest: None, + compose_service: None, }); } } diff --git a/bin/periphery/src/docker/compose.rs b/bin/periphery/src/docker/compose.rs index ecc326bee0..6238fd6428 100644 --- a/bin/periphery/src/docker/compose.rs +++ b/bin/periphery/src/docker/compose.rs @@ -100,6 +100,7 @@ pub fn parse_compose_services( service_name: format!("{service_name}-{i}"), image: image.clone(), image_digest: None, + compose_service: Some(service_name.clone()), }); } } @@ -111,6 +112,7 @@ pub fn parse_compose_services( service_name, image, image_digest: None, + compose_service: None, }); } } @@ -118,3 +120,43 @@ pub fn parse_compose_services( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replicas_record_compose_service() { + let config = r#" +services: + web: + image: nginx + deploy: + replicas: 2 + db: + image: postgres +"#; + let mut services = Vec::new(); + parse_compose_services(config, "proj", &mut services).unwrap(); + services.sort_by(|a, b| a.service_name.cmp(&b.service_name)); + + let names = services + .iter() + .map(|s| { + ( + s.service_name.as_str(), + s.container_name.as_str(), + s.compose_service.as_deref(), + ) + }) + .collect::>(); + assert_eq!( + names, + [ + ("db", "proj-db", None), + ("web-1", "proj-web-1", Some("web")), + ("web-2", "proj-web-2", Some("web")), + ] + ); + } +} diff --git a/client/core/rs/src/entities/stack.rs b/client/core/rs/src/entities/stack.rs index 912469e82a..4233e904cc 100644 --- a/client/core/rs/src/entities/stack.rs +++ b/client/core/rs/src/entities/stack.rs @@ -825,6 +825,12 @@ pub struct StackServiceNames { /// Store the associated image digest. /// This includes both the image name / tag, and the specific digest hash. pub image_digest: Option, + /// Set when the compose service has `deploy.replicas > 1`, + /// in which case Komodo lists each replica separately + /// (`service_name` = `{service}-{i}`, `container_name` = exact replica container). + /// This is the actual compose service name to pass to `docker compose`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compose_service: Option, } /// A stack service, whether server or swarm based. diff --git a/client/core/ts/src/types.ts b/client/core/ts/src/types.ts index f17660f498..1a3dee8ae8 100644 --- a/client/core/ts/src/types.ts +++ b/client/core/ts/src/types.ts @@ -2746,6 +2746,13 @@ export interface StackServiceNames { * This includes both the image name / tag, and the specific digest hash. */ image_digest?: ImageDigest; + /** + * Set when the compose service has `deploy.replicas > 1`, + * in which case Komodo lists each replica separately + * (`service_name` = `{service}-{i}`, `container_name` = exact replica container). + * This is the actual compose service name to pass to `docker compose`. + */ + compose_service?: string; } /** diff --git a/ui/public/client/types.d.ts b/ui/public/client/types.d.ts index b95fdd8e72..0fe6692315 100644 --- a/ui/public/client/types.d.ts +++ b/ui/public/client/types.d.ts @@ -2892,6 +2892,13 @@ export interface StackServiceNames { * This includes both the image name / tag, and the specific digest hash. */ image_digest?: ImageDigest; + /** + * Set when the compose service has `deploy.replicas > 1`, + * in which case Komodo lists each replica separately + * (`service_name` = `{service}-{i}`, `container_name` = exact replica container). + * This is the actual compose service name to pass to `docker compose`. + */ + compose_service?: string; } /** * Same as [FileContents] with some extra From 491e8f0995cf987f1b68b083f21d739c2a1b8180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Thu, 1 Oct 2026 09:40:22 +0200 Subject: [PATCH 2/3] fix: match compose replica containers exactly Per-replica service entries already carry the replica number in their `container_name` (eg `proj-web-1`), so the `^{name}-?[0-9]*$` pattern also matched `proj-web-10`..`proj-web-19`, attaching the wrong container to `web-1` for services with 10+ replicas. Match these entries exactly instead. Co-Authored-By: Claude Opus 5.5 --- bin/core/src/api/read/docker.rs | 3 ++- bin/core/src/helpers/query.rs | 4 ++-- bin/core/src/monitor/resources.rs | 4 ++-- bin/core/src/stack/mod.rs | 32 ++++++++++++++++++++++++++++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/bin/core/src/api/read/docker.rs b/bin/core/src/api/read/docker.rs index 9a50cd5d6e..e5bf3eb93e 100644 --- a/bin/core/src/api/read/docker.rs +++ b/bin/core/src/api/read/docker.rs @@ -287,13 +287,14 @@ impl Resolve for GetResourceMatchingContainer { for StackServiceNames { service_name, container_name, + compose_service, .. } in stack .info .deployed_services .unwrap_or(stack.info.latest_services) { - let is_match = match compose_container_match_regex(&container_name) + let is_match = match compose_container_match_regex(&container_name, compose_service.is_some()) .with_context(|| format!("failed to construct container name matching regex for service {service_name}")) { Ok(regex) => regex, diff --git a/bin/core/src/helpers/query.rs b/bin/core/src/helpers/query.rs index 536aaee320..df1c9a4b15 100644 --- a/bin/core/src/helpers/query.rs +++ b/bin/core/src/helpers/query.rs @@ -167,8 +167,8 @@ pub fn get_stack_state_from_containers( }) .collect::>(); let containers = containers.iter().filter(|container| { - services.iter().any(|StackServiceNames { service_name, container_name, .. }| { - match compose_container_match_regex(container_name) + services.iter().any(|StackServiceNames { service_name, container_name, compose_service, .. }| { + match compose_container_match_regex(container_name, compose_service.is_some()) .with_context(|| format!("failed to construct container name matching regex for service {service_name}")) { Ok(regex) => regex, diff --git a/bin/core/src/monitor/resources.rs b/bin/core/src/monitor/resources.rs index a812e75627..d4928bd343 100644 --- a/bin/core/src/monitor/resources.rs +++ b/bin/core/src/monitor/resources.rs @@ -152,10 +152,10 @@ pub async fn update_server_stack_cache( let stack_status_cache = stack_status_cache(); for stack in stacks { let services = extract_services_from_stack(&stack); - let mut services_with_containers = services.iter().map(|StackServiceNames { service_name, container_name, image, .. }| { + let mut services_with_containers = services.iter().map(|StackServiceNames { service_name, container_name, image, compose_service, .. }| { // Get the container associated with service. let container = containers.iter().find(|container| { - match compose_container_match_regex(container_name) + match compose_container_match_regex(container_name, compose_service.is_some()) .with_context(|| format!("failed to construct container name matching regex for service {service_name}")) { Ok(regex) => regex, diff --git a/bin/core/src/stack/mod.rs b/bin/core/src/stack/mod.rs index acd5f479b7..91519da56c 100644 --- a/bin/core/src/stack/mod.rs +++ b/bin/core/src/stack/mod.rs @@ -31,11 +31,41 @@ pub async fn setup_stack_execution( Ok((stack, swarm_or_server)) } +/// `exact` should be set for the entries Komodo lists per replica +/// of a compose service with `deploy.replicas > 1`, whose `container_name` +/// already includes the replica number. Otherwise eg `web-1` would also +/// match the containers of replicas `web-10`, `web-11`, ... pub fn compose_container_match_regex( container_name: &str, + exact: bool, ) -> anyhow::Result { - let regex = format!("^{container_name}-?[0-9]*$"); + let regex = if exact { + format!("^{container_name}$") + } else { + format!("^{container_name}-?[0-9]*$") + }; Regex::new(®ex).with_context(|| { format!("failed to construct valid regex from {regex}") }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn container_match_regex() { + let regex = + compose_container_match_regex("proj-web", false).unwrap(); + assert!(regex.is_match("proj-web")); + assert!(regex.is_match("proj-web-1")); + assert!(regex.is_match("proj-web-12")); + + // Replica entries already include the replica number. + let regex = + compose_container_match_regex("proj-web-1", true).unwrap(); + assert!(regex.is_match("proj-web-1")); + assert!(!regex.is_match("proj-web-10")); + assert!(!regex.is_match("proj-web-1-1")); + } +} From 5652cff11a295b415a4160a8347022c2b43c5b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Thu, 1 Oct 2026 09:40:22 +0200 Subject: [PATCH 3/3] fix: add project prefix when searching Swarm stack logs SearchStackLog passed the bare compose service name to `docker service logs`, while Swarm names stack services `{project}_{service}` (as GetStackLog already handles), so searching Swarm stack logs always came back empty. Co-Authored-By: Claude Opus 5.5 --- bin/core/src/api/read/stack.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/core/src/api/read/stack.rs b/bin/core/src/api/read/stack.rs index 8e40773cad..408081506e 100644 --- a/bin/core/src/api/read/stack.rs +++ b/bin/core/src/api/read/stack.rs @@ -381,7 +381,11 @@ impl Resolve for SearchStackLog { swarm_request( &swarm.config.server_ids, periphery_client::api::swarm::GetSwarmServiceLogSearch { - service, + // The actual service name on swarm will be stackname_servicename + service: format!( + "{}_{service}", + stack.project_name(false) + ), terms, combinator, invert,