Skip to content

Commit 9d6a430

Browse files
committed
feat(api): add ContainerLogOptions {since, timestamps} to ContainerClient.logs
Adds an additive overload to ContainerClient.logs that accepts a ContainerLogOptions { since: Date?, timestamps: Bool } and plumbs the two parameters through XPC into the daemon's existing log-handle path. Motivation ---------- External orchestrators that drive the API server (the canonical use case is a Compose-spec orchestrator implementing 'compose logs --since <timestamp>' and '--timestamps') need to retrieve only recent log output and optionally annotate lines with timestamps. Today ContainerClient.logs(id:) returns raw containerLog + bootlog file handles unconditionally; consumers either replay the entire log buffer or implement their own line-by-line filter on the client. Surfacing the parameters at the API boundary keeps the line scanning where the file lives — server-side — and matches the docker-compose UX users expect. What this PR changes -------------------- - Sources/ContainerResource/Container/ContainerLogOptions.swift (new): the ContainerLogOptions struct, Sendable + Codable, with a static '.default' equivalent to the original logs(id:) zero-config call. - Sources/Services/ContainerAPIService/Client/XPC+.swift: two new XPCKeys cases ('logSince', 'logTimestamps') for the optional parameters. - Sources/Services/ContainerAPIService/Client/ContainerClient.swift: new logs(id:options:) overload. The existing logs(id:) is retained as a thin wrapper over .default for source compatibility. - Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift: matching logs(id:options:) overload; when options.since is provided, applies a private 'filterFileHandleSince' that parses ISO-8601 timestamps from line starts and drops older lines (lines without a parseable timestamp pass through unchanged). - Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift: parses the two optional XPC keys, builds ContainerLogOptions, and forwards to the new service overload. Wire compatibility ------------------ XPC calls without the new keys decode as 'sinceRaw.timeIntervalSince1970 == 0' (the harness treats this as 'no since filter') and 'timestamps = false', so older clients hitting a newer server see unchanged behavior. Newer clients hitting an older server send the keys; an old harness will silently drop them and run the original codepath. Known limitations (intentional, follow-up work) ----------------------------------------------- - The 'options.timestamps' parameter is plumbed end-to-end but the daemon does not currently decorate raw log lines that lack a timestamp prefix. Line decoration is a deliberate follow-up; keeping the parameter on the API surface today avoids a second wire-format break later. - 'filterFileHandleSince' currently slurps the file into memory then returns a Pipe-backed FileHandle. For the typical container log size this is fine; for very large logs a streaming line iterator would be a worthwhile optimization. Filed as a known-issue for the follow-up timestamp-decoration PR. Verification ------------ Full 'swift build' clean on macOS 26 / Apple silicon (release config, all targets including downstream consumers of ContainerClient.logs).
1 parent b466959 commit 9d6a430

5 files changed

Lines changed: 122 additions & 7 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
19+
/// Options that refine how `ContainerClient.logs(id:options:)` returns
20+
/// container log file handles.
21+
///
22+
/// Both fields are optional / additive; ``default`` is the zero-value
23+
/// equivalent to the original `logs(id:)` behavior.
24+
public struct ContainerLogOptions: Sendable, Codable {
25+
/// If non-nil, log lines whose ISO-8601 timestamp prefix is older than
26+
/// this date are filtered out before the file handle is returned to the
27+
/// client. Lines without a parseable timestamp are passed through
28+
/// unchanged.
29+
public let since: Date?
30+
31+
/// If true, the client wants timestamps preserved on the returned lines.
32+
/// At present this is a hint only — the daemon does not decorate raw log
33+
/// lines that lack a timestamp prefix; line decoration is a follow-up.
34+
public let timestamps: Bool
35+
36+
public static let `default` = ContainerLogOptions(since: nil, timestamps: false)
37+
38+
public init(since: Date? = nil, timestamps: Bool = false) {
39+
self.since = since
40+
self.timestamps = timestamps
41+
}
42+
}

