feat(runtime-logs): enhance runtime log management and configuration

Добавлены новые возможности для управления файловыми логами в Docker-сервисах:
- Обновлены конфигурации для поддержки логов, включая переменные окружения и монтирование директорий.
- Документация обновлена для описания новых эндпоинтов и параметров, связанных с логами.
- Упрощен доступ к логам через API и интерфейс пользователя.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-12 19:18:50 +07:00
co-authored by Cursor
parent 3f0dd6c234
commit 0c5502b5bb
26 changed files with 1188 additions and 294 deletions
+1
View File
@@ -79,6 +79,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
s.registerPostgresMonitoringRoutes(m)
s.registerPostgresMaintenanceRoutes(m)
s.registerMaintenanceRoutes(m)
s.registerRuntimeLogsRoutes(m)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+179
View File
@@ -0,0 +1,179 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
)
func (s *Server) registerRuntimeLogsRoutes(m *http.ServeMux) {
m.HandleFunc("GET /runtime-logs/files", s.handleListRuntimeLogFiles)
m.HandleFunc("GET /runtime-logs/files/{filename}", s.handleGetRuntimeLogTail)
m.HandleFunc("DELETE /runtime-logs/files/{filename}", s.handleDeleteRuntimeLogFile)
m.HandleFunc("GET /runtime-logs/cleanup-audit", s.handleListRuntimeLogCleanupAudit)
}
func (s *Server) requireRuntimeLogs(w http.ResponseWriter) bool {
if s.runtimeLogs != nil && s.runtimeLogs.Available() {
return true
}
writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "runtime_logs_unavailable")
return false
}
func writeRuntimeLogsErr(w http.ResponseWriter, operation string, err error) {
switch {
case errors.Is(err, runtimelogs.ErrUnavailable):
writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "runtime_logs_unavailable")
case errors.Is(err, runtimelogs.ErrNotFound):
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
case errors.Is(err, runtimelogs.ErrFileTooLarge):
writeProblem(w, http.StatusRequestEntityTooLarge, "Payload Too Large", "file exceeds maximum size for cleanup")
case errors.Is(err, runtimelogs.ErrInvalidFilename), errors.Is(err, runtimelogs.ErrNotAFile):
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
default:
writeInternalError(w, operation, err)
}
}
func runtimeLogFileJSON(f store.RuntimeLogFile) map[string]any {
return map[string]any{
"name": f.Name,
"size_bytes": f.SizeBytes,
"modified_at": f.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
}
func runtimeLogCleanupAuditJSON(row *store.RuntimeLogCleanupAudit) map[string]any {
out := map[string]any{
"id": row.ID,
"tenant_id": row.TenantID,
"actor_prefix": row.ActorPrefix,
"filename": row.Filename,
"action": row.Action,
"size_before": row.SizeBefore,
"created_at": row.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if row.SizeAfter != nil {
out["size_after"] = *row.SizeAfter
}
if row.Detail != nil {
out["detail"] = row.Detail
}
return out
}
func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
return
}
items, err := s.runtimeLogs.ListFiles()
if err != nil {
writeRuntimeLogsErr(w, "runtime_logs_list", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, f := range items {
out = append(out, runtimeLogFileJSON(f))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
opts := runtimelogs.TailOptions{
Lines: parsePositiveIntQuery(r, "lines", runtimelogs.DefaultTailLines, runtimelogs.MaxTailLines),
Bytes: parsePositiveIntQuery(r, "bytes", 0, runtimelogs.MaxTailBytes),
Grep: r.URL.Query().Get("grep"),
}
tail, err := s.runtimeLogs.Tail(filename, opts)
if err != nil {
writeRuntimeLogsErr(w, "runtime_logs_tail", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"filename": tail.Filename,
"content": tail.Content,
"truncated": tail.Truncated,
"lines_returned": tail.LinesReturned,
})
}
func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
mode := r.URL.Query().Get("mode")
if mode == "" {
mode = store.RuntimeLogCleanupTruncate
}
if !store.ValidRuntimeLogCleanupAction(mode) {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
sizeBefore, sizeAfter, err := s.runtimeLogs.Cleanup(filename, mode)
if err != nil {
writeRuntimeLogsErr(w, "runtime_logs_cleanup", err)
return
}
auditID, err := s.store.AppendRuntimeLogCleanupAudit(
a.TenantID, actorPrefix(a), filename, mode, sizeBefore, sizeAfter, nil)
if err != nil {
writeInternalError(w, "runtime_logs_cleanup_audit", err)
return
}
out := map[string]any{
"audit_id": auditID,
"filename": filename,
"action": mode,
"size_before": sizeBefore,
}
if sizeAfter != nil {
out["size_after"] = *sizeAfter
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
cursor := r.URL.Query().Get("cursor")
limit := parseLimitQuery(r, 20, 100)
items, next, hasMore, err := s.store.ListRuntimeLogCleanupAudit(a.TenantID, cursor, limit)
if err != nil {
writeInternalError(w, "runtime_logs_cleanup_audit_list", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, row := range items {
out = append(out, runtimeLogCleanupAuditJSON(row))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
}
func parsePositiveIntQuery(r *http.Request, key string, def, max int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return def
}
if max > 0 && n > max {
return max
}
return n
}
@@ -0,0 +1,149 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"evobgp/internal/runtimelogs"
)
func TestRuntimeLogsFSUnavailable503(t *testing.T) {
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
handler := srv.Handler()
tests := []struct {
method string
path string
}{
{http.MethodGet, "/v1/runtime-logs/files"},
{http.MethodGet, "/v1/runtime-logs/files/evobgp-all.log"},
{http.MethodDelete, "/v1/runtime-logs/files/evobgp-all.log"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
req.Header.Set("Authorization", "Bearer dev")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "runtime_logs_unavailable") {
t.Fatalf("expected runtime_logs_unavailable detail, body=%s", rec.Body.String())
}
})
}
}
func TestRuntimeLogsCleanupAuditWithoutFS(t *testing.T) {
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
handler := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/cleanup-audit", nil)
req.Header.Set("Authorization", "Bearer dev")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestRuntimeLogsHappyPath(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "evobgp-all.log")
if err := os.WriteFile(logPath, []byte("line1\nline2\nline3\n"), 0o644); err != nil {
t.Fatal(err)
}
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
srv.runtimeLogs = runtimelogs.NewService(runtimelogs.Config{
RootDir: dir,
ServiceName: runtimelogs.ServiceNameAll,
})
handler := srv.Handler()
tenant := "00000000-0000-0000-0000-000000000001"
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer,opkey|"+tenant+"|operator")
t.Run("list", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/files", nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "evobgp-all.log") {
t.Fatalf("expected file in list, body=%s", rec.Body.String())
}
})
t.Run("tail", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/files/evobgp-all.log?lines=2", nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "line2") || !strings.Contains(rec.Body.String(), "line3") {
t.Fatalf("unexpected tail body=%s", rec.Body.String())
}
})
t.Run("viewer cannot cleanup", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/v1/runtime-logs/files/evobgp-all.log", nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("cleanup truncate and audit", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/v1/runtime-logs/files/evobgp-all.log?mode=truncate", nil)
req.Header.Set("Authorization", "Bearer opkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"action":"truncate"`) {
t.Fatalf("unexpected cleanup body=%s", rec.Body.String())
}
st, err := os.Stat(logPath)
if err != nil {
t.Fatal(err)
}
if st.Size() != 0 {
t.Fatalf("expected truncated file, size=%d", st.Size())
}
auditReq := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/cleanup-audit", nil)
auditReq.Header.Set("Authorization", "Bearer vwkey")
auditRec := httptest.NewRecorder()
handler.ServeHTTP(auditRec, auditReq)
if auditRec.Code != http.StatusOK {
t.Fatalf("audit status=%d body=%s", auditRec.Code, auditRec.Body.String())
}
if !strings.Contains(auditRec.Body.String(), "evobgp-all.log") {
t.Fatalf("expected audit entry, body=%s", auditRec.Body.String())
}
})
}
+3
View File
@@ -13,6 +13,7 @@ import (
"evobgp/internal/jobs"
"evobgp/internal/maintenance"
"evobgp/internal/pgmonitor"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
@@ -30,6 +31,7 @@ type Server struct {
keyResolver *apiKeyResolver
corsOrigins []string
cdnHTTP *http.Client
runtimeLogs *runtimelogs.Service
mux *http.ServeMux
}
@@ -89,6 +91,7 @@ func New(opts Options) (*Server, error) {
keyResolver: resolver,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
}
s.mux = http.NewServeMux()
s.registerRoutes()