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.
206 lines
7.8 KiB
Go
206 lines
7.8 KiB
Go
// Package queryapi is Cairn OBS's query API: a single POST /query endpoint
|
|
// accepting either the pipe syntax or raw SQL, compiled by
|
|
// querylang/planner and executed by querylang/executor. Replaces Phase
|
|
// 0/1's two separate placeholder endpoints (raw-SQL-only /query,
|
|
// free-text-only /search) -- see /docs/query-language-design.md.
|
|
//
|
|
// Still plain net/http, not the pinned gRPC+REST-gateway pattern, for
|
|
// the same reason as Phase 0/1: this is one endpoint, and the
|
|
// proto/annotations/codegen machinery doesn't buy much at that size.
|
|
// `/api` does speak gRPC internally (to /search) — this simplification
|
|
// is about the public-facing surface only.
|
|
package queryapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cairnobs/cairnobs/api/ai/costguard"
|
|
"github.com/cairnobs/cairnobs/api/authz"
|
|
"github.com/cairnobs/cairnobs/api/internal/querylang/planner"
|
|
"github.com/cairnobs/cairnobs/api/querylang/executor"
|
|
)
|
|
|
|
// AuditLogger is core's extension point for query audit logging --
|
|
// deliberately minimal and tenant-agnostic, since core has no concept of
|
|
// tenants (see /docs/phase-4-isolation-design.md: that mechanism lives
|
|
// entirely in enterprise/). enterprise/internal/audit implements this
|
|
// against the real hash-chained, append-only store; a nil AuditLogger
|
|
// (the default for a single-tenant deployment without enterprise/
|
|
// configured) means no audit logging happens and core behaves exactly
|
|
// as it did in Phases 0-3.
|
|
//
|
|
// Tenant/user identity is deliberately NOT a field on QueryAuditEntry --
|
|
// once Phase 4 task 5's auth middleware wraps this handler, it attaches
|
|
// that identity to the request's context.Context via
|
|
// enterprise/internal/tenant, and LogQuery's ctx parameter is the same
|
|
// context the request carried, so an enterprise-side implementation
|
|
// reads identity from ctx rather than this interface growing
|
|
// tenant-awareness. Per /docs/phase-4-isolation-design.md's audit
|
|
// section, this is a fail-open path: a LogQuery error is logged but
|
|
// never fails the HTTP response for a routine read query.
|
|
type AuditLogger interface {
|
|
LogQuery(ctx context.Context, entry QueryAuditEntry) error
|
|
}
|
|
|
|
type QueryAuditEntry struct {
|
|
Query string
|
|
Language string
|
|
RowCount int
|
|
Duration time.Duration
|
|
Success bool
|
|
Error string
|
|
}
|
|
|
|
type Handler struct {
|
|
logger *slog.Logger
|
|
sqlRunner executor.SQLRunner
|
|
search executor.SearchClient
|
|
queryTimeout time.Duration
|
|
audit AuditLogger
|
|
authorizer authz.Authorizer
|
|
}
|
|
|
|
// audit and authorizer may both be nil -- see AuditLogger's doc comment
|
|
// and authz.RequireRoleOrService's nil-safety. /query allows RoleViewer
|
|
// (human sessions) or the alerting service identity (RoleService) --
|
|
// it's the one endpoint /alerting's evaluator legitimately calls, per
|
|
// /docs/phase-4-isolation-design.md's alerting service-identity design.
|
|
func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search executor.SearchClient, queryTimeout time.Duration, audit AuditLogger, authorizer authz.Authorizer) *Handler {
|
|
return &Handler{logger: logger, sqlRunner: sqlRunner, search: search, queryTimeout: queryTimeout, audit: audit, authorizer: authorizer}
|
|
}
|
|
|
|
// RegisterRoutes adds this handler's routes onto a shared mux. Phase 3
|
|
// introduced a second handler package (dashboards), so CORS is
|
|
// now applied once, by main.go, around the fully-assembled mux rather
|
|
// than by each handler wrapping itself individually -- see
|
|
// httpserver.WithCORS.
|
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /query", authz.RequireRoleOrService(h.authorizer, authz.RoleViewer, h.handleQuery))
|
|
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
|
}
|
|
|
|
func (h *Handler) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
type queryRequest struct {
|
|
Query string `json:"query"`
|
|
// Language overrides auto-detection ("" / omitted). "sql" or "spl" --
|
|
// see planner.Language and /docs/query-language-design.md's
|
|
// "Detection" section for why this exists: the rare case a pipe
|
|
// query legitimately starts with the literal word "select".
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
type queryResponse struct {
|
|
Columns []string `json:"columns"`
|
|
Rows [][]any `json:"rows"`
|
|
// Warnings surfaces costguard's assessment (Phase 7 task 4) for
|
|
// every query, hand-written or AI-suggested alike -- the same
|
|
// guard, never a hard block here. AI-suggested queries get a
|
|
// stricter treatment (a Reject-level assessment withholds the
|
|
// suggestion entirely, see the ai package) before a query ever
|
|
// reaches this handler; a hand-written query submitted directly
|
|
// always runs regardless of what this says, matching every prior
|
|
// phase's behavior -- this field is informational, not new
|
|
// enforcement, a deliberate choice recorded in
|
|
// /docs/phase-7-ai-design.md rather than a retrofit nobody decided
|
|
// on. Omitted (not an empty array) when there's nothing to say, so
|
|
// existing callers that don't look for this field see no shape
|
|
// change at all.
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
}
|
|
|
|
type errorResponse struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// maxBodyBytes caps the request body: a query string has no legitimate
|
|
// reason to be larger than this.
|
|
const maxBodyBytes = 1 << 20 // 1 MiB
|
|
|
|
func (h *Handler) handleQuery(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
|
|
var req queryRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Query) == "" {
|
|
writeError(w, http.StatusBadRequest, "query must not be empty")
|
|
return
|
|
}
|
|
|
|
lang := planner.Language(req.Language)
|
|
if lang != planner.Auto && lang != planner.SQL && lang != planner.SPL {
|
|
writeError(w, http.StatusBadRequest, `language must be "sql", "spl", or omitted`)
|
|
return
|
|
}
|
|
|
|
plan, err := planner.Compile(req.Query, lang, time.Now())
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), h.queryTimeout)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
result, err := executor.Execute(ctx, plan, h.sqlRunner, h.search)
|
|
duration := time.Since(start)
|
|
|
|
if err != nil {
|
|
h.logger.Error("query execution failed", "query", req.Query, "error", err)
|
|
h.logAudit(r.Context(), req, 0, duration, err)
|
|
writeError(w, http.StatusBadGateway, "query failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
h.logAudit(r.Context(), req, len(result.Rows), duration, nil)
|
|
resp := queryResponse{Columns: result.Columns, Rows: result.Rows}
|
|
if assessment := costguard.Assess(plan); assessment.Level != costguard.LevelOK {
|
|
resp.Warnings = []string{costguard.Summary(assessment)}
|
|
}
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// logAudit is fail-open by design (see AuditLogger's doc comment): a
|
|
// write failure here is logged and otherwise ignored, never surfaced to
|
|
// the HTTP caller. Uses r.Context() (the original request context, not
|
|
// the query-execution one with its own deadline) so a slow/cancelled
|
|
// query's context.WithTimeout expiring doesn't also cancel the audit
|
|
// write for it.
|
|
func (h *Handler) logAudit(ctx context.Context, req queryRequest, rowCount int, duration time.Duration, execErr error) {
|
|
if h.audit == nil {
|
|
return
|
|
}
|
|
entry := QueryAuditEntry{
|
|
Query: req.Query, Language: req.Language, RowCount: rowCount,
|
|
Duration: duration, Success: execErr == nil,
|
|
}
|
|
if execErr != nil {
|
|
entry.Error = execErr.Error()
|
|
}
|
|
if err := h.audit.LogQuery(ctx, entry); err != nil {
|
|
h.logger.Error("audit log write failed", "error", err)
|
|
}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
|
}
|