Skip to content

Commit 205f0dd

Browse files
committed
fix: close what a second adversarial pass found
Three reviewers went over the previous round in isolated worktrees. Two of the findings are attack surface the previous round introduced. **`health_file` was an arbitrary-file read whose contents came back to an anonymous caller.** `tokio::fs::File::open` follows symlinks and the agent runs as root. The path is measured into the compose hash; what sits *at* that path at runtime is not, and the docs tell the app to bind-mount the directory into a container -- so the container can replace it with a symlink afterwards. The two-line parser then quoted what it found: a line that did not parse as a timestamp came back as `"...is not a unix time in seconds: <that line>"`, 64 bytes at a time, on the unauthenticated 0.0.0.0:8090 listener. The errno was an oracle for any path on top of that. Opened `O_NOFOLLOW` now, refused unless it is a regular file, and the contents are never quoted -- a verdict names the rule that failed, which is what an operator needs and is not an exfiltration channel. **A FIFO at that path killed the agent's blocking pool, silently.** `tokio::fs` opens on `spawn_blocking`, and a blocking task cannot be cancelled, so `open(2)` on a reader-less FIFO parks that thread forever while the timeout merely drops the future. One thread per refresh, 1 per 15s, pool dead in about two hours -- after which no `tokio::fs` call in the root agent completes. The watchdog would not catch it: it probes over HTTP and never touches the pool. `O_NONBLOCK` makes that open return immediately. The test for it hangs if the flag is removed, which is how it was confirmed. **`COMPOSE_PROJECT_NAME` defeated the container path entirely.** Measured on docker compose 5.1.4 and nerdctl 2.3.5: both honour that env var, and it outranks the compose file's top-level `name:`. The app supplies it through `.decrypted-env`, so deriving the project from the file was wrong exactly when an app sets one -- an empty container list, which `judge` reads as "nothing started yet", i.e. permanently unhealthy with a message saying the opposite. `NERDCTL_NAMESPACE` had the same shape. So `app-compose.sh` records the namespace and the project it actually used -- resolved by `docker compose config`, which applies the real precedence -- and the agent reads that instead of guessing. Its absence is now meaningful too: the file is written before anything starts, so "not there yet" is the boot window rather than a guess that happens to find nothing. That also deletes the YAML parsing this branch added, and its dependency: the compose file is no longer parsed by the agent at all. **The poll round had no budget.** Measured against a listener that accepts and never answers: `ceil(n/16) * 2s`, so 256 mute instances make a 33s round, and `MissedTickBehavior::Delay` then makes that the interval -- one tenant's mute CVMs become every other tenant's detection latency, permanently, because being marked unhealthy does not remove an instance from the target list. The round now gets one interval and rotates where it starts, so nothing starves, and it says how many it did not reach rather than truncating quietly. The rest, smaller: - The snapshot is rewritten in full whenever any verdict changes, so one flapping instance rewrote the whole fleet's file every round -- ~39 KB/s at 2000 instances, paid by the operator. Debounced; losing 30s of it costs one poll. - `select_targets` held the routing mutex across `latest_handshakes`, which clones every peer's key (3.9 ms at 5000 instances) and on a cold cache shells out to `wg show` synchronously. Fetched before the lock now. - `warn_all_unhealthy`'s rate limit was a process-global mutex taken on the per-connection path, so one tenant's fail-open serialized against another tenant's connections. The state moved into `ProxyStateMut`, which the caller already holds. - `sanitize` mixed character counts and byte counts three ways: a 400-character multi-byte reason was cut to 512 bytes with no truncation marker. And `char::is_control` is category Cc only, so U+2028, U+2029 and the bidi overrides survived it -- U+2028 is a line break to most log viewers, which is the log forging the function exists to prevent. Both copies now share `dstack_types::sanitize_for_log`. - `HealthStore::restore` could hand a verdict to an instance the record says is not gated, leaving it out of rotation *and* out of the poll set with nothing able to lift it. The record wins. - `Info()` carries the whole app-compose, so 1 MiB was too tight for the port-policy client; the guest-facing bound is sized for that call, in one place both gateway clients go through. The VMM's vsock client to a guest agent was unbounded and now is not -- that peer is a tenant's CVM and the VMM is the control plane for every other tenant on the host. - Deploy-time validation rejected the wrong-runner case but not the likelier one: `enabled` with no `health_file` and no service declaring a `healthcheck:`, which reports healthy forever. - Go could not express `health_file: ""` the way the other three SDKs can, so it hashed differently. Tests for the four things a mutation showed nothing covered: the `requirements.health_check.enabled` -> `RegisterCvmRequest` hop (the only hop between the manifest and the gateway, and making it inert left all 95 tests green), the two guest clients opting into the response bound, `record_instance_health`'s return value (the sole trigger for the snapshot), and the snapshot's wiring at both ends. Two more asserted a constant against itself and now assert the literal `dstack` that `WorkingDirectory=/dstack` implies. Docs corrected where they were wrong rather than merely thin: `requirements` only fails an older guest closed if the deployer writes `"manifest_version": "3"` as a string, since `AppCompose` is not `deny_unknown_fields`; a single-file bind mount plus write-then-rename cancel out, because the rename never touches the inode the agent holds; and the changelog described a transport-wide 1 MiB cap that was deliberately not what shipped.
1 parent a1c2cad commit 205f0dd

