Skip to content

Commit 3e5a31a

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 c1a6d97 commit 3e5a31a

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
@@ -264,9 +264,23 @@ public struct ContainerClient: Sendable {
264264

265265
/// Get the log file handles for a container.
266266
public func logs(id: String) async throws -> [FileHandle] {
267+
try await logs(id: id, options: .default)
268+
}
269+
270+
/// Get the log file handles for a container, refined by ``ContainerLogOptions``.
271+
///
272+
/// `options.since` filters out log lines whose ISO-8601 timestamp prefix
273+
/// predates the given date; lines without a parseable timestamp are
274+
/// passed through. `options.timestamps` is forwarded to the daemon as a
275+
/// hint; line-level timestamp decoration is a deferred follow-up.
276+
public func logs(id: String, options: ContainerLogOptions) async throws -> [FileHandle] {
267277
do {
268278
let request = XPCMessage(route: .containerLogs)
269279
request.set(key: .id, value: id)
280+
if let since = options.since {
281+
request.set(key: .logSince, value: since)
282+
}
283+
request.set(key: .logTimestamps, value: options.timestamps)
270284

271285
let response = try await xpcClient.send(request)
272286
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
@@ -141,6 +141,11 @@ public enum XPCKeys: String {
141141

142142
/// Disk usage
143143
case diskUsageStats
144+
145+
/// Optional `since: Date` filter on `logs`.
146+
case logSince
147+
/// Optional `timestamps: Bool` flag on `logs`.
148+
case logTimestamps
144149
}
145150

146151
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
@@ -289,7 +289,16 @@ public struct ContainersHarness: Sendable {
289289
message: "id cannot be empty"
290290
)
291291
}
292-
let fds = try await service.logs(id: id)
292+
293+
var since: Date? = nil
294+
let sinceRaw = message.date(key: .logSince)
295+
if sinceRaw.timeIntervalSince1970 > 0 {
296+
since = sinceRaw
297+
}
298+
let timestamps = message.bool(key: .logTimestamps)
299+
let options = ContainerLogOptions(since: since, timestamps: timestamps)
300+
301+
let fds = try await service.logs(id: id, options: options)
293302
let reply = message.reply()
294303
try reply.set(key: .logs, value: fds)
295304
return reply

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

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

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

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

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

0 commit comments

Comments
 (0)