diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 557e486..ec92127 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: - name: Run unit tests with race detector and coverage run: | cd backend - go test -race -coverprofile=../coverage.out -coverpkg=./... ./... + go test -race -count=1 -coverprofile=../coverage.out -coverpkg=./... ./... - name: Enforce coverage gate run: bash scripts/coverage-gate.sh coverage.out diff --git a/README.md b/README.md index 1d5c994..4397485 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,28 @@ A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object st - Multiple authentication options (none/basic/OIDC) - Drag-and-drop file uploads +## Compatibility + +Garage UI auto-detects your Garage version at startup. Most features work across all supported versions. + +| Feature | Garage v1.x | Garage v2.x | +|---|---|---| +| Bucket management | Yes | Yes | +| Object browser | Yes | Yes | +| Key management | Yes | Yes | +| Access control | Yes | Yes | +| Cluster health | Yes | Yes | +| Cluster statistics | — | Yes | +| Per-node details | Limited | Yes | + +Features unavailable on your version appear as disabled in the interface. + ## Quick Start ### Prerequisites - Docker & Docker Compose -- Garage S3 cluster (v2.1.0+) or use the included setup +- Garage S3 cluster (v1.1.0+) or use the included setup ### 1. Clone & Setup diff --git a/backend/internal/handlers/capabilities.go b/backend/internal/handlers/capabilities.go new file mode 100644 index 0000000..638a1a6 --- /dev/null +++ b/backend/internal/handlers/capabilities.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +type CapabilitiesHandler struct { + apiVersion string + capabilities services.Capabilities +} + +func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities) *CapabilitiesHandler { + return &CapabilitiesHandler{ + apiVersion: apiVersion, + capabilities: capabilities, + } +} + +func (h *CapabilitiesHandler) GetCapabilities(c fiber.Ctx) error { + return c.JSON(models.SuccessResponse(fiber.Map{ + "garageApiVersion": h.apiVersion, + "features": h.capabilities, + })) +} diff --git a/backend/internal/handlers/capabilities_test.go b/backend/internal/handlers/capabilities_test.go new file mode 100644 index 0000000..abc4d41 --- /dev/null +++ b/backend/internal/handlers/capabilities_test.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +func TestCapabilities_V2(t *testing.T) { + app := fiber.New() + h := NewCapabilitiesHandler("v2", services.CapabilitiesV2()) + app.Get("/capabilities", h.GetCapabilities) + + req := httptest.NewRequest(http.MethodGet, "/capabilities", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var body struct { + Success bool `json:"success"` + Data struct { + GarageApiVersion string `json:"garageApiVersion"` + Features services.Capabilities `json:"features"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !body.Success { + t.Fatal("expected success=true") + } + if body.Data.GarageApiVersion != "v2" { + t.Errorf("garageApiVersion = %q, want v2", body.Data.GarageApiVersion) + } + if !body.Data.Features.ClusterStatistics || !body.Data.Features.NodeInfo || !body.Data.Features.NodeStatistics { + t.Errorf("features = %+v, want all true", body.Data.Features) + } +} + +func TestCapabilities_V1(t *testing.T) { + app := fiber.New() + h := NewCapabilitiesHandler("v1", services.CapabilitiesV1()) + app.Get("/capabilities", h.GetCapabilities) + + req := httptest.NewRequest(http.MethodGet, "/capabilities", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var body struct { + Data struct { + GarageApiVersion string `json:"garageApiVersion"` + Features services.Capabilities `json:"features"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Data.GarageApiVersion != "v1" { + t.Errorf("garageApiVersion = %q, want v1", body.Data.GarageApiVersion) + } + if body.Data.Features.ClusterStatistics || body.Data.Features.NodeInfo || body.Data.Features.NodeStatistics { + t.Errorf("features = %+v, want all false", body.Data.Features) + } +} diff --git a/backend/internal/handlers/cluster.go b/backend/internal/handlers/cluster.go index 6c13b44..daa543b 100644 --- a/backend/internal/handlers/cluster.go +++ b/backend/internal/handlers/cluster.go @@ -1,6 +1,8 @@ package handlers import ( + "errors" + "Noooste/garage-ui/internal/models" "Noooste/garage-ui/internal/services" @@ -69,14 +71,17 @@ func (h *ClusterHandler) GetStatus(c fiber.Ctx) error { // GET /api/v1/cluster/statistics func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error { ctx := c.Context() - stats, err := h.adminService.GetClusterStatistics(ctx) if err != nil { + if errors.Is(err, services.ErrUnsupported) { + return c.Status(fiber.StatusNotImplemented).JSON( + models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"), + ) + } return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()), ) } - return c.JSON(models.SuccessResponse(stats)) } @@ -95,20 +100,22 @@ func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error { func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error { ctx := c.Context() nodeID := c.Params("node_id") - if nodeID == "" { return c.Status(fiber.StatusBadRequest).JSON( models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"), ) } - info, err := h.adminService.GetNodeInfo(ctx, nodeID) if err != nil { + if errors.Is(err, services.ErrUnsupported) { + return c.Status(fiber.StatusNotImplemented).JSON( + models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"), + ) + } return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()), ) } - return c.JSON(models.SuccessResponse(info)) } @@ -127,19 +134,21 @@ func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error { func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error { ctx := c.Context() nodeID := c.Params("node_id") - if nodeID == "" { return c.Status(fiber.StatusBadRequest).JSON( models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"), ) } - stats, err := h.adminService.GetNodeStatistics(ctx, nodeID) if err != nil { + if errors.Is(err, services.ErrUnsupported) { + return c.Status(fiber.StatusNotImplemented).JSON( + models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"), + ) + } return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()), ) } - return c.JSON(models.SuccessResponse(stats)) } diff --git a/backend/internal/handlers/cluster_test.go b/backend/internal/handlers/cluster_test.go index 5e04ff6..337ad4a 100644 --- a/backend/internal/handlers/cluster_test.go +++ b/backend/internal/handlers/cluster_test.go @@ -9,6 +9,7 @@ import ( "testing" "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" "Noooste/garage-ui/internal/services/mocks" "github.com/gofiber/fiber/v3" @@ -124,6 +125,42 @@ func TestCluster_GetStatistics_ServiceErrorReturns500(t *testing.T) { } } +func TestCluster_GetStatistics_UnsupportedReturns501(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) { + return nil, services.ErrUnsupported + } + resp := doGet(t, app, "/cluster/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501", resp.StatusCode) + } +} + +func TestCluster_GetNodeInfo_UnsupportedReturns501(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) { + return nil, services.ErrUnsupported + } + resp := doGet(t, app, "/cluster/nodes/n1") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501", resp.StatusCode) + } +} + +func TestCluster_GetNodeStatistics_UnsupportedReturns501(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) { + return nil, services.ErrUnsupported + } + resp := doGet(t, app, "/cluster/nodes/n1/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501", resp.StatusCode) + } +} + func TestCluster_GetNodeInfo_Success(t *testing.T) { app, admin := newClusterTestApp(t) admin.GetNodeInfoFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) { diff --git a/backend/internal/models/admin.go b/backend/internal/models/admin.go index 9724052..35a2d2e 100644 --- a/backend/internal/models/admin.go +++ b/backend/internal/models/admin.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "encoding/json" + "time" +) // GarageKeyInfo represents detailed information about a Garage access key type GarageKeyInfo struct { @@ -180,6 +183,24 @@ type ClusterHealth struct { PartitionsAllOk int `json:"partitionsAllOk"` } +// UnmarshalJSON handles both "storageNodesOk" (Garage v2.0.0) and +// "storageNodesUp" (Garage v2.1.0+, v1.x) field names. +func (h *ClusterHealth) UnmarshalJSON(data []byte) error { + type plain ClusterHealth + var aux struct { + plain + StorageNodesOk int `json:"storageNodesOk"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *h = ClusterHealth(aux.plain) + if h.StorageNodesUp == 0 && aux.StorageNodesOk != 0 { + h.StorageNodesUp = aux.StorageNodesOk + } + return nil +} + // ClusterStatus represents the current status of the cluster type ClusterStatus struct { LayoutVersion int `json:"layoutVersion"` diff --git a/backend/internal/models/admin_compat_test.go b/backend/internal/models/admin_compat_test.go new file mode 100644 index 0000000..bf1f0b3 --- /dev/null +++ b/backend/internal/models/admin_compat_test.go @@ -0,0 +1,46 @@ +package models + +import ( + "encoding/json" + "testing" +) + +func TestClusterHealth_StorageNodesOk_BackCompat(t *testing.T) { + raw := `{ + "status": "healthy", + "knownNodes": 3, + "connectedNodes": 3, + "storageNodes": 3, + "storageNodesOk": 3, + "partitions": 256, + "partitionsQuorum": 256, + "partitionsAllOk": 256 + }` + var h ClusterHealth + if err := json.Unmarshal([]byte(raw), &h); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if h.StorageNodesUp != 3 { + t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp) + } +} + +func TestClusterHealth_StorageNodesUp(t *testing.T) { + raw := `{ + "status": "healthy", + "knownNodes": 3, + "connectedNodes": 3, + "storageNodes": 3, + "storageNodesUp": 3, + "partitions": 256, + "partitionsQuorum": 256, + "partitionsAllOk": 256 + }` + var h ClusterHealth + if err := json.Unmarshal([]byte(raw), &h); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if h.StorageNodesUp != 3 { + t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp) + } +} diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go index f6afeda..c0296fa 100644 --- a/backend/internal/models/responses.go +++ b/backend/internal/models/responses.go @@ -196,4 +196,5 @@ const ( ErrCodeUploadFailed = "UPLOAD_FAILED" ErrCodeDeleteFailed = "DELETE_FAILED" ErrCodeListFailed = "LIST_FAILED" + ErrCodeUnsupported = "UNSUPPORTED" ) diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index df71591..fd504c3 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -29,6 +29,7 @@ func SetupRoutes( userHandler *handlers.UserHandler, clusterHandler *handlers.ClusterHandler, monitoringHandler *handlers.MonitoringHandler, + capabilitiesHandler *handlers.CapabilitiesHandler, ) { // Apply CORS middleware globally app.Use(middleware.CORSMiddleware(&cfg.CORS)) @@ -52,6 +53,8 @@ func SetupRoutes( // Apply authentication middleware to all API routes api.Use(middleware.AuthMiddleware(&cfg.Auth, authService)) + api.Get("/capabilities", capabilitiesHandler.GetCapabilities) + // Bucket routes buckets := api.Group("/buckets") { diff --git a/backend/internal/routes/routes_test.go b/backend/internal/routes/routes_test.go index 4f56780..e05e5be 100644 --- a/backend/internal/routes/routes_test.go +++ b/backend/internal/routes/routes_test.go @@ -14,6 +14,7 @@ import ( "Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/handlers" + "Noooste/garage-ui/internal/services" "Noooste/garage-ui/internal/services/mocks" "github.com/gofiber/fiber/v3" @@ -67,6 +68,7 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture { handlers.NewUserHandler(admin), handlers.NewClusterHandler(admin), handlers.NewMonitoringHandler(admin, s3), + handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2()), ) return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg} diff --git a/backend/internal/services/admin_buckets_logging_test.go b/backend/internal/services/admin_buckets_logging_test.go index 45e6d64..7d42095 100644 --- a/backend/internal/services/admin_buckets_logging_test.go +++ b/backend/internal/services/admin_buckets_logging_test.go @@ -16,9 +16,9 @@ import ( "github.com/rs/zerolog" ) -// newAdminWithServer creates a GarageAdminService pointed at a test server +// newAdminWithServer creates a GarageV2AdminService pointed at a test server // and returns it with a cleanup hook. -func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService { +func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageV2AdminService { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) @@ -26,7 +26,7 @@ func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminServ AdminEndpoint: srv.URL, AdminToken: "test-token", } - return NewGarageAdminService(cfg, "info") + return NewGarageV2AdminService(cfg, "info") } // ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx. diff --git a/backend/internal/services/admin_factory.go b/backend/internal/services/admin_factory.go new file mode 100644 index 0000000..960567f --- /dev/null +++ b/backend/internal/services/admin_factory.go @@ -0,0 +1,93 @@ +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/pkg/logger" + + "github.com/Noooste/azuretls-client" +) + +var ErrUnsupported = errors.New("operation not supported by this Garage version") + +type Capabilities struct { + ClusterStatistics bool `json:"clusterStatistics"` + NodeInfo bool `json:"nodeInfo"` + NodeStatistics bool `json:"nodeStatistics"` +} + +func CapabilitiesV2() Capabilities { + return Capabilities{ + ClusterStatistics: true, + NodeInfo: true, + NodeStatistics: true, + } +} + +func CapabilitiesV1() Capabilities { + return Capabilities{} +} + +type AdminServiceResult struct { + Service AdminService + Capabilities Capabilities + APIVersion string +} + +func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceResult, error) { + if err := probeEndpoint(cfg, "/v2/GetClusterHealth"); err == nil { + logger.Info().Str("api_version", "v2").Msg("Detected Garage admin API v2") + svc := NewGarageV2AdminService(cfg, logLevel) + return &AdminServiceResult{ + Service: svc, + Capabilities: CapabilitiesV2(), + APIVersion: "v2", + }, nil + } + + if err := probeEndpoint(cfg, "/v1/health"); err == nil { + logger.Info(). + Str("api_version", "v1"). + Msg("Detected Garage admin API v1 — cluster statistics and per-node details will be unavailable") + svc := NewGarageV1AdminService(cfg, logLevel) + return &AdminServiceResult{ + Service: svc, + Capabilities: CapabilitiesV1(), + APIVersion: "v1", + }, nil + } + + return nil, fmt.Errorf( + "could not connect to Garage admin API at %s. Ensure Garage v1.1+ is running and the admin API is enabled", + cfg.AdminEndpoint, + ) +} + +func probeEndpoint(cfg *config.GarageConfig, path string) error { + session := azuretls.NewSession() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := session.Do(&azuretls.Request{ + Method: http.MethodGet, + Url: cfg.AdminEndpoint + path, + IgnoreBody: true, + OrderedHeaders: azuretls.OrderedHeaders{ + {"Authorization", fmt.Sprintf("Bearer %s", cfg.AdminToken)}, + }, + }, ctx) + if err != nil { + return err + } + defer resp.RawBody.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("probe %s returned status %d", path, resp.StatusCode) + } + return nil +} diff --git a/backend/internal/services/admin_factory_test.go b/backend/internal/services/admin_factory_test.go new file mode 100644 index 0000000..c0bdc02 --- /dev/null +++ b/backend/internal/services/admin_factory_test.go @@ -0,0 +1,91 @@ +package services + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "Noooste/garage-ui/internal/config" +) + +func TestErrUnsupportedIsSentinel(t *testing.T) { + err := ErrUnsupported + if !errors.Is(err, ErrUnsupported) { + t.Fatal("ErrUnsupported should match itself via errors.Is") + } +} + +func TestCapabilitiesV2AllTrue(t *testing.T) { + caps := CapabilitiesV2() + if !caps.ClusterStatistics || !caps.NodeInfo || !caps.NodeStatistics { + t.Fatalf("v2 capabilities should all be true, got %+v", caps) + } +} + +func TestCapabilitiesV1AllFalse(t *testing.T) { + caps := CapabilitiesV1() + if caps.ClusterStatistics || caps.NodeInfo || caps.NodeStatistics { + t.Fatalf("v1 capabilities should all be false, got %+v", caps) + } +} + +func TestDetectVersion_V2(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/GetClusterHealth" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy"}`)) + return + } + w.WriteHeader(404) + })) + t.Cleanup(srv.Close) + + cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"} + result, err := NewAdminService(cfg, "") + if err != nil { + t.Fatal(err) + } + if result.APIVersion != "v2" { + t.Fatalf("expected v2, got %s", result.APIVersion) + } + if !result.Capabilities.ClusterStatistics { + t.Fatal("v2 should have ClusterStatistics capability") + } +} + +func TestDetectVersion_V1(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/GetClusterHealth" { + w.WriteHeader(404) + return + } + if r.URL.Path == "/v1/health" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy"}`)) + return + } + w.WriteHeader(404) + })) + t.Cleanup(srv.Close) + + cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"} + result, err := NewAdminService(cfg, "") + if err != nil { + t.Fatal(err) + } + if result.APIVersion != "v1" { + t.Fatalf("expected v1, got %s", result.APIVersion) + } + if result.Capabilities.ClusterStatistics { + t.Fatal("v1 should not have ClusterStatistics capability") + } +} + +func TestDetectVersion_Unreachable(t *testing.T) { + cfg := &config.GarageConfig{AdminEndpoint: "http://127.0.0.1:1", AdminToken: "tok"} + _, err := NewAdminService(cfg, "") + if err == nil { + t.Fatal("expected error for unreachable server") + } +} diff --git a/backend/internal/services/admin_v1.go b/backend/internal/services/admin_v1.go new file mode 100644 index 0000000..81f3900 --- /dev/null +++ b/backend/internal/services/admin_v1.go @@ -0,0 +1,375 @@ +package services + +import ( + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/pkg/utils" + logpkg "Noooste/garage-ui/pkg/logger" + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/Noooste/azuretls-client" +) + +type GarageV1AdminService struct { + baseURL string + token string + httpClient *azuretls.Session +} + +func NewGarageV1AdminService(cfg *config.GarageConfig, logLevel string) *GarageV1AdminService { + session := azuretls.NewSession() + if logLevel == "debug" { + session.Log() + } + return &GarageV1AdminService{ + baseURL: cfg.AdminEndpoint, + token: cfg.AdminToken, + httpClient: session, + } +} + +func (s *GarageV1AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) { + var resp *azuretls.Response + retryConfig := utils.DefaultRetryConfig() + err := utils.RetryWithBackoff(ctx, retryConfig, func() error { + var reqErr error + resp, reqErr = s.httpClient.Do(&azuretls.Request{ + Method: method, + Url: s.baseURL + path, + Body: body, + IgnoreBody: true, + OrderedHeaders: azuretls.OrderedHeaders{ + {"Authorization", fmt.Sprintf("Bearer %s", s.token)}, + }, + }, ctx) + return reqErr + }) + if err != nil { + return nil, err + } + return resp, nil +} + +func (s *GarageV1AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v1/key?list=true", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result []models.ListKeysResponseItem + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return result, nil +} + +func (s *GarageV1AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key?list", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) { + path := fmt.Sprintf("/v1/key?id=%s", keyID) + if showSecret { + path += "&showSecretKey=true" + } + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + path := fmt.Sprintf("/v1/key?id=%s", keyID) + resp, err := s.doRequest(ctx, http.MethodPost, path, req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) DeleteKey(ctx context.Context, keyID string) error { + path := fmt.Sprintf("/v1/key?id=%s", keyID) + resp, err := s.doRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("failed to process response: %w", err) + } + return nil +} + +func (s *GarageV1AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key/import", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) { + log := logpkg.FromCtx(ctx).With().Str("component", "admin-v1").Str("operation", "list_buckets").Logger() + log.Debug().Msg("listing buckets") + start := time.Now() + resp, err := s.doRequest(ctx, http.MethodGet, "/v1/bucket?list", nil) + if err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets request failed") + return nil, fmt.Errorf("request failed: %w", err) + } + var result []models.ListBucketsResponseItem + if err := decodeResponse(resp, &result); err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets decode failed") + return nil, fmt.Errorf("failed to decode response: %w", err) + } + log.Debug().Float64("duration_ms", msSince(start)).Int("count", len(result)).Msg("listed buckets") + return result, nil +} + +func (s *GarageV1AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?globalAlias=%s", globalAlias), nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPut, fmt.Sprintf("/v1/bucket?id=%s", bucketID), req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) DeleteBucket(ctx context.Context, bucketID string) error { + resp, err := s.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("failed to process response: %w", err) + } + return nil +} + +func (s *GarageV1AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/allow", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/deny", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) { + var path string + if req.GlobalAlias != nil { + path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias) + } else if req.LocalAlias != nil && req.AccessKeyID != nil { + path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias) + } else { + return nil, fmt.Errorf("AddBucketAlias requires either globalAlias or localAlias+accessKeyId") + } + resp, err := s.doRequest(ctx, http.MethodPut, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) { + var path string + if req.GlobalAlias != nil { + path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias) + } else if req.LocalAlias != nil && req.AccessKeyID != nil { + path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias) + } else { + return nil, fmt.Errorf("RemoveBucketAlias requires either globalAlias or localAlias+accessKeyId") + } + resp, err := s.doRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +func (s *GarageV1AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v1/health", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var result models.ClusterHealth + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + +type v1StatusResponse struct { + Node string `json:"node"` + GarageVersion string `json:"garageVersion"` + KnownNodes []v1KnownNode `json:"knownNodes"` + Layout *v1Layout `json:"layout"` +} + +type v1KnownNode struct { + ID string `json:"id"` + Addr string `json:"addr"` + IsUp bool `json:"isUp"` + LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo"` + Hostname string `json:"hostname"` +} + +type v1Layout struct { + Version int `json:"version"` +} + +func (s *GarageV1AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v1/status", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + var raw v1StatusResponse + if err := decodeResponse(resp, &raw); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + nodes := make([]models.NodeInfo, len(raw.KnownNodes)) + for i, n := range raw.KnownNodes { + addr := n.Addr + hostname := n.Hostname + nodes[i] = models.NodeInfo{ + ID: n.ID, + IsUp: n.IsUp, + LastSeenSecsAgo: n.LastSeenSecsAgo, + Hostname: &hostname, + Addr: &addr, + } + } + layoutVersion := 0 + if raw.Layout != nil { + layoutVersion = raw.Layout.Version + } + return &models.ClusterStatus{ + LayoutVersion: layoutVersion, + Nodes: nodes, + }, nil +} + +func (s *GarageV1AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) { + return nil, ErrUnsupported +} + +func (s *GarageV1AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + return nil, ErrUnsupported +} + +func (s *GarageV1AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + return nil, ErrUnsupported +} + +func (s *GarageV1AdminService) HealthCheck(ctx context.Context) error { + resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("health check returned error: %w", err) + } + return nil +} + +func (s *GarageV1AdminService) GetMetrics(ctx context.Context) (string, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.RawBody.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.RawBody) + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + bodyBytes, err := io.ReadAll(resp.RawBody) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + return string(bodyBytes), nil +} diff --git a/backend/internal/services/admin_v1_test.go b/backend/internal/services/admin_v1_test.go new file mode 100644 index 0000000..7ae7471 --- /dev/null +++ b/backend/internal/services/admin_v1_test.go @@ -0,0 +1,635 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" +) + +func newV1TestServer(t *testing.T, handler http.Handler) *GarageV1AdminService { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return NewGarageV1AdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token", + }, "") +} + +func TestV1_ListKeys(t *testing.T) { + items := []models.ListKeysResponseItem{{ID: "GK1", Name: "key1"}} + svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/key" || r.URL.Query().Get("list") == "" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) + })) + result, err := svc.ListKeys(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].ID != "GK1" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestV1_GetClusterHealth(t *testing.T) { + svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/health" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","knownNodes":3,"connectedNodes":3,"storageNodes":3,"storageNodesOk":3,"partitions":256,"partitionsQuorum":256,"partitionsAllOk":256}`)) + })) + health, err := svc.GetClusterHealth(context.Background()) + if err != nil { + t.Fatal(err) + } + if health.StorageNodesUp != 3 { + t.Fatalf("expected StorageNodesUp=3, got %d", health.StorageNodesUp) + } +} + +func TestV1_GetClusterStatistics_Unsupported(t *testing.T) { + svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make any HTTP request for unsupported operations") + })) + _, err := svc.GetClusterStatistics(context.Background()) + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected ErrUnsupported, got %v", err) + } +} + +func TestV1_GetNodeInfo_Unsupported(t *testing.T) { + svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make any HTTP request for unsupported operations") + })) + _, err := svc.GetNodeInfo(context.Background(), "abc") + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected ErrUnsupported, got %v", err) + } +} + +func TestV1_GetNodeStatistics_Unsupported(t *testing.T) { + svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not make any HTTP request for unsupported operations") + })) + _, err := svc.GetNodeStatistics(context.Background(), "abc") + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected ErrUnsupported, got %v", err) + } +} + +// v1RecordingHandler returns a handler that records the request and responds with JSON. +func v1RecordingHandler(t *testing.T, status int, body any) (http.Handler, *recordedRequest) { + t.Helper() + rec := &recordedRequest{} + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.method = r.Method + rec.path = r.URL.Path + rec.rawURL = r.URL.RequestURI() + rec.auth = r.Header.Get("Authorization") + if r.Body != nil { + b, _ := io.ReadAll(r.Body) + rec.body = b + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + json.NewEncoder(w).Encode(body) + } + }) + return h, rec +} + +func newV1RecordingServer(t *testing.T, status int, body any) (*GarageV1AdminService, *recordedRequest) { + t.Helper() + h, rec := v1RecordingHandler(t, status, body) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + svc := NewGarageV1AdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token", + }, "") + return svc, rec +} + +func TestV1_CreateKey(t *testing.T) { + name := "mykey" + want := &models.GarageKeyInfo{AccessKeyID: "GK1", Name: name} + svc, rec := newV1RecordingServer(t, 200, want) + + got, err := svc.CreateKey(context.Background(), models.CreateKeyRequest{Name: &name}) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPost || rec.path != "/v1/key" { + t.Errorf("request = %s %s", rec.method, rec.path) + } + if got.AccessKeyID != "GK1" { + t.Errorf("AccessKeyID = %q, want GK1", got.AccessKeyID) + } +} + +func TestV1_GetKeyInfo(t *testing.T) { + want := &models.GarageKeyInfo{AccessKeyID: "ABC"} + svc, rec := newV1RecordingServer(t, 200, want) + + got, err := svc.GetKeyInfo(context.Background(), "ABC", true) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodGet { + t.Errorf("method = %q, want GET", rec.method) + } + if !strings.Contains(rec.rawURL, "id=ABC") || !strings.Contains(rec.rawURL, "showSecretKey=true") { + t.Errorf("rawURL = %q, want id=ABC&showSecretKey=true", rec.rawURL) + } + if got.AccessKeyID != "ABC" { + t.Errorf("got %q, want ABC", got.AccessKeyID) + } +} + +func TestV1_UpdateKey(t *testing.T) { + want := &models.GarageKeyInfo{AccessKeyID: "K1"} + svc, rec := newV1RecordingServer(t, 200, want) + + _, err := svc.UpdateKey(context.Background(), "K1", models.UpdateKeyRequest{}) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q, want POST", rec.method) + } + if !strings.Contains(rec.rawURL, "id=K1") { + t.Errorf("rawURL = %q, want id=K1", rec.rawURL) + } +} + +func TestV1_DeleteKey(t *testing.T) { + svc, rec := newV1RecordingServer(t, 200, nil) + + err := svc.DeleteKey(context.Background(), "K1") + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodDelete { + t.Errorf("method = %q, want DELETE", rec.method) + } + if !strings.Contains(rec.rawURL, "id=K1") { + t.Errorf("rawURL = %q, want id=K1", rec.rawURL) + } +} + +func TestV1_ImportKey(t *testing.T) { + want := &models.GarageKeyInfo{AccessKeyID: "GKimported"} + svc, rec := newV1RecordingServer(t, 200, want) + + got, err := svc.ImportKey(context.Background(), models.ImportKeyRequest{ + AccessKeyID: "GKimported", + SecretAccessKey: "secret", + }) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPost || rec.path != "/v1/key/import" { + t.Errorf("request = %s %s", rec.method, rec.path) + } + if got.AccessKeyID != "GKimported" { + t.Errorf("got %q", got.AccessKeyID) + } +} + +func TestV1_ListBuckets(t *testing.T) { + want := []models.ListBucketsResponseItem{{ID: "b1", GlobalAliases: []string{"mybucket"}}} + svc, rec := newV1RecordingServer(t, 200, want) + + got, err := svc.ListBuckets(context.Background()) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodGet || rec.path != "/v1/bucket" { + t.Errorf("request = %s %s", rec.method, rec.path) + } + if len(got) != 1 || got[0].ID != "b1" { + t.Errorf("got %+v", got) + } +} + +func TestV1_GetBucketInfo(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + + got, err := svc.GetBucketInfo(context.Background(), "b1") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(rec.rawURL, "id=b1") { + t.Errorf("rawURL = %q", rec.rawURL) + } + if got.ID != "b1" { + t.Errorf("got %q", got.ID) + } +} + +func TestV1_GetBucketInfoByAlias(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b2"} + svc, rec := newV1RecordingServer(t, 200, want) + + got, err := svc.GetBucketInfoByAlias(context.Background(), "myalias") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(rec.rawURL, "globalAlias=myalias") { + t.Errorf("rawURL = %q", rec.rawURL) + } + if got.ID != "b2" { + t.Errorf("got %q", got.ID) + } +} + +func TestV1_CreateBucket(t *testing.T) { + want := &models.GarageBucketInfo{ID: "newb"} + svc, rec := newV1RecordingServer(t, 200, want) + + alias := "test-bucket" + got, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{GlobalAlias: &alias}) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPost || rec.path != "/v1/bucket" { + t.Errorf("request = %s %s", rec.method, rec.path) + } + if got.ID != "newb" { + t.Errorf("got %q", got.ID) + } +} + +func TestV1_UpdateBucket(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + + _, err := svc.UpdateBucket(context.Background(), "b1", models.UpdateBucketRequest{}) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPut { + t.Errorf("method = %q, want PUT", rec.method) + } + if !strings.Contains(rec.rawURL, "id=b1") { + t.Errorf("rawURL = %q", rec.rawURL) + } +} + +func TestV1_DeleteBucket(t *testing.T) { + svc, rec := newV1RecordingServer(t, 200, nil) + + err := svc.DeleteBucket(context.Background(), "b1") + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodDelete { + t.Errorf("method = %q, want DELETE", rec.method) + } + if !strings.Contains(rec.rawURL, "id=b1") { + t.Errorf("rawURL = %q", rec.rawURL) + } +} + +func TestV1_AllowBucketKey(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + + _, err := svc.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{ + BucketID: "b1", AccessKeyID: "k1", + Permissions: models.BucketKeyPermission{Read: true}, + }) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPost || rec.path != "/v1/bucket/allow" { + t.Errorf("request = %s %s", rec.method, rec.path) + } +} + +func TestV1_DenyBucketKey(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + + _, err := svc.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{ + BucketID: "b1", AccessKeyID: "k1", + }) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPost || rec.path != "/v1/bucket/deny" { + t.Errorf("request = %s %s", rec.method, rec.path) + } +} + +func TestV1_AddBucketAlias_Global(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + + alias := "myalias" + _, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{ + BucketID: "b1", GlobalAlias: &alias, + }) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/global" { + t.Errorf("request = %s %s", rec.method, rec.path) + } + if !strings.Contains(rec.rawURL, "id=b1") || !strings.Contains(rec.rawURL, "alias=myalias") { + t.Errorf("rawURL = %q", rec.rawURL) + } +} + +func TestV1_AddBucketAlias_Local(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + + alias := "localname" + keyID := "GK1" + _, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{ + BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID, + }) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/local" { + t.Errorf("request = %s %s", rec.method, rec.path) + } +} + +func TestV1_RemoveBucketAlias_Global(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + + alias := "myalias" + _, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{ + BucketID: "b1", GlobalAlias: &alias, + }) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/global" { + t.Errorf("request = %s %s", rec.method, rec.path) + } +} + +func TestV1_GetClusterStatus(t *testing.T) { + raw := map[string]any{ + "node": "abc123", + "garageVersion": "1.3.0", + "knownNodes": []map[string]any{ + {"id": "n1", "addr": "1.2.3.4:3901", "isUp": true, "hostname": "node1"}, + {"id": "n2", "addr": "5.6.7.8:3901", "isUp": false, "lastSeenSecsAgo": 60, "hostname": "node2"}, + }, + "layout": map[string]any{"version": 3}, + } + svc, rec := newV1RecordingServer(t, 200, raw) + + got, err := svc.GetClusterStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if rec.path != "/v1/status" { + t.Errorf("path = %q, want /v1/status", rec.path) + } + if got.LayoutVersion != 3 { + t.Errorf("LayoutVersion = %d, want 3", got.LayoutVersion) + } + if len(got.Nodes) != 2 { + t.Fatalf("len(Nodes) = %d, want 2", len(got.Nodes)) + } + if got.Nodes[0].ID != "n1" || !got.Nodes[0].IsUp { + t.Errorf("Nodes[0] = %+v", got.Nodes[0]) + } + if got.Nodes[1].ID != "n2" || got.Nodes[1].IsUp { + t.Errorf("Nodes[1] = %+v", got.Nodes[1]) + } +} + +func TestV1_HealthCheck(t *testing.T) { + svc, rec := newV1RecordingServer(t, 200, nil) + + err := svc.HealthCheck(context.Background()) + if err != nil { + t.Fatal(err) + } + if rec.path != "/health" { + t.Errorf("path = %q, want /health", rec.path) + } +} + +func TestV1_ErrorPaths(t *testing.T) { + // Server that returns 500 for all requests to exercise error branches. + srv500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + w.Write([]byte(`{"error":"internal"}`)) + })) + t.Cleanup(srv500.Close) + svc := NewGarageV1AdminService(&config.GarageConfig{ + AdminEndpoint: srv500.URL, + AdminToken: "tok", + }, "") + + ctx := context.Background() + + if _, err := svc.ListKeys(ctx); err == nil { + t.Error("ListKeys: expected error") + } + if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil { + t.Error("CreateKey: expected error") + } + if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil { + t.Error("GetKeyInfo: expected error") + } + if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil { + t.Error("UpdateKey: expected error") + } + if err := svc.DeleteKey(ctx, "k"); err == nil { + t.Error("DeleteKey: expected error") + } + if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil { + t.Error("ImportKey: expected error") + } + if _, err := svc.ListBuckets(ctx); err == nil { + t.Error("ListBuckets: expected error") + } + if _, err := svc.GetBucketInfo(ctx, "b"); err == nil { + t.Error("GetBucketInfo: expected error") + } + if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil { + t.Error("GetBucketInfoByAlias: expected error") + } + if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil { + t.Error("CreateBucket: expected error") + } + if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil { + t.Error("UpdateBucket: expected error") + } + if err := svc.DeleteBucket(ctx, "b"); err == nil { + t.Error("DeleteBucket: expected error") + } + if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil { + t.Error("AllowBucketKey: expected error") + } + if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil { + t.Error("DenyBucketKey: expected error") + } + alias := "a" + if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil { + t.Error("AddBucketAlias: expected error") + } + if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil { + t.Error("RemoveBucketAlias: expected error") + } + if _, err := svc.GetClusterHealth(ctx); err == nil { + t.Error("GetClusterHealth: expected error") + } + if _, err := svc.GetClusterStatus(ctx); err == nil { + t.Error("GetClusterStatus: expected error") + } + if err := svc.HealthCheck(ctx); err == nil { + t.Error("HealthCheck: expected error") + } + if _, err := svc.GetMetrics(ctx); err == nil { + t.Error("GetMetrics: expected error") + } +} + +func TestV1_RequestFailurePaths(t *testing.T) { + // Use a cancelled context to make doRequest fail immediately (no retry wait). + svc, _ := newV1RecordingServer(t, 200, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + if _, err := svc.ListKeys(ctx); err == nil { + t.Error("ListKeys: expected error") + } + if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil { + t.Error("CreateKey: expected error") + } + if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil { + t.Error("GetKeyInfo: expected error") + } + if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil { + t.Error("UpdateKey: expected error") + } + if err := svc.DeleteKey(ctx, "k"); err == nil { + t.Error("DeleteKey: expected error") + } + if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil { + t.Error("ImportKey: expected error") + } + if _, err := svc.ListBuckets(ctx); err == nil { + t.Error("ListBuckets: expected error") + } + if _, err := svc.GetBucketInfo(ctx, "b"); err == nil { + t.Error("GetBucketInfo: expected error") + } + if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil { + t.Error("GetBucketInfoByAlias: expected error") + } + if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil { + t.Error("CreateBucket: expected error") + } + if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil { + t.Error("UpdateBucket: expected error") + } + if err := svc.DeleteBucket(ctx, "b"); err == nil { + t.Error("DeleteBucket: expected error") + } + if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil { + t.Error("AllowBucketKey: expected error") + } + if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil { + t.Error("DenyBucketKey: expected error") + } + alias := "a" + if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil { + t.Error("AddBucketAlias: expected error") + } + if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil { + t.Error("RemoveBucketAlias: expected error") + } + if _, err := svc.GetClusterHealth(ctx); err == nil { + t.Error("GetClusterHealth: expected error") + } + if _, err := svc.GetClusterStatus(ctx); err == nil { + t.Error("GetClusterStatus: expected error") + } + if err := svc.HealthCheck(ctx); err == nil { + t.Error("HealthCheck: expected error") + } + if _, err := svc.GetMetrics(ctx); err == nil { + t.Error("GetMetrics: expected error") + } +} + +func TestV1_AddBucketAlias_MissingFields(t *testing.T) { + svc, _ := newV1RecordingServer(t, 200, nil) + // Neither globalAlias nor localAlias set + _, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{BucketID: "b"}) + if err == nil { + t.Fatal("expected error for missing alias fields") + } +} + +func TestV1_RemoveBucketAlias_MissingFields(t *testing.T) { + svc, _ := newV1RecordingServer(t, 200, nil) + _, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{BucketID: "b"}) + if err == nil { + t.Fatal("expected error for missing alias fields") + } +} + +func TestV1_RemoveBucketAlias_Local(t *testing.T) { + want := &models.GarageBucketInfo{ID: "b1"} + svc, rec := newV1RecordingServer(t, 200, want) + alias := "localname" + keyID := "GK1" + _, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{ + BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID, + }) + if err != nil { + t.Fatal(err) + } + if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/local" { + t.Errorf("request = %s %s", rec.method, rec.path) + } +} + +func TestV1_GetMetrics(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(200) + w.Write([]byte("garage_up 1\n")) + })) + t.Cleanup(srv.Close) + svc := NewGarageV1AdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "tok", + }, "") + + got, err := svc.GetMetrics(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "garage_up") { + t.Errorf("got %q", got) + } +} diff --git a/backend/internal/services/admin.go b/backend/internal/services/admin_v2.go similarity index 83% rename from backend/internal/services/admin.go rename to backend/internal/services/admin_v2.go index 97e12fd..bdf7ecd 100644 --- a/backend/internal/services/admin.go +++ b/backend/internal/services/admin_v2.go @@ -15,22 +15,22 @@ import ( "github.com/Noooste/azuretls-client" ) -// GarageAdminService handles interactions with the Garage Admin API -type GarageAdminService struct { +// GarageV2AdminService handles interactions with the Garage Admin API +type GarageV2AdminService struct { baseURL string token string httpClient *azuretls.Session } -// NewGarageAdminService creates a new Garage Admin API service -func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdminService { +// NewGarageV2AdminService creates a new Garage Admin API service +func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV2AdminService { session := azuretls.NewSession() if logLevel == "debug" { session.Log() } - return &GarageAdminService{ + return &GarageV2AdminService{ baseURL: cfg.AdminEndpoint, token: cfg.AdminToken, httpClient: session, @@ -38,7 +38,7 @@ func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdm } // doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors -func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) { +func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) { var resp *azuretls.Response retryConfig := utils.DefaultRetryConfig() @@ -82,7 +82,7 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error { } // ListKeys returns all access keys in the cluster -func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) { +func (s *GarageV2AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) { resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -97,7 +97,7 @@ func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysRes } // CreateKey creates a new API access key -func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) { +func (s *GarageV2AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) { resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -112,7 +112,7 @@ func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKey } // GetKeyInfo returns information about a specific access key -func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) { +func (s *GarageV2AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) { path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID) if showSecret { path += "&showSecretKey=true" @@ -132,7 +132,7 @@ func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showS } // UpdateKey updates information about an access key -func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { +func (s *GarageV2AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID) resp, err := s.doRequest(ctx, http.MethodPost, path, req) @@ -149,7 +149,7 @@ func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req mo } // DeleteKey deletes an access key from the cluster -func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error { +func (s *GarageV2AdminService) DeleteKey(ctx context.Context, keyID string) error { path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID) resp, err := s.doRequest(ctx, http.MethodPost, path, nil) @@ -165,7 +165,7 @@ func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error } // ImportKey imports an existing API access key -func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) { +func (s *GarageV2AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) { resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -180,7 +180,7 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey } // ListBuckets returns all buckets in the cluster. -func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) { +func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) { log := logpkg.FromCtx(ctx).With(). Str("component", "admin"). Str("operation", "list_buckets"). @@ -216,7 +216,7 @@ func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBuck } // GetBucketInfo returns detailed information about a bucket by ID. -func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) { log := logpkg.FromCtx(ctx).With(). Str("component", "admin"). Str("operation", "get_bucket_info"). @@ -243,7 +243,7 @@ func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) } // GetBucketInfoByAlias returns detailed information about a bucket by its global alias. -func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) { log := logpkg.FromCtx(ctx).With(). Str("component", "admin"). Str("operation", "get_bucket_info_by_alias"). @@ -270,7 +270,7 @@ func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAli } // CreateBucket creates a new bucket via the Admin API. -func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { var alias string if req.GlobalAlias != nil { alias = *req.GlobalAlias @@ -301,7 +301,7 @@ func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.Create } // UpdateBucket updates bucket settings. -func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { log := logpkg.FromCtx(ctx).With(). Str("component", "admin"). Str("operation", "update_bucket"). @@ -328,7 +328,7 @@ func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, } // DeleteBucket deletes a bucket. -func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error { +func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string) error { log := logpkg.FromCtx(ctx).With(). Str("component", "admin"). Str("operation", "delete_bucket"). @@ -354,7 +354,7 @@ func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) } // AddBucketAlias adds an alias to a bucket -func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) { resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -369,7 +369,7 @@ func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddB } // RemoveBucketAlias removes an alias from a bucket -func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) { resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -384,7 +384,7 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R } // AllowBucketKey grants permissions for a key on a bucket. -func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { log := logpkg.FromCtx(ctx).With(). Str("component", "admin"). Str("operation", "allow_bucket_key"). @@ -415,7 +415,7 @@ func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.Buck } // DenyBucketKey revokes permissions for a key on a bucket -func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { +func (s *GarageV2AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -430,7 +430,7 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke } // GetClusterHealth returns the health status of the cluster -func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) { +func (s *GarageV2AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) { resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -445,7 +445,7 @@ func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.Clus } // GetClusterStatus returns the current status of the cluster -func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) { +func (s *GarageV2AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) { resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -460,7 +460,7 @@ func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.Clus } // GetClusterStatistics returns global cluster statistics -func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) { +func (s *GarageV2AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) { resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -475,7 +475,7 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models. } // GetNodeInfo returns information about a specific node -func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { +func (s *GarageV2AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID) resp, err := s.doRequest(ctx, http.MethodGet, path, nil) @@ -492,7 +492,7 @@ func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*m } // GetNodeStatistics returns statistics for a specific node -func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { +func (s *GarageV2AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID) resp, err := s.doRequest(ctx, http.MethodGet, path, nil) @@ -509,7 +509,7 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin } // HealthCheck checks if the Admin API is reachable -func (s *GarageAdminService) HealthCheck(ctx context.Context) error { +func (s *GarageV2AdminService) HealthCheck(ctx context.Context) error { resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil) if err != nil { return fmt.Errorf("health check failed: %w", err) @@ -528,7 +528,7 @@ func msSince(t time.Time) float64 { } // GetMetrics returns Prometheus metrics from the Admin API -func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) { +func (s *GarageV2AdminService) GetMetrics(ctx context.Context) (string, error) { resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil) if err != nil { return "", fmt.Errorf("request failed: %w", err) diff --git a/backend/internal/services/admin_test.go b/backend/internal/services/admin_v2_test.go similarity index 95% rename from backend/internal/services/admin_test.go rename to backend/internal/services/admin_v2_test.go index f252cfe..59763a3 100644 --- a/backend/internal/services/admin_test.go +++ b/backend/internal/services/admin_v2_test.go @@ -16,13 +16,13 @@ import ( ) // newAdminTestServer wires an httptest.Server (with the supplied handler) to a -// fresh *GarageAdminService configured with a known bearer token. -func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) { +// fresh *GarageV2AdminService configured with a known bearer token. +func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageV2AdminService, *httptest.Server) { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - svc := NewGarageAdminService(&config.GarageConfig{ + svc := NewGarageV2AdminService(&config.GarageConfig{ AdminEndpoint: srv.URL, AdminToken: "test-token-xyz", }, "") @@ -326,12 +326,12 @@ func TestDeleteBucket_PostWithIDQuery(t *testing.T) { func TestBucketAliasAndPermissionEndpoints(t *testing.T) { cases := []struct { name string - fn func(s *GarageAdminService) error + fn func(s *GarageV2AdminService) error path string }{ { name: "AddBucketAlias", - fn: func(s *GarageAdminService) error { + fn: func(s *GarageV2AdminService) error { _, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{}) return err }, @@ -339,7 +339,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) { }, { name: "RemoveBucketAlias", - fn: func(s *GarageAdminService) error { + fn: func(s *GarageV2AdminService) error { _, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{}) return err }, @@ -347,7 +347,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) { }, { name: "AllowBucketKey", - fn: func(s *GarageAdminService) error { + fn: func(s *GarageV2AdminService) error { _, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{}) return err }, @@ -355,7 +355,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) { }, { name: "DenyBucketKey", - fn: func(s *GarageAdminService) error { + fn: func(s *GarageV2AdminService) error { _, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{}) return err }, @@ -379,12 +379,12 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) { func TestClusterEndpoints(t *testing.T) { cases := []struct { name string - fn func(s *GarageAdminService) error + fn func(s *GarageV2AdminService) error path string }{ { name: "GetClusterHealth", - fn: func(s *GarageAdminService) error { + fn: func(s *GarageV2AdminService) error { _, err := s.GetClusterHealth(context.Background()) return err }, @@ -392,7 +392,7 @@ func TestClusterEndpoints(t *testing.T) { }, { name: "GetClusterStatus", - fn: func(s *GarageAdminService) error { + fn: func(s *GarageV2AdminService) error { _, err := s.GetClusterStatus(context.Background()) return err }, @@ -400,7 +400,7 @@ func TestClusterEndpoints(t *testing.T) { }, { name: "GetClusterStatistics", - fn: func(s *GarageAdminService) error { + fn: func(s *GarageV2AdminService) error { _, err := s.GetClusterStatistics(context.Background()) return err }, @@ -427,12 +427,12 @@ func TestClusterEndpoints(t *testing.T) { func TestNodeEndpoints_NodeIDInQuery(t *testing.T) { cases := []struct { name string - fn func(s *GarageAdminService, id string) error + fn func(s *GarageV2AdminService, id string) error path string }{ { name: "GetNodeInfo", - fn: func(s *GarageAdminService, id string) error { + fn: func(s *GarageV2AdminService, id string) error { _, err := s.GetNodeInfo(context.Background(), id) return err }, @@ -440,7 +440,7 @@ func TestNodeEndpoints_NodeIDInQuery(t *testing.T) { }, { name: "GetNodeStatistics", - fn: func(s *GarageAdminService, id string) error { + fn: func(s *GarageV2AdminService, id string) error { _, err := s.GetNodeStatistics(context.Background(), id) return err }, @@ -576,10 +576,10 @@ func TestAllMethods_Non2xxReturnsError(t *testing.T) { } } -// TestDebugLogLevelEnablesSessionLog exercises the NewGarageAdminService +// TestDebugLogLevelEnablesSessionLog exercises the NewGarageV2AdminService // branch that enables azuretls' session logging when logLevel == "debug". func TestDebugLogLevelEnablesSessionLog(t *testing.T) { - svc := NewGarageAdminService(&config.GarageConfig{ + svc := NewGarageV2AdminService(&config.GarageConfig{ AdminEndpoint: "http://127.0.0.1:1", AdminToken: "t", }, "debug") @@ -598,7 +598,7 @@ func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) { t.Fatalf("close listener: %v", err) } - svc := NewGarageAdminService(&config.GarageConfig{ + svc := NewGarageV2AdminService(&config.GarageConfig{ AdminEndpoint: "http://" + addr, AdminToken: "irrelevant", }, "") diff --git a/backend/internal/services/interfaces.go b/backend/internal/services/interfaces.go index 974506f..cafb8b1 100644 --- a/backend/internal/services/interfaces.go +++ b/backend/internal/services/interfaces.go @@ -9,7 +9,7 @@ import ( ) // AdminService is the set of Garage Admin API operations used by HTTP handlers. -// It is implemented by *GarageAdminService in admin.go. Kept narrow so that +// It is implemented by *GarageV2AdminService in admin_v2.go. Kept narrow so that // hand-rolled mocks in tests don't need to cover admin methods the handlers // never call. type AdminService interface { @@ -65,6 +65,7 @@ type S3Storage interface { // Compile-time guarantees that the concrete services implement the interfaces. var ( - _ AdminService = (*GarageAdminService)(nil) + _ AdminService = (*GarageV2AdminService)(nil) + _ AdminService = (*GarageV1AdminService)(nil) _ S3Storage = (*S3Service)(nil) ) diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index 8755986..0f501de 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -21,11 +21,11 @@ import ( type S3Service struct { client *minio.Client config *config.GarageConfig - adminService *GarageAdminService + adminService AdminService } // NewS3Service creates a new S3 service instance using MinIO SDK -func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service { +func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Service { // Create MinIO client for Garage // trim http or https from endpoint if strings.HasPrefix(cfg.Endpoint, "http://") { diff --git a/backend/internal/services/s3_minio_test.go b/backend/internal/services/s3_minio_test.go index 12e7817..5bc7b1a 100644 --- a/backend/internal/services/s3_minio_test.go +++ b/backend/internal/services/s3_minio_test.go @@ -62,7 +62,7 @@ func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service { srv := httptest.NewServer(combined) t.Cleanup(srv.Close) - admin := NewGarageAdminService(&config.GarageConfig{ + admin := NewGarageV2AdminService(&config.GarageConfig{ AdminEndpoint: srv.URL, AdminToken: "test", }, "") diff --git a/backend/internal/services/s3_test.go b/backend/internal/services/s3_test.go index 8623102..937bbb7 100644 --- a/backend/internal/services/s3_test.go +++ b/backend/internal/services/s3_test.go @@ -56,14 +56,14 @@ func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) { } } -// adminBackedS3 wires an S3Service to a fresh GarageAdminService that talks +// adminBackedS3 wires an S3Service to a fresh GarageV2AdminService that talks // to the supplied http.Handler. func adminBackedS3(t *testing.T, handler http.Handler) (*S3Service, *httptest.Server) { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - admin := NewGarageAdminService(&config.GarageConfig{ + admin := NewGarageV2AdminService(&config.GarageConfig{ AdminEndpoint: srv.URL, AdminToken: "test-token", }, "") diff --git a/backend/main.go b/backend/main.go index d2a7f77..e67d621 100644 --- a/backend/main.go +++ b/backend/main.go @@ -88,8 +88,13 @@ func main() { Msg("Starting Garage UI Backend") // Initialize services - logger.Info().Msg("Initializing Garage Admin service") - adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level) + logger.Info().Msg("Detecting Garage API version") + adminResult, err := services.NewAdminService(&cfg.Garage, cfg.Logging.Level) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API") + } + adminService := adminResult.Service + capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities) logger.Info().Msg("Initializing S3 service") s3Service := services.NewS3Service(&cfg.Garage, adminService) @@ -182,6 +187,7 @@ func main() { userHandler, clusterHandler, monitoringHandler, + capabilitiesHandler, ) // Start server in a goroutine diff --git a/frontend/src/hooks/useCapabilities.ts b/frontend/src/hooks/useCapabilities.ts new file mode 100644 index 0000000..498e2ce --- /dev/null +++ b/frontend/src/hooks/useCapabilities.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { capabilitiesApi } from '@/lib/api'; +import { queryKeys } from '@/lib/query-client'; + +export function useCapabilities() { + return useQuery({ + queryKey: queryKeys.capabilities.get(), + queryFn: () => capabilitiesApi.get(), + staleTime: Infinity, + gcTime: Infinity, + }); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9963098..901d15d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,7 @@ import type { ClusterHealth, ClusterStatistics, ClusterStatus, + GarageCapabilities, GarageMetrics, MultiNodeResponse, MultiNodeStatisticsResponse, @@ -87,6 +88,11 @@ api.interceptors.response.use( return Promise.reject(error); } + // 501 Not Implemented = expected for unsupported Garage version features + if (error.response?.status === 501) { + return Promise.reject(error); + } + // Handle axios errors if (error.response) { // Server responded with error status @@ -167,6 +173,14 @@ export const healthApi = { }, }; +// Capabilities API +export const capabilitiesApi = { + get: async (): Promise => { + const response = await api.get('/v1/capabilities'); + return response.data.data; + }, +}; + // Bucket API export const bucketsApi = { list: async (): Promise => { diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts index 12e3f76..6840d75 100644 --- a/frontend/src/lib/query-client.ts +++ b/frontend/src/lib/query-client.ts @@ -40,4 +40,8 @@ export const queryKeys = { all: ['dashboard'] as const, metrics: () => [...queryKeys.dashboard.all, 'metrics'] as const, }, + capabilities: { + all: ['capabilities'] as const, + get: () => [...queryKeys.capabilities.all, 'get'] as const, + }, }; diff --git a/frontend/src/pages/Cluster.tsx b/frontend/src/pages/Cluster.tsx index 9693306..47dccb7 100644 --- a/frontend/src/pages/Cluster.tsx +++ b/frontend/src/pages/Cluster.tsx @@ -8,10 +8,33 @@ import {Badge} from '@/components/ui/badge'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'; import type {ClusterNode, LocalNodeInfo, NodeStatistics} from '@/types'; import {useState} from 'react'; +import { useCapabilities } from '@/hooks/useCapabilities'; + +function UnsupportedFeatureCard({ title, description }: { title: string; description?: string }) { + return ( + + + + + {title} + + {description && {description}} + + +

+ Requires Garage v2.0+ +

+
+
+ ); +} export function Cluster() { const [selectedNodeId, setSelectedNodeId] = useState(null); + const { data: capabilities } = useCapabilities(); + const features = capabilities?.features; + const { data: health, isLoading: healthLoading } = useQuery({ queryKey: ['cluster-health'], queryFn: () => garageApi.getClusterHealth(), @@ -28,18 +51,19 @@ export function Cluster() { queryKey: ['cluster-statistics'], queryFn: () => garageApi.getClusterStatistics(), refetchInterval: 30000, + enabled: features?.clusterStatistics !== false, }); const { data: nodeInfo, isLoading: nodeInfoLoading } = useQuery({ queryKey: ['node-info', selectedNodeId || '*'], queryFn: () => garageApi.getNodeInfo(selectedNodeId || '*'), - enabled: !!selectedNodeId || selectedNodeId === null, + enabled: features?.nodeInfo !== false && (!!selectedNodeId || selectedNodeId === null), }); const { data: nodeStats } = useQuery({ queryKey: ['node-statistics', selectedNodeId || '*'], queryFn: () => garageApi.getNodeStatistics(selectedNodeId || '*'), - enabled: !!selectedNodeId, + enabled: features?.nodeStatistics !== false && !!selectedNodeId, }); const isLoading = healthLoading || statusLoading || statisticsLoading; @@ -311,119 +335,129 @@ export function Cluster() { {/* Statistics Tab */} - - - Cluster Statistics - - Detailed statistics and metrics from the Garage cluster - - - - {statistics ? ( -
-
-
-                        {statistics.freeform}
-                      
+ {features?.clusterStatistics === false ? ( + + ) : ( + + + Cluster Statistics + + Detailed statistics and metrics from the Garage cluster + + + + {statistics ? ( +
+
+
+                          {statistics.freeform}
+                        
+
-
- ) : ( -
- -

No statistics available

-
- )} - - + ) : ( +
+ +

No statistics available

+
+ )} + + + )} {/* Details Tab */} {selectedNodeId ? ( <> - - - Node Information - - Detailed information for node: {selectedNodeId.substring(0, 16)}... - - - - {nodeInfoLoading ? ( -
-
-

Loading node info...

-
- ) : nodeInfo ? ( -
- {/* Success responses */} - {Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => ( -
-
- -

- Node: {nodeId.substring(0, 16)}... -

-
- -
-
-
Node ID
-
{(info as LocalNodeInfo).nodeId}
+ {features?.nodeInfo === false ? ( + + ) : ( + + + Node Information + + Detailed information for node: {selectedNodeId.substring(0, 16)}... + + + + {nodeInfoLoading ? ( +
+
+

Loading node info...

+
+ ) : nodeInfo ? ( +
+ {/* Success responses */} + {Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => ( +
+
+ +

+ Node: {nodeId.substring(0, 16)}... +

-
-
Garage Version
-
{(info as LocalNodeInfo).garageVersion}
-
+
+
+
Node ID
+
{(info as LocalNodeInfo).nodeId}
+
-
-
Rust Version
-
{(info as LocalNodeInfo).rustVersion}
-
+
+
Garage Version
+
{(info as LocalNodeInfo).garageVersion}
+
-
-
Database Engine
-
{(info as LocalNodeInfo).dbEngine}
-
-
+
+
Rust Version
+
{(info as LocalNodeInfo).rustVersion}
+
- {(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && ( -
-
Garage Features
-
- {(info as LocalNodeInfo).garageFeatures!.map((feature) => ( - - {feature} - - ))} +
+
Database Engine
+
{(info as LocalNodeInfo).dbEngine}
- )} -
- ))} - {/* Error responses */} - {Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => ( -
-
- -
Error for node {nodeId.substring(0, 16)}...
+ {(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && ( +
+
Garage Features
+
+ {(info as LocalNodeInfo).garageFeatures!.map((feature) => ( + + {feature} + + ))} +
+
+ )}
-
{error}
-
- ))} -
- ) : ( -
- -

No node information available

-
- )} - - + ))} - {nodeStats && ( + {/* Error responses */} + {Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => ( +
+
+ +
Error for node {nodeId.substring(0, 16)}...
+
+
{error}
+
+ ))} +
+ ) : ( +
+ +

No node information available

+
+ )} +
+
+ )} + + {features?.nodeStatistics === false ? ( + + ) : nodeStats ? ( Node Statistics @@ -464,7 +498,7 @@ export function Cluster() {
- )} + ) : null} ) : ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dcad9ed..a3518bf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -232,6 +232,15 @@ export interface MultiNodeStatisticsResponse { error: Record; } +export interface GarageCapabilities { + garageApiVersion: string; + features: { + clusterStatistics: boolean; + nodeInfo: boolean; + nodeStatistics: boolean; + }; +} + export interface NodeInfo { nodeId: string; version: string;