Skip to content

Commit 4c85a29

Browse files
feat: implement depends_on conditions (service_started, service_healthy)
Add ServiceDependency and DependencyCondition types to support Docker Compose v3 depends_on semantics. The default condition service_started means a running dependency is sufficient regardless of health check status, while service_healthy requires health checks to pass first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5bffef6 commit 4c85a29

8 files changed

Lines changed: 300 additions & 67 deletions

File tree

crates/vz-stack/src/compose.rs

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use serde_yml::Value;
1212

1313
use crate::error::StackError;
1414
use crate::spec::{
15-
HealthCheckSpec, MountSpec, PortSpec, ResourcesSpec, RestartPolicy, SecretDef,
16-
ServiceSecretRef, ServiceSpec, StackSpec, VolumeSpec,
15+
DependencyCondition, HealthCheckSpec, MountSpec, PortSpec, ResourcesSpec, RestartPolicy,
16+
SecretDef, ServiceDependency, ServiceSecretRef, ServiceSpec, StackSpec, VolumeSpec,
1717
};
1818

1919
// ── Accepted key sets ──────────────────────────────────────────────
@@ -213,10 +213,10 @@ fn parse_compose_inner(
213213
let service_names: Vec<&str> = services.iter().map(|s| s.name.as_str()).collect();
214214
for svc in &services {
215215
for dep in &svc.depends_on {
216-
if !service_names.contains(&dep.as_str()) {
216+
if !service_names.contains(&dep.service.as_str()) {
217217
return Err(StackError::ComposeValidation(format!(
218218
"service `{}` depends on `{}` which is not defined",
219-
svc.name, dep,
219+
svc.name, dep.service,
220220
)));
221221
}
222222
}
@@ -775,7 +775,10 @@ fn parse_mount_long(svc_name: &str, obj: &serde_yml::Mapping) -> Result<MountSpe
775775
}
776776

777777
/// Parse `depends_on` which can be a list of strings or a mapping with conditions.
778-
fn parse_depends_on(svc_name: &str, map: &serde_yml::Mapping) -> Result<Vec<String>, StackError> {
778+
fn parse_depends_on(
779+
svc_name: &str,
780+
map: &serde_yml::Mapping,
781+
) -> Result<Vec<ServiceDependency>, StackError> {
779782
let Some(value) = map.get(val("depends_on")) else {
780783
return Ok(vec![]);
781784
};
@@ -784,22 +787,28 @@ fn parse_depends_on(svc_name: &str, map: &serde_yml::Mapping) -> Result<Vec<Stri
784787
// Simple list: depends_on: [db, cache]
785788
seq.iter()
786789
.map(|v| {
787-
v.as_str().map(String::from).ok_or_else(|| {
788-
StackError::ComposeParse(format!(
789-
"service `{svc_name}`: `depends_on` items must be strings"
790-
))
791-
})
790+
v.as_str()
791+
.map(ServiceDependency::started)
792+
.ok_or_else(|| {
793+
StackError::ComposeParse(format!(
794+
"service `{svc_name}`: `depends_on` items must be strings"
795+
))
796+
})
792797
})
793798
.collect()
794799
} else if let Some(obj) = value.as_mapping() {
795800
// Conditional form: depends_on: { db: { condition: service_healthy } }
796-
// We accept both forms but treat all conditions as service_started.
797-
obj.keys()
798-
.map(|k| {
799-
k.as_str().map(String::from).ok_or_else(|| {
801+
obj.iter()
802+
.map(|(k, v)| {
803+
let dep_name = k.as_str().ok_or_else(|| {
800804
StackError::ComposeParse(format!(
801805
"service `{svc_name}`: `depends_on` keys must be strings"
802806
))
807+
})?;
808+
let condition = parse_dependency_condition(svc_name, dep_name, v)?;
809+
Ok(ServiceDependency {
810+
service: dep_name.to_string(),
811+
condition,
803812
})
804813
})
805814
.collect()
@@ -810,6 +819,40 @@ fn parse_depends_on(svc_name: &str, map: &serde_yml::Mapping) -> Result<Vec<Stri
810819
}
811820
}
812821

822+
/// Extract the condition from a depends_on mapping value.
823+
///
824+
/// The value can be a mapping like `{ condition: service_healthy }` or
825+
/// omitted/empty (defaults to `service_started`).
826+
fn parse_dependency_condition(
827+
svc_name: &str,
828+
dep_name: &str,
829+
value: &serde_yml::Value,
830+
) -> Result<DependencyCondition, StackError> {
831+
let Some(obj) = value.as_mapping() else {
832+
// No condition specified → default.
833+
return Ok(DependencyCondition::ServiceStarted);
834+
};
835+
836+
let Some(cond_val) = obj.get(val("condition")) else {
837+
return Ok(DependencyCondition::ServiceStarted);
838+
};
839+
840+
let cond_str = cond_val.as_str().ok_or_else(|| {
841+
StackError::ComposeParse(format!(
842+
"service `{svc_name}`: depends_on `{dep_name}` condition must be a string"
843+
))
844+
})?;
845+
846+
match cond_str {
847+
"service_started" => Ok(DependencyCondition::ServiceStarted),
848+
"service_healthy" => Ok(DependencyCondition::ServiceHealthy),
849+
"service_completed_successfully" => Ok(DependencyCondition::ServiceCompletedSuccessfully),
850+
other => Err(StackError::ComposeParse(format!(
851+
"service `{svc_name}`: depends_on `{dep_name}` unknown condition `{other}`"
852+
))),
853+
}
854+
}
855+
813856
/// Parse healthcheck configuration.
814857
fn parse_healthcheck(
815858
svc_name: &str,
@@ -1925,11 +1968,11 @@ services:
19251968
"#;
19261969
let spec = parse_compose(yaml, "myapp").unwrap();
19271970
let web = spec.services.iter().find(|s| s.name == "web").unwrap();
1928-
assert_eq!(web.depends_on, vec!["db".to_string()]);
1971+
assert_eq!(web.depends_on, vec![ServiceDependency::started("db")]);
19291972
}
19301973

19311974
#[test]
1932-
fn depends_on_mapping_form() {
1975+
fn depends_on_mapping_form_service_healthy() {
19331976
let yaml = r#"
19341977
services:
19351978
db:
@@ -1942,7 +1985,40 @@ services:
19421985
"#;
19431986
let spec = parse_compose(yaml, "myapp").unwrap();
19441987
let web = spec.services.iter().find(|s| s.name == "web").unwrap();
1945-
assert_eq!(web.depends_on, vec!["db".to_string()]);
1988+
assert_eq!(web.depends_on, vec![ServiceDependency::healthy("db")]);
1989+
}
1990+
1991+
#[test]
1992+
fn depends_on_mapping_form_service_started() {
1993+
let yaml = r#"
1994+
services:
1995+
db:
1996+
image: postgres:15
1997+
web:
1998+
image: nginx:latest
1999+
depends_on:
2000+
db:
2001+
condition: service_started
2002+
"#;
2003+
let spec = parse_compose(yaml, "myapp").unwrap();
2004+
let web = spec.services.iter().find(|s| s.name == "web").unwrap();
2005+
assert_eq!(web.depends_on, vec![ServiceDependency::started("db")]);
2006+
}
2007+
2008+
#[test]
2009+
fn depends_on_mapping_form_no_condition() {
2010+
let yaml = r#"
2011+
services:
2012+
db:
2013+
image: postgres:15
2014+
web:
2015+
image: nginx:latest
2016+
depends_on:
2017+
db: {}
2018+
"#;
2019+
let spec = parse_compose(yaml, "myapp").unwrap();
2020+
let web = spec.services.iter().find(|s| s.name == "web").unwrap();
2021+
assert_eq!(web.depends_on, vec![ServiceDependency::started("db")]);
19462022
}
19472023

19482024
// ── Healthcheck parsing ───────────────────────────────────────
@@ -2555,7 +2631,7 @@ volumes:
25552631

25562632
// Web service.
25572633
let web = &spec.services[1];
2558-
assert_eq!(web.depends_on, vec!["redis".to_string()]);
2634+
assert_eq!(web.depends_on, vec![ServiceDependency::started("redis")]);
25592635
assert_eq!(
25602636
web.environment.get("REDIS_URL").unwrap(),
25612637
"redis://redis:6379"

crates/vz-stack/src/convert.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,9 @@ mod tests {
207207
#![allow(clippy::unwrap_used)]
208208

209209
use super::*;
210-
use crate::spec::{HealthCheckSpec, MountSpec as StackMountSpec, RestartPolicy};
210+
use crate::spec::{
211+
HealthCheckSpec, MountSpec as StackMountSpec, RestartPolicy, ServiceDependency,
212+
};
211213
use crate::volume::{ResolvedMount, ResolvedMountKind};
212214
use std::collections::HashMap;
213215

@@ -513,7 +515,7 @@ mod tests {
513515
host_port: None,
514516
},
515517
],
516-
depends_on: vec!["db".to_string()],
518+
depends_on: vec![ServiceDependency::started("db")],
517519
healthcheck: Some(HealthCheckSpec {
518520
test: vec![
519521
"CMD".to_string(),

crates/vz-stack/src/health.rs

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use tracing::{debug, info, warn};
1616
use crate::error::StackError;
1717
use crate::events::StackEvent;
1818
use crate::executor::ContainerRuntime;
19-
use crate::spec::{HealthCheckSpec, ServiceSpec, StackSpec};
19+
use crate::spec::{DependencyCondition, HealthCheckSpec, ServiceSpec, StackSpec};
2020
use crate::state_store::{ServiceObservedState, ServicePhase, StateStore};
2121

2222
/// Result of checking whether a service's dependencies are satisfied.
@@ -103,11 +103,14 @@ pub fn is_service_ready(
103103
/// only blocks when:
104104
///
105105
/// - It is in a terminal state (`Failed` / `Stopped`).
106-
/// - It is `Running` with a health check that has not yet passed.
106+
/// - The condition is `service_healthy` and the health check has
107+
/// not yet passed.
108+
/// - The condition is `service_completed_successfully` and the
109+
/// service has not exited with code 0.
107110
///
108-
/// This means a fresh deployment creates all services in one
109-
/// topo-sorted pass, while health-checked dependencies gate their
110-
/// dependents across apply cycles.
111+
/// With the default `service_started` condition, a running service
112+
/// is considered ready regardless of health check status — matching
113+
/// Docker Compose semantics.
111114
pub fn check_dependencies(
112115
service: &ServiceSpec,
113116
observed: &[ServiceObservedState],
@@ -128,10 +131,10 @@ pub fn check_dependencies(
128131

129132
let mut waiting_on = Vec::new();
130133

131-
for dep_name in &service.depends_on {
132-
let dep_obs = observed_map.get(dep_name.as_str());
133-
let dep_spec = spec_map.get(dep_name.as_str());
134-
let dep_health = health_statuses.get(dep_name);
134+
for dep in &service.depends_on {
135+
let dep_obs = observed_map.get(dep.service.as_str());
136+
let dep_spec = spec_map.get(dep.service.as_str());
137+
let dep_health = health_statuses.get(&dep.service);
135138

136139
let blocked = match dep_obs {
137140
None => {
@@ -141,21 +144,32 @@ pub fn check_dependencies(
141144
Some(obs) => match obs.phase {
142145
// Terminal states block dependent creation.
143146
ServicePhase::Failed | ServicePhase::Stopped => true,
144-
// Running: block only if there's a health check that hasn't passed.
145-
ServicePhase::Running => {
146-
let healthcheck = dep_spec.and_then(|s| s.healthcheck.as_ref());
147-
match healthcheck {
148-
None => false, // Running + no health check = ready.
149-
Some(hc) => !is_service_ready(obs, Some(hc), dep_health),
147+
// Running: behaviour depends on the condition.
148+
ServicePhase::Running => match dep.condition {
149+
DependencyCondition::ServiceStarted => {
150+
// Running is sufficient — don't check health.
151+
false
150152
}
151-
}
153+
DependencyCondition::ServiceHealthy => {
154+
// Must have a passing health check.
155+
let healthcheck = dep_spec.and_then(|s| s.healthcheck.as_ref());
156+
match healthcheck {
157+
None => false, // No health check defined = ready.
158+
Some(hc) => !is_service_ready(obs, Some(hc), dep_health),
159+
}
160+
}
161+
DependencyCondition::ServiceCompletedSuccessfully => {
162+
// Running means not completed yet → blocked.
163+
true
164+
}
165+
},
152166
// Pending/Creating/Stopping — in progress, don't block.
153167
_ => false,
154168
},
155169
};
156170

157171
if blocked {
158-
waiting_on.push(dep_name.clone());
172+
waiting_on.push(dep.service.clone());
159173
}
160174
}
161175

@@ -433,6 +447,7 @@ mod tests {
433447
#![allow(clippy::unwrap_used)]
434448

435449
use super::*;
450+
use crate::spec::ServiceDependency;
436451
use std::collections::HashMap;
437452

438453
fn svc(name: &str) -> ServiceSpec {
@@ -457,7 +472,14 @@ mod tests {
457472

458473
fn svc_with_deps(name: &str, deps: Vec<&str>) -> ServiceSpec {
459474
ServiceSpec {
460-
depends_on: deps.into_iter().map(String::from).collect(),
475+
depends_on: deps.into_iter().map(ServiceDependency::started).collect(),
476+
..svc(name)
477+
}
478+
}
479+
480+
fn svc_with_healthy_deps(name: &str, deps: Vec<&str>) -> ServiceSpec {
481+
ServiceSpec {
482+
depends_on: deps.into_iter().map(ServiceDependency::healthy).collect(),
461483
..svc(name)
462484
}
463485
}
@@ -644,12 +666,25 @@ mod tests {
644666
}
645667

646668
#[test]
647-
fn dep_running_with_healthcheck_failing_is_blocked() {
669+
fn dep_running_with_healthcheck_service_started_is_ready() {
670+
// service_started condition: running is sufficient, healthcheck irrelevant.
648671
let service = svc_with_deps("web", vec!["db"]);
649672
let all_services = vec![svc_with_healthcheck("db"), service.clone()];
650673
let observed = vec![obs("db", ServicePhase::Running)];
651674

652-
// No health status means health check hasn't passed yet.
675+
// No health status — but condition is service_started, so not blocked.
676+
let result = check_dependencies(&service, &observed, &all_services, &HashMap::new());
677+
assert_eq!(result, DependencyCheck::Ready);
678+
}
679+
680+
#[test]
681+
fn dep_running_with_healthcheck_service_healthy_blocks() {
682+
// service_healthy condition: must wait for health check to pass.
683+
let service = svc_with_healthy_deps("web", vec!["db"]);
684+
let all_services = vec![svc_with_healthcheck("db"), service.clone()];
685+
let observed = vec![obs("db", ServicePhase::Running)];
686+
687+
// No health status means health check hasn't passed yet → blocked.
653688
let result = check_dependencies(&service, &observed, &all_services, &HashMap::new());
654689
assert_eq!(
655690
result,
@@ -660,8 +695,9 @@ mod tests {
660695
}
661696

662697
#[test]
663-
fn dep_running_with_healthcheck_passing_is_ready() {
664-
let service = svc_with_deps("web", vec!["db"]);
698+
fn dep_running_with_healthcheck_passing_service_healthy_is_ready() {
699+
// service_healthy condition + health check passed → ready.
700+
let service = svc_with_healthy_deps("web", vec!["db"]);
665701
let all_services = vec![svc_with_healthcheck("db"), service.clone()];
666702
let observed = vec![obs("db", ServicePhase::Running)];
667703

crates/vz-stack/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ pub use orchestrator::{OrchestrationConfig, OrchestrationResult, RoundReport, St
3939
pub use reconcile::{Action, ApplyResult, DeferredService, apply};
4040
pub use restart::{RestartTracker, compute_restarts};
4141
pub use spec::{
42-
HealthCheckSpec, MountSpec, NetworkSpec, PortSpec, ResourcesSpec, RestartPolicy, SecretDef,
43-
ServiceSecretRef, ServiceSpec, StackSpec, VolumeSpec,
42+
DependencyCondition, HealthCheckSpec, MountSpec, NetworkSpec, PortSpec, ResourcesSpec,
43+
RestartPolicy, SecretDef, ServiceDependency, ServiceSecretRef, ServiceSpec, StackSpec,
44+
VolumeSpec,
4445
};
4546
pub use state_store::{ServiceObservedState, ServicePhase, StateStore};
4647
pub use volume::{

crates/vz-stack/src/orchestrator.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ mod tests {
280280

281281
use super::*;
282282
use crate::executor::tests_support::MockContainerRuntime;
283-
use crate::spec::{HealthCheckSpec, ServiceSpec, StackSpec};
283+
use crate::spec::{HealthCheckSpec, ServiceDependency, ServiceSpec, StackSpec};
284284

285285
fn svc(name: &str) -> ServiceSpec {
286286
ServiceSpec {
@@ -304,7 +304,7 @@ mod tests {
304304

305305
fn svc_with_deps(name: &str, deps: Vec<&str>) -> ServiceSpec {
306306
ServiceSpec {
307-
depends_on: deps.into_iter().map(String::from).collect(),
307+
depends_on: deps.into_iter().map(ServiceDependency::started).collect(),
308308
..svc(name)
309309
}
310310
}

0 commit comments

Comments
 (0)