23 files changed

Lines changed: 913 additions & 286 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796)
1212
- gateway: `Admin.SetInstanceReady` takes a CVM instance out of its app's load-balancing rotation without stopping it; instance-id routing stays open so the instance can still be investigated, and the setting survives re-registration
1313
- gateway: opt-in application-level health polling. An app sets `requirements.health_check.enabled` in its app-compose; the gateway then asks that CVM's guest agent (new `Worker.Health` RPC) whether the app is serving, and keeps instances that say no -- or that have not answered since registering -- out of app-id load balancing. Apps that do not opt in are never polled. Instance-id routing is never gated, and an app whose every instance reports unhealthy is routed to anyway rather than blackholed. Documented in `docs/app-health-checks.md`
14-
- app-compose: `requirements.health_check.health_file` names a file the app writes its own verdict into -- two lines, `healthy`/`unhealthy` and the unix timestamp it was written at, treated as unhealthy once older than 60s. Without it the agent judges the app's own Compose project: every container that declares a `healthcheck` must be running and healthy
14+
- app-compose: `requirements.health_check.health_file` names a file the app writes its own verdict into -- two lines, `healthy`/`unhealthy` and the unix timestamp it was written at, treated as unhealthy once older than 60s. It must be a regular file and is opened `O_NOFOLLOW`, and its contents are never quoted back into a report. Without it the agent judges the app's own Compose project: every container that declares a `healthcheck` must be running and healthy
1515
- guest-agent: container health also covers the `nerdctl-compose` runner, read through `nerdctl inspect` (its output is Docker-compatible). Requires nerdctl >= 2.3.1 for Compose `healthcheck:` to be honoured; the mkosi backend is pinned to 2.3.5
16-
- http-client: response bodies are capped at 1 MiB. Several callers talk to peers they do not control, and one of them now does it on a timer against the whole fleet
16+
- http-client: a caller can bound the response body (`http_request_bounded`, `PrpcClient::with_max_response_bytes`). Nothing is bounded by default — `dstack vmm logs --lines 100000` is a legitimate multi-megabyte fetch — but the gateway's two guest-agent clients opt in, because a CVM is untrusted and one of them polls on a timer against the whole fleet
1717

1818
### Changed
1919
- os/yocto: nerdctl 2.2.1 → 2.3.5, so `nerdctl compose` honours the Compose `healthcheck:` field (only translated into `--health-*` flags from 2.3.1 on). Requires openembedded-core to move to `wrynose` head for go 1.26.5, which also brings gcc 15.2 → 15.3 — every guest image measurement changes, so the new image hashes need whitelisting in KMS

docs/app-health-checks.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,16 @@ That flag reaches the gateway at registration. An app that does not set it is
3232
never polled and is routed to exactly as it was before this feature existed.
3333

3434
It lives under `requirements` rather than at the top level for a reason:
35-
`requirements` is rejected outright by guest images older than
36-
`manifest_version` 3, and unknown fields inside it are a hard error. A
37-
deployment that asks for health gating therefore cannot silently land on an
38-
image that would ignore it.
35+
`requirements` is rejected by guest images older than `manifest_version` 3, and
36+
unknown fields *inside* it are a hard error. So a deployment that asks for
37+
health gating does not silently land on an image that would ignore it.
38+
39+
That protection is conditional, and the condition is yours: write
40+
`"manifest_version": "3"`**the string form**, as above. `AppCompose` itself
41+
is not `deny_unknown_fields`, so an app-compose left at `"manifest_version": 2`
42+
with a `requirements` block is *rejected* by a current guest and *silently
43+
accepted, minus the requirements*, by an older one. The string form is what an
44+
older guest chokes on.
3945

