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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ integration: init-block
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINetwork || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunLifecycle || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIExecCommand || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIStopKillErrors || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLICreateCommand || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIImagesCommand || exit_code=1 ; \
Expand Down
69 changes: 55 additions & 14 deletions Sources/ContainerCommands/Container/ContainerKill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ extension Application {
@Argument(help: "Container IDs")
var containerIds: [String] = []

struct KillError: Error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this could be instead be moved into ContainerizationClient as public struct PartialSuccessError: Error?

let succeeded: [String]
let failed: [(String, Error)]
}

public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
Expand All @@ -50,31 +55,67 @@ extension Application {
}

public mutating func run() async throws {
let set = Set<String>(containerIds)
var containers = [ClientContainer]()
var allErrors: [String] = []

var containers = try await ClientContainer.list().filter { c in
c.status == .running
}
if !self.all {
containers = containers.filter { c in
set.contains(c.id)
let allContainers = try await ClientContainer.list()

if self.all {
containers = allContainers.filter { c in
c.status == .running
}
} else {
let containerMap = Dictionary(uniqueKeysWithValues: allContainers.map { ($0.id, $0) })

for id in containerIds {
if let container = containerMap[id] {
containers.append(container)
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need an error for trying to signal a non-running container here?

allErrors.append("Error: No such container: \(id)")
}
}
}

let signalNumber = try Signals.parseSignal(signal)

var failed: [String] = []
do {
try await Self.killContainers(containers: containers, signal: signalNumber)
for container in containers {
print(container.id)
}
} catch let error as KillError {
for id in error.succeeded {
print(id)
}

for (_, err) in error.failed {
allErrors.append("Error from APIServer: \(err.localizedDescription)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API server

}
}
if !allErrors.isEmpty {
var stderr = StandardError()
for error in allErrors {
print(error, to: &stderr)
}
throw ExitCode(1)
}
}

static func killContainers(containers: [ClientContainer], signal: Int32) async throws {
var succeeded: [String] = []
var failed: [(String, Error)] = []

for container in containers {
do {
try await container.kill(signalNumber)
print(container.id)
try await container.kill(signal)
succeeded.append(container.id)
} catch {
log.error("failed to kill container \(container.id): \(error)")
failed.append(container.id)
failed.append((container.id, error))
}
}
if failed.count > 0 {
throw ContainerizationError(.internalError, message: "kill failed for one or more containers \(failed.joined(separator: ","))")

if !failed.isEmpty {
throw KillError(succeeded: succeeded, failed: failed)
}
}
}
Expand Down
82 changes: 59 additions & 23 deletions Sources/ContainerCommands/Container/ContainerStop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,65 +43,101 @@ extension Application {
@Argument(help: "Container IDs")
var containerIds: [String] = []

package struct StopError: Error {
let succeeded: [String]
let failed: [(String, Error)]
}

public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
throw ContainerizationError(
.invalidArgument,
message: "no containers specified and --all not supplied"
)
}
if containerIds.count > 0 && all {
throw ContainerizationError(
.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag")
.invalidArgument,
message: "explicitly supplied container IDs conflict with the --all flag"
)
}
}

public mutating func run() async throws {
let set = Set<String>(containerIds)
var containers = [ClientContainer]()
var allErrors: [String] = []

if self.all {
containers = try await ClientContainer.list()
} else {
containers = try await ClientContainer.list().filter { c in
set.contains(c.id)
let allContainers = try await ClientContainer.list()
let containerMap = Dictionary(uniqueKeysWithValues: allContainers.map { ($0.id, $0) })

for id in containerIds {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Run state checks aren't wanted/needed here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The daemon returns an error if it's not running

if let container = containerMap[id] {
containers.append(container)
} else {
allErrors.append("Error: No such container: \(id)")
}
}
}

let opts = ContainerStopOptions(
timeoutInSeconds: self.time,
signal: try Signals.parseSignal(self.signal)
)
let failed = try await Self.stopContainers(containers: containers, stopOptions: opts)
if failed.count > 0 {
throw ContainerizationError(
.internalError,
message: "stop failed for one or more containers \(failed.joined(separator: ","))"
)

do {
try await Self.stopContainers(containers: containers, stopOptions: opts)
for container in containers {
print(container.id)
}
} catch let error as ContainerStop.StopError {
for id in error.succeeded {
print(id)
}

for (_, err) in error.failed {
allErrors.append("Error from APIServer: \(err)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API server

}
}

if !allErrors.isEmpty {
var stderr = StandardError()
for err in allErrors {
print(err, to: &stderr)
}
throw ExitCode(1)
}
}

static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] {
var failed: [String] = []
try await withThrowingTaskGroup(of: ClientContainer?.self) { group in
static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws {
var succeeded: [String] = []
var failed: [(String, Error)] = []
try await withThrowingTaskGroup(of: (String, Error?).self) { group in
for container in containers {
group.addTask {
do {
try await container.stop(opts: stopOptions)
print(container.id)
return nil
return (container.id, nil)
} catch {
log.error("failed to stop container \(container.id): \(error)")
return container
return (container.id, error)
}
}
}

for try await ctr in group {
guard let ctr else {
continue
for try await (id, error) in group {
if let error = error {
failed.append((id, error))
} else {
succeeded.append(id)
}
failed.append(ctr.id)
}
}

return failed
if !failed.isEmpty {
throw StopError(succeeded: succeeded, failed: failed)
}
}
}
}
8 changes: 8 additions & 0 deletions Sources/ContainerCommands/Container/ProcessUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ import ContainerizationError
import ContainerizationOS
import Foundation

struct StandardError: TextOutputStream, Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With #680 you should be able to just use the logger. I think you mentioned a while ago that the StderrLogHandler type I introduced for that isn't needed as there's an existing swift-logging type for logging to stderr, this might be a good time to make that switch.

See also #642.

private static let handle = FileHandle.standardError

public func write(_ string: String) {
Self.handle.write(Data(string.utf8))
}
}

extension Application {
static func ensureRunning(container: ClientContainer) throws {
if container.status != .running {
Expand Down
10 changes: 7 additions & 3 deletions Sources/ContainerCommands/System/SystemStop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ extension Application {
let containers = try await ClientContainer.list()
let signal = try Signals.parseSignal("SIGTERM")
let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal)
let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts)
if !failed.isEmpty {
log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"])

do {
try await ContainerStop.stopContainers(containers: containers, stopOptions: opts)
} catch let error as ContainerStop.StopError {
for (id, error) in error.failed {
log.warning("container \(id) failed to stop: \(error)")
}
}
} catch {
log.warning("failed to stop all containers", metadata: ["error": "\(error)"])
Expand Down
122 changes: 122 additions & 0 deletions Tests/CLITests/Subcommands/Containers/TestCLIStopKillErrors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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 TestCLIStopKillErrors: CLITest {
@Test func testStopNonExistentContainer() throws {
let nonExistentName = "nonexistent-container-\(UUID().uuidString)"

let (_, error, status) = try run(arguments: [
"stop",
nonExistentName,
])

#expect(status == 1)
#expect(error.contains(nonExistentName))
}

@Test func testStopMultipleNonExistentContainers() throws {
let nonExistent1 = "nonexistent-1-\(UUID().uuidString)"
let nonExistent2 = "nonexistent-2-\(UUID().uuidString)"

let (_, error, status) = try run(arguments: [
"stop",
nonExistent1,
nonExistent2,
])

#expect(status == 1)
#expect(error.contains(nonExistent1))
#expect(error.contains(nonExistent2))
}

@Test func testKillNonExistentContainer() throws {
let nonExistentName = "nonexistent-container-\(UUID().uuidString)"

let (_, error, status) = try run(arguments: [
"kill",
nonExistentName,
])

#expect(status == 1)
#expect(error.contains(nonExistentName))
}

@Test func testKillMultipleNonExistentContainers() throws {
let nonExistent1 = "nonexistent-1-\(UUID().uuidString)"
let nonExistent2 = "nonexistent-2-\(UUID().uuidString)"

let (_, error, status) = try run(arguments: [
"kill",
nonExistent1,
nonExistent2,
])

#expect(status == 1)
#expect(error.contains(nonExistent1))
#expect(error.contains(nonExistent2))
}

@Test func testStopMixedExistentAndNonExistent() throws {
let existentName = "test-stop-mixed-\(UUID().uuidString)"
let nonExistentName = "nonexistent-\(UUID().uuidString)"

try doCreate(name: existentName)
try doStart(name: existentName)
try waitForContainerRunning(existentName)

defer {
try? doStop(name: existentName)
try? doRemove(name: existentName)
}

let (output, error, status) = try run(arguments: [
"stop",
existentName,
nonExistentName,
])

#expect(status == 1)
#expect(output.contains(existentName))
#expect(error.contains(nonExistentName))
}

@Test func testKillMixedExistentAndNonExistent() throws {
let existentName = "test-kill-mixed-\(UUID().uuidString)"
let nonExistentName = "nonexistent-\(UUID().uuidString)"

try doCreate(name: existentName)
try doStart(name: existentName)
try waitForContainerRunning(existentName)

defer {
try? doStop(name: existentName)
try? doRemove(name: existentName)
}

let (output, error, status) = try run(arguments: [
"kill",
existentName,
nonExistentName,
])

#expect(status == 1)
#expect(output.contains(existentName))
#expect(error.contains(nonExistentName))
}
}