You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

214 lines
6.6 KiB

package capacity
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
)
const (
ClassUpdateStaging = "update_staging"
EnforcementV1 = "application-governor-v1"
)
var ErrInsufficientSpace = errors.New("insufficient storage capacity")
type Policy struct {
FormatVersion int `json:"format_version"`
Layout string `json:"layout"`
Mount struct {
Source string `json:"source"`
PartLabel string `json:"partlabel"`
Filesystem string `json:"filesystem"`
RequiredOptions []string `json:"required_options"`
} `json:"mount"`
CapsBytes struct {
UpdateStaging int64 `json:"update_staging"`
UpdatePackageMax int64 `json:"update_package_max"`
PortEvents int64 `json:"port_events"`
AuditLogs int64 `json:"audit_logs"`
SystemEvents int64 `json:"system_events"`
StateAndIndexes int64 `json:"state_and_indexes"`
ExportScratch int64 `json:"export_scratch"`
GlobalFreeFloor int64 `json:"global_free_floor"`
} `json:"caps_bytes"`
Enforcement string `json:"enforcement"`
PersistentUARTPayload bool `json:"persistent_uart_payload"`
}
type Snapshot struct {
Pressure string `json:"pressure"`
FreeBytes int64 `json:"free_bytes"`
GlobalFreeFloor int64 `json:"global_free_floor_bytes"`
UpdateUsedBytes int64 `json:"update_used_bytes"`
UpdateReserved int64 `json:"update_reserved_bytes"`
UpdateCapBytes int64 `json:"update_cap_bytes"`
UpdatePackageMax int64 `json:"update_package_max_bytes"`
}
type freeSpaceFunc func(string) (int64, error)
type usageFunc func(string) (int64, error)
type Governor struct {
mu sync.Mutex
root string
policy Policy
reserved int64
free freeSpaceFunc
usage usageFunc
}
type Reservation struct {
governor *Governor
bytes int64
once sync.Once
}
func LoadPolicy(path string) (Policy, error) {
info, err := os.Lstat(path)
if err != nil {
return Policy{}, fmt.Errorf("read storage policy: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return Policy{}, fmt.Errorf("storage policy must be a regular file")
}
data, err := os.ReadFile(path)
if err != nil {
return Policy{}, fmt.Errorf("read storage policy: %w", err)
}
var policy Policy
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&policy); err != nil {
return Policy{}, fmt.Errorf("decode storage policy: %w", err)
}
if policy.FormatVersion != 1 || policy.Layout != "tera-userdata-v1" || policy.Mount.Source != "/dev/mmcblk0p8" || policy.Mount.PartLabel != "userdata" || policy.Mount.Filesystem != "ext4" || policy.PersistentUARTPayload {
return Policy{}, fmt.Errorf("unsupported storage policy identity")
}
if policy.Enforcement != EnforcementV1 {
return Policy{}, fmt.Errorf("storage capacity enforcement is not enabled")
}
if policy.CapsBytes.UpdateStaging <= 0 || policy.CapsBytes.UpdatePackageMax <= 0 || policy.CapsBytes.PortEvents <= 0 ||
policy.CapsBytes.AuditLogs <= 0 || policy.CapsBytes.SystemEvents <= 0 || policy.CapsBytes.StateAndIndexes <= 0 ||
policy.CapsBytes.ExportScratch <= 0 || policy.CapsBytes.GlobalFreeFloor <= 0 || policy.CapsBytes.UpdatePackageMax > policy.CapsBytes.UpdateStaging {
return Policy{}, fmt.Errorf("invalid storage policy caps")
}
return policy, nil
}
func New(root string, policy Policy) (*Governor, error) {
root = filepath.Clean(root)
info, err := os.Lstat(root)
if err != nil {
return nil, fmt.Errorf("inspect update staging root: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("update staging root must be a directory")
}
if policy.Enforcement != EnforcementV1 {
return nil, fmt.Errorf("storage capacity enforcement is not enabled")
}
return &Governor{root: root, policy: policy, free: filesystemFreeBytes, usage: directoryUsage}, nil
}
func (g *Governor) PackageMaxBytes() int64 { return g.policy.CapsBytes.UpdatePackageMax }
func (g *Governor) Snapshot() (Snapshot, error) {
g.mu.Lock()
defer g.mu.Unlock()
return g.snapshotLocked()
}
func (g *Governor) Reserve(bytes int64) (*Reservation, Snapshot, error) {
if bytes <= 0 {
return nil, Snapshot{}, fmt.Errorf("package size must be greater than zero")
}
g.mu.Lock()
defer g.mu.Unlock()
snapshot, err := g.snapshotLocked()
if err != nil {
return nil, Snapshot{}, err
}
if bytes > snapshot.UpdatePackageMax || snapshot.UpdateUsedBytes+snapshot.UpdateReserved+bytes > snapshot.UpdateCapBytes || snapshot.FreeBytes-snapshot.UpdateReserved-bytes < snapshot.GlobalFreeFloor {
return nil, snapshot, fmt.Errorf("%w: requested=%d free=%d floor=%d used=%d reserved=%d cap=%d", ErrInsufficientSpace, bytes, snapshot.FreeBytes, snapshot.GlobalFreeFloor, snapshot.UpdateUsedBytes, snapshot.UpdateReserved, snapshot.UpdateCapBytes)
}
g.reserved += bytes
snapshot.UpdateReserved = g.reserved
snapshot.Pressure = pressureFor(snapshot.FreeBytes-g.reserved, snapshot.GlobalFreeFloor)
return &Reservation{governor: g, bytes: bytes}, snapshot, nil
}
func (r *Reservation) Release() {
if r == nil || r.governor == nil {
return
}
r.once.Do(func() {
r.governor.mu.Lock()
defer r.governor.mu.Unlock()
r.governor.reserved -= r.bytes
if r.governor.reserved < 0 {
r.governor.reserved = 0
}
})
}
func (g *Governor) snapshotLocked() (Snapshot, error) {
free, err := g.free(g.root)
if err != nil {
return Snapshot{}, fmt.Errorf("measure filesystem free bytes: %w", err)
}
used, err := g.usage(g.root)
if err != nil {
return Snapshot{}, fmt.Errorf("measure update staging usage: %w", err)
}
return Snapshot{
Pressure: pressureFor(free-g.reserved, g.policy.CapsBytes.GlobalFreeFloor),
FreeBytes: free,
GlobalFreeFloor: g.policy.CapsBytes.GlobalFreeFloor,
UpdateUsedBytes: used,
UpdateReserved: g.reserved,
UpdateCapBytes: g.policy.CapsBytes.UpdateStaging,
UpdatePackageMax: g.policy.CapsBytes.UpdatePackageMax,
}, nil
}
func pressureFor(available, floor int64) string {
margin := available - floor
switch {
case margin < 0:
return "emergency"
case margin < floor/4:
return "critical"
case margin < floor:
return "warning"
default:
return "normal"
}
}
func directoryUsage(root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink is not allowed in update staging: %s", path)
}
if info.Mode().IsRegular() {
total += info.Size()
}
return nil
})
return total, err
}