From 36f02f2240c36221bffca3f28677827de57e4bc7 Mon Sep 17 00:00:00 2001 From: Andre Detsch Date: Tue, 16 Jun 2026 15:48:15 -0300 Subject: [PATCH 1/3] feat(compose): support byte-based storage watermark GetUsageInfo only accepted a percentage watermark, so callers wanting to keep a fixed amount of free space on large partitions had to derive and apply the reserved bytes themselves. Add a watermarkInBytes flag so the watermark is interpreted either as a percentage of total size or as an absolute reserved free-space amount, keeping the available-space math in one place. Signed-off-by: Andre Detsch Assisted-by: Claude Code:claude-opus-4-8 --- cmd/composectl/cmd/check.go | 2 +- pkg/compose/statfs.go | 25 ++++++++++++++------- pkg/compose/statfs_test.go | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 pkg/compose/statfs_test.go diff --git a/cmd/composectl/cmd/check.go b/cmd/composectl/cmd/check.go index 76eede94..28219571 100644 --- a/cmd/composectl/cmd/check.go +++ b/cmd/composectl/cmd/check.go @@ -205,7 +205,7 @@ func checkApps(ctx context.Context, checkResult.TotalRuntimeSize += bi.RuntimeSize } ui, err := compose.GetUsageInfo(config.StoreRoot, - checkResult.TotalStoreSize+checkResult.TotalRuntimeSize, usageWatermark) + checkResult.TotalStoreSize+checkResult.TotalRuntimeSize, uint64(usageWatermark), false) if err != nil { return nil, nil, nil, err } diff --git a/pkg/compose/statfs.go b/pkg/compose/statfs.go index 7a179e5f..2f0d6e05 100644 --- a/pkg/compose/statfs.go +++ b/pkg/compose/statfs.go @@ -48,20 +48,29 @@ func (u *UsageInfo) Print() { FormatBytesUint64(u.Reserved), u.ReservedP) } -func GetUsageInfo(path string, required int64, watermark uint) (*UsageInfo, error) { +// GetUsageInfo reports filesystem usage for path against a watermark that bounds +// how much storage apps may consume. The watermark is interpreted as a percentage +// of total size when watermarkInBytes is false, or as an absolute amount of free +// space to keep reserved (in bytes) when watermarkInBytes is true. +func GetUsageInfo(path string, required int64, watermark uint64, watermarkInBytes bool) (*UsageInfo, error) { fsStat, err := GetFsStat(path) if err != nil { return nil, err } ui := UsageInfo{ - Path: path, - SizeB: uint64(fsStat.BlockSize) * fsStat.Blocks, - Free: fsStat.Bfree * uint64(fsStat.BlockSize), - FreeP: (float32(fsStat.Bfree) / float32(fsStat.Blocks)) * 100.0, - ReservedP: float32(100 - watermark), - Required: uint64(required), + Path: path, + SizeB: uint64(fsStat.BlockSize) * fsStat.Blocks, + Free: fsStat.Bfree * uint64(fsStat.BlockSize), + FreeP: (float32(fsStat.Bfree) / float32(fsStat.Blocks)) * 100.0, + Required: uint64(required), + } + if watermarkInBytes { + ui.Reserved = watermark + ui.ReservedP = (float32(ui.Reserved) / float32(ui.SizeB)) * 100.0 + } else { + ui.Reserved = uint64((float64(100-watermark) / 100.0) * float64(ui.SizeB)) + ui.ReservedP = float32(100 - watermark) } - ui.Reserved = uint64((float64(100-watermark) / 100.0) * float64(ui.SizeB)) ui.RequiredP = (float32(ui.Required) / float32(ui.SizeB)) * 100.0 if ui.Free > ui.Reserved { ui.Available = ui.Free - ui.Reserved diff --git a/pkg/compose/statfs_test.go b/pkg/compose/statfs_test.go new file mode 100644 index 00000000..2a904aae --- /dev/null +++ b/pkg/compose/statfs_test.go @@ -0,0 +1,43 @@ +package compose + +import "testing" + +func TestGetUsageInfoPercentAndBytes(t *testing.T) { + dir := t.TempDir() + + // Percent mode: reserved is (100-watermark)% of total size. + const watermark = 90 + pui, err := GetUsageInfo(dir, 0, watermark, false) + if err != nil { + t.Fatalf("percent GetUsageInfo failed: %v", err) + } + wantReserved := uint64((float64(100-watermark) / 100.0) * float64(pui.SizeB)) + if pui.Reserved != wantReserved { + t.Fatalf("percent reserved: got %d, want %d", pui.Reserved, wantReserved) + } + if pui.Free > pui.Reserved && pui.Available != pui.Free-pui.Reserved { + t.Fatalf("percent available: got %d, want %d", pui.Available, pui.Free-pui.Reserved) + } + + // Bytes mode: reserved is the exact byte count and available is free minus it. + reserved := pui.Free / 4 + bui, err := GetUsageInfo(dir, 0, reserved, true) + if err != nil { + t.Fatalf("bytes GetUsageInfo failed: %v", err) + } + if bui.Reserved != reserved { + t.Fatalf("bytes reserved: got %d, want %d", bui.Reserved, reserved) + } + if bui.Available != bui.Free-reserved { + t.Fatalf("bytes available: got %d, want %d", bui.Available, bui.Free-reserved) + } + + // Bytes mode where the reservation exceeds free space clamps available to 0. + cui, err := GetUsageInfo(dir, 0, bui.Free+1, true) + if err != nil { + t.Fatalf("clamp GetUsageInfo failed: %v", err) + } + if cui.Available != 0 { + t.Fatalf("clamp available: got %d, want 0", cui.Available) + } +} From c761eca329dcb20f4e25657407bf24b505369d18 Mon Sep 17 00:00:00 2001 From: Andre Detsch Date: Wed, 17 Jun 2026 11:58:28 -0300 Subject: [PATCH 2/3] feat(cli): add --reserved-storage flag to pull The pull command only bounded storage usage via the percentage --storage-usage-watermark, a clumsy way to reserve a fixed amount of free space on large partitions. Add a separate --reserved-storage flag that takes an absolute size (e.g. "2GiB") so the two meanings stay unambiguous instead of overloading a single flag. reserved-storage takes precedence over --storage-usage-watermark: when both are set the percentage watermark is ignored and a warning is printed. GetUsageInfo is unchanged; the flags are resolved into its existing (watermark, inBytes) pair. Signed-off-by: Andre Detsch Assisted-by: Claude Code:claude-opus-4-8 --- cmd/composectl/cmd/check.go | 7 ++--- cmd/composectl/cmd/pull.go | 54 ++++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/cmd/composectl/cmd/check.go b/cmd/composectl/cmd/check.go index 28219571..b93afb80 100644 --- a/cmd/composectl/cmd/check.go +++ b/cmd/composectl/cmd/check.go @@ -103,7 +103,7 @@ func checkAppsCmd(cmd *cobra.Command, args []string, opts *checkOptions) { opts.SrcStorePath = &config.StoreRoot } cr, ui, _, err := checkApps(cmd.Context(), args, blobProvider, - *opts.UsageWatermark, *opts.SrcStorePath, quietCheck, opts.Quick) + uint64(*opts.UsageWatermark), false, *opts.SrcStorePath, quietCheck, opts.Quick) DieNotNil(err, "failed to check apps status") var ir InstallCheckResult @@ -153,7 +153,8 @@ func checkAppsCmd(cmd *cobra.Command, args []string, opts *checkOptions) { func checkApps(ctx context.Context, appRefs []string, srcBlobProvider compose.BlobProvider, - usageWatermark uint, + watermark uint64, + watermarkInBytes bool, srcStorePath string, quiet bool, quick bool) (*CheckAppResult, *compose.UsageInfo, []compose.App, error) { @@ -205,7 +206,7 @@ func checkApps(ctx context.Context, checkResult.TotalRuntimeSize += bi.RuntimeSize } ui, err := compose.GetUsageInfo(config.StoreRoot, - checkResult.TotalStoreSize+checkResult.TotalRuntimeSize, uint64(usageWatermark), false) + checkResult.TotalStoreSize+checkResult.TotalRuntimeSize, watermark, watermarkInBytes) if err != nil { return nil, nil, nil, err } diff --git a/cmd/composectl/cmd/pull.go b/cmd/composectl/cmd/pull.go index 5218f733..07e0ac32 100644 --- a/cmd/composectl/cmd/pull.go +++ b/cmd/composectl/cmd/pull.go @@ -4,9 +4,11 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/containerd/containerd/platforms" + "github.com/docker/go-units" "github.com/foundriesio/composeapp/pkg/compose" v1 "github.com/foundriesio/composeapp/pkg/compose/v1" "github.com/moby/term" @@ -15,11 +17,12 @@ import ( type ( pullOptions struct { - UsageWatermark uint - SrcStorePath string - PrintUsageStat bool - Quick bool - Workers uint + UsageWatermark uint + ReservedStorage string + SrcStorePath string + PrintUsageStat bool + Quick bool + Workers uint } ) @@ -44,15 +47,17 @@ func init() { pullCmd.Flags().UintVarP(&opts.UsageWatermark, "storage-usage-watermark", "u", DefaultUsageWatermark, fmt.Sprintf("The maximum allowed storage usage in percentage in range %d-%d", MinUsageWatermark, MaxUsageWatermark)) + pullCmd.Flags().StringVar(&opts.ReservedStorage, "reserved-storage", "", + "Absolute amount of free space to keep reserved, e.g. \"2GiB\" or \"500MB\"; takes precedence over --storage-usage-watermark") pullCmd.Flags().StringVarP(&opts.SrcStorePath, "source-store-path", "l", "", "A path to the source store root directory") pullCmd.Flags().BoolVarP(&opts.PrintUsageStat, "print-usage-stat", "p", false, "A flag to enable/disable usage statistic output to stderr") pullCmd.Flags().BoolVar(&opts.Quick, "quick", false, "Skip checking hash of app blobs; verify only their presence and size") pullCmd.Flags().UintVarP(&opts.Workers, "workers", "w", DefaultWorkers, fmt.Sprintf("Number of concurrent blob download workers in range %d-%d", MinWorkers, MaxWorkers)) pullCmd.Run = func(cmd *cobra.Command, args []string) { - checkWatermark(opts.UsageWatermark) + watermark, watermarkInBytes := resolveWatermark(cmd, opts.UsageWatermark, opts.ReservedStorage) checkWorkers(opts.Workers) - pullApps(cmd, args, &opts) + pullApps(cmd, args, &opts, watermark, watermarkInBytes) } rootCmd.AddCommand(pullCmd) @@ -65,7 +70,38 @@ func checkWorkers(workers uint) { } } -func pullApps(cmd *cobra.Command, args []string, opts *pullOptions) { +// resolveWatermark turns the storage-usage-watermark percentage and the optional +// reserved-storage size into the (watermark, inBytes) pair GetUsageInfo expects. +// reserved-storage takes precedence: when it is set the percentage watermark is +// ignored, with a warning if it was also explicitly provided. +func resolveWatermark(cmd *cobra.Command, usageWatermark uint, reservedStorage string) (watermark uint64, inBytes bool) { + if len(reservedStorage) == 0 { + checkWatermark(usageWatermark) + return uint64(usageWatermark), false + } + if cmd.Flags().Changed("storage-usage-watermark") { + fmt.Fprintln(os.Stderr, + "warning: both --storage-usage-watermark and --reserved-storage are set; ignoring --storage-usage-watermark") + } + reserved, err := parseReservedStorage(reservedStorage) + if err != nil || reserved <= 0 { + DieNotNilWithCode(fmt.Errorf("invalid `--reserved-storage` value: %q; expected a byte size such as \"2GiB\" or \"500MB\"", + reservedStorage), 1, "invalid argument") + } + return uint64(reserved), true +} + +// 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 pullApps(cmd *cobra.Command, args []string, opts *pullOptions, watermark uint64, watermarkInBytes bool) { if len(args) > 1 { fmt.Printf("Pulling %d apps to %s\n", len(args), config.StoreRoot) } else { @@ -75,7 +111,7 @@ func pullApps(cmd *cobra.Command, args []string, opts *pullOptions) { srcBlobProvider, cs, err := getAppStoreAndDstBlobProvider(opts.SrcStorePath, false) DieNotNil(err) - cr, ui, apps, err := checkApps(cmd.Context(), args, srcBlobProvider, opts.UsageWatermark, + cr, ui, apps, err := checkApps(cmd.Context(), args, srcBlobProvider, watermark, watermarkInBytes, opts.SrcStorePath, false, opts.Quick) DieNotNil(err, "failed to check apps status") if len(cr.MissingBlobs) > 0 { From a1b3a4b6f0369306df567e5dbd513ecc36cb75a5 Mon Sep 17 00:00:00 2001 From: Andre Detsch Date: Wed, 17 Jun 2026 12:10:33 -0300 Subject: [PATCH 3/3] feat(cli): add --reserved-storage flag to check Mirror the pull command so check exposes the same --reserved-storage flag for an absolute reserved free space (e.g. "2GiB") alongside the percentage --storage-usage-watermark, keeping the two commands consistent. reserved-storage takes precedence over --storage-usage-watermark; the resolution (including the both-set warning) is shared via resolveWatermark. Signed-off-by: Andre Detsch Assisted-by: Claude Code:claude-opus-4-8 --- cmd/composectl/cmd/check.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/cmd/composectl/cmd/check.go b/cmd/composectl/cmd/check.go index b93afb80..b32763b6 100644 --- a/cmd/composectl/cmd/check.go +++ b/cmd/composectl/cmd/check.go @@ -16,12 +16,13 @@ import ( type ( checkOptions struct { - UsageWatermark *uint - SrcStorePath *string - Locally *bool - Format string - CheckInstall bool - Quick bool + UsageWatermark *uint + ReservedStorage *string + SrcStorePath *string + Locally *bool + Format string + CheckInstall bool + Quick bool } CheckAppResult struct { @@ -61,6 +62,8 @@ func init() { opts := checkOptions{} opts.UsageWatermark = checkCmd.Flags().UintP("storage-usage-watermark", "u", DefaultUsageWatermark, fmt.Sprintf("The maximum allowed storage usage in percentage in range %d-%d", MinUsageWatermark, MaxUsageWatermark)) + opts.ReservedStorage = checkCmd.Flags().String("reserved-storage", "", + "Absolute amount of free space to keep reserved, e.g. \"2GiB\" or \"500MB\"; takes precedence over --storage-usage-watermark") opts.SrcStorePath = checkCmd.Flags().StringP("source-store-path", "l", "", "A path to the source store root directory") opts.Locally = checkCmd.Flags().BoolP("local", "", false, @@ -77,8 +80,8 @@ func init() { fmt.Fprintf(os.Stderr, "unsupported `--format` value: %s\n", opts.Format) os.Exit(1) } - checkWatermark(*opts.UsageWatermark) - checkAppsCmd(cmd, args, &opts) + watermark, watermarkInBytes := resolveWatermark(cmd, *opts.UsageWatermark, *opts.ReservedStorage) + checkAppsCmd(cmd, args, &opts, watermark, watermarkInBytes) } rootCmd.AddCommand(checkCmd) @@ -91,7 +94,7 @@ func checkWatermark(watermark uint) { } } -func checkAppsCmd(cmd *cobra.Command, args []string, opts *checkOptions) { +func checkAppsCmd(cmd *cobra.Command, args []string, opts *checkOptions, watermark uint64, watermarkInBytes bool) { var quietCheck bool if opts.Format == "json" { quietCheck = true @@ -103,7 +106,7 @@ func checkAppsCmd(cmd *cobra.Command, args []string, opts *checkOptions) { opts.SrcStorePath = &config.StoreRoot } cr, ui, _, err := checkApps(cmd.Context(), args, blobProvider, - uint64(*opts.UsageWatermark), false, *opts.SrcStorePath, quietCheck, opts.Quick) + watermark, watermarkInBytes, *opts.SrcStorePath, quietCheck, opts.Quick) DieNotNil(err, "failed to check apps status") var ir InstallCheckResult