4046
## Where the verdict comes from
4147

@@ -125,10 +131,28 @@ to take yourself out of rotation a minute later.
125131

126132
Anything else is not healthy: a missing file, a malformed one, an unparseable
127133
timestamp, or a state word the agent does not recognise. Writing it atomically
128-
(write a temporary file, then rename) avoids being judged on a half-written one.
129-
130-
For a container to write this file, bind-mount the path into it. The agent reads
131-
it from the guest rootfs, not from inside any container.
134+
(write a temporary file in the same directory, then rename) avoids being judged
135+
on a half-written one.
136+
137+
It must be a **regular file**, and the agent will not follow a symlink to it.
138+
The path is measured into the compose hash; what sits at that path at runtime is
139+
not, so a symlink would let a container redirect a root-owned read after the
140+
fact, and a FIFO would park one of the agent's threads on every refresh. Neither
141+
is reported as unhealthy-with-a-reason so much as refused outright.
142+
143+
For the same reason the agent never quotes the file's contents back. A verdict
144+
names the rule that failed — "line 1 is neither healthy nor unhealthy" — because
145+
the report is served to anonymous callers and the bytes at that path may not be
146+
the bytes the app wrote.
147+
148+
The agent reads the file from the guest rootfs, not from inside any container,
149+
so a container that writes it needs the path bind-mounted in. **Mount the
150+
directory, not the file.** A single-file bind mount pins an inode: a rename
151+
inside the container replaces the container's own directory entry and never
152+
touches the file the agent is reading, so the agent sees the original contents
153+
forever, the timestamp ages past the limit, and the instance goes permanently
154+
unhealthy. With the directory mounted, either write-then-rename or a plain
155+
in-place write works.
132156

133157
Before the first refresh completes, the app reports **not** healthy.
134158
Registration happens long before the application is up, so "not determined yet"

dstack/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dstack/dstack-types/src/lib.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,63 @@ pub struct HealthCheck {
556556
pub health_file: Option<String>,
557557
}
558558

