From b8c61c37dc4c300396f593f1c8bb5a6ea355b30c Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 25 Jan 2026 12:03:50 +0800 Subject: [PATCH] feat(proxmox): add journalctl endpoint without service; add limit parameter Added new Proxmox journalctl endpoint `/journalctl/:node/:vmid` for viewing all journalctl output without requiring a service name. Made the service parameter optional across both endpoints. Introduced configurable `limit` query parameter (1-1000, default 100) to both proxmox journalctl and docker logs APIs, replacing hardcoded 100-line tail. Added container status check in LXCCommand to prevent command execution on stopped containers, returning a clear status message instead. Refactored route validation to use pre-fetched IPs and improved References() method for proxmox routes with better alias handling. --- internal/api/handler.go | 1 + internal/api/v1/docker/logs.go | 9 ++- internal/api/v1/docs/swagger.json | 85 ++++++++++++++++++++++++++- internal/api/v1/docs/swagger.yaml | 55 ++++++++++++++++- internal/api/v1/proxmox/journalctl.go | 9 ++- internal/proxmox/lxc_command.go | 23 +++++++- internal/route/route.go | 27 +++++---- 7 files changed, 185 insertions(+), 24 deletions(-) diff --git a/internal/api/handler.go b/internal/api/handler.go index 8e0b67f4..15a3a989 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -142,6 +142,7 @@ func NewHandler(requireAuth bool) *gin.Engine { proxmox := v1.Group("/proxmox") { + proxmox.GET("/journalctl/:node/:vmid", proxmoxApi.Journalctl) proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl) proxmox.GET("/stats/:node/:vmid", proxmoxApi.Stats) } diff --git a/internal/api/v1/docker/logs.go b/internal/api/v1/docker/logs.go index bfaf6ddc..6b4a82bf 100644 --- a/internal/api/v1/docker/logs.go +++ b/internal/api/v1/docker/logs.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/docker/docker/api/types/container" "github.com/docker/docker/pkg/stdcopy" @@ -22,6 +23,7 @@ type LogsQueryParams struct { Since string `form:"from"` Until string `form:"to"` Levels string `form:"levels"` + Limit int `form:"limit,default=100" binding:"omitempty,min=1,max=1000"` } // @name LogsQueryParams // @x-id "logs" @@ -34,9 +36,10 @@ type LogsQueryParams struct { // @Param id path string true "container id" // @Param stdout query bool false "show stdout" // @Param stderr query bool false "show stderr" -// @Param from query string false "from timestamp" -// @Param to query string false "to timestamp" +// @Param from query string false "from timestamp" +// @Param to query string false "to timestamp" // @Param levels query string false "levels" +// @Param limit query int false "limit" // @Success 200 // @Failure 400 {object} apitypes.ErrorResponse // @Failure 403 {object} apitypes.ErrorResponse @@ -77,7 +80,7 @@ func Logs(c *gin.Context) { Until: queryParams.Until, Timestamps: true, Follow: true, - Tail: "100", + Tail: strconv.Itoa(queryParams.Limit), } if queryParams.Levels != "" { opts.Details = true diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json index 691d4546..4d63d8a9 100644 --- a/internal/api/v1/docs/swagger.json +++ b/internal/api/v1/docs/swagger.json @@ -165,6 +165,76 @@ "operationId": "verify" } }, + "/api/v1/proxmox/journalctl/{node}/{vmid}": { + "get": { + "description": "Get journalctl output", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "proxmox", + "websocket" + ], + "summary": "Get journalctl output", + "parameters": [ + { + "type": "string", + "name": "node", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "vmid", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "limit", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Journalctl output", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Node not found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "journalctl", + "operationId": "journalctl" + } + }, "/api/v1/proxmox/journalctl/{node}/{vmid}/{service}": { "get": { "description": "Get journalctl output", @@ -189,14 +259,19 @@ { "type": "string", "name": "service", - "in": "path", - "required": true + "in": "path" }, { "type": "integer", "name": "vmid", "in": "path", "required": true + }, + { + "type": "integer", + "description": "limit", + "name": "limit", + "in": "query" } ], "responses": { @@ -703,6 +778,12 @@ "description": "levels", "name": "levels", "in": "query" + }, + { + "type": "integer", + "description": "limit", + "name": "limit", + "in": "query" } ], "responses": { diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml index 89e092c4..7f361253 100644 --- a/internal/api/v1/docs/swagger.yaml +++ b/internal/api/v1/docs/swagger.yaml @@ -2088,6 +2088,52 @@ paths: tags: - agent x-id: verify + /api/v1/proxmox/journalctl/{node}/{vmid}: + get: + consumes: + - application/json + description: Get journalctl output + parameters: + - in: path + name: node + required: true + type: string + - in: path + name: vmid + required: true + type: integer + - description: limit + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Journalctl output + schema: + type: string + "400": + description: Invalid request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Unauthorized + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Node not found + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get journalctl output + tags: + - proxmox + - websocket + x-id: journalctl /api/v1/proxmox/journalctl/{node}/{vmid}/{service}: get: consumes: @@ -2100,12 +2146,15 @@ paths: type: string - in: path name: service - required: true type: string - in: path name: vmid required: true type: integer + - description: limit + in: query + name: limit + type: integer produces: - application/json responses: @@ -2438,6 +2487,10 @@ paths: in: query name: levels type: string + - description: limit + in: query + name: limit + type: integer produces: - application/json responses: diff --git a/internal/api/v1/proxmox/journalctl.go b/internal/api/v1/proxmox/journalctl.go index 440d28e3..611c27b3 100644 --- a/internal/api/v1/proxmox/journalctl.go +++ b/internal/api/v1/proxmox/journalctl.go @@ -13,7 +13,8 @@ import ( type JournalctlRequest struct { Node string `uri:"node" binding:"required"` VMID int `uri:"vmid" binding:"required"` - Service string `uri:"service" binding:"required"` + Service string `uri:"service"` + Limit int `query:"limit" binding:"omitempty,min=1,max=1000"` } // @x-id "journalctl" @@ -24,12 +25,14 @@ type JournalctlRequest struct { // @Accept json // @Produce application/json // @Param path path JournalctlRequest true "Request" +// @Param limit query int false "limit" // @Success 200 string plain "Journalctl output" // @Failure 400 {object} apitypes.ErrorResponse "Invalid request" // @Failure 403 {object} apitypes.ErrorResponse "Unauthorized" // @Failure 404 {object} apitypes.ErrorResponse "Node not found" // @Failure 500 {object} apitypes.ErrorResponse "Internal server error" -// @Router /api/v1/proxmox/journalctl/{node}/{vmid}/{service} [get] +// @Router /api/v1/proxmox/journalctl/{node}/{vmid} [get] +// @Router /api/v1/proxmox/journalctl/{node}/{vmid}/{service} [get] func Journalctl(c *gin.Context) { var request JournalctlRequest if err := c.ShouldBindUri(&request); err != nil { @@ -50,7 +53,7 @@ func Journalctl(c *gin.Context) { } defer manager.Close() - reader, err := node.LXCJournalctl(c.Request.Context(), request.VMID, request.Service) + reader, err := node.LXCJournalctl(c.Request.Context(), request.VMID, request.Service, request.Limit) if err != nil { c.Error(apitypes.InternalServerError(err, "failed to get journalctl output")) return diff --git a/internal/proxmox/lxc_command.go b/internal/proxmox/lxc_command.go index f9981dcf..37f3a497 100644 --- a/internal/proxmox/lxc_command.go +++ b/internal/proxmox/lxc_command.go @@ -23,6 +23,15 @@ func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.Rea return nil, fmt.Errorf("failed to get node: %w", err) } + lxc, err := node.Container(ctx, vmid) + if err != nil { + return nil, fmt.Errorf("failed to get container: %w", err) + } + + if lxc.Status != "running" { + return io.NopCloser(bytes.NewReader(fmt.Appendf(nil, "container %d is not running, status: %s\n", vmid, lxc.Status))), nil + } + term, err := node.TermProxy(ctx) if err != nil { return nil, fmt.Errorf("failed to get term proxy: %w", err) @@ -117,6 +126,16 @@ func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.Rea } // LXCJournalctl streams journalctl output for the given service. -func (n *Node) LXCJournalctl(ctx context.Context, vmid int, service string) (io.ReadCloser, error) { - return n.LXCCommand(ctx, vmid, fmt.Sprintf("journalctl -u %q -f", service)) +// +// If service is not empty, it will be used to filter the output by service. +// If limit is greater than 0, it will be used to limit the number of lines of output. +func (n *Node) LXCJournalctl(ctx context.Context, vmid int, service string, limit int) (io.ReadCloser, error) { + command := "journalctl -f" + if service != "" { + command = fmt.Sprintf("journalctl -u %q -f", service) + } + if limit > 0 { + command = fmt.Sprintf("%s -n %d", command, limit) + } + return n.LXCCommand(ctx, vmid, command) } diff --git a/internal/route/route.go b/internal/route/route.go index 302dfe78..068c4d7d 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -219,11 +219,7 @@ func (r *Route) validate() gperr.Error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - ips, err := node.LXCGetIPs(ctx, vmid) - if err != nil { - return gperr.Errorf("failed to get ip addresses of vmid %d: %w", vmid, err) - } - + ips := res.IPs if len(ips) == 0 { return gperr.Multiline(). Addf("no ip addresses found for %s", containerName). @@ -345,10 +341,9 @@ func (r *Route) validate() gperr.Error { // reverse lookup resource by ip address, hostname or alias if resource != nil { r.Proxmox = &proxmox.NodeConfig{ - Node: resource.Node, - VMID: int(resource.VMID), - VMName: resource.Name, - Service: r.Alias, + Node: resource.Node, + VMID: int(resource.VMID), + VMName: resource.Name, } log.Info(). Str("node", resource.Node). @@ -535,17 +530,23 @@ func (r *Route) References() []string { } if r.Container != nil { - if r.Container.ContainerName != r.Alias { + if r.Container.ContainerName != aliasRef { return []string{r.Container.ContainerName, aliasRef, r.Container.Image.Name, r.Container.Image.Author} } return []string{r.Container.Image.Name, aliasRef, r.Container.Image.Author} } if r.Proxmox != nil { - if r.Proxmox.VMName != r.Alias { - return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Service} + if r.Proxmox.Service != "" && r.Proxmox.Service != aliasRef { + if r.Proxmox.VMName != aliasRef { + return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Service} + } + return []string{r.Proxmox.Service, aliasRef} + } else { + if r.Proxmox.VMName != aliasRef { + return []string{r.Proxmox.VMName, aliasRef} + } } - return []string{r.Proxmox.Service, aliasRef} } return []string{aliasRef} }