diff --git a/capacity-forecast.go b/capacity-forecast.go new file mode 100644 index 00000000..36915bf1 --- /dev/null +++ b/capacity-forecast.go @@ -0,0 +1,99 @@ +// +// Copyright (c) 2015-2026 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package madmin + +import ( + "context" + "encoding/json" + "net/http" +) + +// CapacityForecast contains storage capacity predictions based on +// historical daily snapshots processed through a Kalman filter. +// +// Days-until-threshold fields are pointers: nil means "unknown" (for example, +// not enough history yet, or usage is not growing). A concrete value may be +// negative when the threshold was already crossed in the past. +type CapacityForecast struct { + CurrentUsedBytes uint64 `json:"currentUsedBytes"` + CurrentTotalBytes uint64 `json:"currentTotalBytes"` + CurrentUsedPercent float64 `json:"currentUsedPercent"` + + // Days until each usage threshold is reached. The projection comes from + // a Kalman filter that integrates up to 365 daily samples with greater + // weight on recent observations, so a fresh shift in usage dominates + // over older history. nil = unknown (slope is non-positive, or there + // is not enough history). + DaysUntil80Pct *float64 `json:"daysUntil80Pct,omitempty"` + DaysUntil90Pct *float64 `json:"daysUntil90Pct,omitempty"` + DaysUntil100Pct *float64 `json:"daysUntil100Pct,omitempty"` + + // GrowthBytesPerDay is the Kalman filter slope expressed in bytes per + // day, projected from the daily snapshots in the circular buffer. + // It is independent of DailySnapshotCount: the filter is recency + // weighted, not a delta between two endpoints. + GrowthBytesPerDay int64 `json:"growthBytesPerDay"` + + // DailySnapshotCount is the number of valid daily snapshots currently + // held in the year-long circular buffer (range 0..365). The forecast + // fields above are only populated when this count reaches the + // minimum required for the filter to produce stable estimates. + DailySnapshotCount int `json:"dailySnapshotCount"` + + // Worst-case prediction based on the largest single-day growth + // observed between any two consecutive data points. nil = unknown. + MinDaysUntilFull *float64 `json:"minDaysUntilFull,omitempty"` + + Variance float64 `json:"variance"` // variance of daily usedFraction deltas + + // Smallest and largest day-to-day changes in used bytes observed + // between consecutive snapshots. A negative value means space was + // freed between those two days. + DayMinDeltaBytes int64 `json:"dayMinDeltaBytes"` + DayMaxDeltaBytes int64 `json:"dayMaxDeltaBytes"` + + // Recency-weighted predictions from the Kalman filter. + RecentGrowthRatePerDay float64 `json:"recentGrowthRatePerDay"` + RecentDaysUntilFull *float64 `json:"recentDaysUntilFull,omitempty"` +} + +// CapacityForecast returns a storage capacity forecast based on +// historical daily snapshots. +func (adm *AdminClient) CapacityForecast(ctx context.Context) (CapacityForecast, error) { + resp, err := adm.executeMethod(ctx, + http.MethodGet, + requestData{ + relPath: adminAPIPrefix + "/capacity-forecast", + }) + defer closeResponse(resp) + if err != nil { + return CapacityForecast{}, err + } + + if resp.StatusCode != http.StatusOK { + return CapacityForecast{}, httpRespToErrorResponse(resp) + } + + var f CapacityForecast + if err = json.NewDecoder(resp.Body).Decode(&f); err != nil { + return CapacityForecast{}, err + } + + return f, nil +} diff --git a/capacity-forecast_test.go b/capacity-forecast_test.go new file mode 100644 index 00000000..4e051499 --- /dev/null +++ b/capacity-forecast_test.go @@ -0,0 +1,146 @@ +// +// Copyright (c) 2015-2026 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package madmin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newCapacityForecastTestClient(t *testing.T, serverURL string) *AdminClient { + t.Helper() + client, err := New(mustParseHost(t, serverURL), "ak", "sk", false) + if err != nil { + t.Fatalf("New: %v", err) + } + return client +} + +// TestCapacityForecastRequest verifies the client issues GET against the +// capacity-forecast admin path. +func TestCapacityForecastRequest(t *testing.T) { + var capturedMethod, capturedPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + if _, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background()); err != nil { + t.Fatalf("CapacityForecast: %v", err) + } + + if capturedMethod != http.MethodGet { + t.Errorf("expected GET, got %s", capturedMethod) + } + if !strings.HasPrefix(capturedPath, "/minio/admin/") { + t.Errorf("path missing /minio/admin/ prefix: %s", capturedPath) + } + if !strings.HasSuffix(capturedPath, "/capacity-forecast") { + t.Errorf("path missing /capacity-forecast suffix: %s", capturedPath) + } +} + +// TestCapacityForecastDecode covers JSON decoding for both nil and non-nil +// pointer fields, including negative values for thresholds already crossed. +func TestCapacityForecastDecode(t *testing.T) { + cases := []struct { + name string + body string + wantNil80 bool + wantNil100 bool + wantVal80 float64 + wantNegative bool + }{ + { + name: "days-until fields omitted produce nil pointers", + body: `{"currentUsedBytes":100,"currentTotalBytes":1000,"dailySnapshotCount":7}`, + wantNil80: true, + wantNil100: true, + }, + { + name: "positive days-until values decode as expected", + body: `{"daysUntil80Pct":42.5,"daysUntil100Pct":120}`, + wantNil80: false, + wantVal80: 42.5, + }, + { + name: "negative days-until preserved (threshold already crossed)", + body: `{"daysUntil80Pct":-3.2,"daysUntil100Pct":-1}`, + wantNil80: false, + wantVal80: -3.2, + wantNegative: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(tc.body)) + })) + defer server.Close() + + f, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background()) + if err != nil { + t.Fatalf("CapacityForecast: %v", err) + } + + if tc.wantNil80 { + if f.DaysUntil80Pct != nil { + t.Errorf("expected DaysUntil80Pct nil, got %v", *f.DaysUntil80Pct) + } + } else { + if f.DaysUntil80Pct == nil { + t.Fatal("expected DaysUntil80Pct non-nil") + } + if *f.DaysUntil80Pct != tc.wantVal80 { + t.Errorf("DaysUntil80Pct: want %v, got %v", tc.wantVal80, *f.DaysUntil80Pct) + } + if tc.wantNegative && *f.DaysUntil80Pct >= 0 { + t.Errorf("expected negative value, got %v", *f.DaysUntil80Pct) + } + } + + if tc.wantNil100 && f.DaysUntil100Pct != nil { + t.Errorf("expected DaysUntil100Pct nil, got %v", *f.DaysUntil100Pct) + } + }) + } +} + +// TestCapacityForecastHTTPError verifies non-200 responses produce an error +// rather than a zero-value struct. +func TestCapacityForecastHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + if _, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background()); err == nil { + t.Fatal("expected error on 500, got nil") + } +}