184 lines
6.8 KiB
Go
184 lines
6.8 KiB
Go
package sentry
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ErrSameWorkspace is returned when a copy targets the same workspace it reads
|
|
// from.
|
|
var ErrSameWorkspace = errors.New("sentry: source and target workspaces are the same")
|
|
|
|
// ErrNothingToCopy is returned when the source workspace has no Sentry
|
|
// instances.
|
|
var ErrNothingToCopy = errors.New("sentry: source workspace has no Sentry instances to copy")
|
|
|
|
type copyInput struct {
|
|
config *SentryConfig
|
|
secret string
|
|
hasSecret bool
|
|
}
|
|
|
|
// CopyConfigToWorkspace copies every Sentry instance from sourceWorkspaceID
|
|
// into targetWorkspaceID: fresh instance IDs, secrets duplicated under the new
|
|
// per-instance keys, and names deduped against the target's existing instances.
|
|
// Issue watches are intentionally out of scope — only the connection settings
|
|
// and secrets are duplicated. Returns the newly created instances.
|
|
func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) ([]*SentryConfig, error) {
|
|
if err := validateCopyWorkspaces(sourceWorkspaceID, targetWorkspaceID); err != nil {
|
|
return nil, err
|
|
}
|
|
prepared, used, err := s.prepareCopyInputs(ctx, sourceWorkspaceID, targetWorkspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
copied, err := s.createCopiedInstances(ctx, targetWorkspaceID, prepared, used)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.finalizeCopiedInstances(copied)
|
|
return copied, nil
|
|
}
|
|
|
|
// validateCopyWorkspaces rejects empty or identical source/target workspace
|
|
// IDs before any store access.
|
|
func validateCopyWorkspaces(sourceWorkspaceID, targetWorkspaceID string) error {
|
|
if sourceWorkspaceID == "" || targetWorkspaceID == "" {
|
|
return fmt.Errorf("%w: source and target workspace IDs are required", ErrInvalidConfig)
|
|
}
|
|
if sourceWorkspaceID == targetWorkspaceID {
|
|
return ErrSameWorkspace
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// prepareCopyInputs reads every source instance and, for each, reveals its
|
|
// secret before any target-workspace mutation, plus the target's existing
|
|
// instance names to dedupe against. Aborting here means no partial target
|
|
// state to roll back.
|
|
func (s *Service) prepareCopyInputs(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) ([]copyInput, map[string]struct{}, error) {
|
|
sources, err := s.store.ListInstances(ctx, sourceWorkspaceID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read source sentry instances: %w", err)
|
|
}
|
|
if len(sources) == 0 {
|
|
return nil, nil, ErrNothingToCopy
|
|
}
|
|
existing, err := s.store.ListInstances(ctx, targetWorkspaceID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read target sentry instances: %w", err)
|
|
}
|
|
used := make(map[string]struct{}, len(existing))
|
|
for _, inst := range existing {
|
|
used[inst.Name] = struct{}{}
|
|
}
|
|
prepared := make([]copyInput, 0, len(sources))
|
|
for _, src := range sources {
|
|
input := copyInput{config: src}
|
|
if s.secrets != nil {
|
|
secret, ok, err := s.revealInstanceSecret(ctx, src.ID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read source sentry secret: %w", err)
|
|
}
|
|
input.secret = secret
|
|
input.hasSecret = ok
|
|
}
|
|
prepared = append(prepared, input)
|
|
}
|
|
return prepared, used, nil
|
|
}
|
|
|
|
// createCopiedInstances copies every prepared input into the target
|
|
// workspace, rolling back all instances created by this request if any copy
|
|
// fails.
|
|
func (s *Service) createCopiedInstances(ctx context.Context, targetWorkspaceID string, prepared []copyInput, used map[string]struct{}) ([]*SentryConfig, error) {
|
|
copied := make([]*SentryConfig, 0, len(prepared))
|
|
for _, input := range prepared {
|
|
out, err := s.copyInstance(ctx, targetWorkspaceID, input, used)
|
|
if out != nil {
|
|
copied = append(copied, out)
|
|
}
|
|
if err != nil {
|
|
return nil, s.rollbackCopiedInstances(ctx, copied, err)
|
|
}
|
|
}
|
|
return copied, nil
|
|
}
|
|
|
|
// finalizeCopiedInstances invalidates the cached clients for the freshly copied
|
|
// instances. It deliberately fires no per-instance auth-health probe here: a
|
|
// workspace may hold arbitrarily many instances, so probing each copy inline
|
|
// would burst an unbounded number of goroutines and outbound Sentry calls.
|
|
// The copied instances instead get their first health check from the bounded
|
|
// background auth-health poller (RecordAuthHealth, concurrency-capped) on its
|
|
// next cycle.
|
|
func (s *Service) finalizeCopiedInstances(copied []*SentryConfig) {
|
|
for _, cfg := range copied {
|
|
s.invalidateClient(cfg.ID)
|
|
}
|
|
}
|
|
|
|
// copyInstance duplicates a prepared source instance into the target workspace
|
|
// with a name deduped against used (which it updates) and a rekeyed secret.
|
|
// Health probing is left to the bounded background poller (see
|
|
// finalizeCopiedInstances).
|
|
func (s *Service) copyInstance(ctx context.Context, targetWorkspaceID string, input copyInput, used map[string]struct{}) (*SentryConfig, error) {
|
|
cfg := &SentryConfig{
|
|
WorkspaceID: targetWorkspaceID,
|
|
Name: uniqueInstanceName(used, input.config.Name),
|
|
AuthMethod: input.config.AuthMethod,
|
|
URL: input.config.URL,
|
|
}
|
|
if err := s.store.CreateInstance(ctx, cfg); err != nil {
|
|
return nil, fmt.Errorf("create copied sentry instance: %w", err)
|
|
}
|
|
if input.hasSecret && s.secrets != nil {
|
|
if err := s.secrets.Set(ctx, secretKeyForInstance(cfg.ID), "Sentry auth token", input.secret); err != nil {
|
|
return cfg, fmt.Errorf("store copied sentry secret: %w", err)
|
|
}
|
|
cfg.HasSecret = true
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// rollbackCopiedInstances removes only instances created by the current copy
|
|
// request. It continues after cleanup errors so every created row is
|
|
// attempted, and only deletes a copied secret once its row delete actually
|
|
// succeeded — if DeleteInstance fails (e.g. the instance became referenced by
|
|
// a watch created concurrently, returning ErrInstanceInUse), that instance's
|
|
// row and secret are left fully intact rather than orphaning a live row.
|
|
func (s *Service) rollbackCopiedInstances(ctx context.Context, copied []*SentryConfig, cause error) error {
|
|
cleanupCtx := context.WithoutCancel(ctx)
|
|
var rollbackErrs []error
|
|
for i := len(copied) - 1; i >= 0; i-- {
|
|
cfg := copied[i]
|
|
if cfg == nil || cfg.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.store.DeleteInstance(cleanupCtx, cfg.ID); err != nil {
|
|
rollbackErrs = append(rollbackErrs, fmt.Errorf("rollback copied sentry instance %q: %w", cfg.ID, err))
|
|
continue
|
|
}
|
|
if s.secrets != nil {
|
|
key := secretKeyForInstance(cfg.ID)
|
|
exists, err := s.secrets.Exists(cleanupCtx, key)
|
|
if err != nil {
|
|
rollbackErrs = append(rollbackErrs, fmt.Errorf("check copied sentry secret %q during rollback: %w", cfg.ID, err))
|
|
} else if exists {
|
|
if err := s.secrets.Delete(cleanupCtx, key); err != nil {
|
|
rollbackErrs = append(rollbackErrs, fmt.Errorf("rollback copied sentry secret %q: %w", cfg.ID, err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(rollbackErrs) == 0 {
|
|
return cause
|
|
}
|
|
cleanupErr := errors.Join(rollbackErrs...)
|
|
s.log.Warn("sentry: copy rollback cleanup failed", zap.Error(cleanupErr))
|
|
return errors.Join(cause, cleanupErr)
|
|
}
|