vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
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
15h 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
13d 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
13d 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
12d 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
15d 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
15d 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
15d 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
15d 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
12d 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
15d 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
15d ago
|
142 | sessions.Close() |
|
c5902451
Keenan Nemetz
16d ago
|
143 | ident.Close() |
| 144 | return nil, err | |
| 145 | } | |
|
4506a4d3
Keenan Nemetz
13d 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
15d 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
13d 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
15d 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
13d 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
15d 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
22h 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
12d 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
12d 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
10d 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 | } |