diff --git a/Package.swift b/Package.swift index 442aa8b9a..42e9978ab 100644 --- a/Package.swift +++ b/Package.swift @@ -203,9 +203,12 @@ let package = Package( name: "ContainerAPIServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + "ContainerAPIClient", + "ContainerAPIService", "ContainerResource", "ContainerRuntimeLinuxClient", "ContainerRuntimeClient", + "ContainerXPC", ] ), .target( diff --git a/Sources/ContainerResource/Container/ContainerLogOptions.swift b/Sources/ContainerResource/Container/ContainerLogOptions.swift new file mode 100644 index 000000000..388cc9457 --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerLogOptions.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Options that refine how `ContainerClient.logs(id:options:)` returns +/// container log file handles. +/// +/// Both fields are optional / additive; ``default`` is the zero-value +/// equivalent to the original `logs(id:)` behavior. +public struct ContainerLogOptions: Sendable, Codable { + /// If non-nil, log lines whose ISO-8601 timestamp prefix is older than + /// this date are filtered out before the file handle is returned to the + /// client. Lines without a parseable timestamp are passed through + /// unchanged. + public let since: Date? + + /// If true, the client wants timestamps preserved on the returned lines. + /// At present this is a hint only — the daemon does not decorate raw log + /// lines that lack a timestamp prefix; line decoration is a follow-up. + public let timestamps: Bool + + public static let `default` = ContainerLogOptions(since: nil, timestamps: false) + + public init(since: Date? = nil, timestamps: Bool = false) { + self.since = since + self.timestamps = timestamps + } +} diff --git a/Sources/ContainerXPC/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift index 3c6a3dca8..43162ea69 100644 --- a/Sources/ContainerXPC/XPCMessage.swift +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -117,6 +117,12 @@ extension XPCMessage { return Data(bytes: bytes, count: length) } + public func contains(key: String) -> Bool { + lock.withLock { + xpc_dictionary_get_value(self.object, key) != nil + } + } + /// dataNoCopy is similar to data, except the data is not copied /// to a new buffer. What this means in practice is the second the /// underlying xpc_object_t gets released by ARC the data will be diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 291e8cf2f..1df105db2 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -264,9 +264,23 @@ public struct ContainerClient: Sendable { /// Get the log file handles for a container. public func logs(id: String) async throws -> [FileHandle] { + try await logs(id: id, options: .default) + } + + /// Get the log file handles for a container, refined by ``ContainerLogOptions``. + /// + /// `options.since` filters out log lines whose ISO-8601 timestamp prefix + /// predates the given date; lines without a parseable timestamp are + /// passed through. `options.timestamps` is forwarded to the daemon as a + /// hint; line-level timestamp decoration is a deferred follow-up. + public func logs(id: String, options: ContainerLogOptions) async throws -> [FileHandle] { do { let request = XPCMessage(route: .containerLogs) request.set(key: .id, value: id) + if let since = options.since { + request.set(key: .logSince, value: since) + } + request.set(key: .logTimestamps, value: options.timestamps) let response = try await xpcClient.send(request) let fds = response.fileHandles(key: .logs) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 4a6f6f42c..65ca1258d 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -147,6 +147,11 @@ public enum XPCKeys: String { case destinationPath case fileMode case createParents + + /// Optional `since: Date` filter on `logs`. + case logSince + /// Optional `timestamps: Bool` flag on `logs`. + case logTimestamps } public enum XPCRoute: String { @@ -207,6 +212,10 @@ extension XPCMessage { dataNoCopy(key: key.rawValue) } + public func contains(key: XPCKeys) -> Bool { + contains(key: key.rawValue) + } + public func set(key: XPCKeys, value: Data) { set(key: key.rawValue, value: value) } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index d7da46e3d..b2cabf1ad 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -289,12 +289,21 @@ public struct ContainersHarness: Sendable { message: "id cannot be empty" ) } - let fds = try await service.logs(id: id) + + let options = Self.logOptions(from: message) + + let fds = try await service.logs(id: id, options: options) let reply = message.reply() try reply.set(key: .logs, value: fds) return reply } + static func logOptions(from message: XPCMessage) -> ContainerLogOptions { + let since = message.contains(key: .logSince) ? message.date(key: .logSince) : nil + let timestamps = message.bool(key: .logTimestamps) + return ContainerLogOptions(since: since, timestamps: timestamps) + } + @Sendable public func copyIn(_ message: XPCMessage) async throws -> XPCMessage { guard let id = message.string(key: .id) else { diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index b18bf55d5..d86cde1da 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -27,6 +27,7 @@ import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS +import Darwin import Foundation import Logging import SystemPackage @@ -729,8 +730,11 @@ public actor ContainersService { try await client.resize(processID, size: size) } - // Get the logs for the container. public func logs(id: String) async throws -> [FileHandle] { + try await logs(id: id, options: .default) + } + + public func logs(id: String, options: ContainerLogOptions) async throws -> [FileHandle] { log.debug( "ContainersService: enter", metadata: [ @@ -748,18 +752,22 @@ public actor ContainersService { ) } - // Logs doesn't care if the container is running or not, just that - // the bundle is there, and that the files actually exist. We do - // first try and get the container state so we get a nicer error message - // (container foo not found) however. do { _ = try _getContainerState(id: id) let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerResource.Bundle(path: path) - return [ + var handles = [ try FileHandle(forReadingFrom: bundle.containerLog), try FileHandle(forReadingFrom: bundle.bootlog), ] + + if let since = options.since { + handles = handles.map { fh in + Self.filterFileHandleSince(fh, since: since) + } + } + + return handles } catch { throw ContainerizationError( .internalError, @@ -792,6 +800,92 @@ public actor ContainersService { try await client.copyOut(source: source, destination: destination, createParents: createParents) } + static func filterFileHandleSince(_ fh: FileHandle, since: Date) -> FileHandle { + guard let data = try? fh.readToEnd() else { + return fh + } + guard let result = Self.filteredLogData(data, since: since) else { + try? fh.seek(toOffset: 0) + return fh + } + + if let filteredHandle = Self.temporaryFileHandle(containing: result) { + return filteredHandle + } + try? fh.seek(toOffset: 0) + return fh + } + + static func filteredLogData(_ data: Data, since: Date) -> Data? { + guard let content = String(data: data, encoding: .utf8) else { + return nil + } + + let iso8601 = ISO8601DateFormatter() + iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let fallbackFormatter = ISO8601DateFormatter() + fallbackFormatter.formatOptions = [.withInternetDateTime] + + let lines = content.components(separatedBy: "\n") + var filtered: [String] = [] + for line in lines { + guard !line.isEmpty else { + filtered.append(line) + continue + } + let parts = line.split(separator: " ", maxSplits: 1) + guard let timestampStr = parts.first else { + filtered.append(line) + continue + } + if let date = iso8601.date(from: String(timestampStr)) ?? fallbackFormatter.date(from: String(timestampStr)) { + if date >= since { + filtered.append(line) + } + } else { + filtered.append(line) + } + } + + let result = filtered.joined(separator: "\n") + return result.data(using: .utf8) + } + + private static func temporaryFileHandle(containing data: Data) -> FileHandle? { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("container-log-filter-") + .appendingPathExtension(UUID().uuidString) + let fd = open(url.path, O_CREAT | O_EXCL | O_RDWR, S_IRUSR | S_IWUSR) + guard fd >= 0 else { + return nil + } + + let success = data.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return true + } + var remaining = buffer.count + var pointer = baseAddress + while remaining > 0 { + let written = write(fd, pointer, remaining) + guard written > 0 else { + return false + } + remaining -= written + pointer += written + } + return true + } + + guard success, lseek(fd, 0, SEEK_SET) >= 0 else { + close(fd) + try? FileManager.default.removeItem(at: url) + return nil + } + + try? FileManager.default.removeItem(at: url) + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } + /// Get statistics for the container. public func stats(id: String) async throws -> ContainerStats { log.debug( diff --git a/Tests/ContainerAPIServiceTests/ContainerLogsTests.swift b/Tests/ContainerAPIServiceTests/ContainerLogsTests.swift new file mode 100644 index 000000000..1e6004c92 --- /dev/null +++ b/Tests/ContainerAPIServiceTests/ContainerLogsTests.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 +@testable import ContainerAPIService +import ContainerXPC +import Foundation +import Testing + +struct ContainerLogsTests { + + @Test("Log filtering can return output larger than a pipe buffer") + func testFilterLargeOutput() throws { + let since = Date(timeIntervalSince1970: 1_700_000_000) + let line = "2027-01-01T00:00:00Z retained log line with enough bytes to grow the output\n" + let content = String(repeating: line, count: 2_000) + let handle = try fileHandle(containing: Data(content.utf8)) + + let filtered = ContainersService.filterFileHandleSince(handle, since: since) + let data = try #require(try filtered.readToEnd()) + + #expect(data == Data(content.utf8)) + } + + @Test("Log filtering preserves empty lines and trailing newline") + func testFilterPreservesEmptyLinesAndTrailingNewline() throws { + let since = Date(timeIntervalSince1970: 1_700_000_000) + let content = "2020-01-01T00:00:00Z old\n\n2027-01-01T00:00:00Z new\n" + let expected = "\n2027-01-01T00:00:00Z new\n" + + let data = try #require(ContainersService.filteredLogData(Data(content.utf8), since: since)) + + #expect(String(data: data, encoding: .utf8) == expected) + } + + @Test("Non-UTF8 logs fall back to the original bytes") + func testNonUTF8LogsReturnOriginalBytes() throws { + let bytes = Data([0xff, 0xfe, 0x00, 0x41]) + let handle = try fileHandle(containing: bytes) + + let filtered = ContainersService.filterFileHandleSince(handle, since: Date()) + let data = try #require(try filtered.readToEnd()) + + #expect(data == bytes) + } + + @Test("Harness preserves explicit epoch log since option") + func testHarnessPreservesEpochSince() { + let message = XPCMessage(route: .containerLogs) + let epoch = Date(timeIntervalSince1970: 0) + message.set(key: .logSince, value: epoch) + + let options = ContainersHarness.logOptions(from: message) + + #expect(options.since == epoch) + } + + @Test("Harness preserves explicit pre-epoch log since option") + func testHarnessPreservesPreEpochSince() { + let message = XPCMessage(route: .containerLogs) + let preEpoch = Date(timeIntervalSince1970: -1) + message.set(key: .logSince, value: preEpoch) + + let options = ContainersHarness.logOptions(from: message) + + #expect(options.since == preEpoch) + } + + @Test("Harness leaves absent log since option unset") + func testHarnessLeavesAbsentSinceUnset() { + let message = XPCMessage(route: .containerLogs) + message.set(key: .logTimestamps, value: true) + + let options = ContainersHarness.logOptions(from: message) + + #expect(options.since == nil) + #expect(options.timestamps) + } + + private func fileHandle(containing data: Data) throws -> FileHandle { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("container-log-test-") + .appendingPathExtension(UUID().uuidString) + try data.write(to: url) + let handle = try FileHandle(forReadingFrom: url) + try? FileManager.default.removeItem(at: url) + return handle + } +} diff --git a/Tests/ContainerResourceTests/ContainerLogOptionsTests.swift b/Tests/ContainerResourceTests/ContainerLogOptionsTests.swift new file mode 100644 index 000000000..d0d65d9c6 --- /dev/null +++ b/Tests/ContainerResourceTests/ContainerLogOptionsTests.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// 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 + +@testable import ContainerResource + +struct ContainerLogOptionsTests { + + @Test("Default log options preserve existing logs behavior") + func testDefaultOptions() { + let options = ContainerLogOptions.default + + #expect(options.since == nil) + #expect(!options.timestamps) + } + + @Test("Custom log options round-trip through JSON") + func testCustomOptionsRoundTrip() throws { + let since = Date(timeIntervalSince1970: 1_800_000_000) + let options = ContainerLogOptions(since: since, timestamps: true) + + let data = try JSONEncoder().encode(options) + let decoded = try JSONDecoder().decode(ContainerLogOptions.self, from: data) + + #expect(decoded.since == since) + #expect(decoded.timestamps) + } +}