diff --git a/src/agent/docker.rs b/src/agent/docker.rs index ba22542..e0b882c 100644 --- a/src/agent/docker.rs +++ b/src/agent/docker.rs @@ -119,6 +119,26 @@ fn name_matches(container_name: &str, app_code: &str) -> bool { false } +/// Stacker's stable service-identity label, set by the control plane on every +/// project service. Preferred over Docker Compose's `com.docker.compose.service` +/// because it carries the app code the control plane resolves by and survives +/// compose service renames (the generated main service is named `app`, but its +/// `my.stacker.service` label is the project code). +const STACKER_SERVICE_LABEL: &str = "my.stacker.service"; +const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service"; + +/// If a container's labels identify it as `app_code`, return which label +/// matched (for logging). Prefers the stacker-owned label over Compose's. +fn label_matches_app(labels: &HashMap, app_code: &str) -> Option<&'static str> { + if labels.get(STACKER_SERVICE_LABEL).map(String::as_str) == Some(app_code) { + return Some(STACKER_SERVICE_LABEL); + } + if labels.get(COMPOSE_SERVICE_LABEL).map(String::as_str) == Some(app_code) { + return Some(COMPOSE_SERVICE_LABEL); + } + None +} + pub async fn resolve_container_name(name: &str) -> Result { let docker = docker_client()?; let opts: Option = @@ -143,15 +163,14 @@ pub async fn resolve_container_name(name: &str) -> Result { available_containers.push(normalized.to_string()); if let Some(labels) = container.labels.as_ref() { - if let Some(service) = labels.get("com.docker.compose.service") { - if service == name { - tracing::info!( - app_code = name, - resolved_name = normalized, - "Container name resolved via compose service label" - ); - return Ok(normalized.to_string()); - } + if let Some(matched_label) = label_matches_app(labels, name) { + tracing::info!( + app_code = name, + resolved_name = normalized, + matched_label, + "Container name resolved via service label" + ); + return Ok(normalized.to_string()); } } @@ -874,6 +893,42 @@ mod tests { assert!(name_matches("/komodo", "komodo")); } + #[test] + fn label_matches_prefers_stacker_service_over_compose() { + let mut labels = HashMap::new(); + // Generated main service: compose service name is "app", but the + // stacker label carries the project code. + labels.insert(COMPOSE_SERVICE_LABEL.to_string(), "app".to_string()); + labels.insert( + STACKER_SERVICE_LABEL.to_string(), + "wordpress-matomo".to_string(), + ); + + // Resolves by the stacker label even though the compose service is "app". + assert_eq!( + label_matches_app(&labels, "wordpress-matomo"), + Some(STACKER_SERVICE_LABEL) + ); + // The Docker Compose service name still resolves. + assert_eq!( + label_matches_app(&labels, "app"), + Some(COMPOSE_SERVICE_LABEL) + ); + // No spurious match. + assert_eq!(label_matches_app(&labels, "matomo"), None); + } + + #[test] + fn label_matches_compose_only_still_works() { + let mut labels = HashMap::new(); + labels.insert(COMPOSE_SERVICE_LABEL.to_string(), "matomo".to_string()); + assert_eq!( + label_matches_app(&labels, "matomo"), + Some(COMPOSE_SERVICE_LABEL) + ); + assert_eq!(label_matches_app(&labels, "app"), None); + } + #[test] fn test_name_matches_replica_suffix() { assert!(name_matches("komodo_1", "komodo")); diff --git a/src/commands/stacker.rs b/src/commands/stacker.rs index 910b16e..e6bcc79 100644 --- a/src/commands/stacker.rs +++ b/src/commands/stacker.rs @@ -8466,11 +8466,25 @@ fn build_probe_result_payload( .map(|protocol| probe_issue_for_protocol(protocol)) .collect::>(); + // Surface the resolved container in the report so callers can act on it — + // e.g. the CLI's direct-container retry when an app-scoped probe finds + // nothing. Without this the report always carried an empty `containers` + // list even after the agent resolved the container internally. + let containers = if container_resolved.is_empty() { + Vec::new() + } else { + vec![json!({ + "name": container_resolved, + "ports": ports.iter().map(|port| port.to_string()).collect::>(), + })] + }; + json!({ "type": "probe_endpoints", "deployment_hash": deployment_hash, "app_code": app_code, "protocols_detected": protocols_detected, + "containers": containers, "endpoints": endpoints, "forms": forms, "diagnostics": ProbeDiagnostics { @@ -12759,6 +12773,10 @@ mod probe_endpoints_command_tests { assert_eq!(payload["type"], "probe_endpoints"); assert_eq!(payload["protocols_detected"], json!([])); + // The resolved container is surfaced so callers (e.g. the CLI's + // direct-container retry) can act on an otherwise-empty probe. + assert_eq!(payload["containers"][0]["name"], "status-panel-web-1"); + assert_eq!(payload["containers"][0]["ports"], json!(["3000"])); assert_eq!( payload["diagnostics"]["protocols_requested"], json!(["html_forms", "rest"])