1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package usecase holds the use cases of ermite. It depends on the domain and on
4// the ports only.
5package usecase
6
7import (
8 "context"
9 "errors"
10 "fmt"
11
12 "github.com/bnema/zerowrap"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// Repos are the repository use cases.
19type Repos struct {
20 meta ports.RepoMetaStore
21 git ports.GitRepos
22 bus ports.EventBus
23 clock ports.Clock
24 ci ports.CIRunCleaner // nil: no CI logs to remove
25}
26
27var _ ports.RepoService = (*Repos)(nil)
28
29// NewRepos builds the repository use cases over their ports. Changes are
30// published on bus.
31func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, clock ports.Clock) *Repos {
32 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
33}
34
35// WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
36// before the first request; it returns r.
37func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
38 r.ci = c
39 return r
40}
41
42// Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
43// metadata (which fails with domain.ErrAlreadyExists if the name is taken,
44// also when a concurrent Create wins) and publishes the directory. A failure
45// after the metadata is written removes it again and discards the staged
46// repository.
47//
48// The principal needs repo:write on the name and no branch prefixes: anonymous
49// callers get domain.ErrUnauthenticated, other callers without it (a token
50// limited to some branches included) domain.ErrForbidden.
51func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {
52 if params.Name.IsZero() {
53 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
54 }
55 if p.IsAnonymous() {
56 return domain.Repo{}, domain.ErrUnauthenticated
57 }
58 if !canCreateRepo(p, params.Name) {
59 return domain.Repo{}, domain.ErrForbidden
60 }
61 if err := validateDescription(params.Description); err != nil {
62 return domain.Repo{}, err
63 }
64 topics, err := domain.NormalizeTopics(params.Topics)
65 if err != nil {
66 return domain.Repo{}, err
67 }
68 vis := params.Visibility
69 if vis == "" {
70 vis = domain.VisibilityPrivate
71 }
72 if _, err := domain.ParseVisibility(vis.String()); err != nil {
73 return domain.Repo{}, err
74 }
75 repo := domain.Repo{
76 Name: params.Name,
77 Description: params.Description,
78 Topics: topics,
79 Visibility: vis,
80 CreatedAt: r.clock.Now().UTC(),
81 Settings: domain.DefaultRepoSettings(),
82 }
83
84 log := zerowrap.FromCtx(ctx)
85 // Cleanup must finish even when the caller gives up.
86 cleanup := context.WithoutCancel(ctx)
87
88 id, err := r.git.Stage(ctx)
89 if err != nil {
90 return domain.Repo{}, fmt.Errorf("stage repository %q: %w", repo.Name, err)
91 }
92 if err := r.meta.Create(ctx, repo); err != nil {
93 r.discard(cleanup, id)
94 if errors.Is(err, domain.ErrAlreadyExists) {
95 return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists)
96 }
97 return domain.Repo{}, fmt.Errorf("store repository %q: %w", repo.Name, err)
98 }
99 if err := r.git.Publish(ctx, id, repo.Name); err != nil {
100 if derr := r.meta.Delete(cleanup, repo.Name); derr != nil {
101 log.Error().Err(derr).Str("repo", repo.Name.String()).Msg("remove metadata after failed publish")
102 }
103 r.discard(cleanup, id)
104 if errors.Is(err, domain.ErrAlreadyExists) {
105 return domain.Repo{}, fmt.Errorf("repository %q: directory %w", repo.Name, domain.ErrAlreadyExists)
106 }
107 return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)
108 }
109 log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")
110 r.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
111 return repo, nil
112}
113
114func (r *Repos) discard(ctx context.Context, id ports.StagedID) {
115 if err := r.git.Discard(ctx, id); err != nil {
116 log := zerowrap.FromCtx(ctx)
117 log.Error().Err(err).Str("staged", string(id)).Msg("discard staged repository")
118 }
119}
120
121// lookupRepo reads the metadata of a repository: domain.ErrInvalidName for the
122// zero name, domain.ErrNotFound for a missing repository. Access rules are the
123// caller's.
124func lookupRepo(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName) (domain.Repo, error) {
125 if name.IsZero() {
126 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
127 }
128 return meta.Get(ctx, name)
129}
130
131// Get returns a repository. A repository the principal may not read answers
132// domain.ErrNotFound, like a missing one.
133func (r *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
134 repo, err := lookupRepo(ctx, r.meta, name)
135 if err != nil {
136 return domain.Repo{}, err
137 }
138 if !canRead(p, repo) {
139 return domain.Repo{}, domain.ErrNotFound
140 }
141 return repo, nil
142}
143
144// List returns the repositories the principal may read, ordered by name.
145func (r *Repos) List(ctx context.Context, p domain.Principal) ([]domain.Repo, error) {
146 all, err := r.meta.List(ctx)
147 if err != nil {
148 return nil, err
149 }
150 visible := make([]domain.Repo, 0, len(all))
151 for _, repo := range all {
152 if canRead(p, repo) {
153 visible = append(visible, repo)
154 }
155 }
156 return visible, nil
157}
158
159// ReconcileReport tells what Reconcile found.
160type ReconcileReport struct {
161 // Registered are directories without metadata, now registered private.
162 Registered []domain.RepoName
163 // MissingDirs are repositories with metadata and no directory. They are
164 // left untouched.
165 MissingDirs []domain.RepoName
166 // InvalidDirs are directory entries that cannot be repositories.
167 InvalidDirs []string
168}
169
170// Reconcile aligns the repository directories with the metadata at startup:
171// it removes staging leftovers, registers a directory without metadata as
172// private, skips invalid directory names, and reports metadata without a
173// directory at error level without touching it.
174func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
175 log := zerowrap.FromCtx(ctx)
176 var rep ReconcileReport
177
178 if err := r.git.PurgeStaging(ctx); err != nil {
179 return rep, fmt.Errorf("purge staging: %w", err)
180 }
181 dirs, err := r.git.List(ctx)
182 if err != nil {
183 return rep, fmt.Errorf("list repository directories: %w", err)
184 }
185 metas, err := r.meta.List(ctx)
186 if err != nil {
187 return rep, fmt.Errorf("list repository metadata: %w", err)
188 }
189
190 for _, entry := range dirs.Invalid {
191 log.Warn().Str("entry", entry).Msg("skip directory that is not a valid repository name")
192 }
193 rep.InvalidDirs = dirs.Invalid
194
195 hasMeta := make(map[domain.RepoName]bool, len(metas))
196 for _, m := range metas {
197 hasMeta[m.Name] = true
198 }
199 hasDir := make(map[domain.RepoName]bool, len(dirs.Names))
200 for _, name := range dirs.Names {
201 hasDir[name] = true
202 }
203
204 for _, name := range dirs.Names {
205 if hasMeta[name] {
206 continue
207 }
208 repo := domain.Repo{
209 Name: name,
210 Visibility: domain.VisibilityPrivate,
211 CreatedAt: r.clock.Now().UTC(),
212 Settings: domain.DefaultRepoSettings(),
213 }
214 if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {
215 return rep, fmt.Errorf("register repository %q: %w", name, err)
216 }
217 log.Warn().Str("repo", name.String()).Msg("registered repository directory without metadata as private")
218 rep.Registered = append(rep.Registered, name)
219 }
220 for _, m := range metas {
221 if !hasDir[m.Name] {
222 log.Error().Str("repo", m.Name.String()).Msg("repository metadata without a directory, left untouched")
223 rep.MissingDirs = append(rep.MissingDirs, m.Name)
224 }
225 }
226 return rep, nil
227}