coverage~bigbes/sr-ht-spec64cae3afservice/service.go

Coverage
85.9% 55/64 statements
Δ
+0.0
Blob
4c035d2
1 package service
2
3 import (
4 "errors"
5 "fmt"
6 "net/url"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/vaughan0/go-ini"
12 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
13
14 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
15 "sourcecraft.dev/bigbes/sr-ht-spec/db"
16 )
17
18 // ConfigSection is our config section, the literal "spec.sr.ht". The ".sr.ht"
19 // suffix is what puts us in the nav network list and what other services look
20 // us up by, so it is taken from authn rather than re-spelled here.
21 const ConfigSection = authn.ConfigSection
22
23 // Sentinel errors. Callers compare with errors.Is. Every error leaving this
24 // package carries one of these in addition to the gitx or db class it came
25 // from, so api/, mcpsrv/, graph/ and web/ can map a failure to a status code
26 // without importing the layers below service.
27 var (
28 // ErrIncompleteConfig is returned by LoadConfig when the instance config
29 // omits a key this service needs. It is a startup failure: the daemon
30 // should print it and exit rather than serve a request that will fail
31 // deeper down with a worse message.
32 ErrIncompleteConfig = errors.New("service: incomplete configuration")
33
34 // ErrNotFound marks a missing space, revision, document or row.
35 ErrNotFound = errors.New("service: not found")
36 // ErrBadReadRev rejects a revision that is not an immutable object name.
37 ErrBadReadRev = errors.New("service: revision must be an object name")
38
39 // ErrSpaceExists marks a create that would clobber an existing space,
40 // either its repository on disk or its row.
41 ErrSpaceExists = errors.New("service: space already exists")
42
43 // ErrProjectExists marks a create that would clobber an existing project.
44 ErrProjectExists = errors.New("service: project already exists")
45
46 // ErrPushRejected marks a push the update hook must refuse. Type-assert to
47 // *PushRejection for the message to print to the pushing client.
48 ErrPushRejected = errors.New("service: push rejected")
49
50 // ErrForbidden marks a write attempted by a principal that may not make it:
51 // a non-agent trying to propose, the write plane's 403. Proposing is an
52 // agent-only act — the human write path is native receive-pack — so this is
53 // a refusal of the principal, not of the request.
54 ErrForbidden = errors.New("service: forbidden")
55
56 // ErrInvalid marks a write the principal may make but the request itself is
57 // malformed: a document whose frontmatter does not parse or fails the
58 // schema, a path outside the tree, two uploads claiming one id, an open with
59 // no title, a write with no documents. It is the write plane's 400/422, kept
60 // apart from ErrForbidden so a surface does not answer "bad document" with
61 // "you are not allowed" — a distinction that matters most to the agent that
62 // has to fix and retry.
63 ErrInvalid = errors.New("service: invalid request")
64
65 // ErrStale marks a proposal whose base moved under it: the write plane's
66 // 409. It wraps a gitx staleness reason, so a caller that wants to tell the
67 // agent which document went stale type-asserts to *gitx.StaleError; one that
68 // only needs the status code matches this. Refetch the approved head and
69 // re-propose.
70 ErrStale = errors.New("service: proposal base is stale")
71
72 // ErrAlreadyMerged marks a merge of a proposal whose commits are already an
73 // ancestor of the approved head — it merged, and this is a repeat. It is a
74 // distinct answer from a staleness 409 (see the design's "already-merged
75 // proposals need an ancestry check, not a staleness check"): the proposal
76 // succeeded, so the caller should read the outcome rather than re-propose.
77 ErrAlreadyMerged = errors.New("service: proposal already merged")
78
79 // ErrProposalNotOpen marks a write to, or a resolution of, a proposal that
80 // has already merged or been rejected. The state machine is terminal in one
81 // direction, so this is never a retryable condition.
82 ErrProposalNotOpen = errors.New("service: proposal is not open")
83 )
84
85 // Config is everything service/ needs from the instance config.ini. It is a
86 // value so the daemon can build it once, log it, and hand copies around.
87 type Config struct {
88 // Repos is [spec.sr.ht] repos: the root under which every space's bare
89 // repository lives as <repos>/~<owner>/<space>. Must be absolute — gitx
90 // keys its per-space write lock by directory, and two spellings of one
91 // directory would be two locks that do not exclude each other.
92 Repos string
93
94 // Cache is [spec.sr.ht] cache: the bleve index and the blob-sha-keyed
95 // render cache. Pure cache, safe to delete at any time; Phase 2 owns what
96 // goes in it, Phase 1 only insists it is configured and absolute.
97 Cache string
98
99 // Origin is [spec.sr.ht] origin, without a trailing slash. It is the base
100 // of every proposal URL an agent hands a human, and the host half of it is
101 // where authn derives the synthetic agent mailbox from.
102 Origin string
103
104 // ConnectionString is [spec.sr.ht] connection-string. This package does not
105 // open the pool — the daemon does, so core-go's database middleware and the
106 // reconciler share one — but a service whose DSN is missing cannot work at
107 // all, so it is validated here with the rest.
108 ConnectionString string
109
110 // Instance carries [sr.ht] owner-name / owner-email and the derived agent
111 // mailbox: the identities stamped on every commit this service makes.
112 Instance authn.Instance
113 }
114
115 // LoadConfig reads and validates every key service/ needs out of the instance
116 // config, reporting all missing keys at once.
117 //
118 // Reporting them together is deliberate, and copied from compare.sr.ht's
119 // validateConfig: an operator fixes the config in one pass instead of
120 // discovering each gap on a separate restart.
121 //
122 // It validates only what this package reads. The daemon is still responsible
123 // for the keys core-go itself fatals on — [sr.ht] network-key and [webhooks]
124 // private-key, both required by crypto.InitCrypto — because those belong to
125 // server.New's contract, not to ours, and duplicating them here would give the
126 // instance two lists to keep in sync.
127 8 func LoadConfig(conf ini.File) (Config, error) {
128 8 var missing []string
129 48 get := func(section, key string) string {
130 48 v, ok := conf.Get(section, key)
131 48 if v = strings.TrimSpace(v); !ok || v == "" {
132 4 missing = append(missing, fmt.Sprintf("[%s] %s", section, key))
133 4 return ""
134 4 }
135 44 return v
136 }
137
138 8 cfg := Config{
139 8 Repos: get(ConfigSection, "repos"),
140 8 Cache: get(ConfigSection, "cache"),
141 8 // One canonical spelling of the origin, so a proposal URL built from it
142 8 // never grows a double slash and never differs between two callers —
143 8 // instconf's, which strips every trailing slash rather than the one
144 8 // TrimSuffix took, and which is the same spelling authn derives the agent
145 8 // mailbox from and mcpsrv compares a Host header against.
146 8 Origin: instconf.CanonicalOrigin(get(ConfigSection, "origin")),
147 8 ConnectionString: get(ConfigSection, "connection-string"),
148 8 }
149 8
150 8 // Read for their presence only; authn.InstanceFromConfig is what turns them
151 8 // into identities, and it must not be reached with a key missing or it
152 8 // reports one gap where we want to report all of them.
153 8 get("sr.ht", "owner-name")
154 8 get("sr.ht", "owner-email")
155 8
156 8 if len(missing) > 0 {
157 2 return Config{}, fmt.Errorf("%w; missing required keys:\n\t%s",
158 2 ErrIncompleteConfig, strings.Join(missing, "\n\t"))
159 2 }
160
161 6 inst, err := authn.InstanceFromConfig(conf)
162 6 if err != nil {
163 1 return Config{}, fmt.Errorf("%w: %w", ErrIncompleteConfig, err)
164 1 }
165 5 cfg.Instance = inst
166 5
167 5 if err := cfg.Validate(); err != nil {
168 3 return Config{}, err
169 3 }
170 2 return cfg, nil
171 }
172
173 // Validate reports whether the configuration is usable. It is exported so a
174 // daemon that builds a Config from somewhere other than an ini file — a test,
175 // or a future flag — is held to the same rules.
176 76 func (c Config) Validate() error {
177 76 var problems []string
178 152 requireAbs := func(key, path string) {
179 152 switch {
180 0 case path == "":
181 0 problems = append(problems, fmt.Sprintf("[%s] %s is empty", ConfigSection, key))
182 3 case !filepath.IsAbs(path):
183 3 problems = append(problems, fmt.Sprintf("[%s] %s must be an absolute path, got %q",
184 3 ConfigSection, key, path))
185 }
186 }
187 76 requireAbs("repos", c.Repos)
188 76 requireAbs("cache", c.Cache)
189 76
190 76 // instconf.OriginHost answers "" for both an origin that does not parse and
191 76 // one that parses to no host, which are one problem to the operator and one
192 76 // message here. The scheme is still read locally: instconf deliberately
193 76 // exposes the host and the authority and no scheme accessor, and "an origin
194 76 // this service will redirect a browser to must be http or https" is
195 76 // spec.sr.ht's own rule rather than the instance's.
196 76 switch u, _ := url.Parse(c.Origin); {
197 0 case c.Origin == "":
198 0 problems = append(problems, fmt.Sprintf("[%s] origin is empty", ConfigSection))
199 0 case instconf.OriginHost(c.Origin) == "":
200 0 problems = append(problems, fmt.Sprintf("[%s] origin %q has no host", ConfigSection, c.Origin))
201 1 case u.Scheme != "http" && u.Scheme != "https":
202 1 problems = append(problems, fmt.Sprintf("[%s] origin %q must be http or https",
203 1 ConfigSection, c.Origin))
204 }
205
206 76 if c.ConnectionString == "" {
207 0 problems = append(problems, fmt.Sprintf("[%s] connection-string is empty", ConfigSection))
208 0 }
209 76 if err := c.Instance.Validate(); err != nil {
210 0 problems = append(problems, err.Error())
211 0 }
212
213 76 if len(problems) > 0 {
214 4 return fmt.Errorf("%w:\n\t%s", ErrIncompleteConfig, strings.Join(problems, "\n\t"))
215 4 }
216 72 return nil
217 }
218
219 // Service is the orchestration layer. One per daemon; safe for concurrent use.
220 type Service struct {
221 cfg Config
222 q db.Querier
223 store *db.Store
224 resolver *authn.Resolver
225
226 // ownerUserID caches the id of the owner's "user" row, seeded by
227 // EnsureOwnerUser at startup. Zero until then. It is the user_id the
228 // core-go webhook engine's user-scoped subscriptions FK against and the
229 // UserID the coreauth bridge stamps on the owner's AuthContext.
230 ownerUserID int
231
232 // events is the optional webhook/notification sink. Nil until SetEventSink
233 // installs it at startup; a Service with no sink emits nothing.
234 events EventSink
235
236 // grace is how long a proposal row with no branch is left alone before the
237 // reconciler deletes it. See DefaultReconcileGrace.
238 grace time.Duration
239
240 // now is the clock, injectable so the reconciler's grace window is
241 // testable without sleeping.
242 now func() time.Time
243 }
244
245 // New assembles a Service over a database handle.
246 //
247 // q is normally the *sql.DB the daemon opened from Config.ConnectionString and
248 // handed to core-go's database middleware, so request-scoped queries and the
249 // reconciler's background queries share one pool. A nil handle is refused
250 // rather than tolerated: half this layer would then fail one query at a time
251 // instead of once, at startup, where an operator is looking.
252 //
253 // Pass WithInstanceTokens(conf) to give the resolver the tokens.sr.ht plane —
254 // the only plane an agent can authenticate on. The daemon passes it and fails
255 // startup without a [tokens.sr.ht] origin; the CLI paths (`specsrht doc`)
256 // authenticate nobody and omit it, and the resolver they get refuses every
257 // bearer credential rather than pretending to check one.
258 71 func New(cfg Config, q db.Querier, opts ...Option) (*Service, error) {
259 71 if err := cfg.Validate(); err != nil {
260 1 return nil, err
261 1 }
262 70 if q == nil {
263 1 return nil, errors.New("service: nil database handle")
264 1 }
265
266 69 var o options
267 69 for _, opt := range opts {
268 2 opt(&o)
269 2 }
270 69 var ropts []authn.ResolverOption
271 69 if o.haveConf {
272 2 plane, err := instancePlane(o.conf, q)
273 2 if err != nil {
274 1 return nil, err
275 1 }
276 1 ropts = append(ropts, plane)
277 }
278
279 68 resolver, err := authn.NewResolver(cfg.Instance.OwnerName, ropts...)
280 68 if err != nil {
281 0 return nil, fmt.Errorf("service: build resolver: %w", err)
282 0 }
283 68 return &Service{
284 68 cfg: cfg,
285 68 q: q,
286 68 store: db.NewStore(q),
287 68 resolver: resolver,
288 68 grace: DefaultReconcileGrace,
289 68 now: time.Now,
290 68 }, nil
291 }
292
293 // SetEventSink installs the webhook/notification sink. Called once at startup,
294 // after the sink (which needs the owner user id) is built.
295 5 func (s *Service) SetEventSink(sink EventSink) { s.events = sink }
296
297 // emit fires a proposal event when a sink is installed. Nil-safe.
298 35 func (s *Service) emit(kind ProposalEventKind, p Proposal) {
299 35 if s.events != nil {
300 8 s.events.ProposalEvent(kind, p)
301 8 }
302 }
303
304 // Config returns the configuration this service was built from.
305 0 func (s *Service) Config() Config { return s.cfg }
306
307 // ReposRoot is [spec.sr.ht] repos, the root every space's bare repository lives
308 // under.
309 1 func (s *Service) ReposRoot() string { return s.cfg.Repos }
310
311 // CacheDir is [spec.sr.ht] cache. Phase 2 owns its contents.
312 0 func (s *Service) CacheDir() string { return s.cfg.Cache }
313
314 // Origin is our external origin, without a trailing slash.
315 1 func (s *Service) Origin() string { return s.cfg.Origin }
316
317 // Instance returns the commit identities: the instance owner and the derived
318 // agent mailbox.
319 0 func (s *Service) Instance() authn.Instance { return s.cfg.Instance }
320
321 // Store exposes the persistence layer. It is here for the daemon's own
322 // bookkeeping (token minting, migrations tooling); handlers above this layer
323 // call Service methods instead, because the dependency rule says nothing above
324 // service/ may touch db/ directly.
325 10 func (s *Service) Store() *db.Store { return s.store }
326
327 // Resolver turns a request into an authn.Principal. The daemon installs
328 // Resolver().Middleware() on its router.
329 4 func (s *Service) Resolver() *authn.Resolver { return s.resolver }