Skip to content

feat(api): wire container healthcheck observer end to end#16

Open
chrisgeo wants to merge 3 commits into
mainfrom
feat/chaos-1381-healthcheck-observer
Open

feat(api): wire container healthcheck observer end to end#16
chrisgeo wants to merge 3 commits into
mainfrom
feat/chaos-1381-healthcheck-observer

Conversation

@chrisgeo

@chrisgeo chrisgeo commented May 2, 2026

Copy link
Copy Markdown

Summary

Implements the full healthcheck observer that populates ContainerSnapshot.health (the read-only field reserved by CHAOS-1319) by running the configured probe inside the running container, interpreting exit codes through a Docker-compatible state machine, and writing the result back through the ContainersService actor under a generation-gated update path.

This is the upstream-shaped staging PR for CHAOS-1381. Together with CHAOS-1319 (which reserves the SDK shape) it closes the loop for compose-spec depends_on.condition: service_healthy against compose-spec orchestrators.

Stacking

This branch is based on feat/chaos-1319-health-status so the HealthStatus enum and ContainerSnapshot.health field exist in the diff. CHAOS-1319 (PR #13) should land first or be batched with this one. The combined change is reviewable in a single pass; once #13 merges, this PR rebases cleanly onto main.

Architecture (per design consult)

                        ┌──────────────────────────┐
   bootstrap → .running │   ContainersService      │
                        │   (existing actor)       │
                        │   ContainerState now     │
                        │   carries healthGeneration│
                        └────────────┬─────────────┘
                              register
                                     ▼
                        ┌──────────────────────────┐
                        │   HealthMonitor (NEW)    │ ← per-container observer Task
                        │   mirrors ExitMonitor    │
                        └────────────┬─────────────┘
                              probe loop (interval, timeout)
                                     ▼
                        ┌──────────────────────────┐
                        │   HealthProber (NEW)     │ ← protocol; mockable
                        │   SandboxClientHealthProber │
                        │   reuses createProcess + │
                        │   startProcess + wait    │
                        └────────────┬─────────────┘
                              probe outcome
                                     ▼
                        ┌──────────────────────────┐
                        │  HealthStateMachine (NEW)│ ← pure value type
                        │  Docker-compatible flow  │
                        └────────────┬─────────────┘
                              status transition
                                     ▼
                        ContainersService.applyHealthUpdate
                              ↓
                        generation check + status==.running gate
                              ↓
                        snapshot.health mutation

Key decisions:

  • Observer placement in a dedicated HealthMonitor actor (mirrors ExitMonitor); keeps ContainersService from growing further and provides a clean cancellation boundary.
  • Probe execution through the existing createProcess / startProcess / wait path; no new XPC route added. Synthetic process id is __container_healthcheck_<UUID>. Stdio is intentionally not forwarded.
  • Probe timeout enforced via task-group race against Task.sleep. Timed-out probes are killed with SIGKILL before the group drains so the wait() task can return.
  • Generation-gated snapshot updates: every transition into .running bumps ContainerState.healthGeneration. Late callbacks from a previous container instance are dropped at applyHealthUpdate (gen mismatch + status check).

CLI surface

container run \
    --health-cmd \"redis-cli ping | grep -q PONG\" \
    --health-interval 5 \
    --health-timeout 2 \
    --health-retries 3 \
    --health-start-period 10 \
    --health-start-interval 1 \
    redis:latest
Flag Effect
--health-cmd <shell> Translates to [\"CMD-SHELL\", cmd]; runs via /bin/sh -c inside the container.
--health-interval <s> Time between probes; default 30.
--health-timeout <s> Per-probe deadline; default 30. Probe killed on timeout.
--health-retries <n> Consecutive failures before unhealthy; default 3.
--health-start-period <s> Grace window. Failures during this window do not count. First success during grace transitions immediately to healthy.
--health-start-interval <s> Probe interval used while still inside the grace window.
--no-healthcheck Sets test=[\"NONE\"]; bypasses any image-baked healthcheck.

The richer [\"CMD\", \"exec\", \"arg1\", ...] form is reachable via API clients that build Healthcheck directly (e.g. compose orchestrators) — CLI surface for CMD-form probes is follow-up work called out in the commit.

Wire compatibility

ContainerConfiguration.healthcheck is a new optional field, decoded with decodeIfPresent. Containers persisted by older daemons round-trip cleanly (covered by testLegacyContainerConfigurationDecodesWithoutHealthcheck). New CLI flags are independent and have no effect when omitted.

Known limitations (intentional, follow-up work)

  • --health-cmd accepts only the shell form. CMD-form CLI surface is follow-up.
  • Daemon restart does not rehydrate health state. Observers restart from .starting rather than persisting counters across daemon launches. Deliberate v1 scope per the design consult.
  • Probe intervals are Foundation TimeInterval (Double seconds). Compose-spec duration strings ("30s", "1m30s") are parsed by the client.

Verification

  • `swift build -c release` — clean on macOS 26 / Apple silicon.
  • `swift test --filter 'HealthcheckTest|HealthStateMachineTest|HealthMonitorTest'` — 26/26 passing:
    • HealthcheckTest (12): shape parsing (CMD/CMD-SHELL/NONE), validation error paths, disable flag, probeInterval rule (start-interval inside grace only), legacy-config Codable round-trip regression.
    • HealthStateMachineTest (10): every transition documented in the design — initial state, success during grace, failure during grace, failures past grace toward retries, success resets counter, unhealthy recovers without restart, disabled machine ignores inputs, retries=0 corner case.
    • HealthMonitorTest (4): `ScriptedProber` actor + `StatusRecorder` ordered-update capture. Disabled-check single-callback, .starting → .healthy transition, consecutive-failure → .unhealthy, unregister-cancels-loop guarantee.

@linear

linear Bot commented May 2, 2026

Copy link
Copy Markdown

@chrisgeo chrisgeo marked this pull request as ready for review May 14, 2026 19:40
chrisgeo added 3 commits May 24, 2026 06:19
Adds a new public enum HealthStatus { none, starting, healthy, unhealthy }
and a new optional 'health: HealthStatus?' field on ContainerSnapshot,
defaulting to nil at all construction sites.

Motivation
----------

External orchestrators that drive the API server (the canonical use
case is the compose-spec depends_on: condition: service_healthy gate)
need to know whether a container is up AND healthy, not just up. Today
ContainerSnapshot exposes .running for any started container, so
consumers have to fall back to .running and treat liveness == health.
Real workloads (databases that take seconds to accept connections,
queue brokers that warm up an in-memory state) hit this regularly and
end up either waiting too long or proceeding too early.

Scope of this PR (deliberately minimal)
---------------------------------------

This PR is data-shape only. It adds the enum and the field to the SDK.
It does NOT wire a healthcheck observer into the daemon: at runtime
the field is always nil, so the on-the-wire behavior is unchanged
modulo one new Codable key on ContainerSnapshot.

Why ship a nil-only field?
~~~~~~~~~~~~~~~~~~~~~~~~~~

A container-level healthcheck observer is a non-trivial design
discussion (where does the spec live? does the API server exec into
the container, or does the runtime drive it? does it leak into the
sandbox boundary?) and we'd rather have that discussion separately,
referencing a concrete companion issue. Reserving the SDK shape now
lets downstream tools start coding against the field with the
'always nil today' guarantee documented inline; flipping the
implementation on later does not require another SDK-shape PR.

Wire compatibility
------------------

ContainerSnapshot is marshaled as Codable JSON over XPC. Adding an
optional field is forward-compatible:

  - Older clients reading from a newer server: ignore the new key.
  - Newer clients reading from an older server: decode health as nil.

Files
-----

- Sources/ContainerResource/Container/HealthStatus.swift (new):
  the enum, with cases documented and a note on the daemon-side
  observer caveat.
- Sources/ContainerResource/Container/ContainerSnapshot.swift:
  new optional field + init parameter (default nil).

Companion issue
---------------

Filed at apple/container with the design proposal for the eventual
healthcheck observer; this PR is deliberately the smaller surface so
the data shape can land independently of that discussion.
Implements the full healthcheck observer that populates
`ContainerSnapshot.health` (the read-only field reserved by CHAOS-1319)
by running the configured probe inside the running container,
interpreting exit codes through a Docker-compatible state machine, and
writing the result back through the `ContainersService` actor under a
generation-gated update path.

Motivation
----------

CHAOS-1319 reserved the SDK shape (`HealthStatus` enum + optional
`health` field on `ContainerSnapshot`) but the daemon never populated
it; the field is always `nil` today, so external orchestrators (the
canonical use case is a compose-spec orchestrator implementing
`depends_on.condition: service_healthy`) can only block on
image-baked healthchecks and only when the underlying runtime owns
the probe loop. Real workloads (databases that take seconds to accept
connections, queue brokers that warm up an in-memory state) need a
container-level healthcheck observer that the daemon owns. This PR
adds it.

What this PR changes
--------------------

- Sources/ContainerResource/Container/Healthcheck.swift (new):
  public Codable / Sendable struct mirroring the Docker / compose-spec
  schema (`test`, `interval`, `timeout`, `retries`, `start_period`,
  `start_interval`, `disable`). Validates the probe shape (`NONE` /
  `CMD` / `CMD-SHELL`) and rejects malformed inputs with actionable
  error messages.
- Sources/ContainerResource/Container/ContainerConfiguration.swift:
  new optional `healthcheck: Healthcheck?` field, `decodeIfPresent`
  on the wire so legacy on-disk configurations decode unchanged.
- Sources/Services/ContainerAPIService/Server/Containers/
  HealthStateMachine.swift (new): pure value type that maps probe
  outcomes to `HealthStatus`. Implements the Docker-compatible flow:
  initial `.starting`, immediate transition to `.healthy` on the
  first successful probe (including during the `start_period` grace
  window), `retries` consecutive failures post-grace transition to
  `.unhealthy`, recovery to `.healthy` without restart.
- Sources/Services/ContainerAPIService/Server/Containers/
  HealthProber.swift (new): `HealthProber` protocol plus production
  `SandboxClientHealthProber` that drives an existing `SandboxClient`
  to spawn a fresh `__container_healthcheck_<UUID>` synthetic process
  per probe, races `wait()` against a per-probe timeout, and signals
  `SIGKILL` on timeout to unblock the synthetic wait task before
  draining the task group.
- Sources/Services/ContainerAPIService/Server/Containers/
  HealthMonitor.swift (new): per-container observer manager actor
  that mirrors `ExitMonitor`. `register(id:generation:startedAt:
  healthcheck:prober:onUpdate:)` cancels any prior observer, fires
  the initial `.starting` (or `.none` for disabled checks) callback,
  and runs the probe loop. `unregister(id:)` is idempotent and
  triggers cooperative cancellation.
- Sources/Services/ContainerAPIService/Server/Containers/
  ContainersService.swift: new private `healthMonitor: HealthMonitor`
  field; new `healthGeneration: UInt64` token on `ContainerState`
  bumped on every transition into `.running`; observer registered
  inside `startProcess` once the init process is up; unregister wired
  into `handleContainerExit`. New private `applyHealthUpdate(id:
  generation:status:)` is the single mutation entry; it drops updates
  whose generation no longer matches the live container or whose
  status is no longer `.running`, closing the late-callback /
  restart race.
- Sources/Services/ContainerAPIService/Client/Flags.swift: seven new
  flags on `Flags.Management` covering `--health-cmd`,
  `--health-interval`, `--health-timeout`, `--health-retries`,
  `--health-start-period`, `--health-start-interval`, and
  `--no-healthcheck`.
- Sources/Services/ContainerAPIService/Client/Utility.swift: new
  private `makeHealthcheck(management:)` that translates the flag
  bag into a `Healthcheck`. Rejects orphan `--health-*` flags
  without `--health-cmd` to catch typos at submit time.
- Package.swift: `ContainerAPIServiceTests` gains a dependency on
  the `ContainerAPIService` target so the new tests can use the
  `@testable` import.
- Tests:
  - Tests/ContainerResourceTests/HealthcheckTest.swift: 12 tests
    covering shape parsing (`CMD` / `CMD-SHELL` / `NONE`), validation
    error paths, the `disable` flag, the `probeInterval` selection
    rule (start-interval inside the grace window only), and a
    legacy-config Codable round-trip regression.
  - Tests/ContainerAPIServiceTests/HealthStateMachineTest.swift: 10
    tests exercising every transition documented in the design:
    initial state, success during grace, failure during grace,
    failures past grace toward `retries`, success resets the counter,
    `unhealthy` recovers without restart, disabled machine ignores
    inputs, retries=0 corner case.
  - Tests/ContainerAPIServiceTests/HealthMonitorTest.swift: 4 tests
    against a `ScriptedProber` actor (deterministic probe outcomes)
    and a `StatusRecorder` (ordered update capture). Covers the
    disabled-check single-callback path, the `.starting` -> `.healthy`
    transition, the consecutive-failure -> `.unhealthy` path, and
    the unregister-cancels-loop guarantee.

Design notes
------------

The implementation follows the architecture recommendation produced
during a design consult (see CHAOS-1381 thread): observer placement
in a dedicated actor (mirroring `ExitMonitor`), probe execution
through the existing `createProcess` / `startProcess` / `wait` path
(no new XPC route added), Docker-compatible state machine semantics,
and generation-gated snapshot updates rather than relying on
cancellation alone to suppress stale callbacks.

Wire compatibility
------------------

`ContainerConfiguration.healthcheck` is a new optional field,
decoded with `decodeIfPresent`. Containers persisted by older
daemons round-trip cleanly (covered by
`testLegacyContainerConfigurationDecodesWithoutHealthcheck`). New
CLI flags are independent and have no effect when omitted, so older
clients hitting a newer daemon and vice versa both behave
identically to today.

Known limitations (intentional, follow-up work)
-----------------------------------------------

- The `--health-cmd` CLI shape currently accepts only the shell
  form (translated to `["CMD-SHELL", cmd]`). The richer
  `["CMD", "exec", "arg1", ...]` form is reachable via API clients
  that build `Healthcheck` directly (e.g. compose orchestrators).
  Adding a CLI surface for CMD-form probes is a follow-up.
- Daemon restart does not rehydrate health state. On daemon launch,
  observers are restarted from `.starting` rather than persisting
  probe counters. Per the design consult this is deliberate scope
  for v1.
- Probe intervals use Foundation `TimeInterval` (Double seconds).
  Compose-spec duration strings (`30s`, `1m30s`) are parsed by the
  client (e.g. container-compose) before reaching the API.

Pairs with CHAOS-1319
---------------------

CHAOS-1319 reserved the SDK shape (`ContainerSnapshot.health`).
This PR is the runtime that populates it, closing the loop for
compose-spec `depends_on.condition: service_healthy` against
container-compose orchestrators. CHAOS-1319's PR
(#13) should land first or be batched with
this one.

Verification
------------

- `swift build -c release` clean on macOS 26 / Apple silicon.
- `swift test --filter 'HealthcheckTest|HealthStateMachineTest|
  HealthMonitorTest'` passes 26/26: 12 Healthcheck data shape +
  Codable + validation, 10 pure HealthStateMachine transitions, 4
  HealthMonitor actor lifecycle / cancellation tests.
@chrisgeo chrisgeo force-pushed the feat/chaos-1381-healthcheck-observer branch from 7cdcd25 to be4aee0 Compare May 24, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant