Files
kandev/apps/backend/internal/sentry/service.go
T
2026-06-15 18:54:16 +01:00

408 lines
13 KiB
Go

package sentry
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/watchreset"
)
// SecretStore is the subset of the secrets store the service needs.
type SecretStore interface {
Reveal(ctx context.Context, id string) (string, error)
Set(ctx context.Context, id, name, value string) error
Delete(ctx context.Context, id string) error
Exists(ctx context.Context, id string) (bool, error)
}
// Service orchestrates Sentry config storage, the cached client, and the
// browse operations used by the HTTP handlers.
type Service struct {
store *Store
secrets SecretStore
log *logger.Logger
mu sync.Mutex
clientFn ClientFactory
client Client
// clientGen is incremented by invalidateClient each time the cached client
// is discarded. clientFor captures it before I/O and only stores a newly
// built client when the value is unchanged, preventing a stale client from
// clobbering a concurrent invalidation.
clientGen uint64
probeHook func()
// mockClient is non-nil only when Provide built the service with a MockClient.
mockClient *MockClient
// eventBus is wired by SetEventBus so the poller can publish
// NewSentryIssueEvent. Optional: when nil, observed issues are not
// surfaced to the orchestrator.
eventBus bus.EventBus
// taskDeleter is the cascade-delete entry point used by ResetIssueWatch.
// Wired post-construction via SetTaskDeleter to avoid an import cycle
// with the task service.
taskDeleter watchreset.TaskDeleter
}
// SetTaskDeleter wires the cascade-delete dependency used by ResetIssueWatch.
// Optional — when unset, reset returns an error so the missing wiring is
// surfaced instead of silently no-op'ing.
func (s *Service) SetTaskDeleter(td watchreset.TaskDeleter) {
s.mu.Lock()
defer s.mu.Unlock()
s.taskDeleter = td
}
// MockClient returns the shared mock client when the service was built in mock
// mode, or nil for production builds.
func (s *Service) MockClient() *MockClient {
return s.mockClient
}
// ClientFactory builds a Client for the given config + secret. Overridable so
// tests can inject fakes without touching HTTP.
type ClientFactory func(cfg *SentryConfig, secret string) Client
// DefaultClientFactory returns a real RESTClient.
func DefaultClientFactory(cfg *SentryConfig, secret string) Client {
return NewRESTClient(cfg, secret)
}
// NewService wires the service. Pass nil for clientFn to use the default.
func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service {
if clientFn == nil {
clientFn = DefaultClientFactory
}
return &Service{
store: store,
secrets: secrets,
log: log,
clientFn: clientFn,
}
}
// Store exposes the underlying store so background workers can persist state.
func (s *Service) Store() *Store {
return s.store
}
// GetConfig returns the singleton config enriched with a HasSecret flag.
func (s *Service) GetConfig(ctx context.Context) (*SentryConfig, error) {
cfg, err := s.store.GetConfig(ctx)
if err != nil || cfg == nil {
return cfg, err
}
if s.secrets == nil {
return cfg, nil
}
exists, existsErr := s.secrets.Exists(ctx, SecretKey)
if existsErr != nil {
s.log.Warn("sentry: secret exists check failed", zap.Error(existsErr))
}
cfg.HasSecret = exists
return cfg, nil
}
// ErrInvalidConfig is returned by SetConfig when the request fails validation.
var ErrInvalidConfig = errors.New("sentry: invalid configuration")
// SetConfig is upsert. An empty Secret on update keeps the existing token.
func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SentryConfig, error) {
if err := validateConfigRequest(req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error())
}
cfg := &SentryConfig{AuthMethod: req.AuthMethod, URL: req.URL}
if err := s.store.UpsertConfig(ctx, cfg); err != nil {
return nil, fmt.Errorf("upsert sentry config: %w", err)
}
if req.Secret != "" && s.secrets != nil {
if err := s.secrets.Set(ctx, SecretKey, "Sentry auth token", req.Secret); err != nil {
return nil, fmt.Errorf("store sentry secret: %w", err)
}
}
s.invalidateClient()
// Probe asynchronously so a slow Sentry doesn't stall the save response.
go func() {
s.RecordAuthHealth(context.Background())
}()
return s.GetConfig(ctx)
}
// DeleteConfig removes both the config row and the stored secret.
func (s *Service) DeleteConfig(ctx context.Context) error {
if err := s.store.DeleteConfig(ctx); err != nil {
return err
}
if s.secrets != nil {
if err := s.secrets.Delete(ctx, SecretKey); err != nil {
s.log.Warn("sentry: secret delete failed", zap.Error(err))
}
}
s.invalidateClient()
return nil
}
// TestConnection validates credentials either from a fresh SetConfigRequest
// (before persisting) or from the stored config (after saving).
func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) {
cfg, secret, err := s.resolveCredentials(ctx, req)
if err != nil {
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
}
client := s.clientFn(cfg, secret)
return client.TestAuth(ctx)
}
// ProbeAuth validates the stored credentials.
func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) {
client, err := s.clientFor(ctx)
if err != nil {
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
}
return client.TestAuth(ctx)
}
// authProbeTimeout caps a single auth-health probe.
const authProbeTimeout = 15 * time.Second
// authHealthWriteTimeout bounds the DB write that persists the probe outcome.
const authHealthWriteTimeout = 5 * time.Second
// SetProbeHook installs a callback fired at the end of each RecordAuthHealth
// call. Production code never sets this; tests use it to synchronise on probe
// completion without sleep-polling.
func (s *Service) SetProbeHook(fn func()) {
s.mu.Lock()
defer s.mu.Unlock()
s.probeHook = fn
}
// RecordAuthHealth probes credentials and writes the outcome onto the row.
func (s *Service) RecordAuthHealth(ctx context.Context) {
probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout)
defer cancel()
res, err := s.ProbeAuth(probeCtx)
ok := err == nil && res != nil && res.OK
errMsg := ""
switch {
case err != nil:
errMsg = err.Error()
case res != nil && !res.OK:
errMsg = res.Error
}
// Detach the DB write from ctx so a probe that exhausted its deadline can
// still record the failure.
writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
defer writeCancel()
if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, time.Now().UTC()); updateErr != nil {
s.log.Warn("sentry: update auth health failed", zap.Error(updateErr))
}
s.mu.Lock()
hook := s.probeHook
s.mu.Unlock()
if hook != nil {
hook()
}
}
// ListOrganizations returns the organizations the stored token can access.
func (s *Service) ListOrganizations(ctx context.Context) ([]SentryOrganization, error) {
client, err := s.clientFor(ctx)
if err != nil {
return nil, err
}
return client.ListOrganizations(ctx)
}
// ListProjects returns the projects the stored token can access.
func (s *Service) ListProjects(ctx context.Context) ([]SentryProject, error) {
client, err := s.clientFor(ctx)
if err != nil {
return nil, err
}
return client.ListProjects(ctx)
}
// SearchIssues runs a filtered search. The caller supplies the org/project to
// search — there is no install-wide default to fall back on.
func (s *Service) SearchIssues(ctx context.Context, filter SearchFilter, cursor string) (*SearchResult, error) {
client, err := s.clientFor(ctx)
if err != nil {
return nil, err
}
return client.SearchIssues(ctx, filter, cursor)
}
// GetIssue loads a single issue by short ID or numeric ID.
func (s *Service) GetIssue(ctx context.Context, idOrShortID string) (*SentryIssue, error) {
client, err := s.clientFor(ctx)
if err != nil {
return nil, err
}
return client.GetIssue(ctx, idOrShortID)
}
// clientFor returns the cached client, creating it if needed.
// It captures the generation counter before releasing the lock for I/O so
// that a concurrent invalidateClient call during the build window is not
// silently overwritten: if the counter changed the freshly built client is
// returned to the caller without being cached, and the next call rebuilds
// with the updated config.
func (s *Service) clientFor(ctx context.Context) (Client, error) {
s.mu.Lock()
if s.client != nil {
c := s.client
s.mu.Unlock()
return c, nil
}
gen := s.clientGen
s.mu.Unlock()
cfg, err := s.store.GetConfig(ctx)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, ErrNotConfigured
}
secret := ""
if s.secrets != nil {
secret, err = s.secrets.Reveal(ctx, SecretKey)
if err != nil {
return nil, fmt.Errorf("read sentry secret: %w", err)
}
if secret == "" {
return nil, ErrNotConfigured
}
}
client := s.clientFn(cfg, secret)
s.mu.Lock()
defer s.mu.Unlock()
if s.client != nil {
// Another goroutine already cached a client; use that one.
return s.client, nil
}
if s.clientGen != gen {
// invalidateClient ran while we were doing I/O — the config/secret we
// read is now stale. Return the client to the caller for this call only;
// the next call will rebuild with fresh credentials.
return client, nil
}
s.client = client
return client, nil
}
// invalidateClient drops the cached client so the next request rebuilds it.
// The generation counter is incremented so a concurrent clientFor build does
// not restore a client built from the now-stale config.
func (s *Service) invalidateClient() {
s.mu.Lock()
s.client = nil
s.clientGen++
s.mu.Unlock()
}
// resolveCredentials picks credentials for a test: inline if the request
// carries a secret, otherwise the stored secret.
func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*SentryConfig, string, error) {
cfg := &SentryConfig{AuthMethod: req.AuthMethod, URL: normalizeSentryURL(req.URL)}
if req.Secret != "" {
if cfg.URL == "" {
cfg.URL = DefaultSentryURL
}
return cfg, req.Secret, nil
}
if s.secrets == nil {
return nil, "", errors.New("no secret store configured")
}
secret, err := s.secrets.Reveal(ctx, SecretKey)
if err != nil {
s.log.Warn("sentry: secret reveal failed", zap.Error(err))
return nil, "", fmt.Errorf("read sentry secret: %w", err)
}
if secret == "" {
return nil, "", errors.New("no auth token stored — paste one to test")
}
stored, storeErr := s.store.GetConfig(ctx)
if storeErr != nil {
s.log.Warn("sentry: load stored config for credential resolution failed", zap.Error(storeErr))
}
if stored != nil {
if cfg.AuthMethod == "" {
cfg.AuthMethod = stored.AuthMethod
}
if cfg.URL == "" {
cfg.URL = stored.URL
}
}
if cfg.URL == "" {
cfg.URL = DefaultSentryURL
}
return cfg, secret, nil
}
// validateConfigRequest normalizes and validates a config request in place: it
// defaults a blank auth method and instance URL, rejects unknown auth methods,
// and rejects URLs the HTTP client could not use (see validateSentryURL).
func validateConfigRequest(req *SetConfigRequest) error {
if req.AuthMethod == "" {
req.AuthMethod = AuthMethodAuthToken
}
if req.AuthMethod != AuthMethodAuthToken {
return fmt.Errorf("unknown auth method: %q", req.AuthMethod)
}
req.URL = normalizeSentryURL(req.URL)
if req.URL == "" {
req.URL = DefaultSentryURL
}
return validateSentryURL(req.URL)
}
// normalizeSentryURL trims whitespace and trailing slashes and prepends
// https:// when the user typed only a host (e.g. "sentry.acme.com"). Without a
// scheme the Go HTTP client fails with "unsupported protocol scheme". An empty
// input is returned as-is so callers can apply the sentry.io default.
func normalizeSentryURL(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimRight(s, "/")
if s == "" {
return s
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
return s
}
// validateSentryURL rejects an instance URL the HTTP client could not use: it
// must parse, carry an http/https scheme, name a host, and be a bare host root.
// A path/query/fragment is rejected because apiPathPrefix ("/api/0") is later
// appended by string concatenation — e.g. "https://host?x=1" would otherwise
// become the malformed base "https://host?x=1/api/0".
func validateSentryURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid instance URL: %s", err.Error())
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("instance URL must use http or https: %q", raw)
}
if u.Host == "" {
return fmt.Errorf("instance URL must include a host: %q", raw)
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("instance URL must be a host root without a path: %q", raw)
}
if u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("instance URL must not include a query or fragment: %q", raw)
}
return nil
}