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:
2026-08-21 20:53:32 -07:00
parent 9e21ea17bb
commit 13cf9a30cb
291 changed files with 1565 additions and 1441 deletions
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestResolveTokenFromEnv(t *testing.T) {
env := func(k string) string {
if k == "CAIRNOBSCTL_TOKEN" {
return "secret-token"
}
return ""
}
if got := resolveToken(env); got != "secret-token" {
t.Errorf("got %q, want %q", got, "secret-token")
}
}
func TestHTTPGetJSONForwardsBearerToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := httpGetJSON(srv.URL, "/thing", "my-token", &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
}
if gotAuth != "Bearer my-token" {
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer my-token")
}
}
func TestHTTPGetJSONOmitsAuthorizationWhenNoToken(t *testing.T) {
sawHeader := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawHeader = r.Header.Get("Authorization") != ""
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := httpGetJSON(srv.URL, "/thing", "", &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
}
if sawHeader {
t.Fatalf("expected no Authorization header when no token is configured")
}
}
func TestHTTPPostFileJSONForwardsBearerToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
f, err := os.CreateTemp(t.TempDir(), "payload-*.json")
if err != nil {
t.Fatalf("creating temp file: %v", err)
}
if _, err := f.WriteString(`{"name":"test"}`); err != nil {
t.Fatalf("writing temp file: %v", err)
}
f.Close()
var stdout, stderr bytes.Buffer
code := httpPostFileJSON(srv.URL, "/thing", "my-token", f.Name(), &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
}
if gotAuth != "Bearer my-token" {
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer my-token")
}
}
func TestCmdPingForwardsBearerToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
t.Setenv("CAIRNOBSCTL_TOKEN", "ping-token")
var stdout, stderr bytes.Buffer
code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
}
if gotAuth != "Bearer ping-token" {
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer ping-token")
}
}
func TestCmdQueryForwardsBearerToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"columns":[],"rows":[]}`))
}))
defer srv.Close()
t.Setenv("CAIRNOBSCTL_TOKEN", "query-token")
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--api", srv.URL, "service=api"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
}
if gotAuth != "Bearer query-token" {
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer query-token")
}
}
+351
View File
@@ -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)
}
+279
View File
@@ -0,0 +1,279 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCmdAgentsMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAgents(nil, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAgentsListSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/agents" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[{"host":"web-01","service":"web"}]`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"list", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "web-01") {
t.Fatalf("stdout = %q, want it to contain the listed agent", stdout.String())
}
}
func TestCmdAgentsGetMissingHost(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"get"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAgentsGetSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/agents/web-01" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"host":"web-01"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"get", "web-01", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
func TestCmdAgentsConfigMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"config"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAgentsConfigClearSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/agents/web-01/config" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"config", "clear", "web-01", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "cleared") {
t.Fatalf("stdout = %q, want a confirmation", stdout.String())
}
}
func TestCmdAgentsConfigSetRequiresAtLeastOneFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"config", "set", "web-01"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "at least one of") {
t.Fatalf("stderr = %q, want it to explain a flag is required", stderr.String())
}
}
func TestCmdAgentsConfigSetInvalidValue(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"config", "set", "web-01", "--batch-max-size", "not-a-number"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
// TestCmdAgentsConfigSetMergesUntouchedFields is the regression test for
// the whole point of the merge logic: setting only --heartbeat-interval-ms
// must carry forward the agent's OTHER already-set override field
// (batch_max_size) and its reported (non-overridden) values for
// everything else, not silently reset them.
func TestCmdAgentsConfigSetMergesUntouchedFields(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/agents/web-01":
w.Write([]byte(`{
"source_kind": "journald",
"batch_max_size": 500,
"batch_flush_interval_ms": 2000,
"heartbeat_enabled": true,
"heartbeat_interval_ms": 60000,
"desired_override": {"batch_max_size": 1000}
}`))
case r.Method == http.MethodPut && r.URL.Path == "/agents/web-01/config":
putBody, _ = io.ReadAll(r.Body)
w.Write(putBody)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"config", "set", "web-01", "--heartbeat-interval-ms", "30000", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
var sent agentConfigOverride
if err := json.Unmarshal(putBody, &sent); err != nil {
t.Fatalf("decoding PUT body: %v", err)
}
if sent.BatchMaxSize == nil || *sent.BatchMaxSize != 1000 {
t.Fatalf("BatchMaxSize = %v, want 1000 (carried forward from the existing override)", sent.BatchMaxSize)
}
if sent.BatchFlushIntervalMS == nil || *sent.BatchFlushIntervalMS != 2000 {
t.Fatalf("BatchFlushIntervalMS = %v, want 2000 (carried forward from reported value)", sent.BatchFlushIntervalMS)
}
if sent.HeartbeatEnabled == nil || *sent.HeartbeatEnabled != true {
t.Fatalf("HeartbeatEnabled = %v, want true (carried forward from reported value)", sent.HeartbeatEnabled)
}
if sent.HeartbeatIntervalMS == nil || *sent.HeartbeatIntervalMS != 30000 {
t.Fatalf("HeartbeatIntervalMS = %v, want 30000 (the flag actually passed)", sent.HeartbeatIntervalMS)
}
}
// TestCmdAgentsConfigSetOmitsJournaldUnitForNonJournaldSource mirrors
// web/src/routes/agents/[host]/+page.svelte's save(): journald_unit
// must never be sent for an agent whose source isn't journald, even if
// a stale override somehow had one.
func TestCmdAgentsConfigSetOmitsJournaldUnitForNonJournaldSource(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet:
w.Write([]byte(`{"source_kind":"file","batch_max_size":500,"batch_flush_interval_ms":2000,"heartbeat_enabled":true,"heartbeat_interval_ms":60000}`))
case r.Method == http.MethodPut:
putBody, _ = io.ReadAll(r.Body)
w.Write(putBody)
}
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"config", "set", "file-host", "--batch-max-size", "100", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if strings.Contains(string(putBody), "journald_unit") {
t.Fatalf("PUT body = %s, must not carry journald_unit for a non-journald source", putBody)
}
}
func TestCmdAgentsConfigSetFetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"agent not found"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"config", "set", "nope", "--batch-max-size", "100", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "agent not found") {
t.Fatalf("stderr = %q, want the server's actual error surfaced", stderr.String())
}
}
func TestCmdAgentsRestartMissingHost(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"restart"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAgentsRestartWithYesSkipsConfirmation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/agents/web-01/command" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"command":"restart"`) {
t.Errorf("body = %s, want command=restart", body)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"host":"web-01","pending_command":"restart"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdAgentsRestart([]string{"web-01", "--yes"}, srv.URL, "", strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
// The interactive confirm-prompt branch (confirmRun asked, "y"/"n"
// answered) isn't reachable in a test the way cmdAgentsRestart is
// structured -- isInteractive checks the concrete *os.File type, which
// a strings.Reader can never satisfy, same constraint cmd_query.go's
// own tests work around by testing confirmRun directly (see
// TestConfirmRunAcceptsY/TestConfirmRunRejectsBlankAndOther in
// cmd_query_test.go) rather than through the full non-interactive gate.
// Those two generic tests already cover the y/n logic this command
// relies on; only the two paths actually reachable with a non-tty
// stdin -- --yes and no-confirmation-possible -- are tested below.
// TestCmdAgentsRestartNonInteractiveWithoutYesRefuses guards against a
// scripted/piped invocation hanging forever waiting for an answer
// nobody can give -- same posture as cmd_query.go's isInteractive check
// for --nl without --execute.
func TestCmdAgentsRestartNonInteractiveWithoutYesRefuses(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("must not call the server without --yes when stdin isn't a terminal")
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
// strings.Reader is never a *os.File, so isInteractive(it) is
// always false -- exercising the same "piped stdin" path a real
// non-interactive invocation would hit.
code := cmdAgentsRestart([]string{"web-01"}, srv.URL, "", strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stdout.String(), "--yes") {
t.Fatalf("stdout = %q, want it to mention --yes", stdout.String())
}
}
func TestCmdAgentsUnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAgents([]string{"bogus"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"fmt"
"io"
"os"
)
func cmdAlerts(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "cairnobsctl alerts: expected a subcommand (list, get, apply)")
return 1
}
alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv)
token := resolveToken(os.Getenv)
switch args[0] {
case "list":
return httpGetJSON(alertingURL, "/rules", token, stdout, stderr)
case "get":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl alerts get: missing rule id")
return 1
}
return httpGetJSON(alertingURL, "/rules/"+rest[0], token, stdout, stderr)
case "apply":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl alerts apply: missing file path")
return 1
}
// POST /rules accepts the same shape it returns -- a rule
// definition file (query, condition, interval, notification
// target ID) applies directly with no reshaping.
return httpPostFileJSON(alertingURL, "/rules", token, rest[0], stdout, stderr)
default:
fmt.Fprintf(stderr, "cairnobsctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0])
return 1
}
}
func extractAlertingAPIFlag(args []string, env func(string) string) (alertingURL string, rest []string) {
alertingURL = resolveAlertingURL(env)
for i := 0; i < len(args); i++ {
if args[i] == "--alerting-api" && i+1 < len(args) {
alertingURL = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
return alertingURL, rest
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"bytes"
"reflect"
"testing"
)
func TestExtractAlertingAPIFlagDefault(t *testing.T) {
alertingURL, rest := extractAlertingAPIFlag([]string{"rule-1"}, func(string) string { return "" })
if alertingURL != defaultAlertingURL {
t.Fatalf("alertingURL = %q, want default %q", alertingURL, defaultAlertingURL)
}
if !reflect.DeepEqual(rest, []string{"rule-1"}) {
t.Fatalf("rest = %v", rest)
}
}
func TestExtractAlertingAPIFlagOverride(t *testing.T) {
alertingURL, rest := extractAlertingAPIFlag([]string{"--alerting-api", "http://custom:9091", "rule-1"}, func(string) string { return "" })
if alertingURL != "http://custom:9091" {
t.Fatalf("alertingURL = %q", alertingURL)
}
if !reflect.DeepEqual(rest, []string{"rule-1"}) {
t.Fatalf("rest = %v", rest)
}
}
func TestExtractAlertingAPIFlagFromEnv(t *testing.T) {
alertingURL, _ := extractAlertingAPIFlag(nil, func(k string) string {
if k == "CAIRNOBSCTL_ALERTING_API_URL" {
return "http://env-alerting:8081"
}
return ""
})
if alertingURL != "http://env-alerting:8081" {
t.Fatalf("alertingURL = %q", alertingURL)
}
}
func TestCmdAlertsMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAlerts(nil, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAlertsApplyMissingFile(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAlerts([]string{"apply"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAlertsUnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAlerts([]string{"bogus"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func cmdDashboards(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "cairnobsctl dashboards: expected a subcommand (list, get, apply, permissions)")
return 1
}
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
token := resolveToken(os.Getenv)
switch args[0] {
case "list":
return httpGetJSON(apiURL, "/dashboards", token, stdout, stderr)
case "get":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl dashboards get: missing dashboard id")
return 1
}
return httpGetJSON(apiURL, "/dashboards/"+rest[0], token, stdout, stderr)
case "apply":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl dashboards apply: missing file path")
return 1
}
// The import endpoint consumes exactly the shape GET
// /dashboards/{id}/export produces and the web UI's Export JSON
// button downloads -- one JSON contract, three call sites.
return httpPostFileJSON(apiURL, "/dashboards/import", token, rest[0], stdout, stderr)
case "permissions":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl dashboards permissions: expected a subcommand (list, grant, revoke)")
return 1
}
return cmdDashboardsPermissions(rest, apiURL, token, stdout, stderr)
default:
fmt.Fprintf(stderr, "cairnobsctl dashboards: unknown subcommand %q (want list, get, apply, permissions)\n", args[0])
return 1
}
}
// cmdDashboardsPermissions is api/dashboards.PermissionStore's CLI
// surface -- PUT/DELETE /dashboards/{id}/permissions/{userId} existed
// with no caller but Go tests and curl until now (see
// /docs/phase-4-runbook.md's "Known gaps"). Kept as dashboards'
// own sub-subcommand rather than a flat cairnobsctl command (like
// "cairnobsctl dashboard-permissions grant ...") since a grant only ever
// makes sense in the context of one specific dashboard -- args[0]
// selects list/grant/revoke.
func cmdDashboardsPermissions(args []string, apiURL, token string, stdout, stderr io.Writer) int {
sub, rest := args[0], args[1:]
switch sub {
case "list":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl dashboards permissions list: missing dashboard id")
return 1
}
return httpGetJSON(apiURL, "/dashboards/"+rest[0]+"/permissions", token, stdout, stderr)
case "grant":
if len(rest) < 3 {
fmt.Fprintln(stderr, "cairnobsctl dashboards permissions grant: usage: grant <dashboard-id> <user-id> <viewer|editor>")
return 1
}
dashboardID, userID, role := rest[0], rest[1], rest[2]
// Mirrors api/dashboards.validGrantRole -- Admin/Owner already
// have tenant-wide dashboard access, so a resource-level grant
// only ever raises someone as high as Editor; the server
// rejects anything else too, this just fails faster/locally.
if role != "viewer" && role != "editor" {
fmt.Fprintf(stderr, "cairnobsctl dashboards permissions grant: role must be \"viewer\" or \"editor\", got %q\n", role)
return 1
}
body := fmt.Sprintf(`{"role":%q}`, role)
path := "/dashboards/" + dashboardID + "/permissions/" + userID
return httpMutateNoBody(http.MethodPut, apiURL, path, token, body, "granted", stdout, stderr)
case "revoke":
if len(rest) < 2 {
fmt.Fprintln(stderr, "cairnobsctl dashboards permissions revoke: usage: revoke <dashboard-id> <user-id>")
return 1
}
dashboardID, userID := rest[0], rest[1]
path := "/dashboards/" + dashboardID + "/permissions/" + userID
return httpMutateNoBody(http.MethodDelete, apiURL, path, token, "", "revoked", stdout, stderr)
default:
fmt.Fprintf(stderr, "cairnobsctl dashboards permissions: unknown subcommand %q (want list, grant, revoke)\n", sub)
return 1
}
}
// extractAPIFlag pulls an optional --api <url> out of args, resolving
// the default the same way parsePingArgs/parseQueryArgs do, and returns
// the remaining positional args. Shared by dashboards and alerts since
// both take an optional --api/--alerting-api override the same way.
func extractAPIFlag(args []string, env func(string) string) (apiURL string, rest []string) {
apiURL = resolveAPIURL(env)
for i := 0; i < len(args); i++ {
if args[i] == "--api" && i+1 < len(args) {
apiURL = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
return apiURL, rest
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestExtractAPIFlagDefault(t *testing.T) {
apiURL, rest := extractAPIFlag([]string{"abc123"}, func(string) string { return "" })
if apiURL != defaultAPIURL {
t.Fatalf("apiURL = %q, want default %q", apiURL, defaultAPIURL)
}
if !reflect.DeepEqual(rest, []string{"abc123"}) {
t.Fatalf("rest = %v", rest)
}
}
func TestExtractAPIFlagOverride(t *testing.T) {
apiURL, rest := extractAPIFlag([]string{"--api", "http://custom:9090", "abc123"}, func(string) string { return "" })
if apiURL != "http://custom:9090" {
t.Fatalf("apiURL = %q", apiURL)
}
if !reflect.DeepEqual(rest, []string{"abc123"}) {
t.Fatalf("rest = %v, want [abc123] (flag pair stripped)", rest)
}
}
func TestCmdDashboardsMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards(nil, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdDashboardsGetMissingID(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"get"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdDashboardsPermissionsMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdDashboardsPermissionsListMissingID(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "list"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdDashboardsPermissionsListSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/dashboards/dash-1/permissions" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[{"UserID":"user-2","Role":"editor"}]`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "list", "dash-1", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "user-2") {
t.Fatalf("stdout = %q, want it to contain the listed grant", stdout.String())
}
}
func TestCmdDashboardsPermissionsGrantMissingArgs(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "grant", "dash-1"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdDashboardsPermissionsGrantInvalidRole(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "grant", "dash-1", "user-2", "owner"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "viewer") {
t.Fatalf("stderr = %q, want it to explain the allowed roles", stderr.String())
}
}
func TestCmdDashboardsPermissionsGrantSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/dashboards/dash-1/permissions/user-2" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"role":"editor"`) {
t.Errorf("body = %q, want it to carry role=editor", body)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "grant", "dash-1", "user-2", "editor", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "granted") {
t.Fatalf("stdout = %q, want a confirmation", stdout.String())
}
}
func TestCmdDashboardsPermissionsGrantServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
w.Write([]byte(`{"error":"dashboard permission grants are not available on this deployment"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "grant", "dash-1", "user-2", "editor", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "not available on this deployment") {
t.Fatalf("stderr = %q, want the server's actual error message surfaced", stderr.String())
}
}
func TestCmdDashboardsPermissionsRevokeMissingArgs(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "revoke", "dash-1"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdDashboardsPermissionsRevokeSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/dashboards/dash-1/permissions/user-2" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "revoke", "dash-1", "user-2", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "revoked") {
t.Fatalf("stdout = %q, want a confirmation", stdout.String())
}
}
func TestCmdDashboardsPermissionsUnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"permissions", "bogus"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
// parsePingArgs resolves the api base URL for ping: --api flag wins, then
// $CAIRNOBSCTL_API_URL, then the hardcoded default. Kept pure (env passed in
// as a function) and separate from the HTTP call so it's unit-testable
// without a real environment or server.
func parsePingArgs(args []string, env func(string) string) string {
apiURL := resolveAPIURL(env)
for i := 0; i < len(args); i++ {
if args[i] == "--api" && i+1 < len(args) {
apiURL = args[i+1]
i++
}
}
return apiURL
}
func cmdPing(args []string, stdout, stderr io.Writer) int {
apiURL := parsePingArgs(args, os.Getenv)
req, err := http.NewRequest(http.MethodGet, apiURL+"/healthz", nil)
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
setAuth(req, resolveToken(os.Getenv))
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(stderr, "ping failed: %v\n", err)
return 1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(stderr, "ping failed: api returned status %d\n", resp.StatusCode)
return 1
}
fmt.Fprintln(stdout, "ok")
return 0
}
+275
View File
@@ -0,0 +1,275 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type queryArgs struct {
apiURL string
jsonOut bool
language string
query string
// nlQuery, execute (Phase 7 task 11): --nl routes through
// POST /ai/translate instead of running query text directly.
// execute is the same "explicit opt-in to actually run this"
// posture the web UI's confirm-to-run action enforces -- a
// translated query never runs itself, here or there.
nlQuery string
execute bool
}
// parseQueryArgs is pure (env passed in, no I/O), same testability
// reasoning as parsePingArgs. Non-flag arguments are joined with spaces
// to form the query, so `cairnobsctl query service=api status=500` (no
// quotes, no shell-special characters) works without requiring users to
// quote every query -- though anything using "|" still needs shell
// quoting regardless, since that's a real shell pipe character otherwise.
func parseQueryArgs(args []string, env func(string) string) queryArgs {
qa := queryArgs{apiURL: resolveAPIURL(env)}
var rest []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--api":
if i+1 < len(args) {
qa.apiURL = args[i+1]
i++
}
case "--json":
qa.jsonOut = true
case "--language":
if i+1 < len(args) {
qa.language = args[i+1]
i++
}
case "--nl":
if i+1 < len(args) {
qa.nlQuery = args[i+1]
i++
}
case "--execute":
qa.execute = true
default:
rest = append(rest, args[i])
}
}
qa.query = strings.Join(rest, " ")
return qa
}
type queryRequestBody struct {
Query string `json:"query"`
Language string `json:"language"`
}
type queryResponseBody struct {
Columns []string `json:"columns"`
Rows [][]any `json:"rows"`
Warnings []string `json:"warnings"`
}
type translateRequestBody struct {
NLQuery string `json:"nlQuery"`
}
type translateResponseBody struct {
Query string `json:"query"`
Confidence string `json:"confidence"`
LowConfidenceReason string `json:"lowConfidenceReason"`
Compiles bool `json:"compiles"`
CompileError string `json:"compileError"`
Blocked bool `json:"blocked"`
CostWarnings []string `json:"costWarnings"`
}
func cmdQuery(args []string, stdout, stderr io.Writer) int {
qa := parseQueryArgs(args, os.Getenv)
if qa.nlQuery != "" {
return cmdQueryNL(qa, stdout, stderr, os.Stdin)
}
if strings.TrimSpace(qa.query) == "" {
fmt.Fprintln(stderr, "cairnobsctl query: missing query string")
return 1
}
return runAndPrintQuery(qa.apiURL, qa.query, qa.language, qa.jsonOut, stdout, stderr)
}
// cmdQueryNL implements --nl: translate, show the result, then only run
// it with explicit opt-in (--execute, or an interactive "y" confirmation
// -- never a bare unattended run). stdin is a parameter (not read from
// os.Stdin directly) so the confirmation prompt is testable the same
// way parseQueryArgs's env injection is.
func cmdQueryNL(qa queryArgs, stdout, stderr io.Writer, stdin io.Reader) int {
reqBody, err := json.Marshal(translateRequestBody{NLQuery: qa.nlQuery})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
req, err := http.NewRequest(http.MethodPost, qa.apiURL+"/ai/translate", bytes.NewReader(reqBody))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
setAuth(req, resolveToken(os.Getenv))
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(stderr, "translation failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode != http.StatusOK {
var errResp errorResponseBody
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "translation failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "translation failed: api returned status %d\n", resp.StatusCode)
}
return 1
}
var t translateResponseBody
if err := json.Unmarshal(respBody, &t); err != nil {
fmt.Fprintf(stderr, "decoding response: %v\n", err)
return 1
}
if t.Query == "" {
reason := t.LowConfidenceReason
if reason == "" {
reason = "the model did not return a query"
}
fmt.Fprintf(stdout, "No confident translation available: %s\n", reason)
return 1
}
fmt.Fprintf(stdout, "Translated query (%s confidence):\n %s\n", t.Confidence, t.Query)
if !t.Compiles {
fmt.Fprintf(stdout, "This does not parse as a valid query: %s\n", t.CompileError)
fmt.Fprintln(stdout, "Not running it -- copy, fix, and run manually if you want to use it.")
return 1
}
if len(t.CostWarnings) > 0 {
fmt.Fprintf(stdout, "Cost guard: %s\n", strings.Join(t.CostWarnings, "; "))
}
if t.Blocked {
fmt.Fprintln(stdout, "Not offered as directly runnable -- copy and adjust manually if you want to use it.")
return 1
}
if !qa.execute {
if !isInteractive(stdin) {
fmt.Fprintln(stdout, "Not running (pass --execute to run automatically, or run this interactively to confirm).")
return 0
}
if !confirmRun(stdin, stdout, "Run this query?") {
fmt.Fprintln(stdout, "Not running.")
return 0
}
}
return runAndPrintQuery(qa.apiURL, t.Query, "spl", qa.jsonOut, stdout, stderr)
}
// confirmRun prompts stdin for a y/N answer -- only "y"/"yes"
// (case-insensitive) counts as confirmation, matching the web UI's
// posture that running an AI-generated query is opt-in, never a
// default a blank Enter press could accidentally trigger.
func confirmRun(stdin io.Reader, stdout io.Writer, prompt string) bool {
fmt.Fprintf(stdout, "%s [y/N] ", prompt)
line, _ := bufio.NewReader(stdin).ReadString('\n')
answer := strings.ToLower(strings.TrimSpace(line))
return answer == "y" || answer == "yes"
}
// isInteractive reports whether stdin looks like a real terminal --
// used to decide whether a confirmation prompt makes sense at all
// (a non-interactive/piped invocation with no --execute would otherwise
// hang forever waiting for an answer nobody can give; refusing to run
// and exiting cleanly is the safe default there, matching --execute's
// own opt-in-required posture rather than silently running).
func isInteractive(stdin io.Reader) bool {
f, ok := stdin.(*os.File)
if !ok {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
func runAndPrintQuery(apiURL, query, language string, jsonOut bool, stdout, stderr io.Writer) int {
reqBody, err := json.Marshal(queryRequestBody{Query: query, Language: language})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
req, err := http.NewRequest(http.MethodPost, apiURL+"/query", bytes.NewReader(reqBody))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
setAuth(req, resolveToken(os.Getenv))
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(stderr, "query failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode != http.StatusOK {
var errResp errorResponseBody
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "query failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "query failed: api returned status %d\n", resp.StatusCode)
}
return 1
}
if jsonOut {
_, _ = stdout.Write(respBody)
fmt.Fprintln(stdout)
return 0
}
var result queryResponseBody
if err := json.Unmarshal(respBody, &result); err != nil {
fmt.Fprintf(stderr, "decoding response: %v\n", err)
return 1
}
printTable(stdout, result.Columns, result.Rows)
if len(result.Warnings) > 0 {
fmt.Fprintf(stdout, "\nWarning: %s\n", strings.Join(result.Warnings, "; "))
}
return 0
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseQueryArgsNL(t *testing.T) {
qa := parseQueryArgs([]string{"--nl", "errors in the last hour", "--execute"}, func(string) string { return "" })
if qa.nlQuery != "errors in the last hour" {
t.Errorf("nlQuery = %q", qa.nlQuery)
}
if !qa.execute {
t.Error("execute = false, want true")
}
}
func TestCmdQueryNLLowConfidenceDoesNotRun(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ai/translate" {
t.Errorf("unexpected request to %s, want only /ai/translate (never /query)", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"","confidence":"low","lowConfidenceReason":"not sure what that means"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "show me weird stuff", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Errorf("code = %d, want 1 (no confident translation)", code)
}
if !strings.Contains(stdout.String(), "not sure what that means") {
t.Errorf("stdout = %q, want the low-confidence reason", stdout.String())
}
}
func TestCmdQueryNLBlockedIsNotRunEvenWithExecute(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/query" {
t.Error("a blocked translation must never reach /query, even with --execute")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"severity=ERROR | stats count by service","confidence":"high","compiles":true,"blocked":true,"costWarnings":["no time range filter, and this query aggregates"]}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "errors by service", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Errorf("code = %d, want 1 (blocked)", code)
}
if !strings.Contains(stdout.String(), "Not offered as directly runnable") {
t.Errorf("stdout = %q", stdout.String())
}
}
func TestCmdQueryNLNonCompilingDoesNotRun(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/query" {
t.Error("a non-compiling translation must never reach /query")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"| stats count","confidence":"high","compiles":false,"compileError":"expected a filter, comparison, or search term, got PIPE"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "something odd", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Errorf("code = %d, want 1", code)
}
if !strings.Contains(stdout.String(), "does not parse") {
t.Errorf("stdout = %q", stdout.String())
}
}
func TestCmdQueryNLWithExecuteRunsTheQuery(t *testing.T) {
var sawQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/ai/translate":
w.Write([]byte(`{"query":"earliest=-1h severity=ERROR | stats count by service","confidence":"high","compiles":true,"blocked":false}`))
case "/query":
sawQuery = "called"
w.Write([]byte(`{"columns":["service","count"],"rows":[["api",5]]}`))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
}
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "errors per service in the last hour", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if sawQuery != "called" {
t.Error("expected /query to be called with --execute set")
}
if !strings.Contains(stdout.String(), "api") {
t.Errorf("stdout = %q, want the query results printed", stdout.String())
}
}
func TestCmdQueryNLWithoutExecuteNonInteractiveDoesNotRun(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/query" {
t.Error("must not run without --execute when stdin isn't a terminal")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"earliest=-1h | stats count","confidence":"high","compiles":true,"blocked":false}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "how many events", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Errorf("code = %d, want 0 (declining to run isn't a failure)", code)
}
if !strings.Contains(stdout.String(), "Not running") {
t.Errorf("stdout = %q", stdout.String())
}
}
func TestConfirmRunAcceptsY(t *testing.T) {
var stdout bytes.Buffer
if !confirmRun(strings.NewReader("y\n"), &stdout, "Run?") {
t.Error("expected 'y' to confirm")
}
}
func TestConfirmRunRejectsBlankAndOther(t *testing.T) {
var stdout bytes.Buffer
if confirmRun(strings.NewReader("\n"), &stdout, "Run?") {
t.Error("expected a blank line to NOT confirm")
}
if confirmRun(strings.NewReader("sure\n"), &stdout, "Run?") {
t.Error("expected an unrecognized answer to NOT confirm")
}
}
+205
View File
@@ -0,0 +1,205 @@
// Command surface for api/localauth -- single-tenant mode's local
// username/password login and user manager (see /docs -- deployment
// runbook, and api/localauth's package doc comment for the full
// feature). Same list/create/delete shape as agents/dashboards, plus a
// "login" subcommand: unlike every other resource this CLI manages,
// there's no way to get a first CAIRNOBSCTL_TOKEN without one.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "cairnobsctl users: expected a subcommand (login, list, create, delete, reset-password)")
return 1
}
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
token := resolveToken(os.Getenv)
switch args[0] {
case "login":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl users login: missing username")
return 1
}
return cmdUsersLogin(rest[0], rest[1:], apiURL, stdin, stdout, stderr)
case "list":
return httpGetJSON(apiURL, "/auth/users", token, stdout, stderr)
case "create":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl users create: missing username")
return 1
}
return cmdUsersCreate(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
case "delete":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl users delete: missing user id")
return 1
}
return httpMutateNoBody(http.MethodDelete, apiURL, "/auth/users/"+rest[0], token, "", "user deleted", stdout, stderr)
case "reset-password":
if len(rest) == 0 {
fmt.Fprintln(stderr, "cairnobsctl users reset-password: missing user id")
return 1
}
return cmdUsersResetPassword(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
default:
fmt.Fprintf(stderr, "cairnobsctl users: unknown subcommand %q (want login, list, create, delete, reset-password)\n", args[0])
return 1
}
}
// extractPasswordStdinFlag pulls the boolean --password-stdin flag out
// of args if present -- same "walk args, splice out the one flag this
// caller cares about" shape extractAPIFlag already uses at the
// top-level dispatch layer. Unlike the --password <value> flag this
// replaced (security-audit finding L-4), this flag never carries the
// secret itself -- only readPasswordFromStdin's caller decides to
// actually read one, same "docker login --password-stdin" convention,
// chosen over inventing a new one: a plaintext password passed as a CLI
// argument is visible to any other local user via `ps`/
// `/proc/<pid>/cmdline` and typically lands in shell history too.
func extractPasswordStdinFlag(args []string) (useStdin bool, rest []string) {
for _, a := range args {
if a == "--password-stdin" {
useStdin = true
continue
}
rest = append(rest, a)
}
return useStdin, rest
}
// readPasswordFromStdin reads a single line from stdin. Not masked
// (this codebase has no terminal/raw-mode dependency to draw on -- see
// resolveToken's doc comment for the same tradeoff already accepted for
// CAIRNOBSCTL_TOKEN); pipe the value in (`echo "$PW" | cairnobsctl users
// login admin`) rather than typing it at an interactive terminal where
// that matters.
func readPasswordFromStdin(stdin io.Reader) (string, error) {
line, err := bufio.NewReader(stdin).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
}
type loginRequestBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponseBody struct {
Token string `json:"token"`
Error string `json:"error"`
}
// cmdUsersLogin prints only the raw token to stdout on success (nothing
// else) -- deliberately pipeable: `export CAIRNOBSCTL_TOKEN=$(cairnobsctl
// users login admin)`.
func cmdUsersLogin(username string, _ []string, apiURL string, stdin io.Reader, stdout, stderr io.Writer) int {
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
body, err := json.Marshal(loginRequestBody{Username: username, Password: password})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
req, err := http.NewRequest(http.MethodPost, apiURL+"/auth/login", strings.NewReader(string(body)))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
var login loginResponseBody
_ = json.Unmarshal(respBody, &login)
if resp.StatusCode != http.StatusOK {
if login.Error != "" {
fmt.Fprintf(stderr, "login failed: %s\n", login.Error)
} else {
fmt.Fprintf(stderr, "login failed: status %d\n", resp.StatusCode)
}
return 1
}
fmt.Fprintln(stdout, login.Token)
return 0
}
func cmdUsersCreate(username string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int {
role := "editor"
for i := 0; i < len(flagArgs); i++ {
if flagArgs[i] == "--role" && i+1 < len(flagArgs) {
role = flagArgs[i+1]
i++
continue
}
}
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
body, err := json.Marshal(struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}{Username: username, Password: password, Role: role})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
return httpPostJSON(apiURL, "/auth/users", token, string(body), stdout, stderr)
}
// cmdUsersResetPassword defaults to requesting a server-generated
// random password (empty body -- see api/localauth's handleResetPassword
// doc comment): pass --password-stdin to instead set a specific password
// read from stdin. There is deliberately no --password <value> flag (see
// extractPasswordStdinFlag's doc comment) -- a specific password chosen
// this way must be piped in, never typed as a bare CLI argument.
func cmdUsersResetPassword(id string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int {
useStdin, _ := extractPasswordStdinFlag(flagArgs)
body := "{}"
if useStdin {
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
encoded, err := json.Marshal(struct {
Password string `json:"password"`
}{Password: password})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
body = string(encoded)
}
return httpPostJSON(apiURL, "/auth/users/"+id+"/reset-password", token, body, stdout, stderr)
}
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCmdUsersMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdUsers(nil, strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdUsersLoginPrintsOnlyTheToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/auth/login" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body loginRequestBody
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Username != "admin" || body.Password != "s3cret!!" {
t.Errorf("unexpected credentials: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token":"abc123","user_id":"u1","username":"admin","role":"owner"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("s3cret!!\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if got := strings.TrimSpace(stdout.String()); got != "abc123" {
t.Fatalf("stdout = %q, want exactly the raw token (pipeable into CAIRNOBSCTL_TOKEN)", got)
}
}
func TestCmdUsersLoginFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"invalid username or password"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("wrong\n"), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "invalid username or password") {
t.Fatalf("stderr = %q, want it to surface the server's error message", stderr.String())
}
if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty on failure (nothing pipeable into CAIRNOBSCTL_TOKEN)", stdout.String())
}
}
func TestCmdUsersCreateSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/auth/users" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Role != "viewer" {
t.Errorf("role = %q, want viewer (from --role)", body.Role)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"u2","username":"bob","role":"viewer"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"create", "bob", "--role", "viewer", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "bob") {
t.Fatalf("stdout = %q, want it to contain the created user", stdout.String())
}
}
func TestCmdUsersCreateDefaultsRoleToEditor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Role string `json:"role"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Role != "editor" {
t.Errorf("role = %q, want editor (the default when --role is omitted)", body.Role)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"u2","username":"bob","role":"editor"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"create", "bob", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
func TestCmdUsersDeleteMissingID(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"delete"}, strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdUsersDeleteSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/auth/users/u2" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"delete", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
func TestCmdUsersResetPasswordWithGeneratedPassword(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/users/u2/reset-password" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if strings.TrimSpace(string(body)) != "{}" {
t.Errorf("body = %q, want {} (no --password-stdin supplied)", body)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"password":"generated-abc"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"reset-password", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "generated-abc") {
t.Fatalf("stdout = %q, want it to contain the generated password", stdout.String())
}
}
// TestCmdUsersResetPasswordWithStdinPassword is the regression test for
// the security-audit finding that this CLI accepted a plaintext
// --password <value> flag (visible via `ps`/shell history). Setting a
// specific password must go through --password-stdin plus piped input
// instead, never a bare argument.
func TestCmdUsersResetPasswordWithStdinPassword(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Password != "a-specific-password" {
t.Errorf("password = %q, want the value piped via stdin", body.Password)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"reset-password", "u2", "--password-stdin", "--api", srv.URL}, strings.NewReader("a-specific-password\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
// setAuth attaches CAIRNOBSCTL_TOKEN (see resolveToken) as a Bearer
// credential, a no-op when token is empty -- matches every backend's
// nil-authorizer no-op default (see api/internal/authz.RequireRole*).
func setAuth(req *http.Request, token string) {
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
// httpGetJSON GETs path and prints the pretty-printed JSON response to
// stdout, or the error body/status to stderr. Shared by dashboards/alerts
// list and get, which otherwise differ only in path and resource name.
func httpGetJSON(baseURL, path, token string, stdout, stderr io.Writer) int {
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
setAuth(req, token)
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
return printJSONResponse(resp, stdout, stderr)
}
// httpPostFileJSON reads file (a JSON document, e.g. an exported
// dashboard or a rule definition) and POSTs it to path as-is -- no
// reshaping, since the file's shape already matches what the endpoint
// expects (the same JSON the web UI's export button and POST /rules
// produce/accept respectively). This is what makes "apply" the seed of a
// future Terraform provider: one JSON contract, multiple callers.
func httpPostFileJSON(baseURL, path, token, file string, stdout, stderr io.Writer) int {
body, err := os.ReadFile(file)
if err != nil {
fmt.Fprintf(stderr, "reading %s: %v\n", file, err)
return 1
}
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
setAuth(req, token)
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
return printJSONResponse(resp, stdout, stderr)
}
// httpMutateNoBody sends method to path with an optional JSON body
// ("" for none, e.g. DELETE) and expects a 2xx with no meaningful
// response body -- PUT/DELETE /dashboards/{id}/permissions/{userId}
// both respond 204 No Content, so there's nothing for printJSONResponse
// to pretty-print here. Prints successMsg to stdout on success, the
// same {"error": "..."} parsing every other helper in this file uses
// otherwise.
func httpMutateNoBody(method, baseURL, path, token, body, successMsg string, stdout, stderr io.Writer) int {
var reqBody io.Reader
if body != "" {
reqBody = strings.NewReader(body)
}
req, err := http.NewRequest(method, baseURL+path, reqBody)
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
setAuth(req, token)
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
var errResp errorResponseBody
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "request failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "request failed: status %d\n", resp.StatusCode)
}
return 1
}
fmt.Fprintln(stdout, successMsg)
return 0
}
// httpPutJSON PUTs body (already-encoded JSON) to path and prints the
// pretty-printed JSON response -- same shape as httpPostFileJSON, but
// for callers that construct the body themselves rather than reading it
// from a file (agents config set, agents restart).
func httpPutJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int {
return httpSendJSON(http.MethodPut, baseURL, path, token, body, stdout, stderr)
}
// httpPostJSON is httpPutJSON's POST sibling -- for callers creating a
// resource from a body they built themselves rather than reading it
// from a file (users create, users reset-password).
func httpPostJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int {
return httpSendJSON(http.MethodPost, baseURL, path, token, body, stdout, stderr)
}
func httpSendJSON(method, baseURL, path, token, body string, stdout, stderr io.Writer) int {
req, err := http.NewRequest(method, baseURL+path, strings.NewReader(body))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
setAuth(req, token)
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
return printJSONResponse(resp, stdout, stderr)
}
func printJSONResponse(resp *http.Response, stdout, stderr io.Writer) int {
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errResp errorResponseBody
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "request failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "request failed: status %d\n", resp.StatusCode)
}
return 1
}
var pretty bytes.Buffer
if json.Indent(&pretty, body, "", " ") == nil {
stdout.Write(pretty.Bytes())
} else {
stdout.Write(body)
}
fmt.Fprintln(stdout)
return 0
}
+181
View File
@@ -0,0 +1,181 @@
// Command cairnobsctl is Cairn OBS's control CLI. Six subcommands now
// (ping, query, dashboards, alerts) clearly justify splitting dispatch
// across files -- see cli/README.md's "revisit once there's a real
// command tree" note -- while keeping the same hand-rolled switch on
// os.Args, no CLI framework, per that same README.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
)
const (
defaultAPIURL = "http://localhost:8080"
defaultAlertingURL = "http://localhost:8081"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
usage(stderr)
return 1
}
switch args[0] {
case "ping":
return cmdPing(args[1:], stdout, stderr)
case "query":
return cmdQuery(args[1:], stdout, stderr)
case "dashboards":
return cmdDashboards(args[1:], stdout, stderr)
case "alerts":
return cmdAlerts(args[1:], stdout, stderr)
case "agents":
return cmdAgents(args[1:], stdout, stderr)
case "users":
return cmdUsers(args[1:], os.Stdin, stdout, stderr)
case "-h", "--help", "help":
usage(stdout)
return 0
default:
fmt.Fprintf(stderr, "cairnobsctl: unknown command %q\n", args[0])
usage(stderr)
return 1
}
}
func usage(w io.Writer) {
fmt.Fprintln(w, `cairnobsctl: Cairn OBS control CLI
Usage:
cairnobsctl ping [--api <url>]
cairnobsctl query "<query>" [--api <url>] [--language sql|spl] [--json]
cairnobsctl dashboards list|get <id>|apply <file> [--api <url>]
cairnobsctl dashboards permissions list <dashboard-id> [--api <url>]
cairnobsctl dashboards permissions grant <dashboard-id> <user-id> viewer|editor [--api <url>]
cairnobsctl dashboards permissions revoke <dashboard-id> <user-id> [--api <url>]
cairnobsctl alerts list|get <id>|apply <file> [--alerting-api <url>]
cairnobsctl agents list|get <host> [--api <url>]
cairnobsctl agents config get <host>|clear <host> [--api <url>]
cairnobsctl agents config set <host> [--batch-max-size N] [--batch-flush-interval-ms N]
[--heartbeat-enabled true|false] [--heartbeat-interval-ms N]
[--journald-unit UNIT] [--api <url>]
cairnobsctl agents restart <host> [--yes] [--api <url>]
cairnobsctl users login <username> [--password <pw>] [--api <url>]
cairnobsctl users list [--api <url>]
cairnobsctl users create <username> [--password <pw>] [--role viewer|editor|admin|owner] [--api <url>]
cairnobsctl users delete <id> [--api <url>]
cairnobsctl users reset-password <id> [--password <pw>] [--api <url>]
Commands:
ping Checks that the api service is reachable via GET /healthz.
query Runs a query (pipe syntax or SQL) against POST /query and
prints the result as a table, or as JSON with --json. Quote
the query in your shell -- pipe syntax uses "|", which your
shell will otherwise interpret itself.
dashboards list/get/apply against api's dashboard CRUD endpoints.
"apply <file>" imports a dashboard exported via the web
UI's Export JSON button or GET /dashboards/{id}/export --
the same JSON shape both places, Terraform-friendly.
"permissions" grants/revokes/lists per-resource dashboard
access (a Phase 4, enterprise-api-only feature -- a 501 on
plain api means no enterprise permission service is wired
in on this deployment, not a client error). A grant only
ever raises someone to viewer or editor on one dashboard;
Admin/Owner already have tenant-wide access.
alerts list/get/apply against alerting's rule CRUD endpoints.
"apply <file>" creates a rule from a JSON file with the
same shape POST /rules accepts.
agents Agent inventory, remote config, and lifecycle commands
(see /docs/agent-management-design.md). "config set" reads
the agent's current effective config first and PUTs back
the complete merged override -- only the fields you pass
change, everything else carries forward unchanged, same
as the web UI's edit form. "restart" briefly interrupts
log collection on that host and prompts for confirmation
unless --yes is given.
users Local username/password login and user management (see
api/localauth -- only meaningful on a deployment with
LOCAL_AUTH_ENABLED set; a 404 on any of these means it
isn't). "login" is the only command that works with no
$CAIRNOBSCTL_TOKEN set yet -- it prints just the raw token
to stdout: `+"`export CAIRNOBSCTL_TOKEN=$(cairnobsctl users login admin)`"+`.
--password (on any users subcommand) is read from stdin
if omitted -- same shell-history/ps caveat as typing a
credential in any flag, prefer piping it in.
"create"/"list"/"delete"/"reset-password" require an
owner-role token (see RegisterRoutes in api/localauth).
--api defaults to $CAIRNOBSCTL_API_URL, or `+defaultAPIURL+` if unset.
--alerting-api defaults to $CAIRNOBSCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset.
--language overrides auto-detection; omit it for the common case.
$CAIRNOBSCTL_TOKEN, if set, is sent as "Authorization: Bearer <token>" on
every request -- required once a deployment configures enterprise-auth
(see /docs/phase-4-rbac-design.md). No flag equivalent, deliberately:
unlike --api, a credential shouldn't be typed where shell history or
`+"`ps`"+` output can capture it.`)
}
func resolveAPIURL(env func(string) string) string {
if v := env("CAIRNOBSCTL_API_URL"); v != "" {
return v
}
return defaultAPIURL
}
func resolveAlertingURL(env func(string) string) string {
if v := env("CAIRNOBSCTL_ALERTING_API_URL"); v != "" {
return v
}
return defaultAlertingURL
}
// resolveToken reads the RoleService/human bearer credential cairnobsctl
// presents to api/alerting once enterprise-auth enforcement is turned
// on (api/internal/authz.RequireRole*) -- empty by default, matching
// every other Phase 0-3 client's nil-authorizer no-op behavior.
func resolveToken(env func(string) string) string {
return env("CAIRNOBSCTL_TOKEN")
}
type errorResponseBody struct {
Error string `json:"error"`
}
func printTable(w io.Writer, columns []string, rows [][]any) {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(columns, "\t"))
for _, row := range rows {
cells := make([]string, len(row))
for i, v := range row {
cells[i] = formatCell(v)
}
fmt.Fprintln(tw, strings.Join(cells, "\t"))
}
_ = tw.Flush()
fmt.Fprintf(w, "(%d row(s))\n", len(rows))
}
func formatCell(v any) string {
switch t := v.(type) {
case nil:
return ""
case map[string]any, []any:
b, err := json.Marshal(t)
if err != nil {
return fmt.Sprintf("%v", t)
}
return string(b)
default:
return fmt.Sprintf("%v", t)
}
}
+250
View File
@@ -0,0 +1,250 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParsePingArgsDefault(t *testing.T) {
env := func(string) string { return "" }
if got := parsePingArgs(nil, env); got != defaultAPIURL {
t.Errorf("got %q, want %q", got, defaultAPIURL)
}
}
func TestParsePingArgsFromEnv(t *testing.T) {
env := func(k string) string {
if k == "CAIRNOBSCTL_API_URL" {
return "http://env-host:1234"
}
return ""
}
if got := parsePingArgs(nil, env); got != "http://env-host:1234" {
t.Errorf("got %q, want env value", got)
}
}
func TestParsePingArgsFlagOverridesEnv(t *testing.T) {
env := func(k string) string {
if k == "CAIRNOBSCTL_API_URL" {
return "http://env-host:1234"
}
return ""
}
got := parsePingArgs([]string{"--api", "http://flag-host:5678"}, env)
if got != "http://flag-host:5678" {
t.Errorf("got %q, want flag value", got)
}
}
func TestCmdPingSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/healthz" {
t.Errorf("unexpected path %q", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%s", code, stderr.String())
}
if strings.TrimSpace(stdout.String()) != "ok" {
t.Fatalf("stdout = %q, want ok", stdout.String())
}
}
func TestCmdPingNonOKStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "503") {
t.Fatalf("stderr = %q, want it to mention the status code", stderr.String())
}
}
func TestCmdPingUnreachable(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdPing([]string{"--api", "http://127.0.0.1:1"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
}
func TestRunNoArgsPrintsUsageAndFails(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(nil, &stdout, &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "Usage:") {
t.Fatalf("stderr should contain usage text, got %q", stderr.String())
}
}
func TestRunUnknownCommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"bogus"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "bogus") {
t.Fatalf("stderr should mention the unknown command, got %q", stderr.String())
}
}
func TestRunHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"help"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0", code)
}
if !strings.Contains(stdout.String(), "Usage:") {
t.Fatalf("stdout should contain usage text, got %q", stdout.String())
}
}
func TestParseQueryArgsJoinsNonFlagArgsAsQuery(t *testing.T) {
env := func(string) string { return "" }
qa := parseQueryArgs([]string{"service=api", "status=500"}, env)
if qa.query != "service=api status=500" {
t.Errorf("query = %q", qa.query)
}
if qa.apiURL != defaultAPIURL {
t.Errorf("apiURL = %q, want default", qa.apiURL)
}
if qa.jsonOut {
t.Error("jsonOut should default to false")
}
}
func TestParseQueryArgsFlags(t *testing.T) {
env := func(string) string { return "" }
qa := parseQueryArgs([]string{"--api", "http://h:1", "--language", "sql", "--json", "SELECT", "1"}, env)
if qa.apiURL != "http://h:1" {
t.Errorf("apiURL = %q", qa.apiURL)
}
if qa.language != "sql" {
t.Errorf("language = %q", qa.language)
}
if !qa.jsonOut {
t.Error("expected jsonOut = true")
}
if qa.query != "SELECT 1" {
t.Errorf("query = %q", qa.query)
}
}
func TestCmdQueryMissingQueryErrors(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdQuery(nil, &stdout, &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "missing query") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestCmdQueryTableOutput(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/query" {
t.Errorf("unexpected path %q", r.URL.Path)
}
var body queryRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decoding request: %v", err)
}
if body.Query != "service=api" {
t.Errorf("query = %q", body.Query)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(queryResponseBody{
Columns: []string{"host", "count"},
Rows: [][]any{{"h1", float64(3)}},
})
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--api", srv.URL, "service=api"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%s", code, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "host") || !strings.Contains(out, "h1") || !strings.Contains(out, "(1 row(s))") {
t.Fatalf("unexpected table output: %q", out)
}
}
func TestCmdQueryJSONOutput(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(queryResponseBody{Columns: []string{"c"}, Rows: [][]any{{"v"}}})
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--api", srv.URL, "--json", "service=api"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%s", code, stderr.String())
}
var got queryResponseBody
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not valid JSON: %v; got %q", err, stdout.String())
}
if len(got.Columns) != 1 || got.Columns[0] != "c" {
t.Fatalf("unexpected JSON output: %+v", got)
}
}
func TestCmdQueryServerErrorPrintsMessage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(errorResponseBody{Error: "only SELECT queries are allowed"})
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--api", srv.URL, "DELETE FROM logs", "--language", "sql"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "only SELECT queries are allowed") {
t.Fatalf("stderr = %q, want it to include the server's error message", stderr.String())
}
}
func TestFormatCellHandlesNilMapAndSlice(t *testing.T) {
if got := formatCell(nil); got != "" {
t.Errorf("formatCell(nil) = %q, want empty", got)
}
if got := formatCell(map[string]any{"a": "b"}); got != `{"a":"b"}` {
t.Errorf("formatCell(map) = %q", got)
}
if got := formatCell(42.0); got != "42" {
t.Errorf("formatCell(42.0) = %q", got)
}
}