detached af0b337

feat(rpc): serve the ConnectRPC API with update, delete, token and event use cases

Connect handlers for every RPC under /-/api/, Bearer interceptor with the
shared failed-auth limiter, domain-to-Connect error mapping, in-process
client for the web, Repos.Update/Delete/View, paged lists, and the Events
ring buffer (1,024) behind EventService.Watch. Browse and the Git reader
are wired into the forge and closed after the HTTP server stops.
commit
af0b3373c241380f1973c0e3c90e40174c52d287
author
brice · · 2026-10-10 09:41 UTC
parent
435618a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
27 files · +2634 -30
M cmd/ermite/e2e_helpers_test.go +6 -1
@@ -44,6 +44,8 @@ type harnessOpts struct {
4444 hookExe string
4545 // maxBody overrides the limit of a push body when positive.
4646 maxBody int64
47+ // eventBuffer overrides the size of the event ring when positive.
48+ eventBuffer int
4749 }
4850
4951 // newHarness starts a forge. The data directory is short: the hook socket
@@ -77,7 +79,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
7779 if hookExe == "" {
7880 hookExe = binary(t)
7981 }
80- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody})
82+ f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer})
8183 require.NoError(t, err)
8284
8385 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
@@ -88,6 +90,9 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
8890
8991 h.home = t.TempDir()
9092 t.Cleanup(func() {
93+ // Event streams never end by themselves: the real server closes them
94+ // when its shutdown begins, the test server needs the same push.
95+ f.EventLog.Close(context.WithoutCancel(ctx))
9196 h.srv.Close()
9297 cancel()
9398 select {
@@ -44,6 +44,8 @@ type harnessOpts struct {
44 hookExe string44 hookExe string
45 // maxBody overrides the limit of a push body when positive.45 // maxBody overrides the limit of a push body when positive.
46 maxBody int6446 maxBody int64
47+ // eventBuffer overrides the size of the event ring when positive.
48+ eventBuffer int
47 }49 }
48 50
49 // newHarness starts a forge. The data directory is short: the hook socket51 // newHarness starts a forge. The data directory is short: the hook socket
@@ -77,7 +79,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
77 if hookExe == "" {79 if hookExe == "" {
78 hookExe = binary(t)80 hookExe = binary(t)
79 }81 }
80- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody})82+ f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer})
81 require.NoError(t, err)83 require.NoError(t, err)
82 84
83 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}85 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
@@ -88,6 +90,9 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
88 90
89 h.home = t.TempDir()91 h.home = t.TempDir()
90 t.Cleanup(func() {92 t.Cleanup(func() {
93+ // Event streams never end by themselves: the real server closes them
94+ // when its shutdown begins, the test server needs the same push.
95+ f.EventLog.Close(context.WithoutCancel(ctx))
91 h.srv.Close()96 h.srv.Close()
92 cancel()97 cancel()
93 select {98 select {
M cmd/ermite/forge.go +61 -10
@@ -9,6 +9,7 @@ import (
99 "net"
1010 "os"
1111 "path/filepath"
12+ "runtime/debug"
1213 "time"
1314
1415 "github.com/bnema/zerowrap"
@@ -18,7 +19,9 @@ import (
1819 "bnema.dev/ermite/internal/adapters/githttp"
1920 "bnema.dev/ermite/internal/adapters/httpserver"
2021 "bnema.dev/ermite/internal/adapters/kvstore"
22+ "bnema.dev/ermite/internal/adapters/rpc"
2123 "bnema.dev/ermite/internal/app"
24+ "bnema.dev/ermite/internal/clientlimit"
2225 "bnema.dev/ermite/internal/config"
2326 "bnema.dev/ermite/internal/ports"
2427 )
@@ -36,6 +39,8 @@ type options struct {
3639 maintenanceDelay time.Duration
3740 // maxBodyBytes overrides the limit of a push body when positive.
3841 maxBodyBytes int64
42+ // eventBuffer overrides app.DefaultEventBuffer when positive.
43+ eventBuffer int
3944 }
4045
4146 // forge is the wired server: every adapter and use case, ready to serve.
@@ -45,14 +50,28 @@ type forge struct {
4550 server *httpserver.Server
4651 hooks *githttp.HookServer
4752 maint *app.Maintainer
53+ git *gitcli.Git
54+
55+ // The use cases and the API are exposed for tests and for the phases
56+ // that add the web.
57+ Repos *app.Repos
58+ Tokens *app.Tokens
59+ Auth *app.Auth
60+ Pushes *app.Pushes
61+ Browse *app.Browse
62+ EventLog *app.Events
63+ API *rpc.API
64+ Events ports.EventBus
65+}
4866
49- // Repos, Tokens, Pushes and Events are exposed for tests and for the
50- // phases that add the API.
51- Repos *app.Repos
52- Tokens *app.Tokens
53- Auth *app.Auth
54- Pushes *app.Pushes
55- Events ports.EventBus
67+// serverVersion is the version GetVersion reports: the module version of the
68+// build, or "devel" for an untagged one.
69+func serverVersion() string {
70+ info, ok := debug.ReadBuildInfo()
71+ if !ok || info.Main.Version == "" || info.Main.Version == "(devel)" {
72+ return "devel"
73+ }
74+ return info.Main.Version
5675 }
5776
5877 // newForge opens the metadata store, resets run/, checks git, reconciles the repositories,
@@ -86,6 +105,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
86105 maxBody = opts.maxBodyBytes
87106 }
88107 git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody})
108+ f.git = git
89109 gitVersion, err := git.CheckVersion(ctx)
90110 if err != nil {
91111 return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
@@ -96,7 +116,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
96116 }
97117
98118 clock := systemClock{}
99- f.Repos = app.NewRepos(store, git, clock)
119+ f.Events = eventbus.New()
120+ f.Repos = app.NewRepos(store, git, f.Events, clock)
100121 report, err := f.Repos.Reconcile(ctx)
101122 if err != nil {
102123 return nil, fmt.Errorf("reconcile repositories: %w", err)
@@ -109,7 +130,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
109130
110131 f.Tokens = app.NewTokens(store.Tokens(), clock)
111132 f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
112- f.Events = eventbus.New()
113133 f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)
114134 f.Pushes = app.NewPushes(store, f.Repos, git, f.Events, clock, f.maint, f.Auth)
115135
@@ -127,6 +147,22 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
127147 return nil, err
128148 }
129149
150+ f.Browse = app.NewBrowse(store, git)
151+ f.EventLog = app.NewEvents(ctx, f.Repos, f.Events, opts.eventBuffer, clock)
152+
153+ // One budget of failed authentications per client, for Git and the API.
154+ limiter := clientlimit.NewLimiter(nil)
155+ f.API = rpc.New(rpc.Config{
156+ Auth: f.Auth,
157+ Repos: f.Repos,
158+ Tokens: f.Tokens,
159+ Browse: f.Browse,
160+ Events: f.EventLog,
161+ Version: serverVersion(),
162+ TrustedProxies: cfg.TrustedProxies,
163+ Limiter: limiter,
164+ })
165+
130166 gh := githttp.New(githttp.Config{
131167 Auth: f.Auth,
132168 Repos: f.Repos,
@@ -134,6 +170,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
134170 Backend: git,
135171 Hooks: f.hooks,
136172 TrustedProxies: cfg.TrustedProxies,
173+ Limiter: limiter,
137174 MaxBodyBytes: maxBody,
138175 })
139176 routes := make([]httpserver.Route, 0, len(githttp.Patterns))
@@ -144,6 +181,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
144181 Addr: cfg.Addr,
145182 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
146183 Routes: routes,
184+ API: f.API.Handler(),
185+ // Event streams never end by themselves: close them when the
186+ // shutdown begins, or it would wait for them until its timeout.
187+ OnShutdown: []func(){func() { f.EventLog.Close(context.WithoutCancel(ctx)) }},
147188 })
148189 return f, nil
149190 }
@@ -178,12 +219,22 @@ func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
178219 return errors.Join(httpErr, <-hookErr)
179220 }
180221
181-// Close releases what newForge opened. It is safe to call twice.
222+// Close releases what newForge opened. It is safe to call twice. Call it after
223+// the HTTP server stopped: it stops the event log and the long-lived Git
224+// processes of the read side, which requests in flight may still use.
182225 func (f *forge) Close(ctx context.Context) {
183226 log := zerowrap.FromCtx(ctx)
184227 if f.hooks != nil {
185228 f.hooks.Close(ctx)
186229 }
230+ if f.EventLog != nil {
231+ f.EventLog.Close(ctx)
232+ }
233+ if f.git != nil {
234+ if err := f.git.Close(ctx); err != nil {
235+ log.Error().Err(err).Msg("close git reader")
236+ }
237+ }
187238 if f.maint != nil {
188239 f.maint.Close(ctx)
189240 }
@@ -9,6 +9,7 @@ import (
9 "net"9 "net"
10 "os"10 "os"
11 "path/filepath"11 "path/filepath"
12+ "runtime/debug"
12 "time"13 "time"
13 14
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -18,7 +19,9 @@ import (
18 "bnema.dev/ermite/internal/adapters/githttp"19 "bnema.dev/ermite/internal/adapters/githttp"
19 "bnema.dev/ermite/internal/adapters/httpserver"20 "bnema.dev/ermite/internal/adapters/httpserver"
20 "bnema.dev/ermite/internal/adapters/kvstore"21 "bnema.dev/ermite/internal/adapters/kvstore"
22+ "bnema.dev/ermite/internal/adapters/rpc"
21 "bnema.dev/ermite/internal/app"23 "bnema.dev/ermite/internal/app"
24+ "bnema.dev/ermite/internal/clientlimit"
22 "bnema.dev/ermite/internal/config"25 "bnema.dev/ermite/internal/config"
23 "bnema.dev/ermite/internal/ports"26 "bnema.dev/ermite/internal/ports"
24 )27 )
@@ -36,6 +39,8 @@ type options struct {
36 maintenanceDelay time.Duration39 maintenanceDelay time.Duration
37 // maxBodyBytes overrides the limit of a push body when positive.40 // maxBodyBytes overrides the limit of a push body when positive.
38 maxBodyBytes int6441 maxBodyBytes int64
42+ // eventBuffer overrides app.DefaultEventBuffer when positive.
43+ eventBuffer int
39 }44 }
40 45
41 // forge is the wired server: every adapter and use case, ready to serve.46 // forge is the wired server: every adapter and use case, ready to serve.
@@ -45,14 +50,28 @@ type forge struct {
45 server *httpserver.Server50 server *httpserver.Server
46 hooks *githttp.HookServer51 hooks *githttp.HookServer
47 maint *app.Maintainer52 maint *app.Maintainer
53+ git *gitcli.Git
54+
55+ // The use cases and the API are exposed for tests and for the phases
56+ // that add the web.
57+ Repos *app.Repos
58+ Tokens *app.Tokens
59+ Auth *app.Auth
60+ Pushes *app.Pushes
61+ Browse *app.Browse
62+ EventLog *app.Events
63+ API *rpc.API
64+ Events ports.EventBus
65+}
48 66
49- // Repos, Tokens, Pushes and Events are exposed for tests and for the67+// serverVersion is the version GetVersion reports: the module version of the
50- // phases that add the API.68+// build, or "devel" for an untagged one.
51- Repos *app.Repos69+func serverVersion() string {
52- Tokens *app.Tokens70+ info, ok := debug.ReadBuildInfo()
53- Auth *app.Auth71+ if !ok || info.Main.Version == "" || info.Main.Version == "(devel)" {
54- Pushes *app.Pushes72+ return "devel"
55- Events ports.EventBus73+ }
74+ return info.Main.Version
56 }75 }
57 76
58 // newForge opens the metadata store, resets run/, checks git, reconciles the repositories,77 // newForge opens the metadata store, resets run/, checks git, reconciles the repositories,
@@ -86,6 +105,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
86 maxBody = opts.maxBodyBytes105 maxBody = opts.maxBodyBytes
87 }106 }
88 git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody})107 git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody})
108+ f.git = git
89 gitVersion, err := git.CheckVersion(ctx)109 gitVersion, err := git.CheckVersion(ctx)
90 if err != nil {110 if err != nil {
91 return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)111 return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
@@ -96,7 +116,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
96 }116 }
97 117
98 clock := systemClock{}118 clock := systemClock{}
99- f.Repos = app.NewRepos(store, git, clock)119+ f.Events = eventbus.New()
120+ f.Repos = app.NewRepos(store, git, f.Events, clock)
100 report, err := f.Repos.Reconcile(ctx)121 report, err := f.Repos.Reconcile(ctx)
101 if err != nil {122 if err != nil {
102 return nil, fmt.Errorf("reconcile repositories: %w", err)123 return nil, fmt.Errorf("reconcile repositories: %w", err)
@@ -109,7 +130,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
109 130
110 f.Tokens = app.NewTokens(store.Tokens(), clock)131 f.Tokens = app.NewTokens(store.Tokens(), clock)
111 f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)132 f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
112- f.Events = eventbus.New()
113 f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)133 f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)
114 f.Pushes = app.NewPushes(store, f.Repos, git, f.Events, clock, f.maint, f.Auth)134 f.Pushes = app.NewPushes(store, f.Repos, git, f.Events, clock, f.maint, f.Auth)
115 135
@@ -127,6 +147,22 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
127 return nil, err147 return nil, err
128 }148 }
129 149
150+ f.Browse = app.NewBrowse(store, git)
151+ f.EventLog = app.NewEvents(ctx, f.Repos, f.Events, opts.eventBuffer, clock)
152+
153+ // One budget of failed authentications per client, for Git and the API.
154+ limiter := clientlimit.NewLimiter(nil)
155+ f.API = rpc.New(rpc.Config{
156+ Auth: f.Auth,
157+ Repos: f.Repos,
158+ Tokens: f.Tokens,
159+ Browse: f.Browse,
160+ Events: f.EventLog,
161+ Version: serverVersion(),
162+ TrustedProxies: cfg.TrustedProxies,
163+ Limiter: limiter,
164+ })
165+
130 gh := githttp.New(githttp.Config{166 gh := githttp.New(githttp.Config{
131 Auth: f.Auth,167 Auth: f.Auth,
132 Repos: f.Repos,168 Repos: f.Repos,
@@ -134,6 +170,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
134 Backend: git,170 Backend: git,
135 Hooks: f.hooks,171 Hooks: f.hooks,
136 TrustedProxies: cfg.TrustedProxies,172 TrustedProxies: cfg.TrustedProxies,
173+ Limiter: limiter,
137 MaxBodyBytes: maxBody,174 MaxBodyBytes: maxBody,
138 })175 })
139 routes := make([]httpserver.Route, 0, len(githttp.Patterns))176 routes := make([]httpserver.Route, 0, len(githttp.Patterns))
@@ -144,6 +181,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
144 Addr: cfg.Addr,181 Addr: cfg.Addr,
145 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},182 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
146 Routes: routes,183 Routes: routes,
184+ API: f.API.Handler(),
185+ // Event streams never end by themselves: close them when the
186+ // shutdown begins, or it would wait for them until its timeout.
187+ OnShutdown: []func(){func() { f.EventLog.Close(context.WithoutCancel(ctx)) }},
147 })188 })
148 return f, nil189 return f, nil
149 }190 }
@@ -178,12 +219,22 @@ func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
178 return errors.Join(httpErr, <-hookErr)219 return errors.Join(httpErr, <-hookErr)
179 }220 }
180 221
181-// Close releases what newForge opened. It is safe to call twice.222+// Close releases what newForge opened. It is safe to call twice. Call it after
223+// the HTTP server stopped: it stops the event log and the long-lived Git
224+// processes of the read side, which requests in flight may still use.
182 func (f *forge) Close(ctx context.Context) {225 func (f *forge) Close(ctx context.Context) {
183 log := zerowrap.FromCtx(ctx)226 log := zerowrap.FromCtx(ctx)
184 if f.hooks != nil {227 if f.hooks != nil {
185 f.hooks.Close(ctx)228 f.hooks.Close(ctx)
186 }229 }
230+ if f.EventLog != nil {
231+ f.EventLog.Close(ctx)
232+ }
233+ if f.git != nil {
234+ if err := f.git.Close(ctx); err != nil {
235+ log.Error().Err(err).Msg("close git reader")
236+ }
237+ }
187 if f.maint != nil {238 if f.maint != nil {
188 f.maint.Close(ctx)239 f.maint.Close(ctx)
189 }240 }
M go.mod +2 -1
@@ -10,7 +10,9 @@ tool (
1010 )
1111
1212 require (
13+ bnema.dev/ermite-api v0.0.0-20261010060753-a137f54f984a
1314 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
15+ connectrpc.com/connect v1.21.0
1416 github.com/bnema/zerowrap v1.4.1
1517 github.com/spf13/cobra v1.10.2
1618 github.com/stretchr/testify v1.12.1
@@ -35,7 +37,6 @@ require (
3537 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
3638 cel.dev/cel-go v0.32.0 // indirect
3739 cel.dev/expr v0.25.3 // indirect
38- connectrpc.com/connect v1.20.0 // indirect
3940 connectrpc.com/otelconnect v0.9.0 // indirect
4041 github.com/Microsoft/go-winio v0.6.2 // indirect
4142 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
@@ -10,7 +10,9 @@ tool (
10 )10 )
11 11
12 require (12 require (
13+ bnema.dev/ermite-api v0.0.0-20261010060753-a137f54f984a
13 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c314 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
15+ connectrpc.com/connect v1.21.0
14 github.com/bnema/zerowrap v1.4.116 github.com/bnema/zerowrap v1.4.1
15 github.com/spf13/cobra v1.10.217 github.com/spf13/cobra v1.10.2
16 github.com/stretchr/testify v1.12.118 github.com/stretchr/testify v1.12.1
@@ -35,7 +37,6 @@ require (
35 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect37 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
36 cel.dev/cel-go v0.32.0 // indirect38 cel.dev/cel-go v0.32.0 // indirect
37 cel.dev/expr v0.25.3 // indirect39 cel.dev/expr v0.25.3 // indirect
38- connectrpc.com/connect v1.20.0 // indirect
39 connectrpc.com/otelconnect v0.9.0 // indirect40 connectrpc.com/otelconnect v0.9.0 // indirect
40 github.com/Microsoft/go-winio v0.6.2 // indirect41 github.com/Microsoft/go-winio v0.6.2 // indirect
41 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect42 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
M go.sum +4 -2 generated file: collapsed
M internal/adapters/httpserver/middleware.go +11 -0
@@ -46,6 +46,17 @@ func (s *statusRecorder) Write(b []byte) (int, error) {
4646 return s.ResponseWriter.Write(b)
4747 }
4848
49+// Flush lets streaming handlers (Connect server streams require an
50+// http.Flusher) push their messages out as they are written.
51+func (s *statusRecorder) Flush() {
52+ if s.status == 0 {
53+ s.status = http.StatusOK
54+ }
55+ if f, ok := s.ResponseWriter.(http.Flusher); ok {
56+ f.Flush()
57+ }
58+}
59+
4960 // Unwrap lets http.ResponseController reach Flush and friends.
5061 func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
5162
@@ -46,6 +46,17 @@ func (s *statusRecorder) Write(b []byte) (int, error) {
46 return s.ResponseWriter.Write(b)46 return s.ResponseWriter.Write(b)
47 }47 }
48 48
49+// Flush lets streaming handlers (Connect server streams require an
50+// http.Flusher) push their messages out as they are written.
51+func (s *statusRecorder) Flush() {
52+ if s.status == 0 {
53+ s.status = http.StatusOK
54+ }
55+ if f, ok := s.ResponseWriter.(http.Flusher); ok {
56+ f.Flush()
57+ }
58+}
59+
49 // Unwrap lets http.ResponseController reach Flush and friends.60 // Unwrap lets http.ResponseController reach Flush and friends.
50 func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }61 func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
51 62
M internal/adapters/httpserver/server.go +21 -3
@@ -40,12 +40,23 @@ type Config struct {
4040 HealthChecks []Check
4141 // Routes are added to the router next to /-/health.
4242 Routes []Route
43+ // API serves the ConnectRPC API under /-/api/ (the prefix is stripped, so
44+ // it sees "/ermite.RepoService/ListRepos"). Nil serves none.
45+ API http.Handler
46+ // OnShutdown functions run, each in its own goroutine, when the graceful
47+ // shutdown begins. Use them to end long-lived responses such as event
48+ // streams, which the shutdown would otherwise wait for.
49+ OnShutdown []func()
4350 }
4451
52+// APIPrefix is where the API is mounted.
53+const APIPrefix = "/-/api"
54+
4555 // Server is the HTTP server of ermite.
4656 type Server struct {
47- addr string
48- handler http.Handler
57+ addr string
58+ handler http.Handler
59+ onShutdown []func()
4960 }
5061
5162 // New builds the server and its routes.
@@ -54,10 +65,13 @@ func New(cfg Config) *Server {
5465 // "GET" patterns also match HEAD; other methods get 405 with Allow, and
5566 // unknown paths get 404.
5667 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
68+ if cfg.API != nil {
69+ mux.Handle(APIPrefix+"/", http.StripPrefix(APIPrefix, cfg.API))
70+ }
5771 for _, r := range cfg.Routes {
5872 mux.Handle(r.Pattern, r.Handler)
5973 }
60- return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))}
74+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux)), onShutdown: cfg.OnShutdown}
6175 }
6276
6377 // Handler returns the root handler, for tests.
@@ -87,6 +101,10 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
87101 // No read or write timeout: Git clones and pushes can be long.
88102 }
89103
104+ for _, f := range s.onShutdown {
105+ srv.RegisterOnShutdown(f)
106+ }
107+
90108 errc := make(chan error, 1)
91109 go func() { errc <- srv.Serve(ln) }()
92110 log.Info().Str("addr", ln.Addr().String()).Msg("listening")
@@ -40,12 +40,23 @@ type Config struct {
40 HealthChecks []Check40 HealthChecks []Check
41 // Routes are added to the router next to /-/health.41 // Routes are added to the router next to /-/health.
42 Routes []Route42 Routes []Route
43+ // API serves the ConnectRPC API under /-/api/ (the prefix is stripped, so
44+ // it sees "/ermite.RepoService/ListRepos"). Nil serves none.
45+ API http.Handler
46+ // OnShutdown functions run, each in its own goroutine, when the graceful
47+ // shutdown begins. Use them to end long-lived responses such as event
48+ // streams, which the shutdown would otherwise wait for.
49+ OnShutdown []func()
43 }50 }
44 51
52+// APIPrefix is where the API is mounted.
53+const APIPrefix = "/-/api"
54+
45 // Server is the HTTP server of ermite.55 // Server is the HTTP server of ermite.
46 type Server struct {56 type Server struct {
47- addr string57+ addr string
48- handler http.Handler58+ handler http.Handler
59+ onShutdown []func()
49 }60 }
50 61
51 // New builds the server and its routes.62 // New builds the server and its routes.
@@ -54,10 +65,13 @@ func New(cfg Config) *Server {
54 // "GET" patterns also match HEAD; other methods get 405 with Allow, and65 // "GET" patterns also match HEAD; other methods get 405 with Allow, and
55 // unknown paths get 404.66 // unknown paths get 404.
56 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))67 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
68+ if cfg.API != nil {
69+ mux.Handle(APIPrefix+"/", http.StripPrefix(APIPrefix, cfg.API))
70+ }
57 for _, r := range cfg.Routes {71 for _, r := range cfg.Routes {
58 mux.Handle(r.Pattern, r.Handler)72 mux.Handle(r.Pattern, r.Handler)
59 }73 }
60- return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))}74+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux)), onShutdown: cfg.OnShutdown}
61 }75 }
62 76
63 // Handler returns the root handler, for tests.77 // Handler returns the root handler, for tests.
@@ -87,6 +101,10 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
87 // No read or write timeout: Git clones and pushes can be long.101 // No read or write timeout: Git clones and pushes can be long.
88 }102 }
89 103
104+ for _, f := range s.onShutdown {
105+ srv.RegisterOnShutdown(f)
106+ }
107+
90 errc := make(chan error, 1)108 errc := make(chan error, 1)
91 go func() { errc <- srv.Serve(ln) }()109 go func() { errc <- srv.Serve(ln) }()
92 log.Info().Str("addr", ln.Addr().String()).Msg("listening")110 log.Info().Str("addr", ln.Addr().String()).Msg("listening")
A internal/adapters/rpc/api.go +134 -0
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
4+// ConnectRPC: handlers that map the messages of the contract to the use cases
5+// of app and back, the interceptor that authenticates callers, and the error
6+// mapping. The same handlers serve remote clients over HTTP and the web in
7+// process (see InProcessClient).
8+package rpc
9+
10+import (
11+ "context"
12+ "net/http"
13+ "net/netip"
14+
15+ "connectrpc.com/connect"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+ "bnema.dev/ermite/internal/app"
20+ "bnema.dev/ermite/internal/clientlimit"
21+ "bnema.dev/ermite/internal/domain"
22+)
23+
24+// MaxRequestBytes bounds the size of a request message. Responses are not
25+// bounded: a diff has its own limits and blobs are streamed.
26+const MaxRequestBytes = 1 << 20
27+
28+// Features are the optional capabilities of the contract that this server
29+// implements, as GetVersion lists them.
30+var Features = []string{"repo.update", "events.resume"}
31+
32+// Authenticator turns a token secret into a principal, or
33+// domain.ErrUnauthenticated. app.Auth fits.
34+type Authenticator interface {
35+ Authenticate(ctx context.Context, secret string) (domain.Principal, error)
36+}
37+
38+// Config configures the API.
39+type Config struct {
40+ Auth Authenticator
41+ Repos *app.Repos
42+ Tokens *app.Tokens
43+ Browse *app.Browse
44+ Events *app.Events
45+ Version string
46+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
47+ TrustedProxies []netip.Prefix
48+ // Limiter counts failed authentications; nil builds a default one. Share
49+ // it with the other adapters that authenticate, so that a client has one
50+ // budget of failures.
51+ Limiter *clientlimit.Limiter
52+}
53+
54+// API is the set of Connect handlers of the contract.
55+type API struct {
56+ cfg Config
57+ handler http.Handler
58+}
59+
60+// New builds the API.
61+func New(cfg Config) *API {
62+ if cfg.Limiter == nil {
63+ cfg.Limiter = clientlimit.NewLimiter(nil)
64+ }
65+ a := &API{cfg: cfg}
66+
67+ opts := []connect.HandlerOption{
68+ connect.WithInterceptors(newInterceptor(cfg)),
69+ connect.WithReadMaxBytes(MaxRequestBytes),
70+ connect.WithRecover(func(ctx context.Context, spec connect.Spec, _ http.Header, panicValue any) error {
71+ log := zerowrap.FromCtx(ctx)
72+ log.Error().Interface("panic", panicValue).Str("procedure", spec.Procedure).Msg("api handler panicked")
73+ return connect.NewError(connect.CodeInternal, errInternal)
74+ }),
75+ }
76+ mux := http.NewServeMux()
77+ for _, mount := range []func() (string, http.Handler){
78+ func() (string, http.Handler) {
79+ return ermiteconnect.NewRepoServiceHandler(&repoService{cfg: cfg}, opts...)
80+ },
81+ func() (string, http.Handler) {
82+ return ermiteconnect.NewGitServiceHandler(&gitService{browse: cfg.Browse}, opts...)
83+ },
84+ func() (string, http.Handler) {
85+ return ermiteconnect.NewTokenServiceHandler(&tokenService{tokens: cfg.Tokens}, opts...)
86+ },
87+ func() (string, http.Handler) {
88+ return ermiteconnect.NewEventServiceHandler(&eventService{events: cfg.Events}, opts...)
89+ },
90+ func() (string, http.Handler) {
91+ return ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version}, opts...)
92+ },
93+ } {
94+ path, h := mount()
95+ mux.Handle(path, h)
96+ }
97+ a.handler = withStreamStart(mux)
98+ return a
99+}
100+
101+// Handler returns the handler of all services. Its paths are the procedure
102+// paths of the contract ("/ermite.RepoService/ListRepos"); httpserver mounts
103+// it under /-/api/ and strips the prefix.
104+func (a *API) Handler() http.Handler { return a.handler }
105+
106+// caller returns the principal the interceptor (or the in-process caller)
107+// put in ctx; anonymous when there is none.
108+func caller(ctx context.Context) domain.Principal {
109+ p, _ := domain.PrincipalFromContext(ctx)
110+ return p
111+}
112+
113+type startedKey struct{}
114+
115+// withStreamStart gives the handlers a way to send the response headers
116+// before the first message of a stream (see startedFunc).
117+func withStreamStart(next http.Handler) http.Handler {
118+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119+ start := func() {
120+ w.WriteHeader(http.StatusOK)
121+ _ = http.NewResponseController(w).Flush()
122+ }
123+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), startedKey{}, start)))
124+ })
125+}
126+
127+// startedFunc returns the function that sends the response headers at once.
128+// A Connect client returns from a streaming call only when the headers have
129+// arrived; a stream that may stay silent for a long time (Watch) must send
130+// them first. Connect has set all the headers by the time a handler runs.
131+func startedFunc(ctx context.Context) func() {
132+ start, _ := ctx.Value(startedKey{}).(func())
133+ return start
134+}
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
4+// ConnectRPC: handlers that map the messages of the contract to the use cases
5+// of app and back, the interceptor that authenticates callers, and the error
6+// mapping. The same handlers serve remote clients over HTTP and the web in
7+// process (see InProcessClient).
8+package rpc
9+
10+import (
11+ "context"
12+ "net/http"
13+ "net/netip"
14+
15+ "connectrpc.com/connect"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+ "bnema.dev/ermite/internal/app"
20+ "bnema.dev/ermite/internal/clientlimit"
21+ "bnema.dev/ermite/internal/domain"
22+)
23+
24+// MaxRequestBytes bounds the size of a request message. Responses are not
25+// bounded: a diff has its own limits and blobs are streamed.
26+const MaxRequestBytes = 1 << 20
27+
28+// Features are the optional capabilities of the contract that this server
29+// implements, as GetVersion lists them.
30+var Features = []string{"repo.update", "events.resume"}
31+
32+// Authenticator turns a token secret into a principal, or
33+// domain.ErrUnauthenticated. app.Auth fits.
34+type Authenticator interface {
35+ Authenticate(ctx context.Context, secret string) (domain.Principal, error)
36+}
37+
38+// Config configures the API.
39+type Config struct {
40+ Auth Authenticator
41+ Repos *app.Repos
42+ Tokens *app.Tokens
43+ Browse *app.Browse
44+ Events *app.Events
45+ Version string
46+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
47+ TrustedProxies []netip.Prefix
48+ // Limiter counts failed authentications; nil builds a default one. Share
49+ // it with the other adapters that authenticate, so that a client has one
50+ // budget of failures.
51+ Limiter *clientlimit.Limiter
52+}
53+
54+// API is the set of Connect handlers of the contract.
55+type API struct {
56+ cfg Config
57+ handler http.Handler
58+}
59+
60+// New builds the API.
61+func New(cfg Config) *API {
62+ if cfg.Limiter == nil {
63+ cfg.Limiter = clientlimit.NewLimiter(nil)
64+ }
65+ a := &API{cfg: cfg}
66+
67+ opts := []connect.HandlerOption{
68+ connect.WithInterceptors(newInterceptor(cfg)),
69+ connect.WithReadMaxBytes(MaxRequestBytes),
70+ connect.WithRecover(func(ctx context.Context, spec connect.Spec, _ http.Header, panicValue any) error {
71+ log := zerowrap.FromCtx(ctx)
72+ log.Error().Interface("panic", panicValue).Str("procedure", spec.Procedure).Msg("api handler panicked")
73+ return connect.NewError(connect.CodeInternal, errInternal)
74+ }),
75+ }
76+ mux := http.NewServeMux()
77+ for _, mount := range []func() (string, http.Handler){
78+ func() (string, http.Handler) {
79+ return ermiteconnect.NewRepoServiceHandler(&repoService{cfg: cfg}, opts...)
80+ },
81+ func() (string, http.Handler) {
82+ return ermiteconnect.NewGitServiceHandler(&gitService{browse: cfg.Browse}, opts...)
83+ },
84+ func() (string, http.Handler) {
85+ return ermiteconnect.NewTokenServiceHandler(&tokenService{tokens: cfg.Tokens}, opts...)
86+ },
87+ func() (string, http.Handler) {
88+ return ermiteconnect.NewEventServiceHandler(&eventService{events: cfg.Events}, opts...)
89+ },
90+ func() (string, http.Handler) {
91+ return ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version}, opts...)
92+ },
93+ } {
94+ path, h := mount()
95+ mux.Handle(path, h)
96+ }
97+ a.handler = withStreamStart(mux)
98+ return a
99+}
100+
101+// Handler returns the handler of all services. Its paths are the procedure
102+// paths of the contract ("/ermite.RepoService/ListRepos"); httpserver mounts
103+// it under /-/api/ and strips the prefix.
104+func (a *API) Handler() http.Handler { return a.handler }
105+
106+// caller returns the principal the interceptor (or the in-process caller)
107+// put in ctx; anonymous when there is none.
108+func caller(ctx context.Context) domain.Principal {
109+ p, _ := domain.PrincipalFromContext(ctx)
110+ return p
111+}
112+
113+type startedKey struct{}
114+
115+// withStreamStart gives the handlers a way to send the response headers
116+// before the first message of a stream (see startedFunc).
117+func withStreamStart(next http.Handler) http.Handler {
118+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119+ start := func() {
120+ w.WriteHeader(http.StatusOK)
121+ _ = http.NewResponseController(w).Flush()
122+ }
123+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), startedKey{}, start)))
124+ })
125+}
126+
127+// startedFunc returns the function that sends the response headers at once.
128+// A Connect client returns from a streaming call only when the headers have
129+// arrived; a stream that may stay silent for a long time (Watch) must send
130+// them first. Connect has set all the headers by the time a handler runs.
131+func startedFunc(ctx context.Context) func() {
132+ start, _ := ctx.Value(startedKey{}).(func())
133+ return start
134+}
A internal/adapters/rpc/convert.go +331 -0
@@ -0,0 +1,331 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "fmt"
7+ "time"
8+
9+ "google.golang.org/protobuf/types/known/timestamppb"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/app"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// This file maps domain values to the messages of the contract and back.
17+// Messages are DTOs: the domain never leaks into them, nor they into the
18+// domain.
19+
20+func timestamp(t time.Time) *timestamppb.Timestamp {
21+ if t.IsZero() {
22+ return nil
23+ }
24+ return timestamppb.New(t)
25+}
26+
27+func goTime(ts *timestamppb.Timestamp) time.Time {
28+ if ts == nil {
29+ return time.Time{}
30+ }
31+ return ts.AsTime()
32+}
33+
34+func visibilityToPB(v domain.Visibility) pb.Visibility {
35+ switch v {
36+ case domain.VisibilityPublic:
37+ return pb.Visibility_VISIBILITY_PUBLIC
38+ case domain.VisibilityPrivate:
39+ return pb.Visibility_VISIBILITY_PRIVATE
40+ }
41+ return pb.Visibility_VISIBILITY_UNSPECIFIED
42+}
43+
44+// visibilityFromPB maps a visibility of a request. Unspecified is the empty
45+// domain.Visibility, which the use cases treat as "default".
46+func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) {
47+ switch v {
48+ case pb.Visibility_VISIBILITY_UNSPECIFIED:
49+ return "", nil
50+ case pb.Visibility_VISIBILITY_PUBLIC:
51+ return domain.VisibilityPublic, nil
52+ case pb.Visibility_VISIBILITY_PRIVATE:
53+ return domain.VisibilityPrivate, nil
54+ }
55+ return "", fmt.Errorf("%w: %v", domain.ErrInvalidVisibility, v)
56+}
57+
58+func repoToPB(v app.RepoView) *pb.Repo {
59+ return &pb.Repo{
60+ Name: v.Name.String(),
61+ Description: v.Description,
62+ Visibility: visibilityToPB(v.Visibility),
63+ DefaultBranch: v.DefaultBranch,
64+ CreatedAt: timestamp(v.CreatedAt),
65+ PushedAt: timestamp(v.PushedAt),
66+ }
67+}
68+
69+var scopeToPB = map[domain.Scope]pb.Scope{
70+ domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,
71+ domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,
72+ domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
73+ domain.ScopeMRWrite: pb.Scope_SCOPE_MR_WRITE,
74+ domain.ScopeMRMerge: pb.Scope_SCOPE_MR_MERGE,
75+ domain.ScopeCIWrite: pb.Scope_SCOPE_CI_WRITE,
76+ domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
77+ domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
78+ domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
79+}
80+
81+func scopeFromPB(s pb.Scope) (domain.Scope, error) {
82+ for d, p := range scopeToPB {
83+ if p == s {
84+ return d, nil
85+ }
86+ }
87+ return "", fmt.Errorf("%w: %v", domain.ErrInvalidScope, s)
88+}
89+
90+func tokenToPB(t domain.Token) *pb.Token {
91+ out := &pb.Token{
92+ Id: t.ID,
93+ Label: t.Label,
94+ ExpiresAt: timestamp(t.ExpiresAt),
95+ CreatedAt: timestamp(t.CreatedAt),
96+ LastUsedAt: timestamp(t.LastUsedAt),
97+ }
98+ for _, s := range t.Scopes {
99+ out.Scopes = append(out.Scopes, scopeToPB[s])
100+ }
101+ for _, r := range t.Repos {
102+ out.Repos = append(out.Repos, r.String())
103+ }
104+ return out
105+}
106+
107+func refKindToPB(k domain.RefKind) pb.RefKind {
108+ switch k {
109+ case domain.RefKindBranch:
110+ return pb.RefKind_REF_KIND_BRANCH
111+ case domain.RefKindTag:
112+ return pb.RefKind_REF_KIND_TAG
113+ case domain.RefKindHead:
114+ return pb.RefKind_REF_KIND_HEAD
115+ }
116+ return pb.RefKind_REF_KIND_UNSPECIFIED
117+}
118+
119+func refKindFromPB(k pb.RefKind) (domain.RefKind, error) {
120+ switch k {
121+ case pb.RefKind_REF_KIND_BRANCH:
122+ return domain.RefKindBranch, nil
123+ case pb.RefKind_REF_KIND_TAG:
124+ return domain.RefKindTag, nil
125+ case pb.RefKind_REF_KIND_HEAD:
126+ return domain.RefKindHead, nil
127+ }
128+ return "", fmt.Errorf("%w: %v", domain.ErrInvalidRefKind, k)
129+}
130+
131+func refToPB(r domain.Ref) *pb.Ref {
132+ return &pb.Ref{
133+ Name: r.Name,
134+ ShortName: r.ShortName,
135+ Kind: refKindToPB(r.Kind),
136+ Id: r.ID.String(),
137+ CommitId: r.CommitID.String(),
138+ }
139+}
140+
141+func entryKindToPB(k domain.EntryKind) pb.EntryKind {
142+ switch k {
143+ case domain.EntryFile:
144+ return pb.EntryKind_ENTRY_KIND_FILE
145+ case domain.EntryExecutable:
146+ return pb.EntryKind_ENTRY_KIND_EXECUTABLE
147+ case domain.EntryDirectory:
148+ return pb.EntryKind_ENTRY_KIND_DIRECTORY
149+ case domain.EntrySymlink:
150+ return pb.EntryKind_ENTRY_KIND_SYMLINK
151+ case domain.EntrySubmodule:
152+ return pb.EntryKind_ENTRY_KIND_SUBMODULE
153+ }
154+ return pb.EntryKind_ENTRY_KIND_UNSPECIFIED
155+}
156+
157+func signatureToPB(s domain.Signature) *pb.Signature {
158+ return &pb.Signature{Name: s.Name, Email: s.Email, Time: timestamp(s.Time)}
159+}
160+
161+func commitToPB(c domain.Commit) *pb.Commit {
162+ out := &pb.Commit{
163+ Id: c.ID.String(),
164+ Author: signatureToPB(c.Author),
165+ Committer: signatureToPB(c.Committer),
166+ Summary: c.Summary,
167+ Message: c.Message,
168+ }
169+ for _, p := range c.Parents {
170+ out.ParentIds = append(out.ParentIds, p.String())
171+ }
172+ return out
173+}
174+
175+func treeEntryToPB(e domain.TreeEntry) *pb.TreeEntry {
176+ out := &pb.TreeEntry{
177+ Name: e.Name,
178+ Path: e.Path,
179+ Kind: entryKindToPB(e.Kind),
180+ Mode: e.Mode,
181+ Id: e.ID.String(),
182+ Size: e.Size,
183+ }
184+ if e.LastCommit != nil {
185+ out.LastCommit = commitToPB(*e.LastCommit)
186+ }
187+ return out
188+}
189+
190+func blobInfoToPB(b domain.BlobInfo) *pb.BlobInfo {
191+ return &pb.BlobInfo{
192+ Id: b.ID.String(),
193+ Path: b.Path,
194+ Size: b.Size,
195+ Binary: b.Binary,
196+ MediaType: b.MediaType,
197+ Mode: b.Mode,
198+ }
199+}
200+
201+func fileStatusToPB(s domain.FileStatus) pb.FileStatus {
202+ switch s {
203+ case domain.FileAdded:
204+ return pb.FileStatus_FILE_STATUS_ADDED
205+ case domain.FileDeleted:
206+ return pb.FileStatus_FILE_STATUS_DELETED
207+ case domain.FileModified:
208+ return pb.FileStatus_FILE_STATUS_MODIFIED
209+ case domain.FileRenamed:
210+ return pb.FileStatus_FILE_STATUS_RENAMED
211+ case domain.FileCopied:
212+ return pb.FileStatus_FILE_STATUS_COPIED
213+ case domain.FileTypeChanged:
214+ return pb.FileStatus_FILE_STATUS_TYPE_CHANGED
215+ }
216+ return pb.FileStatus_FILE_STATUS_UNSPECIFIED
217+}
218+
219+func lineKindToPB(k domain.LineKind) pb.LineKind {
220+ switch k {
221+ case domain.LineContext:
222+ return pb.LineKind_LINE_KIND_CONTEXT
223+ case domain.LineAddition:
224+ return pb.LineKind_LINE_KIND_ADDITION
225+ case domain.LineDeletion:
226+ return pb.LineKind_LINE_KIND_DELETION
227+ }
228+ return pb.LineKind_LINE_KIND_UNSPECIFIED
229+}
230+
231+func hunkToPB(h domain.Hunk) *pb.Hunk {
232+ out := &pb.Hunk{
233+ OldStart: int32(h.OldStart), OldLines: int32(h.OldLines),
234+ NewStart: int32(h.NewStart), NewLines: int32(h.NewLines),
235+ Header: h.Header,
236+ Lines: make([]*pb.Line, 0, len(h.Lines)),
237+ }
238+ for _, l := range h.Lines {
239+ out.Lines = append(out.Lines, &pb.Line{
240+ Kind: lineKindToPB(l.Kind),
241+ OldNumber: int32(l.OldNumber),
242+ NewNumber: int32(l.NewNumber),
243+ Text: l.Text,
244+ NoNewlineAtEof: l.NoNewlineAtEOF,
245+ })
246+ }
247+ return out
248+}
249+
250+func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
251+ out := &pb.FileDiff{
252+ OldPath: f.OldPath, NewPath: f.NewPath,
253+ Status: fileStatusToPB(f.Status),
254+ OldMode: f.OldMode, NewMode: f.NewMode,
255+ OldId: f.OldID.String(), NewId: f.NewID.String(),
256+ Binary: f.Binary,
257+ Additions: int32(f.Additions),
258+ Deletions: int32(f.Deletions),
259+ Similarity: int32(f.Similarity),
260+ Truncated: f.Truncated,
261+ }
262+ for _, h := range f.Hunks {
263+ out.Hunks = append(out.Hunks, hunkToPB(h))
264+ }
265+ return out
266+}
267+
268+func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {
269+ out := &pb.GetCommitResponse{
270+ Commit: commitToPB(d.Commit),
271+ Stats: &pb.DiffStats{
272+ FilesChanged: int32(d.Stats.FilesChanged),
273+ Additions: int32(d.Stats.Additions),
274+ Deletions: int32(d.Stats.Deletions),
275+ },
276+ Diff: &pb.Diff{Truncated: d.Diff.Truncated},
277+ }
278+ for _, f := range d.Diff.Files {
279+ out.Diff.Files = append(out.Diff.Files, fileDiffToPB(f))
280+ }
281+ return out
282+}
283+
284+// oidToPB turns the zero object id of a ref update (no object) into "".
285+func oidToPB(oid string) string {
286+ if oid == domain.ZeroOID {
287+ return ""
288+ }
289+ return oid
290+}
291+
292+func refUpdatesToPB(updates []domain.RefUpdate) []*pb.RefUpdate {
293+ out := make([]*pb.RefUpdate, 0, len(updates))
294+ for _, u := range updates {
295+ out = append(out, &pb.RefUpdate{Ref: u.Ref, OldId: oidToPB(u.Old), NewId: oidToPB(u.New)})
296+ }
297+ return out
298+}
299+
300+// watchEventToPB maps one item of a watch; nil when the event has no
301+// representation in the contract.
302+func watchEventToPB(we app.WatchEvent) *pb.Event {
303+ if we.Gap {
304+ return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}
305+ }
306+ out := &pb.Event{Id: we.ID}
307+ switch ev := we.Event.(type) {
308+ case domain.RepoCreated:
309+ out.Time = timestamp(ev.At)
310+ out.Kind = &pb.Event_RepoCreated{RepoCreated: &pb.RepoCreated{Repo: repoOrName(we, ev.Repo, ev.Visibility)}}
311+ case domain.RepoUpdated:
312+ out.Time = timestamp(ev.At)
313+ out.Kind = &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: repoOrName(we, ev.Repo, "")}}
314+ case domain.RepoDeleted:
315+ out.Time = timestamp(ev.At)
316+ out.Kind = &pb.Event_RepoDeleted{RepoDeleted: &pb.RepoDeleted{Repo: ev.Repo.String()}}
317+ case domain.PushEvent:
318+ out.Time = timestamp(ev.At)
319+ out.Kind = &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: ev.Repo.String(), RefUpdates: refUpdatesToPB(ev.Updates)}}
320+ default:
321+ return nil
322+ }
323+ return out
324+}
325+
326+func repoOrName(we app.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo {
327+ if we.Repo != nil {
328+ return repoToPB(*we.Repo)
329+ }
330+ return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}
331+}
@@ -0,0 +1,331 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "fmt"
7+ "time"
8+
9+ "google.golang.org/protobuf/types/known/timestamppb"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/app"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// This file maps domain values to the messages of the contract and back.
17+// Messages are DTOs: the domain never leaks into them, nor they into the
18+// domain.
19+
20+func timestamp(t time.Time) *timestamppb.Timestamp {
21+ if t.IsZero() {
22+ return nil
23+ }
24+ return timestamppb.New(t)
25+}
26+
27+func goTime(ts *timestamppb.Timestamp) time.Time {
28+ if ts == nil {
29+ return time.Time{}
30+ }
31+ return ts.AsTime()
32+}
33+
34+func visibilityToPB(v domain.Visibility) pb.Visibility {
35+ switch v {
36+ case domain.VisibilityPublic:
37+ return pb.Visibility_VISIBILITY_PUBLIC
38+ case domain.VisibilityPrivate:
39+ return pb.Visibility_VISIBILITY_PRIVATE
40+ }
41+ return pb.Visibility_VISIBILITY_UNSPECIFIED
42+}
43+
44+// visibilityFromPB maps a visibility of a request. Unspecified is the empty
45+// domain.Visibility, which the use cases treat as "default".
46+func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) {
47+ switch v {
48+ case pb.Visibility_VISIBILITY_UNSPECIFIED:
49+ return "", nil
50+ case pb.Visibility_VISIBILITY_PUBLIC:
51+ return domain.VisibilityPublic, nil
52+ case pb.Visibility_VISIBILITY_PRIVATE:
53+ return domain.VisibilityPrivate, nil
54+ }
55+ return "", fmt.Errorf("%w: %v", domain.ErrInvalidVisibility, v)
56+}
57+
58+func repoToPB(v app.RepoView) *pb.Repo {
59+ return &pb.Repo{
60+ Name: v.Name.String(),
61+ Description: v.Description,
62+ Visibility: visibilityToPB(v.Visibility),
63+ DefaultBranch: v.DefaultBranch,
64+ CreatedAt: timestamp(v.CreatedAt),
65+ PushedAt: timestamp(v.PushedAt),
66+ }
67+}
68+
69+var scopeToPB = map[domain.Scope]pb.Scope{
70+ domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,
71+ domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,
72+ domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
73+ domain.ScopeMRWrite: pb.Scope_SCOPE_MR_WRITE,
74+ domain.ScopeMRMerge: pb.Scope_SCOPE_MR_MERGE,
75+ domain.ScopeCIWrite: pb.Scope_SCOPE_CI_WRITE,
76+ domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
77+ domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
78+ domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
79+}
80+
81+func scopeFromPB(s pb.Scope) (domain.Scope, error) {
82+ for d, p := range scopeToPB {
83+ if p == s {
84+ return d, nil
85+ }
86+ }
87+ return "", fmt.Errorf("%w: %v", domain.ErrInvalidScope, s)
88+}
89+
90+func tokenToPB(t domain.Token) *pb.Token {
91+ out := &pb.Token{
92+ Id: t.ID,
93+ Label: t.Label,
94+ ExpiresAt: timestamp(t.ExpiresAt),
95+ CreatedAt: timestamp(t.CreatedAt),
96+ LastUsedAt: timestamp(t.LastUsedAt),
97+ }
98+ for _, s := range t.Scopes {
99+ out.Scopes = append(out.Scopes, scopeToPB[s])
100+ }
101+ for _, r := range t.Repos {
102+ out.Repos = append(out.Repos, r.String())
103+ }
104+ return out
105+}
106+
107+func refKindToPB(k domain.RefKind) pb.RefKind {
108+ switch k {
109+ case domain.RefKindBranch:
110+ return pb.RefKind_REF_KIND_BRANCH
111+ case domain.RefKindTag:
112+ return pb.RefKind_REF_KIND_TAG
113+ case domain.RefKindHead:
114+ return pb.RefKind_REF_KIND_HEAD
115+ }
116+ return pb.RefKind_REF_KIND_UNSPECIFIED
117+}
118+
119+func refKindFromPB(k pb.RefKind) (domain.RefKind, error) {
120+ switch k {
121+ case pb.RefKind_REF_KIND_BRANCH:
122+ return domain.RefKindBranch, nil
123+ case pb.RefKind_REF_KIND_TAG:
124+ return domain.RefKindTag, nil
125+ case pb.RefKind_REF_KIND_HEAD:
126+ return domain.RefKindHead, nil
127+ }
128+ return "", fmt.Errorf("%w: %v", domain.ErrInvalidRefKind, k)
129+}
130+
131+func refToPB(r domain.Ref) *pb.Ref {
132+ return &pb.Ref{
133+ Name: r.Name,
134+ ShortName: r.ShortName,
135+ Kind: refKindToPB(r.Kind),
136+ Id: r.ID.String(),
137+ CommitId: r.CommitID.String(),
138+ }
139+}
140+
141+func entryKindToPB(k domain.EntryKind) pb.EntryKind {
142+ switch k {
143+ case domain.EntryFile:
144+ return pb.EntryKind_ENTRY_KIND_FILE
145+ case domain.EntryExecutable:
146+ return pb.EntryKind_ENTRY_KIND_EXECUTABLE
147+ case domain.EntryDirectory:
148+ return pb.EntryKind_ENTRY_KIND_DIRECTORY
149+ case domain.EntrySymlink:
150+ return pb.EntryKind_ENTRY_KIND_SYMLINK
151+ case domain.EntrySubmodule:
152+ return pb.EntryKind_ENTRY_KIND_SUBMODULE
153+ }
154+ return pb.EntryKind_ENTRY_KIND_UNSPECIFIED
155+}
156+
157+func signatureToPB(s domain.Signature) *pb.Signature {
158+ return &pb.Signature{Name: s.Name, Email: s.Email, Time: timestamp(s.Time)}
159+}
160+
161+func commitToPB(c domain.Commit) *pb.Commit {
162+ out := &pb.Commit{
163+ Id: c.ID.String(),
164+ Author: signatureToPB(c.Author),
165+ Committer: signatureToPB(c.Committer),
166+ Summary: c.Summary,
167+ Message: c.Message,
168+ }
169+ for _, p := range c.Parents {
170+ out.ParentIds = append(out.ParentIds, p.String())
171+ }
172+ return out
173+}
174+
175+func treeEntryToPB(e domain.TreeEntry) *pb.TreeEntry {
176+ out := &pb.TreeEntry{
177+ Name: e.Name,
178+ Path: e.Path,
179+ Kind: entryKindToPB(e.Kind),
180+ Mode: e.Mode,
181+ Id: e.ID.String(),
182+ Size: e.Size,
183+ }
184+ if e.LastCommit != nil {
185+ out.LastCommit = commitToPB(*e.LastCommit)
186+ }
187+ return out
188+}
189+
190+func blobInfoToPB(b domain.BlobInfo) *pb.BlobInfo {
191+ return &pb.BlobInfo{
192+ Id: b.ID.String(),
193+ Path: b.Path,
194+ Size: b.Size,
195+ Binary: b.Binary,
196+ MediaType: b.MediaType,
197+ Mode: b.Mode,
198+ }
199+}
200+
201+func fileStatusToPB(s domain.FileStatus) pb.FileStatus {
202+ switch s {
203+ case domain.FileAdded:
204+ return pb.FileStatus_FILE_STATUS_ADDED
205+ case domain.FileDeleted:
206+ return pb.FileStatus_FILE_STATUS_DELETED
207+ case domain.FileModified:
208+ return pb.FileStatus_FILE_STATUS_MODIFIED
209+ case domain.FileRenamed:
210+ return pb.FileStatus_FILE_STATUS_RENAMED
211+ case domain.FileCopied:
212+ return pb.FileStatus_FILE_STATUS_COPIED
213+ case domain.FileTypeChanged:
214+ return pb.FileStatus_FILE_STATUS_TYPE_CHANGED
215+ }
216+ return pb.FileStatus_FILE_STATUS_UNSPECIFIED
217+}
218+
219+func lineKindToPB(k domain.LineKind) pb.LineKind {
220+ switch k {
221+ case domain.LineContext:
222+ return pb.LineKind_LINE_KIND_CONTEXT
223+ case domain.LineAddition:
224+ return pb.LineKind_LINE_KIND_ADDITION
225+ case domain.LineDeletion:
226+ return pb.LineKind_LINE_KIND_DELETION
227+ }
228+ return pb.LineKind_LINE_KIND_UNSPECIFIED
229+}
230+
231+func hunkToPB(h domain.Hunk) *pb.Hunk {
232+ out := &pb.Hunk{
233+ OldStart: int32(h.OldStart), OldLines: int32(h.OldLines),
234+ NewStart: int32(h.NewStart), NewLines: int32(h.NewLines),
235+ Header: h.Header,
236+ Lines: make([]*pb.Line, 0, len(h.Lines)),
237+ }
238+ for _, l := range h.Lines {
239+ out.Lines = append(out.Lines, &pb.Line{
240+ Kind: lineKindToPB(l.Kind),
241+ OldNumber: int32(l.OldNumber),
242+ NewNumber: int32(l.NewNumber),
243+ Text: l.Text,
244+ NoNewlineAtEof: l.NoNewlineAtEOF,
245+ })
246+ }
247+ return out
248+}
249+
250+func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
251+ out := &pb.FileDiff{
252+ OldPath: f.OldPath, NewPath: f.NewPath,
253+ Status: fileStatusToPB(f.Status),
254+ OldMode: f.OldMode, NewMode: f.NewMode,
255+ OldId: f.OldID.String(), NewId: f.NewID.String(),
256+ Binary: f.Binary,
257+ Additions: int32(f.Additions),
258+ Deletions: int32(f.Deletions),
259+ Similarity: int32(f.Similarity),
260+ Truncated: f.Truncated,
261+ }
262+ for _, h := range f.Hunks {
263+ out.Hunks = append(out.Hunks, hunkToPB(h))
264+ }
265+ return out
266+}
267+
268+func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {
269+ out := &pb.GetCommitResponse{
270+ Commit: commitToPB(d.Commit),
271+ Stats: &pb.DiffStats{
272+ FilesChanged: int32(d.Stats.FilesChanged),
273+ Additions: int32(d.Stats.Additions),
274+ Deletions: int32(d.Stats.Deletions),
275+ },
276+ Diff: &pb.Diff{Truncated: d.Diff.Truncated},
277+ }
278+ for _, f := range d.Diff.Files {
279+ out.Diff.Files = append(out.Diff.Files, fileDiffToPB(f))
280+ }
281+ return out
282+}
283+
284+// oidToPB turns the zero object id of a ref update (no object) into "".
285+func oidToPB(oid string) string {
286+ if oid == domain.ZeroOID {
287+ return ""
288+ }
289+ return oid
290+}
291+
292+func refUpdatesToPB(updates []domain.RefUpdate) []*pb.RefUpdate {
293+ out := make([]*pb.RefUpdate, 0, len(updates))
294+ for _, u := range updates {
295+ out = append(out, &pb.RefUpdate{Ref: u.Ref, OldId: oidToPB(u.Old), NewId: oidToPB(u.New)})
296+ }
297+ return out
298+}
299+
300+// watchEventToPB maps one item of a watch; nil when the event has no
301+// representation in the contract.
302+func watchEventToPB(we app.WatchEvent) *pb.Event {
303+ if we.Gap {
304+ return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}
305+ }
306+ out := &pb.Event{Id: we.ID}
307+ switch ev := we.Event.(type) {
308+ case domain.RepoCreated:
309+ out.Time = timestamp(ev.At)
310+ out.Kind = &pb.Event_RepoCreated{RepoCreated: &pb.RepoCreated{Repo: repoOrName(we, ev.Repo, ev.Visibility)}}
311+ case domain.RepoUpdated:
312+ out.Time = timestamp(ev.At)
313+ out.Kind = &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: repoOrName(we, ev.Repo, "")}}
314+ case domain.RepoDeleted:
315+ out.Time = timestamp(ev.At)
316+ out.Kind = &pb.Event_RepoDeleted{RepoDeleted: &pb.RepoDeleted{Repo: ev.Repo.String()}}
317+ case domain.PushEvent:
318+ out.Time = timestamp(ev.At)
319+ out.Kind = &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: ev.Repo.String(), RefUpdates: refUpdatesToPB(ev.Updates)}}
320+ default:
321+ return nil
322+ }
323+ return out
324+}
325+
326+func repoOrName(we app.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo {
327+ if we.Repo != nil {
328+ return repoToPB(*we.Repo)
329+ }
330+ return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}
331+}
A internal/adapters/rpc/errors.go +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "connectrpc.com/connect"
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// connectError maps an error of a use case to a Connect error with the code
16+// the contract documents. Errors it does not know are internal: they are
17+// logged here and the client only sees a generic message.
18+func connectError(ctx context.Context, err error) error {
19+ if err == nil {
20+ return nil
21+ }
22+ var ce *connect.Error
23+ if errors.As(err, &ce) {
24+ return err
25+ }
26+ if code, ok := codeOf(err); ok {
27+ return connect.NewError(code, err)
28+ }
29+ log := zerowrap.FromCtx(ctx)
30+ log.Error().Err(err).Msg("api request failed")
31+ return connect.NewError(connect.CodeInternal, errors.New("internal error"))
32+}
33+
34+// codeOf finds the Connect code of a domain error.
35+func codeOf(err error) (connect.Code, bool) {
36+ switch {
37+ case errors.Is(err, context.Canceled):
38+ return connect.CodeCanceled, true
39+ case errors.Is(err, context.DeadlineExceeded):
40+ return connect.CodeDeadlineExceeded, true
41+ case errors.Is(err, domain.ErrNotFound):
42+ return connect.CodeNotFound, true
43+ case errors.Is(err, domain.ErrAlreadyExists):
44+ return connect.CodeAlreadyExists, true
45+ case errors.Is(err, domain.ErrUnauthenticated):
46+ return connect.CodeUnauthenticated, true
47+ case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
48+ return connect.CodePermissionDenied, true
49+ case errors.Is(err, domain.ErrPreconditionFailed):
50+ return connect.CodeFailedPrecondition, true
51+ case errors.Is(err, domain.ErrConflict):
52+ return connect.CodeAborted, true
53+ }
54+ for _, invalid := range invalidErrors {
55+ if errors.Is(err, invalid) {
56+ return connect.CodeInvalidArgument, true
57+ }
58+ }
59+ return 0, false
60+}
61+
62+// invalidErrors are the domain errors that mean the request was malformed.
63+var invalidErrors = []error{
64+ domain.ErrInvalid, domain.ErrInvalidName, domain.ErrInvalidVisibility,
65+ domain.ErrInvalidScope, domain.ErrInvalidToken,
66+ domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath,
67+ domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind,
68+ domain.ErrNotDirectory, domain.ErrNotFile,
69+}
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "connectrpc.com/connect"
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// connectError maps an error of a use case to a Connect error with the code
16+// the contract documents. Errors it does not know are internal: they are
17+// logged here and the client only sees a generic message.
18+func connectError(ctx context.Context, err error) error {
19+ if err == nil {
20+ return nil
21+ }
22+ var ce *connect.Error
23+ if errors.As(err, &ce) {
24+ return err
25+ }
26+ if code, ok := codeOf(err); ok {
27+ return connect.NewError(code, err)
28+ }
29+ log := zerowrap.FromCtx(ctx)
30+ log.Error().Err(err).Msg("api request failed")
31+ return connect.NewError(connect.CodeInternal, errors.New("internal error"))
32+}
33+
34+// codeOf finds the Connect code of a domain error.
35+func codeOf(err error) (connect.Code, bool) {
36+ switch {
37+ case errors.Is(err, context.Canceled):
38+ return connect.CodeCanceled, true
39+ case errors.Is(err, context.DeadlineExceeded):
40+ return connect.CodeDeadlineExceeded, true
41+ case errors.Is(err, domain.ErrNotFound):
42+ return connect.CodeNotFound, true
43+ case errors.Is(err, domain.ErrAlreadyExists):
44+ return connect.CodeAlreadyExists, true
45+ case errors.Is(err, domain.ErrUnauthenticated):
46+ return connect.CodeUnauthenticated, true
47+ case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
48+ return connect.CodePermissionDenied, true
49+ case errors.Is(err, domain.ErrPreconditionFailed):
50+ return connect.CodeFailedPrecondition, true
51+ case errors.Is(err, domain.ErrConflict):
52+ return connect.CodeAborted, true
53+ }
54+ for _, invalid := range invalidErrors {
55+ if errors.Is(err, invalid) {
56+ return connect.CodeInvalidArgument, true
57+ }
58+ }
59+ return 0, false
60+}
61+
62+// invalidErrors are the domain errors that mean the request was malformed.
63+var invalidErrors = []error{
64+ domain.ErrInvalid, domain.ErrInvalidName, domain.ErrInvalidVisibility,
65+ domain.ErrInvalidScope, domain.ErrInvalidToken,
66+ domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath,
67+ domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind,
68+ domain.ErrNotDirectory, domain.ErrNotFile,
69+}
A internal/adapters/rpc/event.go +35 -0
@@ -0,0 +1,35 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/app"
12+)
13+
14+// eventService implements ermiteconnect.EventServiceHandler.
15+type eventService struct{ events *app.Events }
16+
17+// Watch streams events until the client goes away or the server stops. The
18+// stream ends with success in both cases: the contract has no error for
19+// "server is shutting down".
20+func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error {
21+ params := app.WatchParams{
22+ Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(),
23+ // A client's call returns when the response begins: send the headers
24+ // now, not with the first event, which may be far away.
25+ Ready: startedFunc(ctx),
26+ }
27+ err := s.events.Watch(ctx, caller(ctx), params, func(we app.WatchEvent) error {
28+ ev := watchEventToPB(we)
29+ if ev == nil {
30+ return nil
31+ }
32+ return stream.Send(&pb.WatchResponse{Event: ev})
33+ })
34+ return connectError(ctx, err)
35+}
@@ -0,0 +1,35 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/app"
12+)
13+
14+// eventService implements ermiteconnect.EventServiceHandler.
15+type eventService struct{ events *app.Events }
16+
17+// Watch streams events until the client goes away or the server stops. The
18+// stream ends with success in both cases: the contract has no error for
19+// "server is shutting down".
20+func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error {
21+ params := app.WatchParams{
22+ Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(),
23+ // A client's call returns when the response begins: send the headers
24+ // now, not with the first event, which may be far away.
25+ Ready: startedFunc(ctx),
26+ }
27+ err := s.events.Watch(ctx, caller(ctx), params, func(we app.WatchEvent) error {
28+ ev := watchEventToPB(we)
29+ if ev == nil {
30+ return nil
31+ }
32+ return stream.Send(&pb.WatchResponse{Event: ev})
33+ })
34+ return connectError(ctx, err)
35+}
A internal/adapters/rpc/git.go +180 -0
@@ -0,0 +1,180 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+
11+ "connectrpc.com/connect"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/app"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// BlobChunkSize is the largest chunk of a GetBlob stream.
19+const BlobChunkSize = 64 << 10
20+
21+// gitService implements ermiteconnect.GitServiceHandler.
22+type gitService struct{ browse *app.Browse }
23+
24+// target is the repository and revision most requests start with.
25+func target(repo, revision string) (domain.RepoName, domain.Revision, error) {
26+ name, err := domain.ParseRepoName(repo)
27+ if err != nil {
28+ return domain.RepoName{}, domain.Revision{}, err
29+ }
30+ rev, err := domain.ParseRevision(revision)
31+ if err != nil {
32+ return domain.RepoName{}, domain.Revision{}, err
33+ }
34+ return name, rev, nil
35+}
36+
37+func (s *gitService) ListRefs(ctx context.Context, req *connect.Request[pb.ListRefsRequest]) (*connect.Response[pb.ListRefsResponse], error) {
38+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
39+ if err != nil {
40+ return nil, connectError(ctx, err)
41+ }
42+ params := app.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()}
43+ for _, k := range req.Msg.GetKinds() {
44+ kind, err := refKindFromPB(k)
45+ if err != nil {
46+ return nil, connectError(ctx, err)
47+ }
48+ params.Kinds = append(params.Kinds, kind)
49+ }
50+ page, err := s.browse.ListRefs(ctx, caller(ctx), name, params)
51+ if err != nil {
52+ return nil, connectError(ctx, err)
53+ }
54+ out := &pb.ListRefsResponse{NextPageToken: page.NextPageToken, Refs: make([]*pb.Ref, 0, len(page.Refs))}
55+ for _, r := range page.Refs {
56+ out.Refs = append(out.Refs, refToPB(r))
57+ }
58+ return connect.NewResponse(out), nil
59+}
60+
61+func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTreeRequest]) (*connect.Response[pb.GetTreeResponse], error) {
62+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
63+ if err != nil {
64+ return nil, connectError(ctx, err)
65+ }
66+ path, err := domain.ParseTreePath(req.Msg.GetPath())
67+ if err != nil {
68+ return nil, connectError(ctx, err)
69+ }
70+ tree, err := s.browse.GetTree(ctx, caller(ctx), name, rev, path, req.Msg.GetIncludeLastCommit())
71+ if err != nil {
72+ return nil, connectError(ctx, err)
73+ }
74+ out := &pb.GetTreeResponse{
75+ CommitId: tree.CommitID.String(),
76+ TreeId: tree.ID.String(),
77+ Entries: make([]*pb.TreeEntry, 0, len(tree.Entries)),
78+ }
79+ for _, e := range tree.Entries {
80+ out.Entries = append(out.Entries, treeEntryToPB(e))
81+ }
82+ return connect.NewResponse(out), nil
83+}
84+
85+// GetBlob streams the blob in chunks of BlobChunkSize. The first message
86+// carries the metadata and the first chunk.
87+func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {
88+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
89+ if err != nil {
90+ return connectError(ctx, err)
91+ }
92+ if req.Msg.GetPath() == "" {
93+ return connectError(ctx, invalidPath("path is required"))
94+ }
95+ path, err := domain.ParseTreePath(req.Msg.GetPath())
96+ if err != nil {
97+ return connectError(ctx, err)
98+ }
99+ info, body, err := s.browse.OpenBlob(ctx, caller(ctx), name, rev, path)
100+ if err != nil {
101+ return connectError(ctx, err)
102+ }
103+ defer func() { _ = body.Close() }()
104+
105+ // Send marshals the message before it returns, so the buffer is reused.
106+ buf := make([]byte, BlobChunkSize)
107+ first := true
108+ for {
109+ n, rerr := io.ReadFull(body, buf)
110+ eof := errors.Is(rerr, io.EOF) || errors.Is(rerr, io.ErrUnexpectedEOF)
111+ if rerr != nil && !eof {
112+ return connectError(ctx, rerr)
113+ }
114+ // An empty file still gets the message with the metadata.
115+ if n > 0 || first {
116+ msg := &pb.GetBlobResponse{Chunk: buf[:n]}
117+ if first {
118+ msg.Info = blobInfoToPB(info)
119+ first = false
120+ }
121+ if err := stream.Send(msg); err != nil {
122+ return err
123+ }
124+ }
125+ if eof {
126+ return nil
127+ }
128+ }
129+}
130+
131+func invalidPath(msg string) error {
132+ return fmt.Errorf("%w: %s", domain.ErrInvalidPath, msg)
133+}
134+
135+func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) {
136+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
137+ if err != nil {
138+ return nil, connectError(ctx, err)
139+ }
140+ path, err := domain.ParseTreePath(req.Msg.GetPath())
141+ if err != nil {
142+ return nil, connectError(ctx, err)
143+ }
144+ page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{
145+ Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
146+ })
147+ if err != nil {
148+ return nil, connectError(ctx, err)
149+ }
150+ out := &pb.ListCommitsResponse{NextPageToken: page.NextPageToken, Commits: make([]*pb.Commit, 0, len(page.Commits))}
151+ for _, c := range page.Commits {
152+ out.Commits = append(out.Commits, commitToPB(c))
153+ }
154+ return connect.NewResponse(out), nil
155+}
156+
157+func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetCommitRequest]) (*connect.Response[pb.GetCommitResponse], error) {
158+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
159+ if err != nil {
160+ return nil, connectError(ctx, err)
161+ }
162+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
163+ if err != nil {
164+ return nil, connectError(ctx, err)
165+ }
166+ return connect.NewResponse(commitDetailToPB(detail)), nil
167+}
168+
169+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
170+func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
171+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
172+ if err != nil {
173+ return nil, connectError(ctx, err)
174+ }
175+ res, err := s.browse.ResolveRevision(ctx, caller(ctx), name, rev)
176+ if err != nil {
177+ return nil, connectError(ctx, err)
178+ }
179+ return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: res.CommitID.String(), Ref: res.Ref}), nil
180+}
@@ -0,0 +1,180 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+
11+ "connectrpc.com/connect"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/app"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// BlobChunkSize is the largest chunk of a GetBlob stream.
19+const BlobChunkSize = 64 << 10
20+
21+// gitService implements ermiteconnect.GitServiceHandler.
22+type gitService struct{ browse *app.Browse }
23+
24+// target is the repository and revision most requests start with.
25+func target(repo, revision string) (domain.RepoName, domain.Revision, error) {
26+ name, err := domain.ParseRepoName(repo)
27+ if err != nil {
28+ return domain.RepoName{}, domain.Revision{}, err
29+ }
30+ rev, err := domain.ParseRevision(revision)
31+ if err != nil {
32+ return domain.RepoName{}, domain.Revision{}, err
33+ }
34+ return name, rev, nil
35+}
36+
37+func (s *gitService) ListRefs(ctx context.Context, req *connect.Request[pb.ListRefsRequest]) (*connect.Response[pb.ListRefsResponse], error) {
38+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
39+ if err != nil {
40+ return nil, connectError(ctx, err)
41+ }
42+ params := app.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()}
43+ for _, k := range req.Msg.GetKinds() {
44+ kind, err := refKindFromPB(k)
45+ if err != nil {
46+ return nil, connectError(ctx, err)
47+ }
48+ params.Kinds = append(params.Kinds, kind)
49+ }
50+ page, err := s.browse.ListRefs(ctx, caller(ctx), name, params)
51+ if err != nil {
52+ return nil, connectError(ctx, err)
53+ }
54+ out := &pb.ListRefsResponse{NextPageToken: page.NextPageToken, Refs: make([]*pb.Ref, 0, len(page.Refs))}
55+ for _, r := range page.Refs {
56+ out.Refs = append(out.Refs, refToPB(r))
57+ }
58+ return connect.NewResponse(out), nil
59+}
60+
61+func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTreeRequest]) (*connect.Response[pb.GetTreeResponse], error) {
62+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
63+ if err != nil {
64+ return nil, connectError(ctx, err)
65+ }
66+ path, err := domain.ParseTreePath(req.Msg.GetPath())
67+ if err != nil {
68+ return nil, connectError(ctx, err)
69+ }
70+ tree, err := s.browse.GetTree(ctx, caller(ctx), name, rev, path, req.Msg.GetIncludeLastCommit())
71+ if err != nil {
72+ return nil, connectError(ctx, err)
73+ }
74+ out := &pb.GetTreeResponse{
75+ CommitId: tree.CommitID.String(),
76+ TreeId: tree.ID.String(),
77+ Entries: make([]*pb.TreeEntry, 0, len(tree.Entries)),
78+ }
79+ for _, e := range tree.Entries {
80+ out.Entries = append(out.Entries, treeEntryToPB(e))
81+ }
82+ return connect.NewResponse(out), nil
83+}
84+
85+// GetBlob streams the blob in chunks of BlobChunkSize. The first message
86+// carries the metadata and the first chunk.
87+func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {
88+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
89+ if err != nil {
90+ return connectError(ctx, err)
91+ }
92+ if req.Msg.GetPath() == "" {
93+ return connectError(ctx, invalidPath("path is required"))
94+ }
95+ path, err := domain.ParseTreePath(req.Msg.GetPath())
96+ if err != nil {
97+ return connectError(ctx, err)
98+ }
99+ info, body, err := s.browse.OpenBlob(ctx, caller(ctx), name, rev, path)
100+ if err != nil {
101+ return connectError(ctx, err)
102+ }
103+ defer func() { _ = body.Close() }()
104+
105+ // Send marshals the message before it returns, so the buffer is reused.
106+ buf := make([]byte, BlobChunkSize)
107+ first := true
108+ for {
109+ n, rerr := io.ReadFull(body, buf)
110+ eof := errors.Is(rerr, io.EOF) || errors.Is(rerr, io.ErrUnexpectedEOF)
111+ if rerr != nil && !eof {
112+ return connectError(ctx, rerr)
113+ }
114+ // An empty file still gets the message with the metadata.
115+ if n > 0 || first {
116+ msg := &pb.GetBlobResponse{Chunk: buf[:n]}
117+ if first {
118+ msg.Info = blobInfoToPB(info)
119+ first = false
120+ }
121+ if err := stream.Send(msg); err != nil {
122+ return err
123+ }
124+ }
125+ if eof {
126+ return nil
127+ }
128+ }
129+}
130+
131+func invalidPath(msg string) error {
132+ return fmt.Errorf("%w: %s", domain.ErrInvalidPath, msg)
133+}
134+
135+func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) {
136+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
137+ if err != nil {
138+ return nil, connectError(ctx, err)
139+ }
140+ path, err := domain.ParseTreePath(req.Msg.GetPath())
141+ if err != nil {
142+ return nil, connectError(ctx, err)
143+ }
144+ page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{
145+ Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
146+ })
147+ if err != nil {
148+ return nil, connectError(ctx, err)
149+ }
150+ out := &pb.ListCommitsResponse{NextPageToken: page.NextPageToken, Commits: make([]*pb.Commit, 0, len(page.Commits))}
151+ for _, c := range page.Commits {
152+ out.Commits = append(out.Commits, commitToPB(c))
153+ }
154+ return connect.NewResponse(out), nil
155+}
156+
157+func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetCommitRequest]) (*connect.Response[pb.GetCommitResponse], error) {
158+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
159+ if err != nil {
160+ return nil, connectError(ctx, err)
161+ }
162+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
163+ if err != nil {
164+ return nil, connectError(ctx, err)
165+ }
166+ return connect.NewResponse(commitDetailToPB(detail)), nil
167+}
168+
169+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
170+func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
171+ name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
172+ if err != nil {
173+ return nil, connectError(ctx, err)
174+ }
175+ res, err := s.browse.ResolveRevision(ctx, caller(ctx), name, rev)
176+ if err != nil {
177+ return nil, connectError(ctx, err)
178+ }
179+ return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: res.CommitID.String(), Ref: res.Ref}), nil
180+}
A internal/adapters/rpc/inprocess.go +120 -0
@@ -0,0 +1,120 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "io"
7+ "net/http"
8+ "sync"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+// inProcessHost is the host of the URLs of in-process calls. The name is
14+// never resolved.
15+const inProcessHost = "ermite.invalid"
16+
17+// InProcessURL is the base URL to give to the generated Connect clients that
18+// use InProcessClient.
19+const InProcessURL = "http://" + inProcessHost
20+
21+// InProcessClient returns an HTTP client whose requests are served by the
22+// handlers of the API in this process: no socket, no network hop. Build the
23+// generated Connect clients on it:
24+//
25+// hc := api.InProcessClient()
26+// repos := ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL)
27+//
28+// The caller is the principal found in the context of each call
29+// (domain.ContextWithPrincipal), anonymous when there is none; the
30+// Authorization header is ignored. This is the identity of the web (P5):
31+// anonymous for visitors, the owner for a session. Only code in this process
32+// can put a principal in a context, which is why the interceptor trusts it.
33+// Unary and server-streaming calls work, with all the codecs; the request
34+// log and the error mapping are those of remote calls.
35+func (a *API) InProcessClient() *http.Client {
36+ return &http.Client{Transport: inProcessTransport{h: a.handler}}
37+}
38+
39+type inProcessTransport struct{ h http.Handler }
40+
41+// RoundTrip serves req with the handler in a goroutine, and returns once the
42+// handler wrote its status. The body of the response is a pipe the handler
43+// writes to, so that streams are delivered as they are sent.
44+func (t inProcessTransport) RoundTrip(req *http.Request) (*http.Response, error) {
45+ ctx := req.Context()
46+ p, ok := domain.PrincipalFromContext(ctx)
47+ if !ok {
48+ p = domain.Anonymous()
49+ }
50+ sreq := req.Clone(domain.ContextWithPrincipal(ctx, p))
51+ sreq.RemoteAddr = "in-process"
52+ sreq.RequestURI = req.URL.RequestURI()
53+ if sreq.Body == nil {
54+ sreq.Body = http.NoBody
55+ }
56+ sreq.Header.Del("Authorization")
57+
58+ pr, pw := io.Pipe()
59+ w := &pipeResponse{header: http.Header{}, pw: pw, ready: make(chan struct{})}
60+ go func() {
61+ defer func() {
62+ if r := recover(); r != nil {
63+ w.writeHeader(http.StatusInternalServerError)
64+ _ = pw.CloseWithError(io.ErrUnexpectedEOF)
65+ }
66+ }()
67+ t.h.ServeHTTP(w, sreq)
68+ w.writeHeader(http.StatusOK) // a handler that wrote nothing
69+ _ = pw.Close()
70+ }()
71+
72+ select {
73+ case <-w.ready:
74+ case <-ctx.Done():
75+ _ = pr.CloseWithError(ctx.Err())
76+ return nil, ctx.Err()
77+ }
78+ return &http.Response{
79+ Status: http.StatusText(w.status),
80+ StatusCode: w.status,
81+ Proto: "HTTP/1.1",
82+ ProtoMajor: 1,
83+ ProtoMinor: 1,
84+ Header: w.sent,
85+ Body: pr,
86+ ContentLength: -1,
87+ Request: req,
88+ }, nil
89+}
90+
91+// pipeResponse is the http.ResponseWriter of an in-process call.
92+type pipeResponse struct {
93+ header http.Header
94+ pw *io.PipeWriter
95+
96+ once sync.Once
97+ ready chan struct{}
98+ status int
99+ sent http.Header // header as of WriteHeader
100+}
101+
102+func (w *pipeResponse) Header() http.Header { return w.header }
103+
104+func (w *pipeResponse) WriteHeader(code int) { w.writeHeader(code) }
105+
106+func (w *pipeResponse) writeHeader(code int) {
107+ w.once.Do(func() {
108+ w.status, w.sent = code, w.header.Clone()
109+ close(w.ready)
110+ })
111+}
112+
113+func (w *pipeResponse) Write(b []byte) (int, error) {
114+ w.writeHeader(http.StatusOK)
115+ return w.pw.Write(b)
116+}
117+
118+// Flush implements http.Flusher, which streaming handlers require: the pipe
119+// delivers every write at once, there is nothing to flush but the header.
120+func (w *pipeResponse) Flush() { w.writeHeader(http.StatusOK) }
@@ -0,0 +1,120 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "io"
7+ "net/http"
8+ "sync"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+// inProcessHost is the host of the URLs of in-process calls. The name is
14+// never resolved.
15+const inProcessHost = "ermite.invalid"
16+
17+// InProcessURL is the base URL to give to the generated Connect clients that
18+// use InProcessClient.
19+const InProcessURL = "http://" + inProcessHost
20+
21+// InProcessClient returns an HTTP client whose requests are served by the
22+// handlers of the API in this process: no socket, no network hop. Build the
23+// generated Connect clients on it:
24+//
25+// hc := api.InProcessClient()
26+// repos := ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL)
27+//
28+// The caller is the principal found in the context of each call
29+// (domain.ContextWithPrincipal), anonymous when there is none; the
30+// Authorization header is ignored. This is the identity of the web (P5):
31+// anonymous for visitors, the owner for a session. Only code in this process
32+// can put a principal in a context, which is why the interceptor trusts it.
33+// Unary and server-streaming calls work, with all the codecs; the request
34+// log and the error mapping are those of remote calls.
35+func (a *API) InProcessClient() *http.Client {
36+ return &http.Client{Transport: inProcessTransport{h: a.handler}}
37+}
38+
39+type inProcessTransport struct{ h http.Handler }
40+
41+// RoundTrip serves req with the handler in a goroutine, and returns once the
42+// handler wrote its status. The body of the response is a pipe the handler
43+// writes to, so that streams are delivered as they are sent.
44+func (t inProcessTransport) RoundTrip(req *http.Request) (*http.Response, error) {
45+ ctx := req.Context()
46+ p, ok := domain.PrincipalFromContext(ctx)
47+ if !ok {
48+ p = domain.Anonymous()
49+ }
50+ sreq := req.Clone(domain.ContextWithPrincipal(ctx, p))
51+ sreq.RemoteAddr = "in-process"
52+ sreq.RequestURI = req.URL.RequestURI()
53+ if sreq.Body == nil {
54+ sreq.Body = http.NoBody
55+ }
56+ sreq.Header.Del("Authorization")
57+
58+ pr, pw := io.Pipe()
59+ w := &pipeResponse{header: http.Header{}, pw: pw, ready: make(chan struct{})}
60+ go func() {
61+ defer func() {
62+ if r := recover(); r != nil {
63+ w.writeHeader(http.StatusInternalServerError)
64+ _ = pw.CloseWithError(io.ErrUnexpectedEOF)
65+ }
66+ }()
67+ t.h.ServeHTTP(w, sreq)
68+ w.writeHeader(http.StatusOK) // a handler that wrote nothing
69+ _ = pw.Close()
70+ }()
71+
72+ select {
73+ case <-w.ready:
74+ case <-ctx.Done():
75+ _ = pr.CloseWithError(ctx.Err())
76+ return nil, ctx.Err()
77+ }
78+ return &http.Response{
79+ Status: http.StatusText(w.status),
80+ StatusCode: w.status,
81+ Proto: "HTTP/1.1",
82+ ProtoMajor: 1,
83+ ProtoMinor: 1,
84+ Header: w.sent,
85+ Body: pr,
86+ ContentLength: -1,
87+ Request: req,
88+ }, nil
89+}
90+
91+// pipeResponse is the http.ResponseWriter of an in-process call.
92+type pipeResponse struct {
93+ header http.Header
94+ pw *io.PipeWriter
95+
96+ once sync.Once
97+ ready chan struct{}
98+ status int
99+ sent http.Header // header as of WriteHeader
100+}
101+
102+func (w *pipeResponse) Header() http.Header { return w.header }
103+
104+func (w *pipeResponse) WriteHeader(code int) { w.writeHeader(code) }
105+
106+func (w *pipeResponse) writeHeader(code int) {
107+ w.once.Do(func() {
108+ w.status, w.sent = code, w.header.Clone()
109+ close(w.ready)
110+ })
111+}
112+
113+func (w *pipeResponse) Write(b []byte) (int, error) {
114+ w.writeHeader(http.StatusOK)
115+ return w.pw.Write(b)
116+}
117+
118+// Flush implements http.Flusher, which streaming handlers require: the pipe
119+// delivers every write at once, there is nothing to flush but the header.
120+func (w *pipeResponse) Flush() { w.writeHeader(http.StatusOK) }
A internal/adapters/rpc/interceptor.go +144 -0
@@ -0,0 +1,144 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "math"
9+ "net/http"
10+ "net/netip"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+ "bnema.dev/ermite/internal/clientlimit"
20+ "bnema.dev/ermite/internal/domain"
21+)
22+
23+var errInternal = errors.New("internal error")
24+
25+// interceptor authenticates the caller of every call and logs it.
26+//
27+// A request that carries "Authorization: Bearer <secret>" is authenticated
28+// with it; one without the header is anonymous. A secret that is not valid is
29+// unauthenticated and counts as a failure of the client address in the
30+// limiter; a client that used up its failures gets resource_exhausted (429 on
31+// the Connect protocol) whatever it presents, like on the Git routes.
32+//
33+// A context that already carries a principal (domain.ContextWithPrincipal) is
34+// trusted as it is: that is how the web calls the handlers in process. Such a
35+// context cannot come from the network.
36+type interceptor struct {
37+ auth Authenticator
38+ limiter *clientlimit.Limiter
39+ trusted []netip.Prefix
40+}
41+
42+func newInterceptor(cfg Config) *interceptor {
43+ return &interceptor{auth: cfg.Auth, limiter: cfg.Limiter, trusted: cfg.TrustedProxies}
44+}
45+
46+// authenticate returns the context to run the call in.
47+func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) {
48+ if _, ok := domain.PrincipalFromContext(ctx); ok {
49+ return ctx, nil
50+ }
51+ if procedure == ermiteconnect.ServerServiceGetVersionProcedure {
52+ // It needs no authentication, so that a client can check compatibility
53+ // first, even with a token that has expired.
54+ return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil
55+ }
56+ log := zerowrap.FromCtx(ctx)
57+ addr := clientlimit.ClientAddr(&http.Request{RemoteAddr: peerAddr, Header: h}, i.trusted)
58+ if blocked, wait := i.limiter.Blocked(ctx, addr); blocked {
59+ log.Warn().Str("client", addr).Msg("too many failed authentications, api request blocked")
60+ err := connect.NewError(connect.CodeResourceExhausted, errors.New("too many failed authentications"))
61+ err.Meta().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
62+ return ctx, err
63+ }
64+
65+ secret, present := bearer(h)
66+ if !present {
67+ return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil
68+ }
69+ p, err := i.auth.Authenticate(ctx, secret)
70+ switch {
71+ case errors.Is(err, domain.ErrUnauthenticated):
72+ i.limiter.Fail(ctx, addr)
73+ log.Debug().Str("client", addr).Msg("invalid api credentials")
74+ return ctx, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
75+ case err != nil:
76+ return ctx, connectError(ctx, err)
77+ }
78+ return domain.ContextWithPrincipal(ctx, p), nil
79+}
80+
81+// bearer extracts the secret of an "Authorization: Bearer" header. present is
82+// true when the header exists, even when it carries nothing usable, which
83+// then fails authentication.
84+func bearer(h http.Header) (secret string, present bool) {
85+ auth := h.Get("Authorization")
86+ if auth == "" {
87+ return "", false
88+ }
89+ scheme, rest, ok := strings.Cut(auth, " ")
90+ if !ok || !strings.EqualFold(scheme, "Bearer") {
91+ return "", true
92+ }
93+ return strings.TrimSpace(rest), true
94+}
95+
96+// logCall logs a finished call at debug level: procedure, code, caller and
97+// duration. Never headers or messages: they can carry credentials.
98+func logCall(ctx context.Context, procedure string, start time.Time, err error) {
99+ code := "ok"
100+ if err != nil {
101+ code = connect.CodeOf(err).String()
102+ }
103+ p, _ := domain.PrincipalFromContext(ctx)
104+ log := zerowrap.FromCtx(ctx)
105+ log.Debug().
106+ Str("procedure", procedure).
107+ Str("code", code).
108+ Str("principal", p.Label()).Str("token_id", p.TokenID()).
109+ Dur("duration", time.Since(start)).
110+ Msg("api call")
111+}
112+
113+// WrapUnary implements connect.Interceptor.
114+func (i *interceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
115+ return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
116+ start := time.Now()
117+ ctx, err := i.authenticate(ctx, req.Spec().Procedure, req.Peer().Addr, req.Header())
118+ var resp connect.AnyResponse
119+ if err == nil {
120+ resp, err = next(ctx, req)
121+ }
122+ logCall(ctx, req.Spec().Procedure, start, err)
123+ return resp, err
124+ }
125+}
126+
127+// WrapStreamingClient implements connect.Interceptor; the server has no
128+// client side.
129+func (i *interceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
130+ return next
131+}
132+
133+// WrapStreamingHandler implements connect.Interceptor.
134+func (i *interceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
135+ return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
136+ start := time.Now()
137+ ctx, err := i.authenticate(ctx, conn.Spec().Procedure, conn.Peer().Addr, conn.RequestHeader())
138+ if err == nil {
139+ err = next(ctx, conn)
140+ }
141+ logCall(ctx, conn.Spec().Procedure, start, err)
142+ return err
143+ }
144+}
@@ -0,0 +1,144 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "math"
9+ "net/http"
10+ "net/netip"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+ "bnema.dev/ermite/internal/clientlimit"
20+ "bnema.dev/ermite/internal/domain"
21+)
22+
23+var errInternal = errors.New("internal error")
24+
25+// interceptor authenticates the caller of every call and logs it.
26+//
27+// A request that carries "Authorization: Bearer <secret>" is authenticated
28+// with it; one without the header is anonymous. A secret that is not valid is
29+// unauthenticated and counts as a failure of the client address in the
30+// limiter; a client that used up its failures gets resource_exhausted (429 on
31+// the Connect protocol) whatever it presents, like on the Git routes.
32+//
33+// A context that already carries a principal (domain.ContextWithPrincipal) is
34+// trusted as it is: that is how the web calls the handlers in process. Such a
35+// context cannot come from the network.
36+type interceptor struct {
37+ auth Authenticator
38+ limiter *clientlimit.Limiter
39+ trusted []netip.Prefix
40+}
41+
42+func newInterceptor(cfg Config) *interceptor {
43+ return &interceptor{auth: cfg.Auth, limiter: cfg.Limiter, trusted: cfg.TrustedProxies}
44+}
45+
46+// authenticate returns the context to run the call in.
47+func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) {
48+ if _, ok := domain.PrincipalFromContext(ctx); ok {
49+ return ctx, nil
50+ }
51+ if procedure == ermiteconnect.ServerServiceGetVersionProcedure {
52+ // It needs no authentication, so that a client can check compatibility
53+ // first, even with a token that has expired.
54+ return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil
55+ }
56+ log := zerowrap.FromCtx(ctx)
57+ addr := clientlimit.ClientAddr(&http.Request{RemoteAddr: peerAddr, Header: h}, i.trusted)
58+ if blocked, wait := i.limiter.Blocked(ctx, addr); blocked {
59+ log.Warn().Str("client", addr).Msg("too many failed authentications, api request blocked")
60+ err := connect.NewError(connect.CodeResourceExhausted, errors.New("too many failed authentications"))
61+ err.Meta().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
62+ return ctx, err
63+ }
64+
65+ secret, present := bearer(h)
66+ if !present {
67+ return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil
68+ }
69+ p, err := i.auth.Authenticate(ctx, secret)
70+ switch {
71+ case errors.Is(err, domain.ErrUnauthenticated):
72+ i.limiter.Fail(ctx, addr)
73+ log.Debug().Str("client", addr).Msg("invalid api credentials")
74+ return ctx, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
75+ case err != nil:
76+ return ctx, connectError(ctx, err)
77+ }
78+ return domain.ContextWithPrincipal(ctx, p), nil
79+}
80+
81+// bearer extracts the secret of an "Authorization: Bearer" header. present is
82+// true when the header exists, even when it carries nothing usable, which
83+// then fails authentication.
84+func bearer(h http.Header) (secret string, present bool) {
85+ auth := h.Get("Authorization")
86+ if auth == "" {
87+ return "", false
88+ }
89+ scheme, rest, ok := strings.Cut(auth, " ")
90+ if !ok || !strings.EqualFold(scheme, "Bearer") {
91+ return "", true
92+ }
93+ return strings.TrimSpace(rest), true
94+}
95+
96+// logCall logs a finished call at debug level: procedure, code, caller and
97+// duration. Never headers or messages: they can carry credentials.
98+func logCall(ctx context.Context, procedure string, start time.Time, err error) {
99+ code := "ok"
100+ if err != nil {
101+ code = connect.CodeOf(err).String()
102+ }
103+ p, _ := domain.PrincipalFromContext(ctx)
104+ log := zerowrap.FromCtx(ctx)
105+ log.Debug().
106+ Str("procedure", procedure).
107+ Str("code", code).
108+ Str("principal", p.Label()).Str("token_id", p.TokenID()).
109+ Dur("duration", time.Since(start)).
110+ Msg("api call")
111+}
112+
113+// WrapUnary implements connect.Interceptor.
114+func (i *interceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
115+ return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
116+ start := time.Now()
117+ ctx, err := i.authenticate(ctx, req.Spec().Procedure, req.Peer().Addr, req.Header())
118+ var resp connect.AnyResponse
119+ if err == nil {
120+ resp, err = next(ctx, req)
121+ }
122+ logCall(ctx, req.Spec().Procedure, start, err)
123+ return resp, err
124+ }
125+}
126+
127+// WrapStreamingClient implements connect.Interceptor; the server has no
128+// client side.
129+func (i *interceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
130+ return next
131+}
132+
133+// WrapStreamingHandler implements connect.Interceptor.
134+func (i *interceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
135+ return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
136+ start := time.Now()
137+ ctx, err := i.authenticate(ctx, conn.Spec().Procedure, conn.Peer().Addr, conn.RequestHeader())
138+ if err == nil {
139+ err = next(ctx, conn)
140+ }
141+ logCall(ctx, conn.Spec().Procedure, start, err)
142+ return err
143+ }
144+}
A internal/adapters/rpc/repo.go +131 -0
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+
9+ "connectrpc.com/connect"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/app"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// repoService implements ermiteconnect.RepoServiceHandler.
17+type repoService struct{ cfg Config }
18+
19+func (s *repoService) ListRepos(ctx context.Context, req *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) {
20+ page, err := s.cfg.Repos.ListPage(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
21+ if err != nil {
22+ return nil, connectError(ctx, err)
23+ }
24+ out := &pb.ListReposResponse{NextPageToken: page.NextPageToken, Repos: make([]*pb.Repo, 0, len(page.Repos))}
25+ for _, repo := range page.Repos {
26+ view, err := s.cfg.Repos.View(ctx, repo)
27+ if err != nil {
28+ return nil, connectError(ctx, err)
29+ }
30+ out.Repos = append(out.Repos, repoToPB(view))
31+ }
32+ return connect.NewResponse(out), nil
33+}
34+
35+func (s *repoService) GetRepo(ctx context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) {
36+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
37+ if err != nil {
38+ return nil, connectError(ctx, err)
39+ }
40+ repo, err := s.cfg.Repos.Get(ctx, caller(ctx), name)
41+ if err != nil {
42+ return nil, connectError(ctx, err)
43+ }
44+ return respondRepo(ctx, s.cfg.Repos, repo, func(r *pb.Repo) *pb.GetRepoResponse { return &pb.GetRepoResponse{Repo: r} })
45+}
46+
47+func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.CreateRepoRequest]) (*connect.Response[pb.CreateRepoResponse], error) {
48+ name, err := domain.ParseRepoName(req.Msg.GetName())
49+ if err != nil {
50+ return nil, connectError(ctx, err)
51+ }
52+ vis, err := visibilityFromPB(req.Msg.GetVisibility())
53+ if err != nil {
54+ return nil, connectError(ctx, err)
55+ }
56+ repo, err := s.cfg.Repos.Create(ctx, caller(ctx), app.CreateParams{
57+ Name: name, Description: req.Msg.GetDescription(), Visibility: vis,
58+ })
59+ if err != nil {
60+ return nil, connectError(ctx, err)
61+ }
62+ return respondRepo(ctx, s.cfg.Repos, repo, func(r *pb.Repo) *pb.CreateRepoResponse { return &pb.CreateRepoResponse{Repo: r} })
63+}
64+
65+func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.UpdateRepoRequest]) (*connect.Response[pb.UpdateRepoResponse], error) {
66+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
67+ if err != nil {
68+ return nil, connectError(ctx, err)
69+ }
70+ params, err := updateParams(req.Msg)
71+ if err != nil {
72+ return nil, connectError(ctx, err)
73+ }
74+ repo, err := s.cfg.Repos.Update(ctx, caller(ctx), name, params)
75+ if err != nil {
76+ return nil, connectError(ctx, err)
77+ }
78+ return respondRepo(ctx, s.cfg.Repos, repo, func(r *pb.Repo) *pb.UpdateRepoResponse { return &pb.UpdateRepoResponse{Repo: r} })
79+}
80+
81+// updateParams maps the field mask of an update. The mask must name at least
82+// one of "description", "visibility" and "default_branch" and nothing else.
83+//
84+//hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
85+func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) {
86+ var params app.UpdateParams
87+ paths := msg.GetUpdateMask().GetPaths()
88+ if len(paths) == 0 {
89+ return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
90+ }
91+ for _, path := range paths {
92+ switch path {
93+ case "description":
94+ params.Description = new(msg.GetDescription())
95+ case "visibility":
96+ vis, err := visibilityFromPB(msg.GetVisibility())
97+ if err != nil {
98+ return params, err
99+ }
100+ if vis == "" {
101+ return params, fmt.Errorf("%w: visibility is unspecified", domain.ErrInvalid)
102+ }
103+ params.Visibility = &vis
104+ case "default_branch":
105+ params.DefaultBranch = new(msg.GetDefaultBranch())
106+ default:
107+ return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
108+ }
109+ }
110+ return params, nil
111+}
112+
113+func (s *repoService) DeleteRepo(ctx context.Context, req *connect.Request[pb.DeleteRepoRequest]) (*connect.Response[pb.DeleteRepoResponse], error) {
114+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
115+ if err != nil {
116+ return nil, connectError(ctx, err)
117+ }
118+ if err := s.cfg.Repos.Delete(ctx, caller(ctx), name); err != nil {
119+ return nil, connectError(ctx, err)
120+ }
121+ return connect.NewResponse(&pb.DeleteRepoResponse{}), nil
122+}
123+
124+// respondRepo adds the default branch to repo and wraps it in a response.
125+func respondRepo[T any](ctx context.Context, repos *app.Repos, repo domain.Repo, wrap func(*pb.Repo) *T) (*connect.Response[T], error) {
126+ view, err := repos.View(ctx, repo)
127+ if err != nil {
128+ return nil, connectError(ctx, err)
129+ }
130+ return connect.NewResponse(wrap(repoToPB(view))), nil
131+}
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "fmt"
8+
9+ "connectrpc.com/connect"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/app"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// repoService implements ermiteconnect.RepoServiceHandler.
17+type repoService struct{ cfg Config }
18+
19+func (s *repoService) ListRepos(ctx context.Context, req *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) {
20+ page, err := s.cfg.Repos.ListPage(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
21+ if err != nil {
22+ return nil, connectError(ctx, err)
23+ }
24+ out := &pb.ListReposResponse{NextPageToken: page.NextPageToken, Repos: make([]*pb.Repo, 0, len(page.Repos))}
25+ for _, repo := range page.Repos {
26+ view, err := s.cfg.Repos.View(ctx, repo)
27+ if err != nil {
28+ return nil, connectError(ctx, err)
29+ }
30+ out.Repos = append(out.Repos, repoToPB(view))
31+ }
32+ return connect.NewResponse(out), nil
33+}
34+
35+func (s *repoService) GetRepo(ctx context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) {
36+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
37+ if err != nil {
38+ return nil, connectError(ctx, err)
39+ }
40+ repo, err := s.cfg.Repos.Get(ctx, caller(ctx), name)
41+ if err != nil {
42+ return nil, connectError(ctx, err)
43+ }
44+ return respondRepo(ctx, s.cfg.Repos, repo, func(r *pb.Repo) *pb.GetRepoResponse { return &pb.GetRepoResponse{Repo: r} })
45+}
46+
47+func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.CreateRepoRequest]) (*connect.Response[pb.CreateRepoResponse], error) {
48+ name, err := domain.ParseRepoName(req.Msg.GetName())
49+ if err != nil {
50+ return nil, connectError(ctx, err)
51+ }
52+ vis, err := visibilityFromPB(req.Msg.GetVisibility())
53+ if err != nil {
54+ return nil, connectError(ctx, err)
55+ }
56+ repo, err := s.cfg.Repos.Create(ctx, caller(ctx), app.CreateParams{
57+ Name: name, Description: req.Msg.GetDescription(), Visibility: vis,
58+ })
59+ if err != nil {
60+ return nil, connectError(ctx, err)
61+ }
62+ return respondRepo(ctx, s.cfg.Repos, repo, func(r *pb.Repo) *pb.CreateRepoResponse { return &pb.CreateRepoResponse{Repo: r} })
63+}
64+
65+func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.UpdateRepoRequest]) (*connect.Response[pb.UpdateRepoResponse], error) {
66+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
67+ if err != nil {
68+ return nil, connectError(ctx, err)
69+ }
70+ params, err := updateParams(req.Msg)
71+ if err != nil {
72+ return nil, connectError(ctx, err)
73+ }
74+ repo, err := s.cfg.Repos.Update(ctx, caller(ctx), name, params)
75+ if err != nil {
76+ return nil, connectError(ctx, err)
77+ }
78+ return respondRepo(ctx, s.cfg.Repos, repo, func(r *pb.Repo) *pb.UpdateRepoResponse { return &pb.UpdateRepoResponse{Repo: r} })
79+}
80+
81+// updateParams maps the field mask of an update. The mask must name at least
82+// one of "description", "visibility" and "default_branch" and nothing else.
83+//
84+//hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
85+func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) {
86+ var params app.UpdateParams
87+ paths := msg.GetUpdateMask().GetPaths()
88+ if len(paths) == 0 {
89+ return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
90+ }
91+ for _, path := range paths {
92+ switch path {
93+ case "description":
94+ params.Description = new(msg.GetDescription())
95+ case "visibility":
96+ vis, err := visibilityFromPB(msg.GetVisibility())
97+ if err != nil {
98+ return params, err
99+ }
100+ if vis == "" {
101+ return params, fmt.Errorf("%w: visibility is unspecified", domain.ErrInvalid)
102+ }
103+ params.Visibility = &vis
104+ case "default_branch":
105+ params.DefaultBranch = new(msg.GetDefaultBranch())
106+ default:
107+ return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
108+ }
109+ }
110+ return params, nil
111+}
112+
113+func (s *repoService) DeleteRepo(ctx context.Context, req *connect.Request[pb.DeleteRepoRequest]) (*connect.Response[pb.DeleteRepoResponse], error) {
114+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
115+ if err != nil {
116+ return nil, connectError(ctx, err)
117+ }
118+ if err := s.cfg.Repos.Delete(ctx, caller(ctx), name); err != nil {
119+ return nil, connectError(ctx, err)
120+ }
121+ return connect.NewResponse(&pb.DeleteRepoResponse{}), nil
122+}
123+
124+// respondRepo adds the default branch to repo and wraps it in a response.
125+func respondRepo[T any](ctx context.Context, repos *app.Repos, repo domain.Repo, wrap func(*pb.Repo) *T) (*connect.Response[T], error) {
126+ view, err := repos.View(ctx, repo)
127+ if err != nil {
128+ return nil, connectError(ctx, err)
129+ }
130+ return connect.NewResponse(wrap(repoToPB(view))), nil
131+}
A internal/adapters/rpc/server.go +19 -0
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "slices"
8+
9+ "connectrpc.com/connect"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+)
13+
14+// serverService implements ermiteconnect.ServerServiceHandler.
15+type serverService struct{ version string }
16+
17+func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
18+ return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil
19+}
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "slices"
8+
9+ "connectrpc.com/connect"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+)
13+
14+// serverService implements ermiteconnect.ServerServiceHandler.
15+type serverService struct{ version string }
16+
17+func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
18+ return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil
19+}
A internal/adapters/rpc/token.go +61 -0
@@ -0,0 +1,61 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/app"
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// tokenService implements ermiteconnect.TokenServiceHandler.
16+type tokenService struct{ tokens *app.Tokens }
17+
18+func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.CreateTokenRequest]) (*connect.Response[pb.CreateTokenResponse], error) {
19+ params := app.CreateTokenParams{
20+ Label: req.Msg.GetLabel(),
21+ ExpiresAt: goTime(req.Msg.GetExpiresAt()),
22+ }
23+ for _, sc := range req.Msg.GetScopes() {
24+ scope, err := scopeFromPB(sc)
25+ if err != nil {
26+ return nil, connectError(ctx, err)
27+ }
28+ params.Scopes = append(params.Scopes, scope)
29+ }
30+ for _, r := range req.Msg.GetRepos() {
31+ name, err := domain.ParseRepoName(r)
32+ if err != nil {
33+ return nil, connectError(ctx, err)
34+ }
35+ params.Repos = append(params.Repos, name)
36+ }
37+ created, err := s.tokens.Create(ctx, caller(ctx), params)
38+ if err != nil {
39+ return nil, connectError(ctx, err)
40+ }
41+ return connect.NewResponse(&pb.CreateTokenResponse{Token: tokenToPB(created.Token), Secret: created.Secret}), nil
42+}
43+
44+func (s *tokenService) ListTokens(ctx context.Context, req *connect.Request[pb.ListTokensRequest]) (*connect.Response[pb.ListTokensResponse], error) {
45+ page, err := s.tokens.ListPage(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
46+ if err != nil {
47+ return nil, connectError(ctx, err)
48+ }
49+ out := &pb.ListTokensResponse{NextPageToken: page.NextPageToken, Tokens: make([]*pb.Token, 0, len(page.Tokens))}
50+ for _, t := range page.Tokens {
51+ out.Tokens = append(out.Tokens, tokenToPB(t))
52+ }
53+ return connect.NewResponse(out), nil
54+}
55+
56+func (s *tokenService) RevokeToken(ctx context.Context, req *connect.Request[pb.RevokeTokenRequest]) (*connect.Response[pb.RevokeTokenResponse], error) {
57+ if err := s.tokens.Revoke(ctx, caller(ctx), req.Msg.GetId()); err != nil {
58+ return nil, connectError(ctx, err)
59+ }
60+ return connect.NewResponse(&pb.RevokeTokenResponse{}), nil
61+}
@@ -0,0 +1,61 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/app"
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// tokenService implements ermiteconnect.TokenServiceHandler.
16+type tokenService struct{ tokens *app.Tokens }
17+
18+func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.CreateTokenRequest]) (*connect.Response[pb.CreateTokenResponse], error) {
19+ params := app.CreateTokenParams{
20+ Label: req.Msg.GetLabel(),
21+ ExpiresAt: goTime(req.Msg.GetExpiresAt()),
22+ }
23+ for _, sc := range req.Msg.GetScopes() {
24+ scope, err := scopeFromPB(sc)
25+ if err != nil {
26+ return nil, connectError(ctx, err)
27+ }
28+ params.Scopes = append(params.Scopes, scope)
29+ }
30+ for _, r := range req.Msg.GetRepos() {
31+ name, err := domain.ParseRepoName(r)
32+ if err != nil {
33+ return nil, connectError(ctx, err)
34+ }
35+ params.Repos = append(params.Repos, name)
36+ }
37+ created, err := s.tokens.Create(ctx, caller(ctx), params)
38+ if err != nil {
39+ return nil, connectError(ctx, err)
40+ }
41+ return connect.NewResponse(&pb.CreateTokenResponse{Token: tokenToPB(created.Token), Secret: created.Secret}), nil
42+}
43+
44+func (s *tokenService) ListTokens(ctx context.Context, req *connect.Request[pb.ListTokensRequest]) (*connect.Response[pb.ListTokensResponse], error) {
45+ page, err := s.tokens.ListPage(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
46+ if err != nil {
47+ return nil, connectError(ctx, err)
48+ }
49+ out := &pb.ListTokensResponse{NextPageToken: page.NextPageToken, Tokens: make([]*pb.Token, 0, len(page.Tokens))}
50+ for _, t := range page.Tokens {
51+ out.Tokens = append(out.Tokens, tokenToPB(t))
52+ }
53+ return connect.NewResponse(out), nil
54+}
55+
56+func (s *tokenService) RevokeToken(ctx context.Context, req *connect.Request[pb.RevokeTokenRequest]) (*connect.Response[pb.RevokeTokenResponse], error) {
57+ if err := s.tokens.Revoke(ctx, caller(ctx), req.Msg.GetId()); err != nil {
58+ return nil, connectError(ctx, err)
59+ }
60+ return connect.NewResponse(&pb.RevokeTokenResponse{}), nil
61+}
M internal/app/access.go +30 -2
@@ -10,10 +10,32 @@ import "bnema.dev/ermite/internal/domain"
1010 // the repository within its list). Callers answer domain.ErrNotFound when it
1111 // is false, so a private repository never shows itself.
1212 func canRead(p domain.Principal, repo domain.Repo) bool {
13- if repo.Visibility == domain.VisibilityPublic {
13+ return canReadAs(p, repo.Name, repo.Visibility)
14+}
15+
16+// canReadAs is canRead for a repository known by its name and visibility only,
17+// such as one that no longer exists.
18+func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool {
19+ if vis == domain.VisibilityPublic {
1420 return true
1521 }
16- return p.Allows(domain.ScopeRepoRead, repo.Name)
22+ return p.Allows(domain.ScopeRepoRead, name)
23+}
24+
25+// adminAccess is the access rule for changing or deleting a repository. A
26+// caller that may not administer it learns nothing it could not already read:
27+// not found when it cannot see the repository, unauthenticated when it is
28+// anonymous, forbidden otherwise.
29+func adminAccess(p domain.Principal, repo domain.Repo) error {
30+ switch {
31+ case canAdmin(p, repo.Name):
32+ return nil
33+ case !canRead(p, repo):
34+ return domain.ErrNotFound
35+ case p.IsAnonymous():
36+ return domain.ErrUnauthenticated
37+ }
38+ return domain.ErrForbidden
1739 }
1840
1941 // canWrite is the access rule for pushing to a repository, or creating it on
@@ -27,3 +49,9 @@ func canWrite(p domain.Principal, name domain.RepoName) bool {
2749 func canManageTokens(p domain.Principal) bool {
2850 return p.HasScope(domain.ScopeTokenAdmin)
2951 }
52+
53+// canAdmin is the access rule for deleting a repository and changing its
54+// visibility, description or default branch: repo:admin on it.
55+func canAdmin(p domain.Principal, name domain.RepoName) bool {
56+ return p.Allows(domain.ScopeRepoAdmin, name)
57+}
@@ -10,10 +10,32 @@ import "bnema.dev/ermite/internal/domain"
10 // the repository within its list). Callers answer domain.ErrNotFound when it10 // the repository within its list). Callers answer domain.ErrNotFound when it
11 // is false, so a private repository never shows itself.11 // is false, so a private repository never shows itself.
12 func canRead(p domain.Principal, repo domain.Repo) bool {12 func canRead(p domain.Principal, repo domain.Repo) bool {
13- if repo.Visibility == domain.VisibilityPublic {13+ return canReadAs(p, repo.Name, repo.Visibility)
14+}
15+
16+// canReadAs is canRead for a repository known by its name and visibility only,
17+// such as one that no longer exists.
18+func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool {
19+ if vis == domain.VisibilityPublic {
14 return true20 return true
15 }21 }
16- return p.Allows(domain.ScopeRepoRead, repo.Name)22+ return p.Allows(domain.ScopeRepoRead, name)
23+}
24+
25+// adminAccess is the access rule for changing or deleting a repository. A
26+// caller that may not administer it learns nothing it could not already read:
27+// not found when it cannot see the repository, unauthenticated when it is
28+// anonymous, forbidden otherwise.
29+func adminAccess(p domain.Principal, repo domain.Repo) error {
30+ switch {
31+ case canAdmin(p, repo.Name):
32+ return nil
33+ case !canRead(p, repo):
34+ return domain.ErrNotFound
35+ case p.IsAnonymous():
36+ return domain.ErrUnauthenticated
37+ }
38+ return domain.ErrForbidden
17 }39 }
18 40
19 // canWrite is the access rule for pushing to a repository, or creating it on41 // canWrite is the access rule for pushing to a repository, or creating it on
@@ -27,3 +49,9 @@ func canWrite(p domain.Principal, name domain.RepoName) bool {
27 func canManageTokens(p domain.Principal) bool {49 func canManageTokens(p domain.Principal) bool {
28 return p.HasScope(domain.ScopeTokenAdmin)50 return p.HasScope(domain.ScopeTokenAdmin)
29 }51 }
52+
53+// canAdmin is the access rule for deleting a repository and changing its
54+// visibility, description or default branch: repo:admin on it.
55+func canAdmin(p domain.Principal, name domain.RepoName) bool {
56+ return p.Allows(domain.ScopeRepoAdmin, name)
57+}
A internal/app/events.go +356 -0
@@ -0,0 +1,356 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strconv"
10+ "strings"
11+ "sync"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// DefaultEventBuffer is the number of events the Events use case remembers.
20+const DefaultEventBuffer = 1024
21+
22+// Topics a watcher may follow.
23+const (
24+ // TopicHome follows every repository the caller may see.
25+ TopicHome = "home"
26+ // topicRepoPrefix starts the topic of one repository: "repo:<name>".
27+ topicRepoPrefix = "repo:"
28+)
29+
30+// bufferedEvent is an event with its sequence number in the buffer.
31+type bufferedEvent struct {
32+ seq uint64
33+ ev domain.Event
34+}
35+
36+// Events keeps the last events of the bus in a ring buffer, numbers them, and
37+// lets callers watch them: live, or replayed from the id of the last event
38+// they saw. Events of a private repository only reach callers that may read
39+// it, checked again for every event.
40+//
41+// Event ids are opaque to clients: "<boot>-<seq>", where boot identifies this
42+// run of the server, so that an id from before a restart is unknown rather
43+// than taken for a newer event.
44+//
45+// The goroutine that feeds the buffer uses the logger of the context given to
46+// NewEvents and lives until Close.
47+type Events struct {
48+ repos *Repos
49+ boot string
50+ size int
51+
52+ stop context.CancelFunc
53+ done chan struct{}
54+
55+ mu sync.Mutex
56+ ring []bufferedEvent // capacity size, oldest first
57+ nextSeq uint64 // sequence number of the next event, from 1
58+ changed chan struct{} // closed and replaced on every append and on close
59+ closed bool
60+ watches int
61+}
62+
63+// NewEvents subscribes to the bus and starts remembering its events, at most
64+// size of them (DefaultEventBuffer when not positive). ctx carries the
65+// logger; its cancellation is not followed, call Close.
66+func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, size int, clock ports.Clock) *Events {
67+ if size <= 0 {
68+ size = DefaultEventBuffer
69+ }
70+ run, stop := context.WithCancel(context.WithoutCancel(ctx))
71+ e := &Events{
72+ repos: repos,
73+ boot: strconv.FormatInt(clock.Now().UnixMilli(), 36),
74+ size: size,
75+ stop: stop,
76+ done: make(chan struct{}),
77+ nextSeq: 1,
78+ changed: make(chan struct{}),
79+ }
80+ sub := bus.Subscribe(run) // before returning: nothing published after is missed
81+ go e.feed(run, sub)
82+ return e
83+}
84+
85+// feed appends the events of the bus until it stops.
86+func (e *Events) feed(ctx context.Context, sub <-chan domain.Event) {
87+ defer close(e.done)
88+ defer e.shutdown()
89+ for {
90+ select {
91+ case ev, ok := <-sub:
92+ if !ok {
93+ return
94+ }
95+ e.append(ev)
96+ case <-ctx.Done():
97+ return
98+ }
99+ }
100+}
101+
102+func (e *Events) append(ev domain.Event) {
103+ e.mu.Lock()
104+ defer e.mu.Unlock()
105+ if len(e.ring) == e.size {
106+ copy(e.ring, e.ring[1:])
107+ e.ring = e.ring[:e.size-1]
108+ }
109+ e.ring = append(e.ring, bufferedEvent{seq: e.nextSeq, ev: ev})
110+ e.nextSeq++
111+ e.wake()
112+}
113+
114+// wake releases the watchers waiting for news. The caller holds mu.
115+func (e *Events) wake() {
116+ close(e.changed)
117+ e.changed = make(chan struct{})
118+}
119+
120+func (e *Events) shutdown() {
121+ e.mu.Lock()
122+ defer e.mu.Unlock()
123+ if !e.closed {
124+ e.closed = true
125+ e.wake()
126+ }
127+}
128+
129+// Close ends every watch cleanly, stops feeding and waits for the feeder.
130+// It is safe to call more than once.
131+func (e *Events) Close(context.Context) {
132+ e.stop()
133+ <-e.done
134+}
135+
136+// Watchers returns the number of watches in progress.
137+func (e *Events) Watchers() int {
138+ e.mu.Lock()
139+ defer e.mu.Unlock()
140+ return e.watches
141+}
142+
143+func (e *Events) id(seq uint64) string { return e.boot + "-" + strconv.FormatUint(seq, 10) }
144+
145+// parseID returns the sequence number of an id of this run.
146+func (e *Events) parseID(id string) (uint64, bool) {
147+ boot, seq, ok := strings.Cut(id, "-")
148+ if !ok || boot != e.boot {
149+ return 0, false
150+ }
151+ n, err := strconv.ParseUint(seq, 10, 64)
152+ return n, err == nil && n > 0
153+}
154+
155+// WatchParams select the events of a watch.
156+type WatchParams struct {
157+ // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
158+ Topics []string
159+ // ResumeAfter is the id of the last event the caller saw; empty to
160+ // receive only new events.
161+ ResumeAfter string
162+ // Ready, when not nil, is called once the topics are valid and the watch
163+ // is registered: events published from then on are not missed. Nothing is
164+ // sent before it returns.
165+ Ready func()
166+}
167+
168+// WatchEvent is one item of a watch.
169+type WatchEvent struct {
170+ // ID identifies the event, for resuming. Empty on a gap.
171+ ID string
172+ // Gap tells that events were lost: the caller must reload what it shows.
173+ // Event is nil then.
174+ Gap bool
175+ // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted or PushEvent.
176+ Event domain.Event
177+ // Repo is the state of the repository at delivery, for RepoCreated and
178+ // RepoUpdated.
179+ Repo *RepoView
180+}
181+
182+// watch is the state of one Watch call.
183+type watch struct {
184+ p domain.Principal
185+ home bool
186+ repos map[domain.RepoName]bool
187+}
188+
189+func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
190+
191+// parseTopics validates the topics of a watch. A repository topic needs a
192+// repository the principal may read (else domain.ErrNotFound); an unknown
193+// topic is domain.ErrInvalid.
194+func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []string) (*watch, error) {
195+ w := &watch{p: p, repos: make(map[domain.RepoName]bool)}
196+ if len(topics) == 0 {
197+ w.home = true
198+ }
199+ for _, t := range topics {
200+ if t == TopicHome {
201+ w.home = true
202+ continue
203+ }
204+ raw, ok := strings.CutPrefix(t, topicRepoPrefix)
205+ if !ok {
206+ return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)
207+ }
208+ name, err := domain.ParseRepoName(raw)
209+ if err != nil {
210+ return nil, err
211+ }
212+ if _, err := e.repos.Get(ctx, p, name); err != nil {
213+ return nil, err
214+ }
215+ w.repos[name] = true
216+ }
217+ return w, nil
218+}
219+
220+// Watch calls send for the events the principal may see until ctx ends, the
221+// buffer closes (both return nil) or send fails (its error is returned).
222+//
223+// Without ResumeAfter only new events are sent. With it, the buffered events
224+// after that id are sent first, then new ones. When the id is no longer
225+// buffered, or was not issued by this run, a gap comes first and the oldest
226+// buffered event follows. A watcher that falls more than the buffer behind
227+// gets a gap too, and carries on with the oldest buffered event.
228+func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchParams, send func(WatchEvent) error) error {
229+ w, err := e.parseTopics(ctx, p, params.Topics)
230+ if err != nil {
231+ return err
232+ }
233+
234+ e.mu.Lock()
235+ next := e.nextSeq // only new events
236+ gap := false
237+ if params.ResumeAfter != "" {
238+ seq, ok := e.parseID(params.ResumeAfter)
239+ oldest := e.nextSeq - uint64(len(e.ring)) // first buffered sequence, nextSeq when empty
240+ switch {
241+ case ok && seq >= oldest-1 && seq < e.nextSeq:
242+ next = seq + 1
243+ default:
244+ next, gap = oldest, true
245+ }
246+ }
247+ e.watches++
248+ e.mu.Unlock()
249+ defer func() {
250+ e.mu.Lock()
251+ e.watches--
252+ e.mu.Unlock()
253+ }()
254+
255+ if params.Ready != nil {
256+ params.Ready()
257+ }
258+ log := zerowrap.FromCtx(ctx)
259+ log.Debug().Strs("topics", params.Topics).Bool("resume", params.ResumeAfter != "").Bool("gap", gap).Msg("watch started")
260+ if gap {
261+ if err := send(WatchEvent{Gap: true}); err != nil {
262+ return err
263+ }
264+ }
265+ for {
266+ batch, changed, oldest, lost, closed := e.since(next)
267+ if lost {
268+ if err := send(WatchEvent{Gap: true}); err != nil {
269+ return err
270+ }
271+ next = oldest
272+ continue
273+ }
274+ for _, be := range batch {
275+ if err := e.deliver(ctx, w, be, send); err != nil {
276+ return err
277+ }
278+ next = be.seq + 1
279+ }
280+ if len(batch) > 0 {
281+ continue
282+ }
283+ if closed {
284+ return nil
285+ }
286+ select {
287+ case <-changed:
288+ case <-ctx.Done():
289+ return nil
290+ }
291+ }
292+}
293+
294+// since returns the buffered events from sequence next on, and the channel
295+// that closes when more arrive. lost is true when next fell out of the buffer;
296+// the caller then restarts from oldest, the first buffered sequence. The
297+// returned slice is a copy.
298+func (e *Events) since(next uint64) (batch []bufferedEvent, changed <-chan struct{}, oldest uint64, lost, closed bool) {
299+ e.mu.Lock()
300+ defer e.mu.Unlock()
301+ oldest = e.nextSeq - uint64(len(e.ring))
302+ if next < oldest {
303+ return nil, e.changed, oldest, true, e.closed
304+ }
305+ batch = append(batch, e.ring[next-oldest:]...)
306+ return batch, e.changed, oldest, false, e.closed
307+}
308+
309+// deliver sends the event if the watcher follows its topic and may see it.
310+func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send func(WatchEvent) error) error {
311+ out := WatchEvent{ID: e.id(be.seq), Event: be.ev}
312+ switch ev := be.ev.(type) {
313+ case domain.EventGap:
314+ // The feeder lost events: tell every watcher.
315+ return send(WatchEvent{Gap: true})
316+ case domain.RepoDeleted:
317+ // The repository is gone: use the visibility it had.
318+ if !w.wants(ev.Repo) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
319+ return nil
320+ }
321+ return send(out)
322+ case domain.RepoCreated:
323+ return e.deliverRepo(ctx, w, ev.Repo, true, out, send)
324+ case domain.RepoUpdated:
325+ return e.deliverRepo(ctx, w, ev.Repo, true, out, send)
326+ case domain.PushEvent:
327+ return e.deliverRepo(ctx, w, ev.Repo, false, out, send)
328+ }
329+ return nil // an event this version does not stream
330+}
331+
332+// deliverRepo re-checks the access to the repository, with its present
333+// visibility, and sends the event. A repository that no longer exists is
334+// skipped: its RepoDeleted follows or came before.
335+func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, out WatchEvent, send func(WatchEvent) error) error {
336+ if !w.wants(name) {
337+ return nil
338+ }
339+ repo, err := e.repos.Get(ctx, w.p, name)
340+ switch {
341+ case errors.Is(err, domain.ErrNotFound):
342+ return nil
343+ case ctx.Err() != nil:
344+ return nil
345+ case err != nil:
346+ return fmt.Errorf("check access to %q: %w", name, err)
347+ }
348+ if withRepo {
349+ view, err := e.repos.View(ctx, repo)
350+ if err != nil {
351+ return err
352+ }
353+ out.Repo = &view
354+ }
355+ return send(out)
356+}
@@ -0,0 +1,356 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strconv"
10+ "strings"
11+ "sync"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// DefaultEventBuffer is the number of events the Events use case remembers.
20+const DefaultEventBuffer = 1024
21+
22+// Topics a watcher may follow.
23+const (
24+ // TopicHome follows every repository the caller may see.
25+ TopicHome = "home"
26+ // topicRepoPrefix starts the topic of one repository: "repo:<name>".
27+ topicRepoPrefix = "repo:"
28+)
29+
30+// bufferedEvent is an event with its sequence number in the buffer.
31+type bufferedEvent struct {
32+ seq uint64
33+ ev domain.Event
34+}
35+
36+// Events keeps the last events of the bus in a ring buffer, numbers them, and
37+// lets callers watch them: live, or replayed from the id of the last event
38+// they saw. Events of a private repository only reach callers that may read
39+// it, checked again for every event.
40+//
41+// Event ids are opaque to clients: "<boot>-<seq>", where boot identifies this
42+// run of the server, so that an id from before a restart is unknown rather
43+// than taken for a newer event.
44+//
45+// The goroutine that feeds the buffer uses the logger of the context given to
46+// NewEvents and lives until Close.
47+type Events struct {
48+ repos *Repos
49+ boot string
50+ size int
51+
52+ stop context.CancelFunc
53+ done chan struct{}
54+
55+ mu sync.Mutex
56+ ring []bufferedEvent // capacity size, oldest first
57+ nextSeq uint64 // sequence number of the next event, from 1
58+ changed chan struct{} // closed and replaced on every append and on close
59+ closed bool
60+ watches int
61+}
62+
63+// NewEvents subscribes to the bus and starts remembering its events, at most
64+// size of them (DefaultEventBuffer when not positive). ctx carries the
65+// logger; its cancellation is not followed, call Close.
66+func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, size int, clock ports.Clock) *Events {
67+ if size <= 0 {
68+ size = DefaultEventBuffer
69+ }
70+ run, stop := context.WithCancel(context.WithoutCancel(ctx))
71+ e := &Events{
72+ repos: repos,
73+ boot: strconv.FormatInt(clock.Now().UnixMilli(), 36),
74+ size: size,
75+ stop: stop,
76+ done: make(chan struct{}),
77+ nextSeq: 1,
78+ changed: make(chan struct{}),
79+ }
80+ sub := bus.Subscribe(run) // before returning: nothing published after is missed
81+ go e.feed(run, sub)
82+ return e
83+}
84+
85+// feed appends the events of the bus until it stops.
86+func (e *Events) feed(ctx context.Context, sub <-chan domain.Event) {
87+ defer close(e.done)
88+ defer e.shutdown()
89+ for {
90+ select {
91+ case ev, ok := <-sub:
92+ if !ok {
93+ return
94+ }
95+ e.append(ev)
96+ case <-ctx.Done():
97+ return
98+ }
99+ }
100+}
101+
102+func (e *Events) append(ev domain.Event) {
103+ e.mu.Lock()
104+ defer e.mu.Unlock()
105+ if len(e.ring) == e.size {
106+ copy(e.ring, e.ring[1:])
107+ e.ring = e.ring[:e.size-1]
108+ }
109+ e.ring = append(e.ring, bufferedEvent{seq: e.nextSeq, ev: ev})
110+ e.nextSeq++
111+ e.wake()
112+}
113+
114+// wake releases the watchers waiting for news. The caller holds mu.
115+func (e *Events) wake() {
116+ close(e.changed)
117+ e.changed = make(chan struct{})
118+}
119+
120+func (e *Events) shutdown() {
121+ e.mu.Lock()
122+ defer e.mu.Unlock()
123+ if !e.closed {
124+ e.closed = true
125+ e.wake()
126+ }
127+}
128+
129+// Close ends every watch cleanly, stops feeding and waits for the feeder.
130+// It is safe to call more than once.
131+func (e *Events) Close(context.Context) {
132+ e.stop()
133+ <-e.done
134+}
135+
136+// Watchers returns the number of watches in progress.
137+func (e *Events) Watchers() int {
138+ e.mu.Lock()
139+ defer e.mu.Unlock()
140+ return e.watches
141+}
142+
143+func (e *Events) id(seq uint64) string { return e.boot + "-" + strconv.FormatUint(seq, 10) }
144+
145+// parseID returns the sequence number of an id of this run.
146+func (e *Events) parseID(id string) (uint64, bool) {
147+ boot, seq, ok := strings.Cut(id, "-")
148+ if !ok || boot != e.boot {
149+ return 0, false
150+ }
151+ n, err := strconv.ParseUint(seq, 10, 64)
152+ return n, err == nil && n > 0
153+}
154+
155+// WatchParams select the events of a watch.
156+type WatchParams struct {
157+ // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
158+ Topics []string
159+ // ResumeAfter is the id of the last event the caller saw; empty to
160+ // receive only new events.
161+ ResumeAfter string
162+ // Ready, when not nil, is called once the topics are valid and the watch
163+ // is registered: events published from then on are not missed. Nothing is
164+ // sent before it returns.
165+ Ready func()
166+}
167+
168+// WatchEvent is one item of a watch.
169+type WatchEvent struct {
170+ // ID identifies the event, for resuming. Empty on a gap.
171+ ID string
172+ // Gap tells that events were lost: the caller must reload what it shows.
173+ // Event is nil then.
174+ Gap bool
175+ // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted or PushEvent.
176+ Event domain.Event
177+ // Repo is the state of the repository at delivery, for RepoCreated and
178+ // RepoUpdated.
179+ Repo *RepoView
180+}
181+
182+// watch is the state of one Watch call.
183+type watch struct {
184+ p domain.Principal
185+ home bool
186+ repos map[domain.RepoName]bool
187+}
188+
189+func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
190+
191+// parseTopics validates the topics of a watch. A repository topic needs a
192+// repository the principal may read (else domain.ErrNotFound); an unknown
193+// topic is domain.ErrInvalid.
194+func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []string) (*watch, error) {
195+ w := &watch{p: p, repos: make(map[domain.RepoName]bool)}
196+ if len(topics) == 0 {
197+ w.home = true
198+ }
199+ for _, t := range topics {
200+ if t == TopicHome {
201+ w.home = true
202+ continue
203+ }
204+ raw, ok := strings.CutPrefix(t, topicRepoPrefix)
205+ if !ok {
206+ return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)
207+ }
208+ name, err := domain.ParseRepoName(raw)
209+ if err != nil {
210+ return nil, err
211+ }
212+ if _, err := e.repos.Get(ctx, p, name); err != nil {
213+ return nil, err
214+ }
215+ w.repos[name] = true
216+ }
217+ return w, nil
218+}
219+
220+// Watch calls send for the events the principal may see until ctx ends, the
221+// buffer closes (both return nil) or send fails (its error is returned).
222+//
223+// Without ResumeAfter only new events are sent. With it, the buffered events
224+// after that id are sent first, then new ones. When the id is no longer
225+// buffered, or was not issued by this run, a gap comes first and the oldest
226+// buffered event follows. A watcher that falls more than the buffer behind
227+// gets a gap too, and carries on with the oldest buffered event.
228+func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchParams, send func(WatchEvent) error) error {
229+ w, err := e.parseTopics(ctx, p, params.Topics)
230+ if err != nil {
231+ return err
232+ }
233+
234+ e.mu.Lock()
235+ next := e.nextSeq // only new events
236+ gap := false
237+ if params.ResumeAfter != "" {
238+ seq, ok := e.parseID(params.ResumeAfter)
239+ oldest := e.nextSeq - uint64(len(e.ring)) // first buffered sequence, nextSeq when empty
240+ switch {
241+ case ok && seq >= oldest-1 && seq < e.nextSeq:
242+ next = seq + 1
243+ default:
244+ next, gap = oldest, true
245+ }
246+ }
247+ e.watches++
248+ e.mu.Unlock()
249+ defer func() {
250+ e.mu.Lock()
251+ e.watches--
252+ e.mu.Unlock()
253+ }()
254+
255+ if params.Ready != nil {
256+ params.Ready()
257+ }
258+ log := zerowrap.FromCtx(ctx)
259+ log.Debug().Strs("topics", params.Topics).Bool("resume", params.ResumeAfter != "").Bool("gap", gap).Msg("watch started")
260+ if gap {
261+ if err := send(WatchEvent{Gap: true}); err != nil {
262+ return err
263+ }
264+ }
265+ for {
266+ batch, changed, oldest, lost, closed := e.since(next)
267+ if lost {
268+ if err := send(WatchEvent{Gap: true}); err != nil {
269+ return err
270+ }
271+ next = oldest
272+ continue
273+ }
274+ for _, be := range batch {
275+ if err := e.deliver(ctx, w, be, send); err != nil {
276+ return err
277+ }
278+ next = be.seq + 1
279+ }
280+ if len(batch) > 0 {
281+ continue
282+ }
283+ if closed {
284+ return nil
285+ }
286+ select {
287+ case <-changed:
288+ case <-ctx.Done():
289+ return nil
290+ }
291+ }
292+}
293+
294+// since returns the buffered events from sequence next on, and the channel
295+// that closes when more arrive. lost is true when next fell out of the buffer;
296+// the caller then restarts from oldest, the first buffered sequence. The
297+// returned slice is a copy.
298+func (e *Events) since(next uint64) (batch []bufferedEvent, changed <-chan struct{}, oldest uint64, lost, closed bool) {
299+ e.mu.Lock()
300+ defer e.mu.Unlock()
301+ oldest = e.nextSeq - uint64(len(e.ring))
302+ if next < oldest {
303+ return nil, e.changed, oldest, true, e.closed
304+ }
305+ batch = append(batch, e.ring[next-oldest:]...)
306+ return batch, e.changed, oldest, false, e.closed
307+}
308+
309+// deliver sends the event if the watcher follows its topic and may see it.
310+func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send func(WatchEvent) error) error {
311+ out := WatchEvent{ID: e.id(be.seq), Event: be.ev}
312+ switch ev := be.ev.(type) {
313+ case domain.EventGap:
314+ // The feeder lost events: tell every watcher.
315+ return send(WatchEvent{Gap: true})
316+ case domain.RepoDeleted:
317+ // The repository is gone: use the visibility it had.
318+ if !w.wants(ev.Repo) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
319+ return nil
320+ }
321+ return send(out)
322+ case domain.RepoCreated:
323+ return e.deliverRepo(ctx, w, ev.Repo, true, out, send)
324+ case domain.RepoUpdated:
325+ return e.deliverRepo(ctx, w, ev.Repo, true, out, send)
326+ case domain.PushEvent:
327+ return e.deliverRepo(ctx, w, ev.Repo, false, out, send)
328+ }
329+ return nil // an event this version does not stream
330+}
331+
332+// deliverRepo re-checks the access to the repository, with its present
333+// visibility, and sends the event. A repository that no longer exists is
334+// skipped: its RepoDeleted follows or came before.
335+func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, out WatchEvent, send func(WatchEvent) error) error {
336+ if !w.wants(name) {
337+ return nil
338+ }
339+ repo, err := e.repos.Get(ctx, w.p, name)
340+ switch {
341+ case errors.Is(err, domain.ErrNotFound):
342+ return nil
343+ case ctx.Err() != nil:
344+ return nil
345+ case err != nil:
346+ return fmt.Errorf("check access to %q: %w", name, err)
347+ }
348+ if withRepo {
349+ view, err := e.repos.View(ctx, repo)
350+ if err != nil {
351+ return err
352+ }
353+ out.Repo = &view
354+ }
355+ return send(out)
356+}
A internal/app/events_test.go +368 -0
@@ -0,0 +1,368 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/app"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+// eventRig is an Events use case fed by a channel the test writes to.
23+type eventRig struct {
24+ t *testing.T
25+ meta *mocks.RepoMetaStore
26+ git *mocks.GitRepos
27+ feed chan domain.Event
28+ events *app.Events
29+}
30+
31+func newEventRig(t *testing.T, size int) *eventRig {
32+ t.Helper()
33+ r := &eventRig{
34+ t: t,
35+ meta: mocks.NewRepoMetaStore(t),
36+ git: mocks.NewGitRepos(t),
37+ feed: make(chan domain.Event, 4096),
38+ }
39+ bus := mocks.NewEventBus(t)
40+ bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
41+ go func() { <-ctx.Done(); close(r.feed) }()
42+ return r.feed
43+ })
44+ clock := mocks.NewClock(t)
45+ clock.EXPECT().Now().Return(now).Maybe()
46+ r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe()
47+ repos := app.NewRepos(r.meta, r.git, bus, clock)
48+ r.events = app.NewEvents(context.Background(), repos, bus, size, clock)
49+ t.Cleanup(func() { r.events.Close(context.Background()) })
50+ return r
51+}
52+
53+// repo declares a repository the rig knows.
54+func (r *eventRig) repo(s string, vis domain.Visibility) domain.RepoName {
55+ n := name(r.t, s)
56+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: vis}, nil).Maybe()
57+ return n
58+}
59+
60+func (r *eventRig) push(n domain.RepoName, i int) {
61+ oid := fmt.Sprintf("%040x", i+1)
62+ r.feed <- domain.PushEvent{Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: "refs/heads/main"}}, At: now}
63+}
64+
65+// watcher is a running Watch.
66+type watcher struct {
67+ got chan app.WatchEvent
68+ done chan error
69+}
70+
71+func (r *eventRig) watch(ctx context.Context, p domain.Principal, params app.WatchParams) *watcher {
72+ w := &watcher{got: make(chan app.WatchEvent, 4096), done: make(chan error, 1)}
73+ go func() {
74+ w.done <- r.events.Watch(ctx, p, params, func(e app.WatchEvent) error {
75+ w.got <- e
76+ return nil
77+ })
78+ }()
79+ return w
80+}
81+
82+func (w *watcher) next(t *testing.T) app.WatchEvent {
83+ t.Helper()
84+ select {
85+ case e := <-w.got:
86+ return e
87+ case err := <-w.done:
88+ t.Fatalf("watch ended: %v", err)
89+ case <-time.After(5 * time.Second):
90+ t.Fatal("no event")
91+ }
92+ return app.WatchEvent{}
93+}
94+
95+func (w *watcher) none(t *testing.T) {
96+ t.Helper()
97+ select {
98+ case e := <-w.got:
99+ t.Fatalf("unexpected event %+v", e)
100+ case <-time.After(100 * time.Millisecond):
101+ }
102+}
103+
104+func TestWatchLiveAndFiltered(t *testing.T) {
105+ r := newEventRig(t, 0)
106+ pub := r.repo("pub", domain.VisibilityPublic)
107+ priv := r.repo("priv", domain.VisibilityPrivate)
108+ ctx, cancel := context.WithCancel(context.Background())
109+ defer cancel()
110+
111+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
112+ root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}})
113+ scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{})
114+ onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}})
115+ require.Eventually(t, func() bool { return r.events.Watchers() == 4 }, 5*time.Second, time.Millisecond)
116+
117+ r.push(priv, 1)
118+ r.push(pub, 2)
119+
120+ // Anonymous and a token limited to another repository never see priv.
121+ for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
122+ e := w.next(t)
123+ assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
124+ w.none(t)
125+ }
126+ first, second := root.next(t), root.next(t)
127+ assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
128+ assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)
129+ assert.NotEmpty(t, first.ID)
130+ assert.NotEqual(t, first.ID, second.ID)
131+
132+ assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
133+ onlyPriv.none(t)
134+}
135+
136+func TestWatchRechecksAccessPerEvent(t *testing.T) {
137+ r := newEventRig(t, 0)
138+ n := name(t, "flip")
139+ vis := domain.VisibilityPublic
140+ var mu sync.Mutex
141+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
142+ mu.Lock()
143+ defer mu.Unlock()
144+ return domain.Repo{Name: n, Visibility: vis}, nil
145+ })
146+ ctx, cancel := context.WithCancel(context.Background())
147+ defer cancel()
148+ w := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
149+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
150+
151+ r.push(n, 1)
152+ w.next(t)
153+
154+ mu.Lock()
155+ vis = domain.VisibilityPrivate
156+ mu.Unlock()
157+ r.push(n, 2)
158+ w.none(t)
159+}
160+
161+func TestWatchResumeAndGap(t *testing.T) {
162+ const size = 8
163+ r := newEventRig(t, size)
164+ n := r.repo("pub", domain.VisibilityPublic)
165+ ctx, cancel := context.WithCancel(context.Background())
166+ defer cancel()
167+
168+ // Record the ids of 20 events through a first watcher.
169+ first := r.watch(ctx, domain.Root(), app.WatchParams{})
170+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
171+ // One at a time: this watcher must not fall behind the small ring.
172+ var ids []string
173+ for i := range 20 {
174+ r.push(n, i)
175+ ids = append(ids, first.next(t).ID)
176+ }
177+ require.Len(t, ids, 20)
178+
179+ t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
180+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
181+ for _, want := range ids[17:] {
182+ e := w.next(t)
183+ assert.False(t, e.Gap)
184+ assert.Equal(t, want, e.ID)
185+ }
186+ w.none(t)
187+ })
188+
189+ t.Run("resume at the last event replays nothing", func(t *testing.T) {
190+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})
191+ w.none(t)
192+ })
193+
194+ t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
195+ // 20 events were taken, the buffer holds the last 8: ids[12] is the
196+ // oldest still present; ids[0] is long gone.
197+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]})
198+ gap := w.next(t)
199+ assert.True(t, gap.Gap)
200+ assert.Empty(t, gap.ID)
201+ oldest := w.next(t)
202+ assert.False(t, oldest.Gap)
203+ assert.Equal(t, ids[12], oldest.ID)
204+ })
205+
206+ t.Run("an unknown id gives a gap", func(t *testing.T) {
207+ for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {
208+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id})
209+ assert.True(t, w.next(t).Gap, id)
210+ }
211+ })
212+
213+ t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) {
214+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"})
215+ assert.True(t, w.next(t).Gap)
216+ })
217+
218+ t.Run("a resumed watch goes live after the replay", func(t *testing.T) {
219+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})
220+ w.none(t)
221+ r.push(n, 99)
222+ e := w.next(t)
223+ assert.False(t, e.Gap)
224+ assert.NotEmpty(t, e.ID)
225+ })
226+}
227+
228+func TestWatchTopicErrors(t *testing.T) {
229+ r := newEventRig(t, 0)
230+ r.repo("pub", domain.VisibilityPublic)
231+ r.repo("priv", domain.VisibilityPrivate)
232+ r.meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound).Maybe()
233+ noop := func(app.WatchEvent) error { return nil }
234+ ctx := context.Background()
235+
236+ for topic, want := range map[string]error{
237+ "nope": domain.ErrInvalid,
238+ "repo:Bad": domain.ErrInvalidName,
239+ "repo:": domain.ErrInvalidName,
240+ "repo:gone": domain.ErrNotFound,
241+ "repo:priv": domain.ErrNotFound, // hidden from anonymous
242+ "repo:pub:foo": domain.ErrInvalidName,
243+ } {
244+ err := r.events.Watch(ctx, domain.Anonymous(), app.WatchParams{Topics: []string{topic}}, noop)
245+ assert.ErrorIs(t, err, want, topic)
246+ }
247+}
248+
249+func TestWatchEndsCleanly(t *testing.T) {
250+ t.Run("on context cancellation", func(t *testing.T) {
251+ r := newEventRig(t, 0)
252+ ctx, cancel := context.WithCancel(context.Background())
253+ w := r.watch(ctx, domain.Root(), app.WatchParams{})
254+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
255+ cancel()
256+ select {
257+ case err := <-w.done:
258+ assert.NoError(t, err)
259+ case <-time.After(5 * time.Second):
260+ t.Fatal("watch did not end")
261+ }
262+ assert.Zero(t, r.events.Watchers())
263+ })
264+
265+ t.Run("on Close", func(t *testing.T) {
266+ r := newEventRig(t, 0)
267+ w := r.watch(context.Background(), domain.Root(), app.WatchParams{})
268+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
269+ r.events.Close(context.Background())
270+ r.events.Close(context.Background()) // twice is fine
271+ select {
272+ case err := <-w.done:
273+ assert.NoError(t, err)
274+ case <-time.After(5 * time.Second):
275+ t.Fatal("watch did not end")
276+ }
277+ })
278+
279+ t.Run("a failing send ends the watch with its error", func(t *testing.T) {
280+ r := newEventRig(t, 0)
281+ n := r.repo("pub", domain.VisibilityPublic)
282+ boom := errors.New("client went away")
283+ done := make(chan error, 1)
284+ go func() {
285+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom })
286+ }()
287+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
288+ r.push(n, 1)
289+ assert.ErrorIs(t, <-done, boom)
290+ })
291+}
292+
293+func TestWatchSlowWatcherGetsAGap(t *testing.T) {
294+ const size = 4
295+ r := newEventRig(t, size)
296+ n := r.repo("pub", domain.VisibilityPublic)
297+
298+ release := make(chan struct{})
299+ var mu sync.Mutex
300+ var got []app.WatchEvent
301+ done := make(chan error, 1)
302+ blocked := make(chan struct{})
303+ first := true
304+ go func() {
305+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error {
306+ if first { // block on the first event while the ring turns over
307+ first = false
308+ close(blocked)
309+ <-release
310+ }
311+ mu.Lock()
312+ got = append(got, e)
313+ mu.Unlock()
314+ return nil
315+ })
316+ }()
317+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
318+ r.push(n, 0)
319+ <-blocked
320+ for i := 1; i < 30; i++ {
321+ r.push(n, i)
322+ }
323+ // Wait for the use case to have taken them all (it does not block).
324+ time.Sleep(100 * time.Millisecond)
325+ close(release)
326+
327+ require.Eventually(t, func() bool {
328+ mu.Lock()
329+ defer mu.Unlock()
330+ return len(got) >= 2
331+ }, 5*time.Second, time.Millisecond)
332+ mu.Lock()
333+ defer mu.Unlock()
334+ hasGap := false
335+ for _, e := range got[1:] {
336+ hasGap = hasGap || e.Gap
337+ }
338+ assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
339+}
340+
341+func TestEventsRepoPayloads(t *testing.T) {
342+ r := newEventRig(t, 0)
343+ pub := r.repo("pub", domain.VisibilityPublic)
344+ priv := r.repo("priv", domain.VisibilityPrivate)
345+ ctx, cancel := context.WithCancel(context.Background())
346+ defer cancel()
347+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
348+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
349+ require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
350+
351+ r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
352+ r.feed <- domain.RepoUpdated{Repo: pub, At: now}
353+ // A deleted repository cannot be read any more: its visibility decides.
354+ r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
355+ r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
356+
357+ for _, w := range []*watcher{anon, root} {
358+ created := w.next(t)
359+ require.NotNil(t, created.Repo)
360+ assert.Equal(t, "main", created.Repo.DefaultBranch)
361+ assert.Equal(t, pub, created.Repo.Name)
362+ require.NotNil(t, w.next(t).Repo)
363+ }
364+ assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
365+ assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
366+ assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
367+ anon.none(t)
368+}
@@ -0,0 +1,368 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/app"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+// eventRig is an Events use case fed by a channel the test writes to.
23+type eventRig struct {
24+ t *testing.T
25+ meta *mocks.RepoMetaStore
26+ git *mocks.GitRepos
27+ feed chan domain.Event
28+ events *app.Events
29+}
30+
31+func newEventRig(t *testing.T, size int) *eventRig {
32+ t.Helper()
33+ r := &eventRig{
34+ t: t,
35+ meta: mocks.NewRepoMetaStore(t),
36+ git: mocks.NewGitRepos(t),
37+ feed: make(chan domain.Event, 4096),
38+ }
39+ bus := mocks.NewEventBus(t)
40+ bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
41+ go func() { <-ctx.Done(); close(r.feed) }()
42+ return r.feed
43+ })
44+ clock := mocks.NewClock(t)
45+ clock.EXPECT().Now().Return(now).Maybe()
46+ r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe()
47+ repos := app.NewRepos(r.meta, r.git, bus, clock)
48+ r.events = app.NewEvents(context.Background(), repos, bus, size, clock)
49+ t.Cleanup(func() { r.events.Close(context.Background()) })
50+ return r
51+}
52+
53+// repo declares a repository the rig knows.
54+func (r *eventRig) repo(s string, vis domain.Visibility) domain.RepoName {
55+ n := name(r.t, s)
56+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: vis}, nil).Maybe()
57+ return n
58+}
59+
60+func (r *eventRig) push(n domain.RepoName, i int) {
61+ oid := fmt.Sprintf("%040x", i+1)
62+ r.feed <- domain.PushEvent{Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: "refs/heads/main"}}, At: now}
63+}
64+
65+// watcher is a running Watch.
66+type watcher struct {
67+ got chan app.WatchEvent
68+ done chan error
69+}
70+
71+func (r *eventRig) watch(ctx context.Context, p domain.Principal, params app.WatchParams) *watcher {
72+ w := &watcher{got: make(chan app.WatchEvent, 4096), done: make(chan error, 1)}
73+ go func() {
74+ w.done <- r.events.Watch(ctx, p, params, func(e app.WatchEvent) error {
75+ w.got <- e
76+ return nil
77+ })
78+ }()
79+ return w
80+}
81+
82+func (w *watcher) next(t *testing.T) app.WatchEvent {
83+ t.Helper()
84+ select {
85+ case e := <-w.got:
86+ return e
87+ case err := <-w.done:
88+ t.Fatalf("watch ended: %v", err)
89+ case <-time.After(5 * time.Second):
90+ t.Fatal("no event")
91+ }
92+ return app.WatchEvent{}
93+}
94+
95+func (w *watcher) none(t *testing.T) {
96+ t.Helper()
97+ select {
98+ case e := <-w.got:
99+ t.Fatalf("unexpected event %+v", e)
100+ case <-time.After(100 * time.Millisecond):
101+ }
102+}
103+
104+func TestWatchLiveAndFiltered(t *testing.T) {
105+ r := newEventRig(t, 0)
106+ pub := r.repo("pub", domain.VisibilityPublic)
107+ priv := r.repo("priv", domain.VisibilityPrivate)
108+ ctx, cancel := context.WithCancel(context.Background())
109+ defer cancel()
110+
111+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
112+ root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}})
113+ scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{})
114+ onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}})
115+ require.Eventually(t, func() bool { return r.events.Watchers() == 4 }, 5*time.Second, time.Millisecond)
116+
117+ r.push(priv, 1)
118+ r.push(pub, 2)
119+
120+ // Anonymous and a token limited to another repository never see priv.
121+ for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
122+ e := w.next(t)
123+ assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
124+ w.none(t)
125+ }
126+ first, second := root.next(t), root.next(t)
127+ assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
128+ assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)
129+ assert.NotEmpty(t, first.ID)
130+ assert.NotEqual(t, first.ID, second.ID)
131+
132+ assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
133+ onlyPriv.none(t)
134+}
135+
136+func TestWatchRechecksAccessPerEvent(t *testing.T) {
137+ r := newEventRig(t, 0)
138+ n := name(t, "flip")
139+ vis := domain.VisibilityPublic
140+ var mu sync.Mutex
141+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
142+ mu.Lock()
143+ defer mu.Unlock()
144+ return domain.Repo{Name: n, Visibility: vis}, nil
145+ })
146+ ctx, cancel := context.WithCancel(context.Background())
147+ defer cancel()
148+ w := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
149+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
150+
151+ r.push(n, 1)
152+ w.next(t)
153+
154+ mu.Lock()
155+ vis = domain.VisibilityPrivate
156+ mu.Unlock()
157+ r.push(n, 2)
158+ w.none(t)
159+}
160+
161+func TestWatchResumeAndGap(t *testing.T) {
162+ const size = 8
163+ r := newEventRig(t, size)
164+ n := r.repo("pub", domain.VisibilityPublic)
165+ ctx, cancel := context.WithCancel(context.Background())
166+ defer cancel()
167+
168+ // Record the ids of 20 events through a first watcher.
169+ first := r.watch(ctx, domain.Root(), app.WatchParams{})
170+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
171+ // One at a time: this watcher must not fall behind the small ring.
172+ var ids []string
173+ for i := range 20 {
174+ r.push(n, i)
175+ ids = append(ids, first.next(t).ID)
176+ }
177+ require.Len(t, ids, 20)
178+
179+ t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
180+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
181+ for _, want := range ids[17:] {
182+ e := w.next(t)
183+ assert.False(t, e.Gap)
184+ assert.Equal(t, want, e.ID)
185+ }
186+ w.none(t)
187+ })
188+
189+ t.Run("resume at the last event replays nothing", func(t *testing.T) {
190+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})
191+ w.none(t)
192+ })
193+
194+ t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
195+ // 20 events were taken, the buffer holds the last 8: ids[12] is the
196+ // oldest still present; ids[0] is long gone.
197+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]})
198+ gap := w.next(t)
199+ assert.True(t, gap.Gap)
200+ assert.Empty(t, gap.ID)
201+ oldest := w.next(t)
202+ assert.False(t, oldest.Gap)
203+ assert.Equal(t, ids[12], oldest.ID)
204+ })
205+
206+ t.Run("an unknown id gives a gap", func(t *testing.T) {
207+ for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {
208+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id})
209+ assert.True(t, w.next(t).Gap, id)
210+ }
211+ })
212+
213+ t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) {
214+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"})
215+ assert.True(t, w.next(t).Gap)
216+ })
217+
218+ t.Run("a resumed watch goes live after the replay", func(t *testing.T) {
219+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})
220+ w.none(t)
221+ r.push(n, 99)
222+ e := w.next(t)
223+ assert.False(t, e.Gap)
224+ assert.NotEmpty(t, e.ID)
225+ })
226+}
227+
228+func TestWatchTopicErrors(t *testing.T) {
229+ r := newEventRig(t, 0)
230+ r.repo("pub", domain.VisibilityPublic)
231+ r.repo("priv", domain.VisibilityPrivate)
232+ r.meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound).Maybe()
233+ noop := func(app.WatchEvent) error { return nil }
234+ ctx := context.Background()
235+
236+ for topic, want := range map[string]error{
237+ "nope": domain.ErrInvalid,
238+ "repo:Bad": domain.ErrInvalidName,
239+ "repo:": domain.ErrInvalidName,
240+ "repo:gone": domain.ErrNotFound,
241+ "repo:priv": domain.ErrNotFound, // hidden from anonymous
242+ "repo:pub:foo": domain.ErrInvalidName,
243+ } {
244+ err := r.events.Watch(ctx, domain.Anonymous(), app.WatchParams{Topics: []string{topic}}, noop)
245+ assert.ErrorIs(t, err, want, topic)
246+ }
247+}
248+
249+func TestWatchEndsCleanly(t *testing.T) {
250+ t.Run("on context cancellation", func(t *testing.T) {
251+ r := newEventRig(t, 0)
252+ ctx, cancel := context.WithCancel(context.Background())
253+ w := r.watch(ctx, domain.Root(), app.WatchParams{})
254+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
255+ cancel()
256+ select {
257+ case err := <-w.done:
258+ assert.NoError(t, err)
259+ case <-time.After(5 * time.Second):
260+ t.Fatal("watch did not end")
261+ }
262+ assert.Zero(t, r.events.Watchers())
263+ })
264+
265+ t.Run("on Close", func(t *testing.T) {
266+ r := newEventRig(t, 0)
267+ w := r.watch(context.Background(), domain.Root(), app.WatchParams{})
268+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
269+ r.events.Close(context.Background())
270+ r.events.Close(context.Background()) // twice is fine
271+ select {
272+ case err := <-w.done:
273+ assert.NoError(t, err)
274+ case <-time.After(5 * time.Second):
275+ t.Fatal("watch did not end")
276+ }
277+ })
278+
279+ t.Run("a failing send ends the watch with its error", func(t *testing.T) {
280+ r := newEventRig(t, 0)
281+ n := r.repo("pub", domain.VisibilityPublic)
282+ boom := errors.New("client went away")
283+ done := make(chan error, 1)
284+ go func() {
285+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom })
286+ }()
287+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
288+ r.push(n, 1)
289+ assert.ErrorIs(t, <-done, boom)
290+ })
291+}
292+
293+func TestWatchSlowWatcherGetsAGap(t *testing.T) {
294+ const size = 4
295+ r := newEventRig(t, size)
296+ n := r.repo("pub", domain.VisibilityPublic)
297+
298+ release := make(chan struct{})
299+ var mu sync.Mutex
300+ var got []app.WatchEvent
301+ done := make(chan error, 1)
302+ blocked := make(chan struct{})
303+ first := true
304+ go func() {
305+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error {
306+ if first { // block on the first event while the ring turns over
307+ first = false
308+ close(blocked)
309+ <-release
310+ }
311+ mu.Lock()
312+ got = append(got, e)
313+ mu.Unlock()
314+ return nil
315+ })
316+ }()
317+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
318+ r.push(n, 0)
319+ <-blocked
320+ for i := 1; i < 30; i++ {
321+ r.push(n, i)
322+ }
323+ // Wait for the use case to have taken them all (it does not block).
324+ time.Sleep(100 * time.Millisecond)
325+ close(release)
326+
327+ require.Eventually(t, func() bool {
328+ mu.Lock()
329+ defer mu.Unlock()
330+ return len(got) >= 2
331+ }, 5*time.Second, time.Millisecond)
332+ mu.Lock()
333+ defer mu.Unlock()
334+ hasGap := false
335+ for _, e := range got[1:] {
336+ hasGap = hasGap || e.Gap
337+ }
338+ assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
339+}
340+
341+func TestEventsRepoPayloads(t *testing.T) {
342+ r := newEventRig(t, 0)
343+ pub := r.repo("pub", domain.VisibilityPublic)
344+ priv := r.repo("priv", domain.VisibilityPrivate)
345+ ctx, cancel := context.WithCancel(context.Background())
346+ defer cancel()
347+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
348+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
349+ require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
350+
351+ r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
352+ r.feed <- domain.RepoUpdated{Repo: pub, At: now}
353+ // A deleted repository cannot be read any more: its visibility decides.
354+ r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
355+ r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
356+
357+ for _, w := range []*watcher{anon, root} {
358+ created := w.next(t)
359+ require.NotNil(t, created.Repo)
360+ assert.Equal(t, "main", created.Repo.DefaultBranch)
361+ assert.Equal(t, pub, created.Repo.Name)
362+ require.NotNil(t, w.next(t).Repo)
363+ }
364+ assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
365+ assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
366+ assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
367+ anon.none(t)
368+}
A internal/app/paging.go +152 -0
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+const (
17+ repoTokenPrefix = "rp1:"
18+ tokenTokenPrefix = "tp1:"
19+)
20+
21+// RepoPage is a page of repositories ordered by name.
22+type RepoPage struct {
23+ Repos []domain.Repo
24+ // NextPageToken is empty on the last page.
25+ NextPageToken string
26+}
27+
28+// ListPage returns a page of the repositories the principal may read, ordered
29+// by name. size is 0 for DefaultPageSize and lowered to MaxPageSize; the page
30+// token names the last repository of the previous page, so repositories that
31+// appear or disappear between two pages never shift the others.
32+func (r *Repos) ListPage(ctx context.Context, p domain.Principal, size int, token string) (RepoPage, error) {
33+ n, err := pageSize(size)
34+ if err != nil {
35+ return RepoPage{}, err
36+ }
37+ after, err := decodeRepoToken(token)
38+ if err != nil {
39+ return RepoPage{}, err
40+ }
41+ all, err := r.List(ctx, p)
42+ if err != nil {
43+ return RepoPage{}, err
44+ }
45+ var page RepoPage
46+ for _, repo := range all {
47+ if after != "" && repo.Name.String() <= after {
48+ continue
49+ }
50+ if len(page.Repos) == n {
51+ page.NextPageToken = encodeRepoToken(page.Repos[n-1].Name)
52+ break
53+ }
54+ page.Repos = append(page.Repos, repo)
55+ }
56+ return page, nil
57+}
58+
59+func encodeRepoToken(last domain.RepoName) string {
60+ return base64.RawURLEncoding.EncodeToString([]byte(repoTokenPrefix + last.String()))
61+}
62+
63+// decodeRepoToken returns "" for the empty token.
64+func decodeRepoToken(tok string) (string, error) {
65+ if tok == "" {
66+ return "", nil
67+ }
68+ raw, ok := decodeToken(tok, repoTokenPrefix)
69+ if !ok || raw == "" {
70+ return "", fmt.Errorf("%w: repositories", domain.ErrInvalidPageToken)
71+ }
72+ return raw, nil
73+}
74+
75+// TokenPage is a page of stored tokens, newest first.
76+type TokenPage struct {
77+ Tokens []domain.Token
78+ // NextPageToken is empty on the last page.
79+ NextPageToken string
80+}
81+
82+// ListPage returns a page of the tokens List returns, newest first (then by
83+// id). size and the page token work like Repos.ListPage; the token names the
84+// last token of the previous page.
85+func (t *Tokens) ListPage(ctx context.Context, p domain.Principal, size int, token string) (TokenPage, error) {
86+ n, err := pageSize(size)
87+ if err != nil {
88+ return TokenPage{}, err
89+ }
90+ after, err := decodeTokenCursor(token)
91+ if err != nil {
92+ return TokenPage{}, err
93+ }
94+ all, err := t.List(ctx, p)
95+ if err != nil {
96+ return TokenPage{}, err
97+ }
98+ slices.SortFunc(all, func(a, b domain.Token) int {
99+ if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
100+ return c
101+ }
102+ return strings.Compare(b.ID, a.ID)
103+ })
104+ var page TokenPage
105+ for _, tok := range all {
106+ if after != nil && !tokenAfter(tok, *after) {
107+ continue
108+ }
109+ if len(page.Tokens) == n {
110+ page.NextPageToken = encodeTokenCursor(page.Tokens[n-1])
111+ break
112+ }
113+ page.Tokens = append(page.Tokens, tok)
114+ }
115+ return page, nil
116+}
117+
118+type tokenCursor struct {
119+ created int64
120+ id string
121+}
122+
123+// tokenAfter reports whether tok comes after the cursor in newest-first order.
124+func tokenAfter(tok domain.Token, c tokenCursor) bool {
125+ if created := tok.CreatedAt.UnixNano(); created != c.created {
126+ return created < c.created
127+ }
128+ return tok.ID < c.id
129+}
130+
131+func encodeTokenCursor(last domain.Token) string {
132+ raw := tokenTokenPrefix + strconv.FormatInt(last.CreatedAt.UnixNano(), 10) + ":" + last.ID
133+ return base64.RawURLEncoding.EncodeToString([]byte(raw))
134+}
135+
136+// decodeTokenCursor returns nil for the empty token.
137+func decodeTokenCursor(tok string) (*tokenCursor, error) {
138+ if tok == "" {
139+ return nil, nil
140+ }
141+ bad := fmt.Errorf("%w: tokens", domain.ErrInvalidPageToken)
142+ raw, ok := decodeToken(tok, tokenTokenPrefix)
143+ if !ok {
144+ return nil, bad
145+ }
146+ created, id, ok := strings.Cut(raw, ":")
147+ n, err := strconv.ParseInt(created, 10, 64)
148+ if !ok || err != nil || id == "" {
149+ return nil, bad
150+ }
151+ return &tokenCursor{created: n, id: id}, nil
152+}
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+const (
17+ repoTokenPrefix = "rp1:"
18+ tokenTokenPrefix = "tp1:"
19+)
20+
21+// RepoPage is a page of repositories ordered by name.
22+type RepoPage struct {
23+ Repos []domain.Repo
24+ // NextPageToken is empty on the last page.
25+ NextPageToken string
26+}
27+
28+// ListPage returns a page of the repositories the principal may read, ordered
29+// by name. size is 0 for DefaultPageSize and lowered to MaxPageSize; the page
30+// token names the last repository of the previous page, so repositories that
31+// appear or disappear between two pages never shift the others.
32+func (r *Repos) ListPage(ctx context.Context, p domain.Principal, size int, token string) (RepoPage, error) {
33+ n, err := pageSize(size)
34+ if err != nil {
35+ return RepoPage{}, err
36+ }
37+ after, err := decodeRepoToken(token)
38+ if err != nil {
39+ return RepoPage{}, err
40+ }
41+ all, err := r.List(ctx, p)
42+ if err != nil {
43+ return RepoPage{}, err
44+ }
45+ var page RepoPage
46+ for _, repo := range all {
47+ if after != "" && repo.Name.String() <= after {
48+ continue
49+ }
50+ if len(page.Repos) == n {
51+ page.NextPageToken = encodeRepoToken(page.Repos[n-1].Name)
52+ break
53+ }
54+ page.Repos = append(page.Repos, repo)
55+ }
56+ return page, nil
57+}
58+
59+func encodeRepoToken(last domain.RepoName) string {
60+ return base64.RawURLEncoding.EncodeToString([]byte(repoTokenPrefix + last.String()))
61+}
62+
63+// decodeRepoToken returns "" for the empty token.
64+func decodeRepoToken(tok string) (string, error) {
65+ if tok == "" {
66+ return "", nil
67+ }
68+ raw, ok := decodeToken(tok, repoTokenPrefix)
69+ if !ok || raw == "" {
70+ return "", fmt.Errorf("%w: repositories", domain.ErrInvalidPageToken)
71+ }
72+ return raw, nil
73+}
74+
75+// TokenPage is a page of stored tokens, newest first.
76+type TokenPage struct {
77+ Tokens []domain.Token
78+ // NextPageToken is empty on the last page.
79+ NextPageToken string
80+}
81+
82+// ListPage returns a page of the tokens List returns, newest first (then by
83+// id). size and the page token work like Repos.ListPage; the token names the
84+// last token of the previous page.
85+func (t *Tokens) ListPage(ctx context.Context, p domain.Principal, size int, token string) (TokenPage, error) {
86+ n, err := pageSize(size)
87+ if err != nil {
88+ return TokenPage{}, err
89+ }
90+ after, err := decodeTokenCursor(token)
91+ if err != nil {
92+ return TokenPage{}, err
93+ }
94+ all, err := t.List(ctx, p)
95+ if err != nil {
96+ return TokenPage{}, err
97+ }
98+ slices.SortFunc(all, func(a, b domain.Token) int {
99+ if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
100+ return c
101+ }
102+ return strings.Compare(b.ID, a.ID)
103+ })
104+ var page TokenPage
105+ for _, tok := range all {
106+ if after != nil && !tokenAfter(tok, *after) {
107+ continue
108+ }
109+ if len(page.Tokens) == n {
110+ page.NextPageToken = encodeTokenCursor(page.Tokens[n-1])
111+ break
112+ }
113+ page.Tokens = append(page.Tokens, tok)
114+ }
115+ return page, nil
116+}
117+
118+type tokenCursor struct {
119+ created int64
120+ id string
121+}
122+
123+// tokenAfter reports whether tok comes after the cursor in newest-first order.
124+func tokenAfter(tok domain.Token, c tokenCursor) bool {
125+ if created := tok.CreatedAt.UnixNano(); created != c.created {
126+ return created < c.created
127+ }
128+ return tok.ID < c.id
129+}
130+
131+func encodeTokenCursor(last domain.Token) string {
132+ raw := tokenTokenPrefix + strconv.FormatInt(last.CreatedAt.UnixNano(), 10) + ":" + last.ID
133+ return base64.RawURLEncoding.EncodeToString([]byte(raw))
134+}
135+
136+// decodeTokenCursor returns nil for the empty token.
137+func decodeTokenCursor(tok string) (*tokenCursor, error) {
138+ if tok == "" {
139+ return nil, nil
140+ }
141+ bad := fmt.Errorf("%w: tokens", domain.ErrInvalidPageToken)
142+ raw, ok := decodeToken(tok, tokenTokenPrefix)
143+ if !ok {
144+ return nil, bad
145+ }
146+ created, id, ok := strings.Cut(raw, ":")
147+ n, err := strconv.ParseInt(created, 10, 64)
148+ if !ok || err != nil || id == "" {
149+ return nil, bad
150+ }
151+ return &tokenCursor{created: n, id: id}, nil
152+}
M internal/app/pushes.go +3 -4
@@ -49,7 +49,7 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4949
5050 // Open decides whether the principal may push to the repository and, with
5151 // create, creates it when it does not exist yet (private, published as
52-// RepoCreated). create is for the first request of a push only.
52+// RepoCreated by Repos.Create). create is for the first request of a push only.
5353 //
5454 // Anonymous callers get domain.ErrUnauthenticated. A repository the caller
5555 // cannot read and cannot write, or a missing one it may not create, answers
@@ -70,11 +70,10 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN
7070 if !create || !canWrite(p, name) {
7171 return domain.ErrNotFound
7272 }
73- repo, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
73+ _, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
7474 switch {
7575 case err == nil:
76- u.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
77- return nil
76+ return nil // Create published RepoCreated
7877 case errors.Is(err, domain.ErrAlreadyExists):
7978 // A concurrent first push won the race: this one pushes to its repository.
8079 repo, gerr := u.meta.Get(ctx, name)
@@ -49,7 +49,7 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
49 49
50 // Open decides whether the principal may push to the repository and, with50 // Open decides whether the principal may push to the repository and, with
51 // create, creates it when it does not exist yet (private, published as51 // create, creates it when it does not exist yet (private, published as
52-// RepoCreated). create is for the first request of a push only.52+// RepoCreated by Repos.Create). create is for the first request of a push only.
53 //53 //
54 // Anonymous callers get domain.ErrUnauthenticated. A repository the caller54 // Anonymous callers get domain.ErrUnauthenticated. A repository the caller
55 // cannot read and cannot write, or a missing one it may not create, answers55 // cannot read and cannot write, or a missing one it may not create, answers
@@ -70,11 +70,10 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN
70 if !create || !canWrite(p, name) {70 if !create || !canWrite(p, name) {
71 return domain.ErrNotFound71 return domain.ErrNotFound
72 }72 }
73- repo, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})73+ _, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
74 switch {74 switch {
75 case err == nil:75 case err == nil:
76- u.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})76+ return nil // Create published RepoCreated
77- return nil
78 case errors.Is(err, domain.ErrAlreadyExists):77 case errors.Is(err, domain.ErrAlreadyExists):
79 // A concurrent first push won the race: this one pushes to its repository.78 // A concurrent first push won the race: this one pushes to its repository.
80 repo, gerr := u.meta.Get(ctx, name)79 repo, gerr := u.meta.Get(ctx, name)
M internal/app/pushes_test.go +1 -1
@@ -51,7 +51,7 @@ func newPushFixture(t *testing.T) *pushFixture {
5151 sched: &scheduler{},
5252 }
5353 f.clock.EXPECT().Now().Return(now).Maybe()
54- repos := app.NewRepos(f.meta, f.git, f.clock)
54+ repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)
5555 auth := app.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
5656 f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
5757 return f
@@ -51,7 +51,7 @@ func newPushFixture(t *testing.T) *pushFixture {
51 sched: &scheduler{},51 sched: &scheduler{},
52 }52 }
53 f.clock.EXPECT().Now().Return(now).Maybe()53 f.clock.EXPECT().Now().Return(now).Maybe()
54- repos := app.NewRepos(f.meta, f.git, f.clock)54+ repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)
55 auth := app.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)55 auth := app.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
56 f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)56 f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
57 return f57 return f
M internal/app/repos.go +10 -4
@@ -19,12 +19,14 @@ import (
1919 type Repos struct {
2020 meta ports.RepoMetaStore
2121 git ports.GitRepos
22+ bus ports.EventBus
2223 clock ports.Clock
2324 }
2425
25-// NewRepos builds the repository use cases over their ports.
26-func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, clock ports.Clock) *Repos {
27- return &Repos{meta: meta, git: git, clock: clock}
26+// NewRepos builds the repository use cases over their ports. Changes are
27+// published on bus.
28+func 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}
2830 }
2931
3032 // CreateParams are the settings of a new repository.
@@ -35,7 +37,7 @@ type CreateParams struct {
3537 Visibility domain.Visibility
3638 }
3739
38-// Create makes a repository: it stages a bare repository, records the
40+// Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
3941 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,
4042 // also when a concurrent Create wins) and publishes the directory. A failure
4143 // after the metadata is written removes it again and discards the staged
@@ -53,6 +55,9 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar
5355 if !canWrite(p, params.Name) {
5456 return domain.Repo{}, domain.ErrForbidden
5557 }
58+ if err := validateDescription(params.Description); err != nil {
59+ return domain.Repo{}, err
60+ }
5661 vis := params.Visibility
5762 if vis == "" {
5863 vis = domain.VisibilityPrivate
@@ -93,6 +98,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar
9398 return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)
9499 }
95100 log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")
101+ r.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
96102 return repo, nil
97103 }
98104
@@ -19,12 +19,14 @@ import (
19 type Repos struct {19 type Repos struct {
20 meta ports.RepoMetaStore20 meta ports.RepoMetaStore
21 git ports.GitRepos21 git ports.GitRepos
22+ bus ports.EventBus
22 clock ports.Clock23 clock ports.Clock
23 }24 }
24 25
25-// NewRepos builds the repository use cases over their ports.26+// NewRepos builds the repository use cases over their ports. Changes are
26-func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, clock ports.Clock) *Repos {27+// published on bus.
27- return &Repos{meta: meta, git: git, clock: clock}28+func 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}
28 }30 }
29 31
30 // CreateParams are the settings of a new repository.32 // CreateParams are the settings of a new repository.
@@ -35,7 +37,7 @@ type CreateParams struct {
35 Visibility domain.Visibility37 Visibility domain.Visibility
36 }38 }
37 39
38-// Create makes a repository: it stages a bare repository, records the40+// Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
39 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,41 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,
40 // also when a concurrent Create wins) and publishes the directory. A failure42 // also when a concurrent Create wins) and publishes the directory. A failure
41 // after the metadata is written removes it again and discards the staged43 // after the metadata is written removes it again and discards the staged
@@ -53,6 +55,9 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar
53 if !canWrite(p, params.Name) {55 if !canWrite(p, params.Name) {
54 return domain.Repo{}, domain.ErrForbidden56 return domain.Repo{}, domain.ErrForbidden
55 }57 }
58+ if err := validateDescription(params.Description); err != nil {
59+ return domain.Repo{}, err
60+ }
56 vis := params.Visibility61 vis := params.Visibility
57 if vis == "" {62 if vis == "" {
58 vis = domain.VisibilityPrivate63 vis = domain.VisibilityPrivate
@@ -93,6 +98,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar
93 return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)98 return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)
94 }99 }
95 log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")100 log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")
101+ r.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
96 return repo, nil102 return repo, nil
97 }103 }
98 104
M internal/app/repos_integration_test.go +2 -1
@@ -15,6 +15,7 @@ import (
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/require"
1717
18+ "bnema.dev/ermite/internal/adapters/eventbus"
1819 "bnema.dev/ermite/internal/adapters/gitcli"
1920 "bnema.dev/ermite/internal/adapters/kvstore"
2021 "bnema.dev/ermite/internal/app"
@@ -45,7 +46,7 @@ func TestCreateConcurrentSameNameRealAdapters(t *testing.T) {
4546 require.NoError(t, err)
4647 t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
4748
48- repos := app.NewRepos(store, git, fixedClock{})
49+ repos := app.NewRepos(store, git, eventbus.New(), fixedClock{})
4950 n := name(t, "tools")
5051
5152 const workers = 8
@@ -15,6 +15,7 @@ import (
15 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 17
18+ "bnema.dev/ermite/internal/adapters/eventbus"
18 "bnema.dev/ermite/internal/adapters/gitcli"19 "bnema.dev/ermite/internal/adapters/gitcli"
19 "bnema.dev/ermite/internal/adapters/kvstore"20 "bnema.dev/ermite/internal/adapters/kvstore"
20 "bnema.dev/ermite/internal/app"21 "bnema.dev/ermite/internal/app"
@@ -45,7 +46,7 @@ func TestCreateConcurrentSameNameRealAdapters(t *testing.T) {
45 require.NoError(t, err)46 require.NoError(t, err)
46 t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })47 t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
47 48
48- repos := app.NewRepos(store, git, fixedClock{})49+ repos := app.NewRepos(store, git, eventbus.New(), fixedClock{})
49 n := name(t, "tools")50 n := name(t, "tools")
50 51
51 const workers = 852 const workers = 8
M internal/app/repos_test.go +5 -1
@@ -24,6 +24,7 @@ type fixture struct {
2424 meta *mocks.RepoMetaStore
2525 git *mocks.GitRepos
2626 clock *mocks.Clock
27+ bus *mocks.EventBus
2728 repos *app.Repos
2829 }
2930
@@ -33,9 +34,10 @@ func newFixture(t *testing.T) *fixture {
3334 meta: mocks.NewRepoMetaStore(t),
3435 git: mocks.NewGitRepos(t),
3536 clock: mocks.NewClock(t),
37+ bus: mocks.NewEventBus(t),
3638 }
3739 f.clock.EXPECT().Now().Return(now).Maybe()
38- f.repos = app.NewRepos(f.meta, f.git, f.clock)
40+ f.repos = app.NewRepos(f.meta, f.git, f.bus, f.clock)
3941 return f
4042 }
4143
@@ -57,6 +59,7 @@ func TestCreate(t *testing.T) {
5759 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
5860 f.meta.EXPECT().Create(ctx, want).Return(nil)
5961 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
62+ f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
6063
6164 got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"})
6265 require.NoError(t, err)
@@ -86,6 +89,7 @@ func TestCreate(t *testing.T) {
8689 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
8790 f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
8891 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
92+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
8993 _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n})
9094 require.NoError(t, err)
9195 })
@@ -24,6 +24,7 @@ type fixture struct {
24 meta *mocks.RepoMetaStore24 meta *mocks.RepoMetaStore
25 git *mocks.GitRepos25 git *mocks.GitRepos
26 clock *mocks.Clock26 clock *mocks.Clock
27+ bus *mocks.EventBus
27 repos *app.Repos28 repos *app.Repos
28 }29 }
29 30
@@ -33,9 +34,10 @@ func newFixture(t *testing.T) *fixture {
33 meta: mocks.NewRepoMetaStore(t),34 meta: mocks.NewRepoMetaStore(t),
34 git: mocks.NewGitRepos(t),35 git: mocks.NewGitRepos(t),
35 clock: mocks.NewClock(t),36 clock: mocks.NewClock(t),
37+ bus: mocks.NewEventBus(t),
36 }38 }
37 f.clock.EXPECT().Now().Return(now).Maybe()39 f.clock.EXPECT().Now().Return(now).Maybe()
38- f.repos = app.NewRepos(f.meta, f.git, f.clock)40+ f.repos = app.NewRepos(f.meta, f.git, f.bus, f.clock)
39 return f41 return f
40 }42 }
41 43
@@ -57,6 +59,7 @@ func TestCreate(t *testing.T) {
57 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)59 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
58 f.meta.EXPECT().Create(ctx, want).Return(nil)60 f.meta.EXPECT().Create(ctx, want).Return(nil)
59 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)61 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
62+ f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
60 63
61 got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"})64 got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"})
62 require.NoError(t, err)65 require.NoError(t, err)
@@ -86,6 +89,7 @@ func TestCreate(t *testing.T) {
86 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)89 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
87 f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)90 f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
88 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)91 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
92+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
89 _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n})93 _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n})
90 require.NoError(t, err)94 require.NoError(t, err)
91 })95 })
A internal/app/repos_write.go +180 -0
@@ -0,0 +1,180 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "unicode"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// MaxDescriptionLen is the longest repository description, in bytes.
18+const MaxDescriptionLen = 500
19+
20+// validateDescription accepts a one-line description of valid UTF-8.
21+func validateDescription(s string) error {
22+ if len(s) > MaxDescriptionLen {
23+ return fmt.Errorf("%w: description is longer than %d bytes", domain.ErrInvalid, MaxDescriptionLen)
24+ }
25+ if !utf8.ValidString(s) {
26+ return fmt.Errorf("%w: description is not valid UTF-8", domain.ErrInvalid)
27+ }
28+ for _, c := range s {
29+ if unicode.IsControl(c) {
30+ return fmt.Errorf("%w: description must be one line without control characters", domain.ErrInvalid)
31+ }
32+ }
33+ return nil
34+}
35+
36+// RepoView is a repository with the Git state that belongs in its summary.
37+type RepoView struct {
38+ domain.Repo
39+ // DefaultBranch is the branch HEAD points to, even when it does not exist
40+ // yet; empty when HEAD is detached or the directory is missing.
41+ DefaultBranch string
42+}
43+
44+// View adds the default branch to a repository the caller has already been
45+// allowed to see. A repository without a directory has no default branch.
46+func (r *Repos) View(ctx context.Context, repo domain.Repo) (RepoView, error) {
47+ branch, err := r.git.HeadBranch(ctx, repo.Name)
48+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
49+ return RepoView{}, fmt.Errorf("read default branch of %q: %w", repo.Name, err)
50+ }
51+ return RepoView{Repo: repo, DefaultBranch: branch}, nil
52+}
53+
54+// UpdateParams are the changes of Update; a nil field is left alone.
55+type UpdateParams struct {
56+ Description *string
57+ Visibility *domain.Visibility
58+ DefaultBranch *string
59+}
60+
61+// Update changes the description, the visibility and the default branch of a
62+// repository, and publishes RepoUpdated. The principal needs repo:admin on it
63+// (see adminAccess for what the others learn). At least one field must be set,
64+// else domain.ErrInvalid. A default branch that does not exist gives
65+// domain.ErrPreconditionFailed; HEAD is only moved after every check passed.
66+func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
67+ if name.IsZero() {
68+ return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
69+ }
70+ repo, err := r.meta.Get(ctx, name)
71+ if err != nil {
72+ return domain.Repo{}, err
73+ }
74+ if err := adminAccess(p, repo); err != nil {
75+ return domain.Repo{}, err
76+ }
77+ if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {
78+ return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
79+ }
80+ if params.Description != nil {
81+ if err := validateDescription(*params.Description); err != nil {
82+ return domain.Repo{}, err
83+ }
84+ }
85+ if params.Visibility != nil {
86+ if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
87+ return domain.Repo{}, err
88+ }
89+ }
90+ if params.DefaultBranch != nil {
91+ if *params.DefaultBranch == "" {
92+ return domain.Repo{}, fmt.Errorf("%w: default branch is empty", domain.ErrInvalid)
93+ }
94+ ok, err := r.git.HasBranch(ctx, name, *params.DefaultBranch)
95+ if err != nil {
96+ return domain.Repo{}, fmt.Errorf("check branch %q of %q: %w", *params.DefaultBranch, name, err)
97+ }
98+ if !ok {
99+ return domain.Repo{}, fmt.Errorf("%w: branch %q does not exist", domain.ErrPreconditionFailed, *params.DefaultBranch)
100+ }
101+ }
102+
103+ if params.DefaultBranch != nil {
104+ if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {
105+ return domain.Repo{}, fmt.Errorf("set default branch of %q: %w", name, err)
106+ }
107+ }
108+ if params.Description != nil || params.Visibility != nil {
109+ if repo, err = r.updateMeta(ctx, name, params); err != nil {
110+ return domain.Repo{}, err
111+ }
112+ }
113+
114+ log := zerowrap.FromCtx(ctx)
115+ log.Info().
116+ Str("repo", name.String()).
117+ Bool("description", params.Description != nil).
118+ Bool("visibility", params.Visibility != nil).
119+ Bool("default_branch", params.DefaultBranch != nil).
120+ Str("principal", p.Label()).Str("token_id", p.TokenID()).
121+ Msg("repository updated")
122+ r.bus.Publish(ctx, domain.RepoUpdated{Repo: name, At: r.clock.Now().UTC()})
123+ return repo, nil
124+}
125+
126+// updateMeta applies the metadata fields, retrying when a write (a push
127+// recording pushed_at) gets in between.
128+func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
129+ for range pushRetries {
130+ repo, err := r.meta.Get(ctx, name)
131+ if err != nil {
132+ return domain.Repo{}, err
133+ }
134+ if params.Description != nil {
135+ repo.Description = *params.Description
136+ }
137+ if params.Visibility != nil {
138+ repo.Visibility = *params.Visibility
139+ }
140+ err = r.meta.Update(ctx, repo)
141+ if errors.Is(err, domain.ErrConflict) {
142+ continue
143+ }
144+ if err != nil {
145+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
146+ }
147+ return repo, nil
148+ }
149+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)
150+}
151+
152+// Delete moves the repository directory to the trash, removes its metadata
153+// and publishes RepoDeleted. The principal needs repo:admin on it (see
154+// adminAccess). A repository whose directory is already gone, left by a
155+// delete that failed half way, loses its metadata.
156+func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
157+ if name.IsZero() {
158+ return fmt.Errorf("%w: empty name", domain.ErrInvalidName)
159+ }
160+ repo, err := r.meta.Get(ctx, name)
161+ if err != nil {
162+ return err
163+ }
164+ if err := adminAccess(p, repo); err != nil {
165+ return err
166+ }
167+ log := zerowrap.FromCtx(ctx)
168+ switch err := r.git.Trash(ctx, name); {
169+ case errors.Is(err, domain.ErrNotFound):
170+ log.Warn().Str("repo", name.String()).Msg("repository directory already gone, removing its metadata")
171+ case err != nil:
172+ return fmt.Errorf("trash repository %q: %w", name, err)
173+ }
174+ if err := r.meta.Delete(ctx, name); err != nil {
175+ return fmt.Errorf("delete metadata of %q: %w", name, err)
176+ }
177+ log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
178+ r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC()})
179+ return nil
180+}
@@ -0,0 +1,180 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "unicode"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// MaxDescriptionLen is the longest repository description, in bytes.
18+const MaxDescriptionLen = 500
19+
20+// validateDescription accepts a one-line description of valid UTF-8.
21+func validateDescription(s string) error {
22+ if len(s) > MaxDescriptionLen {
23+ return fmt.Errorf("%w: description is longer than %d bytes", domain.ErrInvalid, MaxDescriptionLen)
24+ }
25+ if !utf8.ValidString(s) {
26+ return fmt.Errorf("%w: description is not valid UTF-8", domain.ErrInvalid)
27+ }
28+ for _, c := range s {
29+ if unicode.IsControl(c) {
30+ return fmt.Errorf("%w: description must be one line without control characters", domain.ErrInvalid)
31+ }
32+ }
33+ return nil
34+}
35+
36+// RepoView is a repository with the Git state that belongs in its summary.
37+type RepoView struct {
38+ domain.Repo
39+ // DefaultBranch is the branch HEAD points to, even when it does not exist
40+ // yet; empty when HEAD is detached or the directory is missing.
41+ DefaultBranch string
42+}
43+
44+// View adds the default branch to a repository the caller has already been
45+// allowed to see. A repository without a directory has no default branch.
46+func (r *Repos) View(ctx context.Context, repo domain.Repo) (RepoView, error) {
47+ branch, err := r.git.HeadBranch(ctx, repo.Name)
48+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
49+ return RepoView{}, fmt.Errorf("read default branch of %q: %w", repo.Name, err)
50+ }
51+ return RepoView{Repo: repo, DefaultBranch: branch}, nil
52+}
53+
54+// UpdateParams are the changes of Update; a nil field is left alone.
55+type UpdateParams struct {
56+ Description *string
57+ Visibility *domain.Visibility
58+ DefaultBranch *string
59+}
60+
61+// Update changes the description, the visibility and the default branch of a
62+// repository, and publishes RepoUpdated. The principal needs repo:admin on it
63+// (see adminAccess for what the others learn). At least one field must be set,
64+// else domain.ErrInvalid. A default branch that does not exist gives
65+// domain.ErrPreconditionFailed; HEAD is only moved after every check passed.
66+func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
67+ if name.IsZero() {
68+ return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
69+ }
70+ repo, err := r.meta.Get(ctx, name)
71+ if err != nil {
72+ return domain.Repo{}, err
73+ }
74+ if err := adminAccess(p, repo); err != nil {
75+ return domain.Repo{}, err
76+ }
77+ if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {
78+ return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
79+ }
80+ if params.Description != nil {
81+ if err := validateDescription(*params.Description); err != nil {
82+ return domain.Repo{}, err
83+ }
84+ }
85+ if params.Visibility != nil {
86+ if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
87+ return domain.Repo{}, err
88+ }
89+ }
90+ if params.DefaultBranch != nil {
91+ if *params.DefaultBranch == "" {
92+ return domain.Repo{}, fmt.Errorf("%w: default branch is empty", domain.ErrInvalid)
93+ }
94+ ok, err := r.git.HasBranch(ctx, name, *params.DefaultBranch)
95+ if err != nil {
96+ return domain.Repo{}, fmt.Errorf("check branch %q of %q: %w", *params.DefaultBranch, name, err)
97+ }
98+ if !ok {
99+ return domain.Repo{}, fmt.Errorf("%w: branch %q does not exist", domain.ErrPreconditionFailed, *params.DefaultBranch)
100+ }
101+ }
102+
103+ if params.DefaultBranch != nil {
104+ if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {
105+ return domain.Repo{}, fmt.Errorf("set default branch of %q: %w", name, err)
106+ }
107+ }
108+ if params.Description != nil || params.Visibility != nil {
109+ if repo, err = r.updateMeta(ctx, name, params); err != nil {
110+ return domain.Repo{}, err
111+ }
112+ }
113+
114+ log := zerowrap.FromCtx(ctx)
115+ log.Info().
116+ Str("repo", name.String()).
117+ Bool("description", params.Description != nil).
118+ Bool("visibility", params.Visibility != nil).
119+ Bool("default_branch", params.DefaultBranch != nil).
120+ Str("principal", p.Label()).Str("token_id", p.TokenID()).
121+ Msg("repository updated")
122+ r.bus.Publish(ctx, domain.RepoUpdated{Repo: name, At: r.clock.Now().UTC()})
123+ return repo, nil
124+}
125+
126+// updateMeta applies the metadata fields, retrying when a write (a push
127+// recording pushed_at) gets in between.
128+func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
129+ for range pushRetries {
130+ repo, err := r.meta.Get(ctx, name)
131+ if err != nil {
132+ return domain.Repo{}, err
133+ }
134+ if params.Description != nil {
135+ repo.Description = *params.Description
136+ }
137+ if params.Visibility != nil {
138+ repo.Visibility = *params.Visibility
139+ }
140+ err = r.meta.Update(ctx, repo)
141+ if errors.Is(err, domain.ErrConflict) {
142+ continue
143+ }
144+ if err != nil {
145+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
146+ }
147+ return repo, nil
148+ }
149+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)
150+}
151+
152+// Delete moves the repository directory to the trash, removes its metadata
153+// and publishes RepoDeleted. The principal needs repo:admin on it (see
154+// adminAccess). A repository whose directory is already gone, left by a
155+// delete that failed half way, loses its metadata.
156+func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
157+ if name.IsZero() {
158+ return fmt.Errorf("%w: empty name", domain.ErrInvalidName)
159+ }
160+ repo, err := r.meta.Get(ctx, name)
161+ if err != nil {
162+ return err
163+ }
164+ if err := adminAccess(p, repo); err != nil {
165+ return err
166+ }
167+ log := zerowrap.FromCtx(ctx)
168+ switch err := r.git.Trash(ctx, name); {
169+ case errors.Is(err, domain.ErrNotFound):
170+ log.Warn().Str("repo", name.String()).Msg("repository directory already gone, removing its metadata")
171+ case err != nil:
172+ return fmt.Errorf("trash repository %q: %w", name, err)
173+ }
174+ if err := r.meta.Delete(ctx, name); err != nil {
175+ return fmt.Errorf("delete metadata of %q: %w", name, err)
176+ }
177+ log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
178+ r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC()})
179+ return nil
180+}
A internal/app/repos_write_test.go +198 -0
@@ -0,0 +1,198 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/app"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+func ptr[T any](v T) *T { return &v }
20+
21+func TestUpdate(t *testing.T) {
22+ ctx := context.Background()
23+ n := name(t, "tools")
24+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
25+ admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
26+
27+ t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
28+ f := newFixture(t)
29+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
30+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"}
31+ f.meta.EXPECT().Update(ctx, want).Return(nil)
32+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{Repo: n, At: now}).Return().Once()
33+
34+ got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{
35+ Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
36+ })
37+ require.NoError(t, err)
38+ assert.Equal(t, want, got)
39+ })
40+
41+ t.Run("a lost race on the metadata is retried", func(t *testing.T) {
42+ f := newFixture(t)
43+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
44+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
45+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
46+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
47+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x")})
48+ require.NoError(t, err)
49+ })
50+
51+ t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
52+ f := newFixture(t)
53+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
54+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
55+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
56+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
57+ got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
58+ require.NoError(t, err)
59+ assert.Equal(t, priv, got)
60+ })
61+
62+ t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) {
63+ f := newFixture(t)
64+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
65+ f.git.EXPECT().HasBranch(ctx, n, "nope").Return(false, nil)
66+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("nope")})
67+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
68+ })
69+
70+ t.Run("invalid input changes nothing", func(t *testing.T) {
71+ for name, params := range map[string]app.UpdateParams{
72+ "empty": {},
73+ "empty branch": {DefaultBranch: ptr("")},
74+ "multi-line": {Description: ptr("a\nb")},
75+ "too long": {Description: ptr(strings.Repeat("x", app.MaxDescriptionLen+1))},
76+ "bad utf-8": {Description: ptr("\xff")},
77+ "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
78+ } {
79+ f := newFixture(t)
80+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
81+ _, err := f.repos.Update(ctx, admin, n, params)
82+ require.Error(t, err, name)
83+ assert.True(t, errors.Is(err, domain.ErrInvalid) || errors.Is(err, domain.ErrInvalidVisibility), "%s: %v", name, err)
84+ }
85+ })
86+
87+ access := []struct {
88+ name string
89+ p domain.Principal
90+ repo domain.Repo
91+ want error
92+ }{
93+ {"anonymous on a private repository sees nothing", domain.Anonymous(), priv, domain.ErrNotFound},
94+ {"anonymous on a public repository must log in", domain.Anonymous(), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrUnauthenticated},
95+ {"reader of a private repository is forbidden", tokenPrincipal(domain.ScopeRepoRead), priv, domain.ErrForbidden},
96+ {"writer of a private repository is hidden", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
97+ {"admin of another repository is hidden", tokenPrincipal(domain.ScopeRepoAdmin, "other"), priv, domain.ErrNotFound},
98+ {"writer of a public repository is forbidden", tokenPrincipal(domain.ScopeRepoWrite), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrForbidden},
99+ }
100+ for _, tt := range access {
101+ t.Run(tt.name, func(t *testing.T) {
102+ f := newFixture(t)
103+ f.meta.EXPECT().Get(ctx, n).Return(tt.repo, nil)
104+ _, err := f.repos.Update(ctx, tt.p, n, app.UpdateParams{Description: ptr("x")})
105+ assert.ErrorIs(t, err, tt.want)
106+ err = f.repos.Delete(ctx, tt.p, n)
107+ assert.ErrorIs(t, err, tt.want)
108+ })
109+ }
110+
111+ t.Run("a missing repository is not found", func(t *testing.T) {
112+ f := newFixture(t)
113+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
114+ _, err := f.repos.Update(ctx, domain.Root(), n, app.UpdateParams{Description: ptr("x")})
115+ assert.ErrorIs(t, err, domain.ErrNotFound)
116+ })
117+}
118+
119+func TestDelete(t *testing.T) {
120+ ctx := context.Background()
121+ n := name(t, "tools")
122+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
123+
124+ t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
125+ f := newFixture(t)
126+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
127+ f.git.EXPECT().Trash(ctx, n).Return(nil)
128+ f.meta.EXPECT().Delete(ctx, n).Return(nil)
129+ f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
130+ require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
131+ })
132+
133+ t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
134+ f := newFixture(t)
135+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
136+ f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
137+ f.meta.EXPECT().Delete(ctx, n).Return(nil)
138+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
139+ require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
140+ })
141+
142+ t.Run("a failed move keeps the metadata", func(t *testing.T) {
143+ f := newFixture(t)
144+ boom := errors.New("boom")
145+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
146+ f.git.EXPECT().Trash(ctx, n).Return(boom)
147+ assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
148+ })
149+}
150+
151+func TestView(t *testing.T) {
152+ ctx := context.Background()
153+ n := name(t, "tools")
154+ repo := domain.Repo{Name: n}
155+
156+ f := newFixture(t)
157+ f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
158+ v, err := f.repos.View(ctx, repo)
159+ require.NoError(t, err)
160+ assert.Equal(t, "main", v.DefaultBranch)
161+
162+ f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
163+ v, err = f.repos.View(ctx, repo)
164+ require.NoError(t, err, "a missing directory has no default branch")
165+ assert.Empty(t, v.DefaultBranch)
166+}
167+
168+func TestListPage(t *testing.T) {
169+ ctx := context.Background()
170+ var all []domain.Repo
171+ for _, s := range []string{"a", "b", "c", "d", "e"} {
172+ all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
173+ }
174+ priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
175+ all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
176+
177+ f := newFixture(t)
178+ f.meta.EXPECT().List(ctx).Return(all, nil)
179+ var got []string
180+ token := ""
181+ for pages := 0; ; pages++ {
182+ require.Less(t, pages, 10)
183+ page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
184+ require.NoError(t, err)
185+ for _, r := range page.Repos {
186+ got = append(got, r.Name.String())
187+ }
188+ if token = page.NextPageToken; token == "" {
189+ break
190+ }
191+ }
192+ assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
193+
194+ _, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
195+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
196+ _, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
197+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
198+}
@@ -0,0 +1,198 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/app"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+func ptr[T any](v T) *T { return &v }
20+
21+func TestUpdate(t *testing.T) {
22+ ctx := context.Background()
23+ n := name(t, "tools")
24+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
25+ admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
26+
27+ t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
28+ f := newFixture(t)
29+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
30+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"}
31+ f.meta.EXPECT().Update(ctx, want).Return(nil)
32+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{Repo: n, At: now}).Return().Once()
33+
34+ got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{
35+ Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
36+ })
37+ require.NoError(t, err)
38+ assert.Equal(t, want, got)
39+ })
40+
41+ t.Run("a lost race on the metadata is retried", func(t *testing.T) {
42+ f := newFixture(t)
43+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
44+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
45+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
46+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
47+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x")})
48+ require.NoError(t, err)
49+ })
50+
51+ t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
52+ f := newFixture(t)
53+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
54+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
55+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
56+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
57+ got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
58+ require.NoError(t, err)
59+ assert.Equal(t, priv, got)
60+ })
61+
62+ t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) {
63+ f := newFixture(t)
64+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
65+ f.git.EXPECT().HasBranch(ctx, n, "nope").Return(false, nil)
66+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("nope")})
67+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
68+ })
69+
70+ t.Run("invalid input changes nothing", func(t *testing.T) {
71+ for name, params := range map[string]app.UpdateParams{
72+ "empty": {},
73+ "empty branch": {DefaultBranch: ptr("")},
74+ "multi-line": {Description: ptr("a\nb")},
75+ "too long": {Description: ptr(strings.Repeat("x", app.MaxDescriptionLen+1))},
76+ "bad utf-8": {Description: ptr("\xff")},
77+ "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
78+ } {
79+ f := newFixture(t)
80+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
81+ _, err := f.repos.Update(ctx, admin, n, params)
82+ require.Error(t, err, name)
83+ assert.True(t, errors.Is(err, domain.ErrInvalid) || errors.Is(err, domain.ErrInvalidVisibility), "%s: %v", name, err)
84+ }
85+ })
86+
87+ access := []struct {
88+ name string
89+ p domain.Principal
90+ repo domain.Repo
91+ want error
92+ }{
93+ {"anonymous on a private repository sees nothing", domain.Anonymous(), priv, domain.ErrNotFound},
94+ {"anonymous on a public repository must log in", domain.Anonymous(), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrUnauthenticated},
95+ {"reader of a private repository is forbidden", tokenPrincipal(domain.ScopeRepoRead), priv, domain.ErrForbidden},
96+ {"writer of a private repository is hidden", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
97+ {"admin of another repository is hidden", tokenPrincipal(domain.ScopeRepoAdmin, "other"), priv, domain.ErrNotFound},
98+ {"writer of a public repository is forbidden", tokenPrincipal(domain.ScopeRepoWrite), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrForbidden},
99+ }
100+ for _, tt := range access {
101+ t.Run(tt.name, func(t *testing.T) {
102+ f := newFixture(t)
103+ f.meta.EXPECT().Get(ctx, n).Return(tt.repo, nil)
104+ _, err := f.repos.Update(ctx, tt.p, n, app.UpdateParams{Description: ptr("x")})
105+ assert.ErrorIs(t, err, tt.want)
106+ err = f.repos.Delete(ctx, tt.p, n)
107+ assert.ErrorIs(t, err, tt.want)
108+ })
109+ }
110+
111+ t.Run("a missing repository is not found", func(t *testing.T) {
112+ f := newFixture(t)
113+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
114+ _, err := f.repos.Update(ctx, domain.Root(), n, app.UpdateParams{Description: ptr("x")})
115+ assert.ErrorIs(t, err, domain.ErrNotFound)
116+ })
117+}
118+
119+func TestDelete(t *testing.T) {
120+ ctx := context.Background()
121+ n := name(t, "tools")
122+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
123+
124+ t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
125+ f := newFixture(t)
126+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
127+ f.git.EXPECT().Trash(ctx, n).Return(nil)
128+ f.meta.EXPECT().Delete(ctx, n).Return(nil)
129+ f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
130+ require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
131+ })
132+
133+ t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
134+ f := newFixture(t)
135+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
136+ f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
137+ f.meta.EXPECT().Delete(ctx, n).Return(nil)
138+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
139+ require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
140+ })
141+
142+ t.Run("a failed move keeps the metadata", func(t *testing.T) {
143+ f := newFixture(t)
144+ boom := errors.New("boom")
145+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
146+ f.git.EXPECT().Trash(ctx, n).Return(boom)
147+ assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
148+ })
149+}
150+
151+func TestView(t *testing.T) {
152+ ctx := context.Background()
153+ n := name(t, "tools")
154+ repo := domain.Repo{Name: n}
155+
156+ f := newFixture(t)
157+ f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
158+ v, err := f.repos.View(ctx, repo)
159+ require.NoError(t, err)
160+ assert.Equal(t, "main", v.DefaultBranch)
161+
162+ f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
163+ v, err = f.repos.View(ctx, repo)
164+ require.NoError(t, err, "a missing directory has no default branch")
165+ assert.Empty(t, v.DefaultBranch)
166+}
167+
168+func TestListPage(t *testing.T) {
169+ ctx := context.Background()
170+ var all []domain.Repo
171+ for _, s := range []string{"a", "b", "c", "d", "e"} {
172+ all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
173+ }
174+ priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
175+ all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
176+
177+ f := newFixture(t)
178+ f.meta.EXPECT().List(ctx).Return(all, nil)
179+ var got []string
180+ token := ""
181+ for pages := 0; ; pages++ {
182+ require.Less(t, pages, 10)
183+ page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
184+ require.NoError(t, err)
185+ for _, r := range page.Repos {
186+ got = append(got, r.Name.String())
187+ }
188+ if token = page.NextPageToken; token == "" {
189+ break
190+ }
191+ }
192+ assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
193+
194+ _, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
195+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
196+ _, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
197+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
198+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close