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