feat(runtime-logs): enhance runtime log management and configuration
Добавлены новые возможности для управления файловыми логами в Docker-сервисах: - Обновлены конфигурации для поддержки логов, включая переменные окружения и монтирование директорий. - Документация обновлена для описания новых эндпоинтов и параметров, связанных с логами. - Упрощен доступ к логам через API и интерфейс пользователя. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user