diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index 2b846e2c4..b597b97d5 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -86,7 +86,10 @@ extension Application { log: log ) - let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let options = ContainerCreateOptions( + autoRemove: managementFlags.remove, + restartPolicy: managementFlags.restart + ) let client = ContainerClient() try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2) diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index 2957fe473..5cc31ffff 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -108,7 +108,10 @@ extension Application { progress.set(description: "Starting container") - let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let options = ContainerCreateOptions( + autoRemove: managementFlags.remove, + restartPolicy: managementFlags.restart + ) try await client.create( configuration: ck.0, options: options, @@ -176,5 +179,6 @@ extension Application { } throw ArgumentParser.ExitCode(exitCode) } + } } diff --git a/Sources/ContainerResource/Container/ContainerCreateOptions.swift b/Sources/ContainerResource/Container/ContainerCreateOptions.swift index fe75577b0..ecdb65394 100644 --- a/Sources/ContainerResource/Container/ContainerCreateOptions.swift +++ b/Sources/ContainerResource/Container/ContainerCreateOptions.swift @@ -19,12 +19,39 @@ public struct ContainerCreateOptions: Codable, Sendable { public let autoRemove: Bool /// Override the rootFs with this one other than the image-cloned version public let rootFsOverride: Filesystem? + /// Declarative restart policy recorded at creation time. + /// + /// Today this is data-shape only — the daemon stores the policy but does + /// not observe exits and re-launch automatically. Enforcement is tracked + /// by upstream [apple/container#1258](https://github.com/apple/container/pull/1258). + /// + /// Defaults to ``RestartPolicy/no``. Decoded with `decodeIfPresent` so + /// older `options.json` blobs written before the field existed continue to + /// load (forward-compatible additive change). + public let restartPolicy: RestartPolicy - public init(autoRemove: Bool, rootFsOverride: Filesystem? = nil) { + public init( + autoRemove: Bool, + rootFsOverride: Filesystem? = nil, + restartPolicy: RestartPolicy = .no + ) { self.autoRemove = autoRemove self.rootFsOverride = rootFsOverride + self.restartPolicy = restartPolicy } public static let `default` = ContainerCreateOptions(autoRemove: false) + enum CodingKeys: String, CodingKey { + case autoRemove + case rootFsOverride + case restartPolicy + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.autoRemove = try container.decode(Bool.self, forKey: .autoRemove) + self.rootFsOverride = try container.decodeIfPresent(Filesystem.self, forKey: .rootFsOverride) + self.restartPolicy = try container.decodeIfPresent(RestartPolicy.self, forKey: .restartPolicy) ?? .no + } } diff --git a/Sources/ContainerResource/Container/RestartPolicy.swift b/Sources/ContainerResource/Container/RestartPolicy.swift new file mode 100644 index 000000000..43f7d7a0a --- /dev/null +++ b/Sources/ContainerResource/Container/RestartPolicy.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Declarative restart policy that the daemon stores on a created container. +/// +/// The shape mirrors the in-flight upstream proposal in +/// [apple/container#1258](https://github.com/apple/container/pull/1258): +/// a bare `String`-backed enum with the conservative initial set +/// (`no`, `onFailure`, `always`). Bounded `on-failure:N` retries and +/// `unless-stopped` are intentionally deferred — they ship as separate +/// follow-ups once #1258 lands. +/// +/// At present this is a data-shape only contract on the fork: the policy is +/// recorded in ``ContainerCreateOptions/restartPolicy`` but the daemon does +/// not yet observe container exits and re-launch per policy. Enforcement +/// will arrive via the upstream restart manager (#1258). +public enum RestartPolicy: String, Sendable, Codable, Equatable, CaseIterable { + case no + case onFailure + case always +} diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index f8361ef68..e8457712b 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -15,9 +15,12 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ContainerResource import ContainerizationError import Foundation +extension RestartPolicy: ExpressibleByArgument {} + public struct Flags { public struct Logging: ParsableArguments { public init() {} @@ -345,6 +348,12 @@ public struct Flags { @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") public var volumes: [String] = [] + @Option( + name: .customLong("restart"), + help: "Restart policy when the container exits (no, onFailure, always)" + ) + public var restart: RestartPolicy = .no + public func validate() throws { if dnsDisabled { let hasDNSConfig = diff --git a/Tests/ContainerResourceTests/RestartPolicyTests.swift b/Tests/ContainerResourceTests/RestartPolicyTests.swift new file mode 100644 index 000000000..5a866230a --- /dev/null +++ b/Tests/ContainerResourceTests/RestartPolicyTests.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// 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 +import Testing + +@testable import ContainerResource + +/// Coverage for the SDK shape that PR #13 introduces. Wire compatibility is +/// the contract under test: the daemon does not enforce restart policy yet, +/// so behavior tests live with the future restart manager (upstream +/// apple/container#1258), not here. +struct RestartPolicyTests { + // MARK: - RestartPolicy round-trip + + @Test + func testEncodesAsBareString() throws { + let encoder = JSONEncoder() + let data = try encoder.encode(RestartPolicy.always) + let s = String(decoding: data, as: UTF8.self) + #expect(s == "\"always\"") + } + + @Test + func testDecodesEveryCase() throws { + let decoder = JSONDecoder() + for policy in RestartPolicy.allCases { + let data = try JSONEncoder().encode(policy) + let decoded = try decoder.decode(RestartPolicy.self, from: data) + #expect(decoded == policy) + } + } + + @Test + func testRejectsUnknownString() { + let bogus = Data("\"unless-stopped\"".utf8) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(RestartPolicy.self, from: bogus) + } + } + + // MARK: - ContainerCreateOptions forward-compat + + /// JSON written by a daemon version that predates `restartPolicy` MUST + /// still decode — defaulting to `.no`. This is the wire-compatibility + /// invariant the PR description promises. + @Test + func testDecodesLegacyOptionsWithoutRestartPolicy() throws { + let legacy = Data(#"{"autoRemove":false}"#.utf8) + let options = try JSONDecoder().decode(ContainerCreateOptions.self, from: legacy) + #expect(options.autoRemove == false) + #expect(options.restartPolicy == .no) + } + + @Test + func testDecodesLegacyOptionsWithAutoRemoveAndRootFsOverride() throws { + // Older clients may emit only autoRemove + rootFsOverride. rootFsOverride + // is itself optional and may not appear; we test the canonical legacy + // shape (just autoRemove). + let legacy = Data(#"{"autoRemove":true}"#.utf8) + let options = try JSONDecoder().decode(ContainerCreateOptions.self, from: legacy) + #expect(options.autoRemove == true) + #expect(options.rootFsOverride == nil) + #expect(options.restartPolicy == .no) + } + + @Test + func testRoundTripPreservesRestartPolicy() throws { + let original = ContainerCreateOptions(autoRemove: false, restartPolicy: .onFailure) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ContainerCreateOptions.self, from: data) + #expect(decoded.autoRemove == original.autoRemove) + #expect(decoded.restartPolicy == original.restartPolicy) + } + + @Test + func testEncodedJSONIncludesRestartPolicyField() throws { + let options = ContainerCreateOptions(autoRemove: false, restartPolicy: .always) + let data = try JSONEncoder().encode(options) + let json = String(decoding: data, as: UTF8.self) + // Field present and lowercase per CodingKeys + raw value. + #expect(json.contains("\"restartPolicy\":\"always\"")) + } + + @Test + func testDefaultStaticHasNoRestart() { + #expect(ContainerCreateOptions.default.restartPolicy == .no) + #expect(ContainerCreateOptions.default.autoRemove == false) + } +}