~bigbes/sr-ht-spec

ref: dd56e38c60d154ed0db6d60101a2fb92667438ea sr-ht-spec/service/service.go -rw-r--r-- 12.1 KiB
dd56e38c — Eugene Blikh refactor(doc): one route from a revision to an Archive (spec-wcr #2, #4) 25 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
package service

import (
	"errors"
	"fmt"
	"net/url"
	"path/filepath"
	"strings"
	"time"

	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/db"
)

// ConfigSection is our config section, the literal "spec.sr.ht". The ".sr.ht"
// suffix is what puts us in the nav network list and what other services look
// us up by, so it is taken from authn rather than re-spelled here.
const ConfigSection = authn.ConfigSection

// Sentinel errors. Callers compare with errors.Is. Every error leaving this
// package carries one of these in addition to the gitx or db class it came
// from, so api/, mcpsrv/, graph/ and web/ can map a failure to a status code
// without importing the layers below service.
var (
	// ErrIncompleteConfig is returned by LoadConfig when the instance config
	// omits a key this service needs. It is a startup failure: the daemon
	// should print it and exit rather than serve a request that will fail
	// deeper down with a worse message.
	ErrIncompleteConfig = errors.New("service: incomplete configuration")

	// ErrNotFound marks a missing space, revision, document or row.
	ErrNotFound = errors.New("service: not found")
	// ErrBadReadRev rejects a revision that is not an immutable object name.
	ErrBadReadRev = errors.New("service: revision must be an object name")

	// ErrSpaceExists marks a create that would clobber an existing space,
	// either its repository on disk or its row.
	ErrSpaceExists = errors.New("service: space already exists")

	// ErrProjectExists marks a create that would clobber an existing project.
	ErrProjectExists = errors.New("service: project already exists")

	// ErrPushRejected marks a push the update hook must refuse. Type-assert to
	// *PushRejection for the message to print to the pushing client.
	ErrPushRejected = errors.New("service: push rejected")

	// ErrForbidden marks a write attempted by a principal that may not make it:
	// a non-agent trying to propose, the write plane's 403. Proposing is an
	// agent-only act — the human write path is native receive-pack — so this is
	// a refusal of the principal, not of the request.
	ErrForbidden = errors.New("service: forbidden")

	// ErrInvalid marks a write the principal may make but the request itself is
	// malformed: a document whose frontmatter does not parse or fails the
	// schema, a path outside the tree, two uploads claiming one id, an open with
	// no title, a write with no documents. It is the write plane's 400/422, kept
	// apart from ErrForbidden so a surface does not answer "bad document" with
	// "you are not allowed" — a distinction that matters most to the agent that
	// has to fix and retry.
	ErrInvalid = errors.New("service: invalid request")

	// ErrStale marks a proposal whose base moved under it: the write plane's
	// 409. It wraps a gitx staleness reason, so a caller that wants to tell the
	// agent which document went stale type-asserts to *gitx.StaleError; one that
	// only needs the status code matches this. Refetch the approved head and
	// re-propose.
	ErrStale = errors.New("service: proposal base is stale")

	// ErrAlreadyMerged marks a merge of a proposal whose commits are already an
	// ancestor of the approved head — it merged, and this is a repeat. It is a
	// distinct answer from a staleness 409 (see the design's "already-merged
	// proposals need an ancestry check, not a staleness check"): the proposal
	// succeeded, so the caller should read the outcome rather than re-propose.
	ErrAlreadyMerged = errors.New("service: proposal already merged")

	// ErrProposalNotOpen marks a write to, or a resolution of, a proposal that
	// has already merged or been rejected. The state machine is terminal in one
	// direction, so this is never a retryable condition.
	ErrProposalNotOpen = errors.New("service: proposal is not open")
)

// Config is everything service/ needs from the instance config.ini. It is a
// value so the daemon can build it once, log it, and hand copies around.
type Config struct {
	// Repos is [spec.sr.ht] repos: the root under which every space's bare
	// repository lives as <repos>/~<owner>/<space>. Must be absolute — gitx
	// keys its per-space write lock by directory, and two spellings of one
	// directory would be two locks that do not exclude each other.
	Repos string

	// Cache is [spec.sr.ht] cache: the bleve index and the blob-sha-keyed
	// render cache. Pure cache, safe to delete at any time; Phase 2 owns what
	// goes in it, Phase 1 only insists it is configured and absolute.
	Cache string

	// Origin is [spec.sr.ht] origin, without a trailing slash. It is the base
	// of every proposal URL an agent hands a human, and the host half of it is
	// where authn derives the synthetic agent mailbox from.
	Origin string

	// ConnectionString is [spec.sr.ht] connection-string. This package does not
	// open the pool — the daemon does, so core-go's database middleware and the
	// reconciler share one — but a service whose DSN is missing cannot work at
	// all, so it is validated here with the rest.
	ConnectionString string

	// Instance carries [sr.ht] owner-name / owner-email and the derived agent
	// mailbox: the identities stamped on every commit this service makes.
	Instance authn.Instance
}

// LoadConfig reads and validates every key service/ needs out of the instance
// config, reporting all missing keys at once.
//
// Reporting them together is deliberate, and copied from compare.sr.ht's
// validateConfig: an operator fixes the config in one pass instead of
// discovering each gap on a separate restart.
//
// It validates only what this package reads. The daemon is still responsible
// for the keys core-go itself fatals on — [sr.ht] network-key and [webhooks]
// private-key, both required by crypto.InitCrypto — because those belong to
// server.New's contract, not to ours, and duplicating them here would give the
// instance two lists to keep in sync.
func LoadConfig(conf ini.File) (Config, error) {
	var missing []string
	get := func(section, key string) string {
		v, ok := conf.Get(section, key)
		if v = strings.TrimSpace(v); !ok || v == "" {
			missing = append(missing, fmt.Sprintf("[%s] %s", section, key))
			return ""
		}
		return v
	}

	cfg := Config{
		Repos:            get(ConfigSection, "repos"),
		Cache:            get(ConfigSection, "cache"),
		Origin:           get(ConfigSection, "origin"),
		ConnectionString: get(ConfigSection, "connection-string"),
	}
	// One canonical spelling of the origin, so a proposal URL built from it
	// never grows a double slash and never differs between two callers.
	cfg.Origin = strings.TrimSuffix(cfg.Origin, "/")

	// Read for their presence only; authn.InstanceFromConfig is what turns them
	// into identities, and it must not be reached with a key missing or it
	// reports one gap where we want to report all of them.
	get("sr.ht", "owner-name")
	get("sr.ht", "owner-email")

	if len(missing) > 0 {
		return Config{}, fmt.Errorf("%w; missing required keys:\n\t%s",
			ErrIncompleteConfig, strings.Join(missing, "\n\t"))
	}

	inst, err := authn.InstanceFromConfig(conf)
	if err != nil {
		return Config{}, fmt.Errorf("%w: %w", ErrIncompleteConfig, err)
	}
	cfg.Instance = inst

	if err := cfg.Validate(); err != nil {
		return Config{}, err
	}
	return cfg, nil
}

// Validate reports whether the configuration is usable. It is exported so a
// daemon that builds a Config from somewhere other than an ini file — a test,
// or a future flag — is held to the same rules.
func (c Config) Validate() error {
	var problems []string
	requireAbs := func(key, path string) {
		switch {
		case path == "":
			problems = append(problems, fmt.Sprintf("[%s] %s is empty", ConfigSection, key))
		case !filepath.IsAbs(path):
			problems = append(problems, fmt.Sprintf("[%s] %s must be an absolute path, got %q",
				ConfigSection, key, path))
		}
	}
	requireAbs("repos", c.Repos)
	requireAbs("cache", c.Cache)

	switch u, err := url.Parse(c.Origin); {
	case c.Origin == "":
		problems = append(problems, fmt.Sprintf("[%s] origin is empty", ConfigSection))
	case err != nil:
		problems = append(problems, fmt.Sprintf("[%s] origin %q is not a URL: %v",
			ConfigSection, c.Origin, err))
	case u.Hostname() == "":
		problems = append(problems, fmt.Sprintf("[%s] origin %q has no host", ConfigSection, c.Origin))
	case u.Scheme != "http" && u.Scheme != "https":
		problems = append(problems, fmt.Sprintf("[%s] origin %q must be http or https",
			ConfigSection, c.Origin))
	}

	if c.ConnectionString == "" {
		problems = append(problems, fmt.Sprintf("[%s] connection-string is empty", ConfigSection))
	}
	if err := c.Instance.Validate(); err != nil {
		problems = append(problems, err.Error())
	}

	if len(problems) > 0 {
		return fmt.Errorf("%w:\n\t%s", ErrIncompleteConfig, strings.Join(problems, "\n\t"))
	}
	return nil
}

// Service is the orchestration layer. One per daemon; safe for concurrent use.
type Service struct {
	cfg      Config
	q        db.Querier
	store    *db.Store
	tokens   *TokenStore
	resolver *authn.Resolver

	// ownerUserID caches the id of the owner's "user" row, seeded by
	// EnsureOwnerUser at startup. Zero until then. It is the user_id the
	// core-go webhook engine's user-scoped subscriptions FK against and the
	// UserID the coreauth bridge stamps on the owner's AuthContext.
	ownerUserID int

	// events is the optional webhook/notification sink. Nil until SetEventSink
	// installs it at startup; a Service with no sink emits nothing.
	events EventSink

	// grace is how long a proposal row with no branch is left alone before the
	// reconciler deletes it. See DefaultReconcileGrace.
	grace time.Duration

	// now is the clock, injectable so the reconciler's grace window is
	// testable without sleeping.
	now func() time.Time
}

// New assembles a Service over a database handle.
//
// q is normally the *sql.DB the daemon opened from Config.ConnectionString and
// handed to core-go's database middleware, so request-scoped queries and the
// reconciler's background queries share one pool. A nil handle is refused
// rather than tolerated: every agent token would then resolve as unknown, which
// looks exactly like a mass revocation and is a miserable thing to debug.
func New(cfg Config, q db.Querier) (*Service, error) {
	if err := cfg.Validate(); err != nil {
		return nil, err
	}
	if q == nil {
		return nil, errors.New("service: nil database handle")
	}
	store := db.NewStore(q)
	tokens := NewTokenStore(store)
	resolver, err := authn.NewResolver(cfg.Instance.OwnerName, tokens)
	if err != nil {
		return nil, fmt.Errorf("service: build resolver: %w", err)
	}
	return &Service{
		cfg:      cfg,
		q:        q,
		store:    store,
		tokens:   tokens,
		resolver: resolver,
		grace:    DefaultReconcileGrace,
		now:      time.Now,
	}, nil
}

// SetEventSink installs the webhook/notification sink. Called once at startup,
// after the sink (which needs the owner user id) is built.
func (s *Service) SetEventSink(sink EventSink) { s.events = sink }

// emit fires a proposal event when a sink is installed. Nil-safe.
func (s *Service) emit(kind ProposalEventKind, p Proposal) {
	if s.events != nil {
		s.events.ProposalEvent(kind, p)
	}
}

// Config returns the configuration this service was built from.
func (s *Service) Config() Config { return s.cfg }

// ReposRoot is [spec.sr.ht] repos, the root every space's bare repository lives
// under.
func (s *Service) ReposRoot() string { return s.cfg.Repos }

// CacheDir is [spec.sr.ht] cache. Phase 2 owns its contents.
func (s *Service) CacheDir() string { return s.cfg.Cache }

// Origin is our external origin, without a trailing slash.
func (s *Service) Origin() string { return s.cfg.Origin }

// Instance returns the commit identities: the instance owner and the derived
// agent mailbox.
func (s *Service) Instance() authn.Instance { return s.cfg.Instance }

// Store exposes the persistence layer. It is here for the daemon's own
// bookkeeping (token minting, migrations tooling); handlers above this layer
// call Service methods instead, because the dependency rule says nothing above
// service/ may touch db/ directly.
func (s *Service) Store() *db.Store { return s.store }

// Resolver turns a request into an authn.Principal. The daemon installs
// Resolver().Middleware() on its router.
func (s *Service) Resolver() *authn.Resolver { return s.resolver }