feat(audit): DegradingSink + durable buffer + loki/ntfy + reconcile (#54)
The classification-aware I5 audit path (§4.4): - DegradingSink.Reserve: central up → AuditCentral; central down + confidential → refuse (no buffer); central down + internal/public + buffer writable → AuditBuffered; central down + buffer unwritable → floor refuse. Record executes the reserved outcome and, when buffered, fires an ntfy alert. - FileBuffer: durable JSONL buffer that survives process restart; Confirm rewrites the file without a record, so a buffered record is cleared ONLY after its central write is confirmed. - LokiCentral: /ready probe + /loki/api/v1/push (full audit entry as the structured line). NtfyNotifier: degraded-state alerts; token only in the auth header, never logged (regression-tested). - Reconcile + StartReconcile: replay buffered records to central on recovery, confirm-then-clear per record; a failed push keeps the record buffered (no loss). SlogSink updated to the two-phase shape (always central, never fails) — the default when no loki endpoint is set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
)
|
||||
|
||||
// LokiCentral pushes capture audit records to a Grafana Loki instance via
|
||||
// its push API, and probes readiness via /ready. It is the central audit
|
||||
// substrate behind DegradingSink.
|
||||
type LokiCentral struct {
|
||||
baseURL string
|
||||
labels map[string]string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewLokiCentral constructs a LokiCentral for the given base URL (e.g.
|
||||
// http://loki:3100). Returns nil when baseURL is empty so callers can
|
||||
// treat missing config as "no central sink" with a single nil check.
|
||||
func NewLokiCentral(baseURL string) *LokiCentral {
|
||||
if baseURL == "" {
|
||||
return nil
|
||||
}
|
||||
return &LokiCentral{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
labels: map[string]string{"service": "brain-capture", "kind": "audit"},
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Ready probes Loki's readiness endpoint.
|
||||
func (l *LokiCentral) Ready(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.baseURL+"/ready", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loki not ready: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("loki not ready: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushPayload is the Loki push API body: one stream, one entry whose line
|
||||
// is the JSON-encoded audit record.
|
||||
type pushPayload struct {
|
||||
Streams []lokiStream `json:"streams"`
|
||||
}
|
||||
|
||||
type lokiStream struct {
|
||||
Stream map[string]string `json:"stream"`
|
||||
Values [][2]string `json:"values"`
|
||||
}
|
||||
|
||||
// Push writes one audit record to Loki as a structured log line.
|
||||
func (l *LokiCentral) Push(ctx context.Context, e capture.AuditEntry) error {
|
||||
line, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit entry: %w", err)
|
||||
}
|
||||
ts := e.Timestamp
|
||||
if ts.IsZero() {
|
||||
ts = time.Now()
|
||||
}
|
||||
body, err := json.Marshal(pushPayload{Streams: []lokiStream{{
|
||||
Stream: l.labels,
|
||||
Values: [][2]string{{strconv.FormatInt(ts.UTC().UnixNano(), 10), string(line)}},
|
||||
}}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
l.baseURL+"/loki/api/v1/push", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loki push: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("loki push: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user