559+
/// Make a string that came from an untrusted party safe to put in a log line,
560+
/// and bound its length.
561+
///
562+
/// Both ends of the health path need this and neither can rely on the other:
563+
/// the guest agent sanitizes what an app wrote before reporting it, and the
564+
/// gateway sanitizes what a guest agent reported before logging it, because a
565+
/// guest agent is exactly the party the gateway does not trust.
566+
///
567+
/// "Unsafe" is wider than [`char::is_control`], which is only Unicode category
568+
/// Cc. It misses two families that do the same damage:
569+
///
570+
/// - `U+2028` LINE SEPARATOR and `U+2029` PARAGRAPH SEPARATOR, which many log
571+
/// viewers -- and every JavaScript or JSON consumer downstream of one --
572+
/// treat as a line break. That is the log-forging that stripping `\n` was
573+
/// meant to prevent.
574+
/// - The bidirectional formatting characters (`U+202A`..`U+202E`,
575+
/// `U+2066`..`U+2069`, `U+200E`, `U+200F`) and `U+FEFF`. `U+202E` reverses
576+
/// the rendering of everything after it, so an attacker controls how the
577+
/// rest of the line reads in a terminal without controlling its bytes.
578+
///
579+
/// Truncation is by bytes, and it says so when it happens: a bound that
580+
/// silently cuts a reason is worse than a short one, because the reader cannot
581+
/// tell a complete message from a clipped one.
582+
pub fn sanitize_for_log(text: &str, max_bytes: usize) -> String {
583+
const MARKER: &str = "... (truncated)";
584+
let mut cleaned = String::with_capacity(text.len().min(max_bytes));
585+
let mut truncated = false;
586+
for ch in text.chars() {
587+
let ch = if is_unsafe_to_log(ch) { ' ' } else { ch };
588+
if cleaned.len() + ch.len_utf8() > max_bytes {
589+
truncated = true;
590+
break;
591+
}
592+
cleaned.push(ch);
593+
}
594+
if truncated {
595+
cleaned.push_str(MARKER);
596+
}
597+
cleaned
598+
}
599+
600+
/// Whether a character must not survive into a log line. See
601+
/// [`sanitize_for_log`].
602+
fn is_unsafe_to_log(ch: char) -> bool {
603+
ch.is_control()
604+
|| matches!(
605+
ch,
606+
'\u{2028}'
607+
| '\u{2029}'
608+
| '\u{200E}'
609+
| '\u{200F}'
610+
| '\u{202A}'..='\u{202E}'
611+
| '\u{2066}'..='\u{2069}'
612+
| '\u{FEFF}'
613+
)
614+
}
615+
559616
/// How old a `health_file` may be before it is read as unhealthy.
560617
///
561618
/// Fixed rather than configurable: this is a liveness bound, and an app that
@@ -2484,3 +2541,71 @@ mod vm_config_device_count_tests {
24842541
);
24852542
}
24862543
}
2544+
2545+
#[cfg(test)]
2546+
mod log_sanitize_tests {
2547+
use super::sanitize_for_log;
2548+
2549+
#[test]
2550+
fn strips_the_characters_that_forge_a_log_line() {
2551+
let forged = sanitize_for_log("web\nJan 01 INFO all good", 512);
2552+
assert!(!forged.contains('\n'), "{forged:?}");
2553+
let escaped = sanitize_for_log("\u{1b}[2Jcleared", 512);
2554+
assert!(!escaped.contains('\u{1b}'), "{escaped:?}");
2555+
}
2556+
2557+
/// `char::is_control` is category Cc only, so these get through it -- and a
2558+
/// log viewer, or anything JSON downstream of one, treats them as breaks.
2559+
#[test]
2560+
fn strips_the_unicode_line_separators() {
2561+
for separator in ['\u{2028}', '\u{2029}', '\u{85}'] {
2562+
let out = sanitize_for_log(&format!("web{separator}forged"), 512);
2563+
assert!(!out.contains(separator), "{separator:?} survived: {out:?}");
2564+
}
2565+
}
2566+
2567+
/// U+202E reverses how everything after it renders, so an attacker decides
2568+
/// what a reader sees without controlling the bytes.
2569+
#[test]
2570+
fn strips_bidi_overrides() {
2571+
for bidi in ['\u{202E}', '\u{202A}', '\u{2066}', '\u{200F}', '\u{FEFF}'] {
2572+
let out = sanitize_for_log(&format!("web{bidi}txet"), 512);
2573+
assert!(!out.contains(bidi), "{bidi:?} survived: {out:?}");
2574+
}
2575+
}
2576+
2577+
/// Bounded in bytes, not characters. Counting characters lets a multi-byte
2578+
/// string reach four times the intended size.
2579+
#[test]
2580+
fn bounds_bytes_not_characters() {
2581+
let wide = "\u{1d54f}".repeat(400);
2582+
assert_eq!(wide.chars().count(), 400);
2583+
assert_eq!(wide.len(), 1600);
2584+
let out = sanitize_for_log(&wide, 512);
2585+
assert!(out.len() <= 512 + "... (truncated)".len(), "{}", out.len());
2586+
}
2587+
2588+
/// A bound that silently clips is worse than a short one: the reader cannot
2589+
/// tell a complete reason from a cut one.
2590+
#[test]
2591+
fn says_when_it_truncated() {
2592+
let wide = "\u{1d54f}".repeat(400);
2593+
assert!(sanitize_for_log(&wide, 512).ends_with("(truncated)"));
2594+
assert!(!sanitize_for_log("web is starting", 512).ends_with("(truncated)"));
2595+
}
2596+
2597+
#[test]
2598+
fn never_splits_a_character() {
2599+
// 512 is not a multiple of 4, so a naive byte cut would split one.
2600+
let wide = "\u{1d54f}".repeat(400);
2601+
let out = sanitize_for_log(&wide, 512);
2602+
let body = out.trim_end_matches("... (truncated)");
2603+
assert!(body.is_char_boundary(body.len()));
2604+
assert_eq!(body.len() % 4, 0);
2605+
}
2606+
2607+
#[test]
2608+
fn ordinary_text_is_left_alone() {
2609+
assert_eq!(sanitize_for_log("web is starting", 512), "web is starting");
2610+
}
2611+
}

