Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ let package = Package(
name: "ContainerAPIServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
"ContainerAPIService",
"ContainerResource",
"ContainerRuntimeLinuxClient",
"ContainerRuntimeClient",
Expand Down
18 changes: 18 additions & 0 deletions Sources/ContainerCommands/Container/ContainerRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,5 +176,23 @@ extension Application {
}
throw ArgumentParser.ExitCode(exitCode)
}
static func parseRestartPolicy(_ raw: String?) -> RestartPolicy? {
guard let raw, !raw.isEmpty else { return nil }
switch raw {
case "no":
return RestartPolicy.none
case "always":
return RestartPolicy(mode: .always)
case "unless-stopped":
return RestartPolicy(mode: .unlessStopped)
default:
if raw.hasPrefix("on-failure") {
let parts = raw.split(separator: ":", maxSplits: 1)
let retries = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
return RestartPolicy(mode: .onFailure, maxRetries: retries)
}
return nil
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ public struct ContainerConfiguration: Sendable, Codable {
public var shmSize: UInt64?
/// Signal to send to the container process on stop (from image config).
public var stopSignal: String?
/// Optional periodic healthcheck spec. When set and not effectively
/// disabled, the API server starts a per-container observer that runs
/// the configured probe and updates ``ContainerSnapshot/health``.
public var healthcheck: Healthcheck?

enum CodingKeys: String, CodingKey {
case id
Expand All @@ -85,6 +89,7 @@ public struct ContainerConfiguration: Sendable, Codable {
case capDrop
case shmSize
case stopSignal
case healthcheck
}

/// Create a configuration from the supplied Decoder, initializing missing
Expand Down Expand Up @@ -120,6 +125,7 @@ public struct ContainerConfiguration: Sendable, Codable {
capDrop = try container.decodeIfPresent([String].self, forKey: .capDrop) ?? []
shmSize = try container.decodeIfPresent(UInt64.self, forKey: .shmSize)
stopSignal = try container.decodeIfPresent(String.self, forKey: .stopSignal)
healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck)
}

public struct DNSConfiguration: Sendable, Codable {
Expand Down
11 changes: 10 additions & 1 deletion Sources/ContainerResource/Container/ContainerSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,25 @@ public struct ContainerSnapshot: Codable, Sendable {
public var networks: [Attachment]
/// When the container was started.
public var startedDate: Date?
/// The most recently observed health of the container.
///
/// At present the daemon does not run a container-level healthcheck
/// observer, so this field is always `nil`. The shape is reserved so that
/// downstream tools (e.g. `compose`) have a stable type to read from once
/// a healthcheck observer is wired into the API server.
public var health: HealthStatus?

public init(
configuration: ContainerConfiguration,
status: RuntimeStatus,
networks: [Attachment],
startedDate: Date? = nil
startedDate: Date? = nil,
health: HealthStatus? = nil
) {
self.configuration = configuration
self.status = status
self.networks = networks
self.startedDate = startedDate
self.health = health
}
}
35 changes: 35 additions & 0 deletions Sources/ContainerResource/Container/HealthStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import Foundation

/// The observed health status of a container, as derived from a periodic
/// healthcheck probe.
///
/// At present the daemon does not run a container-level healthcheck observer,
/// so ``ContainerSnapshot/health`` is always `nil`. This type is reserved for
/// downstream tools (e.g. `compose`) that want a stable shape to read from
/// once a healthcheck observer is wired into the API server.
public enum HealthStatus: String, CaseIterable, Sendable, Codable {
/// No healthcheck has been configured or no result is yet available.
case none
/// The healthcheck is running but has not yet produced a successful probe.
case starting
/// The most recent probe(s) reported the container as healthy.
case healthy
/// The most recent probe(s) reported the container as unhealthy.
case unhealthy
}
214 changes: 214 additions & 0 deletions Sources/ContainerResource/Container/Healthcheck.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationError
import Foundation

/// Configuration for a periodic, container-level healthcheck.
///
/// The shape mirrors the Docker / compose-spec healthcheck schema so that
/// downstream tools (the canonical use case is a compose-spec orchestrator
/// implementing `depends_on.condition: service_healthy`) can populate this
/// type directly from a `docker-compose.yml` `healthcheck:` block.
///
/// Semantics applied by the daemon's healthcheck observer:
///
/// 1. When the observer starts and the healthcheck is enabled, the
/// container's ``ContainerSnapshot/health`` is set to
/// ``HealthStatus/starting``.
/// 2. While the wall-clock age of the container is within ``startPeriod``,
/// failed probes do not advance the consecutive failure counter.
/// Successful probes during the grace period transition the container
/// immediately to ``HealthStatus/healthy``.
/// 3. After the grace period elapses, ``retries`` consecutive failed probes
/// transition the container to ``HealthStatus/unhealthy``. A subsequent
/// successful probe resets the counter and transitions back to
/// ``HealthStatus/healthy`` without requiring a restart.
/// 4. A probe that does not return within ``timeout`` counts as a failed
/// probe.
/// 5. ``test`` of `["NONE"]` and ``disable`` set to `true` both bypass the
/// observer entirely; ``ContainerSnapshot/health`` remains `nil`.
public struct Healthcheck: Codable, Sendable, Equatable {
/// The probe specification.
///
/// Compatible shapes:
/// - `["NONE"]` — disable any healthcheck inherited from the image.
/// - `["CMD", "executable", "arg1", ...]` — run `executable` with the
/// supplied arguments directly inside the container. Exit code `0`
/// means healthy, any other exit code means unhealthy.
/// - `["CMD-SHELL", "shell command string"]` — run the entire command
/// string through the container's default shell (`/bin/sh -c`).
public let test: [String]

/// Time between consecutive probes, in seconds. Defaults to 30 seconds.
public let interval: TimeInterval

/// Per-probe deadline, in seconds. A probe that does not return within
/// this window counts as a failed probe. Defaults to 30 seconds.
public let timeout: TimeInterval

/// Number of consecutive failed probes that transition the container
/// from ``HealthStatus/healthy`` (or ``HealthStatus/starting``) to
/// ``HealthStatus/unhealthy``. Defaults to 3.
public let retries: Int

/// Optional grace window, in seconds, during which failed probes do not
/// count toward ``retries``. The first successful probe during this
/// window transitions the container immediately to
/// ``HealthStatus/healthy``. When `nil`, no grace is applied.
public let startPeriod: TimeInterval?

/// Optional probe interval used while the container is still within
/// ``startPeriod``. When `nil`, ``interval`` is used during the grace
/// window as well.
public let startInterval: TimeInterval?

/// Bypass the observer entirely. Equivalent to ``test`` = `["NONE"]`.
public let disable: Bool?

/// Default probe interval applied when the configuration omits one.
public static let defaultInterval: TimeInterval = 30
/// Default per-probe deadline applied when the configuration omits one.
public static let defaultTimeout: TimeInterval = 30
/// Default consecutive-failure threshold applied when the configuration
/// omits one.
public static let defaultRetries: Int = 3

public init(
test: [String],
interval: TimeInterval = Healthcheck.defaultInterval,
timeout: TimeInterval = Healthcheck.defaultTimeout,
retries: Int = Healthcheck.defaultRetries,
startPeriod: TimeInterval? = nil,
startInterval: TimeInterval? = nil,
disable: Bool? = nil
) throws {
self.test = test
self.interval = interval
self.timeout = timeout
self.retries = retries
self.startPeriod = startPeriod
self.startInterval = startInterval
self.disable = disable
try validate()
}

enum CodingKeys: String, CodingKey {
case test
case interval
case timeout
case retries
case startPeriod
case startInterval
case disable
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
test = try container.decode([String].self, forKey: .test)
interval = try container.decodeIfPresent(TimeInterval.self, forKey: .interval) ?? Healthcheck.defaultInterval
timeout = try container.decodeIfPresent(TimeInterval.self, forKey: .timeout) ?? Healthcheck.defaultTimeout
retries = try container.decodeIfPresent(Int.self, forKey: .retries) ?? Healthcheck.defaultRetries
startPeriod = try container.decodeIfPresent(TimeInterval.self, forKey: .startPeriod)
startInterval = try container.decodeIfPresent(TimeInterval.self, forKey: .startInterval)
disable = try container.decodeIfPresent(Bool.self, forKey: .disable)
try validate()
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(test, forKey: .test)
try container.encode(interval, forKey: .interval)
try container.encode(timeout, forKey: .timeout)
try container.encode(retries, forKey: .retries)
try container.encodeIfPresent(startPeriod, forKey: .startPeriod)
try container.encodeIfPresent(startInterval, forKey: .startInterval)
try container.encodeIfPresent(disable, forKey: .disable)
}

/// Whether the healthcheck is effectively disabled (no observer should
/// be started, ``ContainerSnapshot/health`` remains `nil`).
public var isEffectivelyDisabled: Bool {
if disable == true { return true }
if test.count == 1 && test[0] == "NONE" { return true }
return false
}

/// The probe interval that should be used at the supplied wall-clock age
/// of the container. Returns ``startInterval`` while the container is
/// still within ``startPeriod``, otherwise ``interval``.
public func probeInterval(forContainerAge age: TimeInterval) -> TimeInterval {
if let startPeriod, age < startPeriod, let startInterval {
return startInterval
}
return interval
}

private func validate() throws {
guard !test.isEmpty else {
throw ContainerizationError(
.invalidArgument,
message: "healthcheck test must not be empty"
)
}
if !isEffectivelyDisabled {
switch test[0] {
case "CMD", "CMD-SHELL":
guard test.count >= 2 else {
throw ContainerizationError(
.invalidArgument,
message: "healthcheck test '\(test[0])' requires at least one argument"
)
}
default:
throw ContainerizationError(
.invalidArgument,
message: "healthcheck test must start with 'NONE', 'CMD', or 'CMD-SHELL' (got '\(test[0])')"
)
}
}
guard interval > 0 else {
throw ContainerizationError(
.invalidArgument,
message: "healthcheck interval must be positive (got \(interval))"
)
}
guard timeout > 0 else {
throw ContainerizationError(
.invalidArgument,
message: "healthcheck timeout must be positive (got \(timeout))"
)
}
guard retries >= 0 else {
throw ContainerizationError(
.invalidArgument,
message: "healthcheck retries must be non-negative (got \(retries))"
)
}
if let startPeriod, startPeriod < 0 {
throw ContainerizationError(
.invalidArgument,
message: "healthcheck start_period must be non-negative (got \(startPeriod))"
)
}
if let startInterval, startInterval <= 0 {
throw ContainerizationError(
.invalidArgument,
message: "healthcheck start_interval must be positive (got \(startInterval))"
)
}
}
}
42 changes: 42 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,48 @@ public struct Flags {
@Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container")
public var volumes: [String] = []

@Option(
name: .customLong("health-cmd"),
help: "Healthcheck command to run inside the container (executed via /bin/sh -c)."
)
public var healthCmd: String?

@Option(
name: .customLong("health-interval"),
help: "Time between healthcheck probes, in seconds (default 30)."
)
public var healthInterval: Double?

@Option(
name: .customLong("health-timeout"),
help: "Per-probe deadline for the healthcheck, in seconds (default 30)."
)
public var healthTimeout: Double?

@Option(
name: .customLong("health-retries"),
help: "Number of consecutive failed probes before the container is reported unhealthy (default 3)."
)
public var healthRetries: Int?

@Option(
name: .customLong("health-start-period"),
help: "Grace window after start during which failed probes do not count, in seconds."
)
public var healthStartPeriod: Double?

@Option(
name: .customLong("health-start-interval"),
help: "Probe interval used while still within the grace window, in seconds."
)
public var healthStartInterval: Double?

@Flag(
name: .customLong("no-healthcheck"),
help: "Disable any image-baked healthcheck for this container."
)
public var noHealthcheck: Bool = false

public func validate() throws {
if dnsDisabled {
let hasDNSConfig =
Expand Down
Loading