| 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 } |