dstack/dstack-util/src/docker_compose.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ pub struct ComposeInfo {
1818
pub service_names: std::collections::HashSet<String>,
1919
}
2020

21+
/// Whether any service in a compose file declares a `healthcheck:`.
22+
///
23+
/// `None` means the file could not be parsed, which is not the same as "no" --
24+
/// the caller must not turn an unreadable file into a verdict about its
25+
/// contents.
26+
pub fn compose_declares_a_healthcheck(compose_content: &str) -> Option<bool> {
27+
let docs = YamlLoader::load_from_str(compose_content).ok()?;
28+
let Yaml::Hash(services) = &docs.first()?["services"] else {
29+
return None;
30+
};
31+
Some(
32+
services
33+
.values()
34+
.any(|service| !service["healthcheck"].is_badvalue()),
35+
)
36+
}
37+
2138
/// Parse a docker-compose file and extract project name and service names
2239
pub fn parse_docker_compose_file(compose_file: impl AsRef<Path>) -> Result<ComposeInfo> {
2340
let compose_content =

dstack/dstack-util/src/system_setup.rs

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -474,16 +474,7 @@ impl<'a> GatewayContext<'a> {
474474
.collect(),
475475
restrict_mode: self.shared.app_compose.port_policy.restrict_mode,
476476
};
477-
// Opt-in, not a build-time fact: the guest agent only refreshes a
478-
// verdict when the app asked for one, so telling the gateway to poll an
479-
// app that did not would cost every gateway node a round trip per
480-
// interval to be told what it already assumes.
481-
let health_check = self
482-
.shared
483-
.app_compose
484-
.requirements
485-
.as_ref()
486-
.is_some_and(|requirements| requirements.health_check_enabled());
477+
let health_check = health_check_requested(&self.shared.app_compose);
487478
let client = self.create_gateway_client(
488479
gateway_url,
489480
&key_store.client_key,
@@ -987,6 +978,24 @@ fn verify_app_compose_policy(shared: &HostShared) -> Result<()> {
987978
Ok(())
988979
}
989980

981+
/// Whether this app asked the gateway to gate its traffic on health.
982+
///
983+
/// Opt-in, not a build-time fact: the guest agent only refreshes a verdict when
984+
/// the app asked for one, so telling the gateway to poll an app that did not
985+
/// would cost every gateway node a round trip per interval to be told what it
986+
/// already assumes.
987+
///
988+
/// This is the only hop between the app's manifest and the gateway's behaviour,
989+
/// which is why it is a named function rather than an expression inside the
990+
/// registration call: getting it wrong makes the whole feature inert fleet-wide
991+
/// with nothing to show for it.
992+
fn health_check_requested(app_compose: &AppCompose) -> bool {
993+
app_compose
994+
.requirements
995+
.as_ref()
996+
.is_some_and(|requirements| requirements.health_check_enabled())
997+
}
998+
990999
fn verify_manifest_feature_requirements(app_compose: &AppCompose) -> Result<()> {
9911000
let manifest_version = verify_manifest_version(app_compose)?;
9921001
if app_compose.requirements.is_some() && manifest_version < MANIFEST_VERSION_3 {
@@ -1043,6 +1052,22 @@ fn verify_health_check_requirement(app_compose: &AppCompose) -> Result<()> {
10431052
app_compose.runner
10441053
);
10451054
}
1055+
// Same failure, one level down and rather more likely: only
1056+
// containers that declare a `healthcheck:` are judged, so a compose
1057+
// file with none reports healthy the moment any container is
1058+
// created and keeps doing so after every one of them has exited.
1059+
// An unparseable file is not evidence either way and is left to
1060+
// whatever fails on it next.
1061+
let declares = app_compose
1062+
.docker_compose_file
1063+
.as_deref()
1064+
.and_then(crate::docker_compose::compose_declares_a_healthcheck);
1065+
if declares == Some(false) {
1066+
bail!(
1067+
"requirements.health_check is enabled but no service in docker_compose_file \
1068+
declares a healthcheck; add one, or set health_file to report health directly"
1069+
);
1070+
}
10461071
}
10471072
}
10481073
Ok(())
@@ -3521,13 +3546,39 @@ fn test_app_compose(
35213546
serde_json::from_value(value).unwrap()
35223547
}
35233548

3549+
/// The single hop between `requirements.health_check.enabled` and
3550+
/// `RegisterCvmRequest.health_check`. Nothing else connects the app's manifest
3551+
/// to the gateway's behaviour, so if this is wrong the feature is inert
3552+
/// fleet-wide and nothing else fails.
3553+
#[test]
3554+
fn health_check_opt_in_reaches_registration() {
3555+
let opted_in =
3556+
compose_with_health_check("docker-compose", serde_json::json!({"enabled": true}));
3557+
assert!(health_check_requested(&opted_in));
3558+
3559+
let opted_out =
3560+
compose_with_health_check("docker-compose", serde_json::json!({"enabled": false}));
3561+
assert!(!health_check_requested(&opted_out));
3562+
}
3563+
3564+
/// An app that says nothing must register as "do not poll me", not as an
3565+
/// opt-in by omission.
3566+
#[test]
3567+
fn an_app_that_says_nothing_does_not_ask_to_be_polled() {
3568+
let no_requirements = test_app_compose(serde_json::json!("3"), None, None);
3569+
assert!(!health_check_requested(&no_requirements));
3570+
3571+
let empty_requirements = test_app_compose(serde_json::json!("3"), Some(">=0.6.1"), None);
3572+
assert!(!health_check_requested(&empty_requirements));
3573+
}
3574+
35243575
#[cfg(test)]
35253576
fn compose_with_health_check(runner: &str, health_check: serde_json::Value) -> AppCompose {
35263577
serde_json::from_value(serde_json::json!({
35273578
"manifest_version": "3",
35283579
"name": "health-app",
35293580
"runner": runner,
3530-
"docker_compose_file": "services: {}\n",
3581+
"docker_compose_file": "services:\n web:\n image: app:1\n healthcheck:\n test: [\"CMD\", \"true\"]\n",
35313582
"requirements": { "health_check": health_check },
35323583
}))
35333584
.unwrap()
@@ -3539,6 +3590,43 @@ fn health_check_on_a_container_runner_needs_no_health_file() {
35393590
verify_manifest_feature_requirements(&compose).unwrap();
35403591
}
35413592

3593+
/// Only containers that declare a `healthcheck:` are judged, so a compose file
3594+
/// with none reports healthy forever -- the same silent no-op the runner check
3595+
/// above exists to remove, and the likelier mistake.
3596+
#[test]
3597+
fn health_check_without_any_declared_healthcheck_is_rejected() {
3598+
let mut compose =
3599+
compose_with_health_check("docker-compose", serde_json::json!({"enabled": true}));
3600+
compose.docker_compose_file = Some("services:\n web:\n image: app:1\n".to_string());
3601+
let err = verify_manifest_feature_requirements(&compose).unwrap_err();
3602+
assert!(
3603+
err.to_string().contains("declares a healthcheck"),
3604+
"unexpected error: {err}"
3605+
);
3606+
}
3607+
3608+
#[test]
3609+
fn a_declared_healthcheck_on_any_service_is_enough() {
3610+
let mut compose =
3611+
compose_with_health_check("docker-compose", serde_json::json!({"enabled": true}));
3612+
compose.docker_compose_file = Some(
3613+
"services:\n init:\n image: app:1\n web:\n image: app:1\n \
3614+
healthcheck:\n test: [\"CMD\", \"true\"]\n"
3615+
.to_string(),
3616+
);
3617+
verify_manifest_feature_requirements(&compose).unwrap();
3618+
}
3619+
3620+
/// An unparseable compose file is not evidence that it declares nothing, and
3621+
/// turning it into one here would reject a deployment for the wrong reason.
3622+
#[test]
3623+
fn an_unparseable_compose_file_is_left_to_whatever_fails_on_it_next() {
3624+
let mut compose =
3625+
compose_with_health_check("docker-compose", serde_json::json!({"enabled": true}));
3626+
compose.docker_compose_file = Some("\tservices: [unbalanced".to_string());
3627+
verify_manifest_feature_requirements(&compose).unwrap();
3628+
}
3629+
35423630
/// The `bash` runner starts no containers, so the container fallback has
35433631
/// nothing to look at and would answer healthy forever. That is exactly the
35443632
/// silent no-op the opt-in exists to avoid.

0 commit comments

Comments
 (0)