diff --git a/bin/core/src/api/read/docker.rs b/bin/core/src/api/read/docker.rs index 9a50cd5d6..e5bf3eb93 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/api/read/stack.rs b/bin/core/src/api/read/stack.rs index 4d66d04d7..408081506 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) @@ -359,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, @@ -372,18 +398,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/helpers/query.rs b/bin/core/src/helpers/query.rs index 536aaee32..df1c9a4b1 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 a812e7562..d4928bd34 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 acd5f479b..91519da56 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")); + } +} diff --git a/bin/core/src/stack/services.rs b/bin/core/src/stack/services.rs index e50afc09b..0af883850 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 8ce8db093..4120fe485 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 ecc326bee..6238fd642 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 912469e82..4233e904c 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 f17660f49..1a3dee8ae 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 b95fdd8e7..0fe669231 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