Sources/Services/ContainerAPIService/Client/ContainerClient.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,23 @@ public struct ContainerClient: Sendable {
259259

260260
/// Get the log file handles for a container.
261261
public func logs(id: String) async throws -> [FileHandle] {
262+
try await logs(id: id, options: .default)
263+
}
264+
265+
/// Get the log file handles for a container, refined by ``ContainerLogOptions``.
266+
///
267+
/// `options.since` filters out log lines whose ISO-8601 timestamp prefix
268+
/// predates the given date; lines without a parseable timestamp are
269+
/// passed through. `options.timestamps` is forwarded to the daemon as a
270+
/// hint; line-level timestamp decoration is a deferred follow-up.
271+
public func logs(id: String, options: ContainerLogOptions) async throws -> [FileHandle] {
262272
do {
263273
let request = XPCMessage(route: .containerLogs)
264274
request.set(key: .id, value: id)
275+
if let since = options.since {
276+
request.set(key: .logSince, value: since)
277+
}
278+
request.set(key: .logTimestamps, value: options.timestamps)
265279

266280
let response = try await xpcClient.send(request)
267281
let fds = response.fileHandles(key: .logs)

Sources/Services/ContainerAPIService/Client/XPC+.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ public enum XPCKeys: String {
139139

140140
/// Disk usage
141141
case diskUsageStats
142+
143+
/// Optional `since: Date` filter on `logs`.
144+
case logSince
145+
/// Optional `timestamps: Bool` flag on `logs`.
146+
case logTimestamps
142147
}
143148

144149
public enum XPCRoute: String {

Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,16 @@ public struct ContainersHarness: Sendable {
288288
message: "id cannot be empty"
289289
)
290290
}
291-
let fds = try await service.logs(id: id)
291+
292+
var since: Date? = nil
293+
let sinceRaw = message.date(key: .logSince)
294+
if sinceRaw.timeIntervalSince1970 > 0 {
295+
since = sinceRaw
296+
}
297+
let timestamps = message.bool(key: .logTimestamps)
298+
let options = ContainerLogOptions(since: since, timestamps: timestamps)
299+
300+
let fds = try await service.logs(id: id, options: options)
292301
let reply = message.reply()
293302
try reply.set(key: .logs, value: fds)
294303
return reply

Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -723,8 +723,11 @@ public actor ContainersService {
723723
try await client.resize(processID, size: size)
724724
}
725725

726-
// Get the logs for the container.
727726
public func logs(id: String) async throws -> [FileHandle] {
727+
try await logs(id: id, options: .default)
728+
}
729+
730+
public func logs(id: String, options: ContainerLogOptions) async throws -> [FileHandle] {
728731
log.debug(
729732
"ContainersService: enter",
730733
metadata: [
@@ -742,18 +745,22 @@ public actor ContainersService {
742745
)
743746
}
744747

745-
// Logs doesn't care if the container is running or not, just that
746-
// the bundle is there, and that the files actually exist. We do
747-
// first try and get the container state so we get a nicer error message
748-
// (container foo not found) however.
749748
do {
750749
_ = try _getContainerState(id: id)
751750
let path = self.containerRoot.appendingPathComponent(id)
752751
let bundle = ContainerResource.Bundle(path: path)
753-
return [
752+
var handles = [
754753
try FileHandle(forReadingFrom: bundle.containerLog),
755754
try FileHandle(forReadingFrom: bundle.bootlog),
756755
]
756+
757+
if let since = options.since {
758+
handles = handles.map { fh in
759+
Self.filterFileHandleSince(fh, since: since)
760+
}
761+
}
762+
763+
return handles
757764
} catch {
758765
throw ContainerizationError(
759766
.internalError,
@@ -762,6 +769,44 @@ public actor ContainersService {
762769
}
763770
}
764771

772+
private static func filterFileHandleSince(_ fh: FileHandle, since: Date) -> FileHandle {
773+
guard let data = try? fh.readToEnd(),
774+
let content = String(data: data, encoding: .utf8) else {
775+
return fh
776+
}
777+
778+
let iso8601 = ISO8601DateFormatter()
779+
iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
780+
let fallbackFormatter = ISO8601DateFormatter()
781+
fallbackFormatter.formatOptions = [.withInternetDateTime]
782+
783+
let lines = content.components(separatedBy: .newlines)
784+
var filtered: [String] = []
785+
for line in lines {
786+
guard !line.isEmpty else { continue }
787+
let parts = line.split(separator: " ", maxSplits: 1)
788+
guard let timestampStr = parts.first else {
789+
filtered.append(line)
790+
continue
791+
}
792+
if let date = iso8601.date(from: String(timestampStr)) ?? fallbackFormatter.date(from: String(timestampStr)) {
793+
if date >= since {
794+
filtered.append(line)
795+
}
796+
} else {
797+
filtered.append(line)
798+
}
799+
}
800+
801+
let pipe = Pipe()
802+
let result = filtered.joined(separator: "\n")
803+
if let resultData = result.data(using: .utf8) {
804+
pipe.fileHandleForWriting.write(resultData)
805+
}
806+
try? pipe.fileHandleForWriting.close()
807+
return pipe.fileHandleForReading
808+
}
809+
765810
/// Get statistics for the container.
766811
public func stats(id: String) async throws -> ContainerStats {
767812
log.debug(

0 commit comments

Comments
 (0)