408 lines
13 KiB
Go
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
|
|
}
|