Full rebrand across cosmetic branding, code identifiers, and infrastructure/data-plane naming, using the supplied Cairn OBS logo package. Cosmetic: favicon/logo swap (also closes a stale license-audit finding -- the old favicon was SvelteKit's unreplaced scaffold logo), new centered welcome landing page, larger/legible sidebar logo, page titles, CLAUDE.md/README/docs prose. Code identifiers: Go module path github.com/sentry/sentry -> github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc regenerated); Rust crates sentry-agent/sentry-parser/sentry-search -> cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type, env vars); every session/auth cookie name; agent config paths and Windows service identity. Deliberately preserved: the gRPC wire protocol's protobuf packages (sentry.logs.v1, sentry.agent.v1) and their Go import directory (proto/sentry/...) -- renaming the wire-level package would break every currently-deployed agent binary (confirmed two real hosts, including mail.inbuxa.com, are actively streaming through this exact contract) until rebuilt and redeployed in lockstep with an ingest cutover. Only the Go module path wrapping the generated code changes. Infrastructure: every docker-compose container name (root and three component-level compose files); the Helm chart (directory, Chart.yaml, named-template helpers, all templates, values.yaml image repos); Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd package. Caught and fixed real path-coupling bugs along the way: the Helm chart's search/ingest volume mounts and the dev-only-credential detection constant vs. docker-compose.yml's literal values had to move together or a security warning would have silently stopped firing. Data plane: Postgres database sentry_metadata -> cairnobs_metadata and role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups. Source-level defaults, docker-compose.yml, and every migrate.sh/ provision script default updated together; already-applied migration files left untouched per this repo's immutable-migration convention. Verified at every layer: all 13 Go modules build/vet/test clean, both Rust workspaces (agent, search) build/clippy/test clean, npm run check/ build clean, docker compose config validates on all four compose files. Live-verified against a real docker stack multiple times through this work, including a final fresh-volume run confirming the actual renamed Postgres database/role, ClickHouse database, and Kafka topic all work end to end with a real login and query, zero console errors.
363 lines
13 KiB
Go
363 lines
13 KiB
Go
package aiapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/cairnobs/cairnobs/api/ai/provider"
|
|
"github.com/cairnobs/cairnobs/api/ai/router"
|
|
)
|
|
|
|
type fakeProvider struct {
|
|
translateResult provider.TranslateResult
|
|
completeResult provider.CompleteResult
|
|
explainResult provider.ExplainResult
|
|
fixResult provider.FixResult
|
|
err error
|
|
|
|
gotExplainReq provider.ExplainRequest
|
|
}
|
|
|
|
func (f *fakeProvider) Translate(context.Context, provider.TranslateRequest) (provider.TranslateResult, error) {
|
|
return f.translateResult, f.err
|
|
}
|
|
func (f *fakeProvider) Complete(context.Context, provider.CompleteRequest) (provider.CompleteResult, error) {
|
|
return f.completeResult, f.err
|
|
}
|
|
func (f *fakeProvider) Explain(_ context.Context, req provider.ExplainRequest) (provider.ExplainResult, error) {
|
|
f.gotExplainReq = req
|
|
return f.explainResult, f.err
|
|
}
|
|
func (f *fakeProvider) Fix(context.Context, provider.FixRequest) (provider.FixResult, error) {
|
|
return f.fixResult, f.err
|
|
}
|
|
|
|
type fakeSchemaSource struct{}
|
|
|
|
func (fakeSchemaSource) SchemaContext(context.Context) provider.SchemaContext {
|
|
return provider.SchemaContext{Services: []string{"api"}}
|
|
}
|
|
|
|
func newTestHandler(p *fakeProvider) *Handler {
|
|
r := router.New(p)
|
|
logger := slog.New(slog.NewTextHandler(bytesDiscard{}, nil))
|
|
return NewHandler(logger, r, fakeSchemaSource{}, nil, nil)
|
|
}
|
|
|
|
type bytesDiscard struct{}
|
|
|
|
func (bytesDiscard) Write(p []byte) (int, error) { return len(p), nil }
|
|
|
|
func doRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
|
t.Fatalf("encoding request body: %v", err)
|
|
}
|
|
}
|
|
req := httptest.NewRequest(method, path, &buf)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestHandleCompleteReturnsSuggestion(t *testing.T) {
|
|
p := &fakeProvider{completeResult: provider.CompleteResult{Suggestion: " | stats count"}}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp completeResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if resp.Suggestion != " | stats count" {
|
|
t.Errorf("Suggestion = %q", resp.Suggestion)
|
|
}
|
|
}
|
|
|
|
func TestHandleCompleteDegradesGracefullyOnProviderError(t *testing.T) {
|
|
p := &fakeProvider{err: errors.New("provider down")}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 even on provider failure (graceful degradation), body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp completeResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if resp.Suggestion != "" {
|
|
t.Errorf("Suggestion = %q, want empty on provider failure", resp.Suggestion)
|
|
}
|
|
}
|
|
|
|
func TestHandleCompleteEmptyPrefixSkipsProviderCall(t *testing.T) {
|
|
p := &fakeProvider{err: errors.New("should not be called")}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: " "})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleExplainReturnsExplanation(t *testing.T) {
|
|
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "counts errors per host"}}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "severity=ERROR | stats count by host"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp explainResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if resp.Explanation == "" {
|
|
t.Error("expected a non-empty explanation")
|
|
}
|
|
}
|
|
|
|
func TestHandleExplainEmptyQueryIsBadRequest(t *testing.T) {
|
|
h := newTestHandler(&fakeProvider{})
|
|
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: ""})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleExplainProviderErrorIsBadGateway(t *testing.T) {
|
|
p := &fakeProvider{err: errors.New("model unavailable")}
|
|
h := newTestHandler(p)
|
|
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "service=api"})
|
|
if rec.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleFixMissingErrorFieldsIsBadRequest(t *testing.T) {
|
|
h := newTestHandler(&fakeProvider{})
|
|
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "service=api"})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 (neither parseError nor executionError set)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleFixAssessesSuggestedQueryCost(t *testing.T) {
|
|
// The provider suggests a fix that aggregates with no time bound --
|
|
// costguard should reject it (an aggregation gets no implicit row
|
|
// cap the way a raw-row fetch does), and the handler must mark it
|
|
// Blocked rather than silently offering it as runnable. Needs a real
|
|
// leading pipe stage -- bare words with no "|" parse as free-text
|
|
// search terms, not an aggregation, per the query grammar.
|
|
p := &fakeProvider{fixResult: provider.FixResult{
|
|
SuggestedQuery: "service=api | stats count by host",
|
|
Explanation: "removed the invalid field reference",
|
|
Confidence: provider.ConfidenceHigh,
|
|
}}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "bogus_field=1 | stats count by host", ParseError: "unknown field"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp fixResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if !resp.Blocked {
|
|
t.Errorf("resp = %+v, want Blocked=true for an unbounded aggregation suggestion", resp)
|
|
}
|
|
if len(resp.CostWarnings) == 0 {
|
|
t.Error("expected non-empty CostWarnings")
|
|
}
|
|
}
|
|
|
|
func TestHandleFixBoundedSuggestionIsNotBlocked(t *testing.T) {
|
|
p := &fakeProvider{fixResult: provider.FixResult{
|
|
SuggestedQuery: "earliest=-1h | stats count by host",
|
|
Confidence: provider.ConfidenceHigh,
|
|
}}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "stats count by host", ParseError: "no time range"})
|
|
var resp fixResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if resp.Blocked {
|
|
t.Errorf("resp = %+v, want Blocked=false for a properly time-bounded suggestion", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandleOptimizeNoFindingsForBoundedQuery(t *testing.T) {
|
|
h := newTestHandler(&fakeProvider{})
|
|
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "earliest=-1h severity=ERROR | stats count by host"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp optimizeResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if len(resp.Findings) != 0 || resp.Phrased != "" {
|
|
t.Errorf("resp = %+v, want no findings for a bounded query", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandleOptimizeSuggestsMechanicalFixForMissingTimeRange(t *testing.T) {
|
|
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "add a time range to avoid scanning everything"}}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp optimizeResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if len(resp.Findings) == 0 {
|
|
t.Error("expected at least one finding for an unbounded aggregation")
|
|
}
|
|
if resp.SuggestedQuery != "earliest=-1h stats count by host" {
|
|
t.Errorf("SuggestedQuery = %q", resp.SuggestedQuery)
|
|
}
|
|
if resp.Phrased == "" {
|
|
t.Error("expected a phrased explanation from the (fake) provider")
|
|
}
|
|
if len(p.gotExplainReq.RuleFindings) == 0 {
|
|
t.Error("expected Explain to have been called with RuleFindings set")
|
|
}
|
|
}
|
|
|
|
func TestHandleOptimizeDegradesGracefullyWhenPhraseFails(t *testing.T) {
|
|
p := &fakeProvider{err: errors.New("model down")}
|
|
h := newTestHandler(p)
|
|
|
|
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 even when phrasing fails, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp optimizeResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if len(resp.Findings) == 0 {
|
|
t.Error("Findings should still be populated (rule-based, no model needed) even if phrasing fails")
|
|
}
|
|
if resp.Phrased != "" {
|
|
t.Errorf("Phrased = %q, want empty when the provider fails", resp.Phrased)
|
|
}
|
|
}
|
|
|
|
func TestHandleOptimizeInvalidQueryIsBadRequest(t *testing.T) {
|
|
h := newTestHandler(&fakeProvider{})
|
|
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "| stats"})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 for an uncompilable query", rec.Code)
|
|
}
|
|
}
|
|
|
|
// ---- translate ----
|
|
|
|
func TestHandleTranslateEmptyNLQueryIsBadRequest(t *testing.T) {
|
|
h := newTestHandler(&fakeProvider{})
|
|
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: " "})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleTranslateProviderErrorIsBadGateway(t *testing.T) {
|
|
p := &fakeProvider{err: errors.New("model unavailable")}
|
|
h := newTestHandler(p)
|
|
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors in the last hour"})
|
|
if rec.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleTranslateBoundedQueryCompilesCleanly(t *testing.T) {
|
|
p := &fakeProvider{translateResult: provider.TranslateResult{
|
|
Query: "earliest=-1h severity=ERROR | stats count by service",
|
|
Confidence: provider.ConfidenceHigh,
|
|
}}
|
|
h := newTestHandler(p)
|
|
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors per service in the last hour"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp translateResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if !resp.Compiles || resp.CompileError != "" {
|
|
t.Errorf("resp = %+v, want Compiles=true, no CompileError", resp)
|
|
}
|
|
if resp.Blocked || len(resp.CostWarnings) != 0 {
|
|
t.Errorf("resp = %+v, want no cost warnings for a time-bounded query", resp)
|
|
}
|
|
if resp.Confidence != string(provider.ConfidenceHigh) {
|
|
t.Errorf("Confidence = %q", resp.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestHandleTranslateUnboundedQueryIsBlocked(t *testing.T) {
|
|
// The model produced a syntactically valid but unbounded aggregation
|
|
// -- task 9's explicit requirement that translation results run
|
|
// through the same cost guard AI-suggested fixes do.
|
|
p := &fakeProvider{translateResult: provider.TranslateResult{
|
|
Query: "severity=ERROR | stats count by service",
|
|
Confidence: provider.ConfidenceHigh,
|
|
}}
|
|
h := newTestHandler(p)
|
|
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors by service"})
|
|
var resp translateResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if !resp.Compiles {
|
|
t.Fatalf("resp = %+v, want Compiles=true", resp)
|
|
}
|
|
if !resp.Blocked || len(resp.CostWarnings) == 0 {
|
|
t.Errorf("resp = %+v, want Blocked=true with cost warnings for an unbounded aggregation", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandleTranslateNonCompilingQueryIsHonestlyReported(t *testing.T) {
|
|
// The model returned something that doesn't actually parse -- a
|
|
// real, distinct outcome from low confidence (a confident model can
|
|
// still produce invalid syntax); the handler must say so plainly,
|
|
// not silently drop it or crash.
|
|
p := &fakeProvider{translateResult: provider.TranslateResult{
|
|
Query: "| stats count",
|
|
Confidence: provider.ConfidenceHigh,
|
|
}}
|
|
h := newTestHandler(p)
|
|
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "something odd"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (a non-compiling suggestion is a reportable outcome, not an HTTP error), body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp translateResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if resp.Compiles || resp.CompileError == "" {
|
|
t.Errorf("resp = %+v, want Compiles=false with a CompileError", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandleTranslateLowConfidenceCarriesReason(t *testing.T) {
|
|
p := &fakeProvider{translateResult: provider.TranslateResult{
|
|
Confidence: provider.ConfidenceLow,
|
|
LowConfidenceReason: "not sure what 'weird stuff' refers to",
|
|
}}
|
|
h := newTestHandler(p)
|
|
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "show me weird stuff"})
|
|
var resp translateResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if resp.Query != "" {
|
|
t.Errorf("Query = %q, want empty for a low-confidence non-answer", resp.Query)
|
|
}
|
|
if resp.Confidence != string(provider.ConfidenceLow) || resp.LowConfidenceReason == "" {
|
|
t.Errorf("resp = %+v, want low confidence with a reason", resp)
|
|
}
|
|
if resp.Compiles {
|
|
t.Errorf("resp = %+v, want Compiles=false when Query is empty", resp)
|
|
}
|
|
}
|