Rebrand: Sentry -> Cairn OBS
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.
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
// Command surface for api/agents -- agent inventory, remote config, and
|
||||
// lifecycle commands (see /docs/agent-management-design.md). Same
|
||||
// list/get shape as dashboards/alerts, plus a "config" sub-subcommand
|
||||
// (mirroring dashboards' "permissions") since an agent's remote
|
||||
// override has its own get/set/clear lifecycle distinct from the
|
||||
// resource itself.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func cmdAgents(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents: expected a subcommand (list, get, config, restart)")
|
||||
return 1
|
||||
}
|
||||
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
|
||||
token := resolveToken(os.Getenv)
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return httpGetJSON(apiURL, "/agents", token, stdout, stderr)
|
||||
case "get":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents get: missing host")
|
||||
return 1
|
||||
}
|
||||
return httpGetJSON(apiURL, "/agents/"+rest[0], token, stdout, stderr)
|
||||
case "config":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config: expected a subcommand (get, set, clear)")
|
||||
return 1
|
||||
}
|
||||
return cmdAgentsConfig(rest, apiURL, token, stdout, stderr)
|
||||
case "restart":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents restart: missing host")
|
||||
return 1
|
||||
}
|
||||
// os.Stdin passed explicitly at this inner layer (not threaded
|
||||
// through cmdAgents' own signature) so the confirmation prompt
|
||||
// is testable the same way cmd_query.go's cmdQueryNL is -- tests
|
||||
// call cmdAgentsRestart directly with a fake reader.
|
||||
return cmdAgentsRestart(rest, apiURL, token, os.Stdin, stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents: unknown subcommand %q (want list, get, config, restart)\n", args[0])
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func cmdAgentsConfig(args []string, apiURL, token string, stdout, stderr io.Writer) int {
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "get":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config get: missing host")
|
||||
return 1
|
||||
}
|
||||
// Same GET /agents/{host} as plain "get" -- an agent's reported
|
||||
// config, desired override, and pending/applied status are all
|
||||
// one resource server-side; a narrower "config-only" response
|
||||
// shape isn't worth a second endpoint just for this command.
|
||||
return httpGetJSON(apiURL, "/agents/"+rest[0], token, stdout, stderr)
|
||||
case "set":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config set: missing host")
|
||||
return 1
|
||||
}
|
||||
return cmdAgentsConfigSet(rest[0], rest[1:], apiURL, token, stdout, stderr)
|
||||
case "clear":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config clear: missing host")
|
||||
return 1
|
||||
}
|
||||
return httpMutateNoBody(http.MethodDelete, apiURL, "/agents/"+rest[0]+"/config", token, "", "config override cleared -- agent will run its local agent.toml again", stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config: unknown subcommand %q (want get, set, clear)\n", sub)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// agentInfo is a CLI-local mirror of api/agents.Agent's JSON shape --
|
||||
// only the fields config-merging actually needs. Deliberately
|
||||
// duplicated rather than imported (cli is a separate Go module from
|
||||
// api), same convention as every other cross-module shared shape in
|
||||
// this codebase (see ingest/internal/agentregistry.overrideFields).
|
||||
type agentInfo struct {
|
||||
SourceKind string `json:"source_kind"`
|
||||
BatchMaxSize int64 `json:"batch_max_size"`
|
||||
BatchFlushIntervalMS int64 `json:"batch_flush_interval_ms"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatIntervalMS int64 `json:"heartbeat_interval_ms"`
|
||||
DesiredOverride *agentConfigOverride `json:"desired_override,omitempty"`
|
||||
}
|
||||
|
||||
type agentConfigOverride struct {
|
||||
BatchMaxSize *int64 `json:"batch_max_size,omitempty"`
|
||||
BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"`
|
||||
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
||||
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
|
||||
JournaldUnit *string `json:"journald_unit,omitempty"`
|
||||
}
|
||||
|
||||
// cmdAgentsConfigSet parses --batch-max-size/--batch-flush-interval-ms/
|
||||
// --heartbeat-enabled/--heartbeat-interval-ms/--journald-unit, fetches
|
||||
// the agent's current effective config, and PUTs the complete merged
|
||||
// override -- api/agents.Store.SetOverride replaces the whole stored
|
||||
// override, it doesn't patch individual fields (same as the web UI's
|
||||
// edit form, see /docs/agent-management-design.md), so every field this
|
||||
// command doesn't touch has to be carried forward from whatever's
|
||||
// currently in effect (the existing override if one's set, otherwise
|
||||
// the agent's reported value) rather than silently reset to zero.
|
||||
func cmdAgentsConfigSet(host string, flagArgs []string, apiURL, token string, stdout, stderr io.Writer) int {
|
||||
var (
|
||||
batchMaxSize, batchFlushMS, heartbeatMS *int64
|
||||
heartbeatEnabled *bool
|
||||
journaldUnit *string
|
||||
)
|
||||
for i := 0; i < len(flagArgs); i++ {
|
||||
flag := flagArgs[i]
|
||||
next := func() (string, bool) {
|
||||
if i+1 >= len(flagArgs) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return flagArgs[i], true
|
||||
}
|
||||
switch flag {
|
||||
case "--batch-max-size":
|
||||
v, ok := next()
|
||||
if !ok {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config set: --batch-max-size requires a value")
|
||||
return 1
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --batch-max-size %q: %v\n", v, err)
|
||||
return 1
|
||||
}
|
||||
batchMaxSize = &n
|
||||
case "--batch-flush-interval-ms":
|
||||
v, ok := next()
|
||||
if !ok {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config set: --batch-flush-interval-ms requires a value")
|
||||
return 1
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --batch-flush-interval-ms %q: %v\n", v, err)
|
||||
return 1
|
||||
}
|
||||
batchFlushMS = &n
|
||||
case "--heartbeat-interval-ms":
|
||||
v, ok := next()
|
||||
if !ok {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config set: --heartbeat-interval-ms requires a value")
|
||||
return 1
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --heartbeat-interval-ms %q: %v\n", v, err)
|
||||
return 1
|
||||
}
|
||||
heartbeatMS = &n
|
||||
case "--heartbeat-enabled":
|
||||
v, ok := next()
|
||||
if !ok {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config set: --heartbeat-enabled requires true or false")
|
||||
return 1
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --heartbeat-enabled %q: %v\n", v, err)
|
||||
return 1
|
||||
}
|
||||
heartbeatEnabled = &b
|
||||
case "--journald-unit":
|
||||
v, ok := next()
|
||||
if !ok {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config set: --journald-unit requires a value (empty string clears the filter)")
|
||||
return 1
|
||||
}
|
||||
journaldUnit = &v
|
||||
default:
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config set: unknown flag %q\n", flag)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if batchMaxSize == nil && batchFlushMS == nil && heartbeatMS == nil && heartbeatEnabled == nil && journaldUnit == nil {
|
||||
fmt.Fprintln(stderr, "cairnobsctl agents config set: at least one of --batch-max-size, --batch-flush-interval-ms, --heartbeat-enabled, --heartbeat-interval-ms, --journald-unit is required")
|
||||
return 1
|
||||
}
|
||||
|
||||
current, err := fetchAgent(apiURL, host, token)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config set: fetching current state: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
mergedBatchMax := mergeInt64(batchMaxSize, overrideBatchMaxSize(current), current.BatchMaxSize)
|
||||
mergedBatchFlush := mergeInt64(batchFlushMS, overrideBatchFlushMS(current), current.BatchFlushIntervalMS)
|
||||
mergedHeartbeatMS := mergeInt64(heartbeatMS, overrideHeartbeatMS(current), current.HeartbeatIntervalMS)
|
||||
mergedHeartbeatEnabled := mergeBool(heartbeatEnabled, overrideHeartbeatEnabled(current), current.HeartbeatEnabled)
|
||||
|
||||
merged := agentConfigOverride{
|
||||
BatchMaxSize: &mergedBatchMax,
|
||||
BatchFlushIntervalMS: &mergedBatchFlush,
|
||||
HeartbeatEnabled: &mergedHeartbeatEnabled,
|
||||
HeartbeatIntervalMS: &mergedHeartbeatMS,
|
||||
}
|
||||
// journald_unit only applies (and is only ever sent) when the
|
||||
// agent's actual source is journald -- ignored server-side
|
||||
// otherwise anyway, but sending it for a non-journald agent would
|
||||
// be misleading in the stored override. Matches
|
||||
// web/src/routes/agents/[host]/+page.svelte's save() exactly.
|
||||
if current.SourceKind == "journald" {
|
||||
unit := ""
|
||||
if u := overrideJournaldUnit(current); u != nil {
|
||||
unit = *u
|
||||
}
|
||||
if journaldUnit != nil {
|
||||
unit = *journaldUnit
|
||||
}
|
||||
merged.JournaldUnit = &unit
|
||||
}
|
||||
|
||||
body, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "cairnobsctl agents config set: encoding request: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return httpPutJSON(apiURL, "/agents/"+host+"/config", token, string(body), stdout, stderr)
|
||||
}
|
||||
|
||||
func mergeInt64(flag, override *int64, reported int64) int64 {
|
||||
if flag != nil {
|
||||
return *flag
|
||||
}
|
||||
if override != nil {
|
||||
return *override
|
||||
}
|
||||
return reported
|
||||
}
|
||||
|
||||
func mergeBool(flag, override *bool, reported bool) bool {
|
||||
if flag != nil {
|
||||
return *flag
|
||||
}
|
||||
if override != nil {
|
||||
return *override
|
||||
}
|
||||
return reported
|
||||
}
|
||||
|
||||
func overrideBatchMaxSize(a *agentInfo) *int64 {
|
||||
if a.DesiredOverride == nil {
|
||||
return nil
|
||||
}
|
||||
return a.DesiredOverride.BatchMaxSize
|
||||
}
|
||||
|
||||
func overrideBatchFlushMS(a *agentInfo) *int64 {
|
||||
if a.DesiredOverride == nil {
|
||||
return nil
|
||||
}
|
||||
return a.DesiredOverride.BatchFlushIntervalMS
|
||||
}
|
||||
|
||||
func overrideHeartbeatMS(a *agentInfo) *int64 {
|
||||
if a.DesiredOverride == nil {
|
||||
return nil
|
||||
}
|
||||
return a.DesiredOverride.HeartbeatIntervalMS
|
||||
}
|
||||
|
||||
func overrideHeartbeatEnabled(a *agentInfo) *bool {
|
||||
if a.DesiredOverride == nil {
|
||||
return nil
|
||||
}
|
||||
return a.DesiredOverride.HeartbeatEnabled
|
||||
}
|
||||
|
||||
func overrideJournaldUnit(a *agentInfo) *string {
|
||||
if a.DesiredOverride == nil {
|
||||
return nil
|
||||
}
|
||||
return a.DesiredOverride.JournaldUnit
|
||||
}
|
||||
|
||||
func fetchAgent(apiURL, host, token string) (*agentInfo, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL+"/agents/"+host, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setAuth(req, token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp errorResponseBody
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
|
||||
return nil, fmt.Errorf("%s", errResp.Error)
|
||||
}
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
var info agentInfo
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// cmdAgentsRestart requires explicit confirmation -- interactive y/N,
|
||||
// or --yes for scripted use -- same "never run something disruptive
|
||||
// without an explicit signal" posture as cmd_query.go's --execute for
|
||||
// running an AI-translated query, matching restart's own real (if
|
||||
// brief) blast radius: it interrupts log collection on that host until
|
||||
// the service manager brings the agent back up.
|
||||
func cmdAgentsRestart(args []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
host := args[0]
|
||||
yes := false
|
||||
for _, a := range args[1:] {
|
||||
if a == "--yes" || a == "-y" {
|
||||
yes = true
|
||||
}
|
||||
}
|
||||
if !yes {
|
||||
if !isInteractive(stdin) {
|
||||
fmt.Fprintln(stdout, "Not restarting: pass --yes to confirm non-interactively.")
|
||||
return 1
|
||||
}
|
||||
prompt := fmt.Sprintf("Restart agent %q? This briefly interrupts log collection on that host.", host)
|
||||
if !confirmRun(stdin, stdout, prompt) {
|
||||
fmt.Fprintln(stdout, "Not restarting.")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return httpPutJSON(apiURL, "/agents/"+host+"/command", token, `{"command":"restart"}`, stdout, stderr)
|
||||
}
|
||||
Reference in New Issue
Block a user