Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · forge/instance.go

Line attribution on main · 419 line(s)

c5902451
Keenan Nemetz
16d ago
1 // Package forge opens and owns the state of a Vole instance.
2 //
3 // An instance is one directory of durable state: the user roster and access
4 // rules (data/users.db), the derived index, the CI queue, the object store, and
5 // the bare repositories. This package is the one place that knows how those fit
6 // together, so the server, the CLI's administrative commands and an embedding
7 // application all open the same set in the same order.
8 package forge
9
10 import (
11 "errors"
12 "fmt"
13 "os"
14 "path/filepath"
15 "strings"
16
d37d301f
Keenan Nemetz
18h ago
17 "rodent.software/vole/ci/logs"
18 "rodent.software/vole/ci/runs"
19 "rodent.software/vole/core/lfs"
20 "rodent.software/vole/forge/access"
21 "rodent.software/vole/forge/config"
22 "rodent.software/vole/forge/identity"
23 "rodent.software/vole/forge/index"
24 "rodent.software/vole/forge/instancerepo"
25 "rodent.software/vole/forge/pageview"
26 "rodent.software/vole/forge/repo"
27 "rodent.software/vole/forge/secret"
28 "rodent.software/vole/forge/session"
29 "rodent.software/vole/internal/audit"
30 "rodent.software/vole/internal/secure"
c5902451
Keenan Nemetz
16d ago
31 )
32
33 // Instance is the state a running or administered Vole instance owns.
34 type Instance struct {
4506a4d3
Keenan Nemetz
14d ago
35 Cfg *config.Config
36 Repos *repo.Store
37 Identity *identity.Store
38 Sessions *session.Store
39 Index *index.Store
40 Runs *runs.Store
41 ACLs *access.Store
42 Locks *lfs.LockStore
43 LFS *lfs.Store
5ece70a5
Keenan Nemetz
10d ago
44 Logs *logs.Store
4506a4d3
Keenan Nemetz
14d ago
45 PageViews *pageview.Store
6ce635a8
Keenan Nemetz
1d ago
46 // InstanceRepo is the repository whose tree customizes the instance: the
47 // profile README, custom CSS, favicon, and assets. Nil or disabled means the
48 // instance keeps its built-in presentation.
49 InstanceRepo *instancerepo.Store
0c40a1d3
Keenan Nemetz
7d ago
50 // Secrets holds each repository's sealed values. It is a separate database
51 // from the roster because it is the one whose contents are encrypted, and it is
52 // opened last because nothing else depends on it.
53 Secrets *secret.Store
c5902451
Keenan Nemetz
16d ago
54
55 // ImportedACLs is how many legacy refs/vole/acl rule sets were imported by
56 // Open, so a caller that logs startup can say so.
57 ImportedACLs int
58 }
59
60 // Open opens every store an instance needs, in the order their dependencies
61 // require, and releases what it opened if one of them fails.
62 func Open(cfg *config.Config, binPath string) (*Instance, error) {
ed181212
Keenan Nemetz
13d ago
63 // Directories that hold secrets are created at 0700. The repositories tree is
64 // content, not secrets, but it lives inside the data directory and there is no
65 // reason for it to be wider than the directory that contains it.
5ece70a5
Keenan Nemetz
10d ago
66 for _, dir := range []string{cfg.DataDir, cfg.ReposDir(), cfg.SpoolDir(), cfg.LFSDir(), cfg.LogsDir()} {
ed181212
Keenan Nemetz
13d ago
67 if err := secure.Mkdir(dir); err != nil {
68 return nil, fmt.Errorf("data directory %s: %w", dir, err)
69 }
c5902451
Keenan Nemetz
16d ago
70 }
b1499c72
Keenan Nemetz
13d ago
71 // An instance that cannot record privileged actions must not start. Checking
72 // here, before a single store or listener is open, turns "the audit log has
73 // been silently broken since the last upgrade" into a startup failure with a
74 // path in it. The write path itself still refuses individual changes; this is
75 // the difference between one failed command and an instance that cannot serve
76 // its promise at all.
77 if err := audit.Check(cfg.AuditLog()); err != nil {
78 return nil, err
79 }
c5902451
Keenan Nemetz
16d ago
80 ident, err := identity.Open(cfg.UsersDB())
81 if err != nil {
3abbe749
Keenan Nemetz
16d ago
82 return nil, fmt.Errorf("%w\nrun `vole admin init` first", err)
c5902451
Keenan Nemetz
16d ago
83 }
84 ident.AuditPath = cfg.AuditLog()
85
6c84d387
Keenan Nemetz
16d ago
86 sessions, err := session.Open(cfg.UsersDB())
87 if err != nil {
88 ident.Close()
89 return nil, fmt.Errorf("open sessions: %w", err)
90 }
91 sessions.AuditPath = cfg.AuditLog()
92
c5902451
Keenan Nemetz
16d ago
93 ix, err := index.Open(cfg.IndexDB())
94 if err != nil {
6c84d387
Keenan Nemetz
16d ago
95 sessions.Close()
c5902451
Keenan Nemetz
16d ago
96 ident.Close()
97 return nil, fmt.Errorf("open index: %w", err)
98 }
99 rn, err := runs.Open(cfg.RunsDB())
100 if err != nil {
101 ix.Close()
6c84d387
Keenan Nemetz
16d ago
102 sessions.Close()
c5902451
Keenan Nemetz
16d ago
103 ident.Close()
104 return nil, fmt.Errorf("open run queue: %w", err)
105 }
106 acls, err := access.Open(cfg.UsersDB())
107 if err != nil {
108 rn.Close()
109 ix.Close()
6c84d387
Keenan Nemetz
16d ago
110 sessions.Close()
c5902451
Keenan Nemetz
16d ago
111 ident.Close()
112 return nil, fmt.Errorf("open access rules: %w", err)
113 }
114 acls.AuditPath = cfg.AuditLog()
5b814e99
Keenan Nemetz
14d ago
115 // Instance roles are account standing, owned by the roster. Wiring the
116 // lookup here means every ACL read from this instance applies them.
117 acls.InstanceRole = ident.InstanceRole
62b59a76
Keenan Nemetz
13d ago
118 // Retirement has to reach the rules too: an entry or a runners-list membership
119 // outlives the account that holds it, and a retired approver or runner must not
120 // keep satisfying a gate.
121 acls.Active = ident.Active
c5902451
Keenan Nemetz
16d ago
122
123 lk, err := lfs.OpenLocks(cfg.UsersDB())
124 if err != nil {
125 acls.Close()
126 rn.Close()
127 ix.Close()
6c84d387
Keenan Nemetz
16d ago
128 sessions.Close()
c5902451
Keenan Nemetz
16d ago
129 ident.Close()
130 return nil, fmt.Errorf("open locks: %w", err)
131 }
132 lk.AuditPath = cfg.AuditLog()
133
134 rs := repo.New(cfg.ReposDir(), binPath, acls)
135 rs.MaxInputBytes = cfg.ReceiveMaxBytes
136 imported, err := rs.ImportACLs()
137 if err != nil {
138 lk.Close()
139 acls.Close()
140 rn.Close()
141 ix.Close()
6c84d387
Keenan Nemetz
16d ago
142 sessions.Close()
c5902451
Keenan Nemetz
16d ago
143 ident.Close()
144 return nil, err
145 }
4506a4d3
Keenan Nemetz
14d ago
146 views, err := pageview.Open(cfg.UsersDB())
147 if err != nil {
148 lk.Close()
149 acls.Close()
150 rn.Close()
151 ix.Close()
152 sessions.Close()
153 ident.Close()
154 return nil, fmt.Errorf("open page views: %w", err)
155 }
c5902451
Keenan Nemetz
16d ago
156
0c40a1d3
Keenan Nemetz
7d ago
157 // Secrets are opened last because nothing else depends on them, and they are
158 // the one store whose failure an operator can act on: a key file that does not
159 // match the database is a restore that cannot read what it restored.
160 secrets, err := secret.Open(cfg.SecretsDB(), cfg.SecretsKeyPath())
161 if err != nil {
162 views.Close()
163 lk.Close()
164 acls.Close()
165 rn.Close()
166 ix.Close()
167 sessions.Close()
168 ident.Close()
169 return nil, fmt.Errorf("open secrets: %w", err)
170 }
171 secrets.AuditPath = cfg.AuditLog()
172 // The repository store deletes a repository's secrets with the repository, so
173 // it needs the store it deletes from. Wiring it here is what keeps a deleted
174 // repository from leaving values behind for a name that is created again.
175 rs.Secrets = secrets
176
c5902451
Keenan Nemetz
16d ago
177 return &Instance{
178 Cfg: cfg,
179 Repos: rs,
180 Identity: ident,
6c84d387
Keenan Nemetz
16d ago
181 Sessions: sessions,
c5902451
Keenan Nemetz
16d ago
182 Index: ix,
183 Runs: rn,
184 ACLs: acls,
185 Locks: lk,
186 LFS: lfs.New(cfg.LFSDir(), cfg.LFSObjectMaxBytes),
5ece70a5
Keenan Nemetz
10d ago
187 Logs: logs.New(cfg.LogsDir()),
4506a4d3
Keenan Nemetz
14d ago
188 PageViews: views,
6ce635a8
Keenan Nemetz
1d ago
189 InstanceRepo: instancerepo.New(rs, cfg.InstanceRepo),
0c40a1d3
Keenan Nemetz
7d ago
190 Secrets: secrets,
c5902451
Keenan Nemetz
16d ago
191 ImportedACLs: imported,
192 }, nil
193 }
194
ed181212
Keenan Nemetz
13d ago
195 // TightenSecrets brings the modes of everything that holds a credential to what
0c40a1d3
Keenan Nemetz
7d ago
196 // they must be: the account database and its write-ahead files, the sealed secret
197 // store and its key, the SSH host key, the ACME account directory and the audit
198 // log. It runs after the stores are open
ed181212
Keenan Nemetz
13d ago
199 // because opening a store may create its file, and it reports what it changed so
200 // the caller can say so — an upgraded instance silently changing modes is better
201 // than one that leaves a private key readable, but it should not be silent.
202 //
203 // A path that cannot be tightened is an error: the instance is about to serve
204 // credentials it cannot protect.
205 func TightenSecrets(cfg *config.Config) ([]string, error) {
206 paths := []string{
207 cfg.DataDir,
208 cfg.ReposDir(),
209 cfg.SpoolDir(),
210 cfg.LFSDir(),
5ece70a5
Keenan Nemetz
10d ago
211 cfg.LogsDir(),
ed181212
Keenan Nemetz
13d ago
212 cfg.UsersDB(), cfg.UsersDB() + "-wal", cfg.UsersDB() + "-shm",
213 cfg.IndexDB(), cfg.IndexDB() + "-wal", cfg.IndexDB() + "-shm",
214 cfg.RunsDB(), cfg.RunsDB() + "-wal", cfg.RunsDB() + "-shm",
0c40a1d3
Keenan Nemetz
7d ago
215 cfg.SecretsDB(), cfg.SecretsDB() + "-wal", cfg.SecretsDB() + "-shm",
216 cfg.SecretsKeyPath(),
ed181212
Keenan Nemetz
13d ago
217 cfg.AuditLog(),
218 cfg.SSHHostKey(),
219 cfg.ACMECachePath(),
220 }
221 results, err := secure.Harden(paths...)
222 var changed []string
223 for _, r := range results {
224 changed = append(changed, r.Path)
225 }
226 if err != nil {
227 return changed, err
228 }
229 return changed, nil
230 }
231
c5902451
Keenan Nemetz
16d ago
232 // OpenPath loads the configuration at cfgPath and opens the instance it names.
233 func OpenPath(cfgPath string) (*Instance, error) {
c36d5073
Keenan Nemetz
16d ago
234 cfg, err := config.Load(config.ResolvePath(cfgPath))
c5902451
Keenan Nemetz
16d ago
235 if err != nil {
236 return nil, err
237 }
238 bin, err := os.Executable()
239 if err != nil {
240 return nil, err
241 }
242 return Open(cfg, bin)
243 }
244
245 // Close releases every store, reporting the first error but closing the rest.
246 func (i *Instance) Close() error {
247 if i == nil {
248 return nil
249 }
250 var err error
251 for _, closeFn := range []func() error{
0c40a1d3
Keenan Nemetz
7d ago
252 i.Secrets.Close,
4506a4d3
Keenan Nemetz
14d ago
253 i.PageViews.Close,
c5902451
Keenan Nemetz
16d ago
254 i.Locks.Close,
255 i.ACLs.Close,
256 i.Runs.Close,
257 i.Index.Close,
6c84d387
Keenan Nemetz
16d ago
258 i.Sessions.Close,
c5902451
Keenan Nemetz
16d ago
259 i.Identity.Close,
260 } {
261 if closeFn == nil {
262 continue
263 }
264 if cerr := closeFn(); cerr != nil && err == nil {
265 err = cerr
266 }
267 }
268 return err
269 }
270
271 // Init creates the three things an instance lives in — the config file, the
272 // repository directory and the roster database — and leaves existing state
273 // alone. It returns the absolute instance directory.
274 func Init(dir string) (string, error) {
275 if dir == "" {
276 dir = "."
277 }
278 abs, err := filepath.Abs(dir)
279 if err != nil {
280 return "", err
281 }
282 if err := os.MkdirAll(abs, 0o755); err != nil {
283 return "", err
284 }
3b39a84c
Keenan Nemetz
14d ago
285 path, err := InitAt(filepath.Join(abs, "vole.toml"))
286 if err != nil {
287 return "", err
288 }
289 return filepath.Dir(path), nil
290 }
291
292 // InitAt prepares the instance a config file describes and returns that file's
293 // absolute path. A missing config is created beside its own data directory, so
294 // `init --dir` keeps its one-directory shape. An existing config is used as
295 // written, which is how a package ships /etc/vole/vole.toml and still has init
296 // create the repositories and roster database it names.
297 func InitAt(cfgPath string) (string, error) {
298 abs, err := filepath.Abs(cfgPath)
299 if err != nil {
300 return "", err
301 }
302 // An absolute data_dir lets `serve` run from any working directory.
303 if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
304 return "", err
305 }
306 if _, err := os.Stat(abs); errors.Is(err, os.ErrNotExist) {
c5902451
Keenan Nemetz
16d ago
307 body := strings.Replace(config.Sample(),
3b39a84c
Keenan Nemetz
14d ago
308 `data_dir = "./data"`, fmt.Sprintf("data_dir = %q", filepath.Join(filepath.Dir(abs), "data")), 1)
309 if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
c5902451
Keenan Nemetz
16d ago
310 return "", err
311 }
312 }
3b39a84c
Keenan Nemetz
14d ago
313 cfg, err := config.Load(abs)
c5902451
Keenan Nemetz
16d ago
314 if err != nil {
315 return "", err
316 }
ed181212
Keenan Nemetz
13d ago
317 if err := secure.Mkdir(cfg.DataDir); err != nil {
318 return "", err
319 }
320 if err := secure.Mkdir(cfg.ReposDir()); err != nil {
c5902451
Keenan Nemetz
16d ago
321 return "", err
322 }
323 if err := identity.Init(cfg.UsersDB()); err != nil {
324 return "", err
325 }
ed181212
Keenan Nemetz
13d ago
326 // A roster created by an older install keeps whatever mode it was created
327 // with; init is also an upgrade path, so tighten it here too.
328 if _, err := secure.HardenFileIfExists(cfg.UsersDB()); err != nil {
329 return "", err
330 }
3e7c5e67
Keenan Nemetz
1d ago
331 if err := seedInstanceRepo(cfg); err != nil {
332 return "", err
333 }
c5902451
Keenan Nemetz
16d ago
334 return abs, nil
335 }
336
3e7c5e67
Keenan Nemetz
1d ago
337 // seedInstanceRepo creates the configured instance repository during init and
338 // seeds its starter tree. It is idempotent: a repository that already has a
339 // commit is left exactly as it is, so `vole admin init` stays an upgrade path.
340 func seedInstanceRepo(cfg *config.Config) error {
341 name := strings.TrimSpace(cfg.InstanceRepo)
342 if name == "" {
343 return nil
344 }
345 acls, err := access.Open(cfg.UsersDB())
346 if err != nil {
347 return fmt.Errorf("open access rules: %w", err)
348 }
349 defer acls.Close()
350 acls.AuditPath = cfg.AuditLog()
351 bin, err := os.Executable()
352 if err != nil {
353 bin = "vole"
354 }
355 repos := repo.New(cfg.ReposDir(), bin, acls)
a407c4af
Keenan Nemetz
1d ago
356 m, created, err := instancerepo.Ensure(repos, name, "", "vole")
3e7c5e67
Keenan Nemetz
1d ago
357 if err != nil {
358 return fmt.Errorf("instance repository %s: %w", name, err)
359 }
360 if !created {
361 return nil
362 }
363 ix, err := index.Open(cfg.IndexDB())
364 if err != nil {
365 return fmt.Errorf("open index: %w", err)
366 }
367 defer ix.Close()
368 if err := ix.Upsert(m); err != nil {
369 return fmt.Errorf("index the instance repository: %w", err)
370 }
371 return nil
372 }
373
c5902451
Keenan Nemetz
16d ago
374 // RenameRepo moves a repository and everything keyed by its old name: the
375 // derived index rows, the CI queue's rows and cursors, and the object store's
376 // per-repository namespace.
377 func (i *Instance) RenameRepo(old, next string) error {
378 m, err := i.Repos.Open(old)
379 if err != nil {
380 return err
381 }
382 if err := i.Repos.Rename(old, next); err != nil {
383 return err
384 }
385 _ = i.Index.Remove(m.ID)
386 if renamed, err := i.Repos.Open(next); err == nil {
387 _ = i.Index.Upsert(renamed)
388 }
389 if err := i.Runs.RenameRepo(old, next); err != nil {
390 return err
391 }
392 if err := i.LFS.RenameRepo(old, next); err != nil {
393 return fmt.Errorf("renamed in the database, but the object namespace did not move: %w", err)
394 }
395 return nil
396 }
397
398 // DeleteRepo removes a repository and the state that would otherwise point at
399 // something nobody can clone: index rows, queue rows and cursors, and locks.
b1499c72
Keenan Nemetz
13d ago
400 // The actor is recorded with the deletion.
401 func (i *Instance) DeleteRepo(name, actor string) error {
c5902451
Keenan Nemetz
16d ago
402 e, _ := i.Index.Get(name)
b1499c72
Keenan Nemetz
13d ago
403 if err := i.Repos.Delete(name, actor); err != nil {
c5902451
Keenan Nemetz
16d ago
404 return err
405 }
406 if e != nil {
407 _ = i.Index.Remove(e.ID)
408 }
409 _ = i.Runs.ForgetRepo(name)
410 if e != nil {
411 _, _ = i.Locks.ForgetRepo(e.ID)
412 }
413 return nil
414 }
415
59badc63
Keenan Nemetz
11d ago
416 // RebuildIndex reconstructs the derived repository and issue rows.
417 func (i *Instance) RebuildIndex() (repos int, err error) {
418 return i.Index.Rebuild(i.Repos)
c5902451
Keenan Nemetz
16d ago
419 }