cli: Add support for --stop-signal (#1462)

- Closes #1581.
- This adds stop signal support to the cli. The priority is:
  1. If an explicit stop signal is passed on the cli use this.
  2. If not, check if there is a stop signal in the image config.
  3. Finally, use the default (TERM).
This commit is contained in:
Danny Canter
2026-05-21 17:55:48 -07:00
committed by GitHub
parent 3d291ff865
commit de780c1478
9 changed files with 103 additions and 19 deletions
@@ -19,7 +19,6 @@ import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
import Logging
@@ -35,7 +34,7 @@ extension Application {
var all = false
@Option(name: .shortAndLong, help: "Signal to send to the containers")
var signal: String = "SIGTERM"
var signal: String?
@Option(name: .shortAndLong, help: "Seconds to wait before killing the containers")
var time: Int32 = 5
@@ -68,7 +67,7 @@ extension Application {
let opts = ContainerStopOptions(
timeoutInSeconds: self.time,
signal: try Signal(self.signal).rawValue
signal: self.signal
)
try await Self.stopContainers(
client: client,
@@ -66,8 +66,7 @@ extension Application {
log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"])
do {
let containers = try await client.list().map { $0.id }
let signal = Signal.term.rawValue
let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal)
let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: nil)
try await ContainerStop.stopContainers(
client: client,
containers: containers,
@@ -59,6 +59,8 @@ public struct ContainerConfiguration: Sendable, Codable {
public var capDrop: [String] = []
/// Size of /dev/shm in bytes. When nil, the default size is used.
public var shmSize: UInt64?
/// Signal to send to the container process on stop (from image config).
public var stopSignal: String?
enum CodingKeys: String, CodingKey {
case id
@@ -82,6 +84,7 @@ public struct ContainerConfiguration: Sendable, Codable {
case capAdd
case capDrop
case shmSize
case stopSignal
}
/// Create a configuration from the supplied Decoder, initializing missing
@@ -116,6 +119,7 @@ public struct ContainerConfiguration: Sendable, Codable {
capAdd = try container.decodeIfPresent([String].self, forKey: .capAdd) ?? []
capDrop = try container.decodeIfPresent([String].self, forKey: .capDrop) ?? []
shmSize = try container.decodeIfPresent(UInt64.self, forKey: .shmSize)
stopSignal = try container.decodeIfPresent(String.self, forKey: .stopSignal)
}
public struct DNSConfiguration: Sendable, Codable {
@@ -17,15 +17,15 @@
import Foundation
public struct ContainerStopOptions: Sendable, Codable {
public let timeoutInSeconds: Int32
public let signal: Int32
public var timeoutInSeconds: Int32
public var signal: String?
public static let `default` = ContainerStopOptions(
timeoutInSeconds: 5,
signal: SIGTERM
signal: nil
)
public init(timeoutInSeconds: Int32, signal: Int32) {
public init(timeoutInSeconds: Int32, signal: String?) {
self.timeoutInSeconds = timeoutInSeconds
self.signal = signal
}
@@ -262,6 +262,7 @@ public struct Utility {
let caps = try Parser.capabilities(capAdd: management.capAdd, capDrop: management.capDrop)
config.capAdd = caps.capAdd
config.capDrop = caps.capDrop
config.stopSignal = imageConfig?.stopSignal
if let runtime = management.runtime {
config.runtimeHandler = runtime
@@ -636,8 +636,13 @@ public actor ContainersService {
return
}
var resolvedOptions = options
if resolvedOptions.signal == nil, let stopSignal = state.snapshot.configuration.stopSignal {
resolvedOptions.signal = stopSignal
}
do {
try await client.stop(options: options)
try await client.stop(options: resolvedOptions)
} catch let err as ContainerizationError {
if err.code != .interrupted {
throw err
@@ -842,7 +847,7 @@ public actor ContainersService {
}
let opts = ContainerStopOptions(
timeoutInSeconds: 5,
signal: SIGKILL
signal: "SIGKILL"
)
let client = try state.getClient()
try await client.stop(options: opts)
@@ -1204,7 +1204,8 @@ public actor RuntimeService {
try await lc.wait()
}
group.addTask {
try await lc.kill(Signal(rawValue: stopOpts.signal))
let signal = try Signal(stopOpts.signal ?? "SIGTERM")
try await lc.kill(signal)
try await Task.sleep(for: .seconds(stopOpts.timeoutInSeconds))
try await lc.kill(.kill)
@@ -0,0 +1,75 @@
//===----------------------------------------------------------------------===//
// 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
class TestCLIStop: CLITest {
private func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}
@Test func testStopWithExplicitSignal() throws {
let name = getTestName()
try doLongRun(name: name)
defer { try? doStop(name: name) }
try waitForContainerRunning(name)
try doStop(name: name, signal: "SIGTERM")
let status = try getContainerStatus(name)
#expect(status == "stopped")
}
@Test func testStopWithoutSignal() throws {
let name = getTestName()
try doLongRun(name: name)
defer { try? doStop(name: name) }
try waitForContainerRunning(name)
try doStop(name: name, signal: nil)
let status = try getContainerStatus(name)
#expect(status == "stopped")
}
@Test func testStopSignalInInspect() throws {
let name = getTestName()
try doLongRun(name: name)
defer { try? doStop(name: name) }
try waitForContainerRunning(name)
let inspect = try inspectContainer(name)
// Alpine doesn't set a STOPSIGNAL, so this should be nil.
#expect(inspect.configuration.stopSignal == nil)
}
@Test func testStopIdempotent() throws {
let name = getTestName()
try doLongRun(name: name)
defer { try? doStop(name: name) }
try waitForContainerRunning(name)
try doStop(name: name, signal: "SIGKILL")
let status = try getContainerStatus(name)
#expect(status == "stopped")
// Stopping an already stopped container should not fail.
try doStop(name: name, signal: "SIGKILL")
}
}
+7 -7
View File
@@ -329,13 +329,13 @@ class CLITest {
return resp
}
func doStop(name: String, signal: String = "SIGKILL") throws {
let (_, _, error, status) = try run(arguments: [
"stop",
"-s",
signal,
name,
])
func doStop(name: String, signal: String? = "SIGKILL") throws {
var arguments = ["stop"]
if let signal {
arguments.append(contentsOf: ["-s", signal])
}
arguments.append(name)
let (_, _, error, status) = try run(arguments: arguments)
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}