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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 44 | 44 | hookExe string | |
| 45 | 45 | // maxBody overrides the limit of a push body when positive. | |
| 46 | 46 | maxBody int64 | |
| 47 | + | // eventBuffer overrides the size of the event ring when positive. | |
| 48 | + | eventBuffer int | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | // 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 | 79 | if hookExe == "" { | |
| 78 | 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 | 83 | require.NoError(t, err) | |
| 82 | 84 | ||
| 83 | 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 | 91 | h.home = t.TempDir() | |
| 90 | 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 | 96 | h.srv.Close() | |
| 92 | 97 | cancel() | |
| 93 | 98 | select { | |
| @@ -44,6 +44,8 @@ type harnessOpts struct { | |||||
| 44 | hookExe string | 44 | 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 int64 | 46 | 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 socket | 51 | // 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 ( | |||
| 9 | 9 | "net" | |
| 10 | 10 | "os" | |
| 11 | 11 | "path/filepath" | |
| 12 | + | "runtime/debug" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/zerowrap" | |
| @@ -18,7 +19,9 @@ import ( | |||
| 18 | 19 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 19 | 20 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 20 | 21 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 22 | + | "bnema.dev/ermite/internal/adapters/rpc" | |
| 21 | 23 | "bnema.dev/ermite/internal/app" | |
| 24 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 22 | 25 | "bnema.dev/ermite/internal/config" | |
| 23 | 26 | "bnema.dev/ermite/internal/ports" | |
| 24 | 27 | ) | |
| @@ -36,6 +39,8 @@ type options struct { | |||
| 36 | 39 | maintenanceDelay time.Duration | |
| 37 | 40 | // maxBodyBytes overrides the limit of a push body when positive. | |
| 38 | 41 | maxBodyBytes int64 | |
| 42 | + | // eventBuffer overrides app.DefaultEventBuffer when positive. | |
| 43 | + | eventBuffer int | |
| 39 | 44 | } | |
| 40 | 45 | ||
| 41 | 46 | // forge is the wired server: every adapter and use case, ready to serve. | |
| @@ -45,14 +50,28 @@ type forge struct { | |||
| 45 | 50 | server *httpserver.Server | |
| 46 | 51 | hooks *githttp.HookServer | |
| 47 | 52 | 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 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 | |
| 56 | 75 | } | |
| 57 | 76 | ||
| 58 | 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 | 105 | maxBody = opts.maxBodyBytes | |
| 87 | 106 | } | |
| 88 | 107 | git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody}) | |
| 108 | + | f.git = git | |
| 89 | 109 | gitVersion, err := git.CheckVersion(ctx) | |
| 90 | 110 | if err != nil { | |
| 91 | 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 | 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 | 121 | report, err := f.Repos.Reconcile(ctx) | |
| 101 | 122 | if err != nil { | |
| 102 | 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 | 131 | f.Tokens = app.NewTokens(store.Tokens(), clock) | |
| 111 | 132 | f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) | |
| 112 | - | f.Events = eventbus.New() | |
| 113 | 133 | f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay) | |
| 114 | 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 | 147 | 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 | 166 | gh := githttp.New(githttp.Config{ | |
| 131 | 167 | Auth: f.Auth, | |
| 132 | 168 | Repos: f.Repos, | |
| @@ -134,6 +170,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 134 | 170 | Backend: git, | |
| 135 | 171 | Hooks: f.hooks, | |
| 136 | 172 | TrustedProxies: cfg.TrustedProxies, | |
| 173 | + | Limiter: limiter, | |
| 137 | 174 | MaxBodyBytes: maxBody, | |
| 138 | 175 | }) | |
| 139 | 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 | 181 | Addr: cfg.Addr, | |
| 145 | 182 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | |
| 146 | 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 | 189 | return f, nil | |
| 149 | 190 | } | |
| @@ -178,12 +219,22 @@ func (f *forge) Serve(ctx context.Context, ln net.Listener) error { | |||
| 178 | 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 | 225 | func (f *forge) Close(ctx context.Context) { | |
| 183 | 226 | log := zerowrap.FromCtx(ctx) | |
| 184 | 227 | if f.hooks != nil { | |
| 185 | 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 | 238 | if f.maint != nil { | |
| 188 | 239 | f.maint.Close(ctx) | |
| 189 | 240 | } | |
| @@ -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.Duration | 39 | 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 int64 | 41 | 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.Server | 50 | server *httpserver.Server | ||
| 46 | hooks *githttp.HookServer | 51 | hooks *githttp.HookServer | ||
| 47 | maint *app.Maintainer | 52 | 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 the | 67 | + | // 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.Repos | 69 | + | func serverVersion() string { |
| 52 | - | Tokens *app.Tokens | 70 | + | info, ok := debug.ReadBuildInfo() |
| 53 | - | Auth *app.Auth | 71 | + | if !ok || info.Main.Version == "" || info.Main.Version == "(devel)" { |
| 54 | - | Pushes *app.Pushes | 72 | + | return "devel" |
| 55 | - | Events ports.EventBus | 73 | + | } |
| 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.maxBodyBytes | 105 | 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, err | 147 | 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, nil | 189 | 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 ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | require ( | |
| 13 | + | bnema.dev/ermite-api v0.0.0-20261010060753-a137f54f984a | |
| 13 | 14 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 15 | + | connectrpc.com/connect v1.21.0 | |
| 14 | 16 | github.com/bnema/zerowrap v1.4.1 | |
| 15 | 17 | github.com/spf13/cobra v1.10.2 | |
| 16 | 18 | github.com/stretchr/testify v1.12.1 | |
| @@ -35,7 +37,6 @@ require ( | |||
| 35 | 37 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | |
| 36 | 38 | cel.dev/cel-go v0.32.0 // indirect | |
| 37 | 39 | cel.dev/expr v0.25.3 // indirect | |
| 38 | - | connectrpc.com/connect v1.20.0 // indirect | |
| 39 | 40 | connectrpc.com/otelconnect v0.9.0 // indirect | |
| 40 | 41 | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 41 | 42 | 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-5e09b48f81c3 | 14 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 15 | + | connectrpc.com/connect v1.21.0 | |||
| 14 | github.com/bnema/zerowrap v1.4.1 | 16 | github.com/bnema/zerowrap v1.4.1 | ||
| 15 | github.com/spf13/cobra v1.10.2 | 17 | github.com/spf13/cobra v1.10.2 | ||
| 16 | github.com/stretchr/testify v1.12.1 | 18 | github.com/stretchr/testify v1.12.1 | ||
| @@ -35,7 +37,6 @@ require ( | |||||
| 35 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | 37 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | ||
| 36 | cel.dev/cel-go v0.32.0 // indirect | 38 | cel.dev/cel-go v0.32.0 // indirect | ||
| 37 | cel.dev/expr v0.25.3 // indirect | 39 | cel.dev/expr v0.25.3 // indirect | ||
| 38 | - | connectrpc.com/connect v1.20.0 // indirect | |||
| 39 | connectrpc.com/otelconnect v0.9.0 // indirect | 40 | connectrpc.com/otelconnect v0.9.0 // indirect | ||
| 40 | github.com/Microsoft/go-winio v0.6.2 // indirect | 41 | github.com/Microsoft/go-winio v0.6.2 // indirect | ||
| 41 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | 42 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | ||
M go.sum +4 -2
| @@ -1,3 +1,5 @@ | |||
| 1 | + | bnema.dev/ermite-api v0.0.0-20261010060753-a137f54f984a h1:nXAA8tkZ0elS5z00eFiGWkM0WwMC2IoIlN1t+bTQxLk= | |
| 2 | + | bnema.dev/ermite-api v0.0.0-20261010060753-a137f54f984a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 1 | 3 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | |
| 2 | 4 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3/go.mod h1:5Tb7lsUx00GVtTC6rGRWG9JbBMKlZ8hi/klP8L/DcEA= | |
| 3 | 5 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU= | |
| @@ -32,8 +34,8 @@ cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= | |||
| 32 | 34 | cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= | |
| 33 | 35 | cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= | |
| 34 | 36 | cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= | |
| 35 | - | connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= | |
| 36 | - | connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= | |
| 37 | + | connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= | |
| 38 | + | connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= | |
| 37 | 39 | connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= | |
| 38 | 40 | connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= | |
| 39 | 41 | connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | bnema.dev/ermite-api v0.0.0-20261010060753-a137f54f984a h1:nXAA8tkZ0elS5z00eFiGWkM0WwMC2IoIlN1t+bTQxLk= | |||
| 2 | + | bnema.dev/ermite-api v0.0.0-20261010060753-a137f54f984a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |||
| 1 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | 3 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | ||
| 2 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3/go.mod h1:5Tb7lsUx00GVtTC6rGRWG9JbBMKlZ8hi/klP8L/DcEA= | 4 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3/go.mod h1:5Tb7lsUx00GVtTC6rGRWG9JbBMKlZ8hi/klP8L/DcEA= | ||
| 3 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU= | 5 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU= | ||
| @@ -32,8 +34,8 @@ cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= | |||||
| 32 | cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= | 34 | cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= | ||
| 33 | cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= | 35 | cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= | ||
| 34 | cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= | 36 | cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= | ||
| 35 | - | connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= | 37 | + | connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= |
| 36 | - | connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= | 38 | + | connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= |
| 37 | connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= | 39 | connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= | ||
| 38 | connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= | 40 | connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= | ||
| 39 | connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= | 41 | connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= | ||
M internal/adapters/httpserver/middleware.go +11 -0
| @@ -46,6 +46,17 @@ func (s *statusRecorder) Write(b []byte) (int, error) { | |||
| 46 | 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 | 60 | // Unwrap lets http.ResponseController reach Flush and friends. | |
| 50 | 61 | func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } | |
| 51 | 62 | ||
| @@ -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 { | |||
| 40 | 40 | HealthChecks []Check | |
| 41 | 41 | // Routes are added to the router next to /-/health. | |
| 42 | 42 | 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 | 55 | // Server is the HTTP server of ermite. | |
| 46 | 56 | type Server struct { | |
| 47 | - | addr string | |
| 48 | - | handler http.Handler | |
| 57 | + | addr string | |
| 58 | + | handler http.Handler | |
| 59 | + | onShutdown []func() | |
| 49 | 60 | } | |
| 50 | 61 | ||
| 51 | 62 | // New builds the server and its routes. | |
| @@ -54,10 +65,13 @@ func New(cfg Config) *Server { | |||
| 54 | 65 | // "GET" patterns also match HEAD; other methods get 405 with Allow, and | |
| 55 | 66 | // unknown paths get 404. | |
| 56 | 67 | mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks)) | |
| 68 | + | if cfg.API != nil { | |
| 69 | + | mux.Handle(APIPrefix+"/", http.StripPrefix(APIPrefix, cfg.API)) | |
| 70 | + | } | |
| 57 | 71 | for _, r := range cfg.Routes { | |
| 58 | 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 | 77 | // Handler returns the root handler, for tests. | |
| @@ -87,6 +101,10 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { | |||
| 87 | 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 | 108 | errc := make(chan error, 1) | |
| 91 | 109 | go func() { errc <- srv.Serve(ln) }() | |
| 92 | 110 | log.Info().Str("addr", ln.Addr().String()).Msg("listening") | |
| @@ -40,12 +40,23 @@ type Config struct { | |||||
| 40 | HealthChecks []Check | 40 | HealthChecks []Check | ||
| 41 | // Routes are added to the router next to /-/health. | 41 | // Routes are added to the router next to /-/health. | ||
| 42 | Routes []Route | 42 | 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 string | 57 | + | addr string |
| 48 | - | handler http.Handler | 58 | + | 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, and | 65 | // "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" | |||
| 10 | 10 | // the repository within its list). Callers answer domain.ErrNotFound when it | |
| 11 | 11 | // is false, so a private repository never shows itself. | |
| 12 | 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 | 20 | 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 | 41 | // 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 | 49 | func canManageTokens(p domain.Principal) bool { | |
| 28 | 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 | + | } | |
| @@ -10,10 +10,32 @@ import "bnema.dev/ermite/internal/domain" | |||||
| 10 | // the repository within its list). Callers answer domain.ErrNotFound when it | 10 | // 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 true | 20 | 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 on | 41 | // 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 | |||
| 49 | 49 | ||
| 50 | 50 | // Open decides whether the principal may push to the repository and, with | |
| 51 | 51 | // 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 | 54 | // Anonymous callers get domain.ErrUnauthenticated. A repository the caller | |
| 55 | 55 | // 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 | 70 | if !create || !canWrite(p, name) { | |
| 71 | 71 | 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 | 74 | switch { | |
| 75 | 75 | 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 | |
| 78 | 77 | case errors.Is(err, domain.ErrAlreadyExists): | |
| 79 | 78 | // A concurrent first push won the race: this one pushes to its repository. | |
| 80 | 79 | 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, with | 50 | // Open decides whether the principal may push to the repository and, with | ||
| 51 | // create, creates it when it does not exist yet (private, published as | 51 | // 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 caller | 54 | // Anonymous callers get domain.ErrUnauthenticated. A repository the caller | ||
| 55 | // cannot read and cannot write, or a missing one it may not create, answers | 55 | // 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.ErrNotFound | 71 | 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 { | |||
| 51 | 51 | sched: &scheduler{}, | |
| 52 | 52 | } | |
| 53 | 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 | 55 | auth := app.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock) | |
| 56 | 56 | f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth) | |
| 57 | 57 | 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 f | 57 | return f | ||
M internal/app/repos.go +10 -4
| @@ -19,12 +19,14 @@ import ( | |||
| 19 | 19 | type Repos struct { | |
| 20 | 20 | meta ports.RepoMetaStore | |
| 21 | 21 | git ports.GitRepos | |
| 22 | + | bus ports.EventBus | |
| 22 | 23 | clock ports.Clock | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 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} | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | // CreateParams are the settings of a new repository. | |
| @@ -35,7 +37,7 @@ type CreateParams struct { | |||
| 35 | 37 | Visibility domain.Visibility | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 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 | |
| 39 | 41 | // metadata (which fails with domain.ErrAlreadyExists if the name is taken, | |
| 40 | 42 | // also when a concurrent Create wins) and publishes the directory. A failure | |
| 41 | 43 | // 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 | 55 | if !canWrite(p, params.Name) { | |
| 54 | 56 | return domain.Repo{}, domain.ErrForbidden | |
| 55 | 57 | } | |
| 58 | + | if err := validateDescription(params.Description); err != nil { | |
| 59 | + | return domain.Repo{}, err | |
| 60 | + | } | |
| 56 | 61 | vis := params.Visibility | |
| 57 | 62 | if vis == "" { | |
| 58 | 63 | vis = domain.VisibilityPrivate | |
| @@ -93,6 +98,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar | |||
| 93 | 98 | return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err) | |
| 94 | 99 | } | |
| 95 | 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 | 102 | return repo, nil | |
| 97 | 103 | } | |
| 98 | 104 | ||
| @@ -19,12 +19,14 @@ import ( | |||||
| 19 | type Repos struct { | 19 | type Repos struct { | ||
| 20 | meta ports.RepoMetaStore | 20 | meta ports.RepoMetaStore | ||
| 21 | git ports.GitRepos | 21 | git ports.GitRepos | ||
| 22 | + | bus ports.EventBus | |||
| 22 | clock ports.Clock | 23 | 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.Visibility | 37 | Visibility domain.Visibility | ||
| 36 | } | 38 | } | ||
| 37 | 39 | ||||
| 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 |
| 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 failure | 42 | // also when a concurrent Create wins) and publishes the directory. A failure | ||
| 41 | // after the metadata is written removes it again and discards the staged | 43 | // 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.ErrForbidden | 56 | return domain.Repo{}, domain.ErrForbidden | ||
| 55 | } | 57 | } | ||
| 58 | + | if err := validateDescription(params.Description); err != nil { | |||
| 59 | + | return domain.Repo{}, err | |||
| 60 | + | } | |||
| 56 | vis := params.Visibility | 61 | vis := params.Visibility | ||
| 57 | if vis == "" { | 62 | if vis == "" { | ||
| 58 | vis = domain.VisibilityPrivate | 63 | 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, nil | 102 | return repo, nil | ||
| 97 | } | 103 | } | ||
| 98 | 104 | ||||
M internal/app/repos_integration_test.go +2 -1
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/assert" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | + | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 18 | 19 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 19 | 20 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 20 | 21 | "bnema.dev/ermite/internal/app" | |
| @@ -45,7 +46,7 @@ func TestCreateConcurrentSameNameRealAdapters(t *testing.T) { | |||
| 45 | 46 | require.NoError(t, err) | |
| 46 | 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 | 50 | n := name(t, "tools") | |
| 50 | 51 | ||
| 51 | 52 | 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 = 8 | 52 | const workers = 8 | ||
M internal/app/repos_test.go +5 -1
| @@ -24,6 +24,7 @@ type fixture struct { | |||
| 24 | 24 | meta *mocks.RepoMetaStore | |
| 25 | 25 | git *mocks.GitRepos | |
| 26 | 26 | clock *mocks.Clock | |
| 27 | + | bus *mocks.EventBus | |
| 27 | 28 | repos *app.Repos | |
| 28 | 29 | } | |
| 29 | 30 | ||
| @@ -33,9 +34,10 @@ func newFixture(t *testing.T) *fixture { | |||
| 33 | 34 | meta: mocks.NewRepoMetaStore(t), | |
| 34 | 35 | git: mocks.NewGitRepos(t), | |
| 35 | 36 | clock: mocks.NewClock(t), | |
| 37 | + | bus: mocks.NewEventBus(t), | |
| 36 | 38 | } | |
| 37 | 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 | 41 | return f | |
| 40 | 42 | } | |
| 41 | 43 | ||
| @@ -57,6 +59,7 @@ func TestCreate(t *testing.T) { | |||
| 57 | 59 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 58 | 60 | f.meta.EXPECT().Create(ctx, want).Return(nil) | |
| 59 | 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 | 64 | got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"}) | |
| 62 | 65 | require.NoError(t, err) | |
| @@ -86,6 +89,7 @@ func TestCreate(t *testing.T) { | |||
| 86 | 89 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 87 | 90 | f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil) | |
| 88 | 91 | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |
| 92 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 89 | 93 | _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n}) | |
| 90 | 94 | require.NoError(t, err) | |
| 91 | 95 | }) | |
| @@ -24,6 +24,7 @@ type fixture struct { | |||||
| 24 | meta *mocks.RepoMetaStore | 24 | meta *mocks.RepoMetaStore | ||
| 25 | git *mocks.GitRepos | 25 | git *mocks.GitRepos | ||
| 26 | clock *mocks.Clock | 26 | clock *mocks.Clock | ||
| 27 | + | bus *mocks.EventBus | |||
| 27 | repos *app.Repos | 28 | 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 f | 41 | 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 | + | } | |||