diff --git a/docs/update-device.md b/docs/update-device.md index cbca0ec..ac007b9 100644 --- a/docs/update-device.md +++ b/docs/update-device.md @@ -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. diff --git a/go.mod b/go.mod index f26b88b..bdba2fb 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index c2aed5b..2eb9e8e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/config/check_test.go b/pkg/config/check_test.go index 641ef5c..a4b887a 100644 --- a/pkg/config/check_test.go +++ b/pkg/config/check_test.go @@ -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) @@ -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) + } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 835bd38..29b1002 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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" @@ -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. @@ -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" @@ -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) != "" { @@ -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) } @@ -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 diff --git a/pkg/state/state.go b/pkg/state/state.go index 55a9ea6..4a0e444 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -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 }