mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
@@ -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/
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
+11
-14
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user