init monlet repo with stage 0-2 (docs, contract, agent MVP)
This commit is contained in:
29
agent/internal/client/backoff.go
Normal file
29
agent/internal/client/backoff.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
BackoffMin = 1 * time.Second
|
||||
BackoffMax = 30 * time.Second
|
||||
)
|
||||
|
||||
// Delay computes exponential backoff with ±20% jitter.
|
||||
// attempt starts at 1 for the first failed try.
|
||||
func Delay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
d := BackoffMin
|
||||
for i := 1; i < attempt; i++ {
|
||||
d *= 2
|
||||
if d >= BackoffMax {
|
||||
d = BackoffMax
|
||||
break
|
||||
}
|
||||
}
|
||||
jitter := 1.0 + (rand.Float64()*0.4 - 0.2)
|
||||
return time.Duration(float64(d) * jitter)
|
||||
}
|
||||
135
agent/internal/client/client.go
Normal file
135
agent/internal/client/client.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/monlet/agent/internal/scheduler"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxBatchEvents = 200
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
agentID string
|
||||
version string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, token, agentID, version string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
agentID: agentID,
|
||||
version: version,
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type HeartbeatRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Hostname string `json:"hostname"`
|
||||
Version string `json:"version"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type EventBatchRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Events []scheduler.Event `json:"events"`
|
||||
}
|
||||
|
||||
type EventBatchResponse struct {
|
||||
Accepted int `json:"accepted"`
|
||||
Deduplicated int `json:"deduplicated"`
|
||||
}
|
||||
|
||||
func (c *Client) SendHeartbeat(ctx context.Context, hb HeartbeatRequest) error {
|
||||
return c.post(ctx, "/api/v1/heartbeat", hb, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SendEvents(ctx context.Context, events []scheduler.Event) (*EventBatchResponse, error) {
|
||||
if len(events) == 0 {
|
||||
return &EventBatchResponse{}, nil
|
||||
}
|
||||
if len(events) > MaxBatchEvents {
|
||||
return nil, fmt.Errorf("batch exceeds %d events", MaxBatchEvents)
|
||||
}
|
||||
req := EventBatchRequest{AgentID: c.agentID, Events: events}
|
||||
var resp EventBatchResponse
|
||||
if err := c.post(ctx, "/api/v1/events", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body any, out any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return &HTTPError{Status: resp.StatusCode, Body: string(msg)}
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type HTTPError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Body) }
|
||||
|
||||
// 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 he *HTTPError
|
||||
if asErr(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
|
||||
}
|
||||
86
agent/internal/client/client_test.go
Normal file
86
agent/internal/client/client_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/monlet/agent/internal/scheduler"
|
||||
)
|
||||
|
||||
func TestSendHeartbeatSuccess(t *testing.T) {
|
||||
var got HeartbeatRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/heartbeat" || r.Header.Get("Authorization") != "Bearer tok" {
|
||||
http.Error(w, "bad", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, `{"accepted":true}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok", "a1", "v0")
|
||||
err := c.SendHeartbeat(context.Background(), HeartbeatRequest{AgentID: "a1", ObservedAt: time.Now().UTC(), Hostname: "h", Version: "v0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.AgentID != "a1" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEventsBatch(t *testing.T) {
|
||||
var got EventBatchRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, `{"accepted":2,"deduplicated":0}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok", "a1", "v0")
|
||||
resp, err := c.SendEvents(context.Background(), []scheduler.Event{{EventID: "x"}, {EventID: "y"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Accepted != 2 || got.AgentID != "a1" || len(got.Events) != 2 {
|
||||
t.Fatalf("resp=%+v got=%+v", resp, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEventsTooBig(t *testing.T) {
|
||||
c := New("http://x", "t", "a1", "v0")
|
||||
big := make([]scheduler.Event, MaxBatchEvents+1)
|
||||
_, err := c.SendEvents(context.Background(), big)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableClassification(t *testing.T) {
|
||||
if Retryable(&HTTPError{Status: 400, Body: ""}) {
|
||||
t.Fatal("4xx should not retry")
|
||||
}
|
||||
if !Retryable(&HTTPError{Status: 503}) {
|
||||
t.Fatal("5xx should retry")
|
||||
}
|
||||
if !Retryable(context.DeadlineExceeded) {
|
||||
t.Fatal("network error should retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffBounds(t *testing.T) {
|
||||
for a := 1; a <= 10; a++ {
|
||||
d := Delay(a)
|
||||
if d < BackoffMin*4/5 {
|
||||
t.Fatalf("attempt %d: delay %s below min", a, d)
|
||||
}
|
||||
if d > BackoffMax*6/5 {
|
||||
t.Fatalf("attempt %d: delay %s above max", a, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user