merge: push mirror use case into feat/push-mirror
8 files · +1260 -0
M internal/usecase/events.go +8 -0
| @@ -167,6 +167,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 167 | 167 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | |
| 168 | 168 | case domain.RepoDeleted: | |
| 169 | 169 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | |
| 170 | + | case domain.MirrorFailed: | |
| 171 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 172 | + | case domain.MirrorRecovered: | |
| 173 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 170 | 174 | case domain.PushEvent: | |
| 171 | 175 | if ev.CreatedAt.IsZero() { | |
| 172 | 176 | return bufferedEvent{} // repository unknown at publish time: nobody is told | |
| @@ -517,6 +521,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 517 | 521 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | |
| 518 | 522 | case domain.RepoUpdated: | |
| 519 | 523 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | |
| 524 | + | case domain.MirrorFailed: | |
| 525 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 526 | + | case domain.MirrorRecovered: | |
| 527 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 520 | 528 | case domain.PushEvent: | |
| 521 | 529 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 522 | 530 | case domain.MergeRequestOpened: | |
| @@ -167,6 +167,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||||
| 167 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | 167 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | ||
| 168 | case domain.RepoDeleted: | 168 | case domain.RepoDeleted: | ||
| 169 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | 169 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | ||
| 170 | + | case domain.MirrorFailed: | |||
| 171 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 172 | + | case domain.MirrorRecovered: | |||
| 173 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 170 | case domain.PushEvent: | 174 | case domain.PushEvent: | ||
| 171 | if ev.CreatedAt.IsZero() { | 175 | if ev.CreatedAt.IsZero() { | ||
| 172 | return bufferedEvent{} // repository unknown at publish time: nobody is told | 176 | return bufferedEvent{} // repository unknown at publish time: nobody is told | ||
| @@ -517,6 +521,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||||
| 517 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | 521 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | ||
| 518 | case domain.RepoUpdated: | 522 | case domain.RepoUpdated: | ||
| 519 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | 523 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | ||
| 524 | + | case domain.MirrorFailed: | |||
| 525 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |||
| 526 | + | case domain.MirrorRecovered: | |||
| 527 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |||
| 520 | case domain.PushEvent: | 528 | case domain.PushEvent: | ||
| 521 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | 529 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | ||
| 522 | case domain.MergeRequestOpened: | 530 | case domain.MergeRequestOpened: | ||
A internal/usecase/mirrors.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/ed25519" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // MirrorsConfig supplies the mirror use case's dependencies. | |
| 16 | + | type MirrorsConfig struct { | |
| 17 | + | Store ports.MirrorStore | |
| 18 | + | Keys ports.MirrorKeyStore | |
| 19 | + | Sealer ports.SecretSealer | |
| 20 | + | Pusher ports.MirrorPusher | |
| 21 | + | Meta ports.RepoMetaStore | |
| 22 | + | Git ports.GitRepos | |
| 23 | + | Reader ports.RepoReader | |
| 24 | + | Bus ports.EventBus | |
| 25 | + | Clock ports.Clock | |
| 26 | + | PublicURL string | |
| 27 | + | ScheduleTick time.Duration | |
| 28 | + | Workers int | |
| 29 | + | AllowAnyURL bool | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Mirrors manages outbound repository mirrors. | |
| 33 | + | 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 | + | scheduleMu sync.Mutex | |
| 47 | + | lastMinute time.Time | |
| 48 | + | keyMu sync.Mutex | |
| 49 | + | key ed25519.PrivateKey | |
| 50 | + | keyCreated time.Time | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // NewMirrors builds the mirror use case. | |
| 54 | + | func NewMirrors(cfg MirrorsConfig) *Mirrors { | |
| 55 | + | if cfg.ScheduleTick <= 0 { | |
| 56 | + | cfg.ScheduleTick = time.Second | |
| 57 | + | } | |
| 58 | + | if cfg.Workers <= 0 { | |
| 59 | + | cfg.Workers = 2 | |
| 60 | + | } | |
| 61 | + | 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 | |
| 71 | + | } | |
| 72 | + | return append(ed25519.PrivateKey(nil), m.key...), nil | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/ed25519" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | "bnema.dev/ermite/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // MirrorsConfig supplies the mirror use case's dependencies. | |||
| 16 | + | type MirrorsConfig struct { | |||
| 17 | + | Store ports.MirrorStore | |||
| 18 | + | Keys ports.MirrorKeyStore | |||
| 19 | + | Sealer ports.SecretSealer | |||
| 20 | + | Pusher ports.MirrorPusher | |||
| 21 | + | Meta ports.RepoMetaStore | |||
| 22 | + | Git ports.GitRepos | |||
| 23 | + | Reader ports.RepoReader | |||
| 24 | + | Bus ports.EventBus | |||
| 25 | + | Clock ports.Clock | |||
| 26 | + | PublicURL string | |||
| 27 | + | ScheduleTick time.Duration | |||
| 28 | + | Workers int | |||
| 29 | + | AllowAnyURL bool | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Mirrors manages outbound repository mirrors. | |||
| 33 | + | 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 | + | scheduleMu sync.Mutex | |||
| 47 | + | lastMinute time.Time | |||
| 48 | + | keyMu sync.Mutex | |||
| 49 | + | key ed25519.PrivateKey | |||
| 50 | + | keyCreated time.Time | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // NewMirrors builds the mirror use case. | |||
| 54 | + | func NewMirrors(cfg MirrorsConfig) *Mirrors { | |||
| 55 | + | if cfg.ScheduleTick <= 0 { | |||
| 56 | + | cfg.ScheduleTick = time.Second | |||
| 57 | + | } | |||
| 58 | + | if cfg.Workers <= 0 { | |||
| 59 | + | cfg.Workers = 2 | |||
| 60 | + | } | |||
| 61 | + | 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 | |||
| 71 | + | } | |||
| 72 | + | return append(ed25519.PrivateKey(nil), m.key...), nil | |||
| 73 | + | } | |||
A internal/usecase/mirrors_config.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func mirrorOwner(p domain.Principal) error { | |
| 16 | + | if p.IsAnonymous() { | |
| 17 | + | return domain.ErrUnauthenticated | |
| 18 | + | } | |
| 19 | + | if !p.HasOwnerRights() { | |
| 20 | + | return domain.ErrForbidden | |
| 21 | + | } | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | func (m *Mirrors) admin(ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 25 | + | if p.IsAnonymous() { | |
| 26 | + | return domain.ErrUnauthenticated | |
| 27 | + | } | |
| 28 | + | r, err := m.cfg.Meta.Get(ctx, repo) | |
| 29 | + | if err != nil { | |
| 30 | + | return err | |
| 31 | + | } | |
| 32 | + | if !p.Allows(domain.ScopeRepoAdmin, repo) { | |
| 33 | + | if !canRead(p, r) { | |
| 34 | + | return domain.ErrNotFound | |
| 35 | + | } | |
| 36 | + | return domain.ErrForbidden | |
| 37 | + | } | |
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | func (m *Mirrors) target(ctx context.Context, repo domain.RepoName) (domain.MirrorTarget, error) { | |
| 41 | + | g, err := m.cfg.Store.GetGlobal(ctx) | |
| 42 | + | if err != nil { | |
| 43 | + | return domain.MirrorTarget{}, err | |
| 44 | + | } | |
| 45 | + | r, err := m.cfg.Store.GetRepoConfig(ctx, repo) | |
| 46 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 47 | + | return m.resolve(g, nil, repo) | |
| 48 | + | } | |
| 49 | + | if err != nil { | |
| 50 | + | return domain.MirrorTarget{}, err | |
| 51 | + | } | |
| 52 | + | return m.resolve(g, &r, repo) | |
| 53 | + | } | |
| 54 | + | func (m *Mirrors) status(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) { | |
| 55 | + | s, err := m.cfg.Store.GetStatus(ctx, repo) | |
| 56 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 57 | + | return domain.MirrorStatus{Repo: repo, State: domain.MirrorDisabled}, nil | |
| 58 | + | } | |
| 59 | + | return s, err | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // GetGlobalConfig returns server-wide policy to an owner. | |
| 63 | + | func (m *Mirrors) GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error) { | |
| 64 | + | if err := mirrorOwner(p); err != nil { | |
| 65 | + | return domain.MirrorGlobalConfig{}, err | |
| 66 | + | } | |
| 67 | + | return m.cfg.Store.GetGlobal(ctx) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // SetGlobalConfig patches server-wide policy and resets affected mirror incidents. | |
| 71 | + | func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch ports.MirrorGlobalPatch) (domain.MirrorGlobalConfig, error) { | |
| 72 | + | if err := mirrorOwner(p); err != nil { | |
| 73 | + | return domain.MirrorGlobalConfig{}, err | |
| 74 | + | } | |
| 75 | + | m.configMu.Lock() | |
| 76 | + | defer m.configMu.Unlock() | |
| 77 | + | g, err := m.cfg.Store.GetGlobal(ctx) | |
| 78 | + | if err != nil { | |
| 79 | + | return g, err | |
| 80 | + | } | |
| 81 | + | if patch.Enabled != nil { | |
| 82 | + | g.Enabled = *patch.Enabled | |
| 83 | + | } | |
| 84 | + | if patch.URLTemplate != nil { | |
| 85 | + | g.URLTemplate = *patch.URLTemplate | |
| 86 | + | } | |
| 87 | + | if patch.Cron != nil { | |
| 88 | + | g.Cron = *patch.Cron | |
| 89 | + | } | |
| 90 | + | if patch.AllBranches != nil { | |
| 91 | + | g.AllBranches = *patch.AllBranches | |
| 92 | + | } | |
| 93 | + | if err := m.validateGlobal(g); err != nil { | |
| 94 | + | return g, err | |
| 95 | + | } | |
| 96 | + | g.UpdatedAt = m.cfg.Clock.Now().UTC() | |
| 97 | + | if err := m.cfg.Store.PutGlobal(ctx, g); err != nil { | |
| 98 | + | return g, err | |
| 99 | + | } | |
| 100 | + | repos, err := m.cfg.Meta.List(ctx) | |
| 101 | + | if err != nil { | |
| 102 | + | return g, err | |
| 103 | + | } | |
| 104 | + | for _, r := range repos { | |
| 105 | + | c, e := m.cfg.Store.GetRepoConfig(ctx, r.Name) | |
| 106 | + | if e != nil && !errors.Is(e, domain.ErrNotFound) { | |
| 107 | + | return g, e | |
| 108 | + | } | |
| 109 | + | if c.URL != "" { | |
| 110 | + | continue | |
| 111 | + | } | |
| 112 | + | if err := m.reset(ctx, r.Name); err != nil { | |
| 113 | + | return g, err | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return g, nil | |
| 117 | + | } | |
| 118 | + | func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error { | |
| 119 | + | t, err := m.target(ctx, repo) | |
| 120 | + | if err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | s, err := m.status(ctx, repo) | |
| 124 | + | if err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | now := m.cfg.Clock.Now().UTC() | |
| 128 | + | if t.Enabled { | |
| 129 | + | s = domain.MirrorReset(s, now) | |
| 130 | + | } else { | |
| 131 | + | s = domain.MirrorDisable(s, now) | |
| 132 | + | } | |
| 133 | + | m.mu.Lock() | |
| 134 | + | m.dirty[repo]++ | |
| 135 | + | m.mu.Unlock() | |
| 136 | + | return m.cfg.Store.PutStatus(ctx, s) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // SetRepoMirror sets a repository override. | |
| 140 | + | func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) { | |
| 141 | + | if err := m.admin(ctx, p, c.Repo); err != nil { | |
| 142 | + | return domain.MirrorTarget{}, err | |
| 143 | + | } | |
| 144 | + | if err := m.validateRepo(c); err != nil { | |
| 145 | + | return domain.MirrorTarget{}, err | |
| 146 | + | } | |
| 147 | + | m.configMu.Lock() | |
| 148 | + | defer m.configMu.Unlock() | |
| 149 | + | c.UpdatedAt = m.cfg.Clock.Now().UTC() | |
| 150 | + | if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil { | |
| 151 | + | return domain.MirrorTarget{}, err | |
| 152 | + | } | |
| 153 | + | if err := m.reset(ctx, c.Repo); err != nil { | |
| 154 | + | return domain.MirrorTarget{}, err | |
| 155 | + | } | |
| 156 | + | return m.target(ctx, c.Repo) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // RemoveRepoMirror restores inherited policy. | |
| 160 | + | func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 161 | + | if err := m.admin(ctx, p, repo); err != nil { | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | m.configMu.Lock() | |
| 165 | + | defer m.configMu.Unlock() | |
| 166 | + | if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | return m.reset(ctx, repo) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // SetRepoExcluded updates exclusion without discarding an override. | |
| 173 | + | func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool) (domain.MirrorTarget, error) { | |
| 174 | + | if err := m.admin(ctx, p, repo); err != nil { | |
| 175 | + | return domain.MirrorTarget{}, err | |
| 176 | + | } | |
| 177 | + | c, err := m.cfg.Store.GetRepoConfig(ctx, repo) | |
| 178 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 179 | + | return domain.MirrorTarget{}, err | |
| 180 | + | } | |
| 181 | + | c.Repo, c.Excluded = repo, excluded | |
| 182 | + | return m.SetRepoMirror(ctx, p, c) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // GetStatus returns a read-authorized status, redacting administrative diagnostics. | |
| 186 | + | func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) { | |
| 187 | + | r, err := m.cfg.Meta.Get(ctx, repo) | |
| 188 | + | if err != nil { | |
| 189 | + | return ports.MirrorStatusView{}, err | |
| 190 | + | } | |
| 191 | + | if !canRead(p, r) { | |
| 192 | + | return ports.MirrorStatusView{}, domain.ErrNotFound | |
| 193 | + | } | |
| 194 | + | t, err := m.target(ctx, repo) | |
| 195 | + | if err != nil { | |
| 196 | + | return ports.MirrorStatusView{}, err | |
| 197 | + | } | |
| 198 | + | s, err := m.status(ctx, repo) | |
| 199 | + | if err != nil { | |
| 200 | + | return ports.MirrorStatusView{}, err | |
| 201 | + | } | |
| 202 | + | var next time.Time | |
| 203 | + | if t.Enabled && s.State != domain.MirrorPaused { | |
| 204 | + | if s.State == domain.MirrorFailing { | |
| 205 | + | next = s.NextRetryAt | |
| 206 | + | } else { | |
| 207 | + | next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now()) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | if !p.Allows(domain.ScopeRepoAdmin, repo) { | |
| 211 | + | s.Detail, s.Reason = "", "" | |
| 212 | + | t.URL = "" | |
| 213 | + | } | |
| 214 | + | return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}, nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // ListStatus returns all statuses to an owner. | |
| 218 | + | func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) { | |
| 219 | + | if err := mirrorOwner(p); err != nil { | |
| 220 | + | return nil, err | |
| 221 | + | } | |
| 222 | + | repos, err := m.cfg.Meta.List(ctx) | |
| 223 | + | if err != nil { | |
| 224 | + | return nil, err | |
| 225 | + | } | |
| 226 | + | out := make([]ports.MirrorStatusView, 0, len(repos)) | |
| 227 | + | for _, r := range repos { | |
| 228 | + | v, err := m.GetStatus(ctx, p, r.Name) | |
| 229 | + | if err != nil { | |
| 230 | + | return nil, err | |
| 231 | + | } | |
| 232 | + | out = append(out, v) | |
| 233 | + | } | |
| 234 | + | return out, nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Warning provides an administrative post-receive diagnostic. | |
| 238 | + | func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string { | |
| 239 | + | s, err := m.status(ctx, repo) | |
| 240 | + | if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) { | |
| 241 | + | return "" | |
| 242 | + | } | |
| 243 | + | t, err := m.target(ctx, repo) | |
| 244 | + | if err != nil { | |
| 245 | + | return "" | |
| 246 | + | } | |
| 247 | + | return "ermite: mirror to " + domain.RedactMirrorURL(t.URL) + " is " + string(s.State) + ": " + s.Reason | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // Badge returns a viewer-safe state and optional administrative reason. | |
| 251 | + | func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) { | |
| 252 | + | v, err := m.GetStatus(ctx, p, repo) | |
| 253 | + | if err != nil { | |
| 254 | + | return domain.MirrorDisabled, "" | |
| 255 | + | } | |
| 256 | + | return v.Status.State, v.Status.Reason | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // testURL substitutes only during validation/resolution; stored and pushed URLs remain intact. | |
| 260 | + | func (m *Mirrors) testURL(url string) string { | |
| 261 | + | if m.cfg.AllowAnyURL && strings.HasPrefix(url, "file://") { | |
| 262 | + | return "git@mirror-test:" + strings.TrimPrefix(url, "file://") | |
| 263 | + | } | |
| 264 | + | return url | |
| 265 | + | } | |
| 266 | + | func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error { | |
| 267 | + | g.URLTemplate = m.testURL(g.URLTemplate) | |
| 268 | + | return domain.ValidateMirrorGlobalConfig(g) | |
| 269 | + | } | |
| 270 | + | func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error { | |
| 271 | + | c.URL = m.testURL(c.URL) | |
| 272 | + | return domain.ValidateMirrorRepoConfig(c) | |
| 273 | + | } | |
| 274 | + | func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) { | |
| 275 | + | originalGlobal := g.URLTemplate | |
| 276 | + | originalRepo := "" | |
| 277 | + | g.URLTemplate = m.testURL(g.URLTemplate) | |
| 278 | + | if c != nil { | |
| 279 | + | copyConfig := *c | |
| 280 | + | originalRepo = c.URL | |
| 281 | + | copyConfig.URL = m.testURL(c.URL) | |
| 282 | + | c = ©Config | |
| 283 | + | } | |
| 284 | + | t, err := domain.ResolveMirror(g, c, repo) | |
| 285 | + | if err == nil && t.Enabled && m.cfg.AllowAnyURL { | |
| 286 | + | if originalRepo != "" { | |
| 287 | + | t.URL = originalRepo | |
| 288 | + | } else if strings.HasPrefix(originalGlobal, "file://") { | |
| 289 | + | t.URL, err = domain.ExpandMirrorURL(originalGlobal, repo) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | return t, err | |
| 293 | + | } | |
| @@ -0,0 +1,293 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | "bnema.dev/ermite/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func mirrorOwner(p domain.Principal) error { | |||
| 16 | + | if p.IsAnonymous() { | |||
| 17 | + | return domain.ErrUnauthenticated | |||
| 18 | + | } | |||
| 19 | + | if !p.HasOwnerRights() { | |||
| 20 | + | return domain.ErrForbidden | |||
| 21 | + | } | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | func (m *Mirrors) admin(ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |||
| 25 | + | if p.IsAnonymous() { | |||
| 26 | + | return domain.ErrUnauthenticated | |||
| 27 | + | } | |||
| 28 | + | r, err := m.cfg.Meta.Get(ctx, repo) | |||
| 29 | + | if err != nil { | |||
| 30 | + | return err | |||
| 31 | + | } | |||
| 32 | + | if !p.Allows(domain.ScopeRepoAdmin, repo) { | |||
| 33 | + | if !canRead(p, r) { | |||
| 34 | + | return domain.ErrNotFound | |||
| 35 | + | } | |||
| 36 | + | return domain.ErrForbidden | |||
| 37 | + | } | |||
| 38 | + | return nil | |||
| 39 | + | } | |||
| 40 | + | func (m *Mirrors) target(ctx context.Context, repo domain.RepoName) (domain.MirrorTarget, error) { | |||
| 41 | + | g, err := m.cfg.Store.GetGlobal(ctx) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return domain.MirrorTarget{}, err | |||
| 44 | + | } | |||
| 45 | + | r, err := m.cfg.Store.GetRepoConfig(ctx, repo) | |||
| 46 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 47 | + | return m.resolve(g, nil, repo) | |||
| 48 | + | } | |||
| 49 | + | if err != nil { | |||
| 50 | + | return domain.MirrorTarget{}, err | |||
| 51 | + | } | |||
| 52 | + | return m.resolve(g, &r, repo) | |||
| 53 | + | } | |||
| 54 | + | func (m *Mirrors) status(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) { | |||
| 55 | + | s, err := m.cfg.Store.GetStatus(ctx, repo) | |||
| 56 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 57 | + | return domain.MirrorStatus{Repo: repo, State: domain.MirrorDisabled}, nil | |||
| 58 | + | } | |||
| 59 | + | return s, err | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // GetGlobalConfig returns server-wide policy to an owner. | |||
| 63 | + | func (m *Mirrors) GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error) { | |||
| 64 | + | if err := mirrorOwner(p); err != nil { | |||
| 65 | + | return domain.MirrorGlobalConfig{}, err | |||
| 66 | + | } | |||
| 67 | + | return m.cfg.Store.GetGlobal(ctx) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // SetGlobalConfig patches server-wide policy and resets affected mirror incidents. | |||
| 71 | + | func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch ports.MirrorGlobalPatch) (domain.MirrorGlobalConfig, error) { | |||
| 72 | + | if err := mirrorOwner(p); err != nil { | |||
| 73 | + | return domain.MirrorGlobalConfig{}, err | |||
| 74 | + | } | |||
| 75 | + | m.configMu.Lock() | |||
| 76 | + | defer m.configMu.Unlock() | |||
| 77 | + | g, err := m.cfg.Store.GetGlobal(ctx) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return g, err | |||
| 80 | + | } | |||
| 81 | + | if patch.Enabled != nil { | |||
| 82 | + | g.Enabled = *patch.Enabled | |||
| 83 | + | } | |||
| 84 | + | if patch.URLTemplate != nil { | |||
| 85 | + | g.URLTemplate = *patch.URLTemplate | |||
| 86 | + | } | |||
| 87 | + | if patch.Cron != nil { | |||
| 88 | + | g.Cron = *patch.Cron | |||
| 89 | + | } | |||
| 90 | + | if patch.AllBranches != nil { | |||
| 91 | + | g.AllBranches = *patch.AllBranches | |||
| 92 | + | } | |||
| 93 | + | if err := m.validateGlobal(g); err != nil { | |||
| 94 | + | return g, err | |||
| 95 | + | } | |||
| 96 | + | g.UpdatedAt = m.cfg.Clock.Now().UTC() | |||
| 97 | + | if err := m.cfg.Store.PutGlobal(ctx, g); err != nil { | |||
| 98 | + | return g, err | |||
| 99 | + | } | |||
| 100 | + | repos, err := m.cfg.Meta.List(ctx) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return g, err | |||
| 103 | + | } | |||
| 104 | + | for _, r := range repos { | |||
| 105 | + | c, e := m.cfg.Store.GetRepoConfig(ctx, r.Name) | |||
| 106 | + | if e != nil && !errors.Is(e, domain.ErrNotFound) { | |||
| 107 | + | return g, e | |||
| 108 | + | } | |||
| 109 | + | if c.URL != "" { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | if err := m.reset(ctx, r.Name); err != nil { | |||
| 113 | + | return g, err | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | return g, nil | |||
| 117 | + | } | |||
| 118 | + | func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error { | |||
| 119 | + | t, err := m.target(ctx, repo) | |||
| 120 | + | if err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | s, err := m.status(ctx, repo) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | now := m.cfg.Clock.Now().UTC() | |||
| 128 | + | if t.Enabled { | |||
| 129 | + | s = domain.MirrorReset(s, now) | |||
| 130 | + | } else { | |||
| 131 | + | s = domain.MirrorDisable(s, now) | |||
| 132 | + | } | |||
| 133 | + | m.mu.Lock() | |||
| 134 | + | m.dirty[repo]++ | |||
| 135 | + | m.mu.Unlock() | |||
| 136 | + | return m.cfg.Store.PutStatus(ctx, s) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // SetRepoMirror sets a repository override. | |||
| 140 | + | func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) { | |||
| 141 | + | if err := m.admin(ctx, p, c.Repo); err != nil { | |||
| 142 | + | return domain.MirrorTarget{}, err | |||
| 143 | + | } | |||
| 144 | + | if err := m.validateRepo(c); err != nil { | |||
| 145 | + | return domain.MirrorTarget{}, err | |||
| 146 | + | } | |||
| 147 | + | m.configMu.Lock() | |||
| 148 | + | defer m.configMu.Unlock() | |||
| 149 | + | c.UpdatedAt = m.cfg.Clock.Now().UTC() | |||
| 150 | + | if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil { | |||
| 151 | + | return domain.MirrorTarget{}, err | |||
| 152 | + | } | |||
| 153 | + | if err := m.reset(ctx, c.Repo); err != nil { | |||
| 154 | + | return domain.MirrorTarget{}, err | |||
| 155 | + | } | |||
| 156 | + | return m.target(ctx, c.Repo) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // RemoveRepoMirror restores inherited policy. | |||
| 160 | + | func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |||
| 161 | + | if err := m.admin(ctx, p, repo); err != nil { | |||
| 162 | + | return err | |||
| 163 | + | } | |||
| 164 | + | m.configMu.Lock() | |||
| 165 | + | defer m.configMu.Unlock() | |||
| 166 | + | if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil { | |||
| 167 | + | return err | |||
| 168 | + | } | |||
| 169 | + | return m.reset(ctx, repo) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // SetRepoExcluded updates exclusion without discarding an override. | |||
| 173 | + | func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool) (domain.MirrorTarget, error) { | |||
| 174 | + | if err := m.admin(ctx, p, repo); err != nil { | |||
| 175 | + | return domain.MirrorTarget{}, err | |||
| 176 | + | } | |||
| 177 | + | c, err := m.cfg.Store.GetRepoConfig(ctx, repo) | |||
| 178 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |||
| 179 | + | return domain.MirrorTarget{}, err | |||
| 180 | + | } | |||
| 181 | + | c.Repo, c.Excluded = repo, excluded | |||
| 182 | + | return m.SetRepoMirror(ctx, p, c) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // GetStatus returns a read-authorized status, redacting administrative diagnostics. | |||
| 186 | + | func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) { | |||
| 187 | + | r, err := m.cfg.Meta.Get(ctx, repo) | |||
| 188 | + | if err != nil { | |||
| 189 | + | return ports.MirrorStatusView{}, err | |||
| 190 | + | } | |||
| 191 | + | if !canRead(p, r) { | |||
| 192 | + | return ports.MirrorStatusView{}, domain.ErrNotFound | |||
| 193 | + | } | |||
| 194 | + | t, err := m.target(ctx, repo) | |||
| 195 | + | if err != nil { | |||
| 196 | + | return ports.MirrorStatusView{}, err | |||
| 197 | + | } | |||
| 198 | + | s, err := m.status(ctx, repo) | |||
| 199 | + | if err != nil { | |||
| 200 | + | return ports.MirrorStatusView{}, err | |||
| 201 | + | } | |||
| 202 | + | var next time.Time | |||
| 203 | + | if t.Enabled && s.State != domain.MirrorPaused { | |||
| 204 | + | if s.State == domain.MirrorFailing { | |||
| 205 | + | next = s.NextRetryAt | |||
| 206 | + | } else { | |||
| 207 | + | next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now()) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | if !p.Allows(domain.ScopeRepoAdmin, repo) { | |||
| 211 | + | s.Detail, s.Reason = "", "" | |||
| 212 | + | t.URL = "" | |||
| 213 | + | } | |||
| 214 | + | return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}, nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // ListStatus returns all statuses to an owner. | |||
| 218 | + | func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) { | |||
| 219 | + | if err := mirrorOwner(p); err != nil { | |||
| 220 | + | return nil, err | |||
| 221 | + | } | |||
| 222 | + | repos, err := m.cfg.Meta.List(ctx) | |||
| 223 | + | if err != nil { | |||
| 224 | + | return nil, err | |||
| 225 | + | } | |||
| 226 | + | out := make([]ports.MirrorStatusView, 0, len(repos)) | |||
| 227 | + | for _, r := range repos { | |||
| 228 | + | v, err := m.GetStatus(ctx, p, r.Name) | |||
| 229 | + | if err != nil { | |||
| 230 | + | return nil, err | |||
| 231 | + | } | |||
| 232 | + | out = append(out, v) | |||
| 233 | + | } | |||
| 234 | + | return out, nil | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // Warning provides an administrative post-receive diagnostic. | |||
| 238 | + | func (m *Mirrors) Warning(ctx context.Context, repo domain.RepoName) string { | |||
| 239 | + | s, err := m.status(ctx, repo) | |||
| 240 | + | if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) { | |||
| 241 | + | return "" | |||
| 242 | + | } | |||
| 243 | + | t, err := m.target(ctx, repo) | |||
| 244 | + | if err != nil { | |||
| 245 | + | return "" | |||
| 246 | + | } | |||
| 247 | + | return "ermite: mirror to " + domain.RedactMirrorURL(t.URL) + " is " + string(s.State) + ": " + s.Reason | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // Badge returns a viewer-safe state and optional administrative reason. | |||
| 251 | + | func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) { | |||
| 252 | + | v, err := m.GetStatus(ctx, p, repo) | |||
| 253 | + | if err != nil { | |||
| 254 | + | return domain.MirrorDisabled, "" | |||
| 255 | + | } | |||
| 256 | + | return v.Status.State, v.Status.Reason | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // testURL substitutes only during validation/resolution; stored and pushed URLs remain intact. | |||
| 260 | + | func (m *Mirrors) testURL(url string) string { | |||
| 261 | + | if m.cfg.AllowAnyURL && strings.HasPrefix(url, "file://") { | |||
| 262 | + | return "git@mirror-test:" + strings.TrimPrefix(url, "file://") | |||
| 263 | + | } | |||
| 264 | + | return url | |||
| 265 | + | } | |||
| 266 | + | func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error { | |||
| 267 | + | g.URLTemplate = m.testURL(g.URLTemplate) | |||
| 268 | + | return domain.ValidateMirrorGlobalConfig(g) | |||
| 269 | + | } | |||
| 270 | + | func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error { | |||
| 271 | + | c.URL = m.testURL(c.URL) | |||
| 272 | + | return domain.ValidateMirrorRepoConfig(c) | |||
| 273 | + | } | |||
| 274 | + | func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) { | |||
| 275 | + | originalGlobal := g.URLTemplate | |||
| 276 | + | originalRepo := "" | |||
| 277 | + | g.URLTemplate = m.testURL(g.URLTemplate) | |||
| 278 | + | if c != nil { | |||
| 279 | + | copyConfig := *c | |||
| 280 | + | originalRepo = c.URL | |||
| 281 | + | copyConfig.URL = m.testURL(c.URL) | |||
| 282 | + | c = ©Config | |||
| 283 | + | } | |||
| 284 | + | t, err := domain.ResolveMirror(g, c, repo) | |||
| 285 | + | if err == nil && t.Enabled && m.cfg.AllowAnyURL { | |||
| 286 | + | if originalRepo != "" { | |||
| 287 | + | t.URL = originalRepo | |||
| 288 | + | } else if strings.HasPrefix(originalGlobal, "file://") { | |||
| 289 | + | t.URL, err = domain.ExpandMirrorURL(originalGlobal, repo) | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | return t, err | |||
| 293 | + | } | |||
A internal/usecase/mirrors_key.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/ed25519" | |
| 8 | + | "crypto/rand" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // 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) { | |
| 18 | + | if p.IsAnonymous() { | |
| 19 | + | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | |
| 20 | + | } | |
| 21 | + | return m.publicKey(ctx, false) | |
| 22 | + | } | |
| 23 | + | ||
| 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) { | |
| 26 | + | if p.IsAnonymous() { | |
| 27 | + | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | |
| 28 | + | } | |
| 29 | + | if !p.HasOwnerRights() { | |
| 30 | + | return domain.MirrorPublicKey{}, domain.ErrForbidden | |
| 31 | + | } | |
| 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() | |
| 37 | + | defer m.keyMu.Unlock() | |
| 38 | + | if err := m.loadKey(ctx, rotate); err != nil { | |
| 39 | + | return domain.MirrorPublicKey{}, err | |
| 40 | + | } | |
| 41 | + | host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@") | |
| 42 | + | return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated), nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // loadKey is called with keyMu held. Only successfully persisted material is cached. | |
| 46 | + | func (m *Mirrors) loadKey(ctx context.Context, rotate bool) error { | |
| 47 | + | 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) | |
| 49 | + | } | |
| 50 | + | if !rotate && m.key != nil { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 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 | |
| 65 | + | } | |
| 66 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 67 | + | return fmt.Errorf("read mirror key: %w", err) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | _, key, err := ed25519.GenerateKey(rand.Reader) | |
| 71 | + | if err != nil { | |
| 72 | + | return fmt.Errorf("generate mirror key: %w", err) | |
| 73 | + | } | |
| 74 | + | created := m.cfg.Clock.Now().UTC() | |
| 75 | + | 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 { | |
| 81 | + | return fmt.Errorf("store mirror key: %w", err) | |
| 82 | + | } | |
| 83 | + | m.key, m.keyCreated = key, created | |
| 84 | + | return nil | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/ed25519" | |||
| 8 | + | "crypto/rand" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // 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) { | |||
| 18 | + | if p.IsAnonymous() { | |||
| 19 | + | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | |||
| 20 | + | } | |||
| 21 | + | return m.publicKey(ctx, false) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 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) { | |||
| 26 | + | if p.IsAnonymous() { | |||
| 27 | + | return domain.MirrorPublicKey{}, domain.ErrUnauthenticated | |||
| 28 | + | } | |||
| 29 | + | if !p.HasOwnerRights() { | |||
| 30 | + | return domain.MirrorPublicKey{}, domain.ErrForbidden | |||
| 31 | + | } | |||
| 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() | |||
| 37 | + | defer m.keyMu.Unlock() | |||
| 38 | + | if err := m.loadKey(ctx, rotate); err != nil { | |||
| 39 | + | return domain.MirrorPublicKey{}, err | |||
| 40 | + | } | |||
| 41 | + | host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@") | |||
| 42 | + | return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated), nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // loadKey is called with keyMu held. Only successfully persisted material is cached. | |||
| 46 | + | func (m *Mirrors) loadKey(ctx context.Context, rotate bool) error { | |||
| 47 | + | 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) | |||
| 49 | + | } | |||
| 50 | + | if !rotate && m.key != nil { | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 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 | |||
| 65 | + | } | |||
| 66 | + | if !errors.Is(err, domain.ErrNotFound) { | |||
| 67 | + | return fmt.Errorf("read mirror key: %w", err) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | _, key, err := ed25519.GenerateKey(rand.Reader) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return fmt.Errorf("generate mirror key: %w", err) | |||
| 73 | + | } | |||
| 74 | + | created := m.cfg.Clock.Now().UTC() | |||
| 75 | + | 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 { | |||
| 81 | + | return fmt.Errorf("store mirror key: %w", err) | |||
| 82 | + | } | |||
| 83 | + | m.key, m.keyCreated = key, created | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
A internal/usecase/mirrors_schedule.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Start starts the bounded worker pool and UTC scheduler exactly once. | |
| 14 | + | func (m *Mirrors) Start() { | |
| 15 | + | m.start.Do(func() { | |
| 16 | + | m.mu.Lock() | |
| 17 | + | defer m.mu.Unlock() | |
| 18 | + | if m.closed { | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | m.wg.Add(m.cfg.Workers + 1) | |
| 22 | + | for i := 0; i < m.cfg.Workers; i++ { | |
| 23 | + | go func() { | |
| 24 | + | defer m.wg.Done() | |
| 25 | + | for { | |
| 26 | + | select { | |
| 27 | + | case <-m.ctx.Done(): | |
| 28 | + | return | |
| 29 | + | case repo := <-m.jobs: | |
| 30 | + | m.run(repo) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | }() | |
| 34 | + | } | |
| 35 | + | go func() { | |
| 36 | + | defer m.wg.Done() | |
| 37 | + | ticker := time.NewTicker(m.cfg.ScheduleTick) | |
| 38 | + | defer ticker.Stop() | |
| 39 | + | for { | |
| 40 | + | select { | |
| 41 | + | case <-m.ctx.Done(): | |
| 42 | + | return | |
| 43 | + | case <-ticker.C: | |
| 44 | + | m.ScheduleNow(m.ctx) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | }() | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Close cancels workers and waits until they stop or the caller's deadline expires. | |
| 52 | + | func (m *Mirrors) Close(ctx context.Context) { | |
| 53 | + | m.mu.Lock() | |
| 54 | + | m.closed = true | |
| 55 | + | m.cancel() | |
| 56 | + | m.mu.Unlock() | |
| 57 | + | done := make(chan struct{}) | |
| 58 | + | go func() { m.wg.Wait(); close(done) }() | |
| 59 | + | select { | |
| 60 | + | case <-ctx.Done(): | |
| 61 | + | case <-done: | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // OnPush marks the repository dirty without I/O or waiting for a worker. | |
| 66 | + | func (m *Mirrors) OnPush(_ context.Context, repo domain.RepoName, _ []domain.RefUpdate) { | |
| 67 | + | m.mu.Lock() | |
| 68 | + | defer m.mu.Unlock() | |
| 69 | + | if !m.closed { | |
| 70 | + | m.dirty[repo]++ | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // ScheduleNow examines one current minute; missed minutes are not replayed. | |
| 75 | + | func (m *Mirrors) ScheduleNow(ctx context.Context) { | |
| 76 | + | m.Start() | |
| 77 | + | m.scheduleMu.Lock() | |
| 78 | + | defer m.scheduleMu.Unlock() | |
| 79 | + | now := m.cfg.Clock.Now().UTC() | |
| 80 | + | minute := now.Truncate(time.Minute) | |
| 81 | + | if !minute.After(m.lastMinute) { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | m.lastMinute = minute | |
| 85 | + | repos, err := m.cfg.Meta.List(ctx) | |
| 86 | + | if err != nil { | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | live := make(map[domain.RepoName]bool, len(repos)) | |
| 90 | + | for _, r := range repos { | |
| 91 | + | if ctx.Err() != nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | live[r.Name] = true | |
| 95 | + | t, err := m.target(ctx, r.Name) | |
| 96 | + | if err != nil || !t.Enabled { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | s, err := m.status(ctx, r.Name) | |
| 100 | + | if err != nil || s.State == domain.MirrorPaused { | |
| 101 | + | continue | |
| 102 | + | } | |
| 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) | |
| 108 | + | } | |
| 109 | + | if !due { | |
| 110 | + | continue | |
| 111 | + | } | |
| 112 | + | m.mu.Lock() | |
| 113 | + | clean, known := m.clean[r.Name] | |
| 114 | + | dirty := m.dirty[r.Name] | |
| 115 | + | m.mu.Unlock() | |
| 116 | + | if s.State == domain.MirrorOK && known && clean == dirty { | |
| 117 | + | snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name) | |
| 118 | + | if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches { | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | } | |
| 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") | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | m.mu.Lock() | |
| 128 | + | for repo := range m.dirty { | |
| 129 | + | if !live[repo] { | |
| 130 | + | delete(m.dirty, repo) | |
| 131 | + | delete(m.clean, repo) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | m.mu.Unlock() | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite/internal/domain" | |||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Start starts the bounded worker pool and UTC scheduler exactly once. | |||
| 14 | + | func (m *Mirrors) Start() { | |||
| 15 | + | m.start.Do(func() { | |||
| 16 | + | m.mu.Lock() | |||
| 17 | + | defer m.mu.Unlock() | |||
| 18 | + | if m.closed { | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | m.wg.Add(m.cfg.Workers + 1) | |||
| 22 | + | for i := 0; i < m.cfg.Workers; i++ { | |||
| 23 | + | go func() { | |||
| 24 | + | defer m.wg.Done() | |||
| 25 | + | for { | |||
| 26 | + | select { | |||
| 27 | + | case <-m.ctx.Done(): | |||
| 28 | + | return | |||
| 29 | + | case repo := <-m.jobs: | |||
| 30 | + | m.run(repo) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | }() | |||
| 34 | + | } | |||
| 35 | + | go func() { | |||
| 36 | + | defer m.wg.Done() | |||
| 37 | + | ticker := time.NewTicker(m.cfg.ScheduleTick) | |||
| 38 | + | defer ticker.Stop() | |||
| 39 | + | for { | |||
| 40 | + | select { | |||
| 41 | + | case <-m.ctx.Done(): | |||
| 42 | + | return | |||
| 43 | + | case <-ticker.C: | |||
| 44 | + | m.ScheduleNow(m.ctx) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | }() | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Close cancels workers and waits until they stop or the caller's deadline expires. | |||
| 52 | + | func (m *Mirrors) Close(ctx context.Context) { | |||
| 53 | + | m.mu.Lock() | |||
| 54 | + | m.closed = true | |||
| 55 | + | m.cancel() | |||
| 56 | + | m.mu.Unlock() | |||
| 57 | + | done := make(chan struct{}) | |||
| 58 | + | go func() { m.wg.Wait(); close(done) }() | |||
| 59 | + | select { | |||
| 60 | + | case <-ctx.Done(): | |||
| 61 | + | case <-done: | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // OnPush marks the repository dirty without I/O or waiting for a worker. | |||
| 66 | + | func (m *Mirrors) OnPush(_ context.Context, repo domain.RepoName, _ []domain.RefUpdate) { | |||
| 67 | + | m.mu.Lock() | |||
| 68 | + | defer m.mu.Unlock() | |||
| 69 | + | if !m.closed { | |||
| 70 | + | m.dirty[repo]++ | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // ScheduleNow examines one current minute; missed minutes are not replayed. | |||
| 75 | + | func (m *Mirrors) ScheduleNow(ctx context.Context) { | |||
| 76 | + | m.Start() | |||
| 77 | + | m.scheduleMu.Lock() | |||
| 78 | + | defer m.scheduleMu.Unlock() | |||
| 79 | + | now := m.cfg.Clock.Now().UTC() | |||
| 80 | + | minute := now.Truncate(time.Minute) | |||
| 81 | + | if !minute.After(m.lastMinute) { | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | m.lastMinute = minute | |||
| 85 | + | repos, err := m.cfg.Meta.List(ctx) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return | |||
| 88 | + | } | |||
| 89 | + | live := make(map[domain.RepoName]bool, len(repos)) | |||
| 90 | + | for _, r := range repos { | |||
| 91 | + | if ctx.Err() != nil { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | live[r.Name] = true | |||
| 95 | + | t, err := m.target(ctx, r.Name) | |||
| 96 | + | if err != nil || !t.Enabled { | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | s, err := m.status(ctx, r.Name) | |||
| 100 | + | if err != nil || s.State == domain.MirrorPaused { | |||
| 101 | + | continue | |||
| 102 | + | } | |||
| 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) | |||
| 108 | + | } | |||
| 109 | + | if !due { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | m.mu.Lock() | |||
| 113 | + | clean, known := m.clean[r.Name] | |||
| 114 | + | dirty := m.dirty[r.Name] | |||
| 115 | + | m.mu.Unlock() | |||
| 116 | + | if s.State == domain.MirrorOK && known && clean == dirty { | |||
| 117 | + | snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name) | |||
| 118 | + | if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches { | |||
| 119 | + | continue | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 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") | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | m.mu.Lock() | |||
| 128 | + | for repo := range m.dirty { | |||
| 129 | + | if !live[repo] { | |||
| 130 | + | delete(m.dirty, repo) | |||
| 131 | + | delete(m.clean, repo) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | m.mu.Unlock() | |||
| 135 | + | } | |||
A internal/usecase/mirrors_sync.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type mirrorFlight struct { | |
| 15 | + | pending, force, manual bool | |
| 16 | + | pendingForce bool | |
| 17 | + | done chan struct{} | |
| 18 | + | completed bool | |
| 19 | + | result ports.MirrorSyncResult | |
| 20 | + | err error | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | var ( | |
| 24 | + | _ ports.MirrorService = (*Mirrors)(nil) | |
| 25 | + | _ ports.MirrorTrigger = (*Mirrors)(nil) | |
| 26 | + | _ ports.MirrorWarner = (*Mirrors)(nil) | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // Sync waits for its own run; overlapping requests coalesce into one sticky rerun. | |
| 30 | + | func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) { | |
| 31 | + | if err := m.admin(ctx, p, repo); err != nil { | |
| 32 | + | return ports.MirrorSyncResult{}, err | |
| 33 | + | } | |
| 34 | + | m.Start() | |
| 35 | + | f, queued, err := m.enqueue(repo, force, true) | |
| 36 | + | if err != nil { | |
| 37 | + | return ports.MirrorSyncResult{}, err | |
| 38 | + | } | |
| 39 | + | if queued { | |
| 40 | + | return ports.MirrorSyncResult{Queued: true}, nil | |
| 41 | + | } | |
| 42 | + | select { | |
| 43 | + | case <-m.ctx.Done(): | |
| 44 | + | return ports.MirrorSyncResult{}, m.ctx.Err() | |
| 45 | + | case <-ctx.Done(): | |
| 46 | + | return ports.MirrorSyncResult{}, ctx.Err() | |
| 47 | + | case <-f.done: | |
| 48 | + | return f.result, f.err | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // SyncAll requests a sync for every repository as an owner. | |
| 53 | + | func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) { | |
| 54 | + | if err := mirrorOwner(p); err != nil { | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | repos, err := m.cfg.Meta.List(ctx) | |
| 58 | + | if err != nil { | |
| 59 | + | return nil, err | |
| 60 | + | } | |
| 61 | + | out := make([]ports.MirrorSyncResult, 0, len(repos)) | |
| 62 | + | for _, r := range repos { | |
| 63 | + | v, err := m.Sync(ctx, p, r.Name, force) | |
| 64 | + | if err != nil { | |
| 65 | + | return out, err | |
| 66 | + | } | |
| 67 | + | out = append(out, v) | |
| 68 | + | } | |
| 69 | + | return out, nil | |
| 70 | + | } | |
| 71 | + | func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlight, bool, error) { | |
| 72 | + | m.mu.Lock() | |
| 73 | + | defer m.mu.Unlock() | |
| 74 | + | if m.closed { | |
| 75 | + | return nil, false, context.Canceled | |
| 76 | + | } | |
| 77 | + | if f := m.flights[repo]; f != nil { | |
| 78 | + | f.pending = true | |
| 79 | + | f.pendingForce = f.pendingForce || force | |
| 80 | + | return f, true, nil | |
| 81 | + | } | |
| 82 | + | f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})} | |
| 83 | + | m.flights[repo] = f | |
| 84 | + | select { | |
| 85 | + | case m.jobs <- repo: | |
| 86 | + | return f, false, nil | |
| 87 | + | default: | |
| 88 | + | delete(m.flights, repo) | |
| 89 | + | return nil, false, domain.ErrResourceExhausted | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | func (m *Mirrors) run(repo domain.RepoName) { | |
| 93 | + | m.mu.Lock() | |
| 94 | + | f := m.flights[repo] | |
| 95 | + | m.mu.Unlock() | |
| 96 | + | for { | |
| 97 | + | result, err := m.attempt(m.ctx, repo, f.force, f.manual) | |
| 98 | + | m.mu.Lock() | |
| 99 | + | if !f.completed { | |
| 100 | + | f.result, f.err = result, err | |
| 101 | + | f.completed = true | |
| 102 | + | close(f.done) | |
| 103 | + | } | |
| 104 | + | if !f.pending || m.closed { | |
| 105 | + | delete(m.flights, repo) | |
| 106 | + | m.mu.Unlock() | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | f.force, f.manual = f.pendingForce, false | |
| 110 | + | f.pending, f.pendingForce = false, false | |
| 111 | + | m.mu.Unlock() | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (m *Mirrors) syncOnce(ctx context.Context, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) { | |
| 116 | + | return m.attempt(ctx, repo, force, true) | |
| 117 | + | } | |
| 118 | + | func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) { | |
| 119 | + | m.configMu.RLock() | |
| 120 | + | defer m.configMu.RUnlock() | |
| 121 | + | meta, err := m.cfg.Meta.Get(ctx, repo) | |
| 122 | + | if err != nil { | |
| 123 | + | return ports.MirrorSyncResult{}, err | |
| 124 | + | } | |
| 125 | + | t, err := m.target(ctx, repo) | |
| 126 | + | if err != nil { | |
| 127 | + | return ports.MirrorSyncResult{}, err | |
| 128 | + | } | |
| 129 | + | prev, err := m.status(ctx, repo) | |
| 130 | + | if err != nil { | |
| 131 | + | return ports.MirrorSyncResult{}, err | |
| 132 | + | } | |
| 133 | + | now := m.cfg.Clock.Now().UTC() | |
| 134 | + | if !t.Enabled { | |
| 135 | + | s := domain.MirrorDisable(prev, now) | |
| 136 | + | if prev.State != domain.MirrorDisabled { | |
| 137 | + | err = m.cfg.Store.PutStatus(ctx, s) | |
| 138 | + | } | |
| 139 | + | return ports.MirrorSyncResult{Status: s}, err | |
| 140 | + | } | |
| 141 | + | if !force && (prev.State == domain.MirrorPaused || (!manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) { | |
| 142 | + | return ports.MirrorSyncResult{Status: prev}, nil | |
| 143 | + | } | |
| 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)) | |
| 146 | + | } | |
| 147 | + | m.mu.Lock() | |
| 148 | + | generation := m.dirty[repo] | |
| 149 | + | m.mu.Unlock() | |
| 150 | + | refs, runErr := m.cfg.Reader.ListRefs(ctx, repo) | |
| 151 | + | var selected []domain.MirrorRef | |
| 152 | + | if runErr == nil { | |
| 153 | + | branch, _, e := m.cfg.Git.DefaultBranch(ctx, repo) | |
| 154 | + | runErr = e | |
| 155 | + | if e == nil { | |
| 156 | + | selected = domain.MirrorRefspecs(refs, branch, t.AllBranches) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | noOp := false | |
| 160 | + | if runErr == nil && !force { | |
| 161 | + | snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo) | |
| 162 | + | if e != nil && !errors.Is(e, domain.ErrNotFound) { | |
| 163 | + | return ports.MirrorSyncResult{}, e | |
| 164 | + | } | |
| 165 | + | noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches) | |
| 166 | + | } | |
| 167 | + | 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 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | if ctx.Err() != nil { | |
| 182 | + | return ports.MirrorSyncResult{}, ctx.Err() | |
| 183 | + | } | |
| 184 | + | var failure *domain.MirrorError | |
| 185 | + | 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} | |
| 191 | + | runErr = failure | |
| 192 | + | if m.cfg.Sealer == nil { | |
| 193 | + | runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now) | |
| 197 | + | if failure != nil && m.cfg.Sealer == nil { | |
| 198 | + | transition.Status.Reason = failure.Detail | |
| 199 | + | } | |
| 200 | + | if failure == nil { | |
| 201 | + | if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil { | |
| 202 | + | return ports.MirrorSyncResult{}, err | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | if err := m.cfg.Store.PutStatus(ctx, transition.Status); err != nil { | |
| 206 | + | return ports.MirrorSyncResult{}, err | |
| 207 | + | } | |
| 208 | + | if failure == nil { | |
| 209 | + | m.mu.Lock() | |
| 210 | + | m.clean[repo] = generation | |
| 211 | + | m.mu.Unlock() | |
| 212 | + | } | |
| 213 | + | if m.cfg.Bus != nil { | |
| 214 | + | if transition.FailedEvent { | |
| 215 | + | 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}) | |
| 216 | + | } | |
| 217 | + | if transition.Recovered { | |
| 218 | + | m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt}) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr | |
| 222 | + | } | |
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type mirrorFlight struct { | |||
| 15 | + | pending, force, manual bool | |||
| 16 | + | pendingForce bool | |||
| 17 | + | done chan struct{} | |||
| 18 | + | completed bool | |||
| 19 | + | result ports.MirrorSyncResult | |||
| 20 | + | err error | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | var ( | |||
| 24 | + | _ ports.MirrorService = (*Mirrors)(nil) | |||
| 25 | + | _ ports.MirrorTrigger = (*Mirrors)(nil) | |||
| 26 | + | _ ports.MirrorWarner = (*Mirrors)(nil) | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // Sync waits for its own run; overlapping requests coalesce into one sticky rerun. | |||
| 30 | + | func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) { | |||
| 31 | + | if err := m.admin(ctx, p, repo); err != nil { | |||
| 32 | + | return ports.MirrorSyncResult{}, err | |||
| 33 | + | } | |||
| 34 | + | m.Start() | |||
| 35 | + | f, queued, err := m.enqueue(repo, force, true) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return ports.MirrorSyncResult{}, err | |||
| 38 | + | } | |||
| 39 | + | if queued { | |||
| 40 | + | return ports.MirrorSyncResult{Queued: true}, nil | |||
| 41 | + | } | |||
| 42 | + | select { | |||
| 43 | + | case <-m.ctx.Done(): | |||
| 44 | + | return ports.MirrorSyncResult{}, m.ctx.Err() | |||
| 45 | + | case <-ctx.Done(): | |||
| 46 | + | return ports.MirrorSyncResult{}, ctx.Err() | |||
| 47 | + | case <-f.done: | |||
| 48 | + | return f.result, f.err | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // SyncAll requests a sync for every repository as an owner. | |||
| 53 | + | func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) { | |||
| 54 | + | if err := mirrorOwner(p); err != nil { | |||
| 55 | + | return nil, err | |||
| 56 | + | } | |||
| 57 | + | repos, err := m.cfg.Meta.List(ctx) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return nil, err | |||
| 60 | + | } | |||
| 61 | + | out := make([]ports.MirrorSyncResult, 0, len(repos)) | |||
| 62 | + | for _, r := range repos { | |||
| 63 | + | v, err := m.Sync(ctx, p, r.Name, force) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return out, err | |||
| 66 | + | } | |||
| 67 | + | out = append(out, v) | |||
| 68 | + | } | |||
| 69 | + | return out, nil | |||
| 70 | + | } | |||
| 71 | + | func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlight, bool, error) { | |||
| 72 | + | m.mu.Lock() | |||
| 73 | + | defer m.mu.Unlock() | |||
| 74 | + | if m.closed { | |||
| 75 | + | return nil, false, context.Canceled | |||
| 76 | + | } | |||
| 77 | + | if f := m.flights[repo]; f != nil { | |||
| 78 | + | f.pending = true | |||
| 79 | + | f.pendingForce = f.pendingForce || force | |||
| 80 | + | return f, true, nil | |||
| 81 | + | } | |||
| 82 | + | f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})} | |||
| 83 | + | m.flights[repo] = f | |||
| 84 | + | select { | |||
| 85 | + | case m.jobs <- repo: | |||
| 86 | + | return f, false, nil | |||
| 87 | + | default: | |||
| 88 | + | delete(m.flights, repo) | |||
| 89 | + | return nil, false, domain.ErrResourceExhausted | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | func (m *Mirrors) run(repo domain.RepoName) { | |||
| 93 | + | m.mu.Lock() | |||
| 94 | + | f := m.flights[repo] | |||
| 95 | + | m.mu.Unlock() | |||
| 96 | + | for { | |||
| 97 | + | result, err := m.attempt(m.ctx, repo, f.force, f.manual) | |||
| 98 | + | m.mu.Lock() | |||
| 99 | + | if !f.completed { | |||
| 100 | + | f.result, f.err = result, err | |||
| 101 | + | f.completed = true | |||
| 102 | + | close(f.done) | |||
| 103 | + | } | |||
| 104 | + | if !f.pending || m.closed { | |||
| 105 | + | delete(m.flights, repo) | |||
| 106 | + | m.mu.Unlock() | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | f.force, f.manual = f.pendingForce, false | |||
| 110 | + | f.pending, f.pendingForce = false, false | |||
| 111 | + | m.mu.Unlock() | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (m *Mirrors) syncOnce(ctx context.Context, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) { | |||
| 116 | + | return m.attempt(ctx, repo, force, true) | |||
| 117 | + | } | |||
| 118 | + | func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) { | |||
| 119 | + | m.configMu.RLock() | |||
| 120 | + | defer m.configMu.RUnlock() | |||
| 121 | + | meta, err := m.cfg.Meta.Get(ctx, repo) | |||
| 122 | + | if err != nil { | |||
| 123 | + | return ports.MirrorSyncResult{}, err | |||
| 124 | + | } | |||
| 125 | + | t, err := m.target(ctx, repo) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return ports.MirrorSyncResult{}, err | |||
| 128 | + | } | |||
| 129 | + | prev, err := m.status(ctx, repo) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return ports.MirrorSyncResult{}, err | |||
| 132 | + | } | |||
| 133 | + | now := m.cfg.Clock.Now().UTC() | |||
| 134 | + | if !t.Enabled { | |||
| 135 | + | s := domain.MirrorDisable(prev, now) | |||
| 136 | + | if prev.State != domain.MirrorDisabled { | |||
| 137 | + | err = m.cfg.Store.PutStatus(ctx, s) | |||
| 138 | + | } | |||
| 139 | + | return ports.MirrorSyncResult{Status: s}, err | |||
| 140 | + | } | |||
| 141 | + | if !force && (prev.State == domain.MirrorPaused || (!manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) { | |||
| 142 | + | return ports.MirrorSyncResult{Status: prev}, nil | |||
| 143 | + | } | |||
| 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)) | |||
| 146 | + | } | |||
| 147 | + | m.mu.Lock() | |||
| 148 | + | generation := m.dirty[repo] | |||
| 149 | + | m.mu.Unlock() | |||
| 150 | + | refs, runErr := m.cfg.Reader.ListRefs(ctx, repo) | |||
| 151 | + | var selected []domain.MirrorRef | |||
| 152 | + | if runErr == nil { | |||
| 153 | + | branch, _, e := m.cfg.Git.DefaultBranch(ctx, repo) | |||
| 154 | + | runErr = e | |||
| 155 | + | if e == nil { | |||
| 156 | + | selected = domain.MirrorRefspecs(refs, branch, t.AllBranches) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | noOp := false | |||
| 160 | + | if runErr == nil && !force { | |||
| 161 | + | snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo) | |||
| 162 | + | if e != nil && !errors.Is(e, domain.ErrNotFound) { | |||
| 163 | + | return ports.MirrorSyncResult{}, e | |||
| 164 | + | } | |||
| 165 | + | noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches) | |||
| 166 | + | } | |||
| 167 | + | 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 | + | } | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | if ctx.Err() != nil { | |||
| 182 | + | return ports.MirrorSyncResult{}, ctx.Err() | |||
| 183 | + | } | |||
| 184 | + | var failure *domain.MirrorError | |||
| 185 | + | 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} | |||
| 191 | + | runErr = failure | |||
| 192 | + | if m.cfg.Sealer == nil { | |||
| 193 | + | runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now) | |||
| 197 | + | if failure != nil && m.cfg.Sealer == nil { | |||
| 198 | + | transition.Status.Reason = failure.Detail | |||
| 199 | + | } | |||
| 200 | + | if failure == nil { | |||
| 201 | + | if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil { | |||
| 202 | + | return ports.MirrorSyncResult{}, err | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | if err := m.cfg.Store.PutStatus(ctx, transition.Status); err != nil { | |||
| 206 | + | return ports.MirrorSyncResult{}, err | |||
| 207 | + | } | |||
| 208 | + | if failure == nil { | |||
| 209 | + | m.mu.Lock() | |||
| 210 | + | m.clean[repo] = generation | |||
| 211 | + | m.mu.Unlock() | |||
| 212 | + | } | |||
| 213 | + | if m.cfg.Bus != nil { | |||
| 214 | + | if transition.FailedEvent { | |||
| 215 | + | 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}) | |||
| 216 | + | } | |||
| 217 | + | if transition.Recovered { | |||
| 218 | + | m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: meta.Visibility, CreatedAt: meta.CreatedAt}) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr | |||
| 222 | + | } | |||
A internal/usecase/mirrors_test.go +433 -0
| @@ -0,0 +1,433 @@ | |||
| 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 | + | }() | |
| 140 | + | } | |
| 141 | + | wg.Wait() | |
| 142 | + | rerun := make(chan struct{}) | |
| 143 | + | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(rerun) }).Return(ports.MirrorPushResult{}, nil).Once() | |
| 144 | + | close(release) | |
| 145 | + | require.NoError(t, <-done) | |
| 146 | + | select { | |
| 147 | + | case <-rerun: | |
| 148 | + | case <-time.After(time.Second): | |
| 149 | + | t.Fatal("coalesced force was lost") | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) { | |
| 153 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Clock: func() *mocks.Clock { c := mocks.NewClock(t); c.On("Now").Return(now); return c }()}) | |
| 154 | + | _, err := m.ShowKey(context.Background(), domain.Anonymous()) | |
| 155 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 156 | + | _, err = m.ShowKey(context.Background(), domain.Root()) | |
| 157 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 158 | + | require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY") | |
| 159 | + | _, err = m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead)) | |
| 160 | + | require.True(t, errors.Is(err, domain.ErrForbidden)) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func mirrorOID(t *testing.T) domain.ObjectID { | |
| 164 | + | t.Helper() | |
| 165 | + | id, err := domain.ParseObjectID(oidA) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | return id | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) { | |
| 171 | + | f := newMirrorFixture(t) | |
| 172 | + | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once() | |
| 173 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 174 | + | require.Error(t, err) | |
| 175 | + | require.Equal(t, domain.MirrorPaused, v.Status.State) | |
| 176 | + | for i := 0; i < 3; i++ { | |
| 177 | + | f.advance(time.Hour) | |
| 178 | + | f.m.ScheduleNow(context.Background()) | |
| 179 | + | } | |
| 180 | + | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |
| 181 | + | _, err = f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools", Cron: "*/5 * * * *"}) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | f.mu.Lock() | |
| 184 | + | s := f.status | |
| 185 | + | events := len(f.events) | |
| 186 | + | f.mu.Unlock() | |
| 187 | + | require.Equal(t, domain.MirrorOK, s.State) | |
| 188 | + | require.Zero(t, s.ConsecutiveFailures) | |
| 189 | + | require.Equal(t, 1, events) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) { | |
| 193 | + | keys, sealer, clock := mocks.NewMirrorKeyStore(t), mocks.NewSecretSealer(t), mocks.NewClock(t) | |
| 194 | + | clock.On("Now").Return(now) | |
| 195 | + | keys.On("GetMirrorKey", mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |
| 196 | + | var seeds [][]byte | |
| 197 | + | sealer.On("Seal", mock.Anything, []byte(domain.MirrorKeyPath)).Return(func(seed, aad []byte) []byte { | |
| 198 | + | seeds = append(seeds, append([]byte(nil), seed...)) | |
| 199 | + | return append([]byte("sealed:"), seed...) | |
| 200 | + | }).Twice() | |
| 201 | + | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, true).Return(nil).Once() | |
| 202 | + | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, false).Return(nil).Once() | |
| 203 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Keys: keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"}) | |
| 204 | + | first, err := m.ShowKey(context.Background(), domain.Root()) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | cached, err := m.ShowKey(context.Background(), domain.Root()) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | require.Equal(t, first, cached) | |
| 209 | + | rotated, err := m.RotateKey(context.Background(), domain.Root()) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | require.NotEqual(t, first.Fingerprint, rotated.Fingerprint) | |
| 212 | + | require.Contains(t, rotated.AuthorizedKey, "ermite-mirror@forge.example.com") | |
| 213 | + | require.Len(t, seeds, 2) | |
| 214 | + | require.NotEqual(t, seeds[0], seeds[1]) | |
| 215 | + | require.Len(t, seeds[0], ed25519.SeedSize) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestMirrorsPushRecordNotification(t *testing.T) { | |
| 219 | + | f := newPushFixture(t) | |
| 220 | + | n := name(t, "tools") | |
| 221 | + | trigger := mocks.NewMirrorTrigger(t) | |
| 222 | + | updates := []domain.RefUpdate{} | |
| 223 | + | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |
| 224 | + | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |
| 225 | + | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |
| 226 | + | f.sched.On("Schedule", mock.Anything, n).Return() | |
| 227 | + | notified := make(chan struct{}) | |
| 228 | + | trigger.On("OnPush", mock.Anything, n, updates).Run(func(mock.Arguments) { close(notified) }).Return().Once() | |
| 229 | + | require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates)) | |
| 230 | + | <-notified | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestMirrorsCronDueAndDirtySkip(t *testing.T) { | |
| 234 | + | f := newMirrorFixture(t) | |
| 235 | + | // Establish a successful snapshot; subsequent clean cron ticks need no refs. | |
| 236 | + | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |
| 237 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 238 | + | require.NoError(t, err) | |
| 239 | + | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |
| 240 | + | f.mu.Lock() | |
| 241 | + | f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute) | |
| 242 | + | f.mu.Unlock() | |
| 243 | + | f.m.ScheduleNow(context.Background()) | |
| 244 | + | f.advance(4 * time.Minute) | |
| 245 | + | f.m.ScheduleNow(context.Background()) | |
| 246 | + | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |
| 247 | + | f.m.OnPush(context.Background(), f.repo, nil) | |
| 248 | + | read := make(chan struct{}) | |
| 249 | + | f.reader.ExpectedCalls = nil | |
| 250 | + | 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() | |
| 251 | + | // Same minute does not run, even though dirty. | |
| 252 | + | f.m.ScheduleNow(context.Background()) | |
| 253 | + | select { | |
| 254 | + | case <-read: | |
| 255 | + | t.Fatal("same-minute cron reran") | |
| 256 | + | default: | |
| 257 | + | } | |
| 258 | + | f.advance(5 * time.Minute) | |
| 259 | + | f.m.ScheduleNow(context.Background()) | |
| 260 | + | select { | |
| 261 | + | case <-read: | |
| 262 | + | case <-time.After(time.Second): | |
| 263 | + | t.Fatal("due dirty repository not examined") | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestMirrorsAuthorizationMatrix(t *testing.T) { | |
| 268 | + | f := newMirrorFixture(t) | |
| 269 | + | ctx := context.Background() | |
| 270 | + | reader := tokenPrincipal(domain.ScopeRepoRead) | |
| 271 | + | admin := tokenPrincipal(domain.ScopeRepoAdmin) | |
| 272 | + | _, err := f.m.GetGlobalConfig(ctx, reader) | |
| 273 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 274 | + | _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{}) | |
| 275 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 276 | + | _, err = f.m.ListStatus(ctx, reader) | |
| 277 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 278 | + | _, err = f.m.RotateKey(ctx, admin) | |
| 279 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 280 | + | // Key show deliberately requires authentication, not owner rights. | |
| 281 | + | _, err = f.m.ShowKey(ctx, reader) | |
| 282 | + | require.NoError(t, err) | |
| 283 | + | _, err = f.m.ShowKey(ctx, domain.Anonymous()) | |
| 284 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 285 | + | _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo}) | |
| 286 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 287 | + | err = f.m.RemoveRepoMirror(ctx, reader, f.repo) | |
| 288 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 289 | + | _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true) | |
| 290 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 291 | + | _, err = f.m.Sync(ctx, reader, f.repo, false) | |
| 292 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 293 | + | f.mu.Lock() | |
| 294 | + | f.status.Detail, f.status.Reason = "admin detail", "admin reason" | |
| 295 | + | f.mu.Unlock() | |
| 296 | + | view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo) | |
| 297 | + | require.NoError(t, err) | |
| 298 | + | require.Empty(t, view.Status.Detail) | |
| 299 | + | require.Empty(t, view.Status.Reason) | |
| 300 | + | require.Empty(t, view.Target.URL) | |
| 301 | + | view, err = f.m.GetStatus(ctx, admin, f.repo) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | require.Equal(t, "admin detail", view.Status.Detail) | |
| 304 | + | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |
| 305 | + | _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo}) | |
| 306 | + | require.NoError(t, err) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestMirrorsPrivateStatusAuthorization(t *testing.T) { | |
| 310 | + | f := newMirrorFixture(t) | |
| 311 | + | meta := mocks.NewRepoMetaStore(t) | |
| 312 | + | meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPrivate}, nil) | |
| 313 | + | clock := mocks.NewClock(t) | |
| 314 | + | clock.On("Now").Return(now) | |
| 315 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Meta: meta, Store: f.store, Clock: clock}) | |
| 316 | + | _, err := m.GetStatus(context.Background(), domain.Anonymous(), f.repo) | |
| 317 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 318 | + | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo) | |
| 319 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 320 | + | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo) | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func TestMirrorsCloseCancelsWorker(t *testing.T) { | |
| 325 | + | f := newMirrorFixture(t) | |
| 326 | + | entered, exited := make(chan struct{}), make(chan struct{}) | |
| 327 | + | 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() | |
| 328 | + | done := make(chan error, 1) | |
| 329 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |
| 330 | + | <-entered | |
| 331 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 332 | + | defer cancel() | |
| 333 | + | f.m.Close(ctx) | |
| 334 | + | require.NoError(t, ctx.Err()) | |
| 335 | + | select { | |
| 336 | + | case <-exited: | |
| 337 | + | default: | |
| 338 | + | t.Fatal("Close returned before worker exited") | |
| 339 | + | } | |
| 340 | + | require.ErrorIs(t, <-done, context.Canceled) | |
| 341 | + | f.m.Close(ctx) // idempotent; Start cannot resurrect workers. | |
| 342 | + | f.m.Start() | |
| 343 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |
| 344 | + | require.ErrorIs(t, err, context.Canceled) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func TestMirrorsCronBackoff(t *testing.T) { | |
| 348 | + | f := newMirrorFixture(t) | |
| 349 | + | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |
| 350 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |
| 351 | + | require.Error(t, err) | |
| 352 | + | f.advance(time.Minute) | |
| 353 | + | f.m.ScheduleNow(context.Background()) | |
| 354 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |
| 355 | + | attempted := make(chan struct{}) | |
| 356 | + | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(attempted) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |
| 357 | + | f.advance(4 * time.Minute) | |
| 358 | + | f.m.ScheduleNow(context.Background()) | |
| 359 | + | select { | |
| 360 | + | case <-attempted: | |
| 361 | + | case <-time.After(time.Second): | |
| 362 | + | t.Fatal("retry did not fire at backoff deadline") | |
| 363 | + | } | |
| 364 | + | // Cleanup joins the worker before mock verification. | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func TestMirrorsRecordDoesNotWaitForTrigger(t *testing.T) { | |
| 368 | + | f := newPushFixture(t) | |
| 369 | + | n := name(t, "tools") | |
| 370 | + | trigger := mocks.NewMirrorTrigger(t) | |
| 371 | + | entered, release, exited := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |
| 372 | + | trigger.On("OnPush", mock.Anything, n, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release; close(exited) }).Return().Once() | |
| 373 | + | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |
| 374 | + | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |
| 375 | + | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |
| 376 | + | f.sched.On("Schedule", mock.Anything, n).Return() | |
| 377 | + | returned := make(chan error, 1) | |
| 378 | + | go func() { returned <- f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, nil) }() | |
| 379 | + | <-entered | |
| 380 | + | select { | |
| 381 | + | case err := <-returned: | |
| 382 | + | require.NoError(t, err) | |
| 383 | + | case <-time.After(time.Second): | |
| 384 | + | close(release) | |
| 385 | + | <-exited | |
| 386 | + | t.Fatal("Record blocked on mirror trigger") | |
| 387 | + | } | |
| 388 | + | close(release) | |
| 389 | + | <-exited | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) { | |
| 393 | + | f := newMirrorFixture(t) | |
| 394 | + | var logs bytes.Buffer | |
| 395 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |
| 396 | + | credentialURL := "ssh://alice:secret-password@example.com/acme/tools" | |
| 397 | + | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL}) | |
| 398 | + | require.Error(t, err) | |
| 399 | + | require.NotContains(t, err.Error(), "secret-password") | |
| 400 | + | require.NotContains(t, err.Error(), "alice") | |
| 401 | + | 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() | |
| 402 | + | _, err = f.m.Sync(ctx, domain.Root(), f.repo, true) | |
| 403 | + | require.Error(t, err) | |
| 404 | + | require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER") | |
| 405 | + | require.NotContains(t, err.Error(), "secret-password") | |
| 406 | + | require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER") | |
| 407 | + | require.NotContains(t, logs.String(), "secret-password") | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func TestMirrorsFileURLTestOption(t *testing.T) { | |
| 411 | + | for _, allowed := range []bool{false, true} { | |
| 412 | + | t.Run(fmt.Sprint(allowed), func(t *testing.T) { | |
| 413 | + | store, meta, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewClock(t) | |
| 414 | + | repo := name(t, "tools") | |
| 415 | + | clock.On("Now").Return(now) | |
| 416 | + | meta.On("Get", mock.Anything, repo).Return(domain.Repo{Name: repo}, nil) | |
| 417 | + | global := domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "file:///tmp/remotes/{repo}", Cron: "*/5 * * * *"} | |
| 418 | + | store.On("GetGlobal", mock.Anything).Return(global, nil).Maybe() | |
| 419 | + | store.On("GetRepoConfig", mock.Anything, repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe() | |
| 420 | + | store.On("GetStatus", mock.Anything, repo).Return(domain.MirrorStatus{Repo: repo}, nil).Maybe() | |
| 421 | + | store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 422 | + | store.On("PutStatus", mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 423 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Store: store, Meta: meta, Clock: clock, AllowAnyURL: allowed}) | |
| 424 | + | target, err := m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"}) | |
| 425 | + | if allowed { | |
| 426 | + | require.NoError(t, err) | |
| 427 | + | require.Equal(t, "file:///tmp/remotes/tools", target.URL) | |
| 428 | + | } else { | |
| 429 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 430 | + | } | |
| 431 | + | }) | |
| 432 | + | } | |
| 433 | + | } | |
| @@ -0,0 +1,433 @@ | |||||
| 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 | + | }() | |||
| 140 | + | } | |||
| 141 | + | wg.Wait() | |||
| 142 | + | rerun := make(chan struct{}) | |||
| 143 | + | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(rerun) }).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 144 | + | close(release) | |||
| 145 | + | require.NoError(t, <-done) | |||
| 146 | + | select { | |||
| 147 | + | case <-rerun: | |||
| 148 | + | case <-time.After(time.Second): | |||
| 149 | + | t.Fatal("coalesced force was lost") | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) { | |||
| 153 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Clock: func() *mocks.Clock { c := mocks.NewClock(t); c.On("Now").Return(now); return c }()}) | |||
| 154 | + | _, err := m.ShowKey(context.Background(), domain.Anonymous()) | |||
| 155 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 156 | + | _, err = m.ShowKey(context.Background(), domain.Root()) | |||
| 157 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 158 | + | require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY") | |||
| 159 | + | _, err = m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead)) | |||
| 160 | + | require.True(t, errors.Is(err, domain.ErrForbidden)) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func mirrorOID(t *testing.T) domain.ObjectID { | |||
| 164 | + | t.Helper() | |||
| 165 | + | id, err := domain.ParseObjectID(oidA) | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | return id | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) { | |||
| 171 | + | f := newMirrorFixture(t) | |||
| 172 | + | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once() | |||
| 173 | + | v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 174 | + | require.Error(t, err) | |||
| 175 | + | require.Equal(t, domain.MirrorPaused, v.Status.State) | |||
| 176 | + | for i := 0; i < 3; i++ { | |||
| 177 | + | f.advance(time.Hour) | |||
| 178 | + | f.m.ScheduleNow(context.Background()) | |||
| 179 | + | } | |||
| 180 | + | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |||
| 181 | + | _, err = f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools", Cron: "*/5 * * * *"}) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | f.mu.Lock() | |||
| 184 | + | s := f.status | |||
| 185 | + | events := len(f.events) | |||
| 186 | + | f.mu.Unlock() | |||
| 187 | + | require.Equal(t, domain.MirrorOK, s.State) | |||
| 188 | + | require.Zero(t, s.ConsecutiveFailures) | |||
| 189 | + | require.Equal(t, 1, events) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) { | |||
| 193 | + | keys, sealer, clock := mocks.NewMirrorKeyStore(t), mocks.NewSecretSealer(t), mocks.NewClock(t) | |||
| 194 | + | clock.On("Now").Return(now) | |||
| 195 | + | keys.On("GetMirrorKey", mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once() | |||
| 196 | + | var seeds [][]byte | |||
| 197 | + | sealer.On("Seal", mock.Anything, []byte(domain.MirrorKeyPath)).Return(func(seed, aad []byte) []byte { | |||
| 198 | + | seeds = append(seeds, append([]byte(nil), seed...)) | |||
| 199 | + | return append([]byte("sealed:"), seed...) | |||
| 200 | + | }).Twice() | |||
| 201 | + | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, true).Return(nil).Once() | |||
| 202 | + | keys.On("PutMirrorKey", mock.Anything, mock.Anything, now, false).Return(nil).Once() | |||
| 203 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Keys: keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"}) | |||
| 204 | + | first, err := m.ShowKey(context.Background(), domain.Root()) | |||
| 205 | + | require.NoError(t, err) | |||
| 206 | + | cached, err := m.ShowKey(context.Background(), domain.Root()) | |||
| 207 | + | require.NoError(t, err) | |||
| 208 | + | require.Equal(t, first, cached) | |||
| 209 | + | rotated, err := m.RotateKey(context.Background(), domain.Root()) | |||
| 210 | + | require.NoError(t, err) | |||
| 211 | + | require.NotEqual(t, first.Fingerprint, rotated.Fingerprint) | |||
| 212 | + | require.Contains(t, rotated.AuthorizedKey, "ermite-mirror@forge.example.com") | |||
| 213 | + | require.Len(t, seeds, 2) | |||
| 214 | + | require.NotEqual(t, seeds[0], seeds[1]) | |||
| 215 | + | require.Len(t, seeds[0], ed25519.SeedSize) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func TestMirrorsPushRecordNotification(t *testing.T) { | |||
| 219 | + | f := newPushFixture(t) | |||
| 220 | + | n := name(t, "tools") | |||
| 221 | + | trigger := mocks.NewMirrorTrigger(t) | |||
| 222 | + | updates := []domain.RefUpdate{} | |||
| 223 | + | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |||
| 224 | + | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |||
| 225 | + | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |||
| 226 | + | f.sched.On("Schedule", mock.Anything, n).Return() | |||
| 227 | + | notified := make(chan struct{}) | |||
| 228 | + | trigger.On("OnPush", mock.Anything, n, updates).Run(func(mock.Arguments) { close(notified) }).Return().Once() | |||
| 229 | + | require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates)) | |||
| 230 | + | <-notified | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestMirrorsCronDueAndDirtySkip(t *testing.T) { | |||
| 234 | + | f := newMirrorFixture(t) | |||
| 235 | + | // Establish a successful snapshot; subsequent clean cron ticks need no refs. | |||
| 236 | + | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once() | |||
| 237 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 238 | + | require.NoError(t, err) | |||
| 239 | + | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |||
| 240 | + | f.mu.Lock() | |||
| 241 | + | f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute) | |||
| 242 | + | f.mu.Unlock() | |||
| 243 | + | f.m.ScheduleNow(context.Background()) | |||
| 244 | + | f.advance(4 * time.Minute) | |||
| 245 | + | f.m.ScheduleNow(context.Background()) | |||
| 246 | + | f.reader.AssertNumberOfCalls(t, "ListRefs", 1) | |||
| 247 | + | f.m.OnPush(context.Background(), f.repo, nil) | |||
| 248 | + | read := make(chan struct{}) | |||
| 249 | + | f.reader.ExpectedCalls = nil | |||
| 250 | + | 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() | |||
| 251 | + | // Same minute does not run, even though dirty. | |||
| 252 | + | f.m.ScheduleNow(context.Background()) | |||
| 253 | + | select { | |||
| 254 | + | case <-read: | |||
| 255 | + | t.Fatal("same-minute cron reran") | |||
| 256 | + | default: | |||
| 257 | + | } | |||
| 258 | + | f.advance(5 * time.Minute) | |||
| 259 | + | f.m.ScheduleNow(context.Background()) | |||
| 260 | + | select { | |||
| 261 | + | case <-read: | |||
| 262 | + | case <-time.After(time.Second): | |||
| 263 | + | t.Fatal("due dirty repository not examined") | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestMirrorsAuthorizationMatrix(t *testing.T) { | |||
| 268 | + | f := newMirrorFixture(t) | |||
| 269 | + | ctx := context.Background() | |||
| 270 | + | reader := tokenPrincipal(domain.ScopeRepoRead) | |||
| 271 | + | admin := tokenPrincipal(domain.ScopeRepoAdmin) | |||
| 272 | + | _, err := f.m.GetGlobalConfig(ctx, reader) | |||
| 273 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 274 | + | _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{}) | |||
| 275 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 276 | + | _, err = f.m.ListStatus(ctx, reader) | |||
| 277 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 278 | + | _, err = f.m.RotateKey(ctx, admin) | |||
| 279 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 280 | + | // Key show deliberately requires authentication, not owner rights. | |||
| 281 | + | _, err = f.m.ShowKey(ctx, reader) | |||
| 282 | + | require.NoError(t, err) | |||
| 283 | + | _, err = f.m.ShowKey(ctx, domain.Anonymous()) | |||
| 284 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 285 | + | _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo}) | |||
| 286 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 287 | + | err = f.m.RemoveRepoMirror(ctx, reader, f.repo) | |||
| 288 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 289 | + | _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true) | |||
| 290 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 291 | + | _, err = f.m.Sync(ctx, reader, f.repo, false) | |||
| 292 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 293 | + | f.mu.Lock() | |||
| 294 | + | f.status.Detail, f.status.Reason = "admin detail", "admin reason" | |||
| 295 | + | f.mu.Unlock() | |||
| 296 | + | view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo) | |||
| 297 | + | require.NoError(t, err) | |||
| 298 | + | require.Empty(t, view.Status.Detail) | |||
| 299 | + | require.Empty(t, view.Status.Reason) | |||
| 300 | + | require.Empty(t, view.Target.URL) | |||
| 301 | + | view, err = f.m.GetStatus(ctx, admin, f.repo) | |||
| 302 | + | require.NoError(t, err) | |||
| 303 | + | require.Equal(t, "admin detail", view.Status.Detail) | |||
| 304 | + | f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Once() | |||
| 305 | + | _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo}) | |||
| 306 | + | require.NoError(t, err) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestMirrorsPrivateStatusAuthorization(t *testing.T) { | |||
| 310 | + | f := newMirrorFixture(t) | |||
| 311 | + | meta := mocks.NewRepoMetaStore(t) | |||
| 312 | + | meta.On("Get", mock.Anything, f.repo).Return(domain.Repo{Name: f.repo, Visibility: domain.VisibilityPrivate}, nil) | |||
| 313 | + | clock := mocks.NewClock(t) | |||
| 314 | + | clock.On("Now").Return(now) | |||
| 315 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Meta: meta, Store: f.store, Clock: clock}) | |||
| 316 | + | _, err := m.GetStatus(context.Background(), domain.Anonymous(), f.repo) | |||
| 317 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 318 | + | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo) | |||
| 319 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 320 | + | _, err = m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo) | |||
| 321 | + | require.NoError(t, err) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func TestMirrorsCloseCancelsWorker(t *testing.T) { | |||
| 325 | + | f := newMirrorFixture(t) | |||
| 326 | + | entered, exited := make(chan struct{}), make(chan struct{}) | |||
| 327 | + | 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() | |||
| 328 | + | done := make(chan error, 1) | |||
| 329 | + | go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }() | |||
| 330 | + | <-entered | |||
| 331 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 332 | + | defer cancel() | |||
| 333 | + | f.m.Close(ctx) | |||
| 334 | + | require.NoError(t, ctx.Err()) | |||
| 335 | + | select { | |||
| 336 | + | case <-exited: | |||
| 337 | + | default: | |||
| 338 | + | t.Fatal("Close returned before worker exited") | |||
| 339 | + | } | |||
| 340 | + | require.ErrorIs(t, <-done, context.Canceled) | |||
| 341 | + | f.m.Close(ctx) // idempotent; Start cannot resurrect workers. | |||
| 342 | + | f.m.Start() | |||
| 343 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) | |||
| 344 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func TestMirrorsCronBackoff(t *testing.T) { | |||
| 348 | + | f := newMirrorFixture(t) | |||
| 349 | + | f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |||
| 350 | + | _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false) | |||
| 351 | + | require.Error(t, err) | |||
| 352 | + | f.advance(time.Minute) | |||
| 353 | + | f.m.ScheduleNow(context.Background()) | |||
| 354 | + | f.push.AssertNumberOfCalls(t, "Push", 1) | |||
| 355 | + | attempted := make(chan struct{}) | |||
| 356 | + | f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(attempted) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once() | |||
| 357 | + | f.advance(4 * time.Minute) | |||
| 358 | + | f.m.ScheduleNow(context.Background()) | |||
| 359 | + | select { | |||
| 360 | + | case <-attempted: | |||
| 361 | + | case <-time.After(time.Second): | |||
| 362 | + | t.Fatal("retry did not fire at backoff deadline") | |||
| 363 | + | } | |||
| 364 | + | // Cleanup joins the worker before mock verification. | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func TestMirrorsRecordDoesNotWaitForTrigger(t *testing.T) { | |||
| 368 | + | f := newPushFixture(t) | |||
| 369 | + | n := name(t, "tools") | |||
| 370 | + | trigger := mocks.NewMirrorTrigger(t) | |||
| 371 | + | entered, release, exited := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |||
| 372 | + | trigger.On("OnPush", mock.Anything, n, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release; close(exited) }).Return().Once() | |||
| 373 | + | f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil) | |||
| 374 | + | f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil) | |||
| 375 | + | f.bus.On("Publish", mock.Anything, mock.Anything).Return() | |||
| 376 | + | f.sched.On("Schedule", mock.Anything, n).Return() | |||
| 377 | + | returned := make(chan error, 1) | |||
| 378 | + | go func() { returned <- f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, nil) }() | |||
| 379 | + | <-entered | |||
| 380 | + | select { | |||
| 381 | + | case err := <-returned: | |||
| 382 | + | require.NoError(t, err) | |||
| 383 | + | case <-time.After(time.Second): | |||
| 384 | + | close(release) | |||
| 385 | + | <-exited | |||
| 386 | + | t.Fatal("Record blocked on mirror trigger") | |||
| 387 | + | } | |||
| 388 | + | close(release) | |||
| 389 | + | <-exited | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) { | |||
| 393 | + | f := newMirrorFixture(t) | |||
| 394 | + | var logs bytes.Buffer | |||
| 395 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |||
| 396 | + | credentialURL := "ssh://alice:secret-password@example.com/acme/tools" | |||
| 397 | + | _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL}) | |||
| 398 | + | require.Error(t, err) | |||
| 399 | + | require.NotContains(t, err.Error(), "secret-password") | |||
| 400 | + | require.NotContains(t, err.Error(), "alice") | |||
| 401 | + | 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() | |||
| 402 | + | _, err = f.m.Sync(ctx, domain.Root(), f.repo, true) | |||
| 403 | + | require.Error(t, err) | |||
| 404 | + | require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER") | |||
| 405 | + | require.NotContains(t, err.Error(), "secret-password") | |||
| 406 | + | require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER") | |||
| 407 | + | require.NotContains(t, logs.String(), "secret-password") | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | func TestMirrorsFileURLTestOption(t *testing.T) { | |||
| 411 | + | for _, allowed := range []bool{false, true} { | |||
| 412 | + | t.Run(fmt.Sprint(allowed), func(t *testing.T) { | |||
| 413 | + | store, meta, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewClock(t) | |||
| 414 | + | repo := name(t, "tools") | |||
| 415 | + | clock.On("Now").Return(now) | |||
| 416 | + | meta.On("Get", mock.Anything, repo).Return(domain.Repo{Name: repo}, nil) | |||
| 417 | + | global := domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "file:///tmp/remotes/{repo}", Cron: "*/5 * * * *"} | |||
| 418 | + | store.On("GetGlobal", mock.Anything).Return(global, nil).Maybe() | |||
| 419 | + | store.On("GetRepoConfig", mock.Anything, repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe() | |||
| 420 | + | store.On("GetStatus", mock.Anything, repo).Return(domain.MirrorStatus{Repo: repo}, nil).Maybe() | |||
| 421 | + | store.On("PutRepoConfig", mock.Anything, mock.Anything).Return(nil).Maybe() | |||
| 422 | + | store.On("PutStatus", mock.Anything, mock.Anything).Return(nil).Maybe() | |||
| 423 | + | m := usecase.NewMirrors(usecase.MirrorsConfig{Store: store, Meta: meta, Clock: clock, AllowAnyURL: allowed}) | |||
| 424 | + | target, err := m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"}) | |||
| 425 | + | if allowed { | |||
| 426 | + | require.NoError(t, err) | |||
| 427 | + | require.Equal(t, "file:///tmp/remotes/tools", target.URL) | |||
| 428 | + | } else { | |||
| 429 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 430 | + | } | |||
| 431 | + | }) | |||
| 432 | + | } | |||
| 433 | + | } | |||
M internal/usecase/pushes.go +11 -0
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // Pushes are the use cases around a Git push. | |
| 19 | 19 | type Pushes struct { | |
| 20 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 20 | 21 | meta ports.RepoMetaStore | |
| 21 | 22 | repos *Repos | |
| 22 | 23 | git ports.GitRepos | |
| @@ -43,6 +44,12 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||
| 43 | 44 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 47 | + | // WithMirror makes Record notify outbound mirrors. Call before the first push. | |
| 48 | + | func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes { | |
| 49 | + | u.mirror = t | |
| 50 | + | return u | |
| 51 | + | } | |
| 52 | + | ||
| 46 | 53 | // WithFreshener makes Record tell the reader that a repository changed, so that | |
| 47 | 54 | // it serves the push at once. Call it before the first push; it returns u. | |
| 48 | 55 | func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes { | |
| @@ -262,6 +269,10 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 262 | 269 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 263 | 270 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 264 | 271 | }) | |
| 272 | + | if u.mirror != nil { | |
| 273 | + | updates := append(make([]domain.RefUpdate, 0, len(updates)), updates...) | |
| 274 | + | go u.mirror.OnPush(ctx, name, updates) | |
| 275 | + | } | |
| 265 | 276 | u.maint.Schedule(ctx, name) | |
| 266 | 277 | log.Info(). | |
| 267 | 278 | Str("repo", name.String()).Int("refs", len(updates)). | |
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // Pushes are the use cases around a Git push. | 18 | // Pushes are the use cases around a Git push. | ||
| 19 | type Pushes struct { | 19 | type Pushes struct { | ||
| 20 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |||
| 20 | meta ports.RepoMetaStore | 21 | meta ports.RepoMetaStore | ||
| 21 | repos *Repos | 22 | repos *Repos | ||
| 22 | git ports.GitRepos | 23 | git ports.GitRepos | ||
| @@ -43,6 +44,12 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||||
| 43 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | 44 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | ||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 47 | + | // WithMirror makes Record notify outbound mirrors. Call before the first push. | |||
| 48 | + | func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes { | |||
| 49 | + | u.mirror = t | |||
| 50 | + | return u | |||
| 51 | + | } | |||
| 52 | + | ||||
| 46 | // WithFreshener makes Record tell the reader that a repository changed, so that | 53 | // WithFreshener makes Record tell the reader that a repository changed, so that | ||
| 47 | // it serves the push at once. Call it before the first push; it returns u. | 54 | // it serves the push at once. Call it before the first push; it returns u. | ||
| 48 | func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes { | 55 | func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes { | ||
| @@ -262,6 +269,10 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||||
| 262 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | 269 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | ||
| 263 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 270 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
| 264 | }) | 271 | }) | ||
| 272 | + | if u.mirror != nil { | |||
| 273 | + | updates := append(make([]domain.RefUpdate, 0, len(updates)), updates...) | |||
| 274 | + | go u.mirror.OnPush(ctx, name, updates) | |||
| 275 | + | } | |||
| 265 | u.maint.Schedule(ctx, name) | 276 | u.maint.Schedule(ctx, name) | ||
| 266 | log.Info(). | 277 | log.Info(). | ||
| 267 | Str("repo", name.String()).Int("refs", len(updates)). | 278 | Str("repo", name.String()).Int("refs", len(updates)). | ||