Skip to content

Commit 01e98c3

Browse files
authored
Implement DMR log streaming via /logs endpoint (#807)
* feat: implement DMR log streaming via /logs endpoint with follow and no-engines options * fix: surface errors when tailing engine log to prevent silent failures
1 parent 183e38c commit 01e98c3

10 files changed

Lines changed: 924 additions & 263 deletions

File tree

cmd/cli/commands/logs.go

Lines changed: 118 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,23 @@
11
package commands
22

33
import (
4-
"bufio"
54
"context"
65
"errors"
76
"fmt"
8-
"io"
97
"os"
108
"os/exec"
11-
"os/signal"
129
"path/filepath"
13-
"regexp"
1410
"runtime"
1511
"strings"
16-
"time"
1712

1813
"github.com/docker/model-runner/cmd/cli/commands/completion"
1914
"github.com/docker/model-runner/cmd/cli/desktop"
2015
"github.com/docker/model-runner/cmd/cli/pkg/standalone"
2116
"github.com/docker/model-runner/cmd/cli/pkg/types"
17+
dmrlogs "github.com/docker/model-runner/pkg/logs"
2218
"github.com/moby/moby/api/pkg/stdcopy"
2319
"github.com/moby/moby/client"
24-
"github.com/nxadm/tail"
2520
"github.com/spf13/cobra"
26-
"golang.org/x/sync/errgroup"
2721
)
2822

2923
func newLogsCmd() *cobra.Command {
@@ -32,135 +26,98 @@ func newLogsCmd() *cobra.Command {
3226
Use: "logs [OPTIONS]",
3327
Short: "Fetch the Docker Model Runner logs",
3428
RunE: func(cmd *cobra.Command, args []string) error {
35-
homeDir, err := os.UserHomeDir()
36-
if err != nil {
37-
return err
38-
}
39-
40-
// If we're running in standalone mode, then print the container
41-
// logs.
29+
// Standalone mode: fetch container logs via Docker API.
4230
engineKind := modelRunner.EngineKind()
4331
useStandaloneLogs := engineKind == types.ModelRunnerEngineKindMoby ||
4432
engineKind == types.ModelRunnerEngineKindCloud
4533
if useStandaloneLogs {
46-
dockerClient, err := desktop.DockerClientForContext(dockerCLI, dockerCLI.CurrentContext())
34+
dockerClient, err := desktop.DockerClientForContext(
35+
dockerCLI, dockerCLI.CurrentContext(),
36+
)
4737
if err != nil {
4838
return fmt.Errorf("failed to create Docker client: %w", err)
4939
}
50-
ctrID, _, _, err := standalone.FindControllerContainer(cmd.Context(), dockerClient)
40+
ctrID, _, _, err := standalone.FindControllerContainer(
41+
cmd.Context(), dockerClient,
42+
)
5143
if err != nil {
52-
return fmt.Errorf("unable to identify Model Runner container: %w", err)
44+
return fmt.Errorf(
45+
"unable to identify Model Runner container: %w", err,
46+
)
5347
} else if ctrID == "" {
5448
return errors.New("unable to identify Model Runner container")
5549
}
56-
log, err := dockerClient.ContainerLogs(cmd.Context(), ctrID, client.ContainerLogsOptions{
57-
ShowStdout: true,
58-
ShowStderr: true,
59-
Follow: follow,
60-
})
50+
log, err := dockerClient.ContainerLogs(
51+
cmd.Context(), ctrID, client.ContainerLogsOptions{
52+
ShowStdout: true,
53+
ShowStderr: true,
54+
Follow: follow,
55+
},
56+
)
6157
if err != nil {
62-
return fmt.Errorf("unable to query Model Runner container logs: %w", err)
58+
return fmt.Errorf(
59+
"unable to query Model Runner container logs: %w", err,
60+
)
6361
}
6462
defer log.Close()
6563
_, err = stdcopy.StdCopy(os.Stdout, os.Stderr, log)
6664
return err
6765
}
6866

69-
var serviceLogPath string
70-
var runtimeLogPath string
71-
switch runtime.GOOS {
72-
case "darwin":
73-
serviceLogPath = filepath.Join(homeDir, "Library/Containers/com.docker.docker/Data/log/host/inference.log")
74-
runtimeLogPath = filepath.Join(homeDir, "Library/Containers/com.docker.docker/Data/log/host/inference-llama.cpp-server.log")
75-
case "windows", "linux":
76-
baseDir := homeDir
77-
if runtime.GOOS == "linux" {
78-
if !isWSL() {
79-
return fmt.Errorf("log viewing on native Linux is only supported in standalone mode")
80-
}
81-
// When running inside WSL2 with Docker Desktop, the log files
82-
// are on the Windows host filesystem mounted under /mnt/.
83-
winHomeDir, wslErr := windowsHomeDirFromWSL(cmd.Context())
84-
if wslErr != nil {
85-
return fmt.Errorf("unable to determine Windows home directory from WSL2: %w", wslErr)
86-
}
87-
baseDir = winHomeDir
67+
// Desktop mode: try local log files first.
68+
serviceLogPath, runtimeLogPath, localErr := resolveDesktopLogPaths(
69+
cmd.Context(),
70+
)
71+
if localErr == nil {
72+
// Verify we can actually open the service log.
73+
f, openErr := os.Open(serviceLogPath)
74+
if openErr != nil {
75+
localErr = openErr
76+
} else {
77+
f.Close()
8878
}
89-
serviceLogPath = filepath.Join(baseDir, "AppData/Local/Docker/log/host/inference.log")
90-
runtimeLogPath = filepath.Join(baseDir, "AppData/Local/Docker/log/host/inference-llama.cpp-server.log")
91-
default:
92-
return fmt.Errorf("unsupported OS: %s", runtime.GOOS)
9379
}
9480

95-
if noEngines {
96-
err = printMergedLog(cmd.OutOrStdout(), serviceLogPath, "")
97-
if err != nil {
98-
return err
99-
}
100-
} else {
101-
err = printMergedLog(cmd.OutOrStdout(), serviceLogPath, runtimeLogPath)
102-
if err != nil {
103-
return err
81+
if localErr != nil {
82+
// Local files unavailable (e.g. running inside a container).
83+
// Fall back to the DMR /logs API.
84+
apiErr := desktopClient.Logs(
85+
cmd.Context(), follow, noEngines, cmd.OutOrStdout(),
86+
)
87+
if apiErr != nil {
88+
return fmt.Errorf(
89+
"local logs unavailable (%w); API fallback failed: %w",
90+
localErr, apiErr,
91+
)
10492
}
105-
}
106-
107-
if !follow {
10893
return nil
10994
}
11095

111-
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
112-
defer cancel()
113-
114-
g, ctx := errgroup.WithContext(ctx)
115-
116-
// Poll mode is needed when tailing files over a mounted filesystem
117-
// (Windows or WSL2 accessing the Windows host via /mnt/).
118-
pollMode := runtime.GOOS == "windows" || (runtime.GOOS == "linux" && isWSL())
119-
120-
g.Go(func() error {
121-
t, err := tail.TailFile(
122-
serviceLogPath, tail.Config{Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekEnd}, Follow: true, ReOpen: true, Poll: pollMode},
123-
)
124-
if err != nil {
125-
return err
126-
}
127-
for {
128-
select {
129-
case line, ok := <-t.Lines:
130-
if !ok {
131-
return nil
132-
}
133-
cmd.Println(line.Text)
134-
case <-ctx.Done():
135-
return t.Stop()
136-
}
137-
}
138-
})
139-
96+
// Local files are accessible: use shared merge/follow logic.
97+
enginePath := ""
14098
if !noEngines {
141-
g.Go(func() error {
142-
t, err := tail.TailFile(
143-
runtimeLogPath, tail.Config{Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekEnd}, Follow: true, ReOpen: true, Poll: pollMode},
144-
)
145-
if err != nil {
146-
return err
147-
}
148-
149-
for {
150-
select {
151-
case line, ok := <-t.Lines:
152-
if !ok {
153-
return nil
154-
}
155-
cmd.Println(line.Text)
156-
case <-ctx.Done():
157-
return t.Stop()
158-
}
159-
}
160-
})
99+
enginePath = runtimeLogPath
161100
}
162101

163-
return g.Wait()
102+
// Poll mode is needed when tailing files over a mounted
103+
// filesystem (Windows or WSL2 accessing the Windows host).
104+
pollMode := runtime.GOOS == "windows" ||
105+
(runtime.GOOS == "linux" && isWSL())
106+
107+
result, err := dmrlogs.MergeLogs(
108+
cmd.OutOrStdout(), serviceLogPath, enginePath,
109+
)
110+
if err != nil {
111+
return err
112+
}
113+
if !follow {
114+
return nil
115+
}
116+
return dmrlogs.Follow(
117+
cmd.Context(), cmd.OutOrStdout(),
118+
serviceLogPath, enginePath,
119+
result, pollMode,
120+
)
164121
},
165122
ValidArgsFunction: completion.NoComplete,
166123
}
@@ -169,15 +126,64 @@ func newLogsCmd() *cobra.Command {
169126
return c
170127
}
171128

172-
// isWSL reports whether the current process is running inside a WSL2 environment.
129+
// resolveDesktopLogPaths returns the service and engine log file paths
130+
// for Docker Desktop mode. It returns an error when the OS is not
131+
// supported or path discovery fails (e.g. native Linux, WSL failure).
132+
func resolveDesktopLogPaths(ctx context.Context) (string, string, error) {
133+
homeDir, err := os.UserHomeDir()
134+
if err != nil {
135+
return "", "", fmt.Errorf("home directory: %w", err)
136+
}
137+
138+
switch runtime.GOOS {
139+
case "darwin":
140+
base := filepath.Join(
141+
homeDir,
142+
"Library/Containers/com.docker.docker/Data/log/host",
143+
)
144+
return filepath.Join(base, dmrlogs.ServiceLogName),
145+
filepath.Join(base, dmrlogs.EngineLogName),
146+
nil
147+
case "windows":
148+
base := filepath.Join(homeDir, "AppData/Local/Docker/log/host")
149+
return filepath.Join(base, dmrlogs.ServiceLogName),
150+
filepath.Join(base, dmrlogs.EngineLogName),
151+
nil
152+
case "linux":
153+
if !isWSL() {
154+
return "", "", fmt.Errorf(
155+
"log viewing on native Linux is only supported in standalone mode",
156+
)
157+
}
158+
// In WSL2 with Docker Desktop, log files are on the Windows
159+
// host filesystem mounted under /mnt/.
160+
winHomeDir, wslErr := windowsHomeDirFromWSL(ctx)
161+
if wslErr != nil {
162+
return "", "", fmt.Errorf(
163+
"unable to determine Windows home directory from WSL2: %w",
164+
wslErr,
165+
)
166+
}
167+
base := filepath.Join(winHomeDir, "AppData/Local/Docker/log/host")
168+
return filepath.Join(base, dmrlogs.ServiceLogName),
169+
filepath.Join(base, dmrlogs.EngineLogName),
170+
nil
171+
default:
172+
return "", "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
173+
}
174+
}
175+
176+
// isWSL reports whether the current process is running inside a WSL2
177+
// environment.
173178
func isWSL() bool {
174179
_, ok := os.LookupEnv("WSL_DISTRO_NAME")
175180
return ok
176181
}
177182

178-
// windowsHomeDirFromWSL resolves the Windows user's home directory from
179-
// within a WSL2 environment by running "wslpath" on the USERPROFILE path
180-
// obtained via "wslvar". This returns a Linux path like /mnt/c/Users/Name.
183+
// windowsHomeDirFromWSL resolves the Windows user's home directory
184+
// from within a WSL2 environment by running "wslpath" on the
185+
// USERPROFILE path obtained via "wslvar". Returns a Linux path such
186+
// as /mnt/c/Users/Name.
181187
func windowsHomeDirFromWSL(ctx context.Context) (string, error) {
182188
out, err := exec.CommandContext(ctx, "wslvar", "USERPROFILE").Output()
183189
if err != nil {
@@ -197,82 +203,3 @@ func windowsHomeDirFromWSL(ctx context.Context) (string, error) {
197203
}
198204
return linuxPath, nil
199205
}
200-
201-
var timestampRe = regexp.MustCompile(`\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\].*`)
202-
203-
const timeFmt = "2006-01-02T15:04:05.000000000Z"
204-
205-
func advanceToNextTimestamp(w io.Writer, logScanner *bufio.Scanner) (time.Time, string) {
206-
if logScanner == nil {
207-
return time.Time{}, ""
208-
}
209-
210-
for logScanner.Scan() {
211-
text := logScanner.Text()
212-
match := timestampRe.FindStringSubmatch(text)
213-
if len(match) == 2 {
214-
timestamp, err := time.Parse(timeFmt, match[1])
215-
if err != nil {
216-
fmt.Fprintln(w, text)
217-
continue
218-
}
219-
return timestamp, text
220-
} else {
221-
fmt.Fprintln(w, text)
222-
}
223-
}
224-
return time.Time{}, ""
225-
}
226-
227-
func printMergedLog(w io.Writer, logPath1, logPath2 string) error {
228-
var logScanner1 *bufio.Scanner
229-
if logPath1 != "" {
230-
logFile1, err := os.Open(logPath1)
231-
if err == nil {
232-
defer logFile1.Close()
233-
logScanner1 = bufio.NewScanner(logFile1)
234-
}
235-
}
236-
237-
var logScanner2 *bufio.Scanner
238-
if logPath2 != "" {
239-
logFile2, err := os.Open(logPath2)
240-
if err == nil {
241-
defer logFile2.Close()
242-
logScanner2 = bufio.NewScanner(logFile2)
243-
}
244-
}
245-
246-
var timestamp1 time.Time
247-
var timestamp2 time.Time
248-
var line1 string
249-
var line2 string
250-
251-
timestamp1, line1 = advanceToNextTimestamp(w, logScanner1)
252-
timestamp2, line2 = advanceToNextTimestamp(w, logScanner2)
253-
254-
for line1 != "" && line2 != "" {
255-
if !timestamp2.Before(timestamp1) {
256-
fmt.Fprintln(w, line1)
257-
timestamp1, line1 = advanceToNextTimestamp(w, logScanner1)
258-
} else {
259-
fmt.Fprintln(w, line2)
260-
timestamp2, line2 = advanceToNextTimestamp(w, logScanner2)
261-
}
262-
}
263-
264-
if line1 != "" {
265-
fmt.Fprintln(w, line1)
266-
for logScanner1.Scan() {
267-
fmt.Fprintln(w, logScanner1.Text())
268-
}
269-
}
270-
if line2 != "" {
271-
fmt.Fprintln(w, line2)
272-
for logScanner2.Scan() {
273-
fmt.Fprintln(w, logScanner2.Text())
274-
}
275-
}
276-
277-
return nil
278-
}

0 commit comments

Comments
 (0)