Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user