coverage~bigbes/sr-ht-dolt3523280cremoteapi/server.go

Coverage
13.9% 5/36 statements
Δ
+0.0
Blob
b293d5b
1 package remoteapi
2
3 import (
4 "context"
5 "database/sql"
6 "fmt"
7 "log/slog"
8 "net"
9
10 remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
11 "github.com/dolthub/dolt/go/libraries/doltcore/remotesrv"
12 "github.com/dolthub/dolt/go/libraries/utils/filesys"
13 "github.com/sirupsen/logrus"
14 "github.com/vaughan0/go-ini"
15
16 "go.bigb.es/auxilia/scribe"
17
18 "sourcecraft.dev/bigbes/sr-ht-dolt/db"
19 "sourcecraft.dev/bigbes/sr-ht-dolt/storage"
20 )
21
22 // serviceName is the SourceHut service identifier for dolt.sr.ht. It selects
23 // the config section and OAuth grant namespace used across the auth stack.
24 const serviceName = "dolt.sr.ht"
25
26 // Config configures the remotesapi server assembly. It is the single struct the
27 // main wiring populates for both New (the chunk-store server) and NewCredServer
28 // (the credentials server); each uses the subset it needs.
29 type Config struct {
30 // Conf is the loaded instance config (the shared config.ini as ini.File),
31 // threaded into request contexts so the auth resolvers can read
32 // [webhooks]private-key, network-key, meta origin, etc.
33 Conf ini.File
34 // DB is the shared Postgres pool.
35 DB *sql.DB
36 // ReposRoot is the absolute directory under which bare NBS stores live
37 // ("<ReposRoot>/~<owner>/<name>"). The remotesrv filesys is rooted here.
38 ReposRoot string
39 // ListenAddr is the host:port for the remotesapi (gRPC + HTTP chunk data
40 // plane multiplexed on one h2c port).
41 ListenAddr string
42 // CredsListenAddr is the host:port for the separate CredentialsService
43 // (WhoAmI) gRPC server.
44 CredsListenAddr string
45 // HttpHost is the authority the server stamps into sealed chunk-download
46 // URLs (e.g. "dolt.srht.bigb.es"; may carry a port for local testing, e.g.
47 // "127.0.0.1:5306"). It also seeds the expected JWT audience: dolt's client
48 // derives the audience with net.SplitHostPort(endpoint), i.e. the bare host
49 // with any port stripped, so the audience the server must expect is
50 // normalizeAud(HttpHost). Leave empty to echo the request :authority into
51 // chunk URLs (auth then cannot be host-checked; used only by tests without a
52 // stable host).
53 HttpHost string
54 // DoltLogger is the logger dolt's own remotesrv writes through. It is a
55 // *logrus.Entry because that API takes nothing else; everything this
56 // package logs itself goes through slog's default.
57 //
58 // The daemon passes logrusbridge.Entry(), which routes remotesrv's records
59 // into the same slog handler as ours. That is not cosmetic: remotesrv is
60 // the code serving remote clone and push traffic, so it is the likeliest
61 // place in this process for a credential to reach a log field, and a
62 // logger of its own would put those records past the masks. A nil entry
63 // lets remotesrv install logrus' standard logger and log around
64 // everything — acceptable in a test that only wants it quiet, not in the
65 // daemon.
66 DoltLogger *logrus.Entry
67 }
68
69 // Server is the assembled remotesapi server: the remotesrv chunk-store server
70 // plus the storage cache it serves from. The cache is exposed so the web delete
71 // flow can evict a store when its repository is removed.
72 type Server struct {
73 srv *remotesrv.Server
74 cache *storage.Cache
75 logger *slog.Logger
76 addr string
77 }
78
79 // New assembles the remotesapi chunk-store server: a db-backed repo lookup, the
80 // storage cache, our auth/authz interceptors, and the importable remotesrv
81 // server bound to a single h2c port. It does not start listening; call Serve.
82 0 func New(cfg Config) (*Server, error) {
83 0 if cfg.DB == nil {
84 0 return nil, fmt.Errorf("remoteapi: New requires a non-nil DB")
85 0 }
86 0 if cfg.ReposRoot == "" {
87 0 return nil, fmt.Errorf("remoteapi: New requires a ReposRoot")
88 0 }
89 0 logger := slog.Default().With("component", "remotesapi")
90 0
91 0 // Repo lookup: resolve owner/name to the absolute on-disk store dir via the
92 0 // repository row. By the time Cache.Get runs, the row already exists: the
93 0 // interceptor runs first on every RPC and, for an authenticated owner
94 0 // touching a new name in their own namespace, has auto-created the row and
95 0 // its empty store (push-to-create). A still-missing row here is therefore a
96 0 // genuine not-found, propagated to the cache's caller.
97 0 lookup := func(ctx context.Context, owner, name string) (string, error) {
98 0 repo, err := db.NewStore(cfg.DB).GetRepoByOwnerAndName(ctx, owner, name)
99 0 if err != nil {
100 0 return "", err
101 0 }
102 0 return repo.Path, nil
103 }
104 0 cache := storage.NewCache(lookup)
105 0
106 0 keys := newKeyStore(cfg.DB)
107 0 icept := newInterceptor(cfg.Conf, serviceName, normalizeAud(cfg.HttpHost), cfg.DB, keys, cfg.ReposRoot, storage.InitEmptyStore)
108 0
109 0 // Load-bearing (see storage/init.go): the FS MUST be rooted at ReposRoot via
110 0 // LocalFilesysWithWorkingDir so sealed chunk-download URLs carry clean
111 0 // relative prefixes; a bare LocalFS breaks every clone/push at chunk
112 0 // transfer.
113 0 fs, err := filesys.LocalFilesysWithWorkingDir(cfg.ReposRoot)
114 0 if err != nil {
115 0 return nil, fmt.Errorf("remoteapi: root filesys at %q: %w", cfg.ReposRoot, err)
116 0 }
117
118 0 srv, err := remotesrv.NewServer(remotesrv.ServerArgs{
119 0 Logger: cfg.DoltLogger,
120 0 HttpHost: cfg.HttpHost,
121 0 HttpListenAddr: cfg.ListenAddr,
122 0 GrpcListenAddr: cfg.ListenAddr, // == HttpListenAddr ⇒ single h2c port
123 0 FS: fs,
124 0 DBCache: cache,
125 0 ReadOnly: false,
126 0 Options: icept.Options(),
127 0 ConcurrencyControl: remotesapi.PushConcurrencyControl_PUSH_CONCURRENCY_CONTROL_IGNORE_WORKING_SET,
128 0 })
129 0 if err != nil {
130 0 if cerr := cache.Close(); cerr != nil {
131 0 logger.Warn("closing the chunk-store cache after a NewServer failure", scribe.Err(cerr))
132 0 }
133 0 return nil, fmt.Errorf("remoteapi: remotesrv.NewServer: %w", err)
134 }
135
136 0 return &Server{srv: srv, cache: cache, logger: logger, addr: cfg.ListenAddr}, nil
137 }
138
139 // Cache returns the storage cache backing this server so the web delete flow
140 // can evict a store on repository removal.
141 0 func (s *Server) Cache() *storage.Cache { return s.cache }
142
143 // Serve binds the listeners and serves until GracefulStop. It blocks. It
144 // returns an error only if binding the listeners fails; the underlying
145 // remotesrv.Serve blocks until shutdown and does not return an error.
146 0 func (s *Server) Serve() error {
147 0 listeners, err := s.srv.Listeners()
148 0 if err != nil {
149 0 return fmt.Errorf("remoteapi: bind %q: %w", s.addr, err)
150 0 }
151 0 s.srv.Serve(listeners)
152 0 return nil
153 }
154
155 // GracefulStop stops the server and closes every memoized chunk store.
156 0 func (s *Server) GracefulStop() {
157 0 s.srv.GracefulStop()
158 0 if err := s.cache.Close(); err != nil {
159 0 s.logger.Warn("closing the chunk-store cache on shutdown", scribe.Err(err))
160 0 }
161 }
162
163 // normalizeAud reduces a configured HttpHost to the bare host the dolt client
164 // puts in a JWT audience. Verified against dolt's grpc_dial_provider:
165 // getHostFromEndpoint(endpoint) calls net.SplitHostPort and returns the host
166 // with any port stripped, so the audience the server receives is always the
167 // bare host. Normalizing here lets operators write either "dolt.srht.bigb.es"
168 // or "dolt.srht.bigb.es:443" (or a "127.0.0.1:PORT" test host) and get the same
169 // expected audience. An empty host yields an empty audience (no keypair auth).
170 4 func normalizeAud(host string) string {
171 4 if host == "" {
172 1 return ""
173 1 }
174 3 if h, _, err := net.SplitHostPort(host); err == nil {
175 2 return h
176 2 }
177 1 return host
178 }