Skip to content

Commit 561427c

Browse files
authored
Merge branch 'main' into k8s-plugin-upstream
2 parents 97ced0c + 60612ee commit 561427c

14 files changed

Lines changed: 528 additions & 24 deletions

File tree

.github/workflows/common.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ jobs:
9494
LOG_ROOT="${APP_ROOT}/logs"
9595
echo "created data directory: ${APP_ROOT}"
9696
echo "hostname: $(hostname)"
97+
echo "hw.physicalcpu: $(sysctl -n hw.physicalcpu)"
98+
echo "hw.logicalcpu: $(sysctl -n hw.logicalcpu)"
9799
export NO_PROXY="${NO_PROXY},192.168.0.0/16,fe80::/10"
98100
echo NO_PROXY=${NO_PROXY}
99101
export no_proxy="${no_proxy},192.168.0.0/16,fe80::/10"

Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//===----------------------------------------------------------------------===//
1616

1717
import Foundation
18+
import SystemPackage
1819
import Testing
1920

2021
// MARK: - Image inspect types
@@ -69,6 +70,27 @@ extension ContainerFixture {
6970
try run(args).check()
7071
}
7172

73+
/// Saves `image` to ``WarmupImage/cacheTarPath``, overwriting any existing archive.
74+
///
75+
/// Called once per image by the `ImageWarmup` suite. No `--platform`/`--os`/`--arch`
76+
/// is passed, so `image save` captures every platform the image supports.
77+
public func cacheWarmupImage(_ image: WarmupImage) throws {
78+
try FileManager.default.createDirectory(
79+
atPath: WarmupImage.cacheDirectory.string, withIntermediateDirectories: true)
80+
try run(["image", "save", "--output", image.cacheTarPath.string, image.rawValue])
81+
.check("failed to cache \(image.rawValue)")
82+
}
83+
84+
/// Reloads `image` from its cached tar archive rather than pulling over the network.
85+
///
86+
/// Use this in serial tests to restore a warmup image after a destructive operation
87+
/// (`image rm --all`, `image prune`) removes it from the store. Requires the
88+
/// `ImageWarmup` suite to have already run and populated the cache.
89+
public func restoreWarmupImage(_ image: WarmupImage) throws {
90+
try run(["image", "load", "--input", image.cacheTarPath.string])
91+
.check("failed to restore \(image.rawValue) from cache")
92+
}
93+
7294
/// Returns the full inspect output for an image, including variant information.
7395
public func doInspectImages(_ name: String) throws -> [ImageInspectOutput] {
7496
let result = try run(["image", "inspect", name]).check()

Sources/ContainerTestSupport/ContainerFixture.swift

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,38 +80,54 @@ public final class ContainerFixture: Sendable {
8080
public static func with<T>(_ body: (ContainerFixture) async throws -> T) async throws -> T {
8181
let testID = String(UUID().uuidString.prefix(8)).lowercased()
8282

83-
let scratchRoot =
84-
ProcessInfo.processInfo.environment["CLITEST_SCRATCH_ROOT"]
85-
.map { FilePath($0) }
86-
?? FilePath(FileManager.default.temporaryDirectory.path)
87-
8883
let testName =
8984
Test.current.map { $0.name.hasSuffix("()") ? String($0.name.dropLast(2)) : $0.name }
9085
?? testID
91-
let suiteName = Test.current.map { "\(type(of: $0))" } ?? "unknown"
92-
93-
// Name the scratch directory so it's immediately identifiable when browsing:
94-
// {sanitizedTestName}-{testID}
95-
let safeName = testName.replacingOccurrences(
96-
of: "[^a-zA-Z0-9]", with: "-", options: .regularExpression)
97-
let testDir = scratchRoot.appending("\(safeName)-\(testID)")
98-
try FileManager.default.createDirectory(
99-
atPath: testDir.string, withIntermediateDirectories: true, attributes: nil)
100-
86+
// Test.current is a value describing the running test, not an instance of the suite
87+
// type, so `type(of:)` always yields `Test` itself. Derive the suite from the test's
88+
// fully-qualified ID instead (e.g. "IntegrationTests.TestCLIStatus/explicitTableFormat()/...")
89+
// — the same identifier format used in the swift-testing event-stream JSON.
90+
let testIdentifier = Test.current.map { "\($0.id)" }
91+
let suiteName = testIdentifier?.split(separator: "/", maxSplits: 1).first.map(String.init) ?? "unknown"
92+
93+
// Swift Testing doesn't expose a stable per-case identifier or the case's arguments
94+
// publicly, only `isParameterized`. Parameterized tests share one `testName` across all
95+
// their concurrently-running cases, so fall back to the per-invocation `testID` to keep
96+
// each case's log file distinct.
97+
let isParameterized = Test.Case.current?.isParameterized ?? false
98+
let logFileName = isParameterized ? "\(testName)-\(testID).log" : "\(testName).log"
99+
100+
// Set up logging before any fixture work (scratch dir creation, etc.) so a "test start"
101+
// message is the first thing recorded — bookended by "test end" once `body` returns.
101102
var logger = Logger(label: "com.apple.container.test") { label in
102103
if let root = ProcessInfo.processInfo.environment["CLITEST_LOG_ROOT"], !root.isEmpty {
103104
let path =
104105
FilePath(root)
105106
.appending("clitests")
106107
.appending(suiteName)
107-
.appending(testName + ".log")
108+
.appending(logFileName)
108109
if let handler = try? FileLogHandler(label: label, category: "clitests", path: path) {
109110
return handler
110111
}
111112
}
112113
return StreamLogHandler.standardOutput(label: label)
113114
}
114115
logger[metadataKey: "testID"] = "\(testID)"
116+
logger[metadataKey: "test"] = "\(testIdentifier ?? testName)"
117+
logger.info("test start")
118+
119+
let scratchRoot =
120+
ProcessInfo.processInfo.environment["CLITEST_SCRATCH_ROOT"]
121+
.map { FilePath($0) }
122+
?? FilePath(FileManager.default.temporaryDirectory.path)
123+
124+
// Name the scratch directory so it's immediately identifiable when browsing:
125+
// {sanitizedTestName}-{testID}
126+
let safeName = testName.replacingOccurrences(
127+
of: "[^a-zA-Z0-9]", with: "-", options: .regularExpression)
128+
let testDir = scratchRoot.appending("\(safeName)-\(testID)")
129+
try FileManager.default.createDirectory(
130+
atPath: testDir.string, withIntermediateDirectories: true, attributes: nil)
115131

116132
let fixture = ContainerFixture(testID: testID, testDir: testDir, log: logger)
117133

@@ -123,10 +139,14 @@ public final class ContainerFixture: Sendable {
123139

124140
do {
125141
let result = try await body(fixture)
142+
logger.info("test end", metadata: ["result": "pass"])
126143
await fixture.runCleanup()
144+
logger.info("test cleaned up")
127145
return result
128146
} catch {
147+
logger.info("test end", metadata: ["result": "fail", "error": "\(error)"])
129148
await fixture.runCleanup()
149+
logger.info("test cleaned up")
130150
throw error
131151
}
132152
}

Sources/ContainerTestSupport/WarmupImage.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,29 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17+
import ContainerPersistence
18+
import SystemPackage
19+
1720
/// Images preloaded by the ``ImageWarmup`` suite before concurrent tests run.
1821
/// Add new commonly-used images here; the warmup pass pulls them in parallel.
1922
public enum WarmupImage: String, CaseIterable, Sendable {
2023
case alpine320 = "ghcr.io/linuxcontainers/alpine:3.20"
2124
case alpine318 = "ghcr.io/linuxcontainers/alpine:3.18"
2225
case busybox136 = "ghcr.io/containerd/busybox:1.36"
26+
27+
/// Directory under app-root holding OCI tar archives of each warmup image.
28+
///
29+
/// Living under app-root (rather than a scratch dir tied to a single
30+
/// fixture) means the cache survives across the warmup/concurrent/serial
31+
/// `swift test` invocations, which run as separate processes, and rides
32+
/// along whatever process clears app-root between full test runs — no
33+
/// dedicated cleanup needed.
34+
public static var cacheDirectory: FilePath {
35+
PathUtils.BaseConfigPath.appRoot.basePath().appending("test-image-cache")
36+
}
37+
38+
/// Path to this image's cached OCI tar archive.
39+
public var cacheTarPath: FilePath {
40+
Self.cacheDirectory.appending("\(self).tar")
41+
}
2342
}

Sources/Services/ContainerAPIService/Client/Flags.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ public struct Flags {
178178
kernel: String?,
179179
kernelArgs: [String],
180180
labels: [String],
181+
maskedPaths: [String],
181182
mounts: [String],
182183
name: String?,
183184
networks: [String],
@@ -186,6 +187,7 @@ public struct Flags {
186187
publishPorts: [String],
187188
publishSockets: [String],
188189
readOnly: Bool,
190+
readonlyPaths: [String],
189191
remove: Bool,
190192
rosetta: Bool,
191193
runtime: String?,
@@ -208,6 +210,7 @@ public struct Flags {
208210
self.kernel = kernel
209211
self.kernelArgs = kernelArgs
210212
self.labels = labels
213+
self.maskedPaths = maskedPaths
211214
self.mounts = mounts
212215
self.name = name
213216
self.networks = networks
@@ -216,6 +219,7 @@ public struct Flags {
216219
self.publishPorts = publishPorts
217220
self.publishSockets = publishSockets
218221
self.readOnly = readOnly
222+
self.readonlyPaths = readonlyPaths
219223
self.remove = remove
220224
self.rosetta = rosetta
221225
self.runtime = runtime
@@ -291,6 +295,16 @@ public struct Flags {
291295
@Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container")
292296
public var labels: [String] = []
293297

298+
/// EXPERIMENTAL: The flag is subject to change.
299+
@Option(
300+
name: .customLong("masked-path"),
301+
help: .init(
302+
"[EXPERIMENTAL] Hide a path inside the container, in addition to the runtime defaults (or NONE to clear prior values and the defaults)",
303+
valueName: "path"
304+
)
305+
)
306+
public var maskedPaths: [String] = []
307+
294308
@Option(name: .customLong("mount"), help: "Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)")
295309
public var mounts: [String] = []
296310

@@ -330,6 +344,16 @@ public struct Flags {
330344
@Flag(name: .long, help: "Mount the container's root filesystem as read-only")
331345
public var readOnly = false
332346

347+
/// EXPERIMENTAL: The flag is subject to change.
348+
@Option(
349+
name: .customLong("read-only-path"),
350+
help: .init(
351+
"[EXPERIMENTAL] Mark a path inside the container read-only, in addition to the runtime defaults (or NONE to clear prior values and the defaults)",
352+
valueName: "path"
353+
)
354+
)
355+
public var readonlyPaths: [String] = []
356+
333357
@Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops")
334358
public var remove = false
335359

Sources/Services/ContainerAPIService/Client/Parser.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,60 @@ public struct Parser {
10561056
return (normalizedAdd, normalizedDrop)
10571057
}
10581058

1059+
// MARK: Security paths
1060+
1061+
/// Sentinel that clears all previously accumulated paths, including the runtime defaults.
1062+
private static let pathResetSentinel = "NONE"
1063+
1064+
/// Parse and validate --masked-path arguments.
1065+
///
1066+
/// Values are processed in order on top of the runtime default set, so
1067+
/// `--masked-path /foo` yields the defaults plus `/foo`. The `NONE` sentinel
1068+
/// clears everything accumulated so far, including the defaults. A nil result
1069+
/// means the flag was not supplied and the runtime defaults apply unchanged.
1070+
public static func maskedPaths(_ values: [String]) throws -> [String]? {
1071+
try pathOverrides(values, defaults: LinuxContainer.defaultMaskedPaths(), flagName: "masked-path")
1072+
}
1073+
1074+
/// Parse and validate --read-only-path arguments. Ordering, the `NONE`
1075+
/// sentinel, and the nil result carry the same meaning as ``maskedPaths(_:)``.
1076+
public static func readonlyPaths(_ values: [String]) throws -> [String]? {
1077+
try pathOverrides(values, defaults: LinuxContainer.defaultReadonlyPaths(), flagName: "read-only-path")
1078+
}
1079+
1080+
/// Accumulate absolute paths on top of `defaults`, honoring the `NONE` reset
1081+
/// sentinel and dropping duplicates while preserving first-occurrence order.
1082+
private static func pathOverrides(_ values: [String], defaults: [String], flagName: String) throws -> [String]? {
1083+
guard !values.isEmpty else {
1084+
return nil
1085+
}
1086+
var paths = defaults
1087+
var seen = Set(defaults)
1088+
for value in values {
1089+
let trimmed = value.trimmingCharacters(in: .whitespaces)
1090+
if trimmed.uppercased() == pathResetSentinel {
1091+
paths = []
1092+
seen = []
1093+
continue
1094+
}
1095+
guard trimmed.hasPrefix("/") else {
1096+
throw ContainerizationError(
1097+
.invalidArgument,
1098+
message: "invalid path '\(value)' for --\(flagName): path must be absolute, or the \(pathResetSentinel) sentinel"
1099+
)
1100+
}
1101+
// Strip trailing slashes, preserving the root path itself.
1102+
var normalized = trimmed
1103+
while normalized.count > 1 && normalized.hasSuffix("/") {
1104+
normalized.removeLast()
1105+
}
1106+
if seen.insert(normalized).inserted {
1107+
paths.append(normalized)
1108+
}
1109+
}
1110+
return paths
1111+
}
1112+
10591113
// MARK: Miscellaneous
10601114

10611115
public static func parseBool(string: String) -> Bool? {

Sources/Services/ContainerAPIService/Client/Utility.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ public struct Utility {
255255
let caps = try Parser.capabilities(capAdd: management.capAdd, capDrop: management.capDrop)
256256
config.capAdd = caps.capAdd
257257
config.capDrop = caps.capDrop
258+
config.maskedPaths = try Parser.maskedPaths(management.maskedPaths)
259+
config.readonlyPaths = try Parser.readonlyPaths(management.readonlyPaths)
258260
config.stopSignal = imageConfig?.stopSignal
259261

260262
if let runtime = management.runtime {

0 commit comments

Comments
 (0)