Skip to content

Commit 4630688

Browse files
committed
feat: vm logs --tail N
Adds the kubectl-style --tail flag to cocoon vm logs. Pairs with -f for tail+follow. seekToLastNLines reads backwards in 4 KiB chunks counting '\n' (skipping a trailing terminator), then Seek positions the fd so the subsequent io.Copy emits exactly the last N lines. Tail-initiated Seek does not interfere with the follow loop's truncate detection — the head signature is sampled via ReadAt from offset 0 and is independent of fd position. README Logs Flags table and CLI tree updated.
1 parent 38539a8 commit 4630688

4 files changed

Lines changed: 98 additions & 8 deletions

File tree

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ cocoon
139139
│ ├── inspect VM Show detailed VM info (JSON)
140140
│ ├── console [flags] VM Attach interactive console
141141
│ ├── exec [flags] VM -- CMD Run a command in a running VM via cocoon-agent (vsock)
142-
│ ├── logs [-f] VM Print the per-VM hypervisor log file
142+
│ ├── logs [-f] [--tail N] VM Print the per-VM hypervisor log file
143143
│ ├── rm [flags] VM [VM...] Delete VM(s) (--force to stop first)
144144
│ ├── restore [flags] VM SNAP Restore a running VM to a snapshot
145145
│ ├── status [VM...] Watch VM status in real time
@@ -335,15 +335,18 @@ Requires cocoon-agent to be running inside the guest (already baked into the off
335335

336336
`cocoon vm logs` prints the per-VM hypervisor process log (`cloud-hypervisor.log` or `firecracker.log` under the configured `log_dir`). The log captures VMM-side activity — device init warnings, API errors, virtio messages, shutdown — but **not** guest console output (use `cocoon vm console` for that). The file lives under the VM's log dir for as long as the VM record exists (cleaned up on `vm rm`); each `vm run` / `vm start` truncates and rewrites it from scratch — `-f` detects the truncation and seeks back to the start of the file so you don't miss the new boot's lines.
337337

338-
| Flag | Default | Description |
339-
| -------------- | ------- | -------------------------------------------------- |
340-
| `--follow`, `-f` | `false` | Stream new log lines as they are written (Ctrl-C to stop) |
338+
| Flag | Default | Description |
339+
| ---------------- | ------- | -------------------------------------------------------------------- |
340+
| `--follow`, `-f` | `false` | Stream new log lines as they are written (Ctrl-C to stop) |
341+
| `--tail` | `0` | Show only the last N lines (0 = all); pairs with `-f` for tail+follow |
341342

342343
```
343344
$ cocoon vm logs myvm
344345
cloud-hypervisor: 0.003732s: <vmm> WARN:virtio-devices/src/block.rs:793 -- sparse=on requested but backend does not support sparse operations
345-
$ cocoon vm logs -f myvm
346-
... live tail ...
346+
$ cocoon vm logs --tail 5 myvm
347+
... last 5 lines ...
348+
$ cocoon vm logs -f --tail 10 myvm
349+
... last 10 lines, then live tail ...
347350
```
348351

349352
### List Flags

cmd/vm/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ func Command(h Actions) *cobra.Command {
120120
RunE: h.Logs,
121121
}
122122
logsCmd.Flags().BoolP("follow", "f", false, "stream new log lines as they are written")
123+
logsCmd.Flags().Int("tail", 0, "show only the last N lines (0 = all)")
123124

124125
rmCmd := &cobra.Command{
125126
Use: "rm [flags] VM [VM...]",

cmd/vm/lifecycle.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,11 @@ func (h Handler) Logs(cmd *cobra.Command, args []string) error {
190190
return fmt.Errorf("logs: %w", err)
191191
}
192192
follow, _ := cmd.Flags().GetBool("follow")
193-
return streamLog(ctx, path, follow)
193+
tail, _ := cmd.Flags().GetInt("tail")
194+
return streamLog(ctx, path, follow, tail)
194195
}
195196

196-
func streamLog(ctx context.Context, path string, follow bool) error {
197+
func streamLog(ctx context.Context, path string, follow bool, tail int) error {
197198
f, err := os.Open(path) //nolint:gosec
198199
if err != nil {
199200
if os.IsNotExist(err) {
@@ -203,6 +204,12 @@ func streamLog(ctx context.Context, path string, follow bool) error {
203204
}
204205
defer f.Close() //nolint:errcheck
205206

207+
if tail > 0 {
208+
if seekErr := seekToLastNLines(f, tail); seekErr != nil {
209+
return fmt.Errorf("seek tail: %w", seekErr)
210+
}
211+
}
212+
206213
if !follow {
207214
if _, copyErr := io.Copy(os.Stdout, f); copyErr != nil {
208215
return fmt.Errorf("read log: %w", copyErr)
@@ -244,6 +251,41 @@ func streamLog(ctx context.Context, path string, follow bool) error {
244251
}
245252
}
246253

254+
// seekToLastNLines positions f so a subsequent read returns the last n lines.
255+
// A final trailing '\n' is not counted as a line separator.
256+
func seekToLastNLines(f *os.File, n int) error {
257+
info, err := f.Stat()
258+
if err != nil {
259+
return err
260+
}
261+
size := info.Size()
262+
if size == 0 {
263+
return nil
264+
}
265+
const chunk = 4096
266+
buf := make([]byte, chunk)
267+
pos, found := size, 0
268+
for pos > 0 {
269+
readSize := min(int64(chunk), pos)
270+
pos -= readSize
271+
if _, readErr := f.ReadAt(buf[:readSize], pos); readErr != nil {
272+
return readErr
273+
}
274+
for i := readSize - 1; i >= 0; i-- {
275+
if buf[i] != '\n' || pos+i == size-1 {
276+
continue
277+
}
278+
found++
279+
if found == n {
280+
_, seekErr := f.Seek(pos+i+1, io.SeekStart)
281+
return seekErr
282+
}
283+
}
284+
}
285+
_, err = f.Seek(0, io.SeekStart)
286+
return err
287+
}
288+
247289
func (h Handler) RM(cmd *cobra.Command, args []string) error {
248290
ctx, conf, err := h.Init(cmd)
249291
if err != nil {

cmd/vm/lifecycle_tail_tmp_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package vm
2+
3+
import (
4+
"io"
5+
"os"
6+
"testing"
7+
)
8+
9+
func TestSeekToLastNLinesTmp(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
body string
13+
n int
14+
want string
15+
}{
16+
{name: "no trailing newline", body: "a\nb\nc", n: 2, want: "b\nc"},
17+
{name: "trailing newline", body: "a\nb\nc\n", n: 2, want: "b\nc\n"},
18+
{name: "more than available", body: "a\nb", n: 5, want: "a\nb"},
19+
{name: "one line trailing", body: "a\nb\nc\n", n: 1, want: "c\n"},
20+
{name: "empty", body: "", n: 3, want: ""},
21+
}
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
f, err := os.CreateTemp(t.TempDir(), "log")
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
defer f.Close()
29+
if _, err := f.WriteString(tt.body); err != nil {
30+
t.Fatal(err)
31+
}
32+
if err := seekToLastNLines(f, tt.n); err != nil {
33+
t.Fatal(err)
34+
}
35+
got, err := io.ReadAll(f)
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
if string(got) != tt.want {
40+
t.Fatalf("got %q, want %q", got, tt.want)
41+
}
42+
})
43+
}
44+
}

0 commit comments

Comments
 (0)