Skip to content

Commit 2d1d158

Browse files
authored
Dedupe user-supplied IDs for container delete. (apple#1388)
- Closes apple#1386. - Fixes a UX quirk.
1 parent 10df89e commit 2d1d158

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

Sources/ContainerCommands/Container/ContainerDelete.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ extension Application {
6767
return c.id
6868
}
6969
} else {
70-
containers = containerIds
70+
containers = Array(Set(containerIds))
7171
}
7272

7373
var errors: [any Error] = []
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import Testing
19+
20+
class TestCLIRemove: CLITest {
21+
22+
@Test func testDeleteStopped() async throws {
23+
let name = testName
24+
defer { try? doRemove(name: name, force: true) }
25+
26+
// Create without --rm so the container persists after being stopped
27+
let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", name, alpine, "sleep", "infinity"])
28+
#expect(createStatus == 0, "create failed: \(createError)")
29+
30+
let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", name])
31+
#expect(deleteStatus == 0, "delete failed: \(deleteError)")
32+
#expect(throws: CLIError.self) { try self.inspectContainer(name) }
33+
}
34+
35+
@Test func testDeleteAlias() async throws {
36+
let name = testName
37+
defer { try? doRemove(name: name, force: true) }
38+
39+
let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", name, alpine, "sleep", "infinity"])
40+
#expect(createStatus == 0, "create failed: \(createError)")
41+
42+
let (_, _, rmError, rmStatus) = try run(arguments: ["rm", name])
43+
#expect(rmStatus == 0, "rm failed: \(rmError)")
44+
#expect(throws: CLIError.self) { try self.inspectContainer(name) }
45+
}
46+
47+
@Test func testDeleteForceRunning() async throws {
48+
let name = testName
49+
defer { try? doRemove(name: name, force: true) }
50+
51+
try doLongRun(name: name, autoRemove: false)
52+
try waitForContainerRunning(name)
53+
54+
try doRemove(name: name, force: true)
55+
#expect(throws: CLIError.self) { try self.inspectContainer(name) }
56+
}
57+
58+
@Test func testDeleteAllStopped() async throws {
59+
let name1 = testName + "-1"
60+
let name2 = testName + "-2"
61+
defer {
62+
try? doRemove(name: name1, force: true)
63+
try? doRemove(name: name2, force: true)
64+
}
65+
66+
let (_, _, e1, s1) = try run(arguments: ["create", "--name", name1, alpine, "sleep", "infinity"])
67+
#expect(s1 == 0, "create \(name1) failed: \(e1)")
68+
let (_, _, e2, s2) = try run(arguments: ["create", "--name", name2, alpine, "sleep", "infinity"])
69+
#expect(s2 == 0, "create \(name2) failed: \(e2)")
70+
71+
let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", "--all"])
72+
#expect(deleteStatus == 0, "delete --all failed: \(deleteError)")
73+
#expect(throws: CLIError.self) { try self.inspectContainer(name1) }
74+
#expect(throws: CLIError.self) { try self.inspectContainer(name2) }
75+
}
76+
77+
@Test func testDeleteAllSkipsRunning() async throws {
78+
let runningName = testName + "-running"
79+
let stoppedName = testName + "-stopped"
80+
defer {
81+
try? doRemove(name: runningName, force: true)
82+
try? doRemove(name: stoppedName, force: true)
83+
}
84+
85+
try doLongRun(name: runningName, autoRemove: false)
86+
try waitForContainerRunning(runningName)
87+
88+
let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", stoppedName, alpine, "sleep", "infinity"])
89+
#expect(createStatus == 0, "create failed: \(createError)")
90+
91+
let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", "--all"])
92+
#expect(deleteStatus == 0, "delete --all failed: \(deleteError)")
93+
94+
// Running container should be untouched
95+
#expect(try getContainerStatus(runningName) == "running")
96+
// Stopped container should be gone
97+
#expect(throws: CLIError.self) { try self.inspectContainer(stoppedName) }
98+
}
99+
100+
@Test func testDeleteAllForce() async throws {
101+
let name = testName
102+
defer { try? doRemove(name: name, force: true) }
103+
104+
try doLongRun(name: name, autoRemove: false)
105+
try waitForContainerRunning(name)
106+
107+
let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", "--all", "--force"])
108+
#expect(deleteStatus == 0, "delete --all --force failed: \(deleteError)")
109+
#expect(throws: CLIError.self) { try self.inspectContainer(name) }
110+
}
111+
112+
@Test func testDeleteNoArgs() throws {
113+
let (_, _, _, status) = try run(arguments: ["delete"])
114+
#expect(status != 0, "Expected non-zero exit when no args and no --all")
115+
}
116+
117+
@Test func testDeleteExplicitIdsConflictWithAll() throws {
118+
let (_, _, error, status) = try run(arguments: ["delete", "--all", "some-container"])
119+
#expect(status != 0, "Expected non-zero exit for conflicting flags")
120+
#expect(error.contains("conflict"))
121+
}
122+
123+
@Test func testDeleteDuplicateIds() async throws {
124+
let name = testName
125+
defer { try? doRemove(name: name, force: true) }
126+
127+
let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", name, alpine, "sleep", "infinity"])
128+
#expect(createStatus == 0, "create failed: \(createError)")
129+
130+
let (_, output, deleteError, deleteStatus) = try run(arguments: ["delete", name, name])
131+
#expect(deleteStatus == 0, "delete with duplicate IDs failed: \(deleteError)")
132+
let lines = output.split(separator: "\n").filter { $0.contains(name) }
133+
#expect(lines.count == 1, "Expected container to be deleted exactly once, got \(lines.count) lines")
134+
}
135+
}

0 commit comments

Comments
 (0)