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.
Expand the admin status page into an operational dashboard
4506a4d3d279556435fdd414265e7499c3dada16 · Keenan Nemetz · 14d ago · parent 2e024cc9
internal/storage/storage.go +159
| @@ -0,0 +1,159 @@ | |||
| 1 | // Package storage measures the instance's data directory and the filesystem it | ||
| 2 | // lives on. It is deliberately read-only: the status page reports what is there, | ||
| 3 | // and the sweep decisions stay in forge.GC. | ||
| 4 | package storage | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "errors" | ||
| 8 | "io/fs" | ||
| 9 | "path/filepath" | ||
| 10 | "strings" | ||
| 11 | "syscall" | ||
| 12 | "time" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // Category is one slice of the data directory's disk use. Bytes counts allocated | ||
| 16 | // blocks, not apparent file size, so a sparse file does not claim space it does | ||
| 17 | // not use. A hard link is charged once, to the first name the walk sees. | ||
| 18 | type Category struct { | ||
| 19 | Name string | ||
| 20 | Bytes int64 | ||
| 21 | Files int | ||
| 22 | } | ||
| 23 | |||
| 24 | // Disk is the filesystem behind the data directory. Available is what an | ||
| 25 | // unprivileged process can still write, which is the number an operator cares | ||
| 26 | // about; Used is the whole filesystem's usage, not just Vole's. | ||
| 27 | type Disk struct { | ||
| 28 | Total int64 | ||
| 29 | Used int64 | ||
| 30 | Available int64 | ||
| 31 | } | ||
| 32 | |||
| 33 | // Stats is one snapshot. DiskErr is set when the operating system cannot report | ||
| 34 | // filesystem capacity; the directory walk can still succeed on its own. | ||
| 35 | type Stats struct { | ||
| 36 | DataDir string | ||
| 37 | Bytes int64 | ||
| 38 | Files int | ||
| 39 | Categories []Category | ||
| 40 | Disk Disk | ||
| 41 | DiskErr string | ||
| 42 | Measured time.Time | ||
| 43 | } | ||
| 44 | |||
| 45 | // Measure walks root, deduplicates hard links, and asks the filesystem for its | ||
| 46 | // free space. | ||
| 47 | func Measure(root string) (Stats, error) { | ||
| 48 | st := Stats{DataDir: root, Measured: time.Now().UTC()} | ||
| 49 | if disk, err := diskUsage(root); err != nil { | ||
| 50 | st.DiskErr = err.Error() | ||
| 51 | } else { | ||
| 52 | st.Disk = disk | ||
| 53 | } | ||
| 54 | |||
| 55 | // Every category is allocated a slot up front so the template can render a | ||
| 56 | // stable order even when an instance has never used one of them. | ||
| 57 | order := []string{"Repositories", "LFS objects", "Databases", "Queue", "ACME", "Other"} | ||
| 58 | cats := make(map[string]*Category, len(order)) | ||
| 59 | for _, name := range order { | ||
| 60 | cats[name] = &Category{Name: name} | ||
| 61 | } | ||
| 62 | seen := map[fileKey]bool{} | ||
| 63 | err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { | ||
| 64 | if err != nil { | ||
| 65 | if errors.Is(err, fs.ErrNotExist) { | ||
| 66 | return nil | ||
| 67 | } | ||
| 68 | return err | ||
| 69 | } | ||
| 70 | if d.IsDir() || !d.Type().IsRegular() { | ||
| 71 | return nil | ||
| 72 | } | ||
| 73 | info, err := d.Info() | ||
| 74 | if err != nil { | ||
| 75 | if errors.Is(err, fs.ErrNotExist) { | ||
| 76 | return nil | ||
| 77 | } | ||
| 78 | return err | ||
| 79 | } | ||
| 80 | key, linked := fileKeyFor(info) | ||
| 81 | if linked { | ||
| 82 | if seen[key] { | ||
| 83 | return nil | ||
| 84 | } | ||
| 85 | seen[key] = true | ||
| 86 | } | ||
| 87 | n := info.Size() | ||
| 88 | if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Blocks > 0 { | ||
| 89 | // Blocks is in 512-byte units, regardless of the filesystem's block | ||
| 90 | // size. It is the allocation the file actually costs. | ||
| 91 | n = st.Blocks * 512 | ||
| 92 | } | ||
| 93 | name := category(path, root) | ||
| 94 | cats[name].Bytes += n | ||
| 95 | cats[name].Files++ | ||
| 96 | st.Bytes += n | ||
| 97 | st.Files++ | ||
| 98 | return nil | ||
| 99 | }) | ||
| 100 | if err != nil { | ||
| 101 | return st, err | ||
| 102 | } | ||
| 103 | for _, name := range order { | ||
| 104 | st.Categories = append(st.Categories, *cats[name]) | ||
| 105 | } | ||
| 106 | return st, nil | ||
| 107 | } | ||
| 108 | |||
| 109 | type fileKey struct { | ||
| 110 | dev uint64 | ||
| 111 | ino uint64 | ||
| 112 | } | ||
| 113 | |||
| 114 | func fileKeyFor(info fs.FileInfo) (fileKey, bool) { | ||
| 115 | st, ok := info.Sys().(*syscall.Stat_t) | ||
| 116 | if !ok { | ||
| 117 | return fileKey{}, false | ||
| 118 | } | ||
| 119 | return fileKey{dev: uint64(st.Dev), ino: st.Ino}, true | ||
| 120 | } | ||
| 121 | |||
| 122 | func category(path, root string) string { | ||
| 123 | rel, err := filepath.Rel(root, path) | ||
| 124 | if err != nil { | ||
| 125 | return "Other" | ||
| 126 | } | ||
| 127 | rel = filepath.ToSlash(rel) | ||
| 128 | top := rel | ||
| 129 | if i := strings.IndexByte(rel, '/'); i >= 0 { | ||
| 130 | top = rel[:i] | ||
| 131 | } | ||
| 132 | switch top { | ||
| 133 | case "repos": | ||
| 134 | return "Repositories" | ||
| 135 | case "lfs": | ||
| 136 | return "LFS objects" | ||
| 137 | case "queue": | ||
| 138 | return "Queue" | ||
| 139 | case "acme": | ||
| 140 | return "ACME" | ||
| 141 | } | ||
| 142 | // SQLite keeps its main file and WAL/SHM siblings at the top of data_dir. | ||
| 143 | if strings.HasPrefix(top, "users.db") || strings.HasPrefix(top, "index.db") || strings.HasPrefix(top, "runs.db") || | ||
| 144 | strings.HasSuffix(top, ".db") || strings.HasSuffix(top, ".db-wal") || strings.HasSuffix(top, ".db-shm") { | ||
| 145 | return "Databases" | ||
| 146 | } | ||
| 147 | return "Other" | ||
| 148 | } | ||
| 149 | |||
| 150 | func diskUsage(root string) (Disk, error) { | ||
| 151 | var st syscall.Statfs_t | ||
| 152 | if err := syscall.Statfs(root, &st); err != nil { | ||
| 153 | return Disk{}, err | ||
| 154 | } | ||
| 155 | total := int64(st.Blocks) * st.Bsize | ||
| 156 | free := int64(st.Bfree) * st.Bsize | ||
| 157 | available := int64(st.Bavail) * st.Bsize | ||
| 158 | return Disk{Total: total, Used: total - free, Available: available}, nil | ||
| 159 | } | ||