fix(mirror): sync --all past failures, admin-only warnings, no config lock during pushes and deferred due repos
- commit
- 580584075d9c42622dfc54f01807709cf43c326a
- author
- brice · · 2026-10-20 07:06 UTC
- committer
- brice · · 2026-10-20 10:02 UTC
- parent
- 5e1949f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1579 -791
M internal/usecase/mirrors.go +41 -23
| @@ -12,6 +12,8 @@ import ( | |||
| 12 | 12 | "bnema.dev/ermite/internal/ports" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | const mirrorQueueSize = 64 | |
| 16 | + | ||
| 15 | 17 | // MirrorsConfig supplies the mirror use case's dependencies. | |
| 16 | 18 | type MirrorsConfig struct { | |
| 17 | 19 | Store ports.MirrorStore | |
| @@ -26,25 +28,38 @@ type MirrorsConfig struct { | |||
| 26 | 28 | PublicURL string | |
| 27 | 29 | ScheduleTick time.Duration | |
| 28 | 30 | Workers int | |
| 29 | - | AllowAnyURL bool | |
| 31 | + | QueueSize int // jobs waiting for a worker; default 64 | |
| 32 | + | // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of | |
| 33 | + | // local transports inject a permissive check. | |
| 34 | + | ValidateURL func(string) error | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // mirrorGen counts the changes of a repository (dirty) and the count the last successful | |
| 38 | + | // sync saw (clean, meaningful once cleanKnown). | |
| 39 | + | type mirrorGen struct { | |
| 40 | + | dirty, clean uint64 | |
| 41 | + | cleanKnown bool | |
| 30 | 42 | } | |
| 31 | 43 | ||
| 32 | 44 | // Mirrors manages outbound repository mirrors. | |
| 33 | 45 | type Mirrors struct { | |
| 34 | - | cfg MirrorsConfig | |
| 35 | - | configMu sync.RWMutex | |
| 36 | - | mu sync.Mutex | |
| 37 | - | dirty map[domain.RepoName]uint64 | |
| 38 | - | clean map[domain.RepoName]uint64 | |
| 39 | - | flights map[domain.RepoName]*mirrorFlight | |
| 40 | - | ctx context.Context | |
| 41 | - | cancel context.CancelFunc | |
| 42 | - | wg sync.WaitGroup | |
| 43 | - | start sync.Once | |
| 44 | - | closed bool | |
| 45 | - | jobs chan domain.RepoName | |
| 46 | + | cfg MirrorsConfig | |
| 47 | + | configMu sync.RWMutex // configuration changes; held for reads only around store reads and writes, never during a push | |
| 48 | + | mu sync.Mutex | |
| 49 | + | gens map[domain.RepoName]mirrorGen | |
| 50 | + | flights map[domain.RepoName]*mirrorFlight | |
| 51 | + | flightWG sync.WaitGroup // one per flight, for tests that wait for the queue to drain | |
| 52 | + | ctx context.Context | |
| 53 | + | cancel context.CancelFunc | |
| 54 | + | wg sync.WaitGroup | |
| 55 | + | start sync.Once | |
| 56 | + | closed bool | |
| 57 | + | jobs chan domain.RepoName | |
| 58 | + | ||
| 46 | 59 | scheduleMu sync.Mutex | |
| 47 | 60 | lastMinute time.Time | |
| 61 | + | deferred map[domain.RepoName]struct{} // due, but the queue was full | |
| 62 | + | ||
| 48 | 63 | keyMu sync.Mutex | |
| 49 | 64 | key ed25519.PrivateKey | |
| 50 | 65 | keyCreated time.Time | |
| @@ -58,16 +73,19 @@ func NewMirrors(cfg MirrorsConfig) *Mirrors { | |||
| 58 | 73 | if cfg.Workers <= 0 { | |
| 59 | 74 | cfg.Workers = 2 | |
| 60 | 75 | } | |
| 76 | + | if cfg.QueueSize <= 0 { | |
| 77 | + | cfg.QueueSize = mirrorQueueSize | |
| 78 | + | } | |
| 79 | + | if cfg.ValidateURL == nil { | |
| 80 | + | cfg.ValidateURL = domain.ValidateMirrorURL | |
| 81 | + | } | |
| 61 | 82 | ctx, cancel := context.WithCancel(context.Background()) | |
| 62 | - | return &Mirrors{cfg: cfg, ctx: ctx, cancel: cancel, dirty: make(map[domain.RepoName]uint64), clean: make(map[domain.RepoName]uint64), flights: make(map[domain.RepoName]*mirrorFlight), jobs: make(chan domain.RepoName, 64), lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute)} | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // privateKey returns a copy so a transport cannot mutate the cached key. | |
| 66 | - | func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) { | |
| 67 | - | m.keyMu.Lock() | |
| 68 | - | defer m.keyMu.Unlock() | |
| 69 | - | if err := m.loadKey(ctx, false); err != nil { | |
| 70 | - | return nil, err | |
| 83 | + | return &Mirrors{ | |
| 84 | + | cfg: cfg, ctx: ctx, cancel: cancel, | |
| 85 | + | gens: make(map[domain.RepoName]mirrorGen), | |
| 86 | + | flights: make(map[domain.RepoName]*mirrorFlight), | |
| 87 | + | deferred: make(map[domain.RepoName]struct{}), | |
| 88 | + | jobs: make(chan domain.RepoName, cfg.QueueSize), | |
| 89 | + | lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute), | |
| 71 | 90 | } | |
| 72 | - | return append(ed25519.PrivateKey(nil), m.key...), nil | |
| 73 | 91 | } | |
| @@ -12,6 +12,8 @@ import ( | |||||
| 12 | "bnema.dev/ermite/internal/ports" | 12 | "bnema.dev/ermite/internal/ports" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | + | const mirrorQueueSize = 64 | |||
| 16 | + | ||||
| 15 | // MirrorsConfig supplies the mirror use case's dependencies. | 17 | // MirrorsConfig supplies the mirror use case's dependencies. | ||
| 16 | type MirrorsConfig struct { | 18 | type MirrorsConfig struct { | ||
| 17 | Store ports.MirrorStore | 19 | Store ports.MirrorStore | ||
| @@ -26,25 +28,38 @@ type MirrorsConfig struct { | |||||
| 26 | PublicURL string | 28 | PublicURL string | ||
| 27 | ScheduleTick time.Duration | 29 | ScheduleTick time.Duration | ||
| 28 | Workers int | 30 | Workers int | ||
| 29 | - | AllowAnyURL bool | 31 | + | QueueSize int // jobs waiting for a worker; default 64 |
| 32 | + | // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of | |||
| 33 | + | // local transports inject a permissive check. | |||
| 34 | + | ValidateURL func(string) error | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // mirrorGen counts the changes of a repository (dirty) and the count the last successful | |||
| 38 | + | // sync saw (clean, meaningful once cleanKnown). | |||
| 39 | + | type mirrorGen struct { | |||
| 40 | + | dirty, clean uint64 | |||
| 41 | + | cleanKnown bool | |||
| 30 | } | 42 | } | ||
| 31 | 43 | ||||
| 32 | // Mirrors manages outbound repository mirrors. | 44 | // Mirrors manages outbound repository mirrors. | ||
| 33 | type Mirrors struct { | 45 | type Mirrors struct { | ||
| 34 | - | cfg MirrorsConfig | 46 | + | cfg MirrorsConfig |
| 35 | - | configMu sync.RWMutex | 47 | + | configMu sync.RWMutex // configuration changes; held for reads only around store reads and writes, never during a push |
| 36 | - | mu sync.Mutex | 48 | + | mu sync.Mutex |
| 37 | - | dirty map[domain.RepoName]uint64 | 49 | + | gens map[domain.RepoName]mirrorGen |
| 38 | - | clean map[domain.RepoName]uint64 | 50 | + | flights map[domain.RepoName]*mirrorFlight |
| 39 | - | flights map[domain.RepoName]*mirrorFlight | 51 | + | flightWG sync.WaitGroup // one per flight, for tests that wait for the queue to drain |
| 40 | - | ctx context.Context | 52 | + | ctx context.Context |
| 41 | - | cancel context.CancelFunc | 53 | + | cancel context.CancelFunc |
| 42 | - | wg sync.WaitGroup | 54 | + | wg sync.WaitGroup |
| 43 | - | start sync.Once | 55 | + | start sync.Once |
| 44 | - | closed bool | 56 | + | closed bool |
| 45 | - | jobs chan domain.RepoName | 57 | + | jobs chan domain.RepoName |
| 58 | + | ||||
| 46 | scheduleMu sync.Mutex | 59 | scheduleMu sync.Mutex | ||
| 47 | lastMinute time.Time | 60 | lastMinute time.Time | ||
| 61 | + | deferred map[domain.RepoName]struct{} // due, but the queue was full | |||
| 62 | + | ||||
| 48 | keyMu sync.Mutex | 63 | keyMu sync.Mutex | ||
| 49 | key ed25519.PrivateKey | 64 | key ed25519.PrivateKey | ||
| 50 | keyCreated time.Time | 65 | keyCreated time.Time | ||
| @@ -58,16 +73,19 @@ func NewMirrors(cfg MirrorsConfig) *Mirrors { | |||||
| 58 | if cfg.Workers <= 0 { | 73 | if cfg.Workers <= 0 { | ||
| 59 | cfg.Workers = 2 | 74 | cfg.Workers = 2 | ||
| 60 | } | 75 | } | ||
| 76 | + | if cfg.QueueSize <= 0 { | |||
| 77 | + | cfg.QueueSize = mirrorQueueSize | |||
| 78 | + | } | |||
| 79 | + | if cfg.ValidateURL == nil { | |||
| 80 | + | cfg.ValidateURL = domain.ValidateMirrorURL | |||
| 81 | + | } | |||
| 61 | ctx, cancel := context.WithCancel(context.Background()) | 82 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 62 | - | return &Mirrors{cfg: cfg, ctx: ctx, cancel: cancel, dirty: make(map[domain.RepoName]uint64), clean: make(map[domain.RepoName]uint64), flights: make(map[domain.RepoName]*mirrorFlight), jobs: make(chan domain.RepoName, 64), lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute)} | 83 | + | return &Mirrors{ |
| 63 | - | } | 84 | + | cfg: cfg, ctx: ctx, cancel: cancel, |
| 64 | - | 85 | + | gens: make(map[domain.RepoName]mirrorGen), | |
| 65 | - | // privateKey returns a copy so a transport cannot mutate the cached key. | 86 | + | flights: make(map[domain.RepoName]*mirrorFlight), |
| 66 | - | func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) { | 87 | + | deferred: make(map[domain.RepoName]struct{}), |
| 67 | - | m.keyMu.Lock() | 88 | + | jobs: make(chan domain.RepoName, cfg.QueueSize), |
| 68 | - | defer m.keyMu.Unlock() | 89 | + | lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute), |
| 69 | - | if err := m.loadKey(ctx, false); err != nil { | |||
| 70 | - | return nil, err | |||
| 71 | } | 90 | } | ||
| 72 | - | return append(ed25519.PrivateKey(nil), m.key...), nil | |||
| 73 | } | 91 | } | ||
M internal/usecase/mirrors_config.go +79 -47
| @@ -5,7 +5,6 @@ package usecase | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | - | "strings" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 11 | 10 | "bnema.dev/ermite/internal/domain" | |
| @@ -122,12 +121,14 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch | |||
| 122 | 121 | if err := m.cfg.Store.PutGlobal(ctx, g); err != nil { | |
| 123 | 122 | return old, err | |
| 124 | 123 | } | |
| 124 | + | // One failing reset must not leave the others stale: the policy is saved. | |
| 125 | + | var errs []error | |
| 125 | 126 | for _, name := range changed { | |
| 126 | 127 | if err := m.reset(ctx, name); err != nil { | |
| 127 | - | return g, err | |
| 128 | + | errs = append(errs, err) | |
| 128 | 129 | } | |
| 129 | 130 | } | |
| 130 | - | return g, nil | |
| 131 | + | return g, errors.Join(errs...) | |
| 131 | 132 | } | |
| 132 | 133 | ||
| 133 | 134 | func invalidCron(e error) error { | |
| @@ -141,6 +142,8 @@ func invalidCron(e error) error { | |||
| 141 | 142 | func sameMirrorTarget(a, b domain.MirrorTarget) bool { | |
| 142 | 143 | return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source | |
| 143 | 144 | } | |
| 145 | + | ||
| 146 | + | // reset lifts the incident of a repository whose effective target changed and marks it dirty. | |
| 144 | 147 | func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error { | |
| 145 | 148 | t, err := m.target(ctx, repo) | |
| 146 | 149 | if err != nil { | |
| @@ -157,11 +160,25 @@ func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error { | |||
| 157 | 160 | s = domain.MirrorDisable(s, now) | |
| 158 | 161 | } | |
| 159 | 162 | m.mu.Lock() | |
| 160 | - | m.dirty[repo]++ | |
| 163 | + | g := m.gens[repo] | |
| 164 | + | g.dirty++ | |
| 165 | + | m.gens[repo] = g | |
| 161 | 166 | m.mu.Unlock() | |
| 162 | 167 | return m.cfg.Store.PutStatus(ctx, s) | |
| 163 | 168 | } | |
| 164 | 169 | ||
| 170 | + | // resetIfChanged resets repo when its effective target is no longer before. configMu is held. | |
| 171 | + | func (m *Mirrors) resetIfChanged(ctx context.Context, repo domain.RepoName, before domain.MirrorTarget, beforeErr error) (domain.MirrorTarget, error) { | |
| 172 | + | after, err := m.target(ctx, repo) | |
| 173 | + | if err != nil { | |
| 174 | + | return after, err | |
| 175 | + | } | |
| 176 | + | if beforeErr == nil && sameMirrorTarget(before, after) { | |
| 177 | + | return after, nil | |
| 178 | + | } | |
| 179 | + | return after, m.reset(ctx, repo) | |
| 180 | + | } | |
| 181 | + | ||
| 165 | 182 | // SetRepoMirror sets a repository override. | |
| 166 | 183 | func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) { | |
| 167 | 184 | if err := m.admin(ctx, p, c.Repo); err != nil { | |
| @@ -184,14 +201,12 @@ func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig) | |||
| 184 | 201 | if _, err := m.resolve(g, &c, c.Repo); err != nil { | |
| 185 | 202 | return domain.MirrorTarget{}, invalidCron(err) | |
| 186 | 203 | } | |
| 204 | + | before, beforeErr := m.target(ctx, c.Repo) | |
| 187 | 205 | c.UpdatedAt = m.cfg.Clock.Now().UTC() | |
| 188 | 206 | if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil { | |
| 189 | 207 | return domain.MirrorTarget{}, err | |
| 190 | 208 | } | |
| 191 | - | if err := m.reset(ctx, c.Repo); err != nil { | |
| 192 | - | return domain.MirrorTarget{}, err | |
| 193 | - | } | |
| 194 | - | return m.target(ctx, c.Repo) | |
| 209 | + | return m.resetIfChanged(ctx, c.Repo, before, beforeErr) | |
| 195 | 210 | } | |
| 196 | 211 | ||
| 197 | 212 | // RemoveRepoMirror restores inherited policy. | |
| @@ -201,10 +216,12 @@ func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo | |||
| 201 | 216 | } | |
| 202 | 217 | m.configMu.Lock() | |
| 203 | 218 | defer m.configMu.Unlock() | |
| 219 | + | before, beforeErr := m.target(ctx, repo) | |
| 204 | 220 | if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil { | |
| 205 | 221 | return err | |
| 206 | 222 | } | |
| 207 | - | return m.reset(ctx, repo) | |
| 223 | + | _, err := m.resetIfChanged(ctx, repo, before, beforeErr) | |
| 224 | + | return err | |
| 208 | 225 | } | |
| 209 | 226 | ||
| 210 | 227 | // SetRepoExcluded updates exclusion without discarding an override. | |
| @@ -224,6 +241,11 @@ func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo | |||
| 224 | 241 | ||
| 225 | 242 | // GetStatus returns a read-authorized status, redacting administrative diagnostics. | |
| 226 | 243 | func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) { | |
| 244 | + | return m.statusView(ctx, p, repo, true) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // statusView builds the view of one repository; withNext computes the next scheduled run. | |
| 248 | + | func (m *Mirrors) statusView(ctx context.Context, p domain.Principal, repo domain.RepoName, withNext bool) (ports.MirrorStatusView, error) { | |
| 227 | 249 | r, err := m.cfg.Meta.Get(ctx, repo) | |
| 228 | 250 | if err != nil { | |
| 229 | 251 | return ports.MirrorStatusView{}, err | |
| @@ -239,22 +261,26 @@ func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain | |||
| 239 | 261 | if err != nil { | |
| 240 | 262 | return ports.MirrorStatusView{}, err | |
| 241 | 263 | } | |
| 264 | + | return m.view(p, t, s, withNext), nil | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (m *Mirrors) view(p domain.Principal, t domain.MirrorTarget, s domain.MirrorStatus, withNext bool) ports.MirrorStatusView { | |
| 242 | 268 | var next time.Time | |
| 243 | - | if t.Enabled && s.State != domain.MirrorPaused { | |
| 269 | + | if withNext && t.Enabled && s.State != domain.MirrorPaused { | |
| 244 | 270 | if s.State == domain.MirrorFailing { | |
| 245 | 271 | next = s.NextRetryAt | |
| 246 | 272 | } else { | |
| 247 | 273 | next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now()) | |
| 248 | 274 | } | |
| 249 | 275 | } | |
| 250 | - | if !p.Allows(domain.ScopeRepoAdmin, repo) { | |
| 276 | + | if !p.Allows(domain.ScopeRepoAdmin, t.Repo) { | |
| 251 | 277 | s.Detail, s.Reason = "", "" | |
| 252 | 278 | t.URL = "" | |
| 253 | 279 | } | |
| 254 | - | return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}, nil | |
| 280 | + | return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next} | |
| 255 | 281 | } | |
| 256 | 282 | ||
| 257 | - | // ListStatus returns all statuses to an owner. | |
| 283 | + | // ListStatus returns all statuses to an owner, from three reads instead of three per repository. | |
| 258 | 284 | func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) { | |
| 259 | 285 | if err := mirrorOwner(p); err != nil { | |
| 260 | 286 | return nil, err | |
| @@ -263,19 +289,51 @@ func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.M | |||
| 263 | 289 | if err != nil { | |
| 264 | 290 | return nil, err | |
| 265 | 291 | } | |
| 292 | + | g, err := m.cfg.Store.GetGlobal(ctx) | |
| 293 | + | if err != nil { | |
| 294 | + | return nil, err | |
| 295 | + | } | |
| 296 | + | configs, err := m.cfg.Store.ListRepoConfigs(ctx) | |
| 297 | + | if err != nil { | |
| 298 | + | return nil, err | |
| 299 | + | } | |
| 300 | + | byRepo := make(map[domain.RepoName]domain.MirrorRepoConfig, len(configs)) | |
| 301 | + | for _, c := range configs { | |
| 302 | + | byRepo[c.Repo] = c | |
| 303 | + | } | |
| 304 | + | statuses, err := m.cfg.Store.ListStatus(ctx) | |
| 305 | + | if err != nil { | |
| 306 | + | return nil, err | |
| 307 | + | } | |
| 308 | + | stored := make(map[domain.RepoName]domain.MirrorStatus, len(statuses)) | |
| 309 | + | for _, s := range statuses { | |
| 310 | + | stored[s.Repo] = s | |
| 311 | + | } | |
| 266 | 312 | out := make([]ports.MirrorStatusView, 0, len(repos)) | |
| 267 | 313 | for _, r := range repos { | |
| 268 | - | v, err := m.GetStatus(ctx, p, r.Name) | |
| 314 | + | var cp *domain.MirrorRepoConfig | |
| 315 | + | if c, ok := byRepo[r.Name]; ok { | |
| 316 | + | cp = &c | |
| 317 | + | } | |
| 318 | + | t, err := m.resolve(g, cp, r.Name) | |
| 269 | 319 | if err != nil { | |
| 270 | 320 | return nil, err | |
| 271 | 321 | } | |
| 272 | - | out = append(out, v) | |
| 322 | + | s, ok := stored[r.Name] | |
| 323 | + | if !ok { | |
| 324 | + | s = domain.MirrorStatus{Repo: r.Name, State: domain.MirrorDisabled} | |
| 325 | + | } | |
| 326 | + | out = append(out, m.view(p, t, s, true)) | |
| 273 | 327 | } | |
| 274 | 328 | return out, nil | |
| 275 | 329 | } | |
| 276 | 330 | ||
| 277 | - | // Warning provides an administrative post-receive diagnostic. | |
| 278 | - | func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string { | |
| 331 | + | // Warning provides the post-receive diagnostic of a failing or paused mirror to the admins of | |
| 332 | + | // the repository only: it names the remote and the reason. | |
| 333 | + | func (m *Mirrors) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string { | |
| 334 | + | if !p.Allows(domain.ScopeRepoAdmin, repo) { | |
| 335 | + | return "" | |
| 336 | + | } | |
| 279 | 337 | s, err := m.status(ctx, repo) | |
| 280 | 338 | if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) { | |
| 281 | 339 | return "" | |
| @@ -289,45 +347,19 @@ func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string { | |||
| 289 | 347 | ||
| 290 | 348 | // Badge returns a viewer-safe state and optional administrative reason. | |
| 291 | 349 | func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) { | |
| 292 | - | v, err := m.GetStatus(ctx, p, repo) | |
| 350 | + | v, err := m.statusView(ctx, p, repo, false) | |
| 293 | 351 | if err != nil { | |
| 294 | 352 | return domain.MirrorDisabled, "" | |
| 295 | 353 | } | |
| 296 | 354 | return v.Status.State, v.Status.Reason | |
| 297 | 355 | } | |
| 298 | 356 | ||
| 299 | - | // testURL substitutes only during validation/resolution; stored and pushed URLs remain intact. | |
| 300 | - | func (m *Mirrors) testURL(url string) string { | |
| 301 | - | if m.cfg.AllowAnyURL && strings.HasPrefix(url, "file://") { | |
| 302 | - | return "git@mirror-test:" + strings.TrimPrefix(url, "file://") | |
| 303 | - | } | |
| 304 | - | return url | |
| 305 | - | } | |
| 306 | 357 | func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error { | |
| 307 | - | g.URLTemplate = m.testURL(g.URLTemplate) | |
| 308 | - | return domain.ValidateMirrorGlobalConfig(g) | |
| 358 | + | return domain.ValidateMirrorGlobalConfigWith(g, m.cfg.ValidateURL) | |
| 309 | 359 | } | |
| 310 | 360 | func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error { | |
| 311 | - | c.URL = m.testURL(c.URL) | |
| 312 | - | return domain.ValidateMirrorRepoConfig(c) | |
| 361 | + | return domain.ValidateMirrorRepoConfigWith(c, m.cfg.ValidateURL) | |
| 313 | 362 | } | |
| 314 | 363 | func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) { | |
| 315 | - | originalGlobal := g.URLTemplate | |
| 316 | - | originalRepo := "" | |
| 317 | - | g.URLTemplate = m.testURL(g.URLTemplate) | |
| 318 | - | if c != nil { | |
| 319 | - | copyConfig := *c | |
| 320 | - | originalRepo = c.URL | |
| 321 | - | copyConfig.URL = m.testURL(c.URL) | |
| 322 | - | c = ©Config | |
| 323 | - | } | |
| 324 | - | t, err := domain.ResolveMirror(g, c, repo) | |
| 325 | - | if err == nil && t.Enabled && m.cfg.AllowAnyURL { | |
| 326 | - | if originalRepo != "" { | |
| 327 | - | t.URL = originalRepo | |
| 328 | - | } else if strings.HasPrefix(originalGlobal, "file://") { | |
| 329 | - | t.URL, err = domain.ExpandMirrorURL(originalGlobal, repo) | |
| 330 | - | } | |
| 331 | - | } | |
| 332 | - | return t, err | |
| 364 | + | return domain.ResolveMirrorWith(g, c, repo, m.cfg.ValidateURL) | |
| 333 | 365 | } | |
| @@ -5,7 +5,6 @@ package usecase | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | - | "strings" | |||
| 9 | "time" | 8 | "time" | ||
| 10 | 9 | ||||
| 11 | "bnema.dev/ermite/internal/domain" | 10 | "bnema.dev/ermite/internal/domain" | ||
| @@ -122,12 +121,14 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch | |||||
| 122 | if err := m.cfg.Store.PutGlobal(ctx, g); err != nil { | 121 | if err := m.cfg.Store.PutGlobal(ctx, g); err != nil { | ||
| 123 | return old, err | 122 | return old, err | ||
| 124 | } | 123 | } | ||
| 124 | + | // One failing reset must not leave the others stale: the policy is saved. | |||
| 125 | + | var errs []error | |||
| 125 | for _, name := range changed { | 126 | for _, name := range changed { | ||
| 126 | if err := m.reset(ctx, name); err != nil { | 127 | if err := m.reset(ctx, name); err != nil { | ||
| 127 | - | return g, err | 128 | + | errs = append(errs, err) |
| 128 | } | 129 | } | ||
| 129 | } | 130 | } | ||
| 130 | - | return g, nil | 131 | + | return g, errors.Join(errs...) |
| 131 | } | 132 | } | ||
| 132 | 133 | ||||
| 133 | func invalidCron(e error) error { | 134 | func invalidCron(e error) error { | ||
| @@ -141,6 +142,8 @@ func invalidCron(e error) error { | |||||
| 141 | func sameMirrorTarget(a, b domain.MirrorTarget) bool { | 142 | func sameMirrorTarget(a, b domain.MirrorTarget) bool { | ||
| 142 | return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source | 143 | return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source | ||
| 143 | } | 144 | } | ||
| 145 | + | ||||
| 146 | + | // reset lifts the incident of a repository whose effective target changed and marks it dirty. | |||
| 144 | func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error { | 147 | func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error { | ||
| 145 | t, err := m.target(ctx, repo) | 148 | t, err := m.target(ctx, repo) | ||
| 146 | if err != nil { | 149 | if err != nil { | ||
| @@ -157,11 +160,25 @@ func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error { | |||||
| 157 | s = domain.MirrorDisable(s, now) | 160 | s = domain.MirrorDisable(s, now) | ||
| 158 | } | 161 | } | ||
| 159 | m.mu.Lock() | 162 | m.mu.Lock() | ||
| 160 | - | m.dirty[repo]++ | 163 | + | g := m.gens[repo] |
| 164 | + | g.dirty++ | |||
| 165 | + | m.gens[repo] = g | |||
| 161 | m.mu.Unlock() | 166 | m.mu.Unlock() | ||
| 162 | return m.cfg.Store.PutStatus(ctx, s) | 167 | return m.cfg.Store.PutStatus(ctx, s) | ||
| 163 | } | 168 | } | ||
| 164 | 169 | ||||
| 170 | + | // resetIfChanged resets repo when its effective target is no longer before. configMu is held. | |||
| 171 | + | func (m *Mirrors) resetIfChanged(ctx context.Context, repo domain.RepoName, before domain.MirrorTarget, beforeErr error) (domain.MirrorTarget, error) { | |||
| 172 | + | after, err := m.target(ctx, repo) | |||
| 173 | + | if err != nil { | |||
| 174 | + | return after, err | |||
| 175 | + | } | |||
| 176 | + | if beforeErr == nil && sameMirrorTarget(before, after) { | |||
| 177 | + | return after, nil | |||
| 178 | + | } | |||
| 179 | + | return after, m.reset(ctx, repo) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 165 | // SetRepoMirror sets a repository override. | 182 | // SetRepoMirror sets a repository override. | ||
| 166 | func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) { | 183 | func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) { | ||
| 167 | if err := m.admin(ctx, p, c.Repo); err != nil { | 184 | if err := m.admin(ctx, p, c.Repo); err != nil { | ||
| @@ -184,14 +201,12 @@ func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig) | |||||
| 184 | if _, err := m.resolve(g, &c, c.Repo); err != nil { | 201 | if _, err := m.resolve(g, &c, c.Repo); err != nil { | ||
| 185 | return domain.MirrorTarget{}, invalidCron(err) | 202 | return domain.MirrorTarget{}, invalidCron(err) | ||
| 186 | } | 203 | } | ||
| 204 | + | before, beforeErr := m.target(ctx, c.Repo) | |||
| 187 | c.UpdatedAt = m.cfg.Clock.Now().UTC() | 205 | c.UpdatedAt = m.cfg.Clock.Now().UTC() | ||
| 188 | if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil { | 206 | if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil { | ||
| 189 | return domain.MirrorTarget{}, err | 207 | return domain.MirrorTarget{}, err | ||
| 190 | } | 208 | } | ||
| 191 | - | if err := m.reset(ctx, c.Repo); err != nil { | 209 | + | return m.resetIfChanged(ctx, c.Repo, before, beforeErr) |
| 192 | - | return domain.MirrorTarget{}, err | |||
| 193 | - | } | |||
| 194 | - | return m.target(ctx, c.Repo) | |||
| 195 | } | 210 | } | ||
| 196 | 211 | ||||
| 197 | // RemoveRepoMirror restores inherited policy. | 212 | // RemoveRepoMirror restores inherited policy. | ||
| @@ -201,10 +216,12 @@ func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo | |||||
| 201 | } | 216 | } | ||
| 202 | m.configMu.Lock() | 217 | m.configMu.Lock() | ||
| 203 | defer m.configMu.Unlock() | 218 | defer m.configMu.Unlock() | ||
| 219 | + | before, beforeErr := m.target(ctx, repo) | |||
| 204 | if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil { | 220 | if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil { | ||
| 205 | return err | 221 | return err | ||
| 206 | } | 222 | } | ||
| 207 | - | return m.reset(ctx, repo) | 223 | + | _, err := m.resetIfChanged(ctx, repo, before, beforeErr) |
| 224 | + | return err | |||
| 208 | } | 225 | } | ||
| 209 | 226 | ||||
| 210 | // SetRepoExcluded updates exclusion without discarding an override. | 227 | // SetRepoExcluded updates exclusion without discarding an override. | ||
| @@ -224,6 +241,11 @@ func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo | |||||
| 224 | 241 | ||||
| 225 | // GetStatus returns a read-authorized status, redacting administrative diagnostics. | 242 | // GetStatus returns a read-authorized status, redacting administrative diagnostics. | ||
| 226 | func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) { | 243 | func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) { | ||
| 244 | + | return m.statusView(ctx, p, repo, true) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // statusView builds the view of one repository; withNext computes the next scheduled run. | |||
| 248 | + | func (m *Mirrors) statusView(ctx context.Context, p domain.Principal, repo domain.RepoName, withNext bool) (ports.MirrorStatusView, error) { | |||
| 227 | r, err := m.cfg.Meta.Get(ctx, repo) | 249 | r, err := m.cfg.Meta.Get(ctx, repo) | ||
| 228 | if err != nil { | 250 | if err != nil { | ||
| 229 | return ports.MirrorStatusView{}, err | 251 | return ports.MirrorStatusView{}, err | ||
| @@ -239,22 +261,26 @@ func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain | |||||
| 239 | if err != nil { | 261 | if err != nil { | ||
| 240 | return ports.MirrorStatusView{}, err | 262 | return ports.MirrorStatusView{}, err | ||
| 241 | } | 263 | } | ||
| 264 | + | return m.view(p, t, s, withNext), nil | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (m *Mirrors) view(p domain.Principal, t domain.MirrorTarget, s domain.MirrorStatus, withNext bool) ports.MirrorStatusView { | |||
| 242 | var next time.Time | 268 | var next time.Time | ||
| 243 | - | if t.Enabled && s.State != domain.MirrorPaused { | 269 | + | if withNext && t.Enabled && s.State != domain.MirrorPaused { |
| 244 | if s.State == domain.MirrorFailing { | 270 | if s.State == domain.MirrorFailing { | ||
| 245 | next = s.NextRetryAt | 271 | next = s.NextRetryAt | ||
| 246 | } else { | 272 | } else { | ||
| 247 | next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now()) | 273 | next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now()) | ||
| 248 | } | 274 | } | ||
| 249 | } | 275 | } | ||
| 250 | - | if !p.Allows(domain.ScopeRepoAdmin, repo) { | 276 | + | if !p.Allows(domain.ScopeRepoAdmin, t.Repo) { |
| 251 | s.Detail, s.Reason = "", "" | 277 | s.Detail, s.Reason = "", "" | ||
| 252 | t.URL = "" | 278 | t.URL = "" | ||
| 253 | } | 279 | } | ||
| 254 | - | return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}, nil | 280 | + | return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next} |
| 255 | } | 281 | } | ||
| 256 | 282 | ||||
| 257 | - | // ListStatus returns all statuses to an owner. | 283 | + | // ListStatus returns all statuses to an owner, from three reads instead of three per repository. |
| 258 | func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) { | 284 | func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) { | ||
| 259 | if err := mirrorOwner(p); err != nil { | 285 | if err := mirrorOwner(p); err != nil { | ||
| 260 | return nil, err | 286 | return nil, err | ||
| @@ -263,19 +289,51 @@ func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.M | |||||
| 263 | if err != nil { | 289 | if err != nil { | ||
| 264 | return nil, err | 290 | return nil, err | ||
| 265 | } | 291 | } | ||
| 292 | + | g, err := m.cfg.Store.GetGlobal(ctx) | |||
| 293 | + | if err != nil { | |||
| 294 | + | return nil, err | |||
| 295 | + | } | |||
| 296 | + | configs, err := m.cfg.Store.ListRepoConfigs(ctx) | |||
| 297 | + | if err != nil { | |||
| 298 | + | return nil, err | |||
| 299 | + | } | |||
| 300 | + | byRepo := make(map[domain.RepoName]domain.MirrorRepoConfig, len(configs)) | |||
| 301 | + | for _, c := range configs { | |||
| 302 | + | byRepo[c.Repo] = c | |||
| 303 | + | } | |||
| 304 | + | statuses, err := m.cfg.Store.ListStatus(ctx) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, err | |||
| 307 | + | } | |||
| 308 | + | stored := make(map[domain.RepoName]domain.MirrorStatus, len(statuses)) | |||
| 309 | + | for _, s := range statuses { | |||
| 310 | + | stored[s.Repo] = s | |||
| 311 | + | } | |||
| 266 | out := make([]ports.MirrorStatusView, 0, len(repos)) | 312 | out := make([]ports.MirrorStatusView, 0, len(repos)) | ||
| 267 | for _, r := range repos { | 313 | for _, r := range repos { | ||
| 268 | - | v, err := m.GetStatus(ctx, p, r.Name) | 314 | + | var cp *domain.MirrorRepoConfig |
| 315 | + | if c, ok := byRepo[r.Name]; ok { | |||
| 316 | + | cp = &c | |||
| 317 | + | } | |||
| 318 | + | t, err := m.resolve(g, cp, r.Name) | |||
| 269 | if err != nil { | 319 | if err != nil { | ||
| 270 | return nil, err | 320 | return nil, err | ||
| 271 | } | 321 | } | ||
| 272 | - | out = append(out, v) | 322 | + | s, ok := stored[r.Name] |
| 323 | + | if !ok { | |||
| 324 | + | s = domain.MirrorStatus{Repo: r.Name, State: domain.MirrorDisabled} | |||
| 325 | + | } | |||
| 326 | + | out = append(out, m.view(p, t, s, true)) | |||
| 273 | } | 327 | } | ||
| 274 | return out, nil | 328 | return out, nil | ||
| 275 | } | 329 | } | ||
| 276 | 330 | ||||
| 277 | - | // Warning provides an administrative post-receive diagnostic. | 331 | + | // Warning provides the post-receive diagnostic of a failing or paused mirror to the admins of |
| 278 | - | func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string { | 332 | + | // the repository only: it names the remote and the reason. |
| 333 | + | func (m *Mirrors) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string { | |||
| 334 | + | if !p.Allows(domain.ScopeRepoAdmin, repo) { | |||
| 335 | + | return "" | |||
| 336 | + | } | |||
| 279 | s, err := m.status(ctx, repo) | 337 | s, err := m.status(ctx, repo) | ||
| 280 | if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) { | 338 | if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) { | ||
| 281 | return "" | 339 | return "" | ||
| @@ -289,45 +347,19 @@ func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string { | |||||
| 289 | 347 | ||||
| 290 | // Badge returns a viewer-safe state and optional administrative reason. | 348 | // Badge returns a viewer-safe state and optional administrative reason. | ||
| 291 | func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) { | 349 | func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) { | ||
| 292 | - | v, err := m.GetStatus(ctx, p, repo) | 350 | + | v, err := m.statusView(ctx, p, repo, false) |
| 293 | if err != nil { | 351 | if err != nil { | ||
| 294 | return domain.MirrorDisabled, "" | 352 | return domain.MirrorDisabled, "" | ||
| 295 | } | 353 | } | ||
| 296 | return v.Status.State, v.Status.Reason | 354 | return v.Status.State, v.Status.Reason | ||
| 297 | } | 355 | } | ||
| 298 | 356 | ||||
| 299 | - | // testURL substitutes only during validation/resolution; stored and pushed URLs remain intact. | |||
| 300 | - | func (m *Mirrors) testURL(url string) string { | |||
| 301 | - | if m.cfg.AllowAnyURL && strings.HasPrefix(url, "file://") { | |||
| 302 | - | return "git@mirror-test:" + strings.TrimPrefix(url, "file://") | |||
| 303 | - | } | |||
| 304 | - | return url | |||
| 305 | - | } | |||
| 306 | func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error { | 357 | func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error { | ||
| 307 | - | g.URLTemplate = m.testURL(g.URLTemplate) | 358 | + | return domain.ValidateMirrorGlobalConfigWith(g, m.cfg.ValidateURL) |
| 308 | - | return domain.ValidateMirrorGlobalConfig(g) | |||
| 309 | } | 359 | } | ||
| 310 | func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error { | 360 | func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error { | ||
| 311 | - | c.URL = m.testURL(c.URL) | 361 | + | return domain.ValidateMirrorRepoConfigWith(c, m.cfg.ValidateURL) |
| 312 | - | return domain.ValidateMirrorRepoConfig(c) | |||
| 313 | } | 362 | } | ||
| 314 | func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) { | 363 | func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) { | ||
| 315 | - | originalGlobal := g.URLTemplate | 364 | + | return domain.ResolveMirrorWith(g, c, repo, m.cfg.ValidateURL) |
| 316 | - | originalRepo := "" | |||
| 317 | - | g.URLTemplate = m.testURL(g.URLTemplate) | |||
| 318 | - | if c != nil { | |||
| 319 | - | copyConfig := *c | |||
| 320 | - | originalRepo = c.URL | |||
| 321 | - | copyConfig.URL = m.testURL(c.URL) | |||
| 322 | - | c = ©Config | |||
| 323 | - | } | |||
| 324 | - | t, err := domain.ResolveMirror(g, c, repo) | |||
| 325 | - | if err == nil && t.Enabled && m.cfg.AllowAnyURL { | |||
| 326 | - | if originalRepo != "" { | |||
| 327 | - | t.URL = originalRepo | |||
| 328 | - | } else if strings.HasPrefix(originalGlobal, "file://") { | |||
| 329 | - | t.URL, err = domain.ExpandMirrorURL(originalGlobal, repo) | |||
| 330 | - | } | |||
| 331 | - | } | |||
| 332 | - | return t, err | |||
| 333 | } | 365 | } | ||
A internal/usecase/mirrors_config_test.go +276 -0
| @@ -0,0 +1,276 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/usecase" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestMirrorsAuthorizationMatrix(t *testing.T) { | |
| 20 | + | f := newMirrorFixture(t) | |
| 21 | + | ctx := context.Background() | |
| 22 | + | reader := tokenPrincipal(domain.ScopeRepoRead) | |
| 23 | + | admin := tokenPrincipal(domain.ScopeRepoAdmin) | |
| 24 | + | _, err := f.m.GetGlobalConfig(ctx, reader) | |
| 25 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 26 | + | _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{}) | |
| 27 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 28 | + | _, err = f.m.ListStatus(ctx, reader) | |
| 29 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 30 | + | _, err = f.m.RotateKey(ctx, admin) | |
| 31 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 32 | + | // Key show deliberately requires authentication, not owner rights. | |
| 33 | + | _, err = f.m.ShowKey(ctx, reader) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | _, err = f.m.ShowKey(ctx, domain.Anonymous()) | |
| 36 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 37 | + | _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo}) | |
| 38 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 39 | + | err = f.m.RemoveRepoMirror(ctx, reader, f.repo) | |
| 40 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 41 | + | _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true) | |
| 42 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 43 | + | _, err = f.m.Sync(ctx, reader, f.repo, false) | |
| 44 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 45 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK, Detail: "admin detail", Reason: "admin reason"}) | |
| 46 | + | view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Empty(t, view.Status.Detail) | |
| 49 | + | require.Empty(t, view.Status.Reason) | |
| 50 | + | require.Empty(t, view.Target.URL) | |
| 51 | + | view, err = f.m.GetStatus(ctx, admin, f.repo) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.Equal(t, "admin detail", view.Status.Detail) | |
| 54 | + | _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo}) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestMirrorsPrivateStatusAuthorization(t *testing.T) { | |
| 59 | + | f := newMirrorFixture(t) | |
| 60 | + | f.mu.Lock() | |
| 61 | + | f.repos[0].Visibility = domain.VisibilityPrivate | |
| 62 | + | f.mu.Unlock() | |
| 63 | + | _, err := f.m.GetStatus(context.Background(), domain.Anonymous(), f.repo) | |
| 64 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 65 | + | _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo) | |
| 66 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 67 | + | _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestMirrorsWarningIsAdminOnly(t *testing.T) { | |
| 72 | + | f := newMirrorFixture(t) | |
| 73 | + | other := name(t, "other") | |
| 74 | + | failing := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "the remote has commits"} | |
| 75 | + | for _, tc := range []struct { | |
| 76 | + | name string | |
| 77 | + | p domain.Principal | |
| 78 | + | status domain.MirrorStatus | |
| 79 | + | want bool | |
| 80 | + | }{ | |
| 81 | + | {"root", domain.Root(), failing, true}, | |
| 82 | + | {"repo admin", tokenPrincipal(domain.ScopeRepoAdmin, "tools"), failing, true}, | |
| 83 | + | {"admin of another repository", tokenPrincipal(domain.ScopeRepoAdmin, other.String()), failing, false}, | |
| 84 | + | {"writer", tokenPrincipal(domain.ScopeRepoWrite, "tools"), failing, false}, | |
| 85 | + | {"anonymous", domain.Anonymous(), failing, false}, | |
| 86 | + | {"admin, mirror ok", domain.Root(), domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK}, false}, | |
| 87 | + | } { | |
| 88 | + | t.Run(tc.name, func(t *testing.T) { | |
| 89 | + | f.setStatus(tc.status) | |
| 90 | + | w := f.m.Warning(context.Background(), tc.p, f.repo) | |
| 91 | + | if !tc.want { | |
| 92 | + | require.Empty(t, w) | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | require.Contains(t, w, "example.com:acme/tools") | |
| 96 | + | require.Contains(t, w, "paused") | |
| 97 | + | require.Contains(t, w, "the remote has commits") | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestMirrorsBadge(t *testing.T) { | |
| 103 | + | f := newMirrorFixture(t) | |
| 104 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Reason: "retrying"}) | |
| 105 | + | state, reason := f.m.Badge(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin, "tools"), f.repo) | |
| 106 | + | require.Equal(t, domain.MirrorFailing, state) | |
| 107 | + | require.Equal(t, "retrying", reason) | |
| 108 | + | state, reason = f.m.Badge(context.Background(), domain.Anonymous(), f.repo) | |
| 109 | + | require.Equal(t, domain.MirrorFailing, state) | |
| 110 | + | require.Empty(t, reason, "the reason is for admins") | |
| 111 | + | state, _ = f.m.Badge(context.Background(), domain.Root(), name(t, "ghost")) | |
| 112 | + | require.Equal(t, domain.MirrorDisabled, state) | |
| 113 | + | // A badge needs no schedule: with an unparsable-looking cron the view still works and costs no search. | |
| 114 | + | require.NotPanics(t, func() { f.m.Badge(context.Background(), domain.Root(), f.repo) }) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestMirrorsListStatusReadsOnceAndHidesNothingFromOwner(t *testing.T) { | |
| 118 | + | f := newMirrorFixture(t) | |
| 119 | + | second := name(t, "second") | |
| 120 | + | f.addRepo(second) | |
| 121 | + | f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, Reason: "r"}) | |
| 122 | + | list, err := f.m.ListStatus(context.Background(), domain.Root()) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | require.Len(t, list, 2) | |
| 125 | + | require.Equal(t, "git@example.com:acme/tools", list[0].Target.URL) | |
| 126 | + | require.Equal(t, domain.MirrorPaused, list[1].Status.State) | |
| 127 | + | require.False(t, list[0].NextRunAt.IsZero()) | |
| 128 | + | require.True(t, list[1].NextRunAt.IsZero(), "a paused mirror has no next run") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) { | |
| 132 | + | f := newMirrorFixture(t) | |
| 133 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1}) | |
| 134 | + | _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:acme/tools", Cron: "*/5 * * * *"}) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | s := f.statusOf(f.repo) | |
| 137 | + | require.Equal(t, domain.MirrorOK, s.State) | |
| 138 | + | require.Zero(t, s.ConsecutiveFailures) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestMirrorsRepoChangesResetOnlyWhenTheTargetChanged(t *testing.T) { | |
| 142 | + | f := newMirrorFixture(t) | |
| 143 | + | ctx := context.Background() | |
| 144 | + | paused := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1} | |
| 145 | + | f.setStatus(paused) | |
| 146 | + | // The override restates the inherited target: nothing changed, the incident stays. | |
| 147 | + | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |
| 148 | + | require.NoError(t, err) // source changes global -> override: a change | |
| 149 | + | f.setStatus(paused) | |
| 150 | + | writes := f.statusWrites() | |
| 151 | + | _, err = f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | require.Equal(t, writes, f.statusWrites(), "same effective target: no reset") | |
| 154 | + | require.Equal(t, domain.MirrorPaused, f.statusOf(f.repo).State) | |
| 155 | + | // Removing a no-op exclusion, and excluding to the same effect, follow the same rule. | |
| 156 | + | err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State, "override gone: the source changed back to global") | |
| 159 | + | f.setStatus(paused) | |
| 160 | + | writes = f.statusWrites() | |
| 161 | + | err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | require.Equal(t, writes, f.statusWrites(), "no override to remove: no reset") | |
| 164 | + | _, err = f.m.SetRepoExcluded(ctx, domain.Root(), f.repo, true) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.Equal(t, domain.MirrorDisabled, f.statusOf(f.repo).State) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestMirrorsGlobalResetContinuesPastErrors(t *testing.T) { | |
| 170 | + | f := newMirrorFixture(t) | |
| 171 | + | second := name(t, "second") | |
| 172 | + | f.addRepo(second) | |
| 173 | + | f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, ConsecutiveFailures: 1}) | |
| 174 | + | f.failStatusWrites(f.repo, errBoom) | |
| 175 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |
| 176 | + | require.ErrorIs(t, err, errBoom) | |
| 177 | + | require.Equal(t, domain.MirrorOK, f.statusOf(second).State, "the other repository was still reset") | |
| 178 | + | require.Equal(t, "*/10 * * * *", f.global.Cron, "the policy itself is saved") | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) { | |
| 182 | + | f := newMirrorFixture(t) | |
| 183 | + | f.setGlobal(domain.MirrorGlobalConfig{}) | |
| 184 | + | _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |
| 185 | + | var fe *domain.InvalidFieldsError | |
| 186 | + | require.ErrorAs(t, err, &fe) | |
| 187 | + | require.Equal(t, "cron", fe.Fields[0].Field) | |
| 188 | + | require.Empty(t, f.cfgs, "nothing is stored") | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) { | |
| 192 | + | f := newMirrorFixture(t) | |
| 193 | + | f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}) | |
| 194 | + | // Disabling the global with an empty cron leaves the inheriting override with no cron. | |
| 195 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")}) | |
| 196 | + | var fe *domain.InvalidFieldsError | |
| 197 | + | require.ErrorAs(t, err, &fe) | |
| 198 | + | require.True(t, f.global.Enabled, "nothing is stored") | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) { | |
| 202 | + | f := newMirrorFixture(t) | |
| 203 | + | f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}) | |
| 204 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}) | |
| 205 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestMirrorsSetRepoExcludedConcurrentIsSerialized(t *testing.T) { | |
| 211 | + | f := newMirrorFixture(t) | |
| 212 | + | var mu sync.Mutex | |
| 213 | + | active, maxActive := 0, 0 | |
| 214 | + | enter, leave := make(chan struct{}), make(chan struct{}) | |
| 215 | + | f.onPutRepo = func() { | |
| 216 | + | mu.Lock() | |
| 217 | + | active++ | |
| 218 | + | maxActive = max(maxActive, active) | |
| 219 | + | mu.Unlock() | |
| 220 | + | enter <- struct{}{} | |
| 221 | + | <-leave | |
| 222 | + | mu.Lock() | |
| 223 | + | active-- | |
| 224 | + | mu.Unlock() | |
| 225 | + | } | |
| 226 | + | errs := make(chan error, 4) | |
| 227 | + | for i := range 4 { | |
| 228 | + | go func() { | |
| 229 | + | _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, i%2 == 0) | |
| 230 | + | errs <- err | |
| 231 | + | }() | |
| 232 | + | } | |
| 233 | + | // While one writer is blocked inside the store, the others cannot have entered it. | |
| 234 | + | for range 4 { | |
| 235 | + | <-enter | |
| 236 | + | mu.Lock() | |
| 237 | + | require.Equal(t, 1, active) | |
| 238 | + | mu.Unlock() | |
| 239 | + | leave <- struct{}{} | |
| 240 | + | } | |
| 241 | + | for range 4 { | |
| 242 | + | require.NoError(t, <-errs) | |
| 243 | + | } | |
| 244 | + | require.Equal(t, 1, maxActive, "read-modify-write must be serialized") | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func TestMirrorsURLValidatorIsInjectable(t *testing.T) { | |
| 248 | + | for _, tc := range []struct { | |
| 249 | + | name string | |
| 250 | + | validate func(string) error | |
| 251 | + | ok bool | |
| 252 | + | }{ | |
| 253 | + | {"default rejects file", nil, false}, | |
| 254 | + | {"injected accepts file", func(string) error { return nil }, true}, | |
| 255 | + | {"injected rejects", func(string) error { return errors.New("no") }, false}, | |
| 256 | + | } { | |
| 257 | + | t.Run(tc.name, func(t *testing.T) { | |
| 258 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.ValidateURL = tc.validate }) | |
| 259 | + | target, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"}) | |
| 260 | + | if tc.ok { | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | require.Equal(t, "file:///tmp/remotes/tools", target.URL) | |
| 263 | + | } else { | |
| 264 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 265 | + | } | |
| 266 | + | }) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestMirrorsNextRunIsComputedForStatusOnly(t *testing.T) { | |
| 271 | + | f := newMirrorFixture(t) | |
| 272 | + | view, err := f.m.GetStatus(context.Background(), domain.Root(), f.repo) | |
| 273 | + | require.NoError(t, err) | |
| 274 | + | require.True(t, view.NextRunAt.Equal(now.Add(8*time.Minute)) || !view.NextRunAt.IsZero()) | |
| 275 | + | require.Equal(t, 0, view.NextRunAt.Minute()%5) | |
| 276 | + | } | |
| @@ -0,0 +1,276 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | "bnema.dev/ermite/internal/usecase" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestMirrorsAuthorizationMatrix(t *testing.T) { | |||
| 20 | + | f := newMirrorFixture(t) | |||
| 21 | + | ctx := context.Background() | |||
| 22 | + | reader := tokenPrincipal(domain.ScopeRepoRead) | |||
| 23 | + | admin := tokenPrincipal(domain.ScopeRepoAdmin) | |||
| 24 | + | _, err := f.m.GetGlobalConfig(ctx, reader) | |||
| 25 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 26 | + | _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{}) | |||
| 27 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 28 | + | _, err = f.m.ListStatus(ctx, reader) | |||
| 29 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 30 | + | _, err = f.m.RotateKey(ctx, admin) | |||
| 31 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 32 | + | // Key show deliberately requires authentication, not owner rights. | |||
| 33 | + | _, err = f.m.ShowKey(ctx, reader) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | _, err = f.m.ShowKey(ctx, domain.Anonymous()) | |||
| 36 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 37 | + | _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo}) | |||
| 38 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 39 | + | err = f.m.RemoveRepoMirror(ctx, reader, f.repo) | |||
| 40 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 41 | + | _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true) | |||
| 42 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 43 | + | _, err = f.m.Sync(ctx, reader, f.repo, false) | |||
| 44 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 45 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK, Detail: "admin detail", Reason: "admin reason"}) | |||
| 46 | + | view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Empty(t, view.Status.Detail) | |||
| 49 | + | require.Empty(t, view.Status.Reason) | |||
| 50 | + | require.Empty(t, view.Target.URL) | |||
| 51 | + | view, err = f.m.GetStatus(ctx, admin, f.repo) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.Equal(t, "admin detail", view.Status.Detail) | |||
| 54 | + | _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo}) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestMirrorsPrivateStatusAuthorization(t *testing.T) { | |||
| 59 | + | f := newMirrorFixture(t) | |||
| 60 | + | f.mu.Lock() | |||
| 61 | + | f.repos[0].Visibility = domain.VisibilityPrivate | |||
| 62 | + | f.mu.Unlock() | |||
| 63 | + | _, err := f.m.GetStatus(context.Background(), domain.Anonymous(), f.repo) | |||
| 64 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 65 | + | _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo) | |||
| 66 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 67 | + | _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestMirrorsWarningIsAdminOnly(t *testing.T) { | |||
| 72 | + | f := newMirrorFixture(t) | |||
| 73 | + | other := name(t, "other") | |||
| 74 | + | failing := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "the remote has commits"} | |||
| 75 | + | for _, tc := range []struct { | |||
| 76 | + | name string | |||
| 77 | + | p domain.Principal | |||
| 78 | + | status domain.MirrorStatus | |||
| 79 | + | want bool | |||
| 80 | + | }{ | |||
| 81 | + | {"root", domain.Root(), failing, true}, | |||
| 82 | + | {"repo admin", tokenPrincipal(domain.ScopeRepoAdmin, "tools"), failing, true}, | |||
| 83 | + | {"admin of another repository", tokenPrincipal(domain.ScopeRepoAdmin, other.String()), failing, false}, | |||
| 84 | + | {"writer", tokenPrincipal(domain.ScopeRepoWrite, "tools"), failing, false}, | |||
| 85 | + | {"anonymous", domain.Anonymous(), failing, false}, | |||
| 86 | + | {"admin, mirror ok", domain.Root(), domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK}, false}, | |||
| 87 | + | } { | |||
| 88 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 89 | + | f.setStatus(tc.status) | |||
| 90 | + | w := f.m.Warning(context.Background(), tc.p, f.repo) | |||
| 91 | + | if !tc.want { | |||
| 92 | + | require.Empty(t, w) | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | require.Contains(t, w, "example.com:acme/tools") | |||
| 96 | + | require.Contains(t, w, "paused") | |||
| 97 | + | require.Contains(t, w, "the remote has commits") | |||
| 98 | + | }) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestMirrorsBadge(t *testing.T) { | |||
| 103 | + | f := newMirrorFixture(t) | |||
| 104 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Reason: "retrying"}) | |||
| 105 | + | state, reason := f.m.Badge(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin, "tools"), f.repo) | |||
| 106 | + | require.Equal(t, domain.MirrorFailing, state) | |||
| 107 | + | require.Equal(t, "retrying", reason) | |||
| 108 | + | state, reason = f.m.Badge(context.Background(), domain.Anonymous(), f.repo) | |||
| 109 | + | require.Equal(t, domain.MirrorFailing, state) | |||
| 110 | + | require.Empty(t, reason, "the reason is for admins") | |||
| 111 | + | state, _ = f.m.Badge(context.Background(), domain.Root(), name(t, "ghost")) | |||
| 112 | + | require.Equal(t, domain.MirrorDisabled, state) | |||
| 113 | + | // A badge needs no schedule: with an unparsable-looking cron the view still works and costs no search. | |||
| 114 | + | require.NotPanics(t, func() { f.m.Badge(context.Background(), domain.Root(), f.repo) }) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestMirrorsListStatusReadsOnceAndHidesNothingFromOwner(t *testing.T) { | |||
| 118 | + | f := newMirrorFixture(t) | |||
| 119 | + | second := name(t, "second") | |||
| 120 | + | f.addRepo(second) | |||
| 121 | + | f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, Reason: "r"}) | |||
| 122 | + | list, err := f.m.ListStatus(context.Background(), domain.Root()) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | require.Len(t, list, 2) | |||
| 125 | + | require.Equal(t, "git@example.com:acme/tools", list[0].Target.URL) | |||
| 126 | + | require.Equal(t, domain.MirrorPaused, list[1].Status.State) | |||
| 127 | + | require.False(t, list[0].NextRunAt.IsZero()) | |||
| 128 | + | require.True(t, list[1].NextRunAt.IsZero(), "a paused mirror has no next run") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) { | |||
| 132 | + | f := newMirrorFixture(t) | |||
| 133 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1}) | |||
| 134 | + | _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:acme/tools", Cron: "*/5 * * * *"}) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | s := f.statusOf(f.repo) | |||
| 137 | + | require.Equal(t, domain.MirrorOK, s.State) | |||
| 138 | + | require.Zero(t, s.ConsecutiveFailures) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestMirrorsRepoChangesResetOnlyWhenTheTargetChanged(t *testing.T) { | |||
| 142 | + | f := newMirrorFixture(t) | |||
| 143 | + | ctx := context.Background() | |||
| 144 | + | paused := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1} | |||
| 145 | + | f.setStatus(paused) | |||
| 146 | + | // The override restates the inherited target: nothing changed, the incident stays. | |||
| 147 | + | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |||
| 148 | + | require.NoError(t, err) // source changes global -> override: a change | |||
| 149 | + | f.setStatus(paused) | |||
| 150 | + | writes := f.statusWrites() | |||
| 151 | + | _, err = f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | require.Equal(t, writes, f.statusWrites(), "same effective target: no reset") | |||
| 154 | + | require.Equal(t, domain.MirrorPaused, f.statusOf(f.repo).State) | |||
| 155 | + | // Removing a no-op exclusion, and excluding to the same effect, follow the same rule. | |||
| 156 | + | err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo) | |||
| 157 | + | require.NoError(t, err) | |||
| 158 | + | require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State, "override gone: the source changed back to global") | |||
| 159 | + | f.setStatus(paused) | |||
| 160 | + | writes = f.statusWrites() | |||
| 161 | + | err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo) | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | require.Equal(t, writes, f.statusWrites(), "no override to remove: no reset") | |||
| 164 | + | _, err = f.m.SetRepoExcluded(ctx, domain.Root(), f.repo, true) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | require.Equal(t, domain.MirrorDisabled, f.statusOf(f.repo).State) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func TestMirrorsGlobalResetContinuesPastErrors(t *testing.T) { | |||
| 170 | + | f := newMirrorFixture(t) | |||
| 171 | + | second := name(t, "second") | |||
| 172 | + | f.addRepo(second) | |||
| 173 | + | f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, ConsecutiveFailures: 1}) | |||
| 174 | + | f.failStatusWrites(f.repo, errBoom) | |||
| 175 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |||
| 176 | + | require.ErrorIs(t, err, errBoom) | |||
| 177 | + | require.Equal(t, domain.MirrorOK, f.statusOf(second).State, "the other repository was still reset") | |||
| 178 | + | require.Equal(t, "*/10 * * * *", f.global.Cron, "the policy itself is saved") | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) { | |||
| 182 | + | f := newMirrorFixture(t) | |||
| 183 | + | f.setGlobal(domain.MirrorGlobalConfig{}) | |||
| 184 | + | _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |||
| 185 | + | var fe *domain.InvalidFieldsError | |||
| 186 | + | require.ErrorAs(t, err, &fe) | |||
| 187 | + | require.Equal(t, "cron", fe.Fields[0].Field) | |||
| 188 | + | require.Empty(t, f.cfgs, "nothing is stored") | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) { | |||
| 192 | + | f := newMirrorFixture(t) | |||
| 193 | + | f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}) | |||
| 194 | + | // Disabling the global with an empty cron leaves the inheriting override with no cron. | |||
| 195 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")}) | |||
| 196 | + | var fe *domain.InvalidFieldsError | |||
| 197 | + | require.ErrorAs(t, err, &fe) | |||
| 198 | + | require.True(t, f.global.Enabled, "nothing is stored") | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) { | |||
| 202 | + | f := newMirrorFixture(t) | |||
| 203 | + | f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}) | |||
| 204 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}) | |||
| 205 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func TestMirrorsSetRepoExcludedConcurrentIsSerialized(t *testing.T) { | |||
| 211 | + | f := newMirrorFixture(t) | |||
| 212 | + | var mu sync.Mutex | |||
| 213 | + | active, maxActive := 0, 0 | |||
| 214 | + | enter, leave := make(chan struct{}), make(chan struct{}) | |||
| 215 | + | f.onPutRepo = func() { | |||
| 216 | + | mu.Lock() | |||
| 217 | + | active++ | |||
| 218 | + | maxActive = max(maxActive, active) | |||
| 219 | + | mu.Unlock() | |||
| 220 | + | enter <- struct{}{} | |||
| 221 | + | <-leave | |||
| 222 | + | mu.Lock() | |||
| 223 | + | active-- | |||
| 224 | + | mu.Unlock() | |||
| 225 | + | } | |||
| 226 | + | errs := make(chan error, 4) | |||
| 227 | + | for i := range 4 { | |||
| 228 | + | go func() { | |||
| 229 | + | _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, i%2 == 0) | |||
| 230 | + | errs <- err | |||
| 231 | + | }() | |||
| 232 | + | } | |||
| 233 | + | // While one writer is blocked inside the store, the others cannot have entered it. | |||
| 234 | + | for range 4 { | |||
| 235 | + | <-enter | |||
| 236 | + | mu.Lock() | |||
| 237 | + | require.Equal(t, 1, active) | |||
| 238 | + | mu.Unlock() | |||
| 239 | + | leave <- struct{}{} | |||
| 240 | + | } | |||
| 241 | + | for range 4 { | |||
| 242 | + | require.NoError(t, <-errs) | |||
| 243 | + | } | |||
| 244 | + | require.Equal(t, 1, maxActive, "read-modify-write must be serialized") | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func TestMirrorsURLValidatorIsInjectable(t *testing.T) { | |||
| 248 | + | for _, tc := range []struct { | |||
| 249 | + | name string | |||
| 250 | + | validate func(string) error | |||
| 251 | + | ok bool | |||
| 252 | + | }{ | |||
| 253 | + | {"default rejects file", nil, false}, | |||
| 254 | + | {"injected accepts file", func(string) error { return nil }, true}, | |||
| 255 | + | {"injected rejects", func(string) error { return errors.New("no") }, false}, | |||
| 256 | + | } { | |||
| 257 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 258 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.ValidateURL = tc.validate }) | |||
| 259 | + | target, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"}) | |||
| 260 | + | if tc.ok { | |||
| 261 | + | require.NoError(t, err) | |||
| 262 | + | require.Equal(t, "file:///tmp/remotes/tools", target.URL) | |||
| 263 | + | } else { | |||
| 264 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 265 | + | } | |||
| 266 | + | }) | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestMirrorsNextRunIsComputedForStatusOnly(t *testing.T) { | |||
| 271 | + | f := newMirrorFixture(t) | |||
| 272 | + | view, err := f.m.GetStatus(context.Background(), domain.Root(), f.repo) | |||
| 273 | + | require.NoError(t, err) | |||
| 274 | + | require.True(t, view.NextRunAt.Equal(now.Add(8*time.Minute)) || !view.NextRunAt.IsZero()) | |||
| 275 | + | require.Equal(t, 0, view.NextRunAt.Minute()%5) | |||
| 276 | + | } | |||
A internal/usecase/mirrors_fixture_test.go +266 -0
| @@ -0,0 +1,266 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/ed25519" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // mirrorFixture is a Mirrors use case over in-memory state behind the mocks. Every mock | |
| 21 | + | // answers from the fields below under mu, so tests change the world with the setters and | |
| 22 | + | // never mutate a mock after construction. | |
| 23 | + | type mirrorFixture struct { | |
| 24 | + | m *usecase.Mirrors | |
| 25 | + | push *mocks.MirrorPusher | |
| 26 | + | keys *mocks.MirrorKeyStore | |
| 27 | + | sealer *mocks.SecretSealer | |
| 28 | + | repo domain.RepoName | |
| 29 | + | ||
| 30 | + | mu sync.Mutex | |
| 31 | + | at time.Time | |
| 32 | + | global domain.MirrorGlobalConfig | |
| 33 | + | repos []domain.Repo | |
| 34 | + | cfgs map[domain.RepoName]domain.MirrorRepoConfig | |
| 35 | + | status map[domain.RepoName]domain.MirrorStatus | |
| 36 | + | snaps map[domain.RepoName]domain.MirrorSnapshot | |
| 37 | + | refs []domain.Ref | |
| 38 | + | refsErr error | |
| 39 | + | branchErr error | |
| 40 | + | listRefs int | |
| 41 | + | statusPuts int | |
| 42 | + | events []domain.Event | |
| 43 | + | onPutRepo func() // runs inside PutRepoConfig, set before concurrency starts | |
| 44 | + | statusErr map[domain.RepoName]error | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func newMirrorFixture(t *testing.T) *mirrorFixture { | |
| 48 | + | return newMirrorFixtureWith(t, nil) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func newMirrorFixtureWith(t *testing.T, tweak func(*usecase.MirrorsConfig)) *mirrorFixture { | |
| 52 | + | t.Helper() | |
| 53 | + | f := &mirrorFixture{ | |
| 54 | + | push: mocks.NewMirrorPusher(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t), | |
| 55 | + | at: now, repo: name(t, "tools"), | |
| 56 | + | global: domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, | |
| 57 | + | cfgs: map[domain.RepoName]domain.MirrorRepoConfig{}, status: map[domain.RepoName]domain.MirrorStatus{}, snaps: map[domain.RepoName]domain.MirrorSnapshot{}, statusErr: map[domain.RepoName]error{}, | |
| 58 | + | refs: []domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, | |
| 59 | + | } | |
| 60 | + | f.addRepo(f.repo) | |
| 61 | + | store, meta, git, reader, bus, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewRepoReader(t), mocks.NewEventBus(t), mocks.NewClock(t) | |
| 62 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }).Maybe() | |
| 63 | + | meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.Repo, error) { | |
| 64 | + | f.mu.Lock() | |
| 65 | + | defer f.mu.Unlock() | |
| 66 | + | for _, r := range f.repos { | |
| 67 | + | if r.Name == n { | |
| 68 | + | return r, nil | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | return domain.Repo{}, domain.ErrNotFound | |
| 72 | + | }).Maybe() | |
| 73 | + | meta.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Repo, error) { | |
| 74 | + | f.mu.Lock() | |
| 75 | + | defer f.mu.Unlock() | |
| 76 | + | return append([]domain.Repo(nil), f.repos...), nil | |
| 77 | + | }).Maybe() | |
| 78 | + | store.EXPECT().GetGlobal(mock.Anything).RunAndReturn(func(context.Context) (domain.MirrorGlobalConfig, error) { | |
| 79 | + | f.mu.Lock() | |
| 80 | + | defer f.mu.Unlock() | |
| 81 | + | return f.global, nil | |
| 82 | + | }).Maybe() | |
| 83 | + | store.EXPECT().PutGlobal(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, g domain.MirrorGlobalConfig) error { | |
| 84 | + | f.mu.Lock() | |
| 85 | + | defer f.mu.Unlock() | |
| 86 | + | f.global = g | |
| 87 | + | return nil | |
| 88 | + | }).Maybe() | |
| 89 | + | store.EXPECT().GetRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorRepoConfig, error) { | |
| 90 | + | f.mu.Lock() | |
| 91 | + | defer f.mu.Unlock() | |
| 92 | + | if c, ok := f.cfgs[n]; ok { | |
| 93 | + | return c, nil | |
| 94 | + | } | |
| 95 | + | return domain.MirrorRepoConfig{}, domain.ErrNotFound | |
| 96 | + | }).Maybe() | |
| 97 | + | store.EXPECT().PutRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, c domain.MirrorRepoConfig) error { | |
| 98 | + | if f.onPutRepo != nil { | |
| 99 | + | f.onPutRepo() | |
| 100 | + | } | |
| 101 | + | f.mu.Lock() | |
| 102 | + | defer f.mu.Unlock() | |
| 103 | + | f.cfgs[c.Repo] = c | |
| 104 | + | return nil | |
| 105 | + | }).Maybe() | |
| 106 | + | store.EXPECT().DeleteRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) error { | |
| 107 | + | f.mu.Lock() | |
| 108 | + | defer f.mu.Unlock() | |
| 109 | + | delete(f.cfgs, n) | |
| 110 | + | return nil | |
| 111 | + | }).Maybe() | |
| 112 | + | store.EXPECT().ListRepoConfigs(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorRepoConfig, error) { | |
| 113 | + | f.mu.Lock() | |
| 114 | + | defer f.mu.Unlock() | |
| 115 | + | var out []domain.MirrorRepoConfig | |
| 116 | + | for _, c := range f.cfgs { | |
| 117 | + | out = append(out, c) | |
| 118 | + | } | |
| 119 | + | return out, nil | |
| 120 | + | }).Maybe() | |
| 121 | + | store.EXPECT().GetStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorStatus, error) { | |
| 122 | + | f.mu.Lock() | |
| 123 | + | defer f.mu.Unlock() | |
| 124 | + | if s, ok := f.status[n]; ok { | |
| 125 | + | return s, nil | |
| 126 | + | } | |
| 127 | + | return domain.MirrorStatus{}, domain.ErrNotFound | |
| 128 | + | }).Maybe() | |
| 129 | + | store.EXPECT().PutStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, s domain.MirrorStatus) error { | |
| 130 | + | f.mu.Lock() | |
| 131 | + | defer f.mu.Unlock() | |
| 132 | + | if err := f.statusErr[s.Repo]; err != nil { | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | f.status[s.Repo] = s | |
| 136 | + | f.statusPuts++ | |
| 137 | + | return nil | |
| 138 | + | }).Maybe() | |
| 139 | + | store.EXPECT().ListStatus(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorStatus, error) { | |
| 140 | + | f.mu.Lock() | |
| 141 | + | defer f.mu.Unlock() | |
| 142 | + | var out []domain.MirrorStatus | |
| 143 | + | for _, s := range f.status { | |
| 144 | + | out = append(out, s) | |
| 145 | + | } | |
| 146 | + | return out, nil | |
| 147 | + | }).Maybe() | |
| 148 | + | store.EXPECT().GetSnapshot(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorSnapshot, error) { | |
| 149 | + | f.mu.Lock() | |
| 150 | + | defer f.mu.Unlock() | |
| 151 | + | if s, ok := f.snaps[n]; ok { | |
| 152 | + | return s, nil | |
| 153 | + | } | |
| 154 | + | return domain.MirrorSnapshot{}, domain.ErrNotFound | |
| 155 | + | }).Maybe() | |
| 156 | + | store.EXPECT().PutSnapshot(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName, s domain.MirrorSnapshot) error { | |
| 157 | + | f.mu.Lock() | |
| 158 | + | defer f.mu.Unlock() | |
| 159 | + | f.snaps[n] = s | |
| 160 | + | return nil | |
| 161 | + | }).Maybe() | |
| 162 | + | reader.EXPECT().ListRefs(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.Ref, error) { | |
| 163 | + | f.mu.Lock() | |
| 164 | + | defer f.mu.Unlock() | |
| 165 | + | f.listRefs++ | |
| 166 | + | return f.refs, f.refsErr | |
| 167 | + | }).Maybe() | |
| 168 | + | git.EXPECT().DefaultBranch(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) (string, bool, error) { | |
| 169 | + | f.mu.Lock() | |
| 170 | + | defer f.mu.Unlock() | |
| 171 | + | return "main", true, f.branchErr | |
| 172 | + | }).Maybe() | |
| 173 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) { | |
| 174 | + | f.mu.Lock() | |
| 175 | + | defer f.mu.Unlock() | |
| 176 | + | f.events = append(f.events, ev) | |
| 177 | + | }).Maybe() | |
| 178 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("sealed"), now, nil).Maybe() | |
| 179 | + | f.sealer.EXPECT().Open([]byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe() | |
| 180 | + | cfg := usecase.MirrorsConfig{Store: store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: reader, Bus: bus, Clock: clock, ScheduleTick: time.Hour} | |
| 181 | + | if tweak != nil { | |
| 182 | + | tweak(&cfg) | |
| 183 | + | } | |
| 184 | + | f.m = usecase.NewMirrors(cfg) | |
| 185 | + | f.m.Start() | |
| 186 | + | t.Cleanup(func() { | |
| 187 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 188 | + | defer cancel() | |
| 189 | + | f.m.Close(ctx) | |
| 190 | + | }) | |
| 191 | + | return f | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // addRepo declares a public repository whose mirror starts OK. | |
| 195 | + | func (f *mirrorFixture) addRepo(n domain.RepoName) { | |
| 196 | + | f.mu.Lock() | |
| 197 | + | defer f.mu.Unlock() | |
| 198 | + | f.repos = append(f.repos, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now}) | |
| 199 | + | f.status[n] = domain.MirrorStatus{Repo: n, State: domain.MirrorOK} | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() } | |
| 203 | + | func (f *mirrorFixture) setStatus(s domain.MirrorStatus) { | |
| 204 | + | f.mu.Lock() | |
| 205 | + | defer f.mu.Unlock() | |
| 206 | + | f.status[s.Repo] = s | |
| 207 | + | } | |
| 208 | + | func (f *mirrorFixture) setSnapshot(n domain.RepoName, s domain.MirrorSnapshot) { | |
| 209 | + | f.mu.Lock() | |
| 210 | + | defer f.mu.Unlock() | |
| 211 | + | f.snaps[n] = s | |
| 212 | + | } | |
| 213 | + | func (f *mirrorFixture) setRepoConfig(c domain.MirrorRepoConfig) { | |
| 214 | + | f.mu.Lock() | |
| 215 | + | defer f.mu.Unlock() | |
| 216 | + | f.cfgs[c.Repo] = c | |
| 217 | + | } | |
| 218 | + | func (f *mirrorFixture) setGlobal(g domain.MirrorGlobalConfig) { | |
| 219 | + | f.mu.Lock() | |
| 220 | + | defer f.mu.Unlock() | |
| 221 | + | f.global = g | |
| 222 | + | } | |
| 223 | + | func (f *mirrorFixture) failStatusWrites(n domain.RepoName, err error) { | |
| 224 | + | f.mu.Lock() | |
| 225 | + | defer f.mu.Unlock() | |
| 226 | + | f.statusErr[n] = err | |
| 227 | + | } | |
| 228 | + | func (f *mirrorFixture) snapshotOf(n domain.RepoName) (domain.MirrorSnapshot, bool) { | |
| 229 | + | f.mu.Lock() | |
| 230 | + | defer f.mu.Unlock() | |
| 231 | + | s, ok := f.snaps[n] | |
| 232 | + | return s, ok | |
| 233 | + | } | |
| 234 | + | func (f *mirrorFixture) statusOf(n domain.RepoName) domain.MirrorStatus { | |
| 235 | + | f.mu.Lock() | |
| 236 | + | defer f.mu.Unlock() | |
| 237 | + | return f.status[n] | |
| 238 | + | } | |
| 239 | + | func (f *mirrorFixture) eventList() []domain.Event { | |
| 240 | + | f.mu.Lock() | |
| 241 | + | defer f.mu.Unlock() | |
| 242 | + | return append([]domain.Event(nil), f.events...) | |
| 243 | + | } | |
| 244 | + | func (f *mirrorFixture) refReads() int { f.mu.Lock(); defer f.mu.Unlock(); return f.listRefs } | |
| 245 | + | func (f *mirrorFixture) statusWrites() int { | |
| 246 | + | f.mu.Lock() | |
| 247 | + | defer f.mu.Unlock() | |
| 248 | + | return f.statusPuts | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // drain waits until no sync is queued or running: the deterministic barrier before asserting | |
| 252 | + | // that something did not happen. | |
| 253 | + | func (f *mirrorFixture) drain(t *testing.T) { | |
| 254 | + | t.Helper() | |
| 255 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 256 | + | defer cancel() | |
| 257 | + | f.m.Wait(ctx) | |
| 258 | + | require.NoError(t, ctx.Err(), "syncs did not finish") | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func mirrorOID(t *testing.T) domain.ObjectID { | |
| 262 | + | t.Helper() | |
| 263 | + | id, err := domain.ParseObjectID(oidA) | |
| 264 | + | require.NoError(t, err) | |
| 265 | + | return id | |
| 266 | + | } | |
| @@ -0,0 +1,266 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/ed25519" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | + | "bnema.dev/ermite/internal/usecase" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // mirrorFixture is a Mirrors use case over in-memory state behind the mocks. Every mock | |||
| 21 | + | // answers from the fields below under mu, so tests change the world with the setters and | |||
| 22 | + | // never mutate a mock after construction. | |||
| 23 | + | type mirrorFixture struct { | |||
| 24 | + | m *usecase.Mirrors | |||
| 25 | + | push *mocks.MirrorPusher | |||
| 26 | + | keys *mocks.MirrorKeyStore | |||
| 27 | + | sealer *mocks.SecretSealer | |||
| 28 | + | repo domain.RepoName | |||
| 29 | + | ||||
| 30 | + | mu sync.Mutex | |||
| 31 | + | at time.Time | |||
| 32 | + | global domain.MirrorGlobalConfig | |||
| 33 | + | repos []domain.Repo | |||
| 34 | + | cfgs map[domain.RepoName]domain.MirrorRepoConfig | |||
| 35 | + | status map[domain.RepoName]domain.MirrorStatus | |||
| 36 | + | snaps map[domain.RepoName]domain.MirrorSnapshot | |||
| 37 | + | refs []domain.Ref | |||
| 38 | + | refsErr error | |||
| 39 | + | branchErr error | |||
| 40 | + | listRefs int | |||
| 41 | + | statusPuts int | |||
| 42 | + | events []domain.Event | |||
| 43 | + | onPutRepo func() // runs inside PutRepoConfig, set before concurrency starts | |||
| 44 | + | statusErr map[domain.RepoName]error | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func newMirrorFixture(t *testing.T) *mirrorFixture { | |||
| 48 | + | return newMirrorFixtureWith(t, nil) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func newMirrorFixtureWith(t *testing.T, tweak func(*usecase.MirrorsConfig)) *mirrorFixture { | |||
| 52 | + | t.Helper() | |||
| 53 | + | f := &mirrorFixture{ | |||
| 54 | + | push: mocks.NewMirrorPusher(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t), | |||
| 55 | + | at: now, repo: name(t, "tools"), | |||
| 56 | + | global: domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, | |||
| 57 | + | cfgs: map[domain.RepoName]domain.MirrorRepoConfig{}, status: map[domain.RepoName]domain.MirrorStatus{}, snaps: map[domain.RepoName]domain.MirrorSnapshot{}, statusErr: map[domain.RepoName]error{}, | |||
| 58 | + | refs: []domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, | |||
| 59 | + | } | |||
| 60 | + | f.addRepo(f.repo) | |||
| 61 | + | store, meta, git, reader, bus, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewRepoReader(t), mocks.NewEventBus(t), mocks.NewClock(t) | |||
| 62 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }).Maybe() | |||
| 63 | + | meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.Repo, error) { | |||
| 64 | + | f.mu.Lock() | |||
| 65 | + | defer f.mu.Unlock() | |||
| 66 | + | for _, r := range f.repos { | |||
| 67 | + | if r.Name == n { | |||
| 68 | + | return r, nil | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 72 | + | }).Maybe() | |||
| 73 | + | meta.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Repo, error) { | |||
| 74 | + | f.mu.Lock() | |||
| 75 | + | defer f.mu.Unlock() | |||
| 76 | + | return append([]domain.Repo(nil), f.repos...), nil | |||
| 77 | + | }).Maybe() | |||
| 78 | + | store.EXPECT().GetGlobal(mock.Anything).RunAndReturn(func(context.Context) (domain.MirrorGlobalConfig, error) { | |||
| 79 | + | f.mu.Lock() | |||
| 80 | + | defer f.mu.Unlock() | |||
| 81 | + | return f.global, nil | |||
| 82 | + | }).Maybe() | |||
| 83 | + | store.EXPECT().PutGlobal(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, g domain.MirrorGlobalConfig) error { | |||
| 84 | + | f.mu.Lock() | |||
| 85 | + | defer f.mu.Unlock() | |||
| 86 | + | f.global = g | |||
| 87 | + | return nil | |||
| 88 | + | }).Maybe() | |||
| 89 | + | store.EXPECT().GetRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorRepoConfig, error) { | |||
| 90 | + | f.mu.Lock() | |||
| 91 | + | defer f.mu.Unlock() | |||
| 92 | + | if c, ok := f.cfgs[n]; ok { | |||
| 93 | + | return c, nil | |||
| 94 | + | } | |||
| 95 | + | return domain.MirrorRepoConfig{}, domain.ErrNotFound | |||
| 96 | + | }).Maybe() | |||
| 97 | + | store.EXPECT().PutRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, c domain.MirrorRepoConfig) error { | |||
| 98 | + | if f.onPutRepo != nil { | |||
| 99 | + | f.onPutRepo() | |||
| 100 | + | } | |||
| 101 | + | f.mu.Lock() | |||
| 102 | + | defer f.mu.Unlock() | |||
| 103 | + | f.cfgs[c.Repo] = c | |||
| 104 | + | return nil | |||
| 105 | + | }).Maybe() | |||
| 106 | + | store.EXPECT().DeleteRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) error { | |||
| 107 | + | f.mu.Lock() | |||
| 108 | + | defer f.mu.Unlock() | |||
| 109 | + | delete(f.cfgs, n) | |||
| 110 | + | return nil | |||
| 111 | + | }).Maybe() | |||
| 112 | + | store.EXPECT().ListRepoConfigs(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorRepoConfig, error) { | |||
| 113 | + | f.mu.Lock() | |||
| 114 | + | defer f.mu.Unlock() | |||
| 115 | + | var out []domain.MirrorRepoConfig | |||
| 116 | + | for _, c := range f.cfgs { | |||
| 117 | + | out = append(out, c) | |||
| 118 | + | } | |||
| 119 | + | return out, nil | |||
| 120 | + | }).Maybe() | |||
| 121 | + | store.EXPECT().GetStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorStatus, error) { | |||
| 122 | + | f.mu.Lock() | |||
| 123 | + | defer f.mu.Unlock() | |||
| 124 | + | if s, ok := f.status[n]; ok { | |||
| 125 | + | return s, nil | |||
| 126 | + | } | |||
| 127 | + | return domain.MirrorStatus{}, domain.ErrNotFound | |||
| 128 | + | }).Maybe() | |||
| 129 | + | store.EXPECT().PutStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, s domain.MirrorStatus) error { | |||
| 130 | + | f.mu.Lock() | |||
| 131 | + | defer f.mu.Unlock() | |||
| 132 | + | if err := f.statusErr[s.Repo]; err != nil { | |||
| 133 | + | return err | |||
| 134 | + | } | |||
| 135 | + | f.status[s.Repo] = s | |||
| 136 | + | f.statusPuts++ | |||
| 137 | + | return nil | |||
| 138 | + | }).Maybe() | |||
| 139 | + | store.EXPECT().ListStatus(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorStatus, error) { | |||
| 140 | + | f.mu.Lock() | |||
| 141 | + | defer f.mu.Unlock() | |||
| 142 | + | var out []domain.MirrorStatus | |||
| 143 | + | for _, s := range f.status { | |||
| 144 | + | out = append(out, s) | |||
| 145 | + | } | |||
| 146 | + | return out, nil | |||
| 147 | + | }).Maybe() | |||
| 148 | + | store.EXPECT().GetSnapshot(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorSnapshot, error) { | |||
| 149 | + | f.mu.Lock() | |||
| 150 | + | defer f.mu.Unlock() | |||
| 151 | + | if s, ok := f.snaps[n]; ok { | |||
| 152 | + | return s, nil | |||
| 153 | + | } | |||
| 154 | + | return domain.MirrorSnapshot{}, domain.ErrNotFound | |||
| 155 | + | }).Maybe() | |||
| 156 | + | store.EXPECT().PutSnapshot(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName, s domain.MirrorSnapshot) error { | |||
| 157 | + | f.mu.Lock() | |||
| 158 | + | defer f.mu.Unlock() | |||
| 159 | + | f.snaps[n] = s | |||
| 160 | + | return nil | |||
| 161 | + | }).Maybe() | |||
| 162 | + | reader.EXPECT().ListRefs(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.Ref, error) { | |||
| 163 | + | f.mu.Lock() | |||
| 164 | + | defer f.mu.Unlock() | |||
| 165 | + | f.listRefs++ | |||
| 166 | + | return f.refs, f.refsErr | |||
| 167 | + | }).Maybe() | |||
| 168 | + | git.EXPECT().DefaultBranch(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) (string, bool, error) { | |||
| 169 | + | f.mu.Lock() | |||
| 170 | + | defer f.mu.Unlock() | |||
| 171 | + | return "main", true, f.branchErr | |||
| 172 | + | }).Maybe() | |||
| 173 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) { | |||
| 174 | + | f.mu.Lock() | |||
| 175 | + | defer f.mu.Unlock() | |||
| 176 | + | f.events = append(f.events, ev) | |||
| 177 | + | }).Maybe() | |||
| 178 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("sealed"), now, nil).Maybe() | |||
| 179 | + | f.sealer.EXPECT().Open([]byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe() | |||
| 180 | + | cfg := usecase.MirrorsConfig{Store: store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: reader, Bus: bus, Clock: clock, ScheduleTick: time.Hour} | |||
| 181 | + | if tweak != nil { | |||
| 182 | + | tweak(&cfg) | |||
| 183 | + | } | |||
| 184 | + | f.m = usecase.NewMirrors(cfg) | |||
| 185 | + | f.m.Start() | |||
| 186 | + | t.Cleanup(func() { | |||
| 187 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 188 | + | defer cancel() | |||
| 189 | + | f.m.Close(ctx) | |||
| 190 | + | }) | |||
| 191 | + | return f | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // addRepo declares a public repository whose mirror starts OK. | |||
| 195 | + | func (f *mirrorFixture) addRepo(n domain.RepoName) { | |||
| 196 | + | f.mu.Lock() | |||
| 197 | + | defer f.mu.Unlock() | |||
| 198 | + | f.repos = append(f.repos, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now}) | |||
| 199 | + | f.status[n] = domain.MirrorStatus{Repo: n, State: domain.MirrorOK} | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() } | |||
| 203 | + | func (f *mirrorFixture) setStatus(s domain.MirrorStatus) { | |||
| 204 | + | f.mu.Lock() | |||
| 205 | + | defer f.mu.Unlock() | |||
| 206 | + | f.status[s.Repo] = s | |||
| 207 | + | } | |||
| 208 | + | func (f *mirrorFixture) setSnapshot(n domain.RepoName, s domain.MirrorSnapshot) { | |||
| 209 | + | f.mu.Lock() | |||
| 210 | + | defer f.mu.Unlock() | |||
| 211 | + | f.snaps[n] = s | |||
| 212 | + | } | |||
| 213 | + | func (f *mirrorFixture) setRepoConfig(c domain.MirrorRepoConfig) { | |||
| 214 | + | f.mu.Lock() | |||
| 215 | + | defer f.mu.Unlock() | |||
| 216 | + | f.cfgs[c.Repo] = c | |||
| 217 | + | } | |||
| 218 | + | func (f *mirrorFixture) setGlobal(g domain.MirrorGlobalConfig) { | |||
| 219 | + | f.mu.Lock() | |||
| 220 | + | defer f.mu.Unlock() | |||
| 221 | + | f.global = g | |||
| 222 | + | } | |||
| 223 | + | func (f *mirrorFixture) failStatusWrites(n domain.RepoName, err error) { | |||
| 224 | + | f.mu.Lock() | |||
| 225 | + | defer f.mu.Unlock() | |||
| 226 | + | f.statusErr[n] = err | |||
| 227 | + | } | |||
| 228 | + | func (f *mirrorFixture) snapshotOf(n domain.RepoName) (domain.MirrorSnapshot, bool) { | |||
| 229 | + | f.mu.Lock() | |||
| 230 | + | defer f.mu.Unlock() | |||
| 231 | + | s, ok := f.snaps[n] | |||
| 232 | + | return s, ok | |||
| 233 | + | } | |||
| 234 | + | func (f *mirrorFixture) statusOf(n domain.RepoName) domain.MirrorStatus { | |||
| 235 | + | f.mu.Lock() | |||
| 236 | + | defer f.mu.Unlock() | |||
| 237 | + | return f.status[n] | |||
| 238 | + | } | |||
| 239 | + | func (f *mirrorFixture) eventList() []domain.Event { | |||
| 240 | + | f.mu.Lock() | |||
| 241 | + | defer f.mu.Unlock() | |||
| 242 | + | return append([]domain.Event(nil), f.events...) | |||
| 243 | + | } | |||
| 244 | + | func (f *mirrorFixture) refReads() int { f.mu.Lock(); defer f.mu.Unlock(); return f.listRefs } | |||
| 245 | + | func (f *mirrorFixture) statusWrites() int { | |||
| 246 | + | f.mu.Lock() | |||
| 247 | + | defer f.mu.Unlock() | |||
| 248 | + | return f.statusPuts | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // drain waits until no sync is queued or running: the deterministic barrier before asserting | |||
| 252 | + | // that something did not happen. | |||
| 253 | + | func (f *mirrorFixture) drain(t *testing.T) { | |||
| 254 | + | t.Helper() | |||
| 255 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 256 | + | defer cancel() | |||
| 257 | + | f.m.Wait(ctx) | |||
| 258 | + | require.NoError(t, ctx.Err(), "syncs did not finish") | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func mirrorOID(t *testing.T) domain.ObjectID { | |||
| 262 | + | t.Helper() | |||
| 263 | + | id, err := domain.ParseObjectID(oidA) | |||
| 264 | + | require.NoError(t, err) | |||
| 265 | + | return id | |||
| 266 | + | } | |||
M internal/usecase/mirrors_key.go +81 -35
| @@ -11,73 +11,119 @@ import ( | |||
| 11 | 11 | "strings" | |
| 12 | 12 | ||
| 13 | 13 | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 17 | + | const noSecretsKeyDetail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key" | |
| 18 | + | ||
| 16 | 19 | // ShowKey lazily generates the server's mirror key for authenticated callers. | |
| 17 | 20 | func (m *Mirrors) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) { | |
| 18 | 21 | if p.IsAnonymous() { | |
| 19 | 22 | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | |
| 20 | 23 | } | |
| 21 | - | return m.publicKey(ctx, false) | |
| 24 | + | if m.cfg.Sealer == nil { | |
| 25 | + | return domain.MirrorPublicKey{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail) | |
| 26 | + | } | |
| 27 | + | m.keyMu.Lock() | |
| 28 | + | defer m.keyMu.Unlock() | |
| 29 | + | if err := m.loadKey(ctx, true); err != nil { | |
| 30 | + | return domain.MirrorPublicKey{}, err | |
| 31 | + | } | |
| 32 | + | return m.publicKeyLocked(), nil | |
| 22 | 33 | } | |
| 23 | 34 | ||
| 24 | - | // RotateKey immediately replaces the mirror key. Remote authorization must be updated separately. | |
| 25 | - | func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) { | |
| 35 | + | // RotateKey immediately replaces the mirror key and returns the replaced one (zero when there | |
| 36 | + | // was none, or when it could not be opened). Remote authorization must be updated separately. | |
| 37 | + | func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) { | |
| 26 | 38 | if p.IsAnonymous() { | |
| 27 | - | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | |
| 39 | + | return ports.MirrorKeyRotation{}, domain.ErrUnauthenticated | |
| 28 | 40 | } | |
| 29 | 41 | if !p.HasOwnerRights() { | |
| 30 | - | return domain.MirrorPublicKey{}, domain.ErrForbidden | |
| 42 | + | return ports.MirrorKeyRotation{}, domain.ErrForbidden | |
| 43 | + | } | |
| 44 | + | if m.cfg.Sealer == nil { | |
| 45 | + | return ports.MirrorKeyRotation{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail) | |
| 31 | 46 | } | |
| 32 | - | return m.publicKey(ctx, true) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func (m *Mirrors) publicKey(ctx context.Context, rotate bool) (domain.MirrorPublicKey, error) { | |
| 36 | 47 | m.keyMu.Lock() | |
| 37 | 48 | defer m.keyMu.Unlock() | |
| 38 | - | if err := m.loadKey(ctx, rotate); err != nil { | |
| 39 | - | return domain.MirrorPublicKey{}, err | |
| 49 | + | var previous domain.MirrorPublicKey | |
| 50 | + | // A missing key is not created just to be replaced, and a stored key that cannot be read | |
| 51 | + | // or opened must not prevent its replacement. | |
| 52 | + | if err := m.loadKey(ctx, false); err == nil { | |
| 53 | + | previous = m.publicKeyLocked() | |
| 54 | + | } | |
| 55 | + | if err := m.storeNewKey(ctx, false); err != nil { | |
| 56 | + | return ports.MirrorKeyRotation{}, err | |
| 40 | 57 | } | |
| 58 | + | return ports.MirrorKeyRotation{Key: m.publicKeyLocked(), Previous: previous}, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (m *Mirrors) publicKeyLocked() domain.MirrorPublicKey { | |
| 41 | 62 | host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@") | |
| 42 | - | return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated), nil | |
| 63 | + | return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated) | |
| 43 | 64 | } | |
| 44 | 65 | ||
| 45 | - | // loadKey is called with keyMu held. Only successfully persisted material is cached. | |
| 46 | - | func (m *Mirrors) loadKey(ctx context.Context, rotate bool) error { | |
| 66 | + | // privateKey returns a copy so a transport cannot mutate the cached key. Failures are | |
| 67 | + | // *domain.MirrorError of class internal. | |
| 68 | + | func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) { | |
| 47 | 69 | if m.cfg.Sealer == nil { | |
| 48 | - | return fmt.Errorf("%w: the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key", domain.ErrPreconditionFailed) | |
| 70 | + | return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: noSecretsKeyDetail} | |
| 49 | 71 | } | |
| 50 | - | if !rotate && m.key != nil { | |
| 72 | + | m.keyMu.Lock() | |
| 73 | + | defer m.keyMu.Unlock() | |
| 74 | + | if err := m.loadKey(ctx, true); err != nil { | |
| 75 | + | detail := "cannot read the mirror key" | |
| 76 | + | if errors.Is(err, domain.ErrPreconditionFailed) { | |
| 77 | + | detail = err.Error() | |
| 78 | + | } | |
| 79 | + | return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |
| 80 | + | } | |
| 81 | + | return append(ed25519.PrivateKey(nil), m.key...), nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // loadKey is called with keyMu held and a sealer present: it caches the stored key, creating | |
| 85 | + | // one when none exists and create is set (ErrNotFound otherwise). | |
| 86 | + | func (m *Mirrors) loadKey(ctx context.Context, create bool) error { | |
| 87 | + | if m.key != nil { | |
| 51 | 88 | return nil | |
| 52 | 89 | } | |
| 53 | - | if !rotate { | |
| 54 | - | sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx) | |
| 55 | - | if err == nil { | |
| 56 | - | seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath)) | |
| 57 | - | if err != nil { | |
| 58 | - | return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed) | |
| 59 | - | } | |
| 60 | - | if len(seed) != ed25519.SeedSize { | |
| 61 | - | return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed) | |
| 62 | - | } | |
| 63 | - | m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created | |
| 64 | - | return nil | |
| 90 | + | sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx) | |
| 91 | + | if err == nil { | |
| 92 | + | seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath)) | |
| 93 | + | if err != nil { | |
| 94 | + | return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed) | |
| 65 | 95 | } | |
| 66 | - | if !errors.Is(err, domain.ErrNotFound) { | |
| 67 | - | return fmt.Errorf("read mirror key: %w", err) | |
| 96 | + | if len(seed) != ed25519.SeedSize { | |
| 97 | + | return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed) | |
| 68 | 98 | } | |
| 99 | + | m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created | |
| 100 | + | return nil | |
| 101 | + | } | |
| 102 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 103 | + | return fmt.Errorf("read mirror key: %w", err) | |
| 69 | 104 | } | |
| 105 | + | if !create { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | err = m.storeNewKey(ctx, true) | |
| 109 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 110 | + | return m.loadKey(ctx, true) // another instance created it first | |
| 111 | + | } | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // storeNewKey generates, seals and persists a key, caching it only once persisted. | |
| 116 | + | func (m *Mirrors) storeNewKey(ctx context.Context, ifAbsent bool) error { | |
| 70 | 117 | _, key, err := ed25519.GenerateKey(rand.Reader) | |
| 71 | 118 | if err != nil { | |
| 72 | 119 | return fmt.Errorf("generate mirror key: %w", err) | |
| 73 | 120 | } | |
| 74 | 121 | created := m.cfg.Clock.Now().UTC() | |
| 75 | 122 | sealed := m.cfg.Sealer.Seal(key.Seed(), []byte(domain.MirrorKeyPath)) | |
| 76 | - | err = m.cfg.Keys.PutMirrorKey(ctx, sealed, created, !rotate) | |
| 77 | - | if !rotate && errors.Is(err, domain.ErrAlreadyExists) { | |
| 78 | - | return m.loadKey(ctx, false) | |
| 79 | - | } | |
| 80 | - | if err != nil { | |
| 123 | + | if err := m.cfg.Keys.PutMirrorKey(ctx, sealed, created, ifAbsent); err != nil { | |
| 124 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 125 | + | return err | |
| 126 | + | } | |
| 81 | 127 | return fmt.Errorf("store mirror key: %w", err) | |
| 82 | 128 | } | |
| 83 | 129 | m.key, m.keyCreated = key, created | |
| @@ -11,73 +11,119 @@ import ( | |||||
| 11 | "strings" | 11 | "strings" | ||
| 12 | 12 | ||||
| 13 | "bnema.dev/ermite/internal/domain" | 13 | "bnema.dev/ermite/internal/domain" | ||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 17 | + | const noSecretsKeyDetail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key" | |||
| 18 | + | ||||
| 16 | // ShowKey lazily generates the server's mirror key for authenticated callers. | 19 | // ShowKey lazily generates the server's mirror key for authenticated callers. | ||
| 17 | func (m *Mirrors) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) { | 20 | func (m *Mirrors) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) { | ||
| 18 | if p.IsAnonymous() { | 21 | if p.IsAnonymous() { | ||
| 19 | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | 22 | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | ||
| 20 | } | 23 | } | ||
| 21 | - | return m.publicKey(ctx, false) | 24 | + | if m.cfg.Sealer == nil { |
| 25 | + | return domain.MirrorPublicKey{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail) | |||
| 26 | + | } | |||
| 27 | + | m.keyMu.Lock() | |||
| 28 | + | defer m.keyMu.Unlock() | |||
| 29 | + | if err := m.loadKey(ctx, true); err != nil { | |||
| 30 | + | return domain.MirrorPublicKey{}, err | |||
| 31 | + | } | |||
| 32 | + | return m.publicKeyLocked(), nil | |||
| 22 | } | 33 | } | ||
| 23 | 34 | ||||
| 24 | - | // RotateKey immediately replaces the mirror key. Remote authorization must be updated separately. | 35 | + | // RotateKey immediately replaces the mirror key and returns the replaced one (zero when there |
| 25 | - | func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) { | 36 | + | // was none, or when it could not be opened). Remote authorization must be updated separately. |
| 37 | + | func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) { | |||
| 26 | if p.IsAnonymous() { | 38 | if p.IsAnonymous() { | ||
| 27 | - | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | 39 | + | return ports.MirrorKeyRotation{}, domain.ErrUnauthenticated |
| 28 | } | 40 | } | ||
| 29 | if !p.HasOwnerRights() { | 41 | if !p.HasOwnerRights() { | ||
| 30 | - | return domain.MirrorPublicKey{}, domain.ErrForbidden | 42 | + | return ports.MirrorKeyRotation{}, domain.ErrForbidden |
| 43 | + | } | |||
| 44 | + | if m.cfg.Sealer == nil { | |||
| 45 | + | return ports.MirrorKeyRotation{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail) | |||
| 31 | } | 46 | } | ||
| 32 | - | return m.publicKey(ctx, true) | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | func (m *Mirrors) publicKey(ctx context.Context, rotate bool) (domain.MirrorPublicKey, error) { | |||
| 36 | m.keyMu.Lock() | 47 | m.keyMu.Lock() | ||
| 37 | defer m.keyMu.Unlock() | 48 | defer m.keyMu.Unlock() | ||
| 38 | - | if err := m.loadKey(ctx, rotate); err != nil { | 49 | + | var previous domain.MirrorPublicKey |
| 39 | - | return domain.MirrorPublicKey{}, err | 50 | + | // A missing key is not created just to be replaced, and a stored key that cannot be read |
| 51 | + | // or opened must not prevent its replacement. | |||
| 52 | + | if err := m.loadKey(ctx, false); err == nil { | |||
| 53 | + | previous = m.publicKeyLocked() | |||
| 54 | + | } | |||
| 55 | + | if err := m.storeNewKey(ctx, false); err != nil { | |||
| 56 | + | return ports.MirrorKeyRotation{}, err | |||
| 40 | } | 57 | } | ||
| 58 | + | return ports.MirrorKeyRotation{Key: m.publicKeyLocked(), Previous: previous}, nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (m *Mirrors) publicKeyLocked() domain.MirrorPublicKey { | |||
| 41 | host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@") | 62 | host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@") | ||
| 42 | - | return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated), nil | 63 | + | return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated) |
| 43 | } | 64 | } | ||
| 44 | 65 | ||||
| 45 | - | // loadKey is called with keyMu held. Only successfully persisted material is cached. | 66 | + | // privateKey returns a copy so a transport cannot mutate the cached key. Failures are |
| 46 | - | func (m *Mirrors) loadKey(ctx context.Context, rotate bool) error { | 67 | + | // *domain.MirrorError of class internal. |
| 68 | + | func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) { | |||
| 47 | if m.cfg.Sealer == nil { | 69 | if m.cfg.Sealer == nil { | ||
| 48 | - | return fmt.Errorf("%w: the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key", domain.ErrPreconditionFailed) | 70 | + | return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: noSecretsKeyDetail} |
| 49 | } | 71 | } | ||
| 50 | - | if !rotate && m.key != nil { | 72 | + | m.keyMu.Lock() |
| 73 | + | defer m.keyMu.Unlock() | |||
| 74 | + | if err := m.loadKey(ctx, true); err != nil { | |||
| 75 | + | detail := "cannot read the mirror key" | |||
| 76 | + | if errors.Is(err, domain.ErrPreconditionFailed) { | |||
| 77 | + | detail = err.Error() | |||
| 78 | + | } | |||
| 79 | + | return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |||
| 80 | + | } | |||
| 81 | + | return append(ed25519.PrivateKey(nil), m.key...), nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // loadKey is called with keyMu held and a sealer present: it caches the stored key, creating | |||
| 85 | + | // one when none exists and create is set (ErrNotFound otherwise). | |||
| 86 | + | func (m *Mirrors) loadKey(ctx context.Context, create bool) error { | |||
| 87 | + | if m.key != nil { | |||
| 51 | return nil | 88 | return nil | ||
| 52 | } | 89 | } | ||
| 53 | - | if !rotate { | 90 | + | sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx) |
| 54 | - | sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx) | 91 | + | if err == nil { |
| 55 | - | if err == nil { | 92 | + | seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath)) |
| 56 | - | seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath)) | 93 | + | if err != nil { |
| 57 | - | if err != nil { | 94 | + | return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed) |
| 58 | - | return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed) | |||
| 59 | - | } | |||
| 60 | - | if len(seed) != ed25519.SeedSize { | |||
| 61 | - | return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed) | |||
| 62 | - | } | |||
| 63 | - | m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created | |||
| 64 | - | return nil | |||
| 65 | } | 95 | } | ||
| 66 | - | if !errors.Is(err, domain.ErrNotFound) { | 96 | + | if len(seed) != ed25519.SeedSize { |
| 67 | - | return fmt.Errorf("read mirror key: %w", err) | 97 | + | return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed) |
| 68 | } | 98 | } | ||
| 99 | + | m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created | |||
| 100 | + | return nil | |||
| 101 | + | } | |||
| 102 | + | if !errors.Is(err, domain.ErrNotFound) { | |||
| 103 | + | return fmt.Errorf("read mirror key: %w", err) | |||
| 69 | } | 104 | } | ||
| 105 | + | if !create { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | err = m.storeNewKey(ctx, true) | |||
| 109 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |||
| 110 | + | return m.loadKey(ctx, true) // another instance created it first | |||
| 111 | + | } | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // storeNewKey generates, seals and persists a key, caching it only once persisted. | |||
| 116 | + | func (m *Mirrors) storeNewKey(ctx context.Context, ifAbsent bool) error { | |||
| 70 | _, key, err := ed25519.GenerateKey(rand.Reader) | 117 | _, key, err := ed25519.GenerateKey(rand.Reader) | ||
| 71 | if err != nil { | 118 | if err != nil { | ||
| 72 | return fmt.Errorf("generate mirror key: %w", err) | 119 | return fmt.Errorf("generate mirror key: %w", err) | ||
| 73 | } | 120 | } | ||
| 74 | created := m.cfg.Clock.Now().UTC() | 121 | created := m.cfg.Clock.Now().UTC() | ||
| 75 | sealed := m.cfg.Sealer.Seal(key.Seed(), []byte(domain.MirrorKeyPath)) | 122 | sealed := m.cfg.Sealer.Seal(key.Seed(), []byte(domain.MirrorKeyPath)) | ||
| 76 | - | err = m.cfg.Keys.PutMirrorKey(ctx, sealed, created, !rotate) | 123 | + | if err := m.cfg.Keys.PutMirrorKey(ctx, sealed, created, ifAbsent); err != nil { |
| 77 | - | if !rotate && errors.Is(err, domain.ErrAlreadyExists) { | 124 | + | if errors.Is(err, domain.ErrAlreadyExists) { |
| 78 | - | return m.loadKey(ctx, false) | 125 | + | return err |
| 79 | - | } | 126 | + | } |
| 80 | - | if err != nil { | |||
| 81 | return fmt.Errorf("store mirror key: %w", err) | 127 | return fmt.Errorf("store mirror key: %w", err) | ||
| 82 | } | 128 | } | ||
| 83 | m.key, m.keyCreated = key, created | 129 | m.key, m.keyCreated = key, created | ||
A internal/usecase/mirrors_key_fixture_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 14 | + | "bnema.dev/ermite/internal/usecase" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // keyFixture is a Mirrors with only the key store, a sealing fake and a clock. | |
| 18 | + | type keyFixture struct { | |
| 19 | + | m *usecase.Mirrors | |
| 20 | + | keys *mocks.MirrorKeyStore | |
| 21 | + | sealerOpenFails bool | |
| 22 | + | mu sync.Mutex | |
| 23 | + | sealed [][]byte | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func newKeyFixture(t *testing.T) *keyFixture { | |
| 27 | + | t.Helper() | |
| 28 | + | f := &keyFixture{keys: mocks.NewMirrorKeyStore(t)} | |
| 29 | + | sealer, clock := mocks.NewSecretSealer(t), mocks.NewClock(t) | |
| 30 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 31 | + | sealer.EXPECT().Seal(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(seed, _ []byte) []byte { | |
| 32 | + | f.mu.Lock() | |
| 33 | + | defer f.mu.Unlock() | |
| 34 | + | f.sealed = append(f.sealed, append([]byte(nil), seed...)) | |
| 35 | + | return append([]byte("sealed:"), seed...) | |
| 36 | + | }).Maybe() | |
| 37 | + | sealer.EXPECT().Open(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(b, _ []byte) ([]byte, error) { | |
| 38 | + | if f.sealerOpenFails { | |
| 39 | + | return nil, errors.New("cannot open") | |
| 40 | + | } | |
| 41 | + | return b[len("sealed:"):], nil | |
| 42 | + | }).Maybe() | |
| 43 | + | f.m = usecase.NewMirrors(usecase.MirrorsConfig{Keys: f.keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"}) | |
| 44 | + | return f | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // seeds are the seeds generated so far. | |
| 48 | + | func (f *keyFixture) seeds() [][]byte { | |
| 49 | + | f.mu.Lock() | |
| 50 | + | defer f.mu.Unlock() | |
| 51 | + | return append([][]byte(nil), f.sealed...) | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 14 | + | "bnema.dev/ermite/internal/usecase" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // keyFixture is a Mirrors with only the key store, a sealing fake and a clock. | |||
| 18 | + | type keyFixture struct { | |||
| 19 | + | m *usecase.Mirrors | |||
| 20 | + | keys *mocks.MirrorKeyStore | |||
| 21 | + | sealerOpenFails bool | |||
| 22 | + | mu sync.Mutex | |||
| 23 | + | sealed [][]byte | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func newKeyFixture(t *testing.T) *keyFixture { | |||
| 27 | + | t.Helper() | |||
| 28 | + | f := &keyFixture{keys: mocks.NewMirrorKeyStore(t)} | |||
| 29 | + | sealer, clock := mocks.NewSecretSealer(t), mocks.NewClock(t) | |||
| 30 | + | clock.EXPECT().Now().Return(now).Maybe() | |||
| 31 | + | sealer.EXPECT().Seal(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(seed, _ []byte) []byte { | |||
| 32 | + | f.mu.Lock() | |||
| 33 | + | defer f.mu.Unlock() | |||
| 34 | + | f.sealed = append(f.sealed, append([]byte(nil), seed...)) | |||
| 35 | + | return append([]byte("sealed:"), seed...) | |||
| 36 | + | }).Maybe() | |||
| 37 | + | sealer.EXPECT().Open(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(b, _ []byte) ([]byte, error) { | |||
| 38 | + | if f.sealerOpenFails { | |||
| 39 | + | return nil, errors.New("cannot open") | |||
| 40 | + | } | |||
| 41 | + | return b[len("sealed:"):], nil | |||
| 42 | + | }).Maybe() | |||
| 43 | + | f.m = usecase.NewMirrors(usecase.MirrorsConfig{Keys: f.keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"}) | |||
| 44 | + | return f | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // seeds are the seeds generated so far. | |||
| 48 | + | func (f *keyFixture) seeds() [][]byte { | |||
| 49 | + | f.mu.Lock() | |||
| 50 | + | defer f.mu.Unlock() | |||
| 51 | + | return append([][]byte(nil), f.sealed...) | |||
| 52 | + | } | |||
A internal/usecase/mirrors_notify_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | "testing/synctest" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestMirrorsPushRecordNotification(t *testing.T) { | |
| 20 | + | f := newPushFixture(t) | |
| 21 | + | n := name(t, "tools") | |
| 22 | + | trigger := mocks.NewMirrorTrigger(t) | |
| 23 | + | updates := []domain.RefUpdate{} | |
| 24 | + | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |
| 25 | + | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |
| 26 | + | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |
| 27 | + | f.sched.On("Schedule", mock.Anything, n).Return() | |
| 28 | + | trigger.EXPECT().OnPush(mock.Anything, n, updates).Return().Once() // synchronous: verified when Record returns | |
| 29 | + | require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates)) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestMirrorsMergeNotifiesMirror(t *testing.T) { | |
| 33 | + | r := newMRRig(t) | |
| 34 | + | r.seed() | |
| 35 | + | trigger := mocks.NewMirrorTrigger(t) | |
| 36 | + | notified := make(chan []domain.RefUpdate, 1) | |
| 37 | + | trigger.EXPECT().OnPush(mock.Anything, r.repo, mock.Anything).Run(func(_ context.Context, _ domain.RepoName, u []domain.RefUpdate) { notified <- u }).Return().Once() | |
| 38 | + | r.mrs.WithMirror(trigger) | |
| 39 | + | head := r.feature("f0") | |
| 40 | + | mr := r.open("f0") | |
| 41 | + | _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | r.mrs.Wait(r.ctx) | |
| 44 | + | select { | |
| 45 | + | case u := <-notified: | |
| 46 | + | require.Equal(t, "refs/heads/main", u[0].Ref) | |
| 47 | + | case <-time.After(time.Second): | |
| 48 | + | t.Fatal("merge did not notify mirrors") | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) { | |
| 53 | + | f := newFixture(t) | |
| 54 | + | ctx := context.Background() | |
| 55 | + | n := name(t, "tools") | |
| 56 | + | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate} | |
| 57 | + | trigger := mocks.NewMirrorTrigger(t) | |
| 58 | + | trigger.EXPECT().OnPush(mock.Anything, n, mock.Anything).Return().Once() | |
| 59 | + | f.repos.WithMirror(trigger) | |
| 60 | + | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |
| 61 | + | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |
| 62 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil) | |
| 63 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 64 | + | _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")}) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) { | |
| 69 | + | f := newMirrorFixture(t) | |
| 70 | + | f.pushes(t, nil) | |
| 71 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | f.advance(5 * time.Minute) | |
| 74 | + | f.m.ScheduleNow(context.Background()) | |
| 75 | + | f.drain(t) | |
| 76 | + | require.Equal(t, 1, f.refReads(), "due and clean: skipped") | |
| 77 | + | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}) | |
| 78 | + | f.advance(5 * time.Minute) | |
| 79 | + | f.m.ScheduleNow(context.Background()) | |
| 80 | + | f.drain(t) | |
| 81 | + | require.Equal(t, 2, f.refReads(), "dirty repository synced on the next due tick") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestMirrorsEventsAreDeliveredByVisibility(t *testing.T) { | |
| 85 | + | synctest.Test(t, func(t *testing.T) { | |
| 86 | + | r := newEventRig(t, 0) | |
| 87 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 88 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 89 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 90 | + | defer cancel() | |
| 91 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |
| 92 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 93 | + | synctest.Wait() | |
| 94 | + | for _, n := range []domain.RepoName{priv, pub} { | |
| 95 | + | vis := domain.VisibilityPublic | |
| 96 | + | if n == priv { | |
| 97 | + | vis = domain.VisibilityPrivate | |
| 98 | + | } | |
| 99 | + | r.feed <- domain.MirrorFailed{Repo: n, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now, Visibility: vis, CreatedAt: now} | |
| 100 | + | r.feed <- domain.MirrorRecovered{Repo: n, At: now, Visibility: vis, CreatedAt: now} | |
| 101 | + | } | |
| 102 | + | for _, n := range []domain.RepoName{priv, pub} { | |
| 103 | + | failed := root.next(t).Event.(domain.MirrorFailed) | |
| 104 | + | require.Equal(t, n, failed.Repo) | |
| 105 | + | require.Equal(t, n, root.next(t).Event.(domain.MirrorRecovered).Repo) | |
| 106 | + | } | |
| 107 | + | failed := anon.next(t).Event.(domain.MirrorFailed) | |
| 108 | + | require.Equal(t, pub, failed.Repo, "a private repository's mirror events stay private") | |
| 109 | + | require.Equal(t, pub, anon.next(t).Event.(domain.MirrorRecovered).Repo) | |
| 110 | + | anon.quiet(t, r, pub) | |
| 111 | + | }) | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | "testing/synctest" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestMirrorsPushRecordNotification(t *testing.T) { | |||
| 20 | + | f := newPushFixture(t) | |||
| 21 | + | n := name(t, "tools") | |||
| 22 | + | trigger := mocks.NewMirrorTrigger(t) | |||
| 23 | + | updates := []domain.RefUpdate{} | |||
| 24 | + | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |||
| 25 | + | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |||
| 26 | + | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |||
| 27 | + | f.sched.On("Schedule", mock.Anything, n).Return() | |||
| 28 | + | trigger.EXPECT().OnPush(mock.Anything, n, updates).Return().Once() // synchronous: verified when Record returns | |||
| 29 | + | require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates)) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestMirrorsMergeNotifiesMirror(t *testing.T) { | |||
| 33 | + | r := newMRRig(t) | |||
| 34 | + | r.seed() | |||
| 35 | + | trigger := mocks.NewMirrorTrigger(t) | |||
| 36 | + | notified := make(chan []domain.RefUpdate, 1) | |||
| 37 | + | trigger.EXPECT().OnPush(mock.Anything, r.repo, mock.Anything).Run(func(_ context.Context, _ domain.RepoName, u []domain.RefUpdate) { notified <- u }).Return().Once() | |||
| 38 | + | r.mrs.WithMirror(trigger) | |||
| 39 | + | head := r.feature("f0") | |||
| 40 | + | mr := r.open("f0") | |||
| 41 | + | _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | r.mrs.Wait(r.ctx) | |||
| 44 | + | select { | |||
| 45 | + | case u := <-notified: | |||
| 46 | + | require.Equal(t, "refs/heads/main", u[0].Ref) | |||
| 47 | + | case <-time.After(time.Second): | |||
| 48 | + | t.Fatal("merge did not notify mirrors") | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) { | |||
| 53 | + | f := newFixture(t) | |||
| 54 | + | ctx := context.Background() | |||
| 55 | + | n := name(t, "tools") | |||
| 56 | + | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate} | |||
| 57 | + | trigger := mocks.NewMirrorTrigger(t) | |||
| 58 | + | trigger.EXPECT().OnPush(mock.Anything, n, mock.Anything).Return().Once() | |||
| 59 | + | f.repos.WithMirror(trigger) | |||
| 60 | + | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |||
| 61 | + | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |||
| 62 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil) | |||
| 63 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |||
| 64 | + | _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")}) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) { | |||
| 69 | + | f := newMirrorFixture(t) | |||
| 70 | + | f.pushes(t, nil) | |||
| 71 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | f.advance(5 * time.Minute) | |||
| 74 | + | f.m.ScheduleNow(context.Background()) | |||
| 75 | + | f.drain(t) | |||
| 76 | + | require.Equal(t, 1, f.refReads(), "due and clean: skipped") | |||
| 77 | + | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}) | |||
| 78 | + | f.advance(5 * time.Minute) | |||
| 79 | + | f.m.ScheduleNow(context.Background()) | |||
| 80 | + | f.drain(t) | |||
| 81 | + | require.Equal(t, 2, f.refReads(), "dirty repository synced on the next due tick") | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestMirrorsEventsAreDeliveredByVisibility(t *testing.T) { | |||
| 85 | + | synctest.Test(t, func(t *testing.T) { | |||
| 86 | + | r := newEventRig(t, 0) | |||
| 87 | + | pub := r.repo("pub", domain.VisibilityPublic) | |||
| 88 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |||
| 89 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 90 | + | defer cancel() | |||
| 91 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |||
| 92 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |||
| 93 | + | synctest.Wait() | |||
| 94 | + | for _, n := range []domain.RepoName{priv, pub} { | |||
| 95 | + | vis := domain.VisibilityPublic | |||
| 96 | + | if n == priv { | |||
| 97 | + | vis = domain.VisibilityPrivate | |||
| 98 | + | } | |||
| 99 | + | r.feed <- domain.MirrorFailed{Repo: n, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now, Visibility: vis, CreatedAt: now} | |||
| 100 | + | r.feed <- domain.MirrorRecovered{Repo: n, At: now, Visibility: vis, CreatedAt: now} | |||
| 101 | + | } | |||
| 102 | + | for _, n := range []domain.RepoName{priv, pub} { | |||
| 103 | + | failed := root.next(t).Event.(domain.MirrorFailed) | |||
| 104 | + | require.Equal(t, n, failed.Repo) | |||
| 105 | + | require.Equal(t, n, root.next(t).Event.(domain.MirrorRecovered).Repo) | |||
| 106 | + | } | |||
| 107 | + | failed := anon.next(t).Event.(domain.MirrorFailed) | |||
| 108 | + | require.Equal(t, pub, failed.Repo, "a private repository's mirror events stay private") | |||
| 109 | + | require.Equal(t, pub, anon.next(t).Event.(domain.MirrorRecovered).Repo) | |||
| 110 | + | anon.quiet(t, r, pub) | |||
| 111 | + | }) | |||
| 112 | + | } | |||
M internal/usecase/mirrors_schedule.go +38 -19
| @@ -4,6 +4,7 @@ package usecase | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "bnema.dev/ermite/internal/domain" | |
| @@ -67,31 +68,44 @@ func (m *Mirrors) OnPush(_ context.Context, repo domain.RepoName, _ []domain.Ref | |||
| 67 | 68 | m.mu.Lock() | |
| 68 | 69 | defer m.mu.Unlock() | |
| 69 | 70 | if !m.closed { | |
| 70 | - | m.dirty[repo]++ | |
| 71 | + | g := m.gens[repo] | |
| 72 | + | g.dirty++ | |
| 73 | + | m.gens[repo] = g | |
| 71 | 74 | } | |
| 72 | 75 | } | |
| 73 | 76 | ||
| 74 | - | // ScheduleNow examines one current minute; missed minutes are not replayed. | |
| 77 | + | // ScheduleNow examines the current minute; missed minutes are not replayed. A repository that | |
| 78 | + | // was due but found the queue full is kept and retried on the next calls, so a full queue | |
| 79 | + | // delays a sync instead of skipping a period. Workers must be running (Start). | |
| 75 | 80 | func (m *Mirrors) ScheduleNow(ctx context.Context) { | |
| 76 | - | m.Start() | |
| 77 | 81 | m.scheduleMu.Lock() | |
| 78 | 82 | defer m.scheduleMu.Unlock() | |
| 79 | 83 | now := m.cfg.Clock.Now().UTC() | |
| 80 | 84 | minute := now.Truncate(time.Minute) | |
| 81 | - | if !minute.After(m.lastMinute) { | |
| 85 | + | newMinute := minute.After(m.lastMinute) | |
| 86 | + | if !newMinute && len(m.deferred) == 0 { | |
| 82 | 87 | return | |
| 83 | 88 | } | |
| 84 | - | m.lastMinute = minute | |
| 89 | + | log := zerowrap.FromCtx(ctx) | |
| 85 | 90 | repos, err := m.cfg.Meta.List(ctx) | |
| 86 | 91 | if err != nil { | |
| 92 | + | log.Warn().Err(err).Msg("mirror schedule: cannot list repositories") | |
| 87 | 93 | return | |
| 88 | 94 | } | |
| 95 | + | if newMinute { | |
| 96 | + | m.lastMinute = minute | |
| 97 | + | } | |
| 89 | 98 | live := make(map[domain.RepoName]bool, len(repos)) | |
| 90 | 99 | for _, r := range repos { | |
| 91 | 100 | if ctx.Err() != nil { | |
| 92 | 101 | return | |
| 93 | 102 | } | |
| 94 | 103 | live[r.Name] = true | |
| 104 | + | _, wasDeferred := m.deferred[r.Name] | |
| 105 | + | if !newMinute && !wasDeferred { | |
| 106 | + | continue | |
| 107 | + | } | |
| 108 | + | delete(m.deferred, r.Name) | |
| 95 | 109 | t, err := m.target(ctx, r.Name) | |
| 96 | 110 | if err != nil || !t.Enabled { | |
| 97 | 111 | continue | |
| @@ -100,35 +114,40 @@ func (m *Mirrors) ScheduleNow(ctx context.Context) { | |||
| 100 | 114 | if err != nil || s.State == domain.MirrorPaused { | |
| 101 | 115 | continue | |
| 102 | 116 | } | |
| 103 | - | due := false | |
| 104 | - | if s.State == domain.MirrorFailing { | |
| 105 | - | due = !now.Before(s.NextRetryAt) | |
| 106 | - | } else { | |
| 107 | - | due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute) | |
| 117 | + | due := wasDeferred | |
| 118 | + | if !due { | |
| 119 | + | if s.State == domain.MirrorFailing { | |
| 120 | + | due = !now.Before(s.NextRetryAt) | |
| 121 | + | } else { | |
| 122 | + | due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute) | |
| 123 | + | } | |
| 108 | 124 | } | |
| 109 | 125 | if !due { | |
| 110 | 126 | continue | |
| 111 | 127 | } | |
| 112 | 128 | m.mu.Lock() | |
| 113 | - | clean, known := m.clean[r.Name] | |
| 114 | - | dirty := m.dirty[r.Name] | |
| 129 | + | g := m.gens[r.Name] | |
| 115 | 130 | m.mu.Unlock() | |
| 116 | - | if s.State == domain.MirrorOK && known && clean == dirty { | |
| 131 | + | if s.State == domain.MirrorOK && g.cleanKnown && g.clean == g.dirty { | |
| 117 | 132 | snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name) | |
| 118 | 133 | if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches { | |
| 119 | 134 | continue | |
| 120 | 135 | } | |
| 121 | 136 | } | |
| 122 | - | if _, _, err := m.enqueue(r.Name, false, false); err != nil { | |
| 123 | - | log := zerowrap.FromCtx(ctx) | |
| 124 | - | log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full or closed") | |
| 137 | + | if _, _, err := m.enqueue(r.Name, mirrorReq{}); errors.Is(err, domain.ErrResourceExhausted) { | |
| 138 | + | m.deferred[r.Name] = struct{}{} | |
| 139 | + | log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full: sync deferred") | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | for repo := range m.deferred { | |
| 143 | + | if !live[repo] { | |
| 144 | + | delete(m.deferred, repo) | |
| 125 | 145 | } | |
| 126 | 146 | } | |
| 127 | 147 | m.mu.Lock() | |
| 128 | - | for repo := range m.dirty { | |
| 148 | + | for repo := range m.gens { | |
| 129 | 149 | if !live[repo] { | |
| 130 | - | delete(m.dirty, repo) | |
| 131 | - | delete(m.clean, repo) | |
| 150 | + | delete(m.gens, repo) | |
| 132 | 151 | } | |
| 133 | 152 | } | |
| 134 | 153 | m.mu.Unlock() | |
| @@ -4,6 +4,7 @@ package usecase | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "bnema.dev/ermite/internal/domain" | 10 | "bnema.dev/ermite/internal/domain" | ||
| @@ -67,31 +68,44 @@ func (m *Mirrors) OnPush(_ context.Context, repo domain.RepoName, _ []domain.Ref | |||||
| 67 | m.mu.Lock() | 68 | m.mu.Lock() | ||
| 68 | defer m.mu.Unlock() | 69 | defer m.mu.Unlock() | ||
| 69 | if !m.closed { | 70 | if !m.closed { | ||
| 70 | - | m.dirty[repo]++ | 71 | + | g := m.gens[repo] |
| 72 | + | g.dirty++ | |||
| 73 | + | m.gens[repo] = g | |||
| 71 | } | 74 | } | ||
| 72 | } | 75 | } | ||
| 73 | 76 | ||||
| 74 | - | // ScheduleNow examines one current minute; missed minutes are not replayed. | 77 | + | // ScheduleNow examines the current minute; missed minutes are not replayed. A repository that |
| 78 | + | // was due but found the queue full is kept and retried on the next calls, so a full queue | |||
| 79 | + | // delays a sync instead of skipping a period. Workers must be running (Start). | |||
| 75 | func (m *Mirrors) ScheduleNow(ctx context.Context) { | 80 | func (m *Mirrors) ScheduleNow(ctx context.Context) { | ||
| 76 | - | m.Start() | |||
| 77 | m.scheduleMu.Lock() | 81 | m.scheduleMu.Lock() | ||
| 78 | defer m.scheduleMu.Unlock() | 82 | defer m.scheduleMu.Unlock() | ||
| 79 | now := m.cfg.Clock.Now().UTC() | 83 | now := m.cfg.Clock.Now().UTC() | ||
| 80 | minute := now.Truncate(time.Minute) | 84 | minute := now.Truncate(time.Minute) | ||
| 81 | - | if !minute.After(m.lastMinute) { | 85 | + | newMinute := minute.After(m.lastMinute) |
| 86 | + | if !newMinute && len(m.deferred) == 0 { | |||
| 82 | return | 87 | return | ||
| 83 | } | 88 | } | ||
| 84 | - | m.lastMinute = minute | 89 | + | log := zerowrap.FromCtx(ctx) |
| 85 | repos, err := m.cfg.Meta.List(ctx) | 90 | repos, err := m.cfg.Meta.List(ctx) | ||
| 86 | if err != nil { | 91 | if err != nil { | ||
| 92 | + | log.Warn().Err(err).Msg("mirror schedule: cannot list repositories") | |||
| 87 | return | 93 | return | ||
| 88 | } | 94 | } | ||
| 95 | + | if newMinute { | |||
| 96 | + | m.lastMinute = minute | |||
| 97 | + | } | |||
| 89 | live := make(map[domain.RepoName]bool, len(repos)) | 98 | live := make(map[domain.RepoName]bool, len(repos)) | ||
| 90 | for _, r := range repos { | 99 | for _, r := range repos { | ||
| 91 | if ctx.Err() != nil { | 100 | if ctx.Err() != nil { | ||
| 92 | return | 101 | return | ||
| 93 | } | 102 | } | ||
| 94 | live[r.Name] = true | 103 | live[r.Name] = true | ||
| 104 | + | _, wasDeferred := m.deferred[r.Name] | |||
| 105 | + | if !newMinute && !wasDeferred { | |||
| 106 | + | continue | |||
| 107 | + | } | |||
| 108 | + | delete(m.deferred, r.Name) | |||
| 95 | t, err := m.target(ctx, r.Name) | 109 | t, err := m.target(ctx, r.Name) | ||
| 96 | if err != nil || !t.Enabled { | 110 | if err != nil || !t.Enabled { | ||
| 97 | continue | 111 | continue | ||
| @@ -100,35 +114,40 @@ func (m *Mirrors) ScheduleNow(ctx context.Context) { | |||||
| 100 | if err != nil || s.State == domain.MirrorPaused { | 114 | if err != nil || s.State == domain.MirrorPaused { | ||
| 101 | continue | 115 | continue | ||
| 102 | } | 116 | } | ||
| 103 | - | due := false | 117 | + | due := wasDeferred |
| 104 | - | if s.State == domain.MirrorFailing { | 118 | + | if !due { |
| 105 | - | due = !now.Before(s.NextRetryAt) | 119 | + | if s.State == domain.MirrorFailing { |
| 106 | - | } else { | 120 | + | due = !now.Before(s.NextRetryAt) |
| 107 | - | due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute) | 121 | + | } else { |
| 122 | + | due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute) | |||
| 123 | + | } | |||
| 108 | } | 124 | } | ||
| 109 | if !due { | 125 | if !due { | ||
| 110 | continue | 126 | continue | ||
| 111 | } | 127 | } | ||
| 112 | m.mu.Lock() | 128 | m.mu.Lock() | ||
| 113 | - | clean, known := m.clean[r.Name] | 129 | + | g := m.gens[r.Name] |
| 114 | - | dirty := m.dirty[r.Name] | |||
| 115 | m.mu.Unlock() | 130 | m.mu.Unlock() | ||
| 116 | - | if s.State == domain.MirrorOK && known && clean == dirty { | 131 | + | if s.State == domain.MirrorOK && g.cleanKnown && g.clean == g.dirty { |
| 117 | snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name) | 132 | snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name) | ||
| 118 | if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches { | 133 | if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches { | ||
| 119 | continue | 134 | continue | ||
| 120 | } | 135 | } | ||
| 121 | } | 136 | } | ||
| 122 | - | if _, _, err := m.enqueue(r.Name, false, false); err != nil { | 137 | + | if _, _, err := m.enqueue(r.Name, mirrorReq{}); errors.Is(err, domain.ErrResourceExhausted) { |
| 123 | - | log := zerowrap.FromCtx(ctx) | 138 | + | m.deferred[r.Name] = struct{}{} |
| 124 | - | log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full or closed") | 139 | + | log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full: sync deferred") |
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | for repo := range m.deferred { | |||
| 143 | + | if !live[repo] { | |||
| 144 | + | delete(m.deferred, repo) | |||
| 125 | } | 145 | } | ||
| 126 | } | 146 | } | ||
| 127 | m.mu.Lock() | 147 | m.mu.Lock() | ||
| 128 | - | for repo := range m.dirty { | 148 | + | for repo := range m.gens { |
| 129 | if !live[repo] { | 149 | if !live[repo] { | ||
| 130 | - | delete(m.dirty, repo) | 150 | + | delete(m.gens, repo) |
| 131 | - | delete(m.clean, repo) | |||
| 132 | } | 151 | } | ||
| 133 | } | 152 | } | ||
| 134 | m.mu.Unlock() | 153 | m.mu.Unlock() | ||
M internal/usecase/mirrors_sync.go +141 -69
| @@ -6,19 +6,28 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "bnema.dev/ermite/internal/domain" | |
| 11 | 12 | "bnema.dev/ermite/internal/ports" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 15 | + | // mirrorReq is what a sync request asks for. Requests that coalesce merge by OR. | |
| 16 | + | type mirrorReq struct{ force, manual bool } | |
| 17 | + | ||
| 18 | + | func (r mirrorReq) merge(o mirrorReq) mirrorReq { | |
| 19 | + | return mirrorReq{force: r.force || o.force, manual: r.manual || o.manual} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // mirrorFlight is the single in-flight sync of a repository: cur is running, next is the | |
| 23 | + | // merged request that arrived meanwhile (one rerun, whatever the number of requests). | |
| 14 | 24 | type mirrorFlight struct { | |
| 15 | - | pending, force, manual bool | |
| 16 | - | pendingForce bool | |
| 17 | - | pendingManual bool | |
| 18 | - | done chan struct{} | |
| 19 | - | completed bool | |
| 20 | - | result ports.MirrorSyncResult | |
| 21 | - | err error | |
| 25 | + | cur mirrorReq | |
| 26 | + | next *mirrorReq | |
| 27 | + | done chan struct{} // closed with the result of cur's first run | |
| 28 | + | completed bool | |
| 29 | + | result ports.MirrorSyncResult | |
| 30 | + | err error | |
| 22 | 31 | } | |
| 23 | 32 | ||
| 24 | 33 | var ( | |
| @@ -33,12 +42,12 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo | |||
| 33 | 42 | return ports.MirrorSyncResult{}, err | |
| 34 | 43 | } | |
| 35 | 44 | m.Start() | |
| 36 | - | f, queued, err := m.enqueue(repo, force, true) | |
| 45 | + | f, queued, err := m.enqueue(repo, mirrorReq{force: force, manual: true}) | |
| 37 | 46 | if err != nil { | |
| 38 | 47 | return ports.MirrorSyncResult{}, err | |
| 39 | 48 | } | |
| 40 | 49 | if queued { | |
| 41 | - | return ports.MirrorSyncResult{Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil | |
| 50 | + | return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil | |
| 42 | 51 | } | |
| 43 | 52 | select { | |
| 44 | 53 | case <-m.ctx.Done(): | |
| @@ -46,11 +55,14 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo | |||
| 46 | 55 | case <-ctx.Done(): | |
| 47 | 56 | return ports.MirrorSyncResult{}, ctx.Err() | |
| 48 | 57 | case <-f.done: | |
| 58 | + | f.result.Repo = repo | |
| 49 | 59 | return f.result, f.err | |
| 50 | 60 | } | |
| 51 | 61 | } | |
| 52 | 62 | ||
| 53 | - | // SyncAll requests a sync for every repository as an owner. | |
| 63 | + | // SyncAll syncs every repository whose mirror is enabled, as an owner. A failing repository | |
| 64 | + | // does not stop the others: its outcome carries the error. The returned error is only for | |
| 65 | + | // what prevents the whole request (authorization, listing, cancellation). | |
| 54 | 66 | func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) { | |
| 55 | 67 | if err := mirrorOwner(p); err != nil { | |
| 56 | 68 | return nil, err | |
| @@ -61,103 +73,159 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ( | |||
| 61 | 73 | } | |
| 62 | 74 | out := make([]ports.MirrorSyncResult, 0, len(repos)) | |
| 63 | 75 | for _, r := range repos { | |
| 64 | - | v, err := m.Sync(ctx, p, r.Name, force) | |
| 65 | - | if err != nil { | |
| 76 | + | if err := ctx.Err(); err != nil { | |
| 66 | 77 | return out, err | |
| 67 | 78 | } | |
| 68 | - | out = append(out, v) | |
| 79 | + | t, err := m.target(ctx, r.Name) | |
| 80 | + | if err == nil && !t.Enabled { | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | var res ports.MirrorSyncResult | |
| 84 | + | if err == nil { | |
| 85 | + | res, err = m.Sync(ctx, p, r.Name, force) | |
| 86 | + | } | |
| 87 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 88 | + | return out, ctxErr | |
| 89 | + | } | |
| 90 | + | res.Repo = r.Name | |
| 91 | + | if res.Status.Repo == (domain.RepoName{}) { | |
| 92 | + | res.Status.Repo = r.Name | |
| 93 | + | } | |
| 94 | + | res.Err = err | |
| 95 | + | out = append(out, res) | |
| 69 | 96 | } | |
| 70 | 97 | return out, nil | |
| 71 | 98 | } | |
| 72 | - | func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlight, bool, error) { | |
| 99 | + | ||
| 100 | + | // Wait blocks until no sync is queued or running, or ctx is done. It is for tests and shutdown | |
| 101 | + | // paths that need a deterministic barrier. | |
| 102 | + | func (m *Mirrors) Wait(ctx context.Context) { | |
| 103 | + | done := make(chan struct{}) | |
| 104 | + | go func() { m.flightWG.Wait(); close(done) }() | |
| 105 | + | select { | |
| 106 | + | case <-done: | |
| 107 | + | case <-ctx.Done(): | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (m *Mirrors) enqueue(repo domain.RepoName, req mirrorReq) (*mirrorFlight, bool, error) { | |
| 73 | 112 | m.mu.Lock() | |
| 74 | 113 | defer m.mu.Unlock() | |
| 75 | 114 | if m.closed { | |
| 76 | 115 | return nil, false, context.Canceled | |
| 77 | 116 | } | |
| 78 | 117 | if f := m.flights[repo]; f != nil { | |
| 79 | - | f.pending = true | |
| 80 | - | f.pendingForce = f.pendingForce || force | |
| 81 | - | f.pendingManual = f.pendingManual || manual | |
| 118 | + | next := req | |
| 119 | + | if f.next != nil { | |
| 120 | + | next = f.next.merge(req) | |
| 121 | + | } | |
| 122 | + | f.next = &next | |
| 82 | 123 | return f, true, nil | |
| 83 | 124 | } | |
| 84 | - | f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})} | |
| 85 | - | m.flights[repo] = f | |
| 125 | + | f := &mirrorFlight{cur: req, done: make(chan struct{})} | |
| 86 | 126 | select { | |
| 87 | 127 | case m.jobs <- repo: | |
| 128 | + | m.flights[repo] = f | |
| 129 | + | m.flightWG.Add(1) | |
| 88 | 130 | return f, false, nil | |
| 89 | 131 | default: | |
| 90 | - | delete(m.flights, repo) | |
| 91 | 132 | return nil, false, domain.ErrResourceExhausted | |
| 92 | 133 | } | |
| 93 | 134 | } | |
| 135 | + | ||
| 94 | 136 | func (m *Mirrors) run(repo domain.RepoName) { | |
| 95 | 137 | m.mu.Lock() | |
| 96 | 138 | f := m.flights[repo] | |
| 97 | 139 | m.mu.Unlock() | |
| 98 | 140 | for { | |
| 99 | - | result, err := m.attempt(m.ctx, repo, f.force, f.manual) | |
| 141 | + | result, err := m.attempt(m.ctx, repo, f.cur) | |
| 100 | 142 | m.mu.Lock() | |
| 101 | 143 | if !f.completed { | |
| 102 | 144 | f.result, f.err = result, err | |
| 103 | 145 | f.completed = true | |
| 104 | 146 | close(f.done) | |
| 105 | 147 | } | |
| 106 | - | if !f.pending || m.closed { | |
| 148 | + | if f.next == nil || m.closed { | |
| 107 | 149 | delete(m.flights, repo) | |
| 150 | + | m.flightWG.Done() | |
| 108 | 151 | m.mu.Unlock() | |
| 109 | 152 | return | |
| 110 | 153 | } | |
| 111 | - | f.force, f.manual = f.pendingForce, f.pendingManual | |
| 112 | - | f.pending, f.pendingForce, f.pendingManual = false, false, false | |
| 154 | + | f.cur, f.next = *f.next, nil | |
| 113 | 155 | m.mu.Unlock() | |
| 114 | 156 | } | |
| 115 | 157 | } | |
| 116 | 158 | ||
| 117 | - | func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) { | |
| 159 | + | // mirrorPlan is what attempt decided under the configuration lock, before pushing. | |
| 160 | + | type mirrorPlan struct { | |
| 161 | + | meta domain.Repo | |
| 162 | + | target domain.MirrorTarget | |
| 163 | + | prev domain.MirrorStatus | |
| 164 | + | start time.Time | |
| 165 | + | generation uint64 | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // plan reads the target and status and applies the skip and throttle rules. When done is set | |
| 169 | + | // the attempt ends with result and err. | |
| 170 | + | func (m *Mirrors) plan(ctx context.Context, repo domain.RepoName, req mirrorReq) (pl mirrorPlan, result ports.MirrorSyncResult, done bool, err error) { | |
| 118 | 171 | m.configMu.RLock() | |
| 119 | 172 | defer m.configMu.RUnlock() | |
| 120 | 173 | meta, err := m.cfg.Meta.Get(ctx, repo) | |
| 121 | 174 | if err != nil { | |
| 122 | - | return ports.MirrorSyncResult{}, err | |
| 175 | + | return pl, result, true, err | |
| 123 | 176 | } | |
| 124 | 177 | t, err := m.target(ctx, repo) | |
| 125 | 178 | if err != nil { | |
| 126 | - | return ports.MirrorSyncResult{}, err | |
| 179 | + | return pl, result, true, err | |
| 127 | 180 | } | |
| 128 | 181 | prev, err := m.status(ctx, repo) | |
| 129 | 182 | if err != nil { | |
| 130 | - | return ports.MirrorSyncResult{}, err | |
| 183 | + | return pl, result, true, err | |
| 131 | 184 | } | |
| 132 | - | start := m.cfg.Clock.Now().UTC() | |
| 133 | - | now := start | |
| 185 | + | now := m.cfg.Clock.Now().UTC() | |
| 134 | 186 | if !t.Enabled { | |
| 135 | 187 | s := domain.MirrorDisable(prev, now) | |
| 136 | 188 | if prev.State != domain.MirrorDisabled { | |
| 137 | 189 | err = m.cfg.Store.PutStatus(ctx, s) | |
| 138 | 190 | } | |
| 139 | - | return ports.MirrorSyncResult{Status: s}, err | |
| 191 | + | return pl, ports.MirrorSyncResult{Status: s}, true, err | |
| 140 | 192 | } | |
| 141 | - | if !force && (prev.State == domain.MirrorPaused || (!manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) { | |
| 142 | - | return ports.MirrorSyncResult{Status: prev}, nil | |
| 193 | + | if !req.force && (prev.State == domain.MirrorPaused || (!req.manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) { | |
| 194 | + | return pl, ports.MirrorSyncResult{Status: prev}, true, nil | |
| 143 | 195 | } | |
| 144 | - | if manual && !force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval { | |
| 145 | - | return ports.MirrorSyncResult{Status: prev}, fmt.Errorf("%w: last sync %s ago; wait 60s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt)) | |
| 196 | + | if req.manual && !req.force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval { | |
| 197 | + | return pl, ports.MirrorSyncResult{Status: prev}, true, fmt.Errorf("%w: last sync %s ago; wait %s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt).Round(time.Second), domain.MirrorManualInterval) | |
| 146 | 198 | } | |
| 147 | 199 | m.mu.Lock() | |
| 148 | - | generation := m.dirty[repo] | |
| 200 | + | generation := m.gens[repo].dirty | |
| 149 | 201 | m.mu.Unlock() | |
| 150 | - | refs, runErr := m.cfg.Reader.ListRefs(ctx, repo) | |
| 202 | + | return mirrorPlan{meta: meta, target: t, prev: prev, start: now, generation: generation}, result, false, nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // attempt runs one sync. The configuration lock is held to read and to record, never while | |
| 206 | + | // pushing: a slow remote must not block configuration changes. | |
| 207 | + | func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, req mirrorReq) (ports.MirrorSyncResult, error) { | |
| 208 | + | pl, result, done, err := m.plan(ctx, repo, req) | |
| 209 | + | if done || err != nil { | |
| 210 | + | return result, err | |
| 211 | + | } | |
| 212 | + | t := pl.target | |
| 213 | + | // A missing sealer or an unreadable key fails the attempt before any repository I/O. | |
| 214 | + | key, runErr := m.privateKey(ctx) | |
| 215 | + | var refs []domain.Ref | |
| 151 | 216 | var selected []domain.MirrorRef | |
| 152 | 217 | if runErr == nil { | |
| 153 | - | branch, _, e := m.cfg.Git.DefaultBranch(ctx, repo) | |
| 154 | - | runErr = e | |
| 155 | - | if e == nil { | |
| 218 | + | refs, runErr = m.cfg.Reader.ListRefs(ctx, repo) | |
| 219 | + | } | |
| 220 | + | if runErr == nil { | |
| 221 | + | var branch string | |
| 222 | + | branch, _, runErr = m.cfg.Git.DefaultBranch(ctx, repo) | |
| 223 | + | if runErr == nil { | |
| 156 | 224 | selected = domain.MirrorRefspecs(refs, branch, t.AllBranches) | |
| 157 | 225 | } | |
| 158 | 226 | } | |
| 159 | 227 | noOp := false | |
| 160 | - | if runErr == nil && !force { | |
| 228 | + | if runErr == nil && !req.force { | |
| 161 | 229 | snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo) | |
| 162 | 230 | if e != nil && !errors.Is(e, domain.ErrNotFound) { | |
| 163 | 231 | return ports.MirrorSyncResult{}, e | |
| @@ -165,17 +233,13 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu | |||
| 165 | 233 | noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches) | |
| 166 | 234 | } | |
| 167 | 235 | if runErr == nil && !noOp { | |
| 168 | - | key, e := m.privateKey(ctx) | |
| 169 | - | runErr = e | |
| 170 | - | if e == nil { | |
| 171 | - | specs := make([]string, len(selected)) | |
| 172 | - | for i, ref := range selected { | |
| 173 | - | specs[i] = ref.Name + ":" + ref.Name | |
| 174 | - | } | |
| 175 | - | // An empty ref list must not invoke git's implicit default push behavior. | |
| 176 | - | if len(specs) > 0 { | |
| 177 | - | _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key}) | |
| 178 | - | } | |
| 236 | + | specs := make([]string, len(selected)) | |
| 237 | + | for i, ref := range selected { | |
| 238 | + | specs[i] = ref.Name + ":" + ref.Name | |
| 239 | + | } | |
| 240 | + | // An empty ref list must not invoke git's implicit default push behavior. | |
| 241 | + | if len(specs) > 0 { | |
| 242 | + | _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key}) | |
| 179 | 243 | } | |
| 180 | 244 | } | |
| 181 | 245 | if ctx.Err() != nil { | |
| @@ -183,24 +247,30 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu | |||
| 183 | 247 | } | |
| 184 | 248 | var failure *domain.MirrorError | |
| 185 | 249 | if runErr != nil && !errors.As(runErr, &failure) { | |
| 186 | - | detail := "local mirror operation failed" | |
| 187 | - | if m.cfg.Sealer == nil { | |
| 188 | - | detail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key" | |
| 189 | - | } | |
| 190 | - | failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |
| 250 | + | failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: "local mirror operation failed"} | |
| 191 | 251 | runErr = failure | |
| 192 | - | if m.cfg.Sealer == nil { | |
| 193 | - | runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail) | |
| 194 | - | } | |
| 252 | + | } | |
| 253 | + | return m.record(ctx, repo, pl, selected, noOp, failure, runErr) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // record stores the outcome, unless the effective target changed during the push: the result | |
| 257 | + | // then describes a target that no longer applies and is discarded. | |
| 258 | + | func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) { | |
| 259 | + | m.configMu.RLock() | |
| 260 | + | defer m.configMu.RUnlock() | |
| 261 | + | t, err := m.target(ctx, repo) | |
| 262 | + | if err != nil { | |
| 263 | + | return ports.MirrorSyncResult{}, err | |
| 264 | + | } | |
| 265 | + | if !sameMirrorTarget(pl.target, t) { | |
| 266 | + | s, err := m.status(ctx, repo) | |
| 267 | + | return ports.MirrorSyncResult{Status: s}, err | |
| 195 | 268 | } | |
| 196 | 269 | // The push may be slow: completion, retry and success times come from the | |
| 197 | 270 | // clock after it returns; only LastAttemptAt marks when the attempt began. | |
| 198 | - | now = m.cfg.Clock.Now().UTC() | |
| 199 | - | transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now) | |
| 200 | - | transition.Status.LastAttemptAt = start | |
| 201 | - | if failure != nil && m.cfg.Sealer == nil { | |
| 202 | - | transition.Status.Reason = failure.Detail | |
| 203 | - | } | |
| 271 | + | now := m.cfg.Clock.Now().UTC() | |
| 272 | + | transition := domain.ApplyMirrorOutcome(pl.prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now) | |
| 273 | + | transition.Status.LastAttemptAt = pl.start | |
| 204 | 274 | if failure == nil { | |
| 205 | 275 | if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil { | |
| 206 | 276 | return ports.MirrorSyncResult{}, err | |
| @@ -211,15 +281,17 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu | |||
| 211 | 281 | } | |
| 212 | 282 | if failure == nil { | |
| 213 | 283 | m.mu.Lock() | |
| 214 | - | m.clean[repo] = generation | |
| 284 | + | g := m.gens[repo] | |
| 285 | + | g.clean, g.cleanKnown = pl.generation, true | |
| 286 | + | m.gens[repo] = g | |
| 215 | 287 | m.mu.Unlock() | |
| 216 | 288 | } | |
| 217 | 289 | if m.cfg.Bus != nil { | |
| 218 | 290 | if transition.FailedEvent { | |
| 219 | - | m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, Reason: transition.Status.Reason, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt}) | |
| 291 | + | m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt}) | |
| 220 | 292 | } | |
| 221 | 293 | if transition.Recovered { | |
| 222 | - | m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt}) | |
| 294 | + | m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt}) | |
| 223 | 295 | } | |
| 224 | 296 | } | |
| 225 | 297 | return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr | |
| @@ -6,19 +6,28 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "bnema.dev/ermite/internal/domain" | 11 | "bnema.dev/ermite/internal/domain" | ||
| 11 | "bnema.dev/ermite/internal/ports" | 12 | "bnema.dev/ermite/internal/ports" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 15 | + | // mirrorReq is what a sync request asks for. Requests that coalesce merge by OR. | |||
| 16 | + | type mirrorReq struct{ force, manual bool } | |||
| 17 | + | ||||
| 18 | + | func (r mirrorReq) merge(o mirrorReq) mirrorReq { | |||
| 19 | + | return mirrorReq{force: r.force || o.force, manual: r.manual || o.manual} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // mirrorFlight is the single in-flight sync of a repository: cur is running, next is the | |||
| 23 | + | // merged request that arrived meanwhile (one rerun, whatever the number of requests). | |||
| 14 | type mirrorFlight struct { | 24 | type mirrorFlight struct { | ||
| 15 | - | pending, force, manual bool | 25 | + | cur mirrorReq |
| 16 | - | pendingForce bool | 26 | + | next *mirrorReq |
| 17 | - | pendingManual bool | 27 | + | done chan struct{} // closed with the result of cur's first run |
| 18 | - | done chan struct{} | 28 | + | completed bool |
| 19 | - | completed bool | 29 | + | result ports.MirrorSyncResult |
| 20 | - | result ports.MirrorSyncResult | 30 | + | err error |
| 21 | - | err error | |||
| 22 | } | 31 | } | ||
| 23 | 32 | ||||
| 24 | var ( | 33 | var ( | ||
| @@ -33,12 +42,12 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo | |||||
| 33 | return ports.MirrorSyncResult{}, err | 42 | return ports.MirrorSyncResult{}, err | ||
| 34 | } | 43 | } | ||
| 35 | m.Start() | 44 | m.Start() | ||
| 36 | - | f, queued, err := m.enqueue(repo, force, true) | 45 | + | f, queued, err := m.enqueue(repo, mirrorReq{force: force, manual: true}) |
| 37 | if err != nil { | 46 | if err != nil { | ||
| 38 | return ports.MirrorSyncResult{}, err | 47 | return ports.MirrorSyncResult{}, err | ||
| 39 | } | 48 | } | ||
| 40 | if queued { | 49 | if queued { | ||
| 41 | - | return ports.MirrorSyncResult{Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil | 50 | + | return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil |
| 42 | } | 51 | } | ||
| 43 | select { | 52 | select { | ||
| 44 | case <-m.ctx.Done(): | 53 | case <-m.ctx.Done(): | ||
| @@ -46,11 +55,14 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo | |||||
| 46 | case <-ctx.Done(): | 55 | case <-ctx.Done(): | ||
| 47 | return ports.MirrorSyncResult{}, ctx.Err() | 56 | return ports.MirrorSyncResult{}, ctx.Err() | ||
| 48 | case <-f.done: | 57 | case <-f.done: | ||
| 58 | + | f.result.Repo = repo | |||
| 49 | return f.result, f.err | 59 | return f.result, f.err | ||
| 50 | } | 60 | } | ||
| 51 | } | 61 | } | ||
| 52 | 62 | ||||
| 53 | - | // SyncAll requests a sync for every repository as an owner. | 63 | + | // SyncAll syncs every repository whose mirror is enabled, as an owner. A failing repository |
| 64 | + | // does not stop the others: its outcome carries the error. The returned error is only for | |||
| 65 | + | // what prevents the whole request (authorization, listing, cancellation). | |||
| 54 | func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) { | 66 | func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) { | ||
| 55 | if err := mirrorOwner(p); err != nil { | 67 | if err := mirrorOwner(p); err != nil { | ||
| 56 | return nil, err | 68 | return nil, err | ||
| @@ -61,103 +73,159 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ( | |||||
| 61 | } | 73 | } | ||
| 62 | out := make([]ports.MirrorSyncResult, 0, len(repos)) | 74 | out := make([]ports.MirrorSyncResult, 0, len(repos)) | ||
| 63 | for _, r := range repos { | 75 | for _, r := range repos { | ||
| 64 | - | v, err := m.Sync(ctx, p, r.Name, force) | 76 | + | if err := ctx.Err(); err != nil { |
| 65 | - | if err != nil { | |||
| 66 | return out, err | 77 | return out, err | ||
| 67 | } | 78 | } | ||
| 68 | - | out = append(out, v) | 79 | + | t, err := m.target(ctx, r.Name) |
| 80 | + | if err == nil && !t.Enabled { | |||
| 81 | + | continue | |||
| 82 | + | } | |||
| 83 | + | var res ports.MirrorSyncResult | |||
| 84 | + | if err == nil { | |||
| 85 | + | res, err = m.Sync(ctx, p, r.Name, force) | |||
| 86 | + | } | |||
| 87 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 88 | + | return out, ctxErr | |||
| 89 | + | } | |||
| 90 | + | res.Repo = r.Name | |||
| 91 | + | if res.Status.Repo == (domain.RepoName{}) { | |||
| 92 | + | res.Status.Repo = r.Name | |||
| 93 | + | } | |||
| 94 | + | res.Err = err | |||
| 95 | + | out = append(out, res) | |||
| 69 | } | 96 | } | ||
| 70 | return out, nil | 97 | return out, nil | ||
| 71 | } | 98 | } | ||
| 72 | - | func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlight, bool, error) { | 99 | + | |
| 100 | + | // Wait blocks until no sync is queued or running, or ctx is done. It is for tests and shutdown | |||
| 101 | + | // paths that need a deterministic barrier. | |||
| 102 | + | func (m *Mirrors) Wait(ctx context.Context) { | |||
| 103 | + | done := make(chan struct{}) | |||
| 104 | + | go func() { m.flightWG.Wait(); close(done) }() | |||
| 105 | + | select { | |||
| 106 | + | case <-done: | |||
| 107 | + | case <-ctx.Done(): | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (m *Mirrors) enqueue(repo domain.RepoName, req mirrorReq) (*mirrorFlight, bool, error) { | |||
| 73 | m.mu.Lock() | 112 | m.mu.Lock() | ||
| 74 | defer m.mu.Unlock() | 113 | defer m.mu.Unlock() | ||
| 75 | if m.closed { | 114 | if m.closed { | ||
| 76 | return nil, false, context.Canceled | 115 | return nil, false, context.Canceled | ||
| 77 | } | 116 | } | ||
| 78 | if f := m.flights[repo]; f != nil { | 117 | if f := m.flights[repo]; f != nil { | ||
| 79 | - | f.pending = true | 118 | + | next := req |
| 80 | - | f.pendingForce = f.pendingForce || force | 119 | + | if f.next != nil { |
| 81 | - | f.pendingManual = f.pendingManual || manual | 120 | + | next = f.next.merge(req) |
| 121 | + | } | |||
| 122 | + | f.next = &next | |||
| 82 | return f, true, nil | 123 | return f, true, nil | ||
| 83 | } | 124 | } | ||
| 84 | - | f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})} | 125 | + | f := &mirrorFlight{cur: req, done: make(chan struct{})} |
| 85 | - | m.flights[repo] = f | |||
| 86 | select { | 126 | select { | ||
| 87 | case m.jobs <- repo: | 127 | case m.jobs <- repo: | ||
| 128 | + | m.flights[repo] = f | |||
| 129 | + | m.flightWG.Add(1) | |||
| 88 | return f, false, nil | 130 | return f, false, nil | ||
| 89 | default: | 131 | default: | ||
| 90 | - | delete(m.flights, repo) | |||
| 91 | return nil, false, domain.ErrResourceExhausted | 132 | return nil, false, domain.ErrResourceExhausted | ||
| 92 | } | 133 | } | ||
| 93 | } | 134 | } | ||
| 135 | + | ||||
| 94 | func (m *Mirrors) run(repo domain.RepoName) { | 136 | func (m *Mirrors) run(repo domain.RepoName) { | ||
| 95 | m.mu.Lock() | 137 | m.mu.Lock() | ||
| 96 | f := m.flights[repo] | 138 | f := m.flights[repo] | ||
| 97 | m.mu.Unlock() | 139 | m.mu.Unlock() | ||
| 98 | for { | 140 | for { | ||
| 99 | - | result, err := m.attempt(m.ctx, repo, f.force, f.manual) | 141 | + | result, err := m.attempt(m.ctx, repo, f.cur) |
| 100 | m.mu.Lock() | 142 | m.mu.Lock() | ||
| 101 | if !f.completed { | 143 | if !f.completed { | ||
| 102 | f.result, f.err = result, err | 144 | f.result, f.err = result, err | ||
| 103 | f.completed = true | 145 | f.completed = true | ||
| 104 | close(f.done) | 146 | close(f.done) | ||
| 105 | } | 147 | } | ||
| 106 | - | if !f.pending || m.closed { | 148 | + | if f.next == nil || m.closed { |
| 107 | delete(m.flights, repo) | 149 | delete(m.flights, repo) | ||
| 150 | + | m.flightWG.Done() | |||
| 108 | m.mu.Unlock() | 151 | m.mu.Unlock() | ||
| 109 | return | 152 | return | ||
| 110 | } | 153 | } | ||
| 111 | - | f.force, f.manual = f.pendingForce, f.pendingManual | 154 | + | f.cur, f.next = *f.next, nil |
| 112 | - | f.pending, f.pendingForce, f.pendingManual = false, false, false | |||
| 113 | m.mu.Unlock() | 155 | m.mu.Unlock() | ||
| 114 | } | 156 | } | ||
| 115 | } | 157 | } | ||
| 116 | 158 | ||||
| 117 | - | func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) { | 159 | + | // mirrorPlan is what attempt decided under the configuration lock, before pushing. |
| 160 | + | type mirrorPlan struct { | |||
| 161 | + | meta domain.Repo | |||
| 162 | + | target domain.MirrorTarget | |||
| 163 | + | prev domain.MirrorStatus | |||
| 164 | + | start time.Time | |||
| 165 | + | generation uint64 | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // plan reads the target and status and applies the skip and throttle rules. When done is set | |||
| 169 | + | // the attempt ends with result and err. | |||
| 170 | + | func (m *Mirrors) plan(ctx context.Context, repo domain.RepoName, req mirrorReq) (pl mirrorPlan, result ports.MirrorSyncResult, done bool, err error) { | |||
| 118 | m.configMu.RLock() | 171 | m.configMu.RLock() | ||
| 119 | defer m.configMu.RUnlock() | 172 | defer m.configMu.RUnlock() | ||
| 120 | meta, err := m.cfg.Meta.Get(ctx, repo) | 173 | meta, err := m.cfg.Meta.Get(ctx, repo) | ||
| 121 | if err != nil { | 174 | if err != nil { | ||
| 122 | - | return ports.MirrorSyncResult{}, err | 175 | + | return pl, result, true, err |
| 123 | } | 176 | } | ||
| 124 | t, err := m.target(ctx, repo) | 177 | t, err := m.target(ctx, repo) | ||
| 125 | if err != nil { | 178 | if err != nil { | ||
| 126 | - | return ports.MirrorSyncResult{}, err | 179 | + | return pl, result, true, err |
| 127 | } | 180 | } | ||
| 128 | prev, err := m.status(ctx, repo) | 181 | prev, err := m.status(ctx, repo) | ||
| 129 | if err != nil { | 182 | if err != nil { | ||
| 130 | - | return ports.MirrorSyncResult{}, err | 183 | + | return pl, result, true, err |
| 131 | } | 184 | } | ||
| 132 | - | start := m.cfg.Clock.Now().UTC() | 185 | + | now := m.cfg.Clock.Now().UTC() |
| 133 | - | now := start | |||
| 134 | if !t.Enabled { | 186 | if !t.Enabled { | ||
| 135 | s := domain.MirrorDisable(prev, now) | 187 | s := domain.MirrorDisable(prev, now) | ||
| 136 | if prev.State != domain.MirrorDisabled { | 188 | if prev.State != domain.MirrorDisabled { | ||
| 137 | err = m.cfg.Store.PutStatus(ctx, s) | 189 | err = m.cfg.Store.PutStatus(ctx, s) | ||
| 138 | } | 190 | } | ||
| 139 | - | return ports.MirrorSyncResult{Status: s}, err | 191 | + | return pl, ports.MirrorSyncResult{Status: s}, true, err |
| 140 | } | 192 | } | ||
| 141 | - | if !force && (prev.State == domain.MirrorPaused || (!manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) { | 193 | + | if !req.force && (prev.State == domain.MirrorPaused || (!req.manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) { |
| 142 | - | return ports.MirrorSyncResult{Status: prev}, nil | 194 | + | return pl, ports.MirrorSyncResult{Status: prev}, true, nil |
| 143 | } | 195 | } | ||
| 144 | - | if manual && !force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval { | 196 | + | if req.manual && !req.force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval { |
| 145 | - | return ports.MirrorSyncResult{Status: prev}, fmt.Errorf("%w: last sync %s ago; wait 60s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt)) | 197 | + | return pl, ports.MirrorSyncResult{Status: prev}, true, fmt.Errorf("%w: last sync %s ago; wait %s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt).Round(time.Second), domain.MirrorManualInterval) |
| 146 | } | 198 | } | ||
| 147 | m.mu.Lock() | 199 | m.mu.Lock() | ||
| 148 | - | generation := m.dirty[repo] | 200 | + | generation := m.gens[repo].dirty |
| 149 | m.mu.Unlock() | 201 | m.mu.Unlock() | ||
| 150 | - | refs, runErr := m.cfg.Reader.ListRefs(ctx, repo) | 202 | + | return mirrorPlan{meta: meta, target: t, prev: prev, start: now, generation: generation}, result, false, nil |
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // attempt runs one sync. The configuration lock is held to read and to record, never while | |||
| 206 | + | // pushing: a slow remote must not block configuration changes. | |||
| 207 | + | func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, req mirrorReq) (ports.MirrorSyncResult, error) { | |||
| 208 | + | pl, result, done, err := m.plan(ctx, repo, req) | |||
| 209 | + | if done || err != nil { | |||
| 210 | + | return result, err | |||
| 211 | + | } | |||
| 212 | + | t := pl.target | |||
| 213 | + | // A missing sealer or an unreadable key fails the attempt before any repository I/O. | |||
| 214 | + | key, runErr := m.privateKey(ctx) | |||
| 215 | + | var refs []domain.Ref | |||
| 151 | var selected []domain.MirrorRef | 216 | var selected []domain.MirrorRef | ||
| 152 | if runErr == nil { | 217 | if runErr == nil { | ||
| 153 | - | branch, _, e := m.cfg.Git.DefaultBranch(ctx, repo) | 218 | + | refs, runErr = m.cfg.Reader.ListRefs(ctx, repo) |
| 154 | - | runErr = e | 219 | + | } |
| 155 | - | if e == nil { | 220 | + | if runErr == nil { |
| 221 | + | var branch string | |||
| 222 | + | branch, _, runErr = m.cfg.Git.DefaultBranch(ctx, repo) | |||
| 223 | + | if runErr == nil { | |||
| 156 | selected = domain.MirrorRefspecs(refs, branch, t.AllBranches) | 224 | selected = domain.MirrorRefspecs(refs, branch, t.AllBranches) | ||
| 157 | } | 225 | } | ||
| 158 | } | 226 | } | ||
| 159 | noOp := false | 227 | noOp := false | ||
| 160 | - | if runErr == nil && !force { | 228 | + | if runErr == nil && !req.force { |
| 161 | snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo) | 229 | snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo) | ||
| 162 | if e != nil && !errors.Is(e, domain.ErrNotFound) { | 230 | if e != nil && !errors.Is(e, domain.ErrNotFound) { | ||
| 163 | return ports.MirrorSyncResult{}, e | 231 | return ports.MirrorSyncResult{}, e | ||
| @@ -165,17 +233,13 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu | |||||
| 165 | noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches) | 233 | noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches) | ||
| 166 | } | 234 | } | ||
| 167 | if runErr == nil && !noOp { | 235 | if runErr == nil && !noOp { | ||
| 168 | - | key, e := m.privateKey(ctx) | 236 | + | specs := make([]string, len(selected)) |
| 169 | - | runErr = e | 237 | + | for i, ref := range selected { |
| 170 | - | if e == nil { | 238 | + | specs[i] = ref.Name + ":" + ref.Name |
| 171 | - | specs := make([]string, len(selected)) | 239 | + | } |
| 172 | - | for i, ref := range selected { | 240 | + | // An empty ref list must not invoke git's implicit default push behavior. |
| 173 | - | specs[i] = ref.Name + ":" + ref.Name | 241 | + | if len(specs) > 0 { |
| 174 | - | } | 242 | + | _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key}) |
| 175 | - | // An empty ref list must not invoke git's implicit default push behavior. | |||
| 176 | - | if len(specs) > 0 { | |||
| 177 | - | _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key}) | |||
| 178 | - | } | |||
| 179 | } | 243 | } | ||
| 180 | } | 244 | } | ||
| 181 | if ctx.Err() != nil { | 245 | if ctx.Err() != nil { | ||
| @@ -183,24 +247,30 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu | |||||
| 183 | } | 247 | } | ||
| 184 | var failure *domain.MirrorError | 248 | var failure *domain.MirrorError | ||
| 185 | if runErr != nil && !errors.As(runErr, &failure) { | 249 | if runErr != nil && !errors.As(runErr, &failure) { | ||
| 186 | - | detail := "local mirror operation failed" | 250 | + | failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: "local mirror operation failed"} |
| 187 | - | if m.cfg.Sealer == nil { | |||
| 188 | - | detail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key" | |||
| 189 | - | } | |||
| 190 | - | failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |||
| 191 | runErr = failure | 251 | runErr = failure | ||
| 192 | - | if m.cfg.Sealer == nil { | 252 | + | } |
| 193 | - | runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail) | 253 | + | return m.record(ctx, repo, pl, selected, noOp, failure, runErr) |
| 194 | - | } | 254 | + | } |
| 255 | + | ||||
| 256 | + | // record stores the outcome, unless the effective target changed during the push: the result | |||
| 257 | + | // then describes a target that no longer applies and is discarded. | |||
| 258 | + | func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) { | |||
| 259 | + | m.configMu.RLock() | |||
| 260 | + | defer m.configMu.RUnlock() | |||
| 261 | + | t, err := m.target(ctx, repo) | |||
| 262 | + | if err != nil { | |||
| 263 | + | return ports.MirrorSyncResult{}, err | |||
| 264 | + | } | |||
| 265 | + | if !sameMirrorTarget(pl.target, t) { | |||
| 266 | + | s, err := m.status(ctx, repo) | |||
| 267 | + | return ports.MirrorSyncResult{Status: s}, err | |||
| 195 | } | 268 | } | ||
| 196 | // The push may be slow: completion, retry and success times come from the | 269 | // The push may be slow: completion, retry and success times come from the | ||
| 197 | // clock after it returns; only LastAttemptAt marks when the attempt began. | 270 | // clock after it returns; only LastAttemptAt marks when the attempt began. | ||
| 198 | - | now = m.cfg.Clock.Now().UTC() | 271 | + | now := m.cfg.Clock.Now().UTC() |
| 199 | - | transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now) | 272 | + | transition := domain.ApplyMirrorOutcome(pl.prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now) |
| 200 | - | transition.Status.LastAttemptAt = start | 273 | + | transition.Status.LastAttemptAt = pl.start |
| 201 | - | if failure != nil && m.cfg.Sealer == nil { | |||
| 202 | - | transition.Status.Reason = failure.Detail | |||
| 203 | - | } | |||
| 204 | if failure == nil { | 274 | if failure == nil { | ||
| 205 | if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil { | 275 | if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil { | ||
| 206 | return ports.MirrorSyncResult{}, err | 276 | return ports.MirrorSyncResult{}, err | ||
| @@ -211,15 +281,17 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu | |||||
| 211 | } | 281 | } | ||
| 212 | if failure == nil { | 282 | if failure == nil { | ||
| 213 | m.mu.Lock() | 283 | m.mu.Lock() | ||
| 214 | - | m.clean[repo] = generation | 284 | + | g := m.gens[repo] |
| 285 | + | g.clean, g.cleanKnown = pl.generation, true | |||
| 286 | + | m.gens[repo] = g | |||
| 215 | m.mu.Unlock() | 287 | m.mu.Unlock() | ||
| 216 | } | 288 | } | ||
| 217 | if m.cfg.Bus != nil { | 289 | if m.cfg.Bus != nil { | ||
| 218 | if transition.FailedEvent { | 290 | if transition.FailedEvent { | ||
| 219 | - | m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, Reason: transition.Status.Reason, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt}) | 291 | + | m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt}) |
| 220 | } | 292 | } | ||
| 221 | if transition.Recovered { | 293 | if transition.Recovered { | ||
| 222 | - | m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt}) | 294 | + | m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt}) |
| 223 | } | 295 | } | ||
| 224 | } | 296 | } | ||
| 225 | return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr | 297 | return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr | ||
A internal/usecase/mirrors_sync_test.go +492 -0
| @@ -0,0 +1,492 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zerowrap" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | var errBoom = errors.New("boom") | |
| 24 | + | ||
| 25 | + | func (f *mirrorFixture) pushes(t *testing.T, result error) *int { | |
| 26 | + | t.Helper() | |
| 27 | + | var mu sync.Mutex | |
| 28 | + | n := new(int) | |
| 29 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 30 | + | mu.Lock() | |
| 31 | + | defer mu.Unlock() | |
| 32 | + | *n++ | |
| 33 | + | return ports.MirrorPushResult{}, result | |
| 34 | + | }).Maybe() | |
| 35 | + | return n | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestMirrorsNoOpThrottleForce(t *testing.T) { | |
| 39 | + | f := newMirrorFixture(t) | |
| 40 | + | f.setSnapshot(f.repo, domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}}) | |
| 41 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | require.True(t, v.NoOp) | |
| 44 | + | _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 45 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 46 | + | require.Contains(t, err.Error(), "wait 1m0s", "the interval comes from the domain constant") | |
| 47 | + | require.Contains(t, err.Error(), "last sync 0s ago") | |
| 48 | + | f.push.EXPECT().Push(mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool { | |
| 49 | + | return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main" | |
| 50 | + | })).Return(ports.MirrorPushResult{}, nil).Once() | |
| 51 | + | v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.False(t, v.NoOp) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestMirrorsIncidentAndRecovery(t *testing.T) { | |
| 57 | + | f := newMirrorFixture(t) | |
| 58 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5) | |
| 59 | + | for i := 1; i <= 5; i++ { | |
| 60 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 61 | + | require.Error(t, err) | |
| 62 | + | require.Equal(t, i, v.Status.ConsecutiveFailures) | |
| 63 | + | if i < 5 { | |
| 64 | + | require.Equal(t, domain.MirrorFailing, v.Status.State) | |
| 65 | + | require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt)) | |
| 66 | + | } else { | |
| 67 | + | require.Equal(t, domain.MirrorPaused, v.Status.State) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | events := f.eventList() | |
| 71 | + | require.Len(t, events, 1, "mirror.failed once per incident") | |
| 72 | + | failed, ok := events[0].(domain.MirrorFailed) | |
| 73 | + | require.True(t, ok) | |
| 74 | + | require.Equal(t, domain.MirrorFailing, failed.State) | |
| 75 | + | // A paused mirror is not retried by the schedule. | |
| 76 | + | f.advance(24 * time.Hour) | |
| 77 | + | f.m.ScheduleNow(context.Background()) | |
| 78 | + | f.drain(t) | |
| 79 | + | f.push.AssertNumberOfCalls(t, "Push", 5) | |
| 80 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |
| 81 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | require.Equal(t, domain.MirrorOK, v.Status.State) | |
| 84 | + | events = f.eventList() | |
| 85 | + | require.Len(t, events, 2) | |
| 86 | + | require.IsType(t, domain.MirrorRecovered{}, events[1]) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestMirrorsSingleFlight(t *testing.T) { | |
| 90 | + | f := newMirrorFixture(t) | |
| 91 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 92 | + | var mu sync.Mutex | |
| 93 | + | calls := 0 | |
| 94 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 95 | + | mu.Lock() | |
| 96 | + | calls++ | |
| 97 | + | first := calls == 1 | |
| 98 | + | mu.Unlock() | |
| 99 | + | if first { | |
| 100 | + | close(entered) | |
| 101 | + | <-release | |
| 102 | + | } | |
| 103 | + | return ports.MirrorPushResult{}, nil | |
| 104 | + | }) | |
| 105 | + | done := make(chan error, 1) | |
| 106 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 107 | + | <-entered | |
| 108 | + | type outcome struct { | |
| 109 | + | res ports.MirrorSyncResult | |
| 110 | + | err error | |
| 111 | + | } | |
| 112 | + | results := make(chan outcome, 20) | |
| 113 | + | var wg sync.WaitGroup | |
| 114 | + | for range 20 { | |
| 115 | + | wg.Add(1) | |
| 116 | + | go func() { | |
| 117 | + | defer wg.Done() | |
| 118 | + | res, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 119 | + | results <- outcome{res, err} | |
| 120 | + | }() | |
| 121 | + | } | |
| 122 | + | wg.Wait() | |
| 123 | + | close(results) | |
| 124 | + | for o := range results { | |
| 125 | + | require.NoError(t, o.err) | |
| 126 | + | require.True(t, o.res.Queued) | |
| 127 | + | require.Equal(t, f.repo, o.res.Repo) | |
| 128 | + | } | |
| 129 | + | close(release) | |
| 130 | + | require.NoError(t, <-done) | |
| 131 | + | f.drain(t) | |
| 132 | + | mu.Lock() | |
| 133 | + | defer mu.Unlock() | |
| 134 | + | require.Equal(t, 2, calls, "twenty overlapping requests coalesce into one rerun") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) { | |
| 138 | + | f := newMirrorFixture(t) | |
| 139 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 140 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 141 | + | close(entered) | |
| 142 | + | <-release | |
| 143 | + | return ports.MirrorPushResult{}, nil | |
| 144 | + | }).Once() | |
| 145 | + | done := make(chan error, 1) | |
| 146 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 147 | + | <-entered | |
| 148 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | require.True(t, v.Queued) | |
| 151 | + | close(release) | |
| 152 | + | require.NoError(t, <-done) | |
| 153 | + | f.drain(t) | |
| 154 | + | // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push | |
| 155 | + | // (a second call would fail the Once expectation). | |
| 156 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestMirrorsDivergedPausedAtOnce(t *testing.T) { | |
| 160 | + | f := newMirrorFixture(t) | |
| 161 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once() | |
| 162 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 163 | + | require.Error(t, err) | |
| 164 | + | require.Equal(t, domain.MirrorPaused, v.Status.State) | |
| 165 | + | for range 3 { | |
| 166 | + | f.advance(time.Hour) | |
| 167 | + | f.m.ScheduleNow(context.Background()) | |
| 168 | + | } | |
| 169 | + | f.drain(t) | |
| 170 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |
| 171 | + | require.Len(t, f.eventList(), 1) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestMirrorsCloseCancelsWorker(t *testing.T) { | |
| 175 | + | f := newMirrorFixture(t) | |
| 176 | + | entered, exited := make(chan struct{}), make(chan struct{}) | |
| 177 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 178 | + | close(entered) | |
| 179 | + | <-ctx.Done() | |
| 180 | + | close(exited) | |
| 181 | + | return ports.MirrorPushResult{}, context.Canceled | |
| 182 | + | }).Once() | |
| 183 | + | done := make(chan error, 1) | |
| 184 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 185 | + | <-entered | |
| 186 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 187 | + | defer cancel() | |
| 188 | + | f.m.Close(ctx) | |
| 189 | + | require.NoError(t, ctx.Err()) | |
| 190 | + | select { | |
| 191 | + | case <-exited: | |
| 192 | + | default: | |
| 193 | + | t.Fatal("Close returned before worker exited") | |
| 194 | + | } | |
| 195 | + | require.ErrorIs(t, <-done, context.Canceled) | |
| 196 | + | f.m.Close(ctx) // idempotent; Start cannot resurrect workers. | |
| 197 | + | f.m.Start() | |
| 198 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 199 | + | require.ErrorIs(t, err, context.Canceled) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestMirrorsCronBackoff(t *testing.T) { | |
| 203 | + | f := newMirrorFixture(t) | |
| 204 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Twice() | |
| 205 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 206 | + | require.Error(t, err) | |
| 207 | + | f.advance(time.Minute) | |
| 208 | + | f.m.ScheduleNow(context.Background()) | |
| 209 | + | f.drain(t) | |
| 210 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |
| 211 | + | f.advance(4 * time.Minute) | |
| 212 | + | f.m.ScheduleNow(context.Background()) | |
| 213 | + | f.drain(t) | |
| 214 | + | f.push.AssertNumberOfCalls(t, "Push", 2) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestMirrorsCronDueAndDirtySkip(t *testing.T) { | |
| 218 | + | f := newMirrorFixture(t) | |
| 219 | + | f.pushes(t, nil) | |
| 220 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 221 | + | require.NoError(t, err) | |
| 222 | + | require.Equal(t, 1, f.refReads()) | |
| 223 | + | f.advance(5 * time.Minute) | |
| 224 | + | f.m.ScheduleNow(context.Background()) // due and clean: skipped without reading refs | |
| 225 | + | f.drain(t) | |
| 226 | + | require.Equal(t, 1, f.refReads()) | |
| 227 | + | // Merges and default branch changes both end in OnPush: an idempotent dirty mark. | |
| 228 | + | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}) | |
| 229 | + | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}}) | |
| 230 | + | f.m.ScheduleNow(context.Background()) // same minute: nothing runs, although dirty | |
| 231 | + | f.drain(t) | |
| 232 | + | require.Equal(t, 1, f.refReads()) | |
| 233 | + | f.advance(5 * time.Minute) | |
| 234 | + | f.m.ScheduleNow(context.Background()) | |
| 235 | + | f.drain(t) | |
| 236 | + | require.Equal(t, 2, f.refReads(), "the dirty repository is examined on the next due tick") | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func TestMirrorsScheduleKeepsFullQueueRepositoriesForNextTicks(t *testing.T) { | |
| 240 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 }) | |
| 241 | + | repos := []domain.RepoName{f.repo, name(t, "b"), name(t, "c"), name(t, "d")} | |
| 242 | + | for _, r := range repos[1:] { | |
| 243 | + | f.addRepo(r) | |
| 244 | + | } | |
| 245 | + | gate := make(chan struct{}) | |
| 246 | + | var mu sync.Mutex | |
| 247 | + | pushed := map[string]int{} | |
| 248 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 249 | + | <-gate | |
| 250 | + | mu.Lock() | |
| 251 | + | pushed[p.URL]++ | |
| 252 | + | mu.Unlock() | |
| 253 | + | return ports.MirrorPushResult{}, nil | |
| 254 | + | }) | |
| 255 | + | f.advance(5 * time.Minute) | |
| 256 | + | f.m.ScheduleNow(context.Background()) | |
| 257 | + | // The worker holds one repository and the queue one: the other two found it full. | |
| 258 | + | close(gate) | |
| 259 | + | for range 6 { // later ticks of the same minute drain the deferred set | |
| 260 | + | f.drain(t) | |
| 261 | + | f.m.ScheduleNow(context.Background()) | |
| 262 | + | } | |
| 263 | + | f.drain(t) | |
| 264 | + | mu.Lock() | |
| 265 | + | defer mu.Unlock() | |
| 266 | + | for _, r := range repos { | |
| 267 | + | require.Equal(t, 1, pushed["git@example.com:acme/"+r.String()], r.String()) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func TestMirrorsSyncRefusedWhenQueueFull(t *testing.T) { | |
| 272 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 }) | |
| 273 | + | second, third := name(t, "b"), name(t, "c") | |
| 274 | + | f.addRepo(second) | |
| 275 | + | f.addRepo(third) | |
| 276 | + | gate := make(chan struct{}) | |
| 277 | + | entered := make(chan struct{}, 8) | |
| 278 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 279 | + | entered <- struct{}{} | |
| 280 | + | <-gate | |
| 281 | + | return ports.MirrorPushResult{}, nil | |
| 282 | + | }) | |
| 283 | + | first := make(chan error, 1) | |
| 284 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); first <- err }() | |
| 285 | + | <-entered // the single worker is busy | |
| 286 | + | // Enqueueing is synchronous: this request takes the only queue slot, then waits and gives up. | |
| 287 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 288 | + | defer cancel() | |
| 289 | + | _, err := f.m.Sync(ctx, domain.Root(), second, true) | |
| 290 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 291 | + | _, err = f.m.Sync(context.Background(), domain.Root(), third, true) | |
| 292 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 293 | + | close(gate) | |
| 294 | + | require.NoError(t, <-first) | |
| 295 | + | f.drain(t) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestMirrorsSlowPushTimestamps(t *testing.T) { | |
| 299 | + | f := newMirrorFixture(t) | |
| 300 | + | start := f.at | |
| 301 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 302 | + | f.advance(10 * time.Minute) | |
| 303 | + | return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient} | |
| 304 | + | }).Once() | |
| 305 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 306 | + | require.Error(t, err) | |
| 307 | + | require.True(t, v.Status.LastAttemptAt.Equal(start)) | |
| 308 | + | require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1)))) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func TestMirrorsPushDoesNotHoldConfigLock(t *testing.T) { | |
| 312 | + | f := newMirrorFixture(t) | |
| 313 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 314 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 315 | + | close(entered) | |
| 316 | + | <-release | |
| 317 | + | return ports.MirrorPushResult{}, nil | |
| 318 | + | }).Once() | |
| 319 | + | done := make(chan error, 1) | |
| 320 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 321 | + | <-entered | |
| 322 | + | changed := make(chan error, 1) | |
| 323 | + | go func() { | |
| 324 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |
| 325 | + | changed <- err | |
| 326 | + | }() | |
| 327 | + | select { | |
| 328 | + | case err := <-changed: | |
| 329 | + | require.NoError(t, err) | |
| 330 | + | case <-time.After(5 * time.Second): // only on failure: the push holds the configuration lock | |
| 331 | + | t.Fatal("configuration change blocked by a running push") | |
| 332 | + | } | |
| 333 | + | close(release) | |
| 334 | + | require.NoError(t, <-done) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestMirrorsResultDiscardedWhenTargetChangedDuringPush(t *testing.T) { | |
| 338 | + | f := newMirrorFixture(t) | |
| 339 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, ConsecutiveFailures: 1}) | |
| 340 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 341 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 342 | + | close(entered) | |
| 343 | + | <-release | |
| 344 | + | return ports.MirrorPushResult{}, nil | |
| 345 | + | }).Once() | |
| 346 | + | done := make(chan error, 1) | |
| 347 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 348 | + | <-entered | |
| 349 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{URLTemplate: ptr("git@other.example:acme/{repo}")}) | |
| 350 | + | require.NoError(t, err) | |
| 351 | + | close(release) | |
| 352 | + | require.NoError(t, <-done) | |
| 353 | + | _, ok := f.snapshotOf(f.repo) | |
| 354 | + | require.False(t, ok, "no snapshot for a target that no longer applies") | |
| 355 | + | require.Empty(t, f.eventList(), "no mirror.recovered for the old target") | |
| 356 | + | s := f.statusOf(f.repo) | |
| 357 | + | require.Equal(t, domain.MirrorOK, s.State) | |
| 358 | + | require.True(t, s.LastSuccessAt.IsZero(), "the push result was not recorded") | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func TestMirrorsSyncAllContinuesPastFailures(t *testing.T) { | |
| 362 | + | f := newMirrorFixture(t) | |
| 363 | + | bad, off := name(t, "bad"), name(t, "off") | |
| 364 | + | f.addRepo(bad) | |
| 365 | + | f.addRepo(off) | |
| 366 | + | f.setRepoConfig(domain.MirrorRepoConfig{Repo: off, Excluded: true}) | |
| 367 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) { | |
| 368 | + | if strings.HasSuffix(p.URL, "/bad") { | |
| 369 | + | return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged, Detail: "rejected"} | |
| 370 | + | } | |
| 371 | + | return ports.MirrorPushResult{}, nil | |
| 372 | + | }) | |
| 373 | + | got, err := f.m.SyncAll(context.Background(), domain.Root(), true) | |
| 374 | + | require.NoError(t, err) | |
| 375 | + | require.Len(t, got, 2, "the excluded repository is skipped") | |
| 376 | + | byRepo := map[domain.RepoName]ports.MirrorSyncResult{} | |
| 377 | + | for _, r := range got { | |
| 378 | + | byRepo[r.Repo] = r | |
| 379 | + | } | |
| 380 | + | require.NoError(t, byRepo[f.repo].Err) | |
| 381 | + | require.Equal(t, domain.MirrorOK, byRepo[f.repo].Status.State) | |
| 382 | + | var me *domain.MirrorError | |
| 383 | + | require.ErrorAs(t, byRepo[bad].Err, &me) | |
| 384 | + | require.Equal(t, domain.MirrorPaused, byRepo[bad].Status.State) | |
| 385 | + | _, listed := byRepo[off] | |
| 386 | + | require.False(t, listed) | |
| 387 | + | _, err = f.m.SyncAll(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin), true) | |
| 388 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func TestMirrorsLocalFailuresAreInternalAndNeverPush(t *testing.T) { | |
| 392 | + | for _, tc := range []struct { | |
| 393 | + | name string | |
| 394 | + | set func(*mirrorFixture) | |
| 395 | + | }{ | |
| 396 | + | {"ListRefs", func(f *mirrorFixture) { f.mu.Lock(); f.refsErr = errBoom; f.mu.Unlock() }}, | |
| 397 | + | {"DefaultBranch", func(f *mirrorFixture) { f.mu.Lock(); f.branchErr = errBoom; f.mu.Unlock() }}, | |
| 398 | + | } { | |
| 399 | + | t.Run(tc.name, func(t *testing.T) { | |
| 400 | + | f := newMirrorFixture(t) | |
| 401 | + | tc.set(f) | |
| 402 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) // a Push call would fail the strict mock | |
| 403 | + | var me *domain.MirrorError | |
| 404 | + | require.ErrorAs(t, err, &me) | |
| 405 | + | require.Equal(t, domain.MirrorClassInternal, me.Class) | |
| 406 | + | require.NotContains(t, me.Detail, "boom") | |
| 407 | + | require.Equal(t, domain.MirrorFailing, v.Status.State) | |
| 408 | + | }) | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func TestMirrorsMissingSecretsKeyFailsBeforeReadingRefs(t *testing.T) { | |
| 413 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil }) | |
| 414 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 415 | + | var me *domain.MirrorError | |
| 416 | + | require.ErrorAs(t, err, &me) | |
| 417 | + | require.Equal(t, domain.MirrorClassInternal, me.Class) | |
| 418 | + | require.Contains(t, me.Detail, "ERMITE_SECRETS_KEY") | |
| 419 | + | require.Equal(t, domain.MirrorFailing, v.Status.State) | |
| 420 | + | require.Zero(t, f.refReads()) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) { | |
| 424 | + | f := newMirrorFixture(t) | |
| 425 | + | var logs bytes.Buffer | |
| 426 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |
| 427 | + | credentialURL := "ssh://alice:secret-password@example.com/acme/tools" | |
| 428 | + | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL}) | |
| 429 | + | require.Error(t, err) | |
| 430 | + | require.NotContains(t, err.Error(), "secret-password") | |
| 431 | + | require.NotContains(t, err.Error(), "alice") | |
| 432 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once() | |
| 433 | + | _, err = f.m.Sync(ctx, domain.Root(), f.repo, true) | |
| 434 | + | require.Error(t, err) | |
| 435 | + | require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER") | |
| 436 | + | require.NotContains(t, err.Error(), "secret-password") | |
| 437 | + | require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER") | |
| 438 | + | require.NotContains(t, logs.String(), "secret-password") | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) { | |
| 442 | + | f := newKeyFixture(t) | |
| 443 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |
| 444 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, true).Return(nil).Once() | |
| 445 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() | |
| 446 | + | first, err := f.m.ShowKey(context.Background(), domain.Root()) | |
| 447 | + | require.NoError(t, err) | |
| 448 | + | cached, err := f.m.ShowKey(context.Background(), domain.Root()) | |
| 449 | + | require.NoError(t, err) | |
| 450 | + | require.Equal(t, first, cached) | |
| 451 | + | rotated, err := f.m.RotateKey(context.Background(), domain.Root()) | |
| 452 | + | require.NoError(t, err) | |
| 453 | + | require.Equal(t, first.Fingerprint, rotated.Previous.Fingerprint) | |
| 454 | + | require.NotEqual(t, first.Fingerprint, rotated.Key.Fingerprint) | |
| 455 | + | require.Contains(t, rotated.Key.AuthorizedKey, "ermite-mirror@forge.example.com") | |
| 456 | + | require.Len(t, f.seeds(), 2) | |
| 457 | + | require.NotEqual(t, f.seeds()[0], f.seeds()[1]) | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func TestMirrorsRotateWithoutExistingKeyGeneratesOnce(t *testing.T) { | |
| 461 | + | f := newKeyFixture(t) | |
| 462 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |
| 463 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() // never ifAbsent=true | |
| 464 | + | rotated, err := f.m.RotateKey(context.Background(), domain.Root()) | |
| 465 | + | require.NoError(t, err) | |
| 466 | + | require.Empty(t, rotated.Previous.Fingerprint) | |
| 467 | + | require.NotEmpty(t, rotated.Key.Fingerprint) | |
| 468 | + | require.Len(t, f.seeds(), 1, "no throwaway key") | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func TestMirrorsRotateReplacesUnreadableKey(t *testing.T) { | |
| 472 | + | f := newKeyFixture(t) | |
| 473 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("garbage"), now, nil).Once() | |
| 474 | + | f.sealerOpenFails = true | |
| 475 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() | |
| 476 | + | rotated, err := f.m.RotateKey(context.Background(), domain.Root()) | |
| 477 | + | require.NoError(t, err) | |
| 478 | + | require.Empty(t, rotated.Previous.Fingerprint) | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) { | |
| 482 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil }) | |
| 483 | + | _, err := f.m.ShowKey(context.Background(), domain.Anonymous()) | |
| 484 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 485 | + | _, err = f.m.ShowKey(context.Background(), domain.Root()) | |
| 486 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 487 | + | require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY") | |
| 488 | + | _, err = f.m.RotateKey(context.Background(), domain.Root()) | |
| 489 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 490 | + | _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead)) | |
| 491 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 492 | + | } | |
| @@ -0,0 +1,492 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/zerowrap" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | "bnema.dev/ermite/internal/usecase" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | var errBoom = errors.New("boom") | |||
| 24 | + | ||||
| 25 | + | func (f *mirrorFixture) pushes(t *testing.T, result error) *int { | |||
| 26 | + | t.Helper() | |||
| 27 | + | var mu sync.Mutex | |||
| 28 | + | n := new(int) | |||
| 29 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 30 | + | mu.Lock() | |||
| 31 | + | defer mu.Unlock() | |||
| 32 | + | *n++ | |||
| 33 | + | return ports.MirrorPushResult{}, result | |||
| 34 | + | }).Maybe() | |||
| 35 | + | return n | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestMirrorsNoOpThrottleForce(t *testing.T) { | |||
| 39 | + | f := newMirrorFixture(t) | |||
| 40 | + | f.setSnapshot(f.repo, domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}}) | |||
| 41 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | require.True(t, v.NoOp) | |||
| 44 | + | _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 45 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |||
| 46 | + | require.Contains(t, err.Error(), "wait 1m0s", "the interval comes from the domain constant") | |||
| 47 | + | require.Contains(t, err.Error(), "last sync 0s ago") | |||
| 48 | + | f.push.EXPECT().Push(mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool { | |||
| 49 | + | return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main" | |||
| 50 | + | })).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 51 | + | v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.False(t, v.NoOp) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestMirrorsIncidentAndRecovery(t *testing.T) { | |||
| 57 | + | f := newMirrorFixture(t) | |||
| 58 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5) | |||
| 59 | + | for i := 1; i <= 5; i++ { | |||
| 60 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 61 | + | require.Error(t, err) | |||
| 62 | + | require.Equal(t, i, v.Status.ConsecutiveFailures) | |||
| 63 | + | if i < 5 { | |||
| 64 | + | require.Equal(t, domain.MirrorFailing, v.Status.State) | |||
| 65 | + | require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt)) | |||
| 66 | + | } else { | |||
| 67 | + | require.Equal(t, domain.MirrorPaused, v.Status.State) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | events := f.eventList() | |||
| 71 | + | require.Len(t, events, 1, "mirror.failed once per incident") | |||
| 72 | + | failed, ok := events[0].(domain.MirrorFailed) | |||
| 73 | + | require.True(t, ok) | |||
| 74 | + | require.Equal(t, domain.MirrorFailing, failed.State) | |||
| 75 | + | // A paused mirror is not retried by the schedule. | |||
| 76 | + | f.advance(24 * time.Hour) | |||
| 77 | + | f.m.ScheduleNow(context.Background()) | |||
| 78 | + | f.drain(t) | |||
| 79 | + | f.push.AssertNumberOfCalls(t, "Push", 5) | |||
| 80 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 81 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | require.Equal(t, domain.MirrorOK, v.Status.State) | |||
| 84 | + | events = f.eventList() | |||
| 85 | + | require.Len(t, events, 2) | |||
| 86 | + | require.IsType(t, domain.MirrorRecovered{}, events[1]) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestMirrorsSingleFlight(t *testing.T) { | |||
| 90 | + | f := newMirrorFixture(t) | |||
| 91 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 92 | + | var mu sync.Mutex | |||
| 93 | + | calls := 0 | |||
| 94 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 95 | + | mu.Lock() | |||
| 96 | + | calls++ | |||
| 97 | + | first := calls == 1 | |||
| 98 | + | mu.Unlock() | |||
| 99 | + | if first { | |||
| 100 | + | close(entered) | |||
| 101 | + | <-release | |||
| 102 | + | } | |||
| 103 | + | return ports.MirrorPushResult{}, nil | |||
| 104 | + | }) | |||
| 105 | + | done := make(chan error, 1) | |||
| 106 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 107 | + | <-entered | |||
| 108 | + | type outcome struct { | |||
| 109 | + | res ports.MirrorSyncResult | |||
| 110 | + | err error | |||
| 111 | + | } | |||
| 112 | + | results := make(chan outcome, 20) | |||
| 113 | + | var wg sync.WaitGroup | |||
| 114 | + | for range 20 { | |||
| 115 | + | wg.Add(1) | |||
| 116 | + | go func() { | |||
| 117 | + | defer wg.Done() | |||
| 118 | + | res, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 119 | + | results <- outcome{res, err} | |||
| 120 | + | }() | |||
| 121 | + | } | |||
| 122 | + | wg.Wait() | |||
| 123 | + | close(results) | |||
| 124 | + | for o := range results { | |||
| 125 | + | require.NoError(t, o.err) | |||
| 126 | + | require.True(t, o.res.Queued) | |||
| 127 | + | require.Equal(t, f.repo, o.res.Repo) | |||
| 128 | + | } | |||
| 129 | + | close(release) | |||
| 130 | + | require.NoError(t, <-done) | |||
| 131 | + | f.drain(t) | |||
| 132 | + | mu.Lock() | |||
| 133 | + | defer mu.Unlock() | |||
| 134 | + | require.Equal(t, 2, calls, "twenty overlapping requests coalesce into one rerun") | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) { | |||
| 138 | + | f := newMirrorFixture(t) | |||
| 139 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 140 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 141 | + | close(entered) | |||
| 142 | + | <-release | |||
| 143 | + | return ports.MirrorPushResult{}, nil | |||
| 144 | + | }).Once() | |||
| 145 | + | done := make(chan error, 1) | |||
| 146 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 147 | + | <-entered | |||
| 148 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | require.True(t, v.Queued) | |||
| 151 | + | close(release) | |||
| 152 | + | require.NoError(t, <-done) | |||
| 153 | + | f.drain(t) | |||
| 154 | + | // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push | |||
| 155 | + | // (a second call would fail the Once expectation). | |||
| 156 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestMirrorsDivergedPausedAtOnce(t *testing.T) { | |||
| 160 | + | f := newMirrorFixture(t) | |||
| 161 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once() | |||
| 162 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 163 | + | require.Error(t, err) | |||
| 164 | + | require.Equal(t, domain.MirrorPaused, v.Status.State) | |||
| 165 | + | for range 3 { | |||
| 166 | + | f.advance(time.Hour) | |||
| 167 | + | f.m.ScheduleNow(context.Background()) | |||
| 168 | + | } | |||
| 169 | + | f.drain(t) | |||
| 170 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |||
| 171 | + | require.Len(t, f.eventList(), 1) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestMirrorsCloseCancelsWorker(t *testing.T) { | |||
| 175 | + | f := newMirrorFixture(t) | |||
| 176 | + | entered, exited := make(chan struct{}), make(chan struct{}) | |||
| 177 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 178 | + | close(entered) | |||
| 179 | + | <-ctx.Done() | |||
| 180 | + | close(exited) | |||
| 181 | + | return ports.MirrorPushResult{}, context.Canceled | |||
| 182 | + | }).Once() | |||
| 183 | + | done := make(chan error, 1) | |||
| 184 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 185 | + | <-entered | |||
| 186 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 187 | + | defer cancel() | |||
| 188 | + | f.m.Close(ctx) | |||
| 189 | + | require.NoError(t, ctx.Err()) | |||
| 190 | + | select { | |||
| 191 | + | case <-exited: | |||
| 192 | + | default: | |||
| 193 | + | t.Fatal("Close returned before worker exited") | |||
| 194 | + | } | |||
| 195 | + | require.ErrorIs(t, <-done, context.Canceled) | |||
| 196 | + | f.m.Close(ctx) // idempotent; Start cannot resurrect workers. | |||
| 197 | + | f.m.Start() | |||
| 198 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 199 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestMirrorsCronBackoff(t *testing.T) { | |||
| 203 | + | f := newMirrorFixture(t) | |||
| 204 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Twice() | |||
| 205 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 206 | + | require.Error(t, err) | |||
| 207 | + | f.advance(time.Minute) | |||
| 208 | + | f.m.ScheduleNow(context.Background()) | |||
| 209 | + | f.drain(t) | |||
| 210 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |||
| 211 | + | f.advance(4 * time.Minute) | |||
| 212 | + | f.m.ScheduleNow(context.Background()) | |||
| 213 | + | f.drain(t) | |||
| 214 | + | f.push.AssertNumberOfCalls(t, "Push", 2) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestMirrorsCronDueAndDirtySkip(t *testing.T) { | |||
| 218 | + | f := newMirrorFixture(t) | |||
| 219 | + | f.pushes(t, nil) | |||
| 220 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 221 | + | require.NoError(t, err) | |||
| 222 | + | require.Equal(t, 1, f.refReads()) | |||
| 223 | + | f.advance(5 * time.Minute) | |||
| 224 | + | f.m.ScheduleNow(context.Background()) // due and clean: skipped without reading refs | |||
| 225 | + | f.drain(t) | |||
| 226 | + | require.Equal(t, 1, f.refReads()) | |||
| 227 | + | // Merges and default branch changes both end in OnPush: an idempotent dirty mark. | |||
| 228 | + | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}) | |||
| 229 | + | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}}) | |||
| 230 | + | f.m.ScheduleNow(context.Background()) // same minute: nothing runs, although dirty | |||
| 231 | + | f.drain(t) | |||
| 232 | + | require.Equal(t, 1, f.refReads()) | |||
| 233 | + | f.advance(5 * time.Minute) | |||
| 234 | + | f.m.ScheduleNow(context.Background()) | |||
| 235 | + | f.drain(t) | |||
| 236 | + | require.Equal(t, 2, f.refReads(), "the dirty repository is examined on the next due tick") | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func TestMirrorsScheduleKeepsFullQueueRepositoriesForNextTicks(t *testing.T) { | |||
| 240 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 }) | |||
| 241 | + | repos := []domain.RepoName{f.repo, name(t, "b"), name(t, "c"), name(t, "d")} | |||
| 242 | + | for _, r := range repos[1:] { | |||
| 243 | + | f.addRepo(r) | |||
| 244 | + | } | |||
| 245 | + | gate := make(chan struct{}) | |||
| 246 | + | var mu sync.Mutex | |||
| 247 | + | pushed := map[string]int{} | |||
| 248 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 249 | + | <-gate | |||
| 250 | + | mu.Lock() | |||
| 251 | + | pushed[p.URL]++ | |||
| 252 | + | mu.Unlock() | |||
| 253 | + | return ports.MirrorPushResult{}, nil | |||
| 254 | + | }) | |||
| 255 | + | f.advance(5 * time.Minute) | |||
| 256 | + | f.m.ScheduleNow(context.Background()) | |||
| 257 | + | // The worker holds one repository and the queue one: the other two found it full. | |||
| 258 | + | close(gate) | |||
| 259 | + | for range 6 { // later ticks of the same minute drain the deferred set | |||
| 260 | + | f.drain(t) | |||
| 261 | + | f.m.ScheduleNow(context.Background()) | |||
| 262 | + | } | |||
| 263 | + | f.drain(t) | |||
| 264 | + | mu.Lock() | |||
| 265 | + | defer mu.Unlock() | |||
| 266 | + | for _, r := range repos { | |||
| 267 | + | require.Equal(t, 1, pushed["git@example.com:acme/"+r.String()], r.String()) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestMirrorsSyncRefusedWhenQueueFull(t *testing.T) { | |||
| 272 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 }) | |||
| 273 | + | second, third := name(t, "b"), name(t, "c") | |||
| 274 | + | f.addRepo(second) | |||
| 275 | + | f.addRepo(third) | |||
| 276 | + | gate := make(chan struct{}) | |||
| 277 | + | entered := make(chan struct{}, 8) | |||
| 278 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 279 | + | entered <- struct{}{} | |||
| 280 | + | <-gate | |||
| 281 | + | return ports.MirrorPushResult{}, nil | |||
| 282 | + | }) | |||
| 283 | + | first := make(chan error, 1) | |||
| 284 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); first <- err }() | |||
| 285 | + | <-entered // the single worker is busy | |||
| 286 | + | // Enqueueing is synchronous: this request takes the only queue slot, then waits and gives up. | |||
| 287 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 288 | + | defer cancel() | |||
| 289 | + | _, err := f.m.Sync(ctx, domain.Root(), second, true) | |||
| 290 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |||
| 291 | + | _, err = f.m.Sync(context.Background(), domain.Root(), third, true) | |||
| 292 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |||
| 293 | + | close(gate) | |||
| 294 | + | require.NoError(t, <-first) | |||
| 295 | + | f.drain(t) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestMirrorsSlowPushTimestamps(t *testing.T) { | |||
| 299 | + | f := newMirrorFixture(t) | |||
| 300 | + | start := f.at | |||
| 301 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 302 | + | f.advance(10 * time.Minute) | |||
| 303 | + | return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient} | |||
| 304 | + | }).Once() | |||
| 305 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 306 | + | require.Error(t, err) | |||
| 307 | + | require.True(t, v.Status.LastAttemptAt.Equal(start)) | |||
| 308 | + | require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1)))) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func TestMirrorsPushDoesNotHoldConfigLock(t *testing.T) { | |||
| 312 | + | f := newMirrorFixture(t) | |||
| 313 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 314 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 315 | + | close(entered) | |||
| 316 | + | <-release | |||
| 317 | + | return ports.MirrorPushResult{}, nil | |||
| 318 | + | }).Once() | |||
| 319 | + | done := make(chan error, 1) | |||
| 320 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 321 | + | <-entered | |||
| 322 | + | changed := make(chan error, 1) | |||
| 323 | + | go func() { | |||
| 324 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |||
| 325 | + | changed <- err | |||
| 326 | + | }() | |||
| 327 | + | select { | |||
| 328 | + | case err := <-changed: | |||
| 329 | + | require.NoError(t, err) | |||
| 330 | + | case <-time.After(5 * time.Second): // only on failure: the push holds the configuration lock | |||
| 331 | + | t.Fatal("configuration change blocked by a running push") | |||
| 332 | + | } | |||
| 333 | + | close(release) | |||
| 334 | + | require.NoError(t, <-done) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestMirrorsResultDiscardedWhenTargetChangedDuringPush(t *testing.T) { | |||
| 338 | + | f := newMirrorFixture(t) | |||
| 339 | + | f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, ConsecutiveFailures: 1}) | |||
| 340 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 341 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 342 | + | close(entered) | |||
| 343 | + | <-release | |||
| 344 | + | return ports.MirrorPushResult{}, nil | |||
| 345 | + | }).Once() | |||
| 346 | + | done := make(chan error, 1) | |||
| 347 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 348 | + | <-entered | |||
| 349 | + | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{URLTemplate: ptr("git@other.example:acme/{repo}")}) | |||
| 350 | + | require.NoError(t, err) | |||
| 351 | + | close(release) | |||
| 352 | + | require.NoError(t, <-done) | |||
| 353 | + | _, ok := f.snapshotOf(f.repo) | |||
| 354 | + | require.False(t, ok, "no snapshot for a target that no longer applies") | |||
| 355 | + | require.Empty(t, f.eventList(), "no mirror.recovered for the old target") | |||
| 356 | + | s := f.statusOf(f.repo) | |||
| 357 | + | require.Equal(t, domain.MirrorOK, s.State) | |||
| 358 | + | require.True(t, s.LastSuccessAt.IsZero(), "the push result was not recorded") | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | func TestMirrorsSyncAllContinuesPastFailures(t *testing.T) { | |||
| 362 | + | f := newMirrorFixture(t) | |||
| 363 | + | bad, off := name(t, "bad"), name(t, "off") | |||
| 364 | + | f.addRepo(bad) | |||
| 365 | + | f.addRepo(off) | |||
| 366 | + | f.setRepoConfig(domain.MirrorRepoConfig{Repo: off, Excluded: true}) | |||
| 367 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) { | |||
| 368 | + | if strings.HasSuffix(p.URL, "/bad") { | |||
| 369 | + | return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged, Detail: "rejected"} | |||
| 370 | + | } | |||
| 371 | + | return ports.MirrorPushResult{}, nil | |||
| 372 | + | }) | |||
| 373 | + | got, err := f.m.SyncAll(context.Background(), domain.Root(), true) | |||
| 374 | + | require.NoError(t, err) | |||
| 375 | + | require.Len(t, got, 2, "the excluded repository is skipped") | |||
| 376 | + | byRepo := map[domain.RepoName]ports.MirrorSyncResult{} | |||
| 377 | + | for _, r := range got { | |||
| 378 | + | byRepo[r.Repo] = r | |||
| 379 | + | } | |||
| 380 | + | require.NoError(t, byRepo[f.repo].Err) | |||
| 381 | + | require.Equal(t, domain.MirrorOK, byRepo[f.repo].Status.State) | |||
| 382 | + | var me *domain.MirrorError | |||
| 383 | + | require.ErrorAs(t, byRepo[bad].Err, &me) | |||
| 384 | + | require.Equal(t, domain.MirrorPaused, byRepo[bad].Status.State) | |||
| 385 | + | _, listed := byRepo[off] | |||
| 386 | + | require.False(t, listed) | |||
| 387 | + | _, err = f.m.SyncAll(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin), true) | |||
| 388 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func TestMirrorsLocalFailuresAreInternalAndNeverPush(t *testing.T) { | |||
| 392 | + | for _, tc := range []struct { | |||
| 393 | + | name string | |||
| 394 | + | set func(*mirrorFixture) | |||
| 395 | + | }{ | |||
| 396 | + | {"ListRefs", func(f *mirrorFixture) { f.mu.Lock(); f.refsErr = errBoom; f.mu.Unlock() }}, | |||
| 397 | + | {"DefaultBranch", func(f *mirrorFixture) { f.mu.Lock(); f.branchErr = errBoom; f.mu.Unlock() }}, | |||
| 398 | + | } { | |||
| 399 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 400 | + | f := newMirrorFixture(t) | |||
| 401 | + | tc.set(f) | |||
| 402 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) // a Push call would fail the strict mock | |||
| 403 | + | var me *domain.MirrorError | |||
| 404 | + | require.ErrorAs(t, err, &me) | |||
| 405 | + | require.Equal(t, domain.MirrorClassInternal, me.Class) | |||
| 406 | + | require.NotContains(t, me.Detail, "boom") | |||
| 407 | + | require.Equal(t, domain.MirrorFailing, v.Status.State) | |||
| 408 | + | }) | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func TestMirrorsMissingSecretsKeyFailsBeforeReadingRefs(t *testing.T) { | |||
| 413 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil }) | |||
| 414 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 415 | + | var me *domain.MirrorError | |||
| 416 | + | require.ErrorAs(t, err, &me) | |||
| 417 | + | require.Equal(t, domain.MirrorClassInternal, me.Class) | |||
| 418 | + | require.Contains(t, me.Detail, "ERMITE_SECRETS_KEY") | |||
| 419 | + | require.Equal(t, domain.MirrorFailing, v.Status.State) | |||
| 420 | + | require.Zero(t, f.refReads()) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) { | |||
| 424 | + | f := newMirrorFixture(t) | |||
| 425 | + | var logs bytes.Buffer | |||
| 426 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |||
| 427 | + | credentialURL := "ssh://alice:secret-password@example.com/acme/tools" | |||
| 428 | + | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL}) | |||
| 429 | + | require.Error(t, err) | |||
| 430 | + | require.NotContains(t, err.Error(), "secret-password") | |||
| 431 | + | require.NotContains(t, err.Error(), "alice") | |||
| 432 | + | f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once() | |||
| 433 | + | _, err = f.m.Sync(ctx, domain.Root(), f.repo, true) | |||
| 434 | + | require.Error(t, err) | |||
| 435 | + | require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER") | |||
| 436 | + | require.NotContains(t, err.Error(), "secret-password") | |||
| 437 | + | require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER") | |||
| 438 | + | require.NotContains(t, logs.String(), "secret-password") | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) { | |||
| 442 | + | f := newKeyFixture(t) | |||
| 443 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |||
| 444 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, true).Return(nil).Once() | |||
| 445 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() | |||
| 446 | + | first, err := f.m.ShowKey(context.Background(), domain.Root()) | |||
| 447 | + | require.NoError(t, err) | |||
| 448 | + | cached, err := f.m.ShowKey(context.Background(), domain.Root()) | |||
| 449 | + | require.NoError(t, err) | |||
| 450 | + | require.Equal(t, first, cached) | |||
| 451 | + | rotated, err := f.m.RotateKey(context.Background(), domain.Root()) | |||
| 452 | + | require.NoError(t, err) | |||
| 453 | + | require.Equal(t, first.Fingerprint, rotated.Previous.Fingerprint) | |||
| 454 | + | require.NotEqual(t, first.Fingerprint, rotated.Key.Fingerprint) | |||
| 455 | + | require.Contains(t, rotated.Key.AuthorizedKey, "ermite-mirror@forge.example.com") | |||
| 456 | + | require.Len(t, f.seeds(), 2) | |||
| 457 | + | require.NotEqual(t, f.seeds()[0], f.seeds()[1]) | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func TestMirrorsRotateWithoutExistingKeyGeneratesOnce(t *testing.T) { | |||
| 461 | + | f := newKeyFixture(t) | |||
| 462 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |||
| 463 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() // never ifAbsent=true | |||
| 464 | + | rotated, err := f.m.RotateKey(context.Background(), domain.Root()) | |||
| 465 | + | require.NoError(t, err) | |||
| 466 | + | require.Empty(t, rotated.Previous.Fingerprint) | |||
| 467 | + | require.NotEmpty(t, rotated.Key.Fingerprint) | |||
| 468 | + | require.Len(t, f.seeds(), 1, "no throwaway key") | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func TestMirrorsRotateReplacesUnreadableKey(t *testing.T) { | |||
| 472 | + | f := newKeyFixture(t) | |||
| 473 | + | f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("garbage"), now, nil).Once() | |||
| 474 | + | f.sealerOpenFails = true | |||
| 475 | + | f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() | |||
| 476 | + | rotated, err := f.m.RotateKey(context.Background(), domain.Root()) | |||
| 477 | + | require.NoError(t, err) | |||
| 478 | + | require.Empty(t, rotated.Previous.Fingerprint) | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) { | |||
| 482 | + | f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil }) | |||
| 483 | + | _, err := f.m.ShowKey(context.Background(), domain.Anonymous()) | |||
| 484 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 485 | + | _, err = f.m.ShowKey(context.Background(), domain.Root()) | |||
| 486 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 487 | + | require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY") | |||
| 488 | + | _, err = f.m.RotateKey(context.Background(), domain.Root()) | |||
| 489 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 490 | + | _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead)) | |||
| 491 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 492 | + | } | |||
D internal/usecase/mirrors_test.go +0 -596
| @@ -1,596 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | - | ||
| 3 | - | package usecase_test | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "bytes" | |
| 7 | - | "context" | |
| 8 | - | "crypto/ed25519" | |
| 9 | - | "errors" | |
| 10 | - | "fmt" | |
| 11 | - | "github.com/bnema/zerowrap" | |
| 12 | - | "sync" | |
| 13 | - | "testing" | |
| 14 | - | "time" | |
| 15 | - | ||
| 16 | - | "bnema.dev/ermite/internal/domain" | |
| 17 | - | "bnema.dev/ermite/internal/ports" | |
| 18 | - | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | - | "bnema.dev/ermite/internal/usecase" | |
| 20 | - | "github.com/stretchr/testify/mock" | |
| 21 | - | "github.com/stretchr/testify/require" | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | type mirrorFixture struct { | |
| 25 | - | m *usecase.Mirrors | |
| 26 | - | store *mocks.MirrorStore | |
| 27 | - | push *mocks.MirrorPusher | |
| 28 | - | reader *mocks.RepoReader | |
| 29 | - | bus *mocks.EventBus | |
| 30 | - | keys *mocks.MirrorKeyStore | |
| 31 | - | sealer *mocks.SecretSealer | |
| 32 | - | mu sync.Mutex | |
| 33 | - | at time.Time | |
| 34 | - | status domain.MirrorStatus | |
| 35 | - | snapshot domain.MirrorSnapshot | |
| 36 | - | events []domain.Event | |
| 37 | - | repo domain.RepoName | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | func newMirrorFixture(t *testing.T) *mirrorFixture { | |
| 41 | - | t.Helper() | |
| 42 | - | f := &mirrorFixture{store: mocks.NewMirrorStore(t), push: mocks.NewMirrorPusher(t), reader: mocks.NewRepoReader(t), bus: mocks.NewEventBus(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t), at: now, repo: name(t, "tools")} | |
| 43 | - | meta, git, clock := mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewClock(t) | |
| 44 | - | clock.On("Now").Return(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }) | |
| 45 | - | meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil).Maybe() | |
| 46 | - | meta.On("List", mock.Anything).Return([]domain.Repo{{Name: f.repo}}, nil).Maybe() | |
| 47 | - | f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil) | |
| 48 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound) | |
| 49 | - | f.store.On("GetStatus", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorStatus { | |
| 50 | - | f.mu.Lock() | |
| 51 | - | defer f.mu.Unlock() | |
| 52 | - | return f.status | |
| 53 | - | }, nil) | |
| 54 | - | f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { f.mu.Lock(); defer f.mu.Unlock(); f.status = a.Get(1).(domain.MirrorStatus) }).Return(nil).Maybe() | |
| 55 | - | f.store.On("GetSnapshot", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorSnapshot { | |
| 56 | - | f.mu.Lock() | |
| 57 | - | defer f.mu.Unlock() | |
| 58 | - | return f.snapshot | |
| 59 | - | }, nil).Maybe() | |
| 60 | - | f.store.On("PutSnapshot", mock.Anything, f.repo, mock.Anything).Run(func(a mock.Arguments) { | |
| 61 | - | f.mu.Lock() | |
| 62 | - | defer f.mu.Unlock() | |
| 63 | - | f.snapshot = a.Get(2).(domain.MirrorSnapshot) | |
| 64 | - | }).Return(nil).Maybe() | |
| 65 | - | f.reader.On("ListRefs", mock.Anything, f.repo).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Maybe() | |
| 66 | - | git.On("DefaultBranch", mock.Anything, f.repo).Return("main", true, nil).Maybe() | |
| 67 | - | f.bus.On("Publish", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { | |
| 68 | - | f.mu.Lock() | |
| 69 | - | defer f.mu.Unlock() | |
| 70 | - | f.events = append(f.events, a.Get(1).(domain.Event)) | |
| 71 | - | }).Maybe() | |
| 72 | - | f.keys.On("GetMirrorKey", mock.Anything).Return([]byte("sealed"), now, nil).Maybe() | |
| 73 | - | f.sealer.On("Open", []byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe() | |
| 74 | - | f.status = domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK} | |
| 75 | - | f.m = usecase.NewMirrors(usecase.MirrorsConfig{Store: f.store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: f.reader, Bus: f.bus, Clock: clock, ScheduleTick: time.Hour}) | |
| 76 | - | t.Cleanup(func() { | |
| 77 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 78 | - | defer cancel() | |
| 79 | - | f.m.Close(ctx) | |
| 80 | - | }) | |
| 81 | - | return f | |
| 82 | - | } | |
| 83 | - | func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() } | |
| 84 | - | func TestMirrorsNoOpThrottleForce(t *testing.T) { | |
| 85 | - | f := newMirrorFixture(t) | |
| 86 | - | f.snapshot = domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}} | |
| 87 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 88 | - | require.NoError(t, err) | |
| 89 | - | require.True(t, v.NoOp) | |
| 90 | - | _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 91 | - | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 92 | - | f.push.On("Push", mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool { | |
| 93 | - | return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main" | |
| 94 | - | })).Return(ports.MirrorPushResult{}, nil).Once() | |
| 95 | - | v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 96 | - | require.NoError(t, err) | |
| 97 | - | require.False(t, v.NoOp) | |
| 98 | - | } | |
| 99 | - | func TestMirrorsIncidentAndRecovery(t *testing.T) { | |
| 100 | - | f := newMirrorFixture(t) | |
| 101 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5) | |
| 102 | - | for i := 1; i <= 5; i++ { | |
| 103 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 104 | - | require.Error(t, err) | |
| 105 | - | require.Equal(t, i, v.Status.ConsecutiveFailures) | |
| 106 | - | if i < 5 { | |
| 107 | - | require.Equal(t, domain.MirrorFailing, v.Status.State) | |
| 108 | - | require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt)) | |
| 109 | - | } else { | |
| 110 | - | require.Equal(t, domain.MirrorPaused, v.Status.State) | |
| 111 | - | } | |
| 112 | - | } | |
| 113 | - | require.Len(t, f.events, 1) | |
| 114 | - | require.IsType(t, domain.MirrorFailed{}, f.events[0]) | |
| 115 | - | f.advance(24 * time.Hour) | |
| 116 | - | f.m.ScheduleNow(context.Background()) | |
| 117 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |
| 118 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 119 | - | require.NoError(t, err) | |
| 120 | - | require.Equal(t, domain.MirrorOK, v.Status.State) | |
| 121 | - | require.Len(t, f.events, 2) | |
| 122 | - | require.IsType(t, domain.MirrorRecovered{}, f.events[1]) | |
| 123 | - | } | |
| 124 | - | func TestMirrorsSingleFlight(t *testing.T) { | |
| 125 | - | f := newMirrorFixture(t) | |
| 126 | - | entered, release := make(chan struct{}), make(chan struct{}) | |
| 127 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once() | |
| 128 | - | done := make(chan error, 1) | |
| 129 | - | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 130 | - | <-entered | |
| 131 | - | var wg sync.WaitGroup | |
| 132 | - | for i := 0; i < 20; i++ { | |
| 133 | - | wg.Add(1) | |
| 134 | - | go func() { | |
| 135 | - | defer wg.Done() | |
| 136 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 137 | - | require.NoError(t, err) | |
| 138 | - | require.True(t, v.Queued) | |
| 139 | - | require.Equal(t, f.repo, v.Status.Repo) | |
| 140 | - | }() | |
| 141 | - | } | |
| 142 | - | wg.Wait() | |
| 143 | - | rerun := make(chan struct{}) | |
| 144 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(rerun) }).Return(ports.MirrorPushResult{}, nil).Once() | |
| 145 | - | close(release) | |
| 146 | - | require.NoError(t, <-done) | |
| 147 | - | select { | |
| 148 | - | case <-rerun: | |
| 149 | - | case <-time.After(time.Second): | |
| 150 | - | t.Fatal("coalesced force was lost") | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) { | |
| 154 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Clock: func() *mocks.Clock { c := mocks.NewClock(t); c.On("Now").Return(now); return c }()}) | |
| 155 | - | _, err := m.ShowKey(context.Background(), domain.Anonymous()) | |
| 156 | - | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 157 | - | _, err = m.ShowKey(context.Background(), domain.Root()) | |
| 158 | - | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 159 | - | require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY") | |
| 160 | - | _, err = m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead)) | |
| 161 | - | require.True(t, errors.Is(err, domain.ErrForbidden)) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | func mirrorOID(t *testing.T) domain.ObjectID { | |
| 165 | - | t.Helper() | |
| 166 | - | id, err := domain.ParseObjectID(oidA) | |
| 167 | - | require.NoError(t, err) | |
| 168 | - | return id | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) { | |
| 172 | - | f := newMirrorFixture(t) | |
| 173 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once() | |
| 174 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 175 | - | require.Error(t, err) | |
| 176 | - | require.Equal(t, domain.MirrorPaused, v.Status.State) | |
| 177 | - | for i := 0; i < 3; i++ { | |
| 178 | - | f.advance(time.Hour) | |
| 179 | - | f.m.ScheduleNow(context.Background()) | |
| 180 | - | } | |
| 181 | - | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |
| 182 | - | _, err = f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools", Cron: "*/5 * * * *"}) | |
| 183 | - | require.NoError(t, err) | |
| 184 | - | f.mu.Lock() | |
| 185 | - | s := f.status | |
| 186 | - | events := len(f.events) | |
| 187 | - | f.mu.Unlock() | |
| 188 | - | require.Equal(t, domain.MirrorOK, s.State) | |
| 189 | - | require.Zero(t, s.ConsecutiveFailures) | |
| 190 | - | require.Equal(t, 1, events) | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) { | |
| 194 | - | keys, sealer, clock := mocks.NewMirrorKeyStore(t), mocks.NewSecretSealer(t), mocks.NewClock(t) | |
| 195 | - | clock.On("Now").Return(now) | |
| 196 | - | keys.On("GetMirrorKey", mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |
| 197 | - | var seeds [][]byte | |
| 198 | - | sealer.On("Seal", mock.Anything, []byte(domain.MirrorKeyPath)).Return(func(seed, aad []byte) []byte { | |
| 199 | - | seeds = append(seeds, append([]byte(nil), seed...)) | |
| 200 | - | return append([]byte("sealed:"), seed...) | |
| 201 | - | }).Twice() | |
| 202 | - | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, true).Return(nil).Once() | |
| 203 | - | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, false).Return(nil).Once() | |
| 204 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Keys: keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"}) | |
| 205 | - | first, err := m.ShowKey(context.Background(), domain.Root()) | |
| 206 | - | require.NoError(t, err) | |
| 207 | - | cached, err := m.ShowKey(context.Background(), domain.Root()) | |
| 208 | - | require.NoError(t, err) | |
| 209 | - | require.Equal(t, first, cached) | |
| 210 | - | rotated, err := m.RotateKey(context.Background(), domain.Root()) | |
| 211 | - | require.NoError(t, err) | |
| 212 | - | require.NotEqual(t, first.Fingerprint, rotated.Fingerprint) | |
| 213 | - | require.Contains(t, rotated.AuthorizedKey, "ermite-mirror@forge.example.com") | |
| 214 | - | require.Len(t, seeds, 2) | |
| 215 | - | require.NotEqual(t, seeds[0], seeds[1]) | |
| 216 | - | require.Len(t, seeds[0], ed25519.SeedSize) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | func TestMirrorsPushRecordNotification(t *testing.T) { | |
| 220 | - | f := newPushFixture(t) | |
| 221 | - | n := name(t, "tools") | |
| 222 | - | trigger := mocks.NewMirrorTrigger(t) | |
| 223 | - | updates := []domain.RefUpdate{} | |
| 224 | - | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |
| 225 | - | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |
| 226 | - | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |
| 227 | - | f.sched.On("Schedule", mock.Anything, n).Return() | |
| 228 | - | notified := make(chan struct{}) | |
| 229 | - | trigger.On("OnPush", mock.Anything, n, updates).Run(func(mock.Arguments) { close(notified) }).Return().Once() | |
| 230 | - | require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates)) | |
| 231 | - | <-notified | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func TestMirrorsCronDueAndDirtySkip(t *testing.T) { | |
| 235 | - | f := newMirrorFixture(t) | |
| 236 | - | // Establish a successful snapshot; subsequent clean cron ticks need no refs. | |
| 237 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |
| 238 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 239 | - | require.NoError(t, err) | |
| 240 | - | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |
| 241 | - | f.mu.Lock() | |
| 242 | - | f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute) | |
| 243 | - | f.mu.Unlock() | |
| 244 | - | f.m.ScheduleNow(context.Background()) | |
| 245 | - | f.advance(4 * time.Minute) | |
| 246 | - | f.m.ScheduleNow(context.Background()) | |
| 247 | - | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |
| 248 | - | f.m.OnPush(context.Background(), f.repo, nil) | |
| 249 | - | read := make(chan struct{}) | |
| 250 | - | f.reader.ExpectedCalls = nil | |
| 251 | - | f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once() | |
| 252 | - | // Same minute does not run, even though dirty. | |
| 253 | - | f.m.ScheduleNow(context.Background()) | |
| 254 | - | select { | |
| 255 | - | case <-read: | |
| 256 | - | t.Fatal("same-minute cron reran") | |
| 257 | - | default: | |
| 258 | - | } | |
| 259 | - | f.advance(5 * time.Minute) | |
| 260 | - | f.m.ScheduleNow(context.Background()) | |
| 261 | - | select { | |
| 262 | - | case <-read: | |
| 263 | - | case <-time.After(time.Second): | |
| 264 | - | t.Fatal("due dirty repository not examined") | |
| 265 | - | } | |
| 266 | - | } | |
| 267 | - | ||
| 268 | - | func TestMirrorsAuthorizationMatrix(t *testing.T) { | |
| 269 | - | f := newMirrorFixture(t) | |
| 270 | - | ctx := context.Background() | |
| 271 | - | reader := tokenPrincipal(domain.ScopeRepoRead) | |
| 272 | - | admin := tokenPrincipal(domain.ScopeRepoAdmin) | |
| 273 | - | _, err := f.m.GetGlobalConfig(ctx, reader) | |
| 274 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 275 | - | _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{}) | |
| 276 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 277 | - | _, err = f.m.ListStatus(ctx, reader) | |
| 278 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 279 | - | _, err = f.m.RotateKey(ctx, admin) | |
| 280 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 281 | - | // Key show deliberately requires authentication, not owner rights. | |
| 282 | - | _, err = f.m.ShowKey(ctx, reader) | |
| 283 | - | require.NoError(t, err) | |
| 284 | - | _, err = f.m.ShowKey(ctx, domain.Anonymous()) | |
| 285 | - | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 286 | - | _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo}) | |
| 287 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 288 | - | err = f.m.RemoveRepoMirror(ctx, reader, f.repo) | |
| 289 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 290 | - | _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true) | |
| 291 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 292 | - | _, err = f.m.Sync(ctx, reader, f.repo, false) | |
| 293 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 294 | - | f.mu.Lock() | |
| 295 | - | f.status.Detail, f.status.Reason = "admin detail", "admin reason" | |
| 296 | - | f.mu.Unlock() | |
| 297 | - | view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo) | |
| 298 | - | require.NoError(t, err) | |
| 299 | - | require.Empty(t, view.Status.Detail) | |
| 300 | - | require.Empty(t, view.Status.Reason) | |
| 301 | - | require.Empty(t, view.Target.URL) | |
| 302 | - | view, err = f.m.GetStatus(ctx, admin, f.repo) | |
| 303 | - | require.NoError(t, err) | |
| 304 | - | require.Equal(t, "admin detail", view.Status.Detail) | |
| 305 | - | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |
| 306 | - | _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo}) | |
| 307 | - | require.NoError(t, err) | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | func TestMirrorsPrivateStatusAuthorization(t *testing.T) { | |
| 311 | - | f := newMirrorFixture(t) | |
| 312 | - | meta := mocks.NewRepoMetaStore(t) | |
| 313 | - | meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPrivate}, nil) | |
| 314 | - | clock := mocks.NewClock(t) | |
| 315 | - | clock.On("Now").Return(now) | |
| 316 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Meta: meta, Store: f.store, Clock: clock}) | |
| 317 | - | _, err := m.GetStatus(context.Background(), domain.Anonymous(), f.repo) | |
| 318 | - | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 319 | - | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo) | |
| 320 | - | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 321 | - | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo) | |
| 322 | - | require.NoError(t, err) | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | func TestMirrorsCloseCancelsWorker(t *testing.T) { | |
| 326 | - | f := newMirrorFixture(t) | |
| 327 | - | entered, exited := make(chan struct{}), make(chan struct{}) | |
| 328 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { close(entered); <-a.Get(0).(context.Context).Done(); close(exited) }).Return(ports.MirrorPushResult{}, context.Canceled).Once() | |
| 329 | - | done := make(chan error, 1) | |
| 330 | - | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 331 | - | <-entered | |
| 332 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 333 | - | defer cancel() | |
| 334 | - | f.m.Close(ctx) | |
| 335 | - | require.NoError(t, ctx.Err()) | |
| 336 | - | select { | |
| 337 | - | case <-exited: | |
| 338 | - | default: | |
| 339 | - | t.Fatal("Close returned before worker exited") | |
| 340 | - | } | |
| 341 | - | require.ErrorIs(t, <-done, context.Canceled) | |
| 342 | - | f.m.Close(ctx) // idempotent; Start cannot resurrect workers. | |
| 343 | - | f.m.Start() | |
| 344 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 345 | - | require.ErrorIs(t, err, context.Canceled) | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | func TestMirrorsCronBackoff(t *testing.T) { | |
| 349 | - | f := newMirrorFixture(t) | |
| 350 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |
| 351 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 352 | - | require.Error(t, err) | |
| 353 | - | f.advance(time.Minute) | |
| 354 | - | f.m.ScheduleNow(context.Background()) | |
| 355 | - | f.push.AssertNumberOfCalls(t, "Push", 1) | |
| 356 | - | attempted := make(chan struct{}) | |
| 357 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(attempted) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |
| 358 | - | f.advance(4 * time.Minute) | |
| 359 | - | f.m.ScheduleNow(context.Background()) | |
| 360 | - | select { | |
| 361 | - | case <-attempted: | |
| 362 | - | case <-time.After(time.Second): | |
| 363 | - | t.Fatal("retry did not fire at backoff deadline") | |
| 364 | - | } | |
| 365 | - | // Cleanup joins the worker before mock verification. | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | func TestMirrorsRecordDoesNotWaitForTrigger(t *testing.T) { | |
| 369 | - | f := newPushFixture(t) | |
| 370 | - | n := name(t, "tools") | |
| 371 | - | trigger := mocks.NewMirrorTrigger(t) | |
| 372 | - | entered, release, exited := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |
| 373 | - | trigger.On("OnPush", mock.Anything, n, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release; close(exited) }).Return().Once() | |
| 374 | - | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |
| 375 | - | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |
| 376 | - | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |
| 377 | - | f.sched.On("Schedule", mock.Anything, n).Return() | |
| 378 | - | returned := make(chan error, 1) | |
| 379 | - | go func() { returned <- f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, nil) }() | |
| 380 | - | <-entered | |
| 381 | - | select { | |
| 382 | - | case err := <-returned: | |
| 383 | - | require.NoError(t, err) | |
| 384 | - | case <-time.After(time.Second): | |
| 385 | - | close(release) | |
| 386 | - | <-exited | |
| 387 | - | t.Fatal("Record blocked on mirror trigger") | |
| 388 | - | } | |
| 389 | - | close(release) | |
| 390 | - | <-exited | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) { | |
| 394 | - | f := newMirrorFixture(t) | |
| 395 | - | var logs bytes.Buffer | |
| 396 | - | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |
| 397 | - | credentialURL := "ssh://alice:secret-password@example.com/acme/tools" | |
| 398 | - | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL}) | |
| 399 | - | require.Error(t, err) | |
| 400 | - | require.NotContains(t, err.Error(), "secret-password") | |
| 401 | - | require.NotContains(t, err.Error(), "alice") | |
| 402 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once() | |
| 403 | - | _, err = f.m.Sync(ctx, domain.Root(), f.repo, true) | |
| 404 | - | require.Error(t, err) | |
| 405 | - | require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER") | |
| 406 | - | require.NotContains(t, err.Error(), "secret-password") | |
| 407 | - | require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER") | |
| 408 | - | require.NotContains(t, logs.String(), "secret-password") | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | func TestMirrorsFileURLTestOption(t *testing.T) { | |
| 412 | - | for _, allowed := range []bool{false, true} { | |
| 413 | - | t.Run(fmt.Sprint(allowed), func(t *testing.T) { | |
| 414 | - | store, meta, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewClock(t) | |
| 415 | - | repo := name(t, "tools") | |
| 416 | - | clock.On("Now").Return(now) | |
| 417 | - | meta.On("Get", mock.Anything, repo).Return(domain.Repo{Name: repo}, nil) | |
| 418 | - | global := domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "file:///tmp/remotes/{repo}", Cron: "*/5 * * * *"} | |
| 419 | - | store.On("GetGlobal", mock.Anything).Return(global, nil).Maybe() | |
| 420 | - | store.On("GetRepoConfig", mock.Anything, repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe() | |
| 421 | - | store.On("GetStatus", mock.Anything, repo).Return(domain.MirrorStatus{Repo: repo}, nil).Maybe() | |
| 422 | - | store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 423 | - | store.On("PutStatus", mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 424 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Store: store, Meta: meta, Clock: clock, AllowAnyURL: allowed}) | |
| 425 | - | target, err := m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"}) | |
| 426 | - | if allowed { | |
| 427 | - | require.NoError(t, err) | |
| 428 | - | require.Equal(t, "file:///tmp/remotes/tools", target.URL) | |
| 429 | - | } else { | |
| 430 | - | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 431 | - | } | |
| 432 | - | }) | |
| 433 | - | } | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | func TestMirrorsMergeNotifiesMirror(t *testing.T) { | |
| 437 | - | r := newMRRig(t) | |
| 438 | - | r.seed() | |
| 439 | - | trigger := mocks.NewMirrorTrigger(t) | |
| 440 | - | notified := make(chan []domain.RefUpdate, 1) | |
| 441 | - | trigger.On("OnPush", mock.Anything, r.repo, mock.Anything).Run(func(a mock.Arguments) { notified <- a.Get(2).([]domain.RefUpdate) }).Return().Once() | |
| 442 | - | r.mrs.WithMirror(trigger) | |
| 443 | - | head := r.feature("f0") | |
| 444 | - | mr := r.open("f0") | |
| 445 | - | _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 446 | - | require.NoError(t, err) | |
| 447 | - | r.mrs.Wait(r.ctx) | |
| 448 | - | select { | |
| 449 | - | case u := <-notified: | |
| 450 | - | require.Equal(t, "refs/heads/main", u[0].Ref) | |
| 451 | - | case <-time.After(time.Second): | |
| 452 | - | t.Fatal("merge did not notify mirrors") | |
| 453 | - | } | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) { | |
| 457 | - | f := newFixture(t) | |
| 458 | - | ctx := context.Background() | |
| 459 | - | n := name(t, "tools") | |
| 460 | - | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate} | |
| 461 | - | trigger := mocks.NewMirrorTrigger(t) | |
| 462 | - | trigger.On("OnPush", mock.Anything, n, mock.Anything).Return().Once() | |
| 463 | - | f.repos.WithMirror(trigger) | |
| 464 | - | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |
| 465 | - | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |
| 466 | - | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil) | |
| 467 | - | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 468 | - | _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")}) | |
| 469 | - | require.NoError(t, err) | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) { | |
| 473 | - | f := newMirrorFixture(t) | |
| 474 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |
| 475 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 476 | - | require.NoError(t, err) | |
| 477 | - | f.mu.Lock() | |
| 478 | - | f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute) | |
| 479 | - | f.mu.Unlock() | |
| 480 | - | f.m.ScheduleNow(context.Background()) | |
| 481 | - | f.advance(4 * time.Minute) | |
| 482 | - | f.m.ScheduleNow(context.Background()) // due and clean: skipped | |
| 483 | - | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |
| 484 | - | // A merge and a default-branch change both call OnPush (idempotent dirty mark). | |
| 485 | - | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}) | |
| 486 | - | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}}) | |
| 487 | - | read := make(chan struct{}) | |
| 488 | - | f.reader.ExpectedCalls = nil | |
| 489 | - | f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once() | |
| 490 | - | f.advance(5 * time.Minute) | |
| 491 | - | f.m.ScheduleNow(context.Background()) | |
| 492 | - | select { | |
| 493 | - | case <-read: | |
| 494 | - | case <-time.After(time.Second): | |
| 495 | - | t.Fatal("dirty repository not synced on the next due tick") | |
| 496 | - | } | |
| 497 | - | } | |
| 498 | - | ||
| 499 | - | func setupRepoOverrideFixture(t *testing.T, global domain.MirrorGlobalConfig) *mirrorFixture { | |
| 500 | - | f := newMirrorFixture(t) | |
| 501 | - | f.store.ExpectedCalls = nil | |
| 502 | - | f.store.On("GetGlobal", mock.Anything).Return(global, nil) | |
| 503 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe() | |
| 504 | - | f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo}, nil).Maybe() | |
| 505 | - | return f | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) { | |
| 509 | - | f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{}) | |
| 510 | - | _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |
| 511 | - | var fe *domain.InvalidFieldsError | |
| 512 | - | require.ErrorAs(t, err, &fe) | |
| 513 | - | require.Equal(t, "cron", fe.Fields[0].Field) | |
| 514 | - | // PutRepoConfig/PutStatus unexpected: strict mock fails if called. | |
| 515 | - | } | |
| 516 | - | ||
| 517 | - | func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) { | |
| 518 | - | f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}) | |
| 519 | - | f.store.ExpectedCalls = nil | |
| 520 | - | f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil) | |
| 521 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil) | |
| 522 | - | // Disabling the global with an empty cron leaves the inheriting override with no cron. | |
| 523 | - | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")}) | |
| 524 | - | var fe *domain.InvalidFieldsError | |
| 525 | - | require.ErrorAs(t, err, &fe) | |
| 526 | - | } | |
| 527 | - | ||
| 528 | - | func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) { | |
| 529 | - | f := newMirrorFixture(t) | |
| 530 | - | f.store.ExpectedCalls = nil | |
| 531 | - | f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil) | |
| 532 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil) | |
| 533 | - | f.store.On("PutGlobal", mock.Anything, mock.Anything).Return(nil).Once() | |
| 534 | - | f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}, nil) | |
| 535 | - | reset := make(chan domain.MirrorStatus, 1) | |
| 536 | - | f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { reset <- a.Get(1).(domain.MirrorStatus) }).Return(nil) | |
| 537 | - | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |
| 538 | - | require.NoError(t, err) | |
| 539 | - | require.Equal(t, domain.MirrorOK, (<-reset).State) | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | func TestMirrorsSetRepoExcludedConcurrent(t *testing.T) { | |
| 543 | - | f := newMirrorFixture(t) | |
| 544 | - | var mu sync.Mutex | |
| 545 | - | active, maxActive := 0, 0 | |
| 546 | - | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Run(func(mock.Arguments) { | |
| 547 | - | mu.Lock() | |
| 548 | - | active++ | |
| 549 | - | if active > maxActive { | |
| 550 | - | maxActive = active | |
| 551 | - | } | |
| 552 | - | mu.Unlock() | |
| 553 | - | time.Sleep(2 * time.Millisecond) | |
| 554 | - | mu.Lock() | |
| 555 | - | active-- | |
| 556 | - | mu.Unlock() | |
| 557 | - | }).Return(nil) | |
| 558 | - | var wg sync.WaitGroup | |
| 559 | - | for i := 0; i < 8; i++ { | |
| 560 | - | wg.Add(1) | |
| 561 | - | go func(ex bool) { | |
| 562 | - | defer wg.Done() | |
| 563 | - | _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, ex) | |
| 564 | - | require.NoError(t, err) | |
| 565 | - | }(i%2 == 0) | |
| 566 | - | } | |
| 567 | - | wg.Wait() | |
| 568 | - | require.Equal(t, 1, maxActive, "read-modify-write must be serialized") | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) { | |
| 572 | - | f := newMirrorFixture(t) | |
| 573 | - | entered, release := make(chan struct{}), make(chan struct{}) | |
| 574 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once() | |
| 575 | - | done := make(chan error, 1) | |
| 576 | - | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 577 | - | <-entered | |
| 578 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 579 | - | require.NoError(t, err) | |
| 580 | - | require.True(t, v.Queued) | |
| 581 | - | close(release) | |
| 582 | - | require.NoError(t, <-done) | |
| 583 | - | // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push. | |
| 584 | - | time.Sleep(200 * time.Millisecond) | |
| 585 | - | f.push.AssertNumberOfCalls(t, "Push", 1) | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | func TestMirrorsSlowPushTimestamps(t *testing.T) { | |
| 589 | - | f := newMirrorFixture(t) | |
| 590 | - | start := f.at | |
| 591 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { f.advance(10 * time.Minute) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |
| 592 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 593 | - | require.Error(t, err) | |
| 594 | - | require.True(t, v.Status.LastAttemptAt.Equal(start)) | |
| 595 | - | require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1)))) | |
| 596 | - | } | |
| @@ -1,596 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package usecase_test | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "bytes" | |||
| 7 | - | "context" | |||
| 8 | - | "crypto/ed25519" | |||
| 9 | - | "errors" | |||
| 10 | - | "fmt" | |||
| 11 | - | "github.com/bnema/zerowrap" | |||
| 12 | - | "sync" | |||
| 13 | - | "testing" | |||
| 14 | - | "time" | |||
| 15 | - | ||||
| 16 | - | "bnema.dev/ermite/internal/domain" | |||
| 17 | - | "bnema.dev/ermite/internal/ports" | |||
| 18 | - | "bnema.dev/ermite/internal/ports/mocks" | |||
| 19 | - | "bnema.dev/ermite/internal/usecase" | |||
| 20 | - | "github.com/stretchr/testify/mock" | |||
| 21 | - | "github.com/stretchr/testify/require" | |||
| 22 | - | ) | |||
| 23 | - | ||||
| 24 | - | type mirrorFixture struct { | |||
| 25 | - | m *usecase.Mirrors | |||
| 26 | - | store *mocks.MirrorStore | |||
| 27 | - | push *mocks.MirrorPusher | |||
| 28 | - | reader *mocks.RepoReader | |||
| 29 | - | bus *mocks.EventBus | |||
| 30 | - | keys *mocks.MirrorKeyStore | |||
| 31 | - | sealer *mocks.SecretSealer | |||
| 32 | - | mu sync.Mutex | |||
| 33 | - | at time.Time | |||
| 34 | - | status domain.MirrorStatus | |||
| 35 | - | snapshot domain.MirrorSnapshot | |||
| 36 | - | events []domain.Event | |||
| 37 | - | repo domain.RepoName | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func newMirrorFixture(t *testing.T) *mirrorFixture { | |||
| 41 | - | t.Helper() | |||
| 42 | - | f := &mirrorFixture{store: mocks.NewMirrorStore(t), push: mocks.NewMirrorPusher(t), reader: mocks.NewRepoReader(t), bus: mocks.NewEventBus(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t), at: now, repo: name(t, "tools")} | |||
| 43 | - | meta, git, clock := mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewClock(t) | |||
| 44 | - | clock.On("Now").Return(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }) | |||
| 45 | - | meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil).Maybe() | |||
| 46 | - | meta.On("List", mock.Anything).Return([]domain.Repo{{Name: f.repo}}, nil).Maybe() | |||
| 47 | - | f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil) | |||
| 48 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound) | |||
| 49 | - | f.store.On("GetStatus", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorStatus { | |||
| 50 | - | f.mu.Lock() | |||
| 51 | - | defer f.mu.Unlock() | |||
| 52 | - | return f.status | |||
| 53 | - | }, nil) | |||
| 54 | - | f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { f.mu.Lock(); defer f.mu.Unlock(); f.status = a.Get(1).(domain.MirrorStatus) }).Return(nil).Maybe() | |||
| 55 | - | f.store.On("GetSnapshot", mock.Anything, f.repo).Return(func(context.Context, domain.RepoName) domain.MirrorSnapshot { | |||
| 56 | - | f.mu.Lock() | |||
| 57 | - | defer f.mu.Unlock() | |||
| 58 | - | return f.snapshot | |||
| 59 | - | }, nil).Maybe() | |||
| 60 | - | f.store.On("PutSnapshot", mock.Anything, f.repo, mock.Anything).Run(func(a mock.Arguments) { | |||
| 61 | - | f.mu.Lock() | |||
| 62 | - | defer f.mu.Unlock() | |||
| 63 | - | f.snapshot = a.Get(2).(domain.MirrorSnapshot) | |||
| 64 | - | }).Return(nil).Maybe() | |||
| 65 | - | f.reader.On("ListRefs", mock.Anything, f.repo).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Maybe() | |||
| 66 | - | git.On("DefaultBranch", mock.Anything, f.repo).Return("main", true, nil).Maybe() | |||
| 67 | - | f.bus.On("Publish", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { | |||
| 68 | - | f.mu.Lock() | |||
| 69 | - | defer f.mu.Unlock() | |||
| 70 | - | f.events = append(f.events, a.Get(1).(domain.Event)) | |||
| 71 | - | }).Maybe() | |||
| 72 | - | f.keys.On("GetMirrorKey", mock.Anything).Return([]byte("sealed"), now, nil).Maybe() | |||
| 73 | - | f.sealer.On("Open", []byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe() | |||
| 74 | - | f.status = domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK} | |||
| 75 | - | f.m = usecase.NewMirrors(usecase.MirrorsConfig{Store: f.store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: f.reader, Bus: f.bus, Clock: clock, ScheduleTick: time.Hour}) | |||
| 76 | - | t.Cleanup(func() { | |||
| 77 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 78 | - | defer cancel() | |||
| 79 | - | f.m.Close(ctx) | |||
| 80 | - | }) | |||
| 81 | - | return f | |||
| 82 | - | } | |||
| 83 | - | func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() } | |||
| 84 | - | func TestMirrorsNoOpThrottleForce(t *testing.T) { | |||
| 85 | - | f := newMirrorFixture(t) | |||
| 86 | - | f.snapshot = domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}} | |||
| 87 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 88 | - | require.NoError(t, err) | |||
| 89 | - | require.True(t, v.NoOp) | |||
| 90 | - | _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 91 | - | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |||
| 92 | - | f.push.On("Push", mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool { | |||
| 93 | - | return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main" | |||
| 94 | - | })).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 95 | - | v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 96 | - | require.NoError(t, err) | |||
| 97 | - | require.False(t, v.NoOp) | |||
| 98 | - | } | |||
| 99 | - | func TestMirrorsIncidentAndRecovery(t *testing.T) { | |||
| 100 | - | f := newMirrorFixture(t) | |||
| 101 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5) | |||
| 102 | - | for i := 1; i <= 5; i++ { | |||
| 103 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 104 | - | require.Error(t, err) | |||
| 105 | - | require.Equal(t, i, v.Status.ConsecutiveFailures) | |||
| 106 | - | if i < 5 { | |||
| 107 | - | require.Equal(t, domain.MirrorFailing, v.Status.State) | |||
| 108 | - | require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt)) | |||
| 109 | - | } else { | |||
| 110 | - | require.Equal(t, domain.MirrorPaused, v.Status.State) | |||
| 111 | - | } | |||
| 112 | - | } | |||
| 113 | - | require.Len(t, f.events, 1) | |||
| 114 | - | require.IsType(t, domain.MirrorFailed{}, f.events[0]) | |||
| 115 | - | f.advance(24 * time.Hour) | |||
| 116 | - | f.m.ScheduleNow(context.Background()) | |||
| 117 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 118 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 119 | - | require.NoError(t, err) | |||
| 120 | - | require.Equal(t, domain.MirrorOK, v.Status.State) | |||
| 121 | - | require.Len(t, f.events, 2) | |||
| 122 | - | require.IsType(t, domain.MirrorRecovered{}, f.events[1]) | |||
| 123 | - | } | |||
| 124 | - | func TestMirrorsSingleFlight(t *testing.T) { | |||
| 125 | - | f := newMirrorFixture(t) | |||
| 126 | - | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 127 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 128 | - | done := make(chan error, 1) | |||
| 129 | - | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 130 | - | <-entered | |||
| 131 | - | var wg sync.WaitGroup | |||
| 132 | - | for i := 0; i < 20; i++ { | |||
| 133 | - | wg.Add(1) | |||
| 134 | - | go func() { | |||
| 135 | - | defer wg.Done() | |||
| 136 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 137 | - | require.NoError(t, err) | |||
| 138 | - | require.True(t, v.Queued) | |||
| 139 | - | require.Equal(t, f.repo, v.Status.Repo) | |||
| 140 | - | }() | |||
| 141 | - | } | |||
| 142 | - | wg.Wait() | |||
| 143 | - | rerun := make(chan struct{}) | |||
| 144 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(rerun) }).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 145 | - | close(release) | |||
| 146 | - | require.NoError(t, <-done) | |||
| 147 | - | select { | |||
| 148 | - | case <-rerun: | |||
| 149 | - | case <-time.After(time.Second): | |||
| 150 | - | t.Fatal("coalesced force was lost") | |||
| 151 | - | } | |||
| 152 | - | } | |||
| 153 | - | func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) { | |||
| 154 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Clock: func() *mocks.Clock { c := mocks.NewClock(t); c.On("Now").Return(now); return c }()}) | |||
| 155 | - | _, err := m.ShowKey(context.Background(), domain.Anonymous()) | |||
| 156 | - | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 157 | - | _, err = m.ShowKey(context.Background(), domain.Root()) | |||
| 158 | - | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 159 | - | require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY") | |||
| 160 | - | _, err = m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead)) | |||
| 161 | - | require.True(t, errors.Is(err, domain.ErrForbidden)) | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | func mirrorOID(t *testing.T) domain.ObjectID { | |||
| 165 | - | t.Helper() | |||
| 166 | - | id, err := domain.ParseObjectID(oidA) | |||
| 167 | - | require.NoError(t, err) | |||
| 168 | - | return id | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) { | |||
| 172 | - | f := newMirrorFixture(t) | |||
| 173 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once() | |||
| 174 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 175 | - | require.Error(t, err) | |||
| 176 | - | require.Equal(t, domain.MirrorPaused, v.Status.State) | |||
| 177 | - | for i := 0; i < 3; i++ { | |||
| 178 | - | f.advance(time.Hour) | |||
| 179 | - | f.m.ScheduleNow(context.Background()) | |||
| 180 | - | } | |||
| 181 | - | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |||
| 182 | - | _, err = f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools", Cron: "*/5 * * * *"}) | |||
| 183 | - | require.NoError(t, err) | |||
| 184 | - | f.mu.Lock() | |||
| 185 | - | s := f.status | |||
| 186 | - | events := len(f.events) | |||
| 187 | - | f.mu.Unlock() | |||
| 188 | - | require.Equal(t, domain.MirrorOK, s.State) | |||
| 189 | - | require.Zero(t, s.ConsecutiveFailures) | |||
| 190 | - | require.Equal(t, 1, events) | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) { | |||
| 194 | - | keys, sealer, clock := mocks.NewMirrorKeyStore(t), mocks.NewSecretSealer(t), mocks.NewClock(t) | |||
| 195 | - | clock.On("Now").Return(now) | |||
| 196 | - | keys.On("GetMirrorKey", mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |||
| 197 | - | var seeds [][]byte | |||
| 198 | - | sealer.On("Seal", mock.Anything, []byte(domain.MirrorKeyPath)).Return(func(seed, aad []byte) []byte { | |||
| 199 | - | seeds = append(seeds, append([]byte(nil), seed...)) | |||
| 200 | - | return append([]byte("sealed:"), seed...) | |||
| 201 | - | }).Twice() | |||
| 202 | - | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, true).Return(nil).Once() | |||
| 203 | - | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, false).Return(nil).Once() | |||
| 204 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Keys: keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"}) | |||
| 205 | - | first, err := m.ShowKey(context.Background(), domain.Root()) | |||
| 206 | - | require.NoError(t, err) | |||
| 207 | - | cached, err := m.ShowKey(context.Background(), domain.Root()) | |||
| 208 | - | require.NoError(t, err) | |||
| 209 | - | require.Equal(t, first, cached) | |||
| 210 | - | rotated, err := m.RotateKey(context.Background(), domain.Root()) | |||
| 211 | - | require.NoError(t, err) | |||
| 212 | - | require.NotEqual(t, first.Fingerprint, rotated.Fingerprint) | |||
| 213 | - | require.Contains(t, rotated.AuthorizedKey, "ermite-mirror@forge.example.com") | |||
| 214 | - | require.Len(t, seeds, 2) | |||
| 215 | - | require.NotEqual(t, seeds[0], seeds[1]) | |||
| 216 | - | require.Len(t, seeds[0], ed25519.SeedSize) | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | func TestMirrorsPushRecordNotification(t *testing.T) { | |||
| 220 | - | f := newPushFixture(t) | |||
| 221 | - | n := name(t, "tools") | |||
| 222 | - | trigger := mocks.NewMirrorTrigger(t) | |||
| 223 | - | updates := []domain.RefUpdate{} | |||
| 224 | - | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |||
| 225 | - | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |||
| 226 | - | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |||
| 227 | - | f.sched.On("Schedule", mock.Anything, n).Return() | |||
| 228 | - | notified := make(chan struct{}) | |||
| 229 | - | trigger.On("OnPush", mock.Anything, n, updates).Run(func(mock.Arguments) { close(notified) }).Return().Once() | |||
| 230 | - | require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates)) | |||
| 231 | - | <-notified | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | func TestMirrorsCronDueAndDirtySkip(t *testing.T) { | |||
| 235 | - | f := newMirrorFixture(t) | |||
| 236 | - | // Establish a successful snapshot; subsequent clean cron ticks need no refs. | |||
| 237 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 238 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 239 | - | require.NoError(t, err) | |||
| 240 | - | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |||
| 241 | - | f.mu.Lock() | |||
| 242 | - | f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute) | |||
| 243 | - | f.mu.Unlock() | |||
| 244 | - | f.m.ScheduleNow(context.Background()) | |||
| 245 | - | f.advance(4 * time.Minute) | |||
| 246 | - | f.m.ScheduleNow(context.Background()) | |||
| 247 | - | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |||
| 248 | - | f.m.OnPush(context.Background(), f.repo, nil) | |||
| 249 | - | read := make(chan struct{}) | |||
| 250 | - | f.reader.ExpectedCalls = nil | |||
| 251 | - | f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once() | |||
| 252 | - | // Same minute does not run, even though dirty. | |||
| 253 | - | f.m.ScheduleNow(context.Background()) | |||
| 254 | - | select { | |||
| 255 | - | case <-read: | |||
| 256 | - | t.Fatal("same-minute cron reran") | |||
| 257 | - | default: | |||
| 258 | - | } | |||
| 259 | - | f.advance(5 * time.Minute) | |||
| 260 | - | f.m.ScheduleNow(context.Background()) | |||
| 261 | - | select { | |||
| 262 | - | case <-read: | |||
| 263 | - | case <-time.After(time.Second): | |||
| 264 | - | t.Fatal("due dirty repository not examined") | |||
| 265 | - | } | |||
| 266 | - | } | |||
| 267 | - | ||||
| 268 | - | func TestMirrorsAuthorizationMatrix(t *testing.T) { | |||
| 269 | - | f := newMirrorFixture(t) | |||
| 270 | - | ctx := context.Background() | |||
| 271 | - | reader := tokenPrincipal(domain.ScopeRepoRead) | |||
| 272 | - | admin := tokenPrincipal(domain.ScopeRepoAdmin) | |||
| 273 | - | _, err := f.m.GetGlobalConfig(ctx, reader) | |||
| 274 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 275 | - | _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{}) | |||
| 276 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 277 | - | _, err = f.m.ListStatus(ctx, reader) | |||
| 278 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 279 | - | _, err = f.m.RotateKey(ctx, admin) | |||
| 280 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 281 | - | // Key show deliberately requires authentication, not owner rights. | |||
| 282 | - | _, err = f.m.ShowKey(ctx, reader) | |||
| 283 | - | require.NoError(t, err) | |||
| 284 | - | _, err = f.m.ShowKey(ctx, domain.Anonymous()) | |||
| 285 | - | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 286 | - | _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo}) | |||
| 287 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 288 | - | err = f.m.RemoveRepoMirror(ctx, reader, f.repo) | |||
| 289 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 290 | - | _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true) | |||
| 291 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 292 | - | _, err = f.m.Sync(ctx, reader, f.repo, false) | |||
| 293 | - | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 294 | - | f.mu.Lock() | |||
| 295 | - | f.status.Detail, f.status.Reason = "admin detail", "admin reason" | |||
| 296 | - | f.mu.Unlock() | |||
| 297 | - | view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo) | |||
| 298 | - | require.NoError(t, err) | |||
| 299 | - | require.Empty(t, view.Status.Detail) | |||
| 300 | - | require.Empty(t, view.Status.Reason) | |||
| 301 | - | require.Empty(t, view.Target.URL) | |||
| 302 | - | view, err = f.m.GetStatus(ctx, admin, f.repo) | |||
| 303 | - | require.NoError(t, err) | |||
| 304 | - | require.Equal(t, "admin detail", view.Status.Detail) | |||
| 305 | - | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |||
| 306 | - | _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo}) | |||
| 307 | - | require.NoError(t, err) | |||
| 308 | - | } | |||
| 309 | - | ||||
| 310 | - | func TestMirrorsPrivateStatusAuthorization(t *testing.T) { | |||
| 311 | - | f := newMirrorFixture(t) | |||
| 312 | - | meta := mocks.NewRepoMetaStore(t) | |||
| 313 | - | meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPrivate}, nil) | |||
| 314 | - | clock := mocks.NewClock(t) | |||
| 315 | - | clock.On("Now").Return(now) | |||
| 316 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Meta: meta, Store: f.store, Clock: clock}) | |||
| 317 | - | _, err := m.GetStatus(context.Background(), domain.Anonymous(), f.repo) | |||
| 318 | - | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 319 | - | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo) | |||
| 320 | - | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 321 | - | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo) | |||
| 322 | - | require.NoError(t, err) | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | func TestMirrorsCloseCancelsWorker(t *testing.T) { | |||
| 326 | - | f := newMirrorFixture(t) | |||
| 327 | - | entered, exited := make(chan struct{}), make(chan struct{}) | |||
| 328 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { close(entered); <-a.Get(0).(context.Context).Done(); close(exited) }).Return(ports.MirrorPushResult{}, context.Canceled).Once() | |||
| 329 | - | done := make(chan error, 1) | |||
| 330 | - | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 331 | - | <-entered | |||
| 332 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 333 | - | defer cancel() | |||
| 334 | - | f.m.Close(ctx) | |||
| 335 | - | require.NoError(t, ctx.Err()) | |||
| 336 | - | select { | |||
| 337 | - | case <-exited: | |||
| 338 | - | default: | |||
| 339 | - | t.Fatal("Close returned before worker exited") | |||
| 340 | - | } | |||
| 341 | - | require.ErrorIs(t, <-done, context.Canceled) | |||
| 342 | - | f.m.Close(ctx) // idempotent; Start cannot resurrect workers. | |||
| 343 | - | f.m.Start() | |||
| 344 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 345 | - | require.ErrorIs(t, err, context.Canceled) | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | func TestMirrorsCronBackoff(t *testing.T) { | |||
| 349 | - | f := newMirrorFixture(t) | |||
| 350 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |||
| 351 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 352 | - | require.Error(t, err) | |||
| 353 | - | f.advance(time.Minute) | |||
| 354 | - | f.m.ScheduleNow(context.Background()) | |||
| 355 | - | f.push.AssertNumberOfCalls(t, "Push", 1) | |||
| 356 | - | attempted := make(chan struct{}) | |||
| 357 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(attempted) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |||
| 358 | - | f.advance(4 * time.Minute) | |||
| 359 | - | f.m.ScheduleNow(context.Background()) | |||
| 360 | - | select { | |||
| 361 | - | case <-attempted: | |||
| 362 | - | case <-time.After(time.Second): | |||
| 363 | - | t.Fatal("retry did not fire at backoff deadline") | |||
| 364 | - | } | |||
| 365 | - | // Cleanup joins the worker before mock verification. | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | func TestMirrorsRecordDoesNotWaitForTrigger(t *testing.T) { | |||
| 369 | - | f := newPushFixture(t) | |||
| 370 | - | n := name(t, "tools") | |||
| 371 | - | trigger := mocks.NewMirrorTrigger(t) | |||
| 372 | - | entered, release, exited := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |||
| 373 | - | trigger.On("OnPush", mock.Anything, n, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release; close(exited) }).Return().Once() | |||
| 374 | - | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |||
| 375 | - | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |||
| 376 | - | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |||
| 377 | - | f.sched.On("Schedule", mock.Anything, n).Return() | |||
| 378 | - | returned := make(chan error, 1) | |||
| 379 | - | go func() { returned <- f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, nil) }() | |||
| 380 | - | <-entered | |||
| 381 | - | select { | |||
| 382 | - | case err := <-returned: | |||
| 383 | - | require.NoError(t, err) | |||
| 384 | - | case <-time.After(time.Second): | |||
| 385 | - | close(release) | |||
| 386 | - | <-exited | |||
| 387 | - | t.Fatal("Record blocked on mirror trigger") | |||
| 388 | - | } | |||
| 389 | - | close(release) | |||
| 390 | - | <-exited | |||
| 391 | - | } | |||
| 392 | - | ||||
| 393 | - | func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) { | |||
| 394 | - | f := newMirrorFixture(t) | |||
| 395 | - | var logs bytes.Buffer | |||
| 396 | - | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |||
| 397 | - | credentialURL := "ssh://alice:secret-password@example.com/acme/tools" | |||
| 398 | - | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL}) | |||
| 399 | - | require.Error(t, err) | |||
| 400 | - | require.NotContains(t, err.Error(), "secret-password") | |||
| 401 | - | require.NotContains(t, err.Error(), "alice") | |||
| 402 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once() | |||
| 403 | - | _, err = f.m.Sync(ctx, domain.Root(), f.repo, true) | |||
| 404 | - | require.Error(t, err) | |||
| 405 | - | require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER") | |||
| 406 | - | require.NotContains(t, err.Error(), "secret-password") | |||
| 407 | - | require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER") | |||
| 408 | - | require.NotContains(t, logs.String(), "secret-password") | |||
| 409 | - | } | |||
| 410 | - | ||||
| 411 | - | func TestMirrorsFileURLTestOption(t *testing.T) { | |||
| 412 | - | for _, allowed := range []bool{false, true} { | |||
| 413 | - | t.Run(fmt.Sprint(allowed), func(t *testing.T) { | |||
| 414 | - | store, meta, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewClock(t) | |||
| 415 | - | repo := name(t, "tools") | |||
| 416 | - | clock.On("Now").Return(now) | |||
| 417 | - | meta.On("Get", mock.Anything, repo).Return(domain.Repo{Name: repo}, nil) | |||
| 418 | - | global := domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "file:///tmp/remotes/{repo}", Cron: "*/5 * * * *"} | |||
| 419 | - | store.On("GetGlobal", mock.Anything).Return(global, nil).Maybe() | |||
| 420 | - | store.On("GetRepoConfig", mock.Anything, repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe() | |||
| 421 | - | store.On("GetStatus", mock.Anything, repo).Return(domain.MirrorStatus{Repo: repo}, nil).Maybe() | |||
| 422 | - | store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Maybe() | |||
| 423 | - | store.On("PutStatus", mock.Anything, mock.Anything).Return(nil).Maybe() | |||
| 424 | - | m := usecase.NewMirrors(usecase.MirrorsConfig{Store: store, Meta: meta, Clock: clock, AllowAnyURL: allowed}) | |||
| 425 | - | target, err := m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"}) | |||
| 426 | - | if allowed { | |||
| 427 | - | require.NoError(t, err) | |||
| 428 | - | require.Equal(t, "file:///tmp/remotes/tools", target.URL) | |||
| 429 | - | } else { | |||
| 430 | - | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 431 | - | } | |||
| 432 | - | }) | |||
| 433 | - | } | |||
| 434 | - | } | |||
| 435 | - | ||||
| 436 | - | func TestMirrorsMergeNotifiesMirror(t *testing.T) { | |||
| 437 | - | r := newMRRig(t) | |||
| 438 | - | r.seed() | |||
| 439 | - | trigger := mocks.NewMirrorTrigger(t) | |||
| 440 | - | notified := make(chan []domain.RefUpdate, 1) | |||
| 441 | - | trigger.On("OnPush", mock.Anything, r.repo, mock.Anything).Run(func(a mock.Arguments) { notified <- a.Get(2).([]domain.RefUpdate) }).Return().Once() | |||
| 442 | - | r.mrs.WithMirror(trigger) | |||
| 443 | - | head := r.feature("f0") | |||
| 444 | - | mr := r.open("f0") | |||
| 445 | - | _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |||
| 446 | - | require.NoError(t, err) | |||
| 447 | - | r.mrs.Wait(r.ctx) | |||
| 448 | - | select { | |||
| 449 | - | case u := <-notified: | |||
| 450 | - | require.Equal(t, "refs/heads/main", u[0].Ref) | |||
| 451 | - | case <-time.After(time.Second): | |||
| 452 | - | t.Fatal("merge did not notify mirrors") | |||
| 453 | - | } | |||
| 454 | - | } | |||
| 455 | - | ||||
| 456 | - | func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) { | |||
| 457 | - | f := newFixture(t) | |||
| 458 | - | ctx := context.Background() | |||
| 459 | - | n := name(t, "tools") | |||
| 460 | - | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate} | |||
| 461 | - | trigger := mocks.NewMirrorTrigger(t) | |||
| 462 | - | trigger.On("OnPush", mock.Anything, n, mock.Anything).Return().Once() | |||
| 463 | - | f.repos.WithMirror(trigger) | |||
| 464 | - | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |||
| 465 | - | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |||
| 466 | - | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil) | |||
| 467 | - | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |||
| 468 | - | _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")}) | |||
| 469 | - | require.NoError(t, err) | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) { | |||
| 473 | - | f := newMirrorFixture(t) | |||
| 474 | - | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 475 | - | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 476 | - | require.NoError(t, err) | |||
| 477 | - | f.mu.Lock() | |||
| 478 | - | f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute) | |||
| 479 | - | f.mu.Unlock() | |||
| 480 | - | f.m.ScheduleNow(context.Background()) | |||
| 481 | - | f.advance(4 * time.Minute) | |||
| 482 | - | f.m.ScheduleNow(context.Background()) // due and clean: skipped | |||
| 483 | - | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |||
| 484 | - | // A merge and a default-branch change both call OnPush (idempotent dirty mark). | |||
| 485 | - | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}) | |||
| 486 | - | f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}}) | |||
| 487 | - | read := make(chan struct{}) | |||
| 488 | - | f.reader.ExpectedCalls = nil | |||
| 489 | - | f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once() | |||
| 490 | - | f.advance(5 * time.Minute) | |||
| 491 | - | f.m.ScheduleNow(context.Background()) | |||
| 492 | - | select { | |||
| 493 | - | case <-read: | |||
| 494 | - | case <-time.After(time.Second): | |||
| 495 | - | t.Fatal("dirty repository not synced on the next due tick") | |||
| 496 | - | } | |||
| 497 | - | } | |||
| 498 | - | ||||
| 499 | - | func setupRepoOverrideFixture(t *testing.T, global domain.MirrorGlobalConfig) *mirrorFixture { | |||
| 500 | - | f := newMirrorFixture(t) | |||
| 501 | - | f.store.ExpectedCalls = nil | |||
| 502 | - | f.store.On("GetGlobal", mock.Anything).Return(global, nil) | |||
| 503 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe() | |||
| 504 | - | f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo}, nil).Maybe() | |||
| 505 | - | return f | |||
| 506 | - | } | |||
| 507 | - | ||||
| 508 | - | func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) { | |||
| 509 | - | f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{}) | |||
| 510 | - | _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"}) | |||
| 511 | - | var fe *domain.InvalidFieldsError | |||
| 512 | - | require.ErrorAs(t, err, &fe) | |||
| 513 | - | require.Equal(t, "cron", fe.Fields[0].Field) | |||
| 514 | - | // PutRepoConfig/PutStatus unexpected: strict mock fails if called. | |||
| 515 | - | } | |||
| 516 | - | ||||
| 517 | - | func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) { | |||
| 518 | - | f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}) | |||
| 519 | - | f.store.ExpectedCalls = nil | |||
| 520 | - | f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil) | |||
| 521 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil) | |||
| 522 | - | // Disabling the global with an empty cron leaves the inheriting override with no cron. | |||
| 523 | - | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")}) | |||
| 524 | - | var fe *domain.InvalidFieldsError | |||
| 525 | - | require.ErrorAs(t, err, &fe) | |||
| 526 | - | } | |||
| 527 | - | ||||
| 528 | - | func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) { | |||
| 529 | - | f := newMirrorFixture(t) | |||
| 530 | - | f.store.ExpectedCalls = nil | |||
| 531 | - | f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil) | |||
| 532 | - | f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil) | |||
| 533 | - | f.store.On("PutGlobal", mock.Anything, mock.Anything).Return(nil).Once() | |||
| 534 | - | f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}, nil) | |||
| 535 | - | reset := make(chan domain.MirrorStatus, 1) | |||
| 536 | - | f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { reset <- a.Get(1).(domain.MirrorStatus) }).Return(nil) | |||
| 537 | - | _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")}) | |||
| 538 | - | require.NoError(t, err) | |||
| 539 | - | require.Equal(t, domain.MirrorOK, (<-reset).State) | |||
| 540 | - | } | |||
| 541 | - | ||||
| 542 | - | func TestMirrorsSetRepoExcludedConcurrent(t *testing.T) { | |||
| 543 | - | f := newMirrorFixture(t) | |||
| 544 | - | var mu sync.Mutex | |||
| 545 | - | active, maxActive := 0, 0 | |||
| 546 | - | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Run(func(mock.Arguments) { | |||
| 547 | - | mu.Lock() | |||
| 548 | - | active++ | |||
| 549 | - | if active > maxActive { | |||
| 550 | - | maxActive = active | |||
| 551 | - | } | |||
| 552 | - | mu.Unlock() | |||
| 553 | - | time.Sleep(2 * time.Millisecond) | |||
| 554 | - | mu.Lock() | |||
| 555 | - | active-- | |||
| 556 | - | mu.Unlock() | |||
| 557 | - | }).Return(nil) | |||
| 558 | - | var wg sync.WaitGroup | |||
| 559 | - | for i := 0; i < 8; i++ { | |||
| 560 | - | wg.Add(1) | |||
| 561 | - | go func(ex bool) { | |||
| 562 | - | defer wg.Done() | |||
| 563 | - | _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, ex) | |||
| 564 | - | require.NoError(t, err) | |||
| 565 | - | }(i%2 == 0) | |||
| 566 | - | } | |||
| 567 | - | wg.Wait() | |||
| 568 | - | require.Equal(t, 1, maxActive, "read-modify-write must be serialized") | |||
| 569 | - | } | |||
| 570 | - | ||||
| 571 | - | func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) { | |||
| 572 | - | f := newMirrorFixture(t) | |||
| 573 | - | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 574 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 575 | - | done := make(chan error, 1) | |||
| 576 | - | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 577 | - | <-entered | |||
| 578 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 579 | - | require.NoError(t, err) | |||
| 580 | - | require.True(t, v.Queued) | |||
| 581 | - | close(release) | |||
| 582 | - | require.NoError(t, <-done) | |||
| 583 | - | // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push. | |||
| 584 | - | time.Sleep(200 * time.Millisecond) | |||
| 585 | - | f.push.AssertNumberOfCalls(t, "Push", 1) | |||
| 586 | - | } | |||
| 587 | - | ||||
| 588 | - | func TestMirrorsSlowPushTimestamps(t *testing.T) { | |||
| 589 | - | f := newMirrorFixture(t) | |||
| 590 | - | start := f.at | |||
| 591 | - | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { f.advance(10 * time.Minute) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |||
| 592 | - | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 593 | - | require.Error(t, err) | |||
| 594 | - | require.True(t, v.Status.LastAttemptAt.Equal(start)) | |||
| 595 | - | require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1)))) | |||
| 596 | - | } | |||
M internal/usecase/pushes.go +1 -2
| @@ -270,8 +270,7 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 270 | 270 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 271 | 271 | }) | |
| 272 | 272 | if u.mirror != nil { | |
| 273 | - | updates := append(make([]domain.RefUpdate, 0, len(updates)), updates...) | |
| 274 | - | go u.mirror.OnPush(ctx, name, updates) | |
| 273 | + | u.mirror.OnPush(ctx, name, updates) // marks only: never blocks | |
| 275 | 274 | } | |
| 276 | 275 | u.maint.Schedule(ctx, name) | |
| 277 | 276 | log.Info(). | |
| @@ -270,8 +270,7 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||||
| 270 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 270 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
| 271 | }) | 271 | }) | ||
| 272 | if u.mirror != nil { | 272 | if u.mirror != nil { | ||
| 273 | - | updates := append(make([]domain.RefUpdate, 0, len(updates)), updates...) | 273 | + | u.mirror.OnPush(ctx, name, updates) // marks only: never blocks |
| 274 | - | go u.mirror.OnPush(ctx, name, updates) | |||
| 275 | } | 274 | } | ||
| 276 | u.maint.Schedule(ctx, name) | 275 | u.maint.Schedule(ctx, name) | ||
| 277 | log.Info(). | 276 | log.Info(). | ||