Migrates container run integration tests. (#1857)

- Part of #1833.
This commit is contained in:
J Logan
2026-07-01 13:23:16 -07:00
committed by GitHub
parent 9bd3c47345
commit 69a2505aeb
19 changed files with 2069 additions and 1695 deletions
+1
View File
@@ -198,6 +198,7 @@ endef
# PARALLEL_WIDTH controls --experimental-maximum-parallelization-width for the
# concurrent pass. WARMUP_FILTER, CONCURRENT_FILTER, and GLOBAL_FILTER select
# the three phases. Expand the filter lists as suites are migrated from CLITests.
#PARALLEL_WIDTH ?= $(shell sysctl -n hw.physicalcpu)
PARALLEL_WIDTH ?= 2
WARMUP_FILTER = ImageWarmup/
+2
View File
@@ -90,6 +90,8 @@ let package = Package(
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "TOML", package: "swift-toml"),
"ContainerAPIClient",
"ContainerLog",
"ContainerPersistence",
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// 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 TestCLIProgressAuto: CLITest {
@Test func testAutoProgressFallsBackToPlainWhenPiped() throws {
let (_, _, error, status) = try run(arguments: [
"image", "pull",
"--progress", "auto",
alpine,
])
#expect(status == 0, "image pull should succeed, stderr: \(error)")
let lines = error.components(separatedBy: .newlines)
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
#expect(!lines.isEmpty, "expected plain progress output on stderr when piped")
#expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output")
}
@Test func testExplicitPlainProgress() throws {
let (_, _, error, status) = try run(arguments: [
"image", "pull",
"--progress", "plain",
alpine,
])
#expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)")
let lines = error.components(separatedBy: .newlines)
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
#expect(!lines.isEmpty, "expected plain progress output on stderr")
#expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain")
}
@Test func testExplicitAnsiProgress() throws {
let (_, _, error, status) = try run(arguments: [
"image", "pull",
"--progress", "ansi",
alpine,
])
#expect(status == 0, "image pull --progress ansi should succeed, stderr: \(error)")
let lines = error.components(separatedBy: .newlines)
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
#expect(!lines.isEmpty, "expected ansi progress output on stderr")
}
@Test func testNoneProgressSuppressesOutput() throws {
let (_, _, error, status) = try run(arguments: [
"image", "pull",
"--progress", "none",
alpine,
])
#expect(status == 0, "image pull --progress none should succeed, stderr: \(error)")
let lines = error.components(separatedBy: .newlines)
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
#expect(lines.isEmpty, "expected no progress output on stderr with --progress none")
}
}
@@ -0,0 +1,323 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 AsyncHTTPClient
import ContainerAPIClient
import ContainerizationError
import ContainerizationExtras
import ContainerizationOS
import Foundation
import Testing
@Suite(.serialSuites, .serialized)
class TestCLINetwork: CLITest {
private static let retries = 10
private static let retryDelaySeconds = Int64(3)
private func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}
private func getLowercasedTestName() -> String {
getTestName().lowercased()
}
@available(macOS 26, *)
@Test func testNetworkCreateAndUse() async throws {
do {
let name = getLowercasedTestName()
let networkDeleteArgs = ["network", "delete", name]
_ = try? run(arguments: networkDeleteArgs)
let networkCreateArgs = ["network", "create", name]
let result = try run(arguments: networkCreateArgs)
if result.status != 0 {
throw CLIError.executionFailed("command failed: \(result.error)")
}
defer {
_ = try? run(arguments: networkDeleteArgs)
}
let listResult = try? run(arguments: ["network", "ls", "--quiet"])
let networkIds =
listResult?.output
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter({ !$0.isEmpty })
?? ["OUTPUT MISSING"]
#expect(networkIds == networkIds.sorted(), "network IDs should be sorted")
let port = UInt16.random(in: 50000..<60000)
try doLongRun(
name: name,
image: "docker.io/library/python:alpine",
args: ["--network", name],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"])
defer {
try? doStop(name: name)
}
let container = try inspectContainer(name)
#expect(container.networks.count > 0)
let cidrAddress = container.networks[0].ipv4Address
let url = "http://\(cidrAddress.address):\(port)"
var request = HTTPClientRequest(url: url)
request.method = .GET
let client = getClient(useHttpProxy: false)
defer { _ = client.shutdown() }
var retriesRemaining = Self.retries
var success = false
while !success && retriesRemaining > 0 {
do {
let response = try await client.execute(request, timeout: .seconds(Self.retryDelaySeconds))
try #require(response.status == .ok)
success = true
} catch {
print("request to \(url) failed, error \(error)")
try await Task.sleep(for: .seconds(Self.retryDelaySeconds))
}
retriesRemaining -= 1
}
#expect(success, "Request to \(url) failed after \(Self.retries - retriesRemaining) retries")
try doStop(name: name)
} catch {
Issue.record("failed to create and use network \(error)")
return
}
}
@available(macOS 26, *)
@Test func testNetworkDeleteWithContainer() async throws {
do {
// prep: delete container and network, ignoring if it doesn't exist
let name = getLowercasedTestName()
try? doRemove(name: name)
let networkDeleteArgs = ["network", "delete", name]
_ = try? run(arguments: networkDeleteArgs)
// create our network
let networkCreateArgs = ["network", "create", name]
let networkCreateResult = try run(arguments: networkCreateArgs)
if networkCreateResult.status != 0 {
throw CLIError.executionFailed("command failed: \(networkCreateResult.error)")
}
// ensure it's deleted
defer {
_ = try? run(arguments: networkDeleteArgs)
}
// create a container that refers to the network
try doCreate(name: name, networks: [name])
defer {
try? doRemove(name: name)
}
// deleting the network should fail
let networkDeleteResult = try run(arguments: networkDeleteArgs)
try #require(networkDeleteResult.status != 0)
// and should fail with a certain message
let msg = networkDeleteResult.error
#expect(msg.contains("delete failed"))
#expect(msg.contains("[\"\(name)\"]"))
// now get rid of the container and its network reference
try? doRemove(name: name)
// delete should succeed
_ = try run(arguments: networkDeleteArgs)
} catch {
Issue.record("failed to safely delete network \(error)")
return
}
}
@available(macOS 26, *)
@Test func testNetworkLabels() async throws {
do {
// prep: delete container and network, ignoring if it doesn't exist
let name = getLowercasedTestName()
try? doRemove(name: name)
let networkDeleteArgs = ["network", "delete", name]
_ = try? run(arguments: networkDeleteArgs)
// create our network
let networkCreateArgs = ["network", "create", "--label", "foo=bar", "--label", "baz=qux", name]
let networkCreateResult = try run(arguments: networkCreateArgs)
guard networkCreateResult.status == 0 else {
throw CLIError.executionFailed("command failed: \(networkCreateResult.error)")
}
// ensure it's deleted
defer {
_ = try? run(arguments: networkDeleteArgs)
}
// inspect the network
let networkInspectArgs = ["network", "inspect", name]
let networkInspectResult = try run(arguments: networkInspectArgs)
guard networkInspectResult.status == 0 else {
throw CLIError.executionFailed("command failed: \(networkInspectResult.error)")
}
// decode the JSON result
let networkInspectOutput = networkInspectResult.output
guard let jsonData = networkInspectOutput.data(using: .utf8) else {
throw CLIError.invalidOutput("network inspect output invalid")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let networks = try decoder.decode([NetworkInspectOutput].self, from: jsonData)
guard networks.count == 1 else {
throw CLIError.invalidOutput("expected exactly one network from inspect, got \(networks.count)")
}
// validate labels
let expectedLabels = [
"foo": "bar",
"baz": "qux",
]
#expect(expectedLabels == networks[0].configuration.labels.dictionary)
// delete should succeed
_ = try run(arguments: networkDeleteArgs)
} catch {
Issue.record("failed to safely delete network \(error)")
return
}
}
@Test func testNetworkMTU() async throws {
let name = getLowercasedTestName()
try? doStop(name: name)
try? doRemove(name: name)
try doLongRun(name: name, args: ["--network", "default,mtu=1500"])
defer { try? doStop(name: name) }
try waitForContainerRunning(name)
let output = try doExec(name: name, cmd: ["ip", "link", "show", "eth0"])
#expect(output.contains("mtu 1500"), "expected mtu 1500 in ip link output: \(output)")
}
@available(macOS 26, *)
@Test func testIsolatedNetwork() async throws {
do {
let name = getLowercasedTestName()
let networkDeleteArgs = ["network", "delete", name]
_ = try? run(arguments: networkDeleteArgs)
let networkCreateArgs = ["network", "create", "--internal", name]
let result = try run(arguments: networkCreateArgs)
if result.status != 0 {
throw CLIError.executionFailed("command failed: \(result.error)")
}
defer {
_ = try? run(arguments: networkDeleteArgs)
}
let port = UInt16.random(in: 50000..<60000)
try doLongRun(
name: name,
image: "docker.io/library/python:alpine",
args: ["--network", name],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"]
)
defer {
try? doStop(name: name)
}
let container = try inspectContainer(name)
#expect(container.networks.count > 0)
let curlImage = "docker.io/curlimages/curl:8.6.0"
let cidrAddress = container.networks[0].ipv4Address
let url = "http://\(cidrAddress.address):\(port)"
let (_, _, _, succeed) = try run(arguments: [
"run",
"--rm",
"--network",
name,
curlImage,
"curl",
url,
])
#expect(succeed == 0, "internal connection should succeed")
let (_, _, _, failed) = try run(arguments: [
"run",
"--rm",
"--network",
name,
curlImage,
"curl",
"--connect-timeout",
"5",
"http://google.com",
])
// hostOnly mode blocks off-host traffic; depending on whether vmnet/firewall
// rejects (7) or drops (28) packets, or DNS itself can't reach an external
// resolver (6), curl will fail with one of these codes.
let hostOnlyBlockedCodes: Set<Int32> = [6, 7, 28]
#expect(hostOnlyBlockedCodes.contains(failed), "external connection should fail")
}
}
@Test func testNetworkListTableFormat() throws {
let name = getLowercasedTestName()
_ = try? run(arguments: ["network", "delete", name])
let createResult = try run(arguments: ["network", "create", name])
if createResult.status != 0 {
throw CLIError.executionFailed("network create failed: \(createResult.error)")
}
defer { _ = try? run(arguments: ["network", "delete", name]) }
let (_, output, error, status) = try run(arguments: ["network", "list"])
#expect(status == 0, "network list should succeed, stderr: \(error)")
let headers = ["NETWORK", "SUBNET"]
#expect(headers.allSatisfy { output.contains($0) }, "table should contain all headers")
#expect(output.contains(name), "table should contain the created network")
}
@Test func testNetworkListJSONFormat() throws {
let name = getLowercasedTestName()
_ = try? run(arguments: ["network", "delete", name])
let createResult = try run(arguments: ["network", "create", name])
if createResult.status != 0 {
throw CLIError.executionFailed("network create failed: \(createResult.error)")
}
defer { _ = try? run(arguments: ["network", "delete", name]) }
let (data, _, error, status) = try run(arguments: ["network", "list", "--format", "json"])
#expect(status == 0, "network list --format json should succeed, stderr: \(error)")
guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else {
Issue.record("JSON output should be an array of objects")
return
}
#expect(json.contains { ($0["id"] as? String) == name }, "JSON should contain the created network")
}
@Test func testInspectMissingNetworkFails() throws {
let (_, _, error, status) = try run(arguments: ["network", "inspect", "definitely-missing-network"])
#expect(status != 0, "Expected non-zero exit for missing network")
#expect(error.contains("network not found"))
}
}
@@ -1,5 +1,5 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
// Copyright © 2025-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.
@@ -16,22 +16,19 @@
import Testing
@Suite
struct TestCLIPluginErrors {
@Test func testHelpfulMessageWhenPluginsUnavailable() async throws {
@Test
func testHelpfulMessageWhenPluginsUnavailable() throws {
// Intentionally invoke an unknown plugin command. In CI this should run
// without the APIServer started, so DefaultCommand will fail to create
// a PluginLoader and emit the improved guidance.
try await ContainerFixture.with { f in
let result = try f.run(["nosuchplugin"])
#expect(result.status != 0)
#expect(result.error.contains("container system start"))
#expect(
result.error.contains("Plugins are unavailable")
|| result.error.contains("Plugin 'container-"))
#expect(
result.error.contains("container-plugins")
|| result.error.contains("container/plugins"))
}
let cli = try CLITest()
let (_, _, stderr, status) = try cli.run(arguments: ["nosuchplugin"]) // non-existent plugin name
#expect(status != 0)
#expect(stderr.contains("container system start"))
#expect(stderr.contains("Plugins are unavailable") || stderr.contains("Plugin 'container-"))
// Should include at least one computed plugin search path hint
#expect(stderr.contains("container-plugins") || stderr.contains("container/plugins"))
}
}
@@ -0,0 +1,48 @@
//===----------------------------------------------------------------------===//
// 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
@Suite(.serialSuites)
class TestCLIRegistry: CLITest {
@Test func testListDefaultFormat() throws {
let (_, output, error, status) = try run(arguments: ["registry", "list"])
#expect(status == 0, "registry list should succeed, stderr: \(error)")
let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]
#expect(
requiredHeaders.allSatisfy { output.contains($0) },
"output should contain all required headers"
)
}
@Test func testListJSONFormat() throws {
let (data, _, error, status) = try run(arguments: ["registry", "list", "--format", "json"])
#expect(status == 0, "registry list --format json should succeed, stderr: \(error)")
let json = try JSONSerialization.jsonObject(with: data, options: [])
#expect(json is [Any], "JSON output should be an array")
}
@Test func testListQuietMode() throws {
let (_, output, error, status) = try run(arguments: ["registry", "list", "-q"])
#expect(status == 0, "registry list -q should succeed, stderr: \(error)")
#expect(!output.contains("HOSTNAME"), "quiet mode should not contain headers")
#expect(!output.contains("USERNAME"), "quiet mode should not contain headers")
}
}
@@ -1,486 +0,0 @@
//===----------------------------------------------------------------------===//
// 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 ContainerAPIClient
import Foundation
import Testing
@Suite(.serialSuites)
class TestCLIRunCapabilities: CLITest {
func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}
// MARK: - Invalid capability names
@Test func testCapDropInvalid() throws {
let (_, _, error, status) = try run(arguments: [
"run", "--rm", "--cap-drop=CHWOWZERS", alpine, "ls",
])
#expect(status != 0, "expected non-zero exit for invalid cap-drop")
#expect(error.contains("CHWOWZERS") || error.contains("invalid"), "expected error about invalid capability, got: \(error)")
}
@Test func testCapAddInvalid() throws {
let (_, _, error, status) = try run(arguments: [
"run", "--rm", "--cap-add=CHWOWZERS", alpine, "ls",
])
#expect(status != 0, "expected non-zero exit for invalid cap-add")
#expect(error.contains("CHWOWZERS") || error.contains("invalid"), "expected error about invalid capability, got: \(error)")
}
// MARK: - Config stored correctly via inspect
@Test func testCapAddStored() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-add", "NET_ADMIN"])
defer { try? doStop(name: name) }
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.capAdd.contains("CAP_NET_ADMIN"), "expected CAP_NET_ADMIN in capAdd")
#expect(inspectResp.configuration.capDrop.isEmpty, "expected empty capDrop")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapDropStored() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-drop", "MKNOD"])
defer { try? doStop(name: name) }
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.capDrop.contains("CAP_MKNOD"), "expected CAP_MKNOD in capDrop")
#expect(inspectResp.configuration.capAdd.isEmpty, "expected empty capAdd")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapAddDropALLStored() throws {
do {
let name = getTestName()
try doLongRun(
name: name,
args: [
"--cap-drop", "ALL",
"--cap-add", "SETGID",
"--cap-add", "NET_RAW",
])
defer { try? doStop(name: name) }
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.capDrop.contains("ALL"), "expected ALL in capDrop")
#expect(inspectResp.configuration.capAdd.contains("CAP_SETGID"), "expected CAP_SETGID in capAdd")
#expect(inspectResp.configuration.capAdd.contains("CAP_NET_RAW"), "expected CAP_NET_RAW in capAdd")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapAddALLStored() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-add", "ALL"])
defer { try? doStop(name: name) }
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.capAdd.contains("ALL"), "expected ALL in capAdd")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapDropLowerCase() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-drop", "mknod"])
defer { try? doStop(name: name) }
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.capDrop.contains("CAP_MKNOD"), "expected normalized CAP_MKNOD in capDrop")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
// MARK: - In-container capability verification
@Test func testCapDropMknodCannotMknod() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-drop", "MKNOD"])
defer { try? doStop(name: name) }
let (_, output, _, status) = try run(arguments: [
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed != "ok", "mknod should fail with CAP_MKNOD dropped")
#expect(status != 0, "expected non-zero exit when mknod fails")
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapDropMknodLowerCaseCannotMknod() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-drop", "mknod"])
defer { try? doStop(name: name) }
let (_, output, _, status) = try run(arguments: [
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed != "ok", "mknod should fail with CAP_MKNOD dropped (lowercase)")
#expect(status != 0, "expected non-zero exit when mknod fails")
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapDropALLCannotMknod() throws {
do {
let name = getTestName()
try doLongRun(
name: name,
args: [
"--cap-drop", "ALL",
"--cap-add", "SETGID",
])
defer { try? doStop(name: name) }
let (_, output, _, status) = try run(arguments: [
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed != "ok", "mknod should fail when ALL dropped and MKNOD not re-added")
#expect(status != 0, "expected non-zero exit")
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapDropALLAddMknodCanMknod() throws {
do {
let name = getTestName()
try doLongRun(
name: name,
args: [
"--cap-drop", "ALL",
"--cap-add", "MKNOD",
"--cap-add", "SETGID",
])
defer { try? doStop(name: name) }
let output = try doExec(
name: name,
cmd: [
"sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed == "ok", "mknod should succeed when MKNOD is explicitly re-added")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapAddALLCanDownInterface() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-add", "ALL"])
defer { try? doStop(name: name) }
let output = try doExec(
name: name,
cmd: [
"sh", "-c", "ip link set lo down && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed == "ok", "ip link set should succeed with ALL caps")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapAddALLDropNetAdminCannotDownInterface() throws {
do {
let name = getTestName()
try doLongRun(
name: name,
args: [
"--cap-add", "ALL",
"--cap-drop", "NET_ADMIN",
])
defer { try? doStop(name: name) }
let (_, output, _, status) = try run(arguments: [
"exec", name, "sh", "-c", "ip link set lo down && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed != "ok", "ip link set should fail with NET_ADMIN dropped")
#expect(status != 0, "expected non-zero exit when NET_ADMIN is dropped")
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapAddNetAdminCanDownInterface() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-add", "NET_ADMIN"])
defer { try? doStop(name: name) }
let output = try doExec(
name: name,
cmd: [
"sh", "-c", "ip link set lo down && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed == "ok", "ip link set should succeed with NET_ADMIN added")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
// MARK: - Default capability behavior
@Test func testDefaultCapChown() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer { try? doStop(name: name) }
// chown should succeed with default caps (CAP_CHOWN is in OCI defaults)
// doExec throws on non-zero exit, so success here means CAP_CHOWN is present
_ = try doExec(name: name, cmd: ["chown", "100", "/tmp"])
try doStop(name: name)
} catch {
Issue.record("chown should succeed with default caps: \(error)")
}
}
@Test func testNonRootUserCannotReadShadow() throws {
// Regression test for https://github.com/apple/container/issues/1352
// Verifies that exec as a non-root user enforces file permissions.
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer { try? doStop(name: name) }
// Root should be able to read /etc/shadow
_ = try doExec(name: name, cmd: ["cat", "/etc/shadow"])
// Non-root user (nobody) should NOT be able to read /etc/shadow
let (_, _, _, status) = try run(arguments: [
"exec", "-u", "nobody", name, "cat", "/etc/shadow",
])
#expect(status != 0, "non-root user should not be able to read /etc/shadow")
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapDropChown() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-drop", "chown"])
defer { try? doStop(name: name) }
let (_, _, _, status) = try run(arguments: [
"exec", name, "chown", "100", "/tmp",
])
#expect(status != 0, "chown should fail when CAP_CHOWN is dropped")
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testDefaultCapFowner() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer { try? doStop(name: name) }
// chmod on a file owned by root should succeed with CAP_FOWNER
_ = try doExec(name: name, cmd: ["chmod", "777", "/etc/passwd"])
try doStop(name: name)
} catch {
Issue.record("chmod should succeed with default caps: \(error)")
}
}
// MARK: - Capability bitmask verification via /proc
@Test func testCapDropALLShowsZeroCaps() throws {
do {
let name = getTestName()
try doLongRun(
name: name,
args: [
"--cap-drop", "ALL",
"--cap-add", "SETUID",
"--cap-add", "SETGID",
])
defer { try? doStop(name: name) }
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
// Verify CapEff is non-zero (SETUID and SETGID are granted)
let lines = output.components(separatedBy: "\n")
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
if let capEffLine {
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
// With only SETUID (7) and SETGID (6), the bitmask should be non-zero but small
#expect(value != "0000000000000000", "expected non-zero CapEff with SETUID+SETGID")
// Verify it's NOT the full capability set
#expect(value != "000001ffffffffff", "expected restricted caps, not full set")
}
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testNoCapFlagsUsesDefaultCaps() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer { try? doStop(name: name) }
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
let lines = output.components(separatedBy: "\n")
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
if let capEffLine {
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
// Default OCI caps should produce a non-zero, restricted bitmask
#expect(value != "0000000000000000", "expected non-zero CapEff with default OCI caps")
}
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapAddALLShowsFullCaps() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--cap-add", "ALL"])
defer { try? doStop(name: name) }
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
let lines = output.components(separatedBy: "\n")
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
if let capEffLine {
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
// With ALL capabilities the bitmask should have all bits set for known caps
#expect(value != "0000000000000000", "expected non-zero CapEff with ALL caps")
}
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
@Test func testCapDropALLOnlyShowsZeroEffective() throws {
do {
let name = getTestName()
// Drop ALL with no adds - effective set should be empty
try doLongRun(name: name, args: ["--cap-drop", "ALL"])
defer { try? doStop(name: name) }
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
let lines = output.components(separatedBy: "\n")
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
if let capEffLine {
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
#expect(value == "0000000000000000", "expected zero CapEff when ALL caps dropped, got \(value)")
}
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
// MARK: - Multiple cap-add and cap-drop combined
@Test func testMultipleCapAddDrop() throws {
do {
let name = getTestName()
try doLongRun(
name: name,
args: [
"--cap-add", "SYS_ADMIN",
"--cap-add", "NET_RAW",
"--cap-drop", "MKNOD",
"--cap-drop", "CHOWN",
])
defer { try? doStop(name: name) }
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.capAdd.count == 2)
#expect(inspectResp.configuration.capDrop.count == 2)
#expect(inspectResp.configuration.capAdd.contains("CAP_SYS_ADMIN"))
#expect(inspectResp.configuration.capAdd.contains("CAP_NET_RAW"))
#expect(inspectResp.configuration.capDrop.contains("CAP_MKNOD"))
#expect(inspectResp.configuration.capDrop.contains("CAP_CHOWN"))
// Verify MKNOD is actually dropped
let (_, output, _, _) = try run(arguments: [
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
])
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed != "ok", "mknod should fail when CAP_MKNOD is dropped")
try doStop(name: name)
} catch {
Issue.record("failed: \(error)")
}
}
}
@@ -1,953 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 AsyncHTTPClient
import ContainerAPIClient
import ContainerizationExtras
import ContainerizationOS
import Foundation
import Testing
// FIXME: We've split the tests into two suites to prevent swamping
// the API server with so many run commands that all wind up pulling
// images.
//
// When https://github.com/swiftlang/swift-testing/pull/1390 lands
// and is available on the CI runners, we can try setting the
// environment variable to limit concurrency and rejoin these suites.
@Suite(.serialSuites)
class TestCLIRunCommand1: CLITest {
func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}
func getLowercasedTestName() -> String {
getTestName().lowercased()
}
@Test func testRunCommand() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer {
try? doStop(name: name)
}
let _ = try doExec(name: name, cmd: ["date"])
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandCWD() throws {
do {
let name = getTestName()
let expectedCWD = "/tmp"
try doLongRun(name: name, args: ["--cwd", expectedCWD])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["pwd"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == expectedCWD, "expected current working directory to be \(expectedCWD), instead got \(output)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandEnv() throws {
do {
let name = getTestName()
let envData = "FOO=bar"
try doLongRun(name: name, args: ["--env", envData])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
#expect(
inspectResp.configuration.initProcess.environment.contains(envData),
"environment variable \(envData) not set in container configuration")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandEnvFile() throws {
do {
let name = getTestName()
let content = """
# Really cool comment
FOO=bar
BAR=baz wow
URL=https://foo.bar?baz=wow
"""
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test.env")
guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) else {
Issue.record("failed to create temporary file \(tempFile.path())")
return
}
defer {
try? FileManager.default.removeItem(at: tempFile)
}
try doLongRun(name: name, args: ["--env-file", tempFile.path()])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
let expected = [
"FOO=bar",
"BAR=baz wow",
"URL=https://foo.bar?baz=wow",
]
for item in expected {
#expect(
inspectResp.configuration.initProcess.environment.contains(item),
"environment variable \(item) not set in container configuration")
}
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandUserIDGroupID() throws {
do {
let name = getTestName()
let uid = "10"
let gid = "100"
try doLongRun(name: name, args: ["--uid", uid, "--gid", gid])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["id"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
try #expect(output.contains(Regex("uid=\(uid).*?gid=\(gid).*")), "invalid user/group id, got \(output)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandUser() throws {
do {
let name = getTestName()
let user = "nobody"
try doLongRun(name: name, args: ["--user", user])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["whoami"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == user, "expected user \(user), got \(output)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandCPUs() throws {
do {
let name = getTestName()
let cpus = 2
try doLongRun(name: name, args: ["--cpus", "\(cpus)"])
defer {
try? doStop(name: name)
}
let cpusPath = "/sys/fs/cgroup/cpu.max"
let output = try doExec(name: name, cmd: ["cat", cpusPath])
let fields = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .whitespaces)
#expect(fields.count == 2, "expected 2 fields in \(cpusPath), instead got \(fields.count)")
let numerator = try #require(Int(fields[0]))
let denominator = try #require(Int(fields[1]))
#expect(denominator > 0, "expected positive denominator in \(cpusPath), instead got \(denominator)")
let expectedNumerator = cpus * denominator
#expect(expectedNumerator == numerator, "expected \(expectedNumerator) in \(cpusPath), instead got \(numerator)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandMemory() throws {
do {
let name = getTestName()
let expectedMBs = 1024
try doLongRun(name: name, args: ["--memory", "\(expectedMBs)M"])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
let actualInBytes = inspectResp.configuration.resources.memoryInBytes
#expect(actualInBytes == expectedMBs.mib(), "expected \(expectedMBs.mib()) bytes, instead got \(actualInBytes) bytes")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandUlimitNofile() throws {
do {
let name = getTestName()
let softLimit = "1024"
let hardLimit = "2048"
try doLongRun(name: name, args: ["--ulimit", "nofile=\(softLimit):\(hardLimit)"])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
let rlimits = inspectResp.configuration.initProcess.rlimits
let nofileRlimit = rlimits.first { $0.limit == "RLIMIT_NOFILE" }
#expect(nofileRlimit != nil, "expected RLIMIT_NOFILE to be set")
#expect(nofileRlimit?.soft == UInt64(softLimit), "expected soft limit \(softLimit), got \(nofileRlimit?.soft ?? 0)")
#expect(nofileRlimit?.hard == UInt64(hardLimit), "expected hard limit \(hardLimit), got \(nofileRlimit?.hard ?? 0)")
var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -n"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == softLimit, "expected ulimit -n to return \(softLimit), got \(output)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandUlimitNproc() throws {
do {
let name = getTestName()
let limit = "256"
try doLongRun(name: name, args: ["--ulimit", "nproc=\(limit)"])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
let rlimits = inspectResp.configuration.initProcess.rlimits
let nprocRlimit = rlimits.first { $0.limit == "RLIMIT_NPROC" }
#expect(nprocRlimit != nil, "expected RLIMIT_NPROC to be set")
#expect(nprocRlimit?.soft == UInt64(limit), "expected soft limit \(limit), got \(nprocRlimit?.soft ?? 0)")
#expect(nprocRlimit?.hard == UInt64(limit), "expected hard limit \(limit), got \(nprocRlimit?.hard ?? 0)")
var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -u"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == limit, "expected ulimit -u to return \(limit), got \(output)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandMultipleUlimits() throws {
do {
let name = getTestName()
try doLongRun(
name: name,
args: [
"--ulimit", "nofile=1024:2048",
"--ulimit", "nproc=512",
"--ulimit", "stack=8388608",
])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
let rlimits = inspectResp.configuration.initProcess.rlimits
#expect(rlimits.count == 3, "expected 3 rlimits, got \(rlimits.count)")
let nofile = rlimits.first { $0.limit == "RLIMIT_NOFILE" }
let nproc = rlimits.first { $0.limit == "RLIMIT_NPROC" }
let stack = rlimits.first { $0.limit == "RLIMIT_STACK" }
#expect(nofile != nil && nofile?.soft == 1024 && nofile?.hard == 2048)
#expect(nproc != nil && nproc?.soft == 512 && nproc?.hard == 512)
#expect(stack != nil && stack?.soft == 8_388_608 && stack?.hard == 8_388_608)
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
}
@Suite(.serialSuites)
class TestCLIRunCommand2: CLITest {
func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}
func getLowercasedTestName() -> String {
getTestName().lowercased()
}
@Test func testRunCommandMount() throws {
do {
let name = getTestName()
let targetContainerPath = "/tmp/testmount"
let testData = "hello world"
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempFile = tempDir.appendingPathComponent(UUID().uuidString)
guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(testData.utf8)) else {
Issue.record("failed to create temporary file \(tempFile.path())")
return
}
defer {
try? FileManager.default.removeItem(at: tempDir)
}
try doLongRun(name: name, args: ["--mount", "type=virtiofs,source=\(tempDir.path()),target=\(targetContainerPath),readonly"])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(tempFile.lastPathComponent)"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandUnixSocketMount() throws {
do {
let name = getTestName()
let socketPath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let guestSocketPath = "/run/ssh-auth.sock"
let socketType = try UnixType(path: socketPath.path, perms: 0o766, unlinkExisting: true)
let socket = try Socket(type: socketType, closeOnDeinit: true)
try socket.listen()
defer {
try? socket.close()
try? FileManager.default.removeItem(at: socketPath)
}
try doLongRun(
name: name,
args: [
"-v", "\(socketPath.path):\(guestSocketPath)",
"-e", "SSH_AUTH_SOCK=\(guestSocketPath)",
]
)
defer {
try? doStop(name: name)
}
_ = try doExec(name: name, cmd: ["apk", "add", "netcat-openbsd"])
let permsOutput = try doExec(
name: name,
cmd: ["sh", "-c", "stat -c \"%a\" \"${SSH_AUTH_SOCK}\""],
user: "guest"
).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(permsOutput == "766", "expected socket permissions 766, got \(permsOutput)")
_ = try doExec(name: name, cmd: ["sh", "-c", "nc -zU \"${SSH_AUTH_SOCK}\""], user: "guest")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandTmpfs() throws {
do {
let name = getTestName()
let targetContainerPath = "/tmp/testtmpfs"
let expectedFilesystem = "tmpfs"
try doLongRun(name: name, args: ["--tmpfs", targetContainerPath])
defer {
try? doStop(name: name)
}
let output = try doExec(name: name, cmd: ["df", targetContainerPath])
let lines = output.split(separator: "\n")
#expect(lines.count == 2, "expected only two rows of output, instead got \(lines.count)")
let words = lines[1].split(separator: " ")
#expect(words.count > 1, "expected information to contain multiple words, got \(words.count)")
#expect(words[0].lowercased() == expectedFilesystem, "expected filesystem type to be \(expectedFilesystem), instead got \(output)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandShmSize() throws {
do {
let name = getTestName()
let shmSize = "128m"
let expectedKB = 128 * 1024
try doLongRun(name: name, args: ["--shm-size", shmSize])
defer {
try? doStop(name: name)
}
let output = try doExec(name: name, cmd: ["mount"])
let shmLine = output.split(separator: "\n").first { $0.contains("/dev/shm") }
#expect(shmLine != nil, "expected /dev/shm in mount output")
#expect(shmLine!.contains("size=\(expectedKB)k"), "expected size=\(expectedKB)k in mount options, got: \(shmLine!)")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandOSArch() throws {
do {
let name = getLowercasedTestName()
let os = "linux"
let arch = "amd64"
let expectedArch = "x86_64"
try doLongRun(name: name, args: ["--os", os, "--arch", arch])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["uname", "-sm"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
#expect(output == "\(os) \(expectedArch)", "expected container to use '\(os) \(expectedArch)', instead got '\(output)'")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandPlatform() throws {
do {
let name = getTestName()
let os = "linux"
let platform = "linux/amd64"
let expectedArch = "x86_64"
try doLongRun(name: name, args: ["--platform", platform])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["uname", "-sm"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
#expect(output == "\(os) \(expectedArch)", "expected container to use '\(os) \(expectedArch)', instead got '\(output)'")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandVolume() throws {
do {
let name = getTestName()
let targetContainerPath = "/tmp/testvolume"
let testData = "one small step"
let volume = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: true)
let volumeFile = volume.appendingPathComponent(UUID().uuidString)
guard FileManager.default.createFile(atPath: volumeFile.path(), contents: Data(testData.utf8)) else {
Issue.record("failed to create file at \(volumeFile)")
return
}
defer {
try? FileManager.default.removeItem(at: volume)
}
try doLongRun(name: name, args: ["--volume", "\(volume.path):\(targetContainerPath)"])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(volumeFile.lastPathComponent)"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandCidfile() throws {
do {
let name = getTestName()
let filePath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer {
try? FileManager.default.removeItem(at: filePath)
}
try doLongRun(name: name, args: ["--cidfile", filePath.path()])
defer {
try? doStop(name: name)
}
let actualID = try String(contentsOf: filePath, encoding: .utf8)
#expect(actualID == name, "expected container ID '\(name)', instead got '\(actualID)'")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandNoDNS() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--no-dns"])
defer {
try? doStop(name: name)
}
#expect(throws: (any Error).self) {
try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"])
}
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandInit() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--init"])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.useInit == true, "expected useInit to be true in container configuration")
// With --init, PID 1 should be the init process, not "sleep".
var output = try doExec(name: name, cmd: ["cat", "/proc/1/cmdline"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(
!output.hasPrefix("sleep"),
"expected PID 1 to be init process, not 'sleep', got '\(output)'"
)
try doStop(name: name)
} catch {
Issue.record("failed to run container with --init: \(error)")
return
}
}
@Test func testRunCommandInitReapsZombies() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--init"])
defer {
try? doStop(name: name)
}
_ = try doExec(
name: name,
cmd: [
"sh", "-c",
"sh -c 'sh -c \"exit 0\" &' && sleep 1",
])
let psOutput = try doExec(name: name, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"])
let zombieCount = Int(psOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1
#expect(
zombieCount == 0,
"expected no zombie processes with --init, found \(zombieCount)"
)
try doStop(name: name)
} catch {
Issue.record("failed to verify zombie reaping with --init: \(error)")
return
}
}
@Test func testRunCommandWithoutInitDefault() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.useInit == false, "expected useInit to be false by default")
try doStop(name: name)
} catch {
Issue.record("failed to run container without --init: \(error)")
return
}
}
}
@Suite(.serialSuites)
class TestCLIRunCommand3: CLITest {
func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}
func getLowercasedTestName() -> String {
getTestName().lowercased()
}
@Test func testRunCommandDefaultResolvConf() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer {
try? doStop(name: name)
}
let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"])
let actualLines = output.components(separatedBy: .newlines)
.filter { !$0.isEmpty }
.map { $0.components(separatedBy: .whitespaces) }
.map { $0.joined(separator: " ") }
let inspectOutput = try inspectContainer(name)
let ip = inspectOutput.networks[0].ipv4Address.address
let expectedNameserver = IPv4Address((ip.value & Prefix(length: 24)!.prefixMask32) + 1).description
let defaultDomain = try getDefaultDomain()
let expectedLines: [String] = [
"nameserver \(expectedNameserver)",
defaultDomain.map { "domain \($0)" },
].compactMap { $0 }
#expect(expectedLines == actualLines)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandNonDefaultResolvConf() throws {
do {
let expectedDns: String = "8.8.8.8"
let expectedDomain = "example.com"
let expectedSearch = "test.com"
let expectedOption = "debug"
let name = getTestName()
try doLongRun(
name: name,
args: [
"--dns", expectedDns,
"--dns-domain", expectedDomain,
"--dns-search", expectedSearch,
"--dns-option", expectedOption,
])
defer {
try? doStop(name: name)
}
let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"])
let actualLines = output.components(separatedBy: .newlines)
.filter { !$0.isEmpty }
.map { $0.components(separatedBy: .whitespaces) }
.map { $0.joined(separator: " ") }
let expectedLines: [String] = [
"nameserver \(expectedDns)",
"domain \(expectedDomain)",
"search \(expectedSearch)",
"options \(expectedOption)",
]
#expect(expectedLines == actualLines)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunDefaultHostsEntries() throws {
do {
let name = getTestName()
try doLongRun(name: name)
defer {
try? doStop(name: name)
}
let inspectOutput = try inspectContainer(name)
let ip = inspectOutput.networks[0].ipv4Address.address
let output = try doExec(name: name, cmd: ["cat", "/etc/hosts"])
let lines = output.split(separator: "\n")
let expectedEntries = [("127.0.0.1", "localhost"), (ip.description, name)]
for (i, line) in lines.enumerated() {
let words = line.split(separator: " ").map { String($0) }
#expect(words.count >= 2, "expected /etc/hosts entry to have 2 or more entries")
let expected = expectedEntries[i]
#expect(expected.0 == words[0], "expected /etc/hosts entries IP to be \(expected.0), instead got \(words[0])")
#expect(expected.1 == words[1], "expected /etc/hosts entries host to be \(expected.1), instead got \(words[1])")
}
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testForwardTCP() async throws {
let retries = 10
let retryDelaySeconds = Int64(3)
do {
let name = getLowercasedTestName()
let proxyIp = "127.0.0.1"
let proxyPort = UInt16.random(in: 50000..<55000)
let serverPort = UInt16.random(in: 55000..<60000)
try doLongRun(
name: name,
image: "docker.io/library/python:alpine",
args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPort)"])
defer {
try? doStop(name: name)
}
let url = "http://\(proxyIp):\(proxyPort)"
var request = HTTPClientRequest(url: url)
request.method = .GET
let config = HTTPClient.Configuration(proxy: nil)
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { _ = client.shutdown() }
var retriesRemaining = retries
var success = false
while !success && retriesRemaining > 0 {
do {
let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds))
try #require(response.status == .ok)
success = true
print("request to \(url) succeeded")
} catch {
print("request to \(url) failed, error \(error)")
try await Task.sleep(for: .seconds(retryDelaySeconds))
}
retriesRemaining -= 1
}
try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testForwardTCPPortRange() async throws {
let range = UInt16(10)
for portOffset in 0..<range {
let retries = 10
let retryDelaySeconds = Int64(3)
do {
let name = getLowercasedTestName()
let proxyIp = "127.0.0.1"
let proxyPortStart = UInt16.random(in: 50000..<55000)
let serverPortStart = UInt16.random(in: 55000..<60000)
let proxyPortEnd = proxyPortStart + range
let serverPortEnd = serverPortStart + range
try doLongRun(
name: name,
image: "docker.io/library/python:alpine",
args: ["--publish", "\(proxyIp):\(proxyPortStart)-\(proxyPortEnd):\(serverPortStart)-\(serverPortEnd)/tcp"],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPortStart + portOffset)"])
defer {
try? doStop(name: name)
}
let url = "http://\(proxyIp):\(proxyPortStart + portOffset)"
var request = HTTPClientRequest(url: url)
request.method = .GET
let config = HTTPClient.Configuration(proxy: nil)
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { _ = client.shutdown() }
var retriesRemaining = retries
var success = false
while !success && retriesRemaining > 0 {
do {
let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds))
try #require(response.status == .ok)
success = true
print("request to \(url) succeeded")
} catch {
print("request to \(url) failed, error: \(error)")
try await Task.sleep(for: .seconds(retryDelaySeconds))
}
retriesRemaining -= 1
}
try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
}
@available(macOS 26, *)
@Test func testForwardTCPv6() async throws {
let retries = 10
let retryDelaySeconds = Int64(3)
do {
let name = getLowercasedTestName()
let proxyIp = "[::1]"
let proxyPort = UInt16.random(in: 50000..<55000)
let serverPort = UInt16.random(in: 55000..<60000)
try doLongRun(
name: name,
image: "docker.io/library/node:alpine",
args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"],
containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"])
defer {
try? doStop(name: name)
}
let url = "http://\(proxyIp):\(proxyPort)"
var request = HTTPClientRequest(url: url)
request.method = .GET
let config = HTTPClient.Configuration(proxy: nil)
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { _ = client.shutdown() }
var retriesRemaining = retries
var success = false
while !success && retriesRemaining > 0 {
do {
let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds))
try #require(response.status == .ok)
success = true
print("request to \(url) succeeded")
} catch {
print("request to \(url) failed, error \(error)")
try await Task.sleep(for: .seconds(retryDelaySeconds))
}
retriesRemaining -= 1
}
try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
@Test func testRunCommandEnvFileFromNamedPipe() throws {
do {
let name = getTestName()
let pipePath = FileManager.default.temporaryDirectory.appendingPathComponent("envfile-pipe\(UUID().uuidString)")
// create pipe
let result = mkfifo(pipePath.path(), 0o600)
guard result == 0 else {
Issue.record("failed to create named pipe: \(String(cString: strerror(errno)))")
return
}
defer {
try? FileManager.default.removeItem(at: pipePath)
}
let content = """
FOO=bar
BAR=baz
"""
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async {
do {
let handle = try FileHandle(forWritingTo: pipePath)
try handle.write(contentsOf: Data(content.utf8))
try handle.close()
} catch {
Issue.record(error)
return
}
group.leave()
}
try doLongRun(name: name, args: ["--env-file", pipePath.path()])
defer {
try? doStop(name: name)
}
group.wait()
let inspectResult = try inspectContainer(name)
let expected = [
"FOO=bar",
"BAR=baz",
]
for item in expected {
#expect(
inspectResult.configuration.initProcess.environment.contains(item),
"expected environment variable \(item) not found"
)
}
try doStop(name: name)
} catch {
Issue.record(error)
}
}
@Test func testRunCommandReadOnly() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--read-only"])
defer {
try? doStop(name: name)
}
// Attempt to touch a file on the read-only rootfs should fail
#expect(throws: (any Error).self) {
try doExec(name: name, cmd: ["touch", "/testfile"])
}
} catch {
Issue.record("failed to run container \(error)")
return
}
}
func getDefaultDomain() throws -> String? {
let config = try getSystemConfig()
return config.dns.domain
}
@Test func testPrivilegedPortError() throws {
try #require(geteuid() != 0)
let name = getTestName()
let privilegedPort = 80
let (_, _, error, status) = try run(arguments: [
"run",
"--name", name,
"--publish", "127.0.0.1:\(privilegedPort):80",
alpine,
])
defer {
try? doRemove(name: name, force: true)
}
#expect(status != 0, "Command should have failed")
#expect(
error.contains("Permission denied while binding to host port \(privilegedPort)"),
"Error message should mention permission denied for the port. Got: \(error)"
)
#expect(
error.contains("root privileges"),
"Error message should mention root privileges requirement. Got: \(error)"
)
}
}
@@ -1,114 +0,0 @@
//===----------------------------------------------------------------------===//
// 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
/// Tests for the `--init-image` flag which allows specifying a custom init filesystem
/// image for microvms. This enables customizing boot-time behavior before the OCI
/// container starts.
///
/// See: https://github.com/apple/container/discussions/838
///
/// Note: A full integration test that verifies custom init behavior would require
/// a pre-built test init image that writes a marker to /dev/kmsg. This can be added
/// once a test init image is published to the registry.
@Suite(.serialSuites)
class TestCLIRunInitImage: CLITest {
private func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}
/// Test that specifying a non-existent init-image fails with an appropriate error.
@Test func testRunWithNonExistentInitImage() throws {
let name = getTestName()
let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist"
#expect(throws: CLIError.self, "expected container run with non-existent init-image to fail") {
let (_, _, error, status) = try run(arguments: [
"run",
"--rm",
"--name", name,
"-d",
"--init-image", nonExistentImage,
alpine,
"sleep", "infinity",
])
defer { try? doRemove(name: name, force: true) }
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}
}
}
/// Test that the `--init-image` flag is recognized and documented in CLI help.
@Test func testInitImageFlagInHelp() throws {
let (_, output, _, status) = try run(arguments: ["run", "--help"])
#expect(status == 0, "expected help command to succeed")
#expect(
output.contains("--init-image"),
"expected help output to contain --init-image flag"
)
#expect(
output.contains("custom init image"),
"expected help output to describe the init-image flag"
)
}
/// Test that the `--init-image` flag works with `container create` command.
@Test func testCreateWithNonExistentInitImage() throws {
let name = getTestName()
let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist"
#expect(throws: CLIError.self, "expected container create with non-existent init-image to fail") {
let (_, _, error, status) = try run(arguments: [
"create",
"--rm",
"--name", name,
"--init-image", nonExistentImage,
alpine,
"echo", "hello",
])
defer { try? doRemove(name: name, force: true) }
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}
}
}
/// Test that explicitly specifying the default init image works the same as
/// not specifying any init image.
@Test func testRunWithExplicitDefaultInitImage() throws {
let name = getTestName()
let config = try getSystemConfig()
let initImage = config.vminit.image
// Run container with explicit default init image
try doLongRun(name: name, args: ["--init-image", initImage])
defer {
try? doStop(name: name)
}
// Verify container is running and functional
try waitForContainerRunning(name)
let output = try doExec(name: name, cmd: ["echo", "hello"])
#expect(
output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello",
"expected 'hello' output from exec, got '\(output)'"
)
}
}
@@ -20,9 +20,15 @@ import Testing
/// Serial prune tests `container prune` affects all stopped containers regardless of name.
@Suite(.serialized)
struct TestCLIPruneCommandSerial {
@Test(.disabled("flaky — prune picks up containers from concurrent suites; tests being rewritten"))
func testContainerPruneNoContainers() async throws {
@Test func testContainerPruneNoContainers() async throws {
try await ContainerFixture.with { f in
// Establish empty state the serial global pass runs after the concurrent
// pass, and any test that failed mid-cleanup could leave stopped containers
// that would break the "reclaimed zero" assertion. Machine-backing containers
// are excluded from `container delete --all` by design so this doesn't
// interfere with the machine plugin.
_ = try? f.run(["delete", "--all", "--force"])
let result = try f.run(["prune"]).check()
#expect(result.error.contains("Reclaimed Zero KB in disk space"), "should show no containers message")
}
@@ -1,69 +0,0 @@
//===----------------------------------------------------------------------===//
// 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 Testing
@Suite
struct TestCLIProgressAuto {
private let alpine = ContainerFixture.warmupImages[0]
@Test func testAutoProgressFallsBackToPlainWhenPiped() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["image", "pull", "--progress", "auto", alpine])
#expect(result.status == 0, "image pull should succeed, stderr: \(result.error)")
let lines = result.error.components(separatedBy: .newlines)
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
#expect(!lines.isEmpty, "expected plain progress output on stderr when piped")
#expect(!result.error.contains("\u{1B}["), "expected no ANSI escapes in piped output")
}
}
@Test func testExplicitPlainProgress() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["image", "pull", "--progress", "plain", alpine])
#expect(
result.status == 0,
"image pull --progress plain should succeed, stderr: \(result.error)")
let lines = result.error.components(separatedBy: .newlines)
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
#expect(!lines.isEmpty, "expected plain progress output on stderr")
#expect(!result.error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain")
}
}
@Test func testExplicitAnsiProgress() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["image", "pull", "--progress", "ansi", alpine])
// Verify the command succeeds; ANSI output is suppressed in non-TTY contexts
// so we don't assert on stderr content here.
#expect(
result.status == 0,
"image pull --progress ansi should succeed, stderr: \(result.error)")
}
}
@Test func testNoneProgressSuppressesOutput() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["image", "pull", "--progress", "none", alpine])
#expect(
result.status == 0,
"image pull --progress none should succeed, stderr: \(result.error)")
let lines = result.error.components(separatedBy: .newlines)
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
#expect(lines.isEmpty, "expected no progress output on stderr with --progress none")
}
}
}
@@ -0,0 +1,223 @@
//===----------------------------------------------------------------------===//
// 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 AsyncHTTPClient
import ContainerizationExtras
import Foundation
import Testing
@Suite
struct TestCLINetwork {
// MARK: - Tests
@available(macOS 26, *)
@Test func testNetworkCreateAndUse() async throws {
try await ContainerFixture.with { f in
let net = "\(f.testID)-net"
let c = "\(f.testID)-c"
f.addCleanup { f.doNetworkDeleteIfExists(net) }
try f.doNetworkCreate(net)
let listResult = try f.run(["network", "ls", "--quiet"]).check()
let networkIds = listResult.output
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
#expect(networkIds == networkIds.sorted(), "network IDs should be sorted")
let port = UInt16.random(in: 50000..<60000)
try f.doLongRun(
name: c, image: "docker.io/library/python:alpine",
args: ["--network", net],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let container = try f.inspectContainer(c)
#expect(container.networks.count > 0)
let ip = container.networks[0].ipv4Address.address
let url = "http://\(ip):\(port)"
let client = f.makeHTTPClient()
defer { _ = client.shutdown() }
var request = HTTPClientRequest(url: url)
request.method = .GET
// waitForContainerRunning only tells us init is running; the python http
// server inside is still starting, so retry until it accepts connections.
var lastError: Error?
var response: HTTPClientResponse?
for attempt in 1...10 {
do {
response = try await client.execute(request, timeout: .seconds(3))
break
} catch {
lastError = error
print("request to \(url) failed on attempt \(attempt): \(error)")
try await Task.sleep(for: .seconds(1))
}
}
let final = try #require(response, "request to \(url) failed after retries: \(lastError.map(String.init(describing:)) ?? "no error")")
#expect(final.status == .ok, "request to \(url) returned \(final.status)")
}
}
@available(macOS 26, *)
@Test func testNetworkDeleteWithContainer() async throws {
try await ContainerFixture.with { f in
let net = "\(f.testID)-net"
let c = "\(f.testID)-c"
f.addCleanup { f.doNetworkDeleteIfExists(net) }
f.addCleanup { try? f.doRemove(c, force: true) }
try f.doNetworkCreate(net)
try f.doCreate(name: c, networks: [net])
let deleteResult = try f.run(["network", "delete", net])
try #require(deleteResult.status != 0, "network delete should fail while container references it")
#expect(deleteResult.error.contains("delete failed"))
#expect(deleteResult.error.contains("[\"\(net)\"]"))
try f.doRemove(c, force: true)
try f.doNetworkDelete(net)
}
}
@available(macOS 26, *)
@Test func testNetworkLabels() async throws {
try await ContainerFixture.with { f in
let net = "\(f.testID)-net"
f.addCleanup { f.doNetworkDeleteIfExists(net) }
try f.doNetworkCreate(net, args: ["--label", "foo=bar", "--label", "baz=qux"])
let network = try f.inspectNetwork(net)
let expectedLabels = ["foo": "bar", "baz": "qux"]
#expect(expectedLabels == network.configuration.labels.dictionary)
}
}
@Test func testNetworkMTU() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0])
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--network", "default,mtu=1500"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["ip", "link", "show", "eth0"])
#expect(output.contains("mtu 1500"), "expected mtu 1500 in ip link output: \(output)")
}
}
@available(macOS 26, *)
@Test func testIsolatedNetwork() async throws {
try await ContainerFixture.with { f in
let net = "\(f.testID)-net"
let server = "\(f.testID)-server"
let pythonImage = "docker.io/library/python:alpine"
let curlImage = "docker.io/curlimages/curl:8.6.0"
f.addCleanup { f.doNetworkDeleteIfExists(net) }
f.addCleanup {
try? f.doStop(server)
try? f.doRemove(server)
}
try f.doNetworkCreate(net, args: ["--internal"])
let port = UInt16.random(in: 50000..<60000)
try f.doLongRun(
name: server, image: pythonImage,
args: ["--network", net],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"],
autoRemove: false)
try await f.waitForContainerRunning(server)
let container = try f.inspectContainer(server)
#expect(container.networks.count > 0)
let ip = container.networks[0].ipv4Address.address
let serverURL = "http://\(ip):\(port)"
// Internal connection should succeed. `waitForContainerRunning` only
// proves the container's init is up; the python http.server inside
// may still be starting, so let curl retry on connection refused.
// FIXME: Task.sleep here is a kludge, figure out why curl fails with error 7 without it.
try await Task.sleep(for: .seconds(1))
let internalResult = try f.run([
"run", "--rm", "--network", net, curlImage,
"curl", "--retry", "10", "--retry-connrefused", "--retry-delay", "1", serverURL,
])
#expect(
internalResult.status == 0,
"connection within isolated network should succeed, got exit \(internalResult.status): \(internalResult.error)"
)
// External connection should be blocked the isolated network has no gateway.
let externalResult = try f.run([
"run", "--rm", "--network", net, curlImage,
"curl", "--connect-timeout", "5", "http://google.com",
])
let hostOnlyBlockedCodes: Set<Int32> = [6, 7, 28]
#expect(
hostOnlyBlockedCodes.contains(externalResult.status),
"external connection from isolated network should be blocked, got exit \(externalResult.status)")
}
}
@Test func testNetworkListTableFormat() async throws {
try await ContainerFixture.with { f in
let net = "\(f.testID)-net"
f.addCleanup { f.doNetworkDeleteIfExists(net) }
try f.doNetworkCreate(net)
let result = try f.run(["network", "list"]).check()
#expect(["NETWORK", "SUBNET"].allSatisfy { result.output.contains($0) })
#expect(result.output.contains(net))
}
}
@Test func testNetworkListJSONFormat() async throws {
try await ContainerFixture.with { f in
let net = "\(f.testID)-net"
f.addCleanup { f.doNetworkDeleteIfExists(net) }
try f.doNetworkCreate(net)
let result = try f.run(["network", "list", "--format", "json"]).check()
guard let json = try JSONSerialization.jsonObject(with: result.outputData) as? [[String: Any]] else {
Issue.record("JSON output should be an array of objects")
return
}
#expect(json.contains { ($0["id"] as? String) == net })
}
}
@Test func testInspectMissingNetworkFails() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["network", "inspect", "definitely-missing-\(f.testID)"])
#expect(result.status != 0)
#expect(result.error.contains("network not found"))
}
}
}
@@ -1,55 +0,0 @@
//===----------------------------------------------------------------------===//
// 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
@Suite
struct TestCLIRegistry {
@Test func testListDefaultFormat() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["registry", "list"])
#expect(result.status == 0, "registry list should succeed, stderr: \(result.error)")
let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]
#expect(
requiredHeaders.allSatisfy { result.output.contains($0) },
"output should contain all required headers"
)
}
}
@Test func testListJSONFormat() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["registry", "list", "--format", "json"])
#expect(
result.status == 0,
"registry list --format json should succeed, stderr: \(result.error)")
let json = try JSONSerialization.jsonObject(with: result.outputData, options: [])
#expect(json is [Any], "JSON output should be an array")
}
}
@Test func testListQuietMode() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["registry", "list", "-q"])
#expect(result.status == 0, "registry list -q should succeed, stderr: \(result.error)")
#expect(!result.output.contains("HOSTNAME"), "quiet mode should not contain headers")
#expect(!result.output.contains("USERNAME"), "quiet mode should not contain headers")
}
}
}
@@ -0,0 +1,434 @@
//===----------------------------------------------------------------------===//
// 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
@Suite
struct TestCLIRunCapabilities {
private let alpine = ContainerFixture.warmupImages[0]
// MARK: - Invalid capability names
@Test func testCapDropInvalid() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let result = try f.run(["run", "--rm", "--cap-drop=CHWOWZERS", image, "ls"])
#expect(result.status != 0)
#expect(result.error.contains("CHWOWZERS") || result.error.contains("invalid"))
}
}
@Test func testCapAddInvalid() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let result = try f.run(["run", "--rm", "--cap-add=CHWOWZERS", image, "ls"])
#expect(result.status != 0)
#expect(result.error.contains("CHWOWZERS") || result.error.contains("invalid"))
}
}
// MARK: - Config stored correctly via inspect
@Test func testCapAddStored() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.capAdd.contains("CAP_NET_ADMIN"))
#expect(inspect.configuration.capDrop.isEmpty)
}
}
@Test func testCapDropStored() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.capDrop.contains("CAP_MKNOD"))
#expect(inspect.configuration.capAdd.isEmpty)
}
}
@Test func testCapAddDropALLStored() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: ["--cap-drop", "ALL", "--cap-add", "SETGID", "--cap-add", "NET_RAW"],
autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.capDrop.contains("ALL"))
#expect(inspect.configuration.capAdd.contains("CAP_SETGID"))
#expect(inspect.configuration.capAdd.contains("CAP_NET_RAW"))
}
}
@Test func testCapAddALLStored() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.capAdd.contains("ALL"))
}
}
@Test func testCapDropLowerCase() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.capDrop.contains("CAP_MKNOD"))
}
}
// MARK: - In-container capability verification
@Test func testCapDropMknodCannotMknod() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"])
#expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok")
#expect(result.status != 0)
}
}
@Test func testCapDropMknodLowerCaseCannotMknod() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"])
#expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok")
#expect(result.status != 0)
}
}
@Test func testCapDropALLCannotMknod() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: ["--cap-drop", "ALL", "--cap-add", "SETGID"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"])
#expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok")
#expect(result.status != 0)
}
}
@Test func testCapDropALLAddMknodCanMknod() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: ["--cap-drop", "ALL", "--cap-add", "MKNOD", "--cap-add", "SETGID"],
autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"])
#expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "ok")
}
}
@Test func testCapAddALLCanDownInterface() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["sh", "-c", "ip link set lo down && echo ok"])
#expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "ok")
}
}
@Test func testCapAddALLDropNetAdminCannotDownInterface() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: ["--cap-add", "ALL", "--cap-drop", "NET_ADMIN"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let result = try f.run(["exec", c, "sh", "-c", "ip link set lo down && echo ok"])
#expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok")
#expect(result.status != 0)
}
}
@Test func testCapAddNetAdminCanDownInterface() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["sh", "-c", "ip link set lo down && echo ok"])
#expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "ok")
}
}
// MARK: - Default capability behavior
@Test func testDefaultCapChown() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
_ = try f.doExec(c, cmd: ["chown", "100", "/tmp"])
}
}
@Test func testNonRootUserCannotReadShadow() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
_ = try f.doExec(c, cmd: ["cat", "/etc/shadow"])
let result = try f.run(["exec", "-u", "nobody", c, "cat", "/etc/shadow"])
#expect(result.status != 0, "non-root user should not be able to read /etc/shadow")
}
}
@Test func testCapDropChown() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-drop", "chown"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let result = try f.run(["exec", c, "chown", "100", "/tmp"])
#expect(result.status != 0)
}
}
@Test func testDefaultCapFowner() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
_ = try f.doExec(c, cmd: ["chmod", "777", "/etc/passwd"])
}
}
// MARK: - Capability bitmask verification via /proc
@Test func testCapDropALLShowsZeroCaps() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: ["--cap-drop", "ALL", "--cap-add", "SETUID", "--cap-add", "SETGID"],
autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"])
let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") }
try #require(capEff != nil)
let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
#expect(value != "0000000000000000", "expected non-zero CapEff with SETUID+SETGID")
#expect(value != "000001ffffffffff", "expected restricted caps, not full set")
}
}
@Test func testNoCapFlagsUsesDefaultCaps() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"])
let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") }
try #require(capEff != nil)
let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
#expect(value != "0000000000000000")
}
}
@Test func testCapAddALLShowsFullCaps() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"])
let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") }
try #require(capEff != nil)
let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
#expect(value != "0000000000000000")
}
}
@Test func testCapDropALLOnlyShowsZeroEffective() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cap-drop", "ALL"], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"])
let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") }
try #require(capEff != nil)
let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
#expect(value == "0000000000000000", "expected zero CapEff when ALL caps dropped, got \(value)")
}
}
@Test func testMultipleCapAddDrop() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: [
"--cap-add", "SYS_ADMIN", "--cap-add", "NET_RAW",
"--cap-drop", "MKNOD", "--cap-drop", "CHOWN",
],
autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.capAdd.count == 2)
#expect(inspect.configuration.capDrop.count == 2)
#expect(inspect.configuration.capAdd.contains("CAP_SYS_ADMIN"))
#expect(inspect.configuration.capAdd.contains("CAP_NET_RAW"))
#expect(inspect.configuration.capDrop.contains("CAP_MKNOD"))
#expect(inspect.configuration.capDrop.contains("CAP_CHOWN"))
let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"])
#expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok")
}
}
}
@@ -0,0 +1,748 @@
//===----------------------------------------------------------------------===//
// 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 AsyncHTTPClient
import ContainerizationExtras
import ContainerizationOS
import Foundation
import Testing
@Suite
struct TestCLIRunCommand {
private let alpine = ContainerFixture.warmupImages[0]
// MARK: - Basic run options
@Test func testRunCommand() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
_ = try f.doExec(c, cmd: ["date"])
}
}
@Test func testRunCommandCWD() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cwd", "/tmp"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["pwd"]).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == "/tmp")
}
}
@Test func testRunCommandEnv() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--env", "FOO=bar"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.initProcess.environment.contains("FOO=bar"))
}
}
@Test func testRunCommandEnvFile() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
let envFile = f.testDir.appending("test.env")
let content = "# comment\nFOO=bar\nBAR=baz wow\nURL=https://foo.bar?baz=wow\n"
try content.write(toFile: envFile.string, atomically: true, encoding: .utf8)
try f.doLongRun(name: c, image: image, args: ["--env-file", envFile.string], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
for expected in ["FOO=bar", "BAR=baz wow", "URL=https://foo.bar?baz=wow"] {
#expect(inspect.configuration.initProcess.environment.contains(expected))
}
}
}
@Test func testRunCommandUserIDGroupID() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--uid", "10", "--gid", "100"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["id"]).trimmingCharacters(in: .whitespacesAndNewlines)
try #expect(output.contains(Regex("uid=10.*?gid=100.*")))
}
}
@Test func testRunCommandUser() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--user", "nobody"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["whoami"]).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == "nobody")
}
}
@Test func testRunCommandCPUs() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--cpus", "2"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["cat", "/sys/fs/cgroup/cpu.max"])
.trimmingCharacters(in: .whitespacesAndNewlines)
let fields = output.components(separatedBy: .whitespaces)
#expect(fields.count == 2)
let numerator = try #require(Int(fields[0]))
let denominator = try #require(Int(fields[1]))
#expect(denominator > 0)
#expect(2 * denominator == numerator)
}
}
@Test func testRunCommandMemory() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--memory", "1024M"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
let expectedBytes = UInt64(1024) * 1024 * 1024
#expect(inspect.configuration.resources.memoryInBytes == expectedBytes)
}
}
@Test func testRunCommandUlimitNofile() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--ulimit", "nofile=1024:2048"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
let nofile = inspect.configuration.initProcess.rlimits.first { $0.limit == "RLIMIT_NOFILE" }
try #require(nofile != nil)
#expect(nofile?.soft == 1024)
#expect(nofile?.hard == 2048)
let output = try f.doExec(c, cmd: ["sh", "-c", "ulimit -n"])
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == "1024")
}
}
@Test func testRunCommandUlimitNproc() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--ulimit", "nproc=256"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
let nproc = inspect.configuration.initProcess.rlimits.first { $0.limit == "RLIMIT_NPROC" }
try #require(nproc != nil)
#expect(nproc?.soft == 256)
#expect(nproc?.hard == 256)
let output = try f.doExec(c, cmd: ["sh", "-c", "ulimit -u"])
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == "256")
}
}
@Test func testRunCommandMultipleUlimits() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: ["--ulimit", "nofile=1024:2048", "--ulimit", "nproc=512", "--ulimit", "stack=8388608"],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let rlimits = try f.inspectContainer(c).configuration.initProcess.rlimits
#expect(rlimits.count == 3)
let nofile = rlimits.first { $0.limit == "RLIMIT_NOFILE" }
let nproc = rlimits.first { $0.limit == "RLIMIT_NPROC" }
let stack = rlimits.first { $0.limit == "RLIMIT_STACK" }
#expect(nofile?.soft == 1024 && nofile?.hard == 2048)
#expect(nproc?.soft == 512 && nproc?.hard == 512)
#expect(stack?.soft == 8_388_608 && stack?.hard == 8_388_608)
}
}
// MARK: - Mounts and storage
@Test func testRunCommandMount() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
let testData = "hello world"
let hostFile = f.testDir.appending("testfile.txt")
try testData.write(toFile: hostFile.string, atomically: true, encoding: .utf8)
try f.doLongRun(
name: c, image: image,
args: ["--mount", "type=virtiofs,source=\(f.testDir.string),target=/tmp/testmount,readonly"],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["cat", "/tmp/testmount/testfile.txt"])
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == testData)
}
}
@Test func testRunCommandUnixSocketMount() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
// sockaddr_un.sun_path is 104 bytes on macOS use /tmp to keep
// the host socket path short regardless of project directory depth.
let socketDir = "/tmp/\(f.testID)-sock"
try FileManager.default.createDirectory(
atPath: socketDir, withIntermediateDirectories: true, attributes: nil)
f.addCleanup { try? FileManager.default.removeItem(atPath: socketDir) }
let socketPath = socketDir + "/ssh-auth.sock"
let guestSocketPath = "/run/ssh-auth.sock"
let socketType = try UnixType(path: socketPath, perms: 0o766, unlinkExisting: true)
let socket = try Socket(type: socketType, closeOnDeinit: true)
try socket.listen()
f.addCleanup { try? socket.close() }
try f.doLongRun(
name: c, image: image,
args: ["-v", "\(socketPath):\(guestSocketPath)", "-e", "SSH_AUTH_SOCK=\(guestSocketPath)"],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
_ = try f.doExec(c, cmd: ["apk", "add", "netcat-openbsd"])
let perms = try f.doExec(
c, cmd: ["sh", "-c", "stat -c \"%a\" \"${SSH_AUTH_SOCK}\""],
user: "guest"
).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(perms == "766")
_ = try f.doExec(c, cmd: ["sh", "-c", "nc -zU \"${SSH_AUTH_SOCK}\""], user: "guest")
}
}
@Test func testRunCommandTmpfs() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--tmpfs", "/tmp/testtmpfs"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["df", "/tmp/testtmpfs"])
let lines = output.split(separator: "\n")
#expect(lines.count == 2)
let words = lines[1].split(separator: " ")
#expect(words[0].lowercased() == "tmpfs")
}
}
@Test func testRunCommandShmSize() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--shm-size", "128m"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["mount"])
let shmLine = output.split(separator: "\n").first { $0.contains("/dev/shm") }
try #require(shmLine != nil)
#expect(shmLine!.contains("size=\(128 * 1024)k"))
}
}
@Test func testRunCommandVolume() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
let testData = "one small step"
let volumeFile = f.testDir.appending("data.txt")
try testData.write(toFile: volumeFile.string, atomically: true, encoding: .utf8)
try f.doLongRun(
name: c, image: image,
args: ["--volume", "\(f.testDir.string):/tmp/testvolume"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["cat", "/tmp/testvolume/data.txt"])
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == testData)
}
}
@Test func testRunCommandCidfile() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
let cidfile = f.testDir.appending("container.cid")
try f.doLongRun(name: c, image: image, args: ["--cidfile", cidfile.string], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let actualID = try String(contentsOfFile: cidfile.string, encoding: .utf8)
#expect(actualID == c)
}
}
// MARK: - Network and DNS
@Test func testRunCommandNoDNS() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--no-dns"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let result = try f.run(["exec", c, "cat", "/etc/resolv.conf"])
#expect(result.status != 0)
}
}
@Test func testRunCommandDefaultResolvConf() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["cat", "/etc/resolv.conf"])
let actualLines = output.components(separatedBy: .newlines)
.filter { !$0.isEmpty }
.map { $0.components(separatedBy: .whitespaces).joined(separator: " ") }
let inspect = try f.inspectContainer(c)
let ip = inspect.networks[0].ipv4Address.address
let nameserver = IPv4Address((ip.value & Prefix(length: 24)!.prefixMask32) + 1).description
let config = try f.getSystemConfig()
let expectedLines: [String] = [
"nameserver \(nameserver)",
config.dns.domain.map { "domain \($0)" },
].compactMap { $0 }
#expect(expectedLines == actualLines)
}
}
@Test func testRunCommandNonDefaultResolvConf() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: image,
args: [
"--dns", "8.8.8.8", "--dns-domain", "example.com",
"--dns-search", "test.com", "--dns-option", "debug",
],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["cat", "/etc/resolv.conf"])
let actualLines = output.components(separatedBy: .newlines)
.filter { !$0.isEmpty }
.map { $0.components(separatedBy: .whitespaces).joined(separator: " ") }
#expect(
actualLines == [
"nameserver 8.8.8.8",
"domain example.com",
"search test.com",
"options debug",
])
}
}
@Test func testRunDefaultHostsEntries() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
let ip = inspect.networks[0].ipv4Address.address.description
let output = try f.doExec(c, cmd: ["cat", "/etc/hosts"])
let lines = output.split(separator: "\n")
let expected = [("127.0.0.1", "localhost"), (ip, c)]
for (i, line) in lines.enumerated() {
guard i < expected.count else { break }
let words = line.split(separator: " ").map(String.init)
#expect(words.count >= 2)
#expect(words[0] == expected[i].0)
#expect(words[1] == expected[i].1)
}
}
}
@Test func testPrivilegedPortError() async throws {
try #require(geteuid() != 0)
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
f.addCleanup { try? f.doRemove(c, force: true) }
let result = try f.run(["run", "--name", c, "--publish", "127.0.0.1:80:80", image])
#expect(result.status != 0)
#expect(result.error.contains("Permission denied while binding to host port 80"))
#expect(result.error.contains("root privileges"))
}
}
// MARK: - Platform
@Test func testRunCommandOSArch() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--os", "linux", "--arch", "amd64"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["uname", "-sm"])
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
#expect(output == "linux x86_64")
}
}
@Test func testRunCommandPlatform() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--platform", "linux/amd64"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let output = try f.doExec(c, cmd: ["uname", "-sm"])
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
#expect(output == "linux x86_64")
}
}
// MARK: - init process
@Test func testRunCommandInit() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.useInit == true)
let cmdline = try f.doExec(c, cmd: ["cat", "/proc/1/cmdline"])
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(!cmdline.hasPrefix("sleep"), "PID 1 should be init process, not 'sleep'")
}
}
@Test func testRunCommandInitReapsZombies() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
_ = try f.doExec(c, cmd: ["sh", "-c", "sh -c 'sh -c \"exit 0\" &' && sleep 1"])
let ps = try f.doExec(c, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"])
let zombieCount = Int(ps.trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1
#expect(zombieCount == 0, "expected no zombie processes with --init")
}
}
@Test func testRunCommandWithoutInitDefault() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.useInit == false)
}
}
// MARK: - Read-only rootfs
@Test func testRunCommandReadOnly() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
try f.doLongRun(name: c, image: image, args: ["--read-only"], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await f.waitForContainerRunning(c)
let result = try f.run(["exec", c, "touch", "/testfile"])
#expect(result.status != 0, "touch on read-only rootfs should fail")
}
}
// MARK: - Env file from named pipe
@Test func testRunCommandEnvFileFromNamedPipe() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
let pipePath = f.testDir.appending("envfile.pipe")
guard mkfifo(pipePath.string, 0o600) == 0 else {
Issue.record("failed to create named pipe")
return
}
let content = "FOO=bar\nBAR=baz\n"
// Write to the FIFO in a detached task so the open doesn't block forever.
let writeTask = Task.detached {
let handle = try FileHandle(forWritingTo: URL(filePath: pipePath.string))
try handle.write(contentsOf: Data(content.utf8))
try handle.close()
}
defer { writeTask.cancel() }
try f.doLongRun(name: c, image: image, args: ["--env-file", pipePath.string], autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
try await writeTask.value
try await f.waitForContainerRunning(c)
let inspect = try f.inspectContainer(c)
#expect(inspect.configuration.initProcess.environment.contains("FOO=bar"))
#expect(inspect.configuration.initProcess.environment.contains("BAR=baz"))
}
}
// MARK: - TCP port forwarding
@Test func testForwardTCP() async throws {
try await ContainerFixture.with { f in
let c = "\(f.testID)-c"
let proxyPort = UInt16.random(in: 50000..<55000)
let serverPort = UInt16.random(in: 55000..<60000)
try f.doLongRun(
name: c, image: "docker.io/library/python:alpine",
args: ["--publish", "127.0.0.1:\(proxyPort):\(serverPort)/tcp"],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPort)"],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let client = HTTPClient(eventLoopGroupProvider: .singleton)
defer { _ = client.shutdown() }
var success = false
for attempt in 1...10 {
do {
var req = HTTPClientRequest(url: "http://127.0.0.1:\(proxyPort)")
req.method = .GET
let resp = try await client.execute(req, timeout: .seconds(3))
if resp.status == .ok {
f.log.info("testForwardTCP: attempt \(attempt) succeeded")
success = true
break
}
} catch {
f.log.info("testForwardTCP: attempt \(attempt) failed: \(error)")
try await Task.sleep(for: .seconds(3))
}
}
#expect(success, "TCP forward to port \(proxyPort) did not succeed after retries")
}
}
@Test func testForwardTCPPortRange() async throws {
try await ContainerFixture.with { f in
let range = UInt16(10)
let proxyPortStart = UInt16.random(in: 50000..<55000)
let serverPortStart = UInt16.random(in: 55000..<60000)
let c = "\(f.testID)-c"
try f.doLongRun(
name: c, image: "docker.io/library/python:alpine",
args: ["--publish", "127.0.0.1:\(proxyPortStart)-\(proxyPortStart + range):\(serverPortStart)-\(serverPortStart + range)/tcp"],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPortStart)"],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let client = HTTPClient(eventLoopGroupProvider: .singleton)
defer { _ = client.shutdown() }
var success = false
for attempt in 1...10 {
do {
var req = HTTPClientRequest(url: "http://127.0.0.1:\(proxyPortStart)")
req.method = .GET
let resp = try await client.execute(req, timeout: .seconds(3))
if resp.status == .ok {
f.log.info("testForwardTCPPortRange: attempt \(attempt) succeeded")
success = true
break
}
} catch {
f.log.info("testForwardTCPPortRange: attempt \(attempt) failed: \(error)")
try await Task.sleep(for: .seconds(3))
}
}
#expect(success, "TCP port range forward did not succeed after retries")
}
}
@available(macOS 26, *)
@Test func testForwardTCPv6() async throws {
try await ContainerFixture.with { f in
let c = "\(f.testID)-c"
let proxyPort = UInt16.random(in: 50000..<55000)
let serverPort = UInt16.random(in: 55000..<60000)
try f.doLongRun(
name: c, image: "docker.io/library/node:alpine",
args: ["--publish", "[::1]:\(proxyPort):\(serverPort)/tcp"],
containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"],
autoRemove: false)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let client = HTTPClient(eventLoopGroupProvider: .singleton)
defer { _ = client.shutdown() }
var success = false
for attempt in 1...10 {
do {
var req = HTTPClientRequest(url: "http://[::1]:\(proxyPort)")
req.method = .GET
let resp = try await client.execute(req, timeout: .seconds(3))
if resp.status == .ok {
f.log.info("testForwardTCPv6: attempt \(attempt) succeeded")
success = true
break
}
} catch {
f.log.info("testForwardTCPv6: attempt \(attempt) failed: \(error)")
try await Task.sleep(for: .seconds(3))
}
}
#expect(success, "TCPv6 forward to port \(proxyPort) did not succeed after retries")
}
}
}
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// 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
/// Tests for the `--init-image` flag which allows specifying a custom init filesystem
/// image for microvms.
///
/// Note: A full integration test that verifies custom init behavior would require
/// a pre-built test init image that writes a marker to /dev/kmsg. This can be added
/// once a test init image is published to the registry.
@Suite
struct TestCLIRunInitImage {
private let alpine = ContainerFixture.warmupImages[0]
@Test func testRunWithNonExistentInitImage() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
f.addCleanup { try? f.doRemove(c, force: true) }
let result = try f.run([
"run", "--rm", "--name", c, "-d",
"--init-image", "nonexistent.invalid/init-image:does-not-exist",
image, "sleep", "infinity",
])
#expect(result.status != 0, "run with non-existent init-image should fail")
}
}
@Test func testInitImageFlagInHelp() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["run", "--help"]).check()
#expect(result.output.contains("--init-image"))
#expect(result.output.contains("custom init image"))
}
}
@Test func testCreateWithNonExistentInitImage() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
f.addCleanup { try? f.doRemove(c, force: true) }
let result = try f.run([
"create", "--name", c,
"--init-image", "nonexistent.invalid/init-image:does-not-exist",
image, "echo", "hello",
])
#expect(result.status != 0, "create with non-existent init-image should fail")
}
}
@Test func testRunWithExplicitDefaultInitImage() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(alpine)
let c = "\(f.testID)-c"
let config = try f.getSystemConfig()
try f.doLongRun(
name: c, image: image,
args: ["--init-image", config.vminit.image], autoRemove: false)
try await f.waitForContainerRunning(c)
f.addCleanup {
try? f.doStop(c)
try? f.doRemove(c)
}
let output = try f.doExec(c, cmd: ["echo", "hello"])
#expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello")
}
}
}
@@ -0,0 +1,72 @@
//===----------------------------------------------------------------------===//
// 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 AsyncHTTPClient
import ContainerResource
import Foundation
// MARK: - Network inspect output
struct NetworkInspectOutput: Codable {
struct Status: Codable {
let ipv4Subnet: String?
let ipv4Gateway: String?
let ipv6Subnet: String?
}
let id: String
let configuration: NetworkConfiguration
let status: Status
}
// MARK: - Network lifecycle helpers
extension ContainerFixture {
/// Creates a named network, throwing on failure.
func doNetworkCreate(_ name: String, args: [String] = []) throws {
var arguments = ["network", "create"]
arguments += args
arguments.append(name)
try run(arguments).check()
}
/// Deletes a named network, throwing on failure.
func doNetworkDelete(_ name: String) throws {
try run(["network", "delete", name]).check()
}
/// Deletes a named network, silently ignoring errors.
func doNetworkDeleteIfExists(_ name: String) {
_ = try? run(["network", "delete", name])
}
/// Inspects a network and returns decoded output.
func inspectNetwork(_ name: String) throws -> NetworkInspectOutput {
let result = try run(["network", "inspect", name]).check()
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let networks = try decoder.decode([NetworkInspectOutput].self, from: result.outputData)
guard let network = networks.first else {
throw CommandError.executionFailed("network inspect returned empty array")
}
return network
}
/// Returns an `HTTPClient` for use in network connectivity tests.
func makeHTTPClient() -> HTTPClient {
HTTPClient(eventLoopGroupProvider: .singleton)
}
}
@@ -0,0 +1,41 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import Foundation
import SystemPackage
import TOML
// MARK: - System helpers
extension ContainerFixture {
/// Returns the decoded system configuration from `container system property list`.
func getSystemConfig() throws -> ContainerSystemConfig {
let result = try run(["system", "property", "list", "--format", "toml"]).check()
return try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(result.output.utf8))
}
/// Creates a temporary directory, calls `body` with its URL, then removes it
/// regardless of whether `body` throws.
func withTempDir<T>(_ body: (URL) async throws -> T) async throws -> T {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
return try await body(dir)
}
}
@@ -78,6 +78,9 @@ final class ContainerFixture: Sendable {
/// Created at fixture init; removed on cleanup unless `CLITEST_PRESERVE_SCRATCH=true`.
let testDir: FilePath
/// Logger for this fixture scope. Tests may emit diagnostic messages via this logger.
let log: Logger
// MARK: - Unstructured API
/// Runs `body` with a fresh fixture, then tears down all registered resources.
@@ -116,7 +119,7 @@ final class ContainerFixture: Sendable {
return handler
}
}
return StderrLogHandler()
return StreamLogHandler.standardOutput(label: label)
}
logger[metadataKey: "testID"] = "\(testID)"
@@ -310,7 +313,6 @@ final class ContainerFixture: Sendable {
// MARK: - Private
private let log: Logger
private let cleanupTasks: Mutex<[@Sendable () async throws -> Void]> = .init([])
private static let commandSeq: Mutex<Int> = .init(0)