Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/update-device.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,40 @@ default `/var/sota/sota.toml`) to `"1"`:
[pacman]
prune_unused_images = "1"
```

### Configure Storage Usage

`fioup` checks that enough storage is available before fetching and installing
an update, and refuses to proceed otherwise. Two options under the `[pacman]`
section of the `fioup` configuration file (by default `/var/sota/sota.toml`)
control how much of the device's storage may be consumed by apps.

#### `storage_watermark`

A percentage of the total storage that apps may use. The value must be an
integer between `20` and `99`; when unset, the default is `95`.

```toml
[pacman]
storage_watermark = "80"
```

With the example above, an update is allowed only while the total storage
usage including the apps stays below 80% of the underlying filesystem.

#### `reserved_storage`

An absolute amount of free space to keep reserved for non-app usage, expressed
as a byte size with either a binary (`KiB`, `MiB`, `GiB`) or decimal (`KB`,
`MB`, `GB`) suffix. When set, apps may use all available storage except the
reserved amount.

```toml
[pacman]
reserved_storage = "2GiB"
```

`reserved_storage` takes precedence over `storage_watermark`: when both are
set, the percentage watermark is ignored and a warning is logged. Use
`reserved_storage` on devices with large data partitions where a fixed
absolute reserve is more meaningful than a percentage.
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ replace github.com/theupdateframework/go-tuf/v2 v2.0.2 => github.com/foundriesio
require (
github.com/containerd/containerd v1.7.15
github.com/docker/distribution v2.8.2+incompatible
github.com/docker/docker v25.0.3+incompatible
github.com/foundriesio/composeapp v0.0.0-20260611084625-12503bb40593
github.com/docker/go-units v0.5.0
github.com/foundriesio/composeapp v0.0.0-20260619212818-3b952986a27b
github.com/foundriesio/fioconfig v0.0.0-20260223150833-638424812372
github.com/foundriesio/fiotuf v0.0.0-20250811143610-819b20a26cb8
github.com/google/uuid v1.6.0
Expand Down Expand Up @@ -37,10 +37,10 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v25.0.3+incompatible // indirect
github.com/docker/docker v25.0.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.7.0 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/foundriesio/go-ecies v0.3.0 // indirect
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foundriesio/composeapp v0.0.0-20260427073250-05c7cebb3b64 h1:owtj6L9LyBuwzhmWagH+QSA+6aNsPhUe0bLxZuUsrlM=
github.com/foundriesio/composeapp v0.0.0-20260427073250-05c7cebb3b64/go.mod h1:l5brruAI/C8bxx0Co0WDFHnXUeyQXIvpzFITVeKO5S0=
github.com/foundriesio/composeapp v0.0.0-20260611084625-12503bb40593 h1:FFS587tLCvUTLljyiN3UAQ/D2hzQhq5QErkjmPfVzrY=
github.com/foundriesio/composeapp v0.0.0-20260611084625-12503bb40593/go.mod h1:VZn+etf0oK1t3aqE3CVo/mgExQHPDk6ID/+AeDlv3rc=
github.com/foundriesio/composeapp v0.0.0-20260619212818-3b952986a27b h1:s/jFsf75wMRvAiB34gjJmO4Sf9XG1cEppDv/X1llSlE=
github.com/foundriesio/composeapp v0.0.0-20260619212818-3b952986a27b/go.mod h1:VZn+etf0oK1t3aqE3CVo/mgExQHPDk6ID/+AeDlv3rc=
github.com/foundriesio/fioconfig v0.0.0-20260223150833-638424812372 h1:RLtO6fYyoxVdV84Ajdo0kYC7qqH3sXXrrF+jy/6ZW7U=
github.com/foundriesio/fioconfig v0.0.0-20260223150833-638424812372/go.mod h1:3cLFsAyngsCwvXgtt+JgU170oKmJQIvoktvT7gQALE8=
github.com/foundriesio/fiotuf v0.0.0-20250811143610-819b20a26cb8 h1:nL7q+DCc1M925rKSCvZQEwyKxHanjsMBF1kjyRZ9v0U=
Expand Down
65 changes: 52 additions & 13 deletions pkg/config/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ func TestConfig_StorageWatermark(t *testing.T) {
tree.Set("pacman.compose_apps_root", tomlConfigPath)
tree.Set("storage.path", tomlConfigPath)

checkStorageWatermark := func(value string, expected uint) {
if len(value) > 0 {
tree.Set(StorageUsageWatermark, value)
loadConfig := func(watermark, reserved string) *Config {
setOrDelete := func(key, value string) {
if len(value) > 0 {
tree.Set(key, value)
} else {
_ = tree.Delete(key)
}
}
setOrDelete(StorageUsageWatermark, watermark)
setOrDelete(ReservedStorageKey, reserved)
if b, err := toml.Marshal(tree); err == nil {
if err := os.WriteFile(tomlConfigPath+"/sota.toml", b, 0644); err != nil {
t.Fatalf("failed to write temp config file: %v", err)
Expand All @@ -37,17 +43,50 @@ func TestConfig_StorageWatermark(t *testing.T) {
}
cfg, err := NewConfig([]string{tomlConfigPath})
checkErr(t, err)
if cfg.GetStorageUsageWatermark() != expected {
t.Fatalf("expected watermark %d, got %d", expected, cfg.GetStorageUsageWatermark())
return cfg
}
checkPercent := func(watermark string, expected uint64) {
cfg := loadConfig(watermark, "")
if reserved := cfg.GetReservedStorage(); reserved != 0 {
t.Fatalf("expected no reserved storage for watermark %q, got %d", watermark, reserved)
}
if value := cfg.GetStorageWatermark(); value != expected {
t.Fatalf("expected watermark %d, got %d", expected, value)
}
}
checkReserved := func(watermark, reserved string, expected uint64) {
cfg := loadConfig(watermark, reserved)
value := cfg.GetReservedStorage()
if value == 0 {
t.Fatalf("expected reserved storage to be set for reserved %q", reserved)
}
if value != expected {
t.Fatalf("expected reserved bytes %d, got %d", expected, value)
}
}
// No value set, should get default
checkStorageWatermark("", StorageUsageWatermarkDefault)
// Valid value
checkStorageWatermark("90", 90)
// Values out of the allowed range
checkStorageWatermark(strconv.Itoa(MinStorageUsageWatermark-1), StorageUsageWatermarkDefault)
checkStorageWatermark(strconv.Itoa(MaxStorageUsageWatermark+1), StorageUsageWatermarkDefault)
// Invalid value
checkStorageWatermark("80abc", StorageUsageWatermarkDefault)
checkPercent("", StorageUsageWatermarkDefault)
// Valid percentage
checkPercent("90", 90)
// Percentages out of the allowed range fall back to the default
checkPercent(strconv.Itoa(MinStorageUsageWatermark-1), StorageUsageWatermarkDefault)
checkPercent(strconv.Itoa(MaxStorageUsageWatermark+1), StorageUsageWatermarkDefault)
// Invalid percentage falls back to the default
checkPercent("80abc", StorageUsageWatermarkDefault)
// reserved_storage sets an absolute reserved free space in bytes
checkReserved("", "2GiB", 2*1024*1024*1024)
checkReserved("", "500MiB", 500*1024*1024)
// decimal byte suffixes are accepted in addition to the binary ones
checkReserved("", "2GB", 2*1000*1000*1000)
checkReserved("", "500MB", 500*1000*1000)
// reserved_storage takes precedence over storage_watermark when both are set
checkReserved("80", "2GiB", 2*1024*1024*1024)
// Invalid reserved_storage is ignored, falling back to the percentage watermark
cfg := loadConfig("90", "2XB")
if reserved := cfg.GetReservedStorage(); reserved != 0 {
t.Fatalf("expected no reserved storage when reserved_storage is invalid, got %d", reserved)
}
if value := cfg.GetStorageWatermark(); value != 90 {
t.Fatalf("expected percentage watermark 90 when reserved_storage is invalid, got %d", value)
}
}
51 changes: 45 additions & 6 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strconv"
"strings"

"github.com/docker/go-units"
"github.com/foundriesio/composeapp/pkg/compose"
v1 "github.com/foundriesio/composeapp/pkg/compose/v1"
"github.com/foundriesio/fioconfig/sotatoml"
Expand All @@ -27,7 +28,8 @@ type (
tomlConfig *sotatoml.AppConfig
composeConfig *compose.Config
dgBaseURL *url.URL
storageWatermark uint
storageWatermark uint64
reservedStorage uint64
proxyProvider *ProxyProvider
}
// HttpClientFunc defines a function type for making HTTP requests via a proxy.
Expand All @@ -46,7 +48,8 @@ const (
StorageDirKey = "storage.path"
StorageDBPathKey = "storage.sqldb_path"
HardwareIDKey = "provision.primary_ecu_hardware_id"
StorageUsageWatermark = "pacman.storage_watermark" // in percentage of overall storage, the maximum allowed to be used by apps
StorageUsageWatermark = "pacman.storage_watermark" // percentage (20-99) of overall storage usable by apps
ReservedStorageKey = "pacman.reserved_storage" // absolute free space to keep reserved (e.g. "2GiB" or "500MB"); takes precedence over StorageUsageWatermark
ComposeAppsProxyKey = "pacman.compose_apps_proxy"
ComposeAppsProxyCaKey = "import.tls_cacert_path"
ComposeAppsPruneUnusedImagesKey = "pacman.prune_unused_images"
Expand Down Expand Up @@ -105,19 +108,36 @@ func NewConfig(tomlConfigPaths []string) (*Config, error) {
if err != nil {
return nil, fmt.Errorf("invalid value of the device gateway base URL: %w", err)
}
// Validate and set storage usage watermark
// Validate and set the storage usage watermark. pacman.storage_watermark is a
// percentage of overall storage usable by apps; pacman.reserved_storage is an
// absolute amount of free space to keep reserved (e.g. "2GiB") and, when set,
// takes precedence over the percentage watermark.
cfg.storageWatermark = StorageUsageWatermarkDefault
watermarkStr := cfg.tomlConfig.GetDefault(StorageUsageWatermark, StorageUsageWatermarkDefaultStr)
if watermark, err := strconv.Atoi(watermarkStr); err == nil {
if watermark < MinStorageUsageWatermark || watermark > MaxStorageUsageWatermark {
slog.Warn("storage usage watermark out of range; using default", "value", watermark, "default", StorageUsageWatermarkDefaultStr)
} else {
cfg.storageWatermark = uint(watermark)
cfg.storageWatermark = uint64(watermark)
}
} else {
slog.Warn("invalid storage usage watermark value; using default", "value", watermarkStr, "default", StorageUsageWatermarkDefaultStr)
}
slog.Debug("storage usage watermark set", "value", cfg.storageWatermark)
if reservedStr := cfg.tomlConfig.GetDefault(ReservedStorageKey, ""); reservedStr != "" {
if reserved, err := parseReservedStorage(reservedStr); err == nil && reserved > 0 {
if cfg.tomlConfig.Has(StorageUsageWatermark) {
slog.Warn("both pacman.storage_watermark and pacman.reserved_storage are set; ignoring pacman.storage_watermark",
"reserved_storage", reservedStr)
}
cfg.reservedStorage = uint64(reserved)
slog.Debug("storage reserved free space set", "bytes", cfg.reservedStorage)
} else {
slog.Warn("invalid pacman.reserved_storage value; ignoring", "value", reservedStr)
}
}
if cfg.reservedStorage == 0 {
slog.Debug("storage usage watermark set", "percent", cfg.storageWatermark)
}

var composeProxyProvider compose.ProxyProvider
if cfg.tomlConfig.Has(ComposeAppsProxyKey) && cfg.tomlConfig.Get(ComposeAppsProxyKey) != "" {
Expand All @@ -136,6 +156,16 @@ func NewConfig(tomlConfigPaths []string) (*Config, error) {
return cfg, nil
}

// parseReservedStorage accepts both binary (e.g. "2GiB", "500MiB") and decimal
// (e.g. "2GB", "500MB") byte-size suffixes. The presence of an "ib" suffix
// selects the binary parser; otherwise the decimal parser is used.
func parseReservedStorage(s string) (int64, error) {
if strings.Contains(strings.ToLower(s), "ib") {
return units.RAMInBytes(s)
}
return units.FromHumanSize(s)
}

func (c *Config) GetHardwareID() string {
return c.tomlConfig.Get(HardwareIDKey)
}
Expand Down Expand Up @@ -185,10 +215,19 @@ func (c *Config) GetEnabledApps() []string {
return result
}

func (c *Config) GetStorageUsageWatermark() uint {
// GetStorageWatermark returns the configured storage usage watermark as a
// percentage of total storage usable by apps.
func (c *Config) GetStorageWatermark() uint64 {
return c.storageWatermark
}

// GetReservedStorage returns the absolute amount of free space, in bytes, to
// keep reserved for non-Apps usage. A value of zero means no reservation is
// configured and the percentage watermark from GetStorageWatermark applies.
func (c *Config) GetReservedStorage() uint64 {
return c.reservedStorage
}

func (c *Config) SetClientForProxy(client proxyHTTPClient) {
if c.proxyProvider != nil {
c.proxyProvider.client = client
Expand Down
24 changes: 15 additions & 9 deletions pkg/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,16 +287,22 @@ func (u *UpdateContext) getInstallationCompletedDetails(eventErr error) interfac
}

func (u *UpdateContext) getAndSetStorageUsageInfo() error {
usageWatermark := u.Config.GetStorageUsageWatermark()
if ui, err := compose.GetUsageInfo(u.Config.ComposeConfig().StoreRoot, 0, usageWatermark); err != nil {
return fmt.Errorf("failed to get storage usage info: %w", err)
var watermark uint64
var inBytes bool
if reserved := u.Config.GetReservedStorage(); reserved > 0 {
watermark, inBytes = reserved, true
} else {
u.StorageUsage = &StorageStat{
Size: ui.SizeB,
Free: ui.Free,
Reserved: ui.Reserved,
Available: ui.Available,
}
watermark = u.Config.GetStorageWatermark()
}
ui, err := compose.GetUsageInfo(u.Config.ComposeConfig().StoreRoot, 0, watermark, inBytes)
if err != nil {
return fmt.Errorf("failed to get storage usage info: %w", err)
}
u.StorageUsage = &StorageStat{
Size: ui.SizeB,
Free: ui.Free,
Reserved: ui.Reserved,
Available: ui.Available,
}
return nil
}