Add pagination support for listing VM events (#386)

* Add pagination support for listing VM events

Introduced a paginated event listing API, added support for pagination parameters in the request, and included cursor-based navigation using headers. Relevant tests and Badger store implementations were updated to support the new logic.

* Remove support for ordering VM events

Dropped `ListOrder` type, `order` query parameter, and related logic for ordering VM events. Updated tests, API schema, and Badger store to reflect the removal.

* Remove invalid VM events ordering test

Deleted a test case for invalid VM events ordering since the `order` query parameter and related functionality have been removed.

* Add support for ordering VM events

Implemented `order` query parameter for specifying sort order (ascending/descending) of VM events. Updated API schema, Badger store, and added related tests.

* Add support for limiting and ordering VM logs

Introduced `--limit` and `--order` flags for controlling the number of log lines and their sort order (ascending/descending). Updated API client to handle new options.

* Update internal/controller/store/badger/badger_events.go

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>

* fix: address PR review feedback

- switch logs CLI to --tail with desc ordering
- reuse ParseLogsOrder in controller with helpful errors
- always use ListEventsPage and scope event cursors
- move events pagination coverage to integration test

🤖 Generated with [Codex](https://chatgpt.com/codex)

Co-Authored-By: Codex <codex@openai.com>

* refactor: simplify prefix trimming and improve error formatting

- Replaced manual prefix check with `bytes.TrimPrefix` in Badger store.
- Enhanced error message formatting in VM logs controller.

* fix: address PR review feedback

- use suggested reverse seek in badger events pagination
- add events pagination client helper and use it in integration test

🤖 Generated with [Codex](https://chatgpt.com/codex)

Co-Authored-By: Codex <codex@openai.com>

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Fedor Korotkov
2026-01-22 09:22:53 -05:00
committed by GitHub
co-authored by Codex Nikolay Edigaryev
parent ea89d01760
commit 6fe523ef69
9 changed files with 470 additions and 24 deletions
+71 -2
View File
@@ -1,9 +1,11 @@
package controller
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/cirruslabs/orchard/internal/controller/lifecycle"
@@ -11,6 +13,7 @@ import (
"github.com/cirruslabs/orchard/internal/responder"
"github.com/cirruslabs/orchard/internal/simplename"
"github.com/cirruslabs/orchard/internal/worker/ondiskname"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/gin-gonic/gin"
"github.com/google/go-cmp/cmp"
@@ -364,21 +367,87 @@ func (controller *Controller) listVMEvents(ctx *gin.Context) responder.Responder
}
name := ctx.Param("name")
options, parseResponder := parseListVMEventsOptions(ctx)
if parseResponder != nil {
return parseResponder
}
return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
vm, err := txn.GetVM(name)
if err != nil {
return responder.Error(err)
}
events, err := txn.ListEvents("vms", vm.UID)
page, err := txn.ListEventsPage(options, "vms", vm.UID)
if err != nil {
return responder.Error(err)
}
if len(page.NextCursor) != 0 {
ctx.Header("X-Next-Cursor", encodeEventCursor(page.NextCursor))
}
return responder.JSON(http.StatusOK, events)
return responder.JSON(http.StatusOK, page.Items)
})
}
func parseListVMEventsOptions(ctx *gin.Context) (storepkg.ListOptions, responder.Responder) {
var options storepkg.ListOptions
limitRaw := ctx.Query("limit")
orderRaw := ctx.Query("order")
cursorRaw := ctx.Query("cursor")
if limitRaw != "" {
limit, ok := parsePositiveInt(limitRaw)
if !ok {
return options, responder.JSON(http.StatusBadRequest,
NewErrorResponse("invalid limit %q: expected positive integer", limitRaw))
}
options.Limit = limit
}
if orderRaw != "" {
order, err := client.ParseLogsOrder(orderRaw)
if err != nil {
return options, responder.JSON(http.StatusBadRequest, NewErrorResponse("%s", err))
}
options.Order = storepkg.ListOrder(order)
}
if cursorRaw != "" {
cursor, err := decodeEventCursor(cursorRaw)
if err != nil {
return options, responder.JSON(http.StatusBadRequest,
NewErrorResponse("invalid cursor %q", cursorRaw))
}
options.Cursor = cursor
}
return options, nil
}
func parsePositiveInt(raw string) (int, bool) {
value, err := strconv.ParseInt(raw, 10, 0)
if err != nil || value <= 0 {
return 0, false
}
return int(value), true
}
func encodeEventCursor(cursor []byte) string {
return base64.RawURLEncoding.EncodeToString(cursor)
}
func decodeEventCursor(cursorRaw string) ([]byte, error) {
cursor, err := base64.RawURLEncoding.DecodeString(cursorRaw)
if err == nil {
return cursor, nil
}
return base64.URLEncoding.DecodeString(cursorRaw)
}
func (controller *Controller) validateHostDirs(hostDirs []v1.HostDir) responder.Responder {
if len(hostDirs) == 0 {
return nil