1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
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// MaintenanceScheduler queues repository maintenance. *Maintainer fits.
22type MaintenanceScheduler interface {
23 Schedule(ctx context.Context, name domain.RepoName)
24}
25
26// Revalidator confirms that a principal obtained earlier is still
27// authorized. *Auth fits.
28type Revalidator interface {
29 Revalidate(ctx context.Context, p domain.Principal) error
30}
31
32// Pushes are the use cases around a Git push.
33type Pushes struct {
34 meta ports.RepoMetaStore
35 repos *Repos
36 git ports.GitRepos
37 bus ports.EventBus
38 clock ports.Clock
39 maint MaintenanceScheduler
40 auth Revalidator
41}
42
43// NewPushes builds the push use cases. Every argument is required: auth
44// re-validates the token of a push when its pre-receive hook runs, maint
45// queues the maintenance that follows a push.
46func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint MaintenanceScheduler, auth Revalidator) *Pushes {
47 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
48}
49
50// Open decides whether the principal may push to the repository and, with
51// create, creates it when it does not exist yet (private, published as
52// RepoCreated by Repos.Create). create is for the first request of a push only.
53//
54// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
55// cannot read and cannot write, or a missing one it may not create, answers
56// domain.ErrNotFound, so that a private repository never shows itself. One it
57// can read but not write answers domain.ErrForbidden.
58func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
59 if p.IsAnonymous() {
60 return domain.ErrUnauthenticated
61 }
62 repo, err := u.meta.Get(ctx, name)
63 switch {
64 case err == nil:
65 return openExisting(p, repo)
66 case !errors.Is(err, domain.ErrNotFound):
67 return fmt.Errorf("get repository %q: %w", name, err)
68 }
69
70 if !create || !canWrite(p, name) {
71 return domain.ErrNotFound
72 }
73 _, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
74 switch {
75 case err == nil:
76 return nil // Create published RepoCreated
77 case errors.Is(err, domain.ErrAlreadyExists):
78 // A concurrent first push won the race: this one pushes to its repository.
79 repo, gerr := u.meta.Get(ctx, name)
80 if gerr != nil {
81 return fmt.Errorf("get repository %q: %w", name, gerr)
82 }
83 return openExisting(p, repo)
84 }
85 return err
86}
87
88func openExisting(p domain.Principal, repo domain.Repo) error {
89 switch {
90 case canWrite(p, repo.Name):
91 return nil
92 case canRead(p, repo):
93 return domain.ErrForbidden
94 }
95 return domain.ErrNotFound
96}
97
98// Check is the pre-receive decision. v1 accepts every push of a principal that
99// may write the repository; protected branches and limits will refuse here by
100// returning an error that matches domain.ErrRejected.
101//
102// The token of the push was valid when the request began, but a push can run
103// for long: it is re-validated here, so a token revoked or expired meanwhile
104// stops the push before Git applies it. A failure to tell refuses it too.
105func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
106 if err := u.auth.Revalidate(ctx, p); err != nil {
107 if errors.Is(err, domain.ErrUnauthenticated) {
108 return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
109 }
110 return fmt.Errorf("revalidate token: %w", err)
111 }
112 if !canWrite(p, name) {
113 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
114 }
115 log := zerowrap.FromCtx(ctx)
116 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
117 return nil
118}
119
120// Record is the post-receive step of an accepted push: it sets pushed_at,
121// repairs HEAD when it points to a missing branch, publishes a PushEvent and
122// schedules maintenance. Every step runs even when an earlier one fails; the
123// failures are returned together and the push itself is not affected.
124func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
125 log := zerowrap.FromCtx(ctx)
126 now := u.clock.Now().UTC()
127 var errs []error
128
129 repo, err := touchPushedAt(ctx, u.meta, name, now)
130 if err != nil {
131 errs = append(errs, err)
132 }
133 if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
134 errs = append(errs, err)
135 }
136 u.bus.Publish(ctx, domain.PushEvent{
137 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
138 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
139 })
140 u.maint.Schedule(ctx, name)
141 log.Info().
142 Str("repo", name.String()).Int("refs", len(updates)).
143 Str("principal", p.Label()).Str("token_id", p.TokenID()).
144 Msg("push received")
145 return errors.Join(errs...)
146}
147
148// touchPushedAt stores pushed_at, retrying when another write got in between.
149// A repository deleted meanwhile is not an error. It returns the repository
150// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
151// merge, which moves a branch too, both record through it.
152func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
153 for range pushRetries {
154 repo, err := meta.Get(ctx, name)
155 if errors.Is(err, domain.ErrNotFound) {
156 return domain.Repo{}, nil
157 }
158 if err != nil {
159 return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
160 }
161 if !now.After(repo.PushedAt) {
162 return repo, nil // a later push already recorded
163 }
164 repo.PushedAt = now
165 err = meta.Update(ctx, repo)
166 if errors.Is(err, domain.ErrConflict) {
167 continue
168 }
169 if errors.Is(err, domain.ErrNotFound) {
170 return domain.Repo{}, nil
171 }
172 if err != nil {
173 return repo, fmt.Errorf("update repository %q: %w", name, err)
174 }
175 return repo, nil
176 }
177 return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
178}
179
180// fixDefaultBranch points HEAD to a branch that exists when it points to a
181// missing one: main when it was pushed, else the first pushed branch.
182func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
183 branch, exists, err := u.git.DefaultBranch(ctx, name)
184 if errors.Is(err, domain.ErrNotFound) {
185 return nil // deleted meanwhile
186 }
187 if err != nil {
188 return fmt.Errorf("read default branch of %q: %w", name, err)
189 }
190 if exists {
191 return nil
192 }
193 target := ""
194 for _, up := range updates {
195 b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
196 if !ok || up.IsDelete() {
197 continue
198 }
199 if b == "main" {
200 target = b
201 break
202 }
203 if target == "" {
204 target = b
205 }
206 }
207 if target == "" || target == branch {
208 return nil
209 }
210 err = u.git.SetDefaultBranch(ctx, name, target)
211 if errors.Is(err, domain.ErrNotFound) {
212 return nil // deleted meanwhile
213 }
214 if err != nil {
215 return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
216 }
217 log := zerowrap.FromCtx(ctx)
218 log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
219 return nil
220}