Files
cairnobs/cli/cmd/sentryctl/main.go
T
jcoffey-dev 3cf1320881 Add sentryctl dashboards permissions list|grant|revoke
PUT/DELETE /dashboards/{id}/permissions/{userId} (per-resource dashboard
grants, built earlier this phase) had no caller but Go tests and curl --
named as a real, disclosed gap in docs/phase-4-runbook.md. Adds a CLI
surface: sentryctl dashboards permissions list/grant/revoke, following
the existing dashboards subcommand pattern.

grant/revoke needed a new httpclient.go helper (httpMutateNoBody) since
both endpoints respond 204 No Content -- the existing helpers all expect
a JSON body to pretty-print. grant validates the role client-side
(viewer/editor only, mirroring api/dashboards.validGrantRole) before
making a request, since Admin/Owner already have tenant-wide dashboard
access and a resource-level grant can never raise someone past Editor.

Verified with real httptest.Server round trips (method, path, request
body, and error-body parsing on a 501 from a deployment with no
enterprise permission service wired in) -- the same pattern every other
sentryctl subcommand's tests already use, no fake/mock client needed
since sentryctl itself is just an HTTP client with no store of its own.
2026-08-14 23:09:33 -07:00

148 lines
4.7 KiB
Go

// Command sentryctl is Sentry'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 "-h", "--help", "help":
usage(stdout)
return 0
default:
fmt.Fprintf(stderr, "sentryctl: unknown command %q\n", args[0])
usage(stderr)
return 1
}
}
func usage(w io.Writer) {
fmt.Fprintln(w, `sentryctl: Sentry control CLI
Usage:
sentryctl ping [--api <url>]
sentryctl query "<query>" [--api <url>] [--language sql|spl] [--json]
sentryctl dashboards list|get <id>|apply <file> [--api <url>]
sentryctl dashboards permissions list <dashboard-id> [--api <url>]
sentryctl dashboards permissions grant <dashboard-id> <user-id> viewer|editor [--api <url>]
sentryctl dashboards permissions revoke <dashboard-id> <user-id> [--api <url>]
sentryctl alerts list|get <id>|apply <file> [--alerting-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.
--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.
--alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset.
--language overrides auto-detection; omit it for the common case.
$SENTRYCTL_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("SENTRYCTL_API_URL"); v != "" {
return v
}
return defaultAPIURL
}
func resolveAlertingURL(env func(string) string) string {
if v := env("SENTRYCTL_ALERTING_API_URL"); v != "" {
return v
}
return defaultAlertingURL
}
// resolveToken reads the RoleService/human bearer credential sentryctl
// 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("SENTRYCTL_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)
}
}