Skip to content

Commit 3aec004

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 2ee3f3d commit 3aec004

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
@@ -758,8 +758,11 @@ public actor ContainersService {
758758
try await client.resize(processID, size: size)
759759
}
760760

761-
// Get the logs for the container.
762761
public func logs(id: String) async throws -> [FileHandle] {
762+
try await logs(id: id, options: .default)
763+
}
764+
765+
public func logs(id: String, options: ContainerLogOptions) async throws -> [FileHandle] {
763766
log.debug(
764767
"ContainersService: enter",
765768
metadata: [
@@ -777,18 +780,22 @@ public actor ContainersService {
777780
)
778781
}
779782

780-
// Logs doesn't care if the container is running or not, just that
781-
// the bundle is there, and that the files actually exist. We do
782-
// first try and get the container state so we get a nicer error message
783-
// (container foo not found) however.
784783
do {
785784
_ = try _getContainerState(id: id)
786785
let path = self.containerRoot.appendingPathComponent(id)
787786
let bundle = ContainerResource.Bundle(path: path)
788-
return [
787+
var handles = [
789788
try FileHandle(forReadingFrom: bundle.containerLog),
790789
try FileHandle(forReadingFrom: bundle.bootlog),
791790
]
791+
792+
if let since = options.since {
793+
handles = handles.map { fh in
794+
Self.filterFileHandleSince(fh, since: since)
795+
}
796+
}
797+
798+
return handles
792799
} catch {
793800
throw ContainerizationError(
794801
.internalError,
@@ -797,6 +804,44 @@ public actor ContainersService {
797804
}
798805
}
799806

807+
private static func filterFileHandleSince(_ fh: FileHandle, since: Date) -> FileHandle {
808+
guard let data = try? fh.readToEnd(),
809+
let content = String(data: data, encoding: .utf8) else {
810+
return fh
811+
}
812+
813+
let iso8601 = ISO8601DateFormatter()
814+
iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
815+
let fallbackFormatter = ISO8601DateFormatter()
816+
fallbackFormatter.formatOptions = [.withInternetDateTime]
817+
818+
let lines = content.components(separatedBy: .newlines)
819+
var filtered: [String] = []
820+
for line in lines {
821+
guard !line.isEmpty else { continue }
822+
let parts = line.split(separator: " ", maxSplits: 1)
823+
guard let timestampStr = parts.first else {
824+
filtered.append(line)
825+
continue
826+
}
827+
if let date = iso8601.date(from: String(timestampStr)) ?? fallbackFormatter.date(from: String(timestampStr)) {
828+
if date >= since {
829+
filtered.append(line)
830+
}
831+
} else {
832+
filtered.append(line)
833+
}
834+
}
835+
836+
let pipe = Pipe()
837+
let result = filtered.joined(separator: "\n")
838+
if let resultData = result.data(using: .utf8) {
839+
pipe.fileHandleForWriting.write(resultData)
840+
}
841+
try? pipe.fileHandleForWriting.close()
842+
return pipe.fileHandleForReading
843+
}
844+
800845
/// Get statistics for the container.
801846
public func stats(id: String) async throws -> ContainerStats {
802847
log.debug(

0 commit comments

Comments
 (0)