Skip to content

Commit 968134b

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 caff1e9 commit 968134b

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
@@ -147,6 +147,11 @@ public enum XPCKeys: String {
147147
case destinationPath
148148
case fileMode
149149
case createParents
150+
151+
/// Optional `since: Date` filter on `logs`.
152+
case logSince
153+
/// Optional `timestamps: Bool` flag on `logs`.
154+
case logTimestamps
150155
}
151156

152157
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
@@ -729,8 +729,11 @@ public actor ContainersService {
729729
try await client.resize(processID, size: size)
730730
}
731731

732-
// Get the logs for the container.
733732
public func logs(id: String) async throws -> [FileHandle] {
733+
try await logs(id: id, options: .default)
734+
}
735+
736+
public func logs(id: String, options: ContainerLogOptions) async throws -> [FileHandle] {
734737
log.debug(
735738
"ContainersService: enter",
736739
metadata: [
@@ -748,18 +751,22 @@ public actor ContainersService {
748751
)
749752
}
750753

751-
// Logs doesn't care if the container is running or not, just that
752-
// the bundle is there, and that the files actually exist. We do
753-
// first try and get the container state so we get a nicer error message
754-
// (container foo not found) however.
755754
do {
756755
_ = try _getContainerState(id: id)
757756
let path = self.containerRoot.appendingPathComponent(id)
758757
let bundle = ContainerResource.Bundle(path: path)
759-
return [
758+
var handles = [
760759
try FileHandle(forReadingFrom: bundle.containerLog),
761760
try FileHandle(forReadingFrom: bundle.bootlog),
762761
]
762+
763+
if let since = options.since {
764+
handles = handles.map { fh in
765+
Self.filterFileHandleSince(fh, since: since)
766+
}
767+
}
768+
769+
return handles
763770
} catch {
764771
throw ContainerizationError(
765772
.internalError,
@@ -792,6 +799,44 @@ public actor ContainersService {
792799
try await client.copyOut(source: source, destination: destination, createParents: createParents)
793800
}
794801

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

0 commit comments

Comments
 (0)