1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// pushRetries bounds the retries of a metadata update that loses a race.
19const pushRetries = 5
20
21// Pushes are the use cases around a Git push.
22type Pushes struct {
23 meta ports.RepoMetaStore
24 repos *Repos
25 git ports.GitRepos
26 bus ports.EventBus
27 clock ports.Clock
28 maint ports.MaintenanceScheduler
29 auth ports.Revalidator
30 fresh ports.RepoFreshener // nil: the reader needs no telling
31 ci ports.WorkflowTrigger // nil: no CI
32}
33
34var (
35 _ ports.PushService = (*Pushes)(nil)
36 _ ports.PushHookService = (*Pushes)(nil)
37)
38
39// NewPushes builds the push use cases. Every argument is required: auth
40// re-validates the token of a push when its pre-receive hook runs, maint
41// queues the maintenance that follows a push.
42func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint ports.MaintenanceScheduler, auth ports.Revalidator) *Pushes {
43 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
44}
45
46// 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.
48func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
49 u.fresh = f
50 return u
51}
52
53// WithWorkflowTrigger makes Record start the workflows that match the push.
54// Call it before the first push; it returns u.
55func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
56 u.ci = t
57 return u
58}
59
60// Open decides whether the principal may push to the repository and, with
61// create, creates it when it does not exist yet (private, published as
62// RepoCreated by Repos.Create). create is for the first request of a push only.
63//
64// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
65// cannot read and cannot write, or a missing one it may not create, answers
66// domain.ErrNotFound, so that a private repository never shows itself. One it
67// can read but not write answers domain.ErrForbidden.
68func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
69 if p.IsAnonymous() {
70 return domain.ErrUnauthenticated
71 }
72 repo, err := u.meta.Get(ctx, name)
73 switch {
74 case err == nil:
75 return openExisting(p, repo)
76 case !errors.Is(err, domain.ErrNotFound):
77 return fmt.Errorf("get repository %q: %w", name, err)
78 }
79
80 if !create || !canCreateRepo(p, name) {
81 return domain.ErrNotFound
82 }
83 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
84 switch {
85 case err == nil:
86 return nil // Create published RepoCreated
87 case errors.Is(err, domain.ErrAlreadyExists):
88 // A concurrent first push won the race: this one pushes to its repository.
89 repo, gerr := u.meta.Get(ctx, name)
90 if gerr != nil {
91 return fmt.Errorf("get repository %q: %w", name, gerr)
92 }
93 return openExisting(p, repo)
94 }
95 return err
96}
97
98func openExisting(p domain.Principal, repo domain.Repo) error {
99 switch {
100 case canWrite(p, repo.Name):
101 return nil
102 case canRead(p, repo):
103 return domain.ErrForbidden
104 }
105 return domain.ErrNotFound
106}
107
108// Check is the pre-receive decision. It accepts a push of a principal that
109// may write the repository. A token limited to branch prefixes may only update
110// (create, move or delete) branches under them: the first ref outside them
111// refuses the whole push, tags and every other ref included. Further rules
112// (protected branches, limits) will refuse here too, by returning an error
113// that matches domain.ErrRejected.
114//
115// The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no
116// symbolic ref under refs/heads besides HEAD (ermite never creates one and
117// receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees
118// every ref of the push, so a tag or any other ref is judged like a branch.
119//
120// The token of the push was valid when the request began, but a push can run
121// for long: it is re-validated here, so a token revoked or expired meanwhile
122// stops the push before Git applies it. A failure to tell refuses it too.
123func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
124 if err := u.auth.Revalidate(ctx, p); err != nil {
125 if errors.Is(err, domain.ErrUnauthenticated) {
126 return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
127 }
128 return fmt.Errorf("revalidate token: %w", err)
129 }
130 if !canWrite(p, name) {
131 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
132 }
133 for _, up := range updates {
134 if !p.AllowsRef(up.Ref) {
135 return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
136 domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
137 }
138 }
139 log := zerowrap.FromCtx(ctx)
140 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
141 return nil
142}
143
144// Record is the post-receive step of an accepted push: it sets pushed_at,
145// repairs HEAD when it points to a missing branch, tells the reader that the
146// repository changed, queues the workflows the push triggers, publishes a
147// PushEvent and schedules maintenance. Every
148// step runs even when an earlier one fails; the failures are returned together
149// and the push itself is not affected.
150func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
151 log := zerowrap.FromCtx(ctx)
152 now := u.clock.Now().UTC()
153 var errs []error
154
155 repo, err := touchPushedAt(ctx, u.meta, name, now)
156 if err != nil {
157 errs = append(errs, err)
158 }
159 if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
160 errs = append(errs, err)
161 }
162 if u.fresh != nil {
163 u.fresh.Refresh(ctx, name)
164 }
165 if u.ci != nil {
166 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
167 }
168 u.bus.Publish(ctx, domain.PushEvent{
169 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
170 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
171 })
172 u.maint.Schedule(ctx, name)
173 log.Info().
174 Str("repo", name.String()).Int("refs", len(updates)).
175 Str("principal", p.Label()).Str("token_id", p.TokenID()).
176 Msg("push received")
177 return errors.Join(errs...)
178}
179
180// touchPushedAt stores pushed_at, retrying when another write got in between.
181// A repository deleted meanwhile is not an error. It returns the repository
182// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
183// merge, which moves a branch too, both record through it.
184func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
185 for range pushRetries {
186 repo, err := meta.Get(ctx, name)
187 if errors.Is(err, domain.ErrNotFound) {
188 return domain.Repo{}, nil
189 }
190 if err != nil {
191 return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
192 }
193 if !now.After(repo.PushedAt) {
194 return repo, nil // a later push already recorded
195 }
196 repo.PushedAt = now
197 err = meta.Update(ctx, repo)
198 if errors.Is(err, domain.ErrConflict) {
199 continue
200 }
201 if errors.Is(err, domain.ErrNotFound) {
202 return domain.Repo{}, nil
203 }
204 if err != nil {
205 return repo, fmt.Errorf("update repository %q: %w", name, err)
206 }
207 return repo, nil
208 }
209 return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
210}
211
212// fixDefaultBranch points HEAD to a branch that exists when it points to a
213// missing one: main when it was pushed, else the first pushed branch.
214func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
215 branch, exists, err := u.git.DefaultBranch(ctx, name)
216 if errors.Is(err, domain.ErrNotFound) {
217 return nil // deleted meanwhile
218 }
219 if err != nil {
220 return fmt.Errorf("read default branch of %q: %w", name, err)
221 }
222 if exists {
223 return nil
224 }
225 target := ""
226 for _, up := range updates {
227 b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
228 if !ok || up.IsDelete() {
229 continue
230 }
231 if b == "main" {
232 target = b
233 break
234 }
235 if target == "" {
236 target = b
237 }
238 }
239 if target == "" || target == branch {
240 return nil
241 }
242 err = u.git.SetDefaultBranch(ctx, name, target)
243 if errors.Is(err, domain.ErrNotFound) {
244 return nil // deleted meanwhile
245 }
246 if err != nil {
247 return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
248 }
249 log := zerowrap.FromCtx(ctx)
250 log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
251 return nil
252}