Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web

This commit is contained in:
Stanislav Rossovskii
2026-05-28 23:45:12 +04:00
parent 37b1a1d6d6
commit 1026e9ebbe
75 changed files with 3021 additions and 916 deletions

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -15,6 +16,7 @@ import (
const (
MaxBatchEvents = 200
MaxBatchBytes = 1 << 20
)
type Client struct {
@@ -68,18 +70,81 @@ func (c *Client) SendEvents(ctx context.Context, events []scheduler.Event) (*Eve
return &EventBatchResponse{}, nil
}
if len(events) > MaxBatchEvents {
return nil, fmt.Errorf("batch exceeds %d events", MaxBatchEvents)
return nil, nonRetryablef("batch exceeds %d events", MaxBatchEvents)
}
wireEvents := make([]scheduler.Event, len(events))
for i, ev := range events {
wireEvents[i] = ev.WireEvent()
}
req := EventBatchRequest{AgentID: c.agentID, Events: wireEvents}
var resp EventBatchResponse
if err := c.post(ctx, "/api/v1/events", req, &resp); err != nil {
batches, err := c.splitEventBatches(req.Events)
if err != nil {
return nil, err
}
return &resp, nil
var total EventBatchResponse
for _, batch := range batches {
var resp EventBatchResponse
if err := c.post(ctx, "/api/v1/events", EventBatchRequest{AgentID: c.agentID, Events: batch}, &resp); err != nil {
return nil, err
}
total.Accepted += resp.Accepted
total.Deduplicated += resp.Deduplicated
}
return &total, nil
}
func (c *Client) splitEventBatches(events []scheduler.Event) ([][]scheduler.Event, error) {
overhead, err := c.eventBatchOverhead()
if err != nil {
return nil, err
}
batches := make([][]scheduler.Event, 0, 1)
current := make([]scheduler.Event, 0, len(events))
currentSize := overhead
for _, ev := range events {
eventSize, err := jsonSize(ev)
if err != nil {
return nil, err
}
nextSize := currentSize + eventSize
if len(current) > 0 {
nextSize++
}
if nextSize <= MaxBatchBytes {
current = append(current, ev)
currentSize = nextSize
continue
}
if len(current) == 0 {
return nil, nonRetryablef("single event batch exceeds %d bytes", MaxBatchBytes)
}
batches = append(batches, current)
current = []scheduler.Event{ev}
currentSize = overhead + eventSize
if currentSize > MaxBatchBytes {
return nil, nonRetryablef("single event batch exceeds %d bytes", MaxBatchBytes)
}
}
if len(current) > 0 {
batches = append(batches, current)
}
return batches, nil
}
func (c *Client) eventBatchOverhead() (int, error) {
size, err := jsonSize(EventBatchRequest{AgentID: c.agentID, Events: []scheduler.Event{}})
if err != nil {
return 0, err
}
return size, nil
}
func jsonSize(v any) (int, error) {
b, err := json.Marshal(v)
if err != nil {
return 0, err
}
return len(b), nil
}
func (c *Client) post(ctx context.Context, path string, body any, out any) error {
@@ -115,30 +180,28 @@ type HTTPError struct {
func (e *HTTPError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Body) }
type NonRetryableError struct{ Err error }
func (e *NonRetryableError) Error() string { return e.Err.Error() }
func (e *NonRetryableError) Unwrap() error { return e.Err }
func nonRetryablef(format string, args ...any) error {
return &NonRetryableError{Err: fmt.Errorf(format, args...)}
}
// Retryable reports whether the error should trigger a retry (network errors
// and 5xx are retryable; 4xx are dropped to avoid hot-loop on bad payload).
func Retryable(err error) bool {
if err == nil {
return false
}
var ne *NonRetryableError
if errors.As(err, &ne) {
return false
}
var he *HTTPError
if asErr(err, &he) {
if errors.As(err, &he) {
return he.Status >= 500
}
return true
}
func asErr(err error, target **HTTPError) bool {
for e := err; e != nil; {
if h, ok := e.(*HTTPError); ok {
*target = h
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -57,6 +58,59 @@ func TestSendEventsBatch(t *testing.T) {
}
}
func TestSendEventsSplitsByJSONSize(t *testing.T) {
var (
requests int
total int
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
http.Error(w, "read body", http.StatusInternalServerError)
return
}
if len(body) > MaxBatchBytes {
t.Errorf("body size %d exceeds %d", len(body), MaxBatchBytes)
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
return
}
var got EventBatchRequest
if err := json.Unmarshal(body, &got); err != nil {
t.Error(err)
http.Error(w, "bad json", http.StatusBadRequest)
return
}
requests++
total += len(got.Events)
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(EventBatchResponse{Accepted: len(got.Events)})
}))
defer srv.Close()
events := make([]scheduler.Event, MaxBatchEvents)
for i := range events {
events[i] = scheduler.Event{
EventID: "event-" + time.Unix(int64(i), 0).UTC().Format("20060102150405"),
CheckID: "check",
ObservedAt: time.Unix(int64(i), 0).UTC(),
Status: "ok",
Output: strings.Repeat("x", 8192),
}
}
c := New(srv.URL, "tok", "a1")
resp, err := c.SendEvents(context.Background(), events)
if err != nil {
t.Fatal(err)
}
if requests < 2 {
t.Fatalf("expected split batch, got %d request", requests)
}
if total != MaxBatchEvents || resp.Accepted != MaxBatchEvents {
t.Fatalf("total=%d resp=%+v", total, resp)
}
}
func TestSendEventsTooBig(t *testing.T) {
c := New("http://x", "t", "a1")
big := make([]scheduler.Event, MaxBatchEvents+1)
@@ -64,6 +118,24 @@ func TestSendEventsTooBig(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
if Retryable(err) {
t.Fatal("oversized batch should not retry")
}
}
func TestSendEventsSingleEventTooLargeIsNotRetryable(t *testing.T) {
c := New("http://x", "t", "a1")
_, err := c.SendEvents(context.Background(), []scheduler.Event{{
EventID: "x",
CheckID: strings.Repeat("c", MaxBatchBytes),
Status: "ok",
}})
if err == nil {
t.Fatal("expected error")
}
if Retryable(err) {
t.Fatal("oversized single event should not retry")
}
}
func TestRetryableClassification(t *testing.T) {