Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,12 @@ let package = Package(
name: "ContainerAPIServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
"ContainerAPIClient",
"ContainerAPIService",
"ContainerResource",
"ContainerRuntimeLinuxClient",
"ContainerRuntimeClient",
"ContainerXPC",
]
),
.target(
Expand Down
42 changes: 42 additions & 0 deletions Sources/ContainerResource/Container/ContainerLogOptions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 6 additions & 0 deletions Sources/ContainerXPC/XPCMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ContainerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import ContainerizationOS
import Darwin
import Foundation
import Logging
import SystemPackage
Expand Down Expand Up @@ -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: [
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
101 changes: 101 additions & 0 deletions Tests/ContainerAPIServiceTests/ContainerLogsTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading