Compare commits

..

13 Commits

Author SHA1 Message Date
Noooste b8061e5109 test(handlers): cover upstream error mapping to API response codes 2026-04-20 00:23:55 +02:00
Noooste cfa13be75b refactor(handlers): use apierr.Respond for key/permission upstream errors 2026-04-20 00:21:49 +02:00
Noooste adb430c2b2 refactor(handlers): use apierr.Respond for object upstream errors 2026-04-20 00:20:15 +02:00
Noooste 849840b808 refactor(handlers): use apierr.Respond for bucket upstream errors 2026-04-20 00:18:15 +02:00
Noooste 23d63be980 refactor(services): return *UpstreamError from S3 operations via FromMinio 2026-04-20 00:16:13 +02:00
Noooste 5b12c8121f refactor(services): propagate UpstreamError unchanged from admin callers
Strip redundant fmt.Errorf wrapping at every decodeResponse call site so
the typed *apierr.UpstreamError propagates directly to handlers without
an extra indirection layer.
2026-04-20 00:12:48 +02:00
Noooste 785d21d38c feat(ci): update Dockerfile and CI configuration for backend image and caching
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-20 00:12:07 +02:00
Noooste c2e99afb8b refactor(services): return *UpstreamError from decodeResponse
Replace the ad-hoc fmt.Errorf non-2xx branch with apierr.ParseGarage so
decodeResponse now returns a typed *apierr.UpstreamError on error.
Update two admin_test.go tests that string-matched the old error shape to
assert UpstreamError fields (HTTPStatus, Message) instead.
2026-04-20 00:09:01 +02:00
Noooste 691ed8db4d feat(apierr): map upstream codes to API status/code and add Respond helper 2026-04-20 00:05:59 +02:00
Noooste caf8d9fd2e feat(apierr): convert MinIO errors to UpstreamError via FromMinio 2026-04-20 00:02:53 +02:00
Noooste f2cc660c8f feat(apierr): parse Garage structured error responses into UpstreamError 2026-04-20 00:01:31 +02:00
Noooste 5c2e5dbf6f feat(apierr): add UpstreamError type for typed upstream failures 2026-04-19 23:59:54 +02:00
Noooste adfd044798 feat(api): add BUCKET_NOT_EMPTY/KEY_NOT_FOUND/INVALID_REQUEST error codes 2026-04-19 23:58:34 +02:00
70 changed files with 1276 additions and 3342 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 344 KiB

-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+11 -5
View File
@@ -14,9 +14,11 @@ jobs:
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
include:
- platform: linux/amd64
arch: amd64
- platform: linux/arm64
arch: arm64
steps:
- name: Checkout repository
@@ -52,8 +54,12 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }}
cache-to: |
type=gha,mode=max,scope=build-${{ matrix.arch }}
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }},mode=max
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
+15 -5
View File
@@ -11,9 +11,6 @@ on:
push:
branches: [main]
permissions:
contents: read
jobs:
unit:
name: Unit tests + coverage gate
@@ -30,13 +27,13 @@ jobs:
- name: Generate swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd backend && swag init
- name: Run unit tests with race detector and coverage
run: |
cd backend
go test -race -count=1 -coverprofile=../coverage.out -coverpkg=./... ./...
go test -race -coverprofile=../coverage.out -coverpkg=./... ./...
- name: Enforce coverage gate
run: bash scripts/coverage-gate.sh coverage.out
@@ -69,6 +66,19 @@ jobs:
cache: true
cache-dependency-path: backend/go.sum
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Pre-build backend image with layer cache
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
load: true
tags: garage-ui-smoke-backend:ci
cache-from: type=gha,scope=smoke-backend
cache-to: type=gha,mode=max,scope=smoke-backend
- name: Run smoke test
run: make test-smoke
+2 -2
View File
@@ -52,7 +52,7 @@ dist-ssr
*.sw?
.env*
!config.example.yaml
!config.yaml.example
docker-compose.*.yml
!backend/tests/smoke/docker-compose.test.yml
@@ -64,6 +64,6 @@ garage.toml
backend/docs/
config.yaml
docs/**/*.md
!docs/garage-setup.md
# Superpowers brainstorm / session scratch
.superpowers/
+1 -1
View File
@@ -20,7 +20,7 @@ WORKDIR /app
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/swaggo/swag/cmd/swag@v1.16.4
COPY backend/go.mod backend/go.sum ./
+88 -93
View File
@@ -1,15 +1,13 @@
<p align="center">
<a href="https://github.com/Noooste/garage-ui/actions/workflows/build.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg" alt="Docker Build" /></a>
<a href="https://github.com/Noooste/garage-ui/actions/workflows/release.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg" alt="Helm Chart" /></a>
<a href="https://codecov.io/gh/Noooste/garage-ui"><img src="https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg" alt="Coverage" /></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go" alt="Go Version" /></a>
<a href="https://artifacthub.io/packages/search?repo=garage-ui"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui" alt="Artifact Hub" /></a>
</p>
# Garage UI
# Garage UI - Web Dashboard for Garage S3 Storage
A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object storage clusters.
A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garage</a> object storage clusters. Browse buckets, manage access keys, monitor your cluster, all from your browser.
[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
[![Coverage](https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg)](https://codecov.io/gh/Noooste/garage-ui)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
---
@@ -26,30 +24,53 @@ A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garag
## Features
- **Bucket management** - create, configure, and browse buckets with drag-and-drop file uploads
- **Access key management** - create keys, assign per-bucket permissions
- **Cluster overview** - monitor node status, layout configuration, and storage usage
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
- **Easy deployment** - single Docker image or Helm chart, configure with one YAML file
- Bucket and object management
- User access control
- Cluster monitoring
- Multiple authentication options (none/basic/OIDC)
- Drag-and-drop file uploads
## Quick Start
### Prerequisites
- Docker & Docker Compose
- A running Garage cluster (v2.1.0+) - [setup guide](docs/garage-setup.md) if you need one
- Garage S3 cluster (v2.1.0+) or use the included setup
### 1. Clone & Configure
### 1. Clone & Setup
```bash
git clone https://github.com/Noooste/garage-ui.git
cd garage-ui
cp config.example.yaml config.yaml
```
### 2. Start Garage
If you don't have Garage running:
```bash
docker compose up -d garage
sleep 10
# Initialize cluster
docker compose exec garage garage layout assign -z dc1 -c 1G $(docker compose exec garage garage node id -q)
docker compose exec garage garage layout apply --version 1
# Create admin key
docker compose exec garage garage key create admin-key
```
Save the access key and secret key from the output.
### 3. Configure
```bash
cp config.yaml.example config.yaml
```
Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml`).
### 2. Start
### 4. Start UI
```bash
docker compose up -d garage-ui
@@ -57,6 +78,43 @@ docker compose up -d garage-ui
Access at http://localhost:8080
## Configuration
Minimum required config:
```yaml
server:
port: 8080
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
region: "garage"
```
Enable authentication (optional):
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-password"
```
See [config.yaml.example](config.yaml.example) for all options.
### Environment Variables
Override any config value with `GARAGE_UI_` prefix:
```bash
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
```
## Deployment
### Docker
@@ -77,67 +135,22 @@ helm install garage-ui garage-ui/garage-ui \
--set garage.adminToken=your-token
```
Access at http://localhost:8080
### Quick Start with garage.toml
If you already have a running Garage instance, you can point Garage UI directly at your `garage.toml` -- no `config.yaml` needed:
## Development
Backend (Go 1.25+):
```bash
./garage-ui --garage-toml /etc/garage.toml
cd backend
go run main.go --config ../config.yaml
```
Garage UI reads the S3 endpoint, admin endpoint, admin token, and S3 region straight from the TOML file. When no authentication method is explicitly configured, **token auth auto-enables**: the login page asks for the Garage admin token, giving you a login wall with zero extra config.
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside containers this won't work -- override the endpoint explicitly with environment variables or a config file.
**Docker:**
Frontend (Node.js 25+):
```bash
docker run -d -p 8080:8080 \
-v /etc/garage.toml:/etc/garage.toml:ro \
-e GARAGE_UI_GARAGE_TOML=/etc/garage.toml \
-e GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900 \
-e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903 \
noooste/garage-ui:latest
cd frontend
npm install
npm run dev
```
The endpoint overrides are needed because the container cannot reach `127.0.0.1` on the host.
**Combining flags:** Use `--garage-toml` for Garage connection values and `--config` for everything else (auth, CORS, logging, etc.):
```bash
./garage-ui --garage-toml /etc/garage.toml --config config.yaml
```
**Precedence order** (highest wins): built-in defaults < `garage.toml` < `config.yaml` < environment variables.
## Configuration
Minimum required config:
```yaml
server:
port: 8080
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
region: "garage"
```
See [config.example.yaml](config.example.yaml) for all options including authentication, CORS, and logging.
### Environment Variables
Override any config value with `GARAGE_UI_` prefix:
```bash
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
```
API docs: http://localhost:8080/api/v1/
## Garage Configuration
@@ -159,23 +172,6 @@ api_bind_addr = "[::]:3900" # Default: 127.0.0.1:3900
For complete Garage configuration, see the [official documentation](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/).
## Development
Backend (Go 1.25+):
```bash
cd backend
go run main.go --config ../config.yaml
```
Frontend (Node.js 25+):
```bash
cd frontend
npm install
npm run dev
```
API docs: http://localhost:8080/api/v1/
## Troubleshooting
**Connection failed:**
@@ -214,12 +210,11 @@ Ideas being considered. Contributions welcome.
- [ ] Per-bucket usage graph over time
**Access keys**
- [ ] Permission matrix view (keys x buckets)
- [ ] Permission matrix view (keys × buckets)
- [ ] Key rotation helper
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
**Cluster**
- [X] Support Garage v1 to latest
- [ ] Visual layout editor with staged vs. applied diff
- [ ] Capacity planner / simulation
- [ ] Rebalance progress and node health timeline
@@ -244,4 +239,4 @@ MIT - see [LICENSE](LICENSE)
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
+7 -3
View File
@@ -10,9 +10,9 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.0.100
github.com/pelletier/go-toml/v2 v2.3.0
github.com/rs/zerolog v1.35.0
github.com/spf13/viper v1.21.0
github.com/swaggo/swag v1.16.6
golang.org/x/oauth2 v0.36.0
)
@@ -36,6 +36,7 @@ require (
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
@@ -47,24 +48,27 @@ require (
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.3 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
@@ -76,5 +80,5 @@ require (
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+100 -17
View File
@@ -2,6 +2,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ=
github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs=
github.com/Noooste/azuretls-client v1.13.2 h1:8Dli5aKP5O6qN/FNSGFVkpQ1V1F1gGawAkLIE2Nrk+U=
github.com/Noooste/azuretls-client v1.13.2/go.mod h1:ON+SmiBm4Zy5vAhJmBNZk61Y7nqf4iM/b1MC1lN47Bk=
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
@@ -10,57 +12,80 @@ github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FY
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE=
github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4=
github.com/Noooste/uquic-go v1.0.5 h1:HWfrxhxgB1a9Y2Au5mfFs2Y5Dy13OQIwa86D/kULPtE=
github.com/Noooste/uquic-go v1.0.5/go.mod h1:1y+qiy23PqLKudi4kQiJ0b3zXXYcyctEBRfZPTuyBz4=
github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
github.com/Noooste/utls v1.3.21 h1:5yEzTibikzF0/d0REfbjXURGHxJDKCrRghVAU/OQBko=
github.com/Noooste/utls v1.3.21/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA=
github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA=
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
@@ -69,19 +94,22 @@ github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFu
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/gofiber/utils/v2 v2.0.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI=
github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -90,13 +118,19 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
@@ -105,40 +139,63 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -155,52 +212,78 @@ github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+23
View File
@@ -0,0 +1,23 @@
// Package apierr translates upstream (Garage Admin API, S3/MinIO) errors into
// backend API error responses with correct HTTP status codes and stable codes.
package apierr
import "fmt"
// UpstreamError is a typed error describing a failure reported by an upstream
// service. Returned from the services layer and consumed by handlers via Map
// or Respond.
type UpstreamError struct {
HTTPStatus int
Code string // upstream code, e.g. "BucketNotEmpty", "NoSuchKey"
Message string // human-readable upstream message
Source string // "garage" or "s3"
Details map[string]string // optional: region, path, bucket, key
}
func (e *UpstreamError) Error() string {
if e.Code == "" {
return fmt.Sprintf("%s: %s", e.Source, e.Message)
}
return fmt.Sprintf("%s %s: %s", e.Source, e.Code, e.Message)
}
+40
View File
@@ -0,0 +1,40 @@
package apierr
import (
"errors"
"testing"
)
func TestUpstreamError_Error(t *testing.T) {
e := &UpstreamError{
HTTPStatus: 409,
Code: "BucketNotEmpty",
Message: "Tried to delete a non-empty bucket",
Source: "garage",
}
got := e.Error()
want := "garage BucketNotEmpty: Tried to delete a non-empty bucket"
if got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
}
func TestUpstreamError_ErrorWithoutCode(t *testing.T) {
e := &UpstreamError{HTTPStatus: 500, Source: "garage", Message: "server went boom"}
got := e.Error()
want := "garage: server went boom"
if got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
}
func TestUpstreamError_ErrorsAs(t *testing.T) {
var base error = &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Source: "garage"}
var target *UpstreamError
if !errors.As(base, &target) {
t.Fatal("errors.As should have matched *UpstreamError")
}
if target.Code != "NoSuchBucket" {
t.Fatalf("Code = %q, want NoSuchBucket", target.Code)
}
}
+59
View File
@@ -0,0 +1,59 @@
package apierr
import (
"errors"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// codeEntry defines how an upstream code translates to the API surface.
type codeEntry struct {
HTTPStatus int
APICode string
}
// upstreamCodeTable maps upstream codes (Garage + S3 share the same naming for
// most codes) to (httpStatus, apiCode). Extend here as new upstream codes are
// discovered.
var upstreamCodeTable = map[string]codeEntry{
"BucketNotEmpty": {409, models.ErrCodeBucketNotEmpty},
"NoSuchBucket": {404, models.ErrCodeBucketNotFound},
"BucketAlreadyExists": {409, models.ErrCodeBucketExists},
"BucketAlreadyOwnedByYou": {409, models.ErrCodeBucketExists},
"NoSuchKey": {404, models.ErrCodeObjectNotFound},
"InvalidBucketName": {400, models.ErrCodeInvalidBucketName},
"AccessDenied": {403, models.ErrCodeForbidden},
}
// Map translates an error into (httpStatus, apiCode, message) for the API
// response. Falls back to 500 / INTERNAL_ERROR for non-UpstreamError values.
func Map(err error) (int, string, string) {
var ue *UpstreamError
if !errors.As(err, &ue) {
return 500, models.ErrCodeInternalError, err.Error()
}
if entry, ok := upstreamCodeTable[ue.Code]; ok {
return entry.HTTPStatus, entry.APICode, ue.Message
}
status := ue.HTTPStatus
if status == 0 {
status = 500
}
msg := ue.Message
if msg == "" {
msg = ue.Error()
}
return status, models.ErrCodeInternalError, msg
}
// Respond writes the mapped error as a Fiber JSON response using the standard
// APIResponse envelope. Handlers should call this from their err != nil
// branches for all upstream failures.
func Respond(c fiber.Ctx, err error) error {
status, code, msg := Map(err)
return c.Status(status).JSON(models.ErrorResponse(code, msg))
}
+104
View File
@@ -0,0 +1,104 @@
package apierr
import (
"errors"
"testing"
"Noooste/garage-ui/internal/models"
)
func TestMap_TableDriven(t *testing.T) {
cases := []struct {
name string
err error
wantStatus int
wantCode string
wantMsg string
}{
{
name: "BucketNotEmpty",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketNotEmpty", Message: "Tried to delete a non-empty bucket", Source: "garage"},
wantStatus: 409,
wantCode: models.ErrCodeBucketNotEmpty,
wantMsg: "Tried to delete a non-empty bucket",
},
{
name: "NoSuchBucket",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Message: "missing", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeBucketNotFound,
wantMsg: "missing",
},
{
name: "BucketAlreadyExists",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyExists", Message: "dup", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "dup",
},
{
name: "BucketAlreadyOwnedByYou",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyOwnedByYou", Message: "yours", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "yours",
},
{
name: "NoSuchKey",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchKey", Message: "gone", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeObjectNotFound,
wantMsg: "gone",
},
{
name: "InvalidBucketName",
err: &UpstreamError{HTTPStatus: 400, Code: "InvalidBucketName", Message: "bad", Source: "s3"},
wantStatus: 400,
wantCode: models.ErrCodeInvalidBucketName,
wantMsg: "bad",
},
{
name: "AccessDenied",
err: &UpstreamError{HTTPStatus: 403, Code: "AccessDenied", Message: "nope", Source: "garage"},
wantStatus: 403,
wantCode: models.ErrCodeForbidden,
wantMsg: "nope",
},
{
name: "UnknownCodeWithStatus",
err: &UpstreamError{HTTPStatus: 503, Code: "SomethingWeird", Message: "weird", Source: "garage"},
wantStatus: 503,
wantCode: models.ErrCodeInternalError,
wantMsg: "weird",
},
{
name: "UnknownCodeNoStatus",
err: &UpstreamError{HTTPStatus: 0, Code: "", Message: "boom", Source: "garage"},
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "boom",
},
{
name: "NonUpstreamError",
err: errors.New("plain"),
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "plain",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, code, msg := Map(tc.err)
if status != tc.wantStatus {
t.Errorf("status = %d, want %d", status, tc.wantStatus)
}
if code != tc.wantCode {
t.Errorf("code = %q, want %q", code, tc.wantCode)
}
if msg != tc.wantMsg {
t.Errorf("msg = %q, want %q", msg, tc.wantMsg)
}
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package apierr
import (
"encoding/json"
"errors"
"io"
"github.com/Noooste/azuretls-client"
"github.com/minio/minio-go/v7"
)
// garageErrBody mirrors Garage's JSON error envelope.
type garageErrBody struct {
Code string `json:"code"`
Message string `json:"message"`
Region string `json:"region"`
Path string `json:"path"`
}
// ParseGarage returns nil for 2xx responses. For non-2xx it reads the body
// once, JSON-decodes the structured Garage error envelope, and returns a
// *UpstreamError. Malformed bodies are preserved verbatim in Message.
//
// ParseGarage DOES NOT close resp.RawBody on the success path — callers that
// decode the success body (decodeResponse in services/admin.go) still need
// access and are responsible for closing. On the error path the body is fully
// consumed before return.
func ParseGarage(resp *azuretls.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
bodyBytes, _ := io.ReadAll(resp.RawBody)
ue := &UpstreamError{
HTTPStatus: resp.StatusCode,
Source: "garage",
}
var parsed garageErrBody
if len(bodyBytes) > 0 && json.Unmarshal(bodyBytes, &parsed) == nil && parsed.Code != "" {
ue.Code = parsed.Code
ue.Message = parsed.Message
ue.Details = map[string]string{}
if parsed.Region != "" {
ue.Details["region"] = parsed.Region
}
if parsed.Path != "" {
ue.Details["path"] = parsed.Path
}
} else {
ue.Message = string(bodyBytes)
}
return ue
}
// FromMinio converts a MinIO/S3 error into an *UpstreamError. Returns nil when
// err is nil. If err is not a minio.ErrorResponse (e.g. a network error), a
// generic 500 *UpstreamError is returned with the raw error string as Message.
func FromMinio(err error) *UpstreamError {
if err == nil {
return nil
}
var mer minio.ErrorResponse
if errors.As(err, &mer) {
details := map[string]string{}
if mer.BucketName != "" {
details["bucket"] = mer.BucketName
}
if mer.Key != "" {
details["key"] = mer.Key
}
status := mer.StatusCode
if status == 0 {
status = 500
}
return &UpstreamError{
HTTPStatus: status,
Code: mer.Code,
Message: mer.Message,
Source: "s3",
Details: details,
}
}
return &UpstreamError{
HTTPStatus: 500,
Source: "s3",
Message: err.Error(),
}
}
+134
View File
@@ -0,0 +1,134 @@
package apierr
import (
"bytes"
"errors"
"io"
"strings"
"testing"
"github.com/Noooste/azuretls-client"
"github.com/minio/minio-go/v7"
)
func fakeResp(status int, body string) *azuretls.Response {
return &azuretls.Response{
StatusCode: status,
RawBody: io.NopCloser(bytes.NewBufferString(body)),
}
}
func TestParseGarage_Success(t *testing.T) {
resp := fakeResp(200, `{"foo":"bar"}`)
if err := ParseGarage(resp); err != nil {
t.Fatalf("ParseGarage(2xx) returned %v, want nil", err)
}
}
func TestParseGarage_StructuredError(t *testing.T) {
body := `{"code":"BucketNotEmpty","message":"Tried to delete a non-empty bucket","region":"eu-west-1","path":"/v2/DeleteBucket"}`
resp := fakeResp(409, body)
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 409 {
t.Errorf("HTTPStatus = %d, want 409", ue.HTTPStatus)
}
if ue.Code != "BucketNotEmpty" {
t.Errorf("Code = %q, want BucketNotEmpty", ue.Code)
}
if ue.Message != "Tried to delete a non-empty bucket" {
t.Errorf("Message = %q", ue.Message)
}
if ue.Source != "garage" {
t.Errorf("Source = %q, want garage", ue.Source)
}
if ue.Details["region"] != "eu-west-1" || ue.Details["path"] != "/v2/DeleteBucket" {
t.Errorf("Details = %v", ue.Details)
}
}
func TestParseGarage_MalformedBody(t *testing.T) {
resp := fakeResp(500, "not json at all")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 500 || ue.Code != "" {
t.Errorf("HTTPStatus=%d Code=%q, want 500 and empty code", ue.HTTPStatus, ue.Code)
}
if !strings.Contains(ue.Message, "not json at all") {
t.Errorf("Message = %q, expected raw body", ue.Message)
}
}
func TestParseGarage_EmptyBody(t *testing.T) {
resp := fakeResp(502, "")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 502 {
t.Errorf("HTTPStatus = %d, want 502", ue.HTTPStatus)
}
}
func TestFromMinio_NilInput(t *testing.T) {
if got := FromMinio(nil); got != nil {
t.Fatalf("FromMinio(nil) = %v, want nil", got)
}
}
func TestFromMinio_MinioErrorResponse(t *testing.T) {
in := minio.ErrorResponse{
StatusCode: 404,
Code: "NoSuchBucket",
Message: "The specified bucket does not exist",
BucketName: "missing",
}
got := FromMinio(in)
if got == nil {
t.Fatal("FromMinio returned nil")
}
if got.HTTPStatus != 404 {
t.Errorf("HTTPStatus = %d, want 404", got.HTTPStatus)
}
if got.Code != "NoSuchBucket" {
t.Errorf("Code = %q, want NoSuchBucket", got.Code)
}
if got.Message != "The specified bucket does not exist" {
t.Errorf("Message = %q", got.Message)
}
if got.Source != "s3" {
t.Errorf("Source = %q, want s3", got.Source)
}
if got.Details["bucket"] != "missing" {
t.Errorf("Details[bucket] = %q, want missing", got.Details["bucket"])
}
}
func TestFromMinio_NonMinioError(t *testing.T) {
got := FromMinio(errors.New("connection refused"))
if got == nil {
t.Fatal("FromMinio returned nil")
}
if got.HTTPStatus != 500 {
t.Errorf("HTTPStatus = %d, want 500", got.HTTPStatus)
}
if got.Code != "" {
t.Errorf("Code = %q, want empty", got.Code)
}
if !strings.Contains(got.Message, "connection refused") {
t.Errorf("Message = %q", got.Message)
}
if got.Source != "s3" {
t.Errorf("Source = %q, want s3", got.Source)
}
}
+7 -30
View File
@@ -4,11 +4,9 @@ import (
"Noooste/garage-ui/pkg/logger"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"Noooste/garage-ui/internal/config"
@@ -24,7 +22,6 @@ type Service struct {
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
oidcClient *http.Client
jwtService *JWTService
}
@@ -61,15 +58,7 @@ func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig)
// initOIDC initializes the OIDC provider and configuration
func (a *Service) initOIDC() error {
if a.authConfig.OIDC.TLSSkipVerify {
a.oidcClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
}
ctx := a.oidcContext(context.Background())
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
@@ -103,13 +92,6 @@ func (a *Service) initOIDC() error {
return nil
}
func (a *Service) oidcContext(ctx context.Context) context.Context {
if a.oidcClient == nil {
return ctx
}
return oidc.ClientContext(ctx, a.oidcClient)
}
// ValidateBasicAuth validates basic authentication credentials
func (a *Service) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
@@ -141,7 +123,7 @@ func (a *Service) ExchangeCode(ctx context.Context, code string) (*oauth2.Token,
return nil, fmt.Errorf("OIDC not initialized")
}
token, err := a.oauth2Config.Exchange(a.oidcContext(ctx), code)
token, err := a.oauth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
@@ -156,7 +138,7 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn
}
// Verify the ID token
idToken, err := a.oidcVerifier.Verify(a.oidcContext(ctx), rawIDToken)
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
@@ -188,8 +170,6 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
return nil, fmt.Errorf("OIDC not initialized")
}
ctx = a.oidcContext(ctx)
// Create OAuth2 token source
tokenSource := a.oauth2Config.TokenSource(ctx, token)
@@ -252,18 +232,15 @@ func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
// IsAdmin checks if the user has any of the configured admin roles.
// IsAdmin checks if the user has admin role
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
if len(adminRoles) == 0 {
if a.authConfig.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
for _, adminRole := range adminRoles {
if role == adminRole {
return true
}
if role == a.authConfig.OIDC.AdminRole {
return true
}
}
+10 -102
View File
@@ -268,86 +268,6 @@ func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) {
}
}
// newTLSDiscoveryServer is the same as newDiscoveryServer but serves the OIDC
// discovery document over HTTPS using httptest's self-signed certificate. The
// cert is not signed by any system-trusted CA, so any HTTP client without
// InsecureSkipVerify (or the cert pinned) will fail to connect.
func newTLSDiscoveryServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
doc := map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/auth",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"id_token_signing_alg_values_supported": []string{"RS256", "EdDSA"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"keys":[]}`))
})
srv = httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestNewAuthService_OIDCEnabled_SelfSignedIssuer_FailsWithoutTLSSkipVerify(t *testing.T) {
disco := newTLSDiscoveryServer(t)
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid"},
TLSSkipVerify: false,
},
}
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
_, err := NewAuthService(authCfg, srvCfg)
if err == nil {
t.Fatal("expected TLS verification error from self-signed issuer, got nil")
}
if !strings.Contains(err.Error(), "failed to initialize OIDC") {
t.Errorf("expected wrapping error, got %v", err)
}
}
func TestNewAuthService_OIDCEnabled_SelfSignedIssuer_SucceedsWithTLSSkipVerify(t *testing.T) {
disco := newTLSDiscoveryServer(t)
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid"},
TLSSkipVerify: true,
},
}
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
svc, err := NewAuthService(authCfg, srvCfg)
if err != nil {
t.Fatalf("NewAuthService with tls_skip_verify=true should succeed: %v", err)
}
if svc.oidcProvider == nil {
t.Fatal("oidcProvider not initialized")
}
if svc.oidcClient == nil {
t.Fatal("oidcClient should be set when tls_skip_verify=true")
}
}
func TestNewAuthService_OIDCEnabled_BadIssuerURLReturnsError(t *testing.T) {
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
@@ -609,35 +529,23 @@ func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) {
func TestIsAdmin(t *testing.T) {
tests := []struct {
name string
adminRole string
adminRoles []string
userRoles []string
want bool
name string
adminRole string
userRoles []string
want bool
}{
{"empty admin role config returns false", "", nil, []string{"admin"}, false},
{"user has admin role", "admin", nil, []string{"viewer", "admin"}, true},
{"user lacks admin role", "admin", nil, []string{"viewer"}, false},
{"user has no roles", "admin", nil, nil, false},
{"role match is exact (case-sensitive)", "admin", nil, []string{"Admin"}, false},
// admin_roles list
{"user matches first entry in admin_roles", "", []string{"group1", "group2"}, []string{"group1"}, true},
{"user matches second entry in admin_roles", "", []string{"group1", "group2"}, []string{"group2"}, true},
{"user matches none of admin_roles", "", []string{"group1", "group2"}, []string{"group3"}, false},
{"empty admin_roles list returns false", "", []string{}, []string{"group1"}, false},
// admin_role + admin_roles merge
{"matches single admin_role when admin_roles set too", "admin", []string{"ops"}, []string{"admin"}, true},
{"matches admin_roles entry when admin_role set too", "admin", []string{"ops"}, []string{"ops"}, true},
{"matches neither admin_role nor admin_roles", "admin", []string{"ops"}, []string{"viewer"}, false},
{"empty admin role config returns false", "", []string{"admin"}, false},
{"user has admin role", "admin", []string{"viewer", "admin"}, true},
{"user lacks admin role", "admin", []string{"viewer"}, false},
{"user has no roles", "admin", nil, false},
{"role match is exact (case-sensitive)", "admin", []string{"Admin"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles},
OIDC: config.OIDCConfig{AdminRole: tc.adminRole},
},
}
if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want {
-29
View File
@@ -1,29 +0,0 @@
package auth
import (
"crypto/subtle"
"testing"
)
func TestValidateAdminToken(t *testing.T) {
tests := []struct {
name string
configured string
provided string
want bool
}{
{"correct token", "my-secret-token", "my-secret-token", true},
{"wrong token", "my-secret-token", "wrong-token", false},
{"empty provided", "my-secret-token", "", false},
{"empty configured", "", "any-token", false},
{"both empty", "", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := subtle.ConstantTimeCompare([]byte(tc.configured), []byte(tc.provided)) == 1
if got != tc.want {
t.Errorf("ValidateAdminToken(%q, %q) = %v, want %v", tc.configured, tc.provided, got, tc.want)
}
})
}
}
+6 -93
View File
@@ -46,7 +46,6 @@ type GarageConfig struct {
type AuthConfig struct {
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
Token TokenAuthConfig `mapstructure:"token"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
}
@@ -57,12 +56,6 @@ type AdminAuthConfig struct {
Password string `mapstructure:"password"`
}
// TokenAuthConfig contains admin token authentication settings.
// When enabled, users can log in using the Garage admin token.
type TokenAuthConfig struct {
Enabled bool `mapstructure:"enabled"`
}
// OIDCConfig contains OIDC authentication settings
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -78,7 +71,6 @@ type OIDCConfig struct {
NameAttribute string `mapstructure:"name_attribute"`
RoleAttributePath string `mapstructure:"role_attribute_path"`
AdminRole string `mapstructure:"admin_role"`
AdminRoles []string `mapstructure:"admin_roles"`
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
SessionMaxAge int `mapstructure:"session_max_age"`
CookieName string `mapstructure:"cookie_name"`
@@ -87,29 +79,6 @@ type OIDCConfig struct {
CookieSameSite string `mapstructure:"cookie_same_site"`
}
// EffectiveAdminRoles returns the deduplicated list of admin roles drawn from
// both admin_role (legacy single-value) and admin_roles (list). A user is
// considered an admin if any of their roles matches any entry in this list.
func (o OIDCConfig) EffectiveAdminRoles() []string {
seen := make(map[string]struct{}, len(o.AdminRoles)+1)
var roles []string
add := func(r string) {
if r == "" {
return
}
if _, ok := seen[r]; ok {
return
}
seen[r] = struct{}{}
roles = append(roles, r)
}
add(o.AdminRole)
for _, r := range o.AdminRoles {
add(r)
}
return roles
}
// CORSConfig contains CORS settings for frontend communication
type CORSConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -126,28 +95,8 @@ type LoggingConfig struct {
Format string `mapstructure:"format"`
}
// LoadOption configures optional behaviour of Load.
type LoadOption func(*loadOptions)
type loadOptions struct {
garageTomlPath string
}
// WithGarageToml tells Load to parse a garage.toml file and use its values as
// lowest-priority defaults (below YAML, below env vars).
func WithGarageToml(path string) LoadOption {
return func(o *loadOptions) {
o.garageTomlPath = path
}
}
// Load reads the configuration from the specified file
func Load(configPath string, opts ...LoadOption) (*Config, error) {
var lo loadOptions
for _, fn := range opts {
fn(&lo)
}
func Load(configPath string) (*Config, error) {
// Set default config file name if not specified
if configPath == "" {
configPath = "config.yaml"
@@ -157,28 +106,6 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// Built-in defaults (lowest priority)
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.environment", "production")
viper.SetDefault("garage.force_path_style", true)
viper.SetDefault("logging.level", "info")
viper.SetDefault("logging.format", "text")
// If garage.toml path is provided, parse it and set values as viper
// defaults. Defaults sit below config-file and env-var values in viper's
// priority order, so YAML and env vars will still win.
if lo.garageTomlPath != "" {
tomlResult, err := ParseGarageToml(lo.garageTomlPath)
if err != nil {
return nil, fmt.Errorf("error parsing garage.toml: %w", err)
}
viper.SetDefault("garage.endpoint", tomlResult.Endpoint)
viper.SetDefault("garage.admin_endpoint", tomlResult.AdminEndpoint)
viper.SetDefault("garage.admin_token", tomlResult.AdminToken)
viper.SetDefault("garage.region", tomlResult.Region)
}
// Allow environment variables to override config values
// Environment variables take precedence over config file
viper.AutomaticEnv()
@@ -238,9 +165,6 @@ func bindEnvVars() {
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
// Token auth config
viper.BindEnv("auth.token.enabled", "GARAGE_UI_AUTH_TOKEN_ENABLED")
// OIDC config
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
viper.BindEnv("auth.oidc.provider_name", "GARAGE_UI_AUTH_OIDC_PROVIDER_NAME")
@@ -255,7 +179,6 @@ func bindEnvVars() {
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE")
viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH")
viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
viper.BindEnv("auth.oidc.admin_roles", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLES")
viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY")
viper.BindEnv("auth.oidc.session_max_age", "GARAGE_UI_AUTH_OIDC_SESSION_MAX_AGE")
viper.BindEnv("auth.oidc.cookie_name", "GARAGE_UI_AUTH_OIDC_COOKIE_NAME")
@@ -316,27 +239,17 @@ func (c *Config) Validate() error {
return fmt.Errorf("oidc scopes are required when oidc is enabled")
}
// Every authenticated route on this service grants full admin
// access — there is no separate authorization layer. Empty
// admin role configuration would therefore promote every user
// in the IdP realm to cluster admin. Require operators to opt
// in explicitly via admin_role or admin_roles.
if len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled: leaving them empty would grant cluster-admin access to any authenticated IdP user")
// access — there is no separate authorization layer. An empty
// admin_role would therefore promote every user in the IdP realm
// to cluster admin. Require operators to opt in explicitly.
if c.Auth.OIDC.AdminRole == "" {
return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user")
}
}
return nil
}
// ResolveTokenAuth auto-enables token auth when no other auth method is
// configured, unless it was explicitly set. This ensures the app never
// starts without a login wall.
func (c *Config) ResolveTokenAuth() {
if !c.Auth.Admin.Enabled && !c.Auth.OIDC.Enabled && !c.Auth.Token.Enabled {
c.Auth.Token.Enabled = true
}
}
// GetAddress returns the full server address (host:port)
func (c *Config) GetAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
+1 -178
View File
@@ -3,7 +3,6 @@ package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -293,33 +292,7 @@ func TestValidate(t *testing.T) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
},
wantErrContains: "oidc admin_role or admin_roles is required",
},
{
name: "oidc enabled with admin_roles only is valid",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
c.Auth.OIDC.AdminRoles = []string{"group1", "group2"}
},
wantErrContains: "",
},
{
name: "oidc enabled with both admin_role and admin_roles is valid",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRoles = []string{"group2", "group3"}
},
wantErrContains: "",
},
{
name: "oidc enabled with empty admin_role and empty admin_roles rejected",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
c.Auth.OIDC.AdminRoles = []string{}
},
wantErrContains: "oidc admin_role or admin_roles is required",
wantErrContains: "oidc admin_role is required",
},
{
name: "oidc fully configured is valid",
@@ -401,156 +374,6 @@ func TestIsDevelopment(t *testing.T) {
}
}
func writeToml(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "garage.toml")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write toml: %v", err)
}
return path
}
const testGarageToml = `
[admin]
api_bind_addr = "[::]:3903"
admin_token = "toml-token"
[s3_api]
api_bind_addr = "[::]:3900"
s3_region = "garage"
`
func TestLoad_GarageTomlOnly(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
missingYaml := filepath.Join(t.TempDir(), "nope.yaml")
cfg, err := Load(missingYaml, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "toml-token" {
t.Errorf("AdminToken = %q, want toml-token", cfg.Garage.AdminToken)
}
if cfg.Garage.Endpoint != "http://127.0.0.1:3900" {
t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", cfg.Garage.Endpoint)
}
if cfg.Garage.AdminEndpoint != "http://127.0.0.1:3903" {
t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", cfg.Garage.AdminEndpoint)
}
if cfg.Garage.Region != "garage" {
t.Errorf("Region = %q, want garage", cfg.Garage.Region)
}
}
func TestLoad_YAMLOverridesToml(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
yaml := `
server:
host: "0.0.0.0"
port: 8080
garage:
endpoint: http://custom:3900
admin_endpoint: http://custom:3903
admin_token: yaml-wins
`
yamlPath := writeConfigFile(t, yaml)
cfg, err := Load(yamlPath, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "yaml-wins" {
t.Errorf("AdminToken = %q, want yaml-wins (yaml overrides toml)", cfg.Garage.AdminToken)
}
if cfg.Garage.Endpoint != "http://custom:3900" {
t.Errorf("Endpoint = %q, want http://custom:3900", cfg.Garage.Endpoint)
}
}
func TestLoad_EnvOverridesToml(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
missingYaml := filepath.Join(t.TempDir(), "nope.yaml")
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins")
cfg, err := Load(missingYaml, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "env-wins" {
t.Errorf("AdminToken = %q, want env-wins (env overrides toml)", cfg.Garage.AdminToken)
}
}
func TestValidate_TokenAuthAutoEnabled(t *testing.T) {
cfg := validBaseConfig()
cfg.ResolveTokenAuth()
if !cfg.Auth.Token.Enabled {
t.Error("expected token auth to be auto-enabled when no other auth is configured")
}
}
func TestValidate_TokenAuthNotAutoEnabledWhenAdminEnabled(t *testing.T) {
cfg := validBaseConfig()
cfg.Auth.Admin.Enabled = true
cfg.Auth.Admin.Username = "u"
cfg.Auth.Admin.Password = "p"
cfg.ResolveTokenAuth()
if cfg.Auth.Token.Enabled {
t.Error("expected token auth to stay disabled when admin auth is configured")
}
}
func TestValidate_TokenAuthNotAutoEnabledWhenOIDCEnabled(t *testing.T) {
cfg := validBaseConfig()
applyValidOIDC(&cfg)
cfg.ResolveTokenAuth()
if cfg.Auth.Token.Enabled {
t.Error("expected token auth to stay disabled when OIDC is configured")
}
}
func TestValidate_TokenAuthExplicitlyEnabled(t *testing.T) {
cfg := validBaseConfig()
cfg.Auth.Admin.Enabled = true
cfg.Auth.Admin.Username = "u"
cfg.Auth.Admin.Password = "p"
cfg.Auth.Token.Enabled = true
cfg.ResolveTokenAuth()
if !cfg.Auth.Token.Enabled {
t.Error("expected token auth to stay enabled when explicitly set")
}
}
func TestEffectiveAdminRoles(t *testing.T) {
tests := []struct {
name string
adminRole string
adminRoles []string
want []string
}{
{"both empty", "", nil, nil},
{"single only", "admin", nil, []string{"admin"}},
{"list only", "", []string{"a", "b"}, []string{"a", "b"}},
{"merge single + list", "admin", []string{"viewer", "ops"}, []string{"admin", "viewer", "ops"}},
{"dedupes overlap", "admin", []string{"admin", "ops"}, []string{"admin", "ops"}},
{"dedupes within list", "", []string{"a", "a", "b"}, []string{"a", "b"}},
{"skips empty strings in list", "admin", []string{"", "ops", ""}, []string{"admin", "ops"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
o := OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles}
got := o.EffectiveAdminRoles()
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("EffectiveAdminRoles() = %v, want %v", got, tc.want)
}
})
}
}
func TestIsProduction(t *testing.T) {
tests := []struct {
env string
-105
View File
@@ -1,105 +0,0 @@
package config
import (
"fmt"
"net"
"os"
"strings"
toml "github.com/pelletier/go-toml/v2"
)
// GarageTomlResult holds the values extracted from a garage.toml file.
type GarageTomlResult struct {
Endpoint string
AdminEndpoint string
AdminToken string
Region string
}
// garageTomlFile represents the subset of garage.toml we care about.
type garageTomlFile struct {
S3API struct {
APIBindAddr string `toml:"api_bind_addr"`
S3Region string `toml:"s3_region"`
} `toml:"s3_api"`
Admin struct {
APIBindAddr string `toml:"api_bind_addr"`
AdminToken string `toml:"admin_token"`
} `toml:"admin"`
}
// ParseGarageToml reads a garage.toml file and extracts the values needed
// for garage-ui configuration.
func ParseGarageToml(path string) (*GarageTomlResult, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading garage.toml: %w", err)
}
var f garageTomlFile
if err := toml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parsing garage.toml: %w", err)
}
if f.Admin.AdminToken == "" {
return nil, fmt.Errorf("garage.toml: [admin].admin_token is required")
}
if f.Admin.APIBindAddr == "" {
return nil, fmt.Errorf("garage.toml: [admin].api_bind_addr is required")
}
if f.S3API.APIBindAddr == "" {
return nil, fmt.Errorf("garage.toml: [s3_api].api_bind_addr is required")
}
adminEndpoint, err := convertBindAddr(f.Admin.APIBindAddr)
if err != nil {
return nil, fmt.Errorf("garage.toml: converting admin api_bind_addr: %w", err)
}
s3Endpoint, err := convertBindAddr(f.S3API.APIBindAddr)
if err != nil {
return nil, fmt.Errorf("garage.toml: converting s3_api api_bind_addr: %w", err)
}
region := f.S3API.S3Region
if region == "" {
region = "garage"
}
return &GarageTomlResult{
Endpoint: s3Endpoint,
AdminEndpoint: adminEndpoint,
AdminToken: f.Admin.AdminToken,
Region: region,
}, nil
}
// convertBindAddr converts a bind address like "[::]:3900" into an HTTP
// endpoint like "http://127.0.0.1:3900". Wildcard hosts (::, 0.0.0.0, empty)
// are replaced with 127.0.0.1.
func convertBindAddr(bindAddr string) (string, error) {
if bindAddr == "" {
return "", fmt.Errorf("bind address is empty")
}
host, port, err := net.SplitHostPort(bindAddr)
if err != nil {
return "", fmt.Errorf("invalid bind address %q: %w", bindAddr, err)
}
if port == "" {
return "", fmt.Errorf("bind address %q has no port", bindAddr)
}
switch host {
case "", "::", "0.0.0.0":
host = "127.0.0.1"
}
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return fmt.Sprintf("http://%s:%s", host, port), nil
}
-165
View File
@@ -1,165 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeTomlFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "garage.toml")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write toml: %v", err)
}
return path
}
const validGarageToml = `
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "my-secret-admin-token"
`
func TestParseGarageToml_ValidFile(t *testing.T) {
path := writeTomlFile(t, validGarageToml)
result, err := ParseGarageToml(path)
if err != nil {
t.Fatalf("ParseGarageToml: %v", err)
}
if result.AdminToken != "my-secret-admin-token" {
t.Errorf("AdminToken = %q, want my-secret-admin-token", result.AdminToken)
}
if result.AdminEndpoint != "http://127.0.0.1:3903" {
t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", result.AdminEndpoint)
}
if result.Endpoint != "http://127.0.0.1:3900" {
t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", result.Endpoint)
}
if result.Region != "garage" {
t.Errorf("Region = %q, want garage", result.Region)
}
}
func TestParseGarageToml_MissingAdminToken(t *testing.T) {
toml := `
[admin]
api_bind_addr = "[::]:3903"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing admin_token, got nil")
}
}
func TestParseGarageToml_MissingAdminBindAddr(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing admin api_bind_addr, got nil")
}
}
func TestParseGarageToml_MissingS3BindAddr(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
api_bind_addr = "[::]:3903"
[s3_api]
s3_region = "garage"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing s3_api api_bind_addr, got nil")
}
}
func TestParseGarageToml_DefaultRegion(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
api_bind_addr = "[::]:3903"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
result, err := ParseGarageToml(path)
if err != nil {
t.Fatalf("ParseGarageToml: %v", err)
}
if result.Region != "garage" {
t.Errorf("Region = %q, want garage (default)", result.Region)
}
}
func TestParseGarageToml_FileNotFound(t *testing.T) {
_, err := ParseGarageToml("/nonexistent/garage.toml")
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestConvertBindAddr(t *testing.T) {
tests := []struct {
name string
bindAddr string
want string
wantErr bool
}{
{"ipv6 wildcard", "[::]:3900", "http://127.0.0.1:3900", false},
{"ipv4 wildcard", "0.0.0.0:3900", "http://127.0.0.1:3900", false},
{"localhost", "127.0.0.1:3900", "http://127.0.0.1:3900", false},
{"specific ipv4", "192.168.1.1:3900", "http://192.168.1.1:3900", false},
{"ipv6 localhost", "[::1]:3900", "http://[::1]:3900", false},
{"specific ipv6", "[2001:db8::1]:3900", "http://[2001:db8::1]:3900", false},
{"empty host", ":3900", "http://127.0.0.1:3900", false},
{"empty string", "", "", true},
{"no port", "127.0.0.1", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := convertBindAddr(tc.bindAddr)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("convertBindAddr(%q) = %q, want %q", tc.bindAddr, got, tc.want)
}
})
}
}
-46
View File
@@ -1,8 +1,6 @@
package handlers
import (
"crypto/subtle"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
@@ -40,9 +38,6 @@ func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
"oidc": fiber.Map{
"enabled": h.cfg.Auth.OIDC.Enabled,
},
"token": fiber.Map{
"enabled": h.cfg.Auth.Token.Enabled,
},
}
// Add provider name if OIDC is enabled
@@ -113,47 +108,6 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
})
}
// LoginTokenRequest represents the token auth login request
type LoginTokenRequest struct {
Token string `json:"token" validate:"required"`
}
// LoginToken handles admin token authentication login
func (h *AuthHandler) LoginToken(c fiber.Ctx) error {
var req LoginTokenRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
)
}
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(h.cfg.Garage.AdminToken), []byte(req.Token)) != 1 {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid admin token"),
)
}
userInfo := &auth.UserInfo{
Username: "admin-token",
}
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"),
)
}
return c.JSON(fiber.Map{
"success": true,
"token": sessionToken,
"user": fiber.Map{
"username": userInfo.Username,
},
})
}
// GetMe returns the current authenticated user's information
//
// @Summary Get current user
-133
View File
@@ -250,139 +250,6 @@ func TestLoginAdmin_MalformedJSONReturns400(t *testing.T) {
}
}
func TestLoginToken_Success(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
body := `{"token":"test-admin-token"}`
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, want 200\nbody: %s", resp.StatusCode, raw)
}
var decoded struct {
Success bool `json:"success"`
Token string `json:"token"`
User struct {
Username string `json:"username"`
} `json:"user"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
t.Fatalf("decode: %v", err)
}
if !decoded.Success {
t.Error("success = false")
}
if decoded.Token == "" {
t.Error("token empty")
}
if decoded.User.Username != "admin-token" {
t.Errorf("username = %q, want admin-token", decoded.User.Username)
}
}
func TestLoginToken_WrongToken(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
body := `{"token":"wrong-token"}`
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestLoginToken_MalformedJSONReturns400(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader("{not-json"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestGetAuthConfig_TokenEnabled(t *testing.T) {
cfg := &config.Config{
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
app, _ := newAuthTestApp(t, cfg)
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
var body struct {
Token struct {
Enabled bool `json:"enabled"`
} `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.Token.Enabled {
t.Error("token.enabled = false, want true")
}
}
func TestGetMe_OIDCUserInfoLocal(t *testing.T) {
cfg := &config.Config{Auth: config.AuthConfig{}}
app, h := newAuthTestApp(t, cfg)
+12 -33
View File
@@ -1,6 +1,7 @@
package handlers
import (
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -37,9 +38,7 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
// List all buckets from Garage Admin API
adminBuckets, err := h.adminService.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert admin bucket response to BucketInfo
@@ -124,9 +123,7 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
}
if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -165,9 +162,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -178,9 +173,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
// Delete the bucket
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -219,9 +212,7 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -276,9 +267,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
// Get bucket info to retrieve bucket ID
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -310,9 +299,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
Permissions: allow,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
return apierr.Respond(c, err)
}
result = r
}
@@ -324,9 +311,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
Permissions: deny,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
)
return apierr.Respond(c, err)
}
result = r
}
@@ -336,9 +321,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
// Fetch current bucket state to return a consistent response.
r, err := h.adminService.GetBucketInfo(ctx, bucketInfo.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to fetch bucket info: "+err.Error()),
)
return apierr.Respond(c, err)
}
result = r
}
@@ -385,9 +368,7 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -412,9 +393,7 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket website: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(result))
+35
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
@@ -426,3 +427,37 @@ func TestUpdateBucketWebsite_Disable(t *testing.T) {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestDeleteBucket_UpstreamBucketNotEmpty(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.DeleteBucketFn = func(_ context.Context, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 409,
Code: "BucketNotEmpty",
Message: "Tried to delete a non-empty bucket",
Source: "garage",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil {
t.Fatal("error missing from response body")
}
if body.Error.Code != models.ErrCodeBucketNotEmpty {
t.Errorf("code = %q, want %s", body.Error.Code, models.ErrCodeBucketNotEmpty)
}
if body.Error.Message != "Tried to delete a non-empty bucket" {
t.Errorf("message = %q", body.Error.Message)
}
}
-27
View File
@@ -1,27 +0,0 @@
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,
}))
}
@@ -1,78 +0,0 @@
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)
}
}
+8 -17
View File
@@ -1,8 +1,6 @@
package handlers
import (
"errors"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -71,17 +69,14 @@ 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))
}
@@ -100,22 +95,20 @@ 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))
}
@@ -134,21 +127,19 @@ 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))
}
-37
View File
@@ -9,7 +9,6 @@ import (
"testing"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
@@ -125,42 +124,6 @@ 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) {
+11 -30
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -122,9 +123,7 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
// List objects in the bucket
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(objects))
@@ -186,9 +185,7 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
@@ -238,9 +235,7 @@ func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
result, err := h.s3Service.CreateDirectoryMarker(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to create directory: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(result))
@@ -281,9 +276,7 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
// Get object from Garage
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
return apierr.Respond(c, err)
}
// The uploader controls Content-Type. Rewrite executable MIME types to
@@ -345,9 +338,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if !exists {
@@ -358,9 +349,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
// Delete the object
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -407,9 +396,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
// Get object metadata
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(metadata))
@@ -467,9 +454,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if !exists {
@@ -481,9 +466,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// Generate pre-signed URL
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
)
return apierr.Respond(c, err)
}
response := models.PresignedURLResponse{
@@ -540,9 +523,7 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
return apierr.Respond(c, err)
}
response := models.ObjectDeleteMultipleResponse{
+42 -8
View File
@@ -13,6 +13,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
@@ -144,18 +145,20 @@ func TestGetObjectMetadata_Success(t *testing.T) {
}
}
func TestGetObjectMetadata_NotFound404(t *testing.T) {
func TestGetObjectMetadata_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return nil, errors.New("not found")
return nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
// is covered by Task 12 tests.
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
@@ -363,18 +366,20 @@ func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
}
}
func TestGetObject_ServiceErrorReturns404(t *testing.T) {
func TestGetObject_ServiceErrorReturns500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return nil, nil, errors.New("not found")
return nil, nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
// is covered by Task 12 tests.
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
@@ -864,3 +869,32 @@ func TestCreateDirectory_ServiceError500(t *testing.T) {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestDeleteObject_UpstreamNoSuchKey(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
s3.DeleteObjectFn = func(_ context.Context, _, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 404,
Code: "NoSuchKey",
Message: "The specified key does not exist",
Source: "s3",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/foo.txt", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil || body.Error.Code != models.ErrCodeObjectNotFound {
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeObjectNotFound)
}
if body.Error.Message != "The specified key does not exist" {
t.Errorf("message = %q", body.Error.Message)
}
}
+7 -18
View File
@@ -3,6 +3,7 @@ package handlers
import (
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -35,9 +36,7 @@ func (h *UserHandler) ListUsers(c fiber.Ctx) error {
keys, err := h.adminService.ListKeys(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert to UserInfo format
@@ -135,9 +134,7 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error {
// Create the key
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
@@ -188,9 +185,7 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
// Delete the key
err := h.adminService.DeleteKey(ctx, accessKey)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(map[string]interface{}{
@@ -223,9 +218,7 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
// Get key information (without secret key)
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
@@ -275,9 +268,7 @@ func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error {
// Get key information WITH secret key
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get secret key: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return only the secret key
@@ -347,9 +338,7 @@ func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
// Update the key
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
+29
View File
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
@@ -365,3 +366,31 @@ func TestUpdateUser_AdminError500(t *testing.T) {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestDeleteUser_UpstreamAccessDenied(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.DeleteKeyFn = func(_ context.Context, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 403,
Code: "AccessDenied",
Message: "permission denied",
Source: "garage",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/abc", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil || body.Error.Code != models.ErrCodeForbidden {
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeForbidden)
}
if body.Error.Message != "permission denied" {
t.Errorf("message = %q", body.Error.Message)
}
}
+11 -17
View File
@@ -1,8 +1,6 @@
package middleware
import (
"strings"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
@@ -19,14 +17,14 @@ import (
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler {
return func(c fiber.Ctx) error {
// If no auth is enabled, allow all requests.
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled && !cfg.Token.Enabled {
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Next()
}
authHeader := c.Get("Authorization")
// Try bearer token auth (works for admin, token, or any JWT session)
if (cfg.Admin.Enabled || cfg.Token.Enabled) && authHeader != "" {
// Try admin auth if enabled and header is present.
if cfg.Admin.Enabled && authHeader != "" {
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
userInfo, err := authService.ValidateSessionToken(token)
@@ -82,18 +80,14 @@ func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) {
}
func authMethodsEnabled(cfg *config.AuthConfig) string {
methods := []string{}
if cfg.Admin.Enabled {
methods = append(methods, "admin")
}
if cfg.OIDC.Enabled {
methods = append(methods, "oidc")
}
if cfg.Token.Enabled {
methods = append(methods, "token")
}
if len(methods) == 0 {
switch {
case cfg.Admin.Enabled && cfg.OIDC.Enabled:
return "admin+oidc"
case cfg.Admin.Enabled:
return "admin"
case cfg.OIDC.Enabled:
return "oidc"
default:
return "none"
}
return strings.Join(methods, "+")
}
+1 -22
View File
@@ -1,9 +1,6 @@
package models
import (
"encoding/json"
"time"
)
import "time"
// GarageKeyInfo represents detailed information about a Garage access key
type GarageKeyInfo struct {
@@ -183,24 +180,6 @@ 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"`
@@ -1,46 +0,0 @@
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)
}
}
+3 -1
View File
@@ -196,5 +196,7 @@ const (
ErrCodeUploadFailed = "UPLOAD_FAILED"
ErrCodeDeleteFailed = "DELETE_FAILED"
ErrCodeListFailed = "LIST_FAILED"
ErrCodeUnsupported = "UNSUPPORTED"
ErrCodeBucketNotEmpty = "BUCKET_NOT_EMPTY"
ErrCodeKeyNotFound = "KEY_NOT_FOUND"
ErrCodeInvalidRequest = "INVALID_REQUEST"
)
+4 -13
View File
@@ -29,7 +29,6 @@ func SetupRoutes(
userHandler *handlers.UserHandler,
clusterHandler *handlers.ClusterHandler,
monitoringHandler *handlers.MonitoringHandler,
capabilitiesHandler *handlers.CapabilitiesHandler,
) {
// Apply CORS middleware globally
app.Use(middleware.CORSMiddleware(&cfg.CORS))
@@ -53,8 +52,6 @@ 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")
{
@@ -152,13 +149,8 @@ func SetupRoutes(
app.Post("/auth/login", authHandler.LoginAdmin)
}
// Token auth login endpoint (only if token auth is enabled)
if cfg.Auth.Token.Enabled {
app.Post("/auth/login-token", authHandler.LoginToken)
}
// Auth "me" endpoint (if any auth is enabled)
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled || cfg.Auth.Token.Enabled {
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
}
@@ -236,8 +228,7 @@ func SetupRoutes(
// ID token and the userinfo endpoint (Keycloak emits resource_access
// only in the access token by default), so fall back to the access
// token and then the userinfo endpoint before denying access.
adminRoles := cfg.Auth.OIDC.EffectiveAdminRoles()
if len(adminRoles) > 0 {
if cfg.Auth.OIDC.AdminRole != "" {
if !authService.IsAdmin(userInfo) {
if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 {
userInfo.Roles = roles
@@ -251,9 +242,9 @@ func SetupRoutes(
if !authService.IsAdmin(userInfo) {
logger.Warn().
Str("username", userInfo.Username).
Strs("required_roles", adminRoles).
Str("required_role", cfg.Auth.OIDC.AdminRole).
Strs("roles", userInfo.Roles).
Msg("OIDC login denied: user does not have any required admin role")
Msg("OIDC login denied: user does not have required admin role")
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "User does not have the required admin role",
})
-2
View File
@@ -14,7 +14,6 @@ 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"
@@ -68,7 +67,6 @@ 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}
@@ -1,6 +1,7 @@
package services
import (
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
@@ -15,22 +16,22 @@ import (
"github.com/Noooste/azuretls-client"
)
// GarageV2AdminService handles interactions with the Garage Admin API
type GarageV2AdminService struct {
// GarageAdminService handles interactions with the Garage Admin API
type GarageAdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
// NewGarageV2AdminService creates a new Garage Admin API service
func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV2AdminService {
// NewGarageAdminService creates a new Garage Admin API service
func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdminService {
session := azuretls.NewSession()
if logLevel == "debug" {
session.Log()
}
return &GarageV2AdminService{
return &GarageAdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
@@ -38,7 +39,7 @@ func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV
}
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
var resp *azuretls.Response
retryConfig := utils.DefaultRetryConfig()
@@ -63,13 +64,14 @@ func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path strin
return resp, nil
}
// decodeResponse decodes a JSON response into the target structure
// decodeResponse decodes a JSON response into the target structure. Non-2xx
// responses are converted to *apierr.UpstreamError (Source="garage") so the
// handler layer can map them to the correct API error code and HTTP status.
func decodeResponse(resp *azuretls.Response, target interface{}) error {
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))
if err := apierr.ParseGarage(resp); err != nil {
return err
}
if target != nil {
@@ -77,12 +79,11 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
// ListKeys returns all access keys in the cluster
func (s *GarageV2AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
func (s *GarageAdminService) 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)
@@ -90,14 +91,14 @@ func (s *GarageV2AdminService) ListKeys(ctx context.Context) ([]models.ListKeysR
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return result, nil
}
// CreateKey creates a new API access key
func (s *GarageV2AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) 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)
@@ -105,14 +106,14 @@ func (s *GarageV2AdminService) CreateKey(ctx context.Context, req models.CreateK
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetKeyInfo returns information about a specific access key
func (s *GarageV2AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
@@ -125,14 +126,14 @@ func (s *GarageV2AdminService) GetKeyInfo(ctx context.Context, keyID string, sho
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// UpdateKey updates information about an access key
func (s *GarageV2AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) 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)
@@ -142,14 +143,14 @@ func (s *GarageV2AdminService) UpdateKey(ctx context.Context, keyID string, req
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// DeleteKey deletes an access key from the cluster
func (s *GarageV2AdminService) DeleteKey(ctx context.Context, keyID string) error {
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
@@ -158,14 +159,14 @@ func (s *GarageV2AdminService) DeleteKey(ctx context.Context, keyID string) erro
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
return err
}
return nil
}
// ImportKey imports an existing API access key
func (s *GarageV2AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) 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)
@@ -173,14 +174,14 @@ func (s *GarageV2AdminService) ImportKey(ctx context.Context, req models.ImportK
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// ListBuckets returns all buckets in the cluster.
func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "list_buckets").
@@ -204,7 +205,7 @@ func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBu
Float64("duration_ms", msSince(start)).
Str("outcome", "failure").
Msg("garage list_buckets decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Debug().
@@ -216,7 +217,7 @@ func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBu
}
// GetBucketInfo returns detailed information about a bucket by ID.
func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "get_bucket_info").
@@ -235,7 +236,7 @@ func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID strin
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("got bucket info")
@@ -243,7 +244,7 @@ func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID strin
}
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias.
func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "get_bucket_info_by_alias").
@@ -262,7 +263,7 @@ func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalA
var result models.GarageBucketInfo
if err = decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("got bucket info by alias")
@@ -270,7 +271,7 @@ func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalA
}
// CreateBucket creates a new bucket via the Admin API.
func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
var alias string
if req.GlobalAlias != nil {
alias = *req.GlobalAlias
@@ -293,7 +294,7 @@ func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.Crea
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("bucket created")
@@ -301,7 +302,7 @@ func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.Crea
}
// UpdateBucket updates bucket settings.
func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "update_bucket").
@@ -320,7 +321,7 @@ func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket updated")
@@ -328,7 +329,7 @@ func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string
}
// DeleteBucket deletes a bucket.
func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string) error {
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "delete_bucket").
@@ -346,7 +347,7 @@ func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string
if err := decodeResponse(resp, nil); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket decode failed")
return fmt.Errorf("failed to process response: %w", err)
return err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket deleted")
@@ -354,7 +355,7 @@ func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string
}
// AddBucketAlias adds an alias to a bucket
func (s *GarageV2AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) 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)
@@ -362,14 +363,14 @@ func (s *GarageV2AdminService) AddBucketAlias(ctx context.Context, req models.Ad
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// RemoveBucketAlias removes an alias from a bucket
func (s *GarageV2AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) 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)
@@ -377,14 +378,14 @@ func (s *GarageV2AdminService) RemoveBucketAlias(ctx context.Context, req models
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// AllowBucketKey grants permissions for a key on a bucket.
func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "allow_bucket_key").
@@ -407,7 +408,7 @@ func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.Bu
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket key permissions granted")
@@ -415,7 +416,7 @@ func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.Bu
}
// DenyBucketKey revokes permissions for a key on a bucket
func (s *GarageV2AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) 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)
@@ -423,14 +424,14 @@ func (s *GarageV2AdminService) DenyBucketKey(ctx context.Context, req models.Buc
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetClusterHealth returns the health status of the cluster
func (s *GarageV2AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
func (s *GarageAdminService) 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)
@@ -438,14 +439,14 @@ func (s *GarageV2AdminService) GetClusterHealth(ctx context.Context) (*models.Cl
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetClusterStatus returns the current status of the cluster
func (s *GarageV2AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
func (s *GarageAdminService) 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)
@@ -453,14 +454,14 @@ func (s *GarageV2AdminService) GetClusterStatus(ctx context.Context) (*models.Cl
var result models.ClusterStatus
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetClusterStatistics returns global cluster statistics
func (s *GarageV2AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
func (s *GarageAdminService) 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)
@@ -468,14 +469,14 @@ func (s *GarageV2AdminService) GetClusterStatistics(ctx context.Context) (*model
var result models.ClusterStatistics
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetNodeInfo returns information about a specific node
func (s *GarageV2AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
func (s *GarageAdminService) 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)
@@ -485,14 +486,14 @@ func (s *GarageV2AdminService) GetNodeInfo(ctx context.Context, nodeID string) (
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetNodeStatistics returns statistics for a specific node
func (s *GarageV2AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
func (s *GarageAdminService) 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)
@@ -502,21 +503,21 @@ func (s *GarageV2AdminService) GetNodeStatistics(ctx context.Context, nodeID str
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// HealthCheck checks if the Admin API is reachable
func (s *GarageV2AdminService) HealthCheck(ctx context.Context) error {
func (s *GarageAdminService) 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 err
}
return nil
@@ -528,7 +529,7 @@ func msSince(t time.Time) float64 {
}
// GetMetrics returns Prometheus metrics from the Admin API
func (s *GarageV2AdminService) GetMetrics(ctx context.Context) (string, error) {
func (s *GarageAdminService) 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)
@@ -16,9 +16,9 @@ import (
"github.com/rs/zerolog"
)
// newAdminWithServer creates a GarageV2AdminService pointed at a test server
// newAdminWithServer creates a GarageAdminService pointed at a test server
// and returns it with a cleanup hook.
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageV2AdminService {
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
@@ -26,7 +26,7 @@ func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageV2AdminSe
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}
return NewGarageV2AdminService(cfg, "info")
return NewGarageAdminService(cfg, "info")
}
// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx.
@@ -1,93 +0,0 @@
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
}
@@ -1,91 +0,0 @@
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")
}
}
@@ -3,6 +3,7 @@ package services
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
@@ -11,18 +12,19 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
)
// newAdminTestServer wires an httptest.Server (with the supplied handler) to a
// fresh *GarageV2AdminService configured with a known bearer token.
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageV2AdminService, *httptest.Server) {
// fresh *GarageAdminService configured with a known bearer token.
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
svc := NewGarageV2AdminService(&config.GarageConfig{
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token-xyz",
}, "")
@@ -103,8 +105,12 @@ func TestHealthCheck_Non2xxReturnsError(t *testing.T) {
if err == nil {
t.Fatal("expected error for 503, got nil")
}
if !strings.Contains(err.Error(), "503") {
t.Errorf("error should mention status code, got %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
}
if ue.HTTPStatus != http.StatusServiceUnavailable {
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusServiceUnavailable)
}
}
@@ -326,12 +332,12 @@ func TestDeleteBucket_PostWithIDQuery(t *testing.T) {
func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
cases := []struct {
name string
fn func(s *GarageV2AdminService) error
fn func(s *GarageAdminService) error
path string
}{
{
name: "AddBucketAlias",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{})
return err
},
@@ -339,7 +345,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
},
{
name: "RemoveBucketAlias",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{})
return err
},
@@ -347,7 +353,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
},
{
name: "AllowBucketKey",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{})
return err
},
@@ -355,7 +361,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
},
{
name: "DenyBucketKey",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{})
return err
},
@@ -379,12 +385,12 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
func TestClusterEndpoints(t *testing.T) {
cases := []struct {
name string
fn func(s *GarageV2AdminService) error
fn func(s *GarageAdminService) error
path string
}{
{
name: "GetClusterHealth",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.GetClusterHealth(context.Background())
return err
},
@@ -392,7 +398,7 @@ func TestClusterEndpoints(t *testing.T) {
},
{
name: "GetClusterStatus",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.GetClusterStatus(context.Background())
return err
},
@@ -400,7 +406,7 @@ func TestClusterEndpoints(t *testing.T) {
},
{
name: "GetClusterStatistics",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.GetClusterStatistics(context.Background())
return err
},
@@ -427,12 +433,12 @@ func TestClusterEndpoints(t *testing.T) {
func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
cases := []struct {
name string
fn func(s *GarageV2AdminService, id string) error
fn func(s *GarageAdminService, id string) error
path string
}{
{
name: "GetNodeInfo",
fn: func(s *GarageV2AdminService, id string) error {
fn: func(s *GarageAdminService, id string) error {
_, err := s.GetNodeInfo(context.Background(), id)
return err
},
@@ -440,7 +446,7 @@ func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
},
{
name: "GetNodeStatistics",
fn: func(s *GarageV2AdminService, id string) error {
fn: func(s *GarageAdminService, id string) error {
_, err := s.GetNodeStatistics(context.Background(), id)
return err
},
@@ -509,8 +515,15 @@ func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) {
if err == nil {
t.Fatal("expected error for 400, got nil")
}
if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") {
t.Errorf("error %v should contain 400 and the response body", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
}
if ue.HTTPStatus != http.StatusBadRequest {
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusBadRequest)
}
if !strings.Contains(ue.Message, "bad request") {
t.Errorf("Message = %q, want it to contain the response body", ue.Message)
}
}
@@ -576,10 +589,10 @@ func TestAllMethods_Non2xxReturnsError(t *testing.T) {
}
}
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageV2AdminService
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageAdminService
// branch that enables azuretls' session logging when logLevel == "debug".
func TestDebugLogLevelEnablesSessionLog(t *testing.T) {
svc := NewGarageV2AdminService(&config.GarageConfig{
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: "http://127.0.0.1:1",
AdminToken: "t",
}, "debug")
@@ -598,7 +611,7 @@ func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) {
t.Fatalf("close listener: %v", err)
}
svc := NewGarageV2AdminService(&config.GarageConfig{
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: "http://" + addr,
AdminToken: "irrelevant",
}, "")
-375
View File
@@ -1,375 +0,0 @@
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
}
-635
View File
@@ -1,635 +0,0 @@
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)
}
}
+2 -3
View File
@@ -9,7 +9,7 @@ import (
)
// AdminService is the set of Garage Admin API operations used by HTTP handlers.
// It is implemented by *GarageV2AdminService in admin_v2.go. Kept narrow so that
// It is implemented by *GarageAdminService in admin.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,7 +65,6 @@ type S3Storage interface {
// Compile-time guarantees that the concrete services implement the interfaces.
var (
_ AdminService = (*GarageV2AdminService)(nil)
_ AdminService = (*GarageV1AdminService)(nil)
_ AdminService = (*GarageAdminService)(nil)
_ S3Storage = (*S3Service)(nil)
)
+15 -14
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
@@ -21,11 +22,11 @@ import (
type S3Service struct {
client *minio.Client
config *config.GarageConfig
adminService AdminService
adminService *GarageAdminService
}
// NewS3Service creates a new S3 service instance using MinIO SDK
func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Service {
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create MinIO client for Garage
// trim http or https from endpoint
if strings.HasPrefix(cfg.Endpoint, "http://") {
@@ -131,7 +132,7 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
return listErr
})
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
return nil, apierr.FromMinio(err)
}
// Convert MinIO buckets to our model
@@ -164,7 +165,7 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
})
})
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -183,7 +184,7 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return client.RemoveBucket(ctx, bucketName)
})
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -216,7 +217,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
return nil, apierr.FromMinio(err)
}
// Drop directory marker objects (zero-byte keys ending in "/"). Garage
@@ -333,7 +334,7 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectUploadResponse{
@@ -366,7 +367,7 @@ func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key s
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to create directory %s in bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectUploadResponse{
@@ -396,14 +397,14 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
return getErr
})
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
return nil, nil, apierr.FromMinio(err)
}
// Get object info
stat, err := object.Stat()
if err != nil {
object.Close()
return nil, nil, fmt.Errorf("failed to get object info for %s in bucket %s: %w", key, bucketName, err)
return nil, nil, apierr.FromMinio(err)
}
// Create object info
@@ -432,7 +433,7 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
return client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -461,7 +462,7 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
if errResponse.Code == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to check if object exists: %w", err)
return false, apierr.FromMinio(err)
}
return true, nil
}
@@ -484,7 +485,7 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
return statErr
})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectInfo{
@@ -529,7 +530,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// Check for errors
for err := range errorCh {
if err.Err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
return apierr.FromMinio(err.Err)
}
}
+27 -17
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -13,6 +14,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
@@ -62,7 +64,7 @@ func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service {
srv := httptest.NewServer(combined)
t.Cleanup(srv.Close)
admin := NewGarageV2AdminService(&config.GarageConfig{
admin := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test",
}, "")
@@ -105,8 +107,9 @@ func TestS3_ListBuckets_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error from ListBuckets, got nil")
}
if !strings.Contains(err.Error(), "failed to list buckets") {
t.Errorf("error %v should wrap 'failed to list buckets'", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -122,8 +125,9 @@ func TestS3_CreateBucket_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create bucket") {
t.Errorf("error = %v, want wrap 'failed to create bucket'", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -138,8 +142,9 @@ func TestS3_DeleteBucket_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete bucket") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -154,8 +159,9 @@ func TestS3_ListObjects_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error from ListObjects, got nil")
}
if !strings.Contains(err.Error(), "failed to list objects") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -170,8 +176,9 @@ func TestS3_UploadObject_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to upload object") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -186,8 +193,9 @@ func TestS3_CreateDirectoryMarker_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create directory") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -215,8 +223,9 @@ func TestS3_DeleteObject_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete object") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -260,8 +269,9 @@ func TestS3_GetObjectMetadata_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to get metadata") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
+2 -2
View File
@@ -56,14 +56,14 @@ func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) {
}
}
// adminBackedS3 wires an S3Service to a fresh GarageV2AdminService that talks
// adminBackedS3 wires an S3Service to a fresh GarageAdminService 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 := NewGarageV2AdminService(&config.GarageConfig{
admin := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}, "")
+3 -36
View File
@@ -64,32 +64,15 @@ var version = "dev"
func main() {
// Parse command-line flags
configPath := flag.String("config", "config.yaml", "Path to configuration file")
garageTomlPath := flag.String("garage-toml", "", "Path to garage.toml file (extracts Garage connection values)")
flag.Parse()
// Env var fallback for --garage-toml
if *garageTomlPath == "" {
if envPath := os.Getenv("GARAGE_UI_GARAGE_TOML"); envPath != "" {
*garageTomlPath = envPath
}
}
// Build load options
var loadOpts []config.LoadOption
if *garageTomlPath != "" {
loadOpts = append(loadOpts, config.WithGarageToml(*garageTomlPath))
}
// Load configuration first (before initializing logger)
cfg, err := config.Load(*configPath, loadOpts...)
cfg, err := config.Load(*configPath)
if err != nil {
// If config fails to load, use default logger to report the error
logger.Get().Fatal().Err(err).Str("config_path", *configPath).Msg("Failed to load configuration")
}
// Auto-enable token auth if no other auth method is configured
cfg.ResolveTokenAuth()
// Initialize logger with configuration from config file
logger.Init(logger.Config{
Level: cfg.Logging.Level,
@@ -104,21 +87,9 @@ func main() {
Str("environment", cfg.Server.Environment).
Msg("Starting Garage UI Backend")
if *garageTomlPath != "" {
logger.Warn().
Str("s3_endpoint", cfg.Garage.Endpoint).
Str("admin_endpoint", cfg.Garage.AdminEndpoint).
Msg("Endpoints inferred from garage.toml bind addresses — override with GARAGE_UI_GARAGE_ENDPOINT / GARAGE_UI_GARAGE_ADMIN_ENDPOINT for remote/container setups")
}
// Initialize services
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 Garage Admin service")
adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level)
logger.Info().Msg("Initializing S3 service")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
@@ -131,9 +102,6 @@ func main() {
if cfg.Auth.OIDC.Enabled {
authMethods = append(authMethods, "oidc")
}
if cfg.Auth.Token.Enabled {
authMethods = append(authMethods, "token")
}
if len(authMethods) == 0 {
authMethods = append(authMethods, "none")
}
@@ -214,7 +182,6 @@ func main() {
userHandler,
clusterHandler,
monitoringHandler,
capabilitiesHandler,
)
// Start server in a goroutine
@@ -15,6 +15,7 @@ services:
start_period: 2s
backend:
image: garage-ui-smoke-backend:ci
build:
context: ../../..
dockerfile: Dockerfile
@@ -40,12 +40,6 @@ auth:
username: "admin"
password: "changeme"
# Admin Token Authentication
# When enabled, users can log in using the Garage admin token
# Auto-enabled when no other auth method is configured (zero-config fallback)
token:
enabled: false # Set to true to explicitly enable, or leave all auth disabled for auto-enable
# OIDC Configuration
# NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects
# The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback
@@ -75,14 +69,7 @@ auth:
# Role-based access (optional)
role_attribute_path: "resource_access.garage-ui.roles"
# Single admin role (backward-compatible).
admin_role: "admin"
# Multiple admin roles: a user is granted admin if ANY of their roles
# matches ANY entry below. Values from admin_role and admin_roles are
# merged, so you can set either, both, or only admin_roles.
# admin_roles:
# - "garage-admins"
# - "platform-team"
# TLS configuration
tls_skip_verify: false # Only set to true for testing, not recommended for production
-59
View File
@@ -1,59 +0,0 @@
# Setting Up a Garage Cluster
This guide walks you through setting up a local Garage cluster using Docker Compose for use with Garage UI.
If you already have a running Garage cluster, skip this and go straight to the [Quick Start](../README.md#quick-start).
## Prerequisites
- Docker & Docker Compose
## 1. Start Garage
From the garage-ui repository root:
```bash
docker compose up -d garage
sleep 10
```
## 2. Initialize the Cluster Layout
```bash
# Assign the node to a zone with 1GB capacity
docker compose exec garage garage layout assign -z dc1 -c 1G $(docker compose exec garage garage node id -q)
# Apply the layout
docker compose exec garage garage layout apply --version 1
```
## 3. Create an Admin Key
```bash
docker compose exec garage garage key create admin-key
```
Save the **access key** and **secret key** from the output — you'll need them for configuration.
## 4. Configure Garage UI
Copy the example config and fill in your Garage endpoints and admin token:
```bash
cp config.example.yaml config.yaml
```
The `admin_token` can be found in your `garage.toml` file. See [Garage Configuration](../README.md#garage-configuration) for the required `garage.toml` settings.
## 5. Start Garage UI
```bash
docker compose up -d garage-ui
```
Access Garage UI at http://localhost:8080
## Next Steps
- [Configuration reference](../config.example.yaml) for all available options
- [Garage official documentation](https://garagehq.deuxfleurs.fr/documentation/) for advanced Garage setup
+149 -239
View File
@@ -12,7 +12,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.15.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -21,7 +21,7 @@
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.14.1",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -44,7 +44,7 @@
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.3.2"
"vite": "^7.2.4"
}
},
"node_modules/@alloc/quick-lru": {
@@ -91,6 +91,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1546,9 +1547,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"cpu": [
"arm"
],
@@ -1560,9 +1561,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"cpu": [
"arm64"
],
@@ -1574,9 +1575,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"cpu": [
"arm64"
],
@@ -1588,9 +1589,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"cpu": [
"x64"
],
@@ -1602,9 +1603,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"cpu": [
"arm64"
],
@@ -1616,9 +1617,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"cpu": [
"x64"
],
@@ -1630,9 +1631,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"cpu": [
"arm"
],
@@ -1644,9 +1645,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"cpu": [
"arm"
],
@@ -1658,9 +1659,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"cpu": [
"arm64"
],
@@ -1672,9 +1673,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"cpu": [
"arm64"
],
@@ -1686,23 +1687,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"cpu": [
"loong64"
],
@@ -1714,23 +1701,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"cpu": [
"ppc64"
],
@@ -1742,9 +1715,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"cpu": [
"riscv64"
],
@@ -1756,9 +1729,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"cpu": [
"riscv64"
],
@@ -1770,9 +1743,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"cpu": [
"s390x"
],
@@ -1784,9 +1757,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"cpu": [
"x64"
],
@@ -1798,9 +1771,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"cpu": [
"x64"
],
@@ -1811,24 +1784,10 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"cpu": [
"arm64"
],
@@ -1840,9 +1799,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"cpu": [
"arm64"
],
@@ -1854,9 +1813,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"cpu": [
"ia32"
],
@@ -1868,9 +1827,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"cpu": [
"x64"
],
@@ -1882,9 +1841,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"cpu": [
"x64"
],
@@ -2130,66 +2089,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -2425,6 +2324,7 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2435,6 +2335,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2445,6 +2346,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2500,6 +2402,7 @@
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
@@ -2654,13 +2557,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2751,6 +2654,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2861,14 +2765,14 @@
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
@@ -2919,6 +2823,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3438,6 +3343,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3718,16 +3624,16 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@@ -4498,9 +4404,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -4650,11 +4556,12 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4682,6 +4589,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4726,13 +4634,10 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -4749,6 +4654,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4758,6 +4664,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4787,6 +4694,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz",
"integrity": "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -4810,6 +4718,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -4839,9 +4748,9 @@
}
},
"node_modules/react-router": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4861,12 +4770,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.1"
"react-router": "7.11.0"
},
"engines": {
"node": ">=20.0.0"
@@ -4910,7 +4819,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -4938,9 +4848,9 @@
}
},
"node_modules/rollup": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4954,31 +4864,28 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-x64-msvc": "4.54.0",
"fsevents": "~2.3.2"
}
},
@@ -5165,6 +5072,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5277,11 +5185,12 @@
}
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5402,6 +5311,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+3 -3
View File
@@ -14,7 +14,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.15.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -23,7 +23,7 @@
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.14.1",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -46,6 +46,6 @@
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.3.2"
"vite": "^7.2.4"
}
}
@@ -1,71 +0,0 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TokenLoginForm() {
const [token, setToken] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { loginToken } = useAuthStore();
const returnUrl = searchParams.get('returnUrl') || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await loginToken(token);
navigate(decodeURIComponent(returnUrl));
} catch (error) {
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<img
src="/garage.png"
alt="Garage Logo"
className="h-16 w-16 object-contain"
/>
</div>
<CardTitle className="text-2xl text-center">
Welcome to Garage UI
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="admin-token" className="text-sm font-medium">Admin Token</label>
<Input
id="admin-token"
type="password"
placeholder="Enter your Garage admin token"
value={token}
onChange={(e) => setToken(e.target.value)}
required
disabled={isLoading}
autoComplete="off"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !token}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
);
}
-12
View File
@@ -1,12 +0,0 @@
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,
});
}
-22
View File
@@ -8,7 +8,6 @@ import type {
ClusterHealth,
ClusterStatistics,
ClusterStatus,
GarageCapabilities,
GarageMetrics,
MultiNodeResponse,
MultiNodeStatisticsResponse,
@@ -88,11 +87,6 @@ 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
@@ -133,7 +127,6 @@ export const authApi = {
const response = await authApiClient.get<{
admin: { enabled: boolean };
oidc: { enabled: boolean; provider?: string };
token: { enabled: boolean };
}>('/config');
return response;
},
@@ -146,13 +139,6 @@ export const authApi = {
return response;
},
loginToken: async (token: string) => {
const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login-token', {
token,
});
return response;
},
me: async () => {
const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me');
return response;
@@ -181,14 +167,6 @@ export const healthApi = {
},
};
// Capabilities API
export const capabilitiesApi = {
get: async (): Promise<GarageCapabilities> => {
const response = await api.get('/v1/capabilities');
return response.data.data;
},
};
// Bucket API
export const bucketsApi = {
list: async (): Promise<Bucket[]> => {
-4
View File
@@ -40,8 +40,4 @@ 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,
},
};
+105 -139
View File
@@ -8,33 +8,10 @@ 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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Info className="h-4 w-4" />
{title}
</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted-foreground)]">
Requires Garage v2.0+
</p>
</CardContent>
</Card>
);
}
export function Cluster() {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const { data: capabilities } = useCapabilities();
const features = capabilities?.features;
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ['cluster-health'],
queryFn: () => garageApi.getClusterHealth(),
@@ -51,19 +28,18 @@ 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: features?.nodeInfo !== false && (!!selectedNodeId || selectedNodeId === null),
enabled: !!selectedNodeId || selectedNodeId === null,
});
const { data: nodeStats } = useQuery({
queryKey: ['node-statistics', selectedNodeId || '*'],
queryFn: () => garageApi.getNodeStatistics(selectedNodeId || '*'),
enabled: features?.nodeStatistics !== false && !!selectedNodeId,
enabled: !!selectedNodeId,
});
const isLoading = healthLoading || statusLoading || statisticsLoading;
@@ -335,129 +311,119 @@ export function Cluster() {
{/* Statistics Tab */}
<TabsContent value="statistics" className="space-y-4">
{features?.clusterStatistics === false ? (
<UnsupportedFeatureCard title="Cluster Statistics" description="Global cluster metrics and statistics" />
) : (
<Card>
<CardHeader>
<CardTitle>Cluster Statistics</CardTitle>
<CardDescription>
Detailed statistics and metrics from the Garage cluster
</CardDescription>
</CardHeader>
<CardContent>
{statistics ? (
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{statistics.freeform}
</pre>
</div>
<Card>
<CardHeader>
<CardTitle>Cluster Statistics</CardTitle>
<CardDescription>
Detailed statistics and metrics from the Garage cluster
</CardDescription>
</CardHeader>
<CardContent>
{statistics ? (
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{statistics.freeform}
</pre>
</div>
) : (
<div className="text-center text-muted-foreground py-8">
<Activity className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No statistics available</p>
</div>
)}
</CardContent>
</Card>
)}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
<Activity className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No statistics available</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Details Tab */}
<TabsContent value="details" className="space-y-4">
{selectedNodeId ? (
<>
{features?.nodeInfo === false ? (
<UnsupportedFeatureCard title="Node Details" description="Per-node information and configuration" />
) : (
<Card>
<CardHeader>
<CardTitle>Node Information</CardTitle>
<CardDescription>
Detailed information for node: {selectedNodeId.substring(0, 16)}...
</CardDescription>
</CardHeader>
<CardContent>
{nodeInfoLoading ? (
<div className="text-center py-8">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading node info...</p>
</div>
) : nodeInfo ? (
<div className="space-y-4">
{/* Success responses */}
{Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => (
<div key={nodeId} className="space-y-3">
<div className="flex items-center gap-2 mb-3">
<Info className="h-4 w-4 text-primary" />
<h4 className="font-medium">
Node: {nodeId.substring(0, 16)}...
</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Node ID</div>
<div className="font-mono text-sm break-all">{(info as LocalNodeInfo).nodeId}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Garage Version</div>
<div className="text-sm">{(info as LocalNodeInfo).garageVersion}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Rust Version</div>
<div className="text-sm">{(info as LocalNodeInfo).rustVersion}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Database Engine</div>
<div className="text-sm">{(info as LocalNodeInfo).dbEngine}</div>
</div>
</div>
{(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && (
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-2">Garage Features</div>
<div className="flex flex-wrap gap-2">
{(info as LocalNodeInfo).garageFeatures!.map((feature) => (
<Badge key={feature} variant="neutral">
{feature}
</Badge>
))}
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Node Information</CardTitle>
<CardDescription>
Detailed information for node: {selectedNodeId.substring(0, 16)}...
</CardDescription>
</CardHeader>
<CardContent>
{nodeInfoLoading ? (
<div className="text-center py-8">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading node info...</p>
</div>
) : nodeInfo ? (
<div className="space-y-4">
{/* Success responses */}
{Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => (
<div key={nodeId} className="space-y-3">
<div className="flex items-center gap-2 mb-3">
<Info className="h-4 w-4 text-primary" />
<h4 className="font-medium">
Node: {nodeId.substring(0, 16)}...
</h4>
</div>
))}
{/* Error responses */}
{Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => (
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="flex items-center gap-2 text-red-600 mb-1">
<XCircle className="h-4 w-4" />
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Node ID</div>
<div className="font-mono text-sm break-all">{(info as LocalNodeInfo).nodeId}</div>
</div>
<div className="text-sm text-red-800">{error}</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
<Info className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No node information available</p>
</div>
)}
</CardContent>
</Card>
)}
{features?.nodeStatistics === false ? (
<UnsupportedFeatureCard title="Node Statistics" description="Per-node performance metrics" />
) : nodeStats ? (
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Garage Version</div>
<div className="text-sm">{(info as LocalNodeInfo).garageVersion}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Rust Version</div>
<div className="text-sm">{(info as LocalNodeInfo).rustVersion}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Database Engine</div>
<div className="text-sm">{(info as LocalNodeInfo).dbEngine}</div>
</div>
</div>
{(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && (
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-2">Garage Features</div>
<div className="flex flex-wrap gap-2">
{(info as LocalNodeInfo).garageFeatures!.map((feature) => (
<Badge key={feature} variant="neutral">
{feature}
</Badge>
))}
</div>
</div>
)}
</div>
))}
{/* Error responses */}
{Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => (
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="flex items-center gap-2 text-red-600 mb-1">
<XCircle className="h-4 w-4" />
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
</div>
<div className="text-sm text-red-800">{error}</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
<Info className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No node information available</p>
</div>
)}
</CardContent>
</Card>
{nodeStats && (
<Card>
<CardHeader>
<CardTitle>Node Statistics</CardTitle>
@@ -498,7 +464,7 @@ export function Cluster() {
</div>
</CardContent>
</Card>
) : null}
)}
</>
) : (
<Card>
+9 -17
View File
@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { BasicLoginForm } from '@/components/auth/BasicLoginForm';
import { OIDCLoginView } from '@/components/auth/OIDCLoginView';
import { TokenLoginForm } from '@/components/auth/TokenLoginForm';
import { LoadingSpinner } from '@/components/auth/LoadingSpinner';
export function Login() {
@@ -15,7 +14,9 @@ export function Login() {
const returnUrl = searchParams.get('returnUrl') || '/';
useEffect(() => {
// Handle OIDC callback
if (loginSuccess === 'success') {
// OIDC login successful, re-initialize auth to fetch user
initialize().then(() => {
navigate(decodeURIComponent(returnUrl));
});
@@ -23,6 +24,7 @@ export function Login() {
}, [loginSuccess, initialize, navigate, returnUrl]);
useEffect(() => {
// If already authenticated, redirect to return URL
if (isAuthenticated && !loginSuccess) {
navigate(decodeURIComponent(returnUrl));
}
@@ -33,27 +35,16 @@ export function Login() {
}
// No auth enabled, redirect to dashboard immediately
if (config && !config.admin.enabled && !config.oidc.enabled && !config.token.enabled) {
if (config && !config.admin.enabled && !config.oidc.enabled) {
navigate('/');
return null;
}
// Show login options based on what's enabled
const showAdmin = config?.admin.enabled || false;
const showOIDC = config?.oidc.enabled || false;
const showToken = config?.token.enabled || false;
// Token-only auth (zero-config fallback)
if (showToken && !showAdmin && !showOIDC) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<TokenLoginForm />
</div>
</div>
);
}
// Both admin and OIDC enabled
// If both are enabled, show both options in single modal
if (showAdmin && showOIDC) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
@@ -64,12 +55,12 @@ export function Login() {
);
}
// Only OIDC
// Show only OIDC if enabled
if (showOIDC) {
return <OIDCLoginView />;
}
// Only admin
// Show only admin if enabled
if (showAdmin) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
@@ -80,5 +71,6 @@ export function Login() {
);
}
// Still loading config
return <LoadingSpinner />;
}
+1 -32
View File
@@ -16,7 +16,6 @@ interface AuthStore extends AuthState {
// Async actions
initialize: () => Promise<void>;
loginAdmin: (username: string, password: string) => Promise<void>;
loginToken: (token: string) => Promise<void>;
loginOIDC: () => void;
logout: () => Promise<void>;
}
@@ -46,7 +45,7 @@ export const useAuthStore = create<AuthStore>()(
set({ config });
// If no auth is enabled, mark as authenticated immediately
if (!config.admin.enabled && !config.oidc.enabled && !config.token.enabled) {
if (!config.admin.enabled && !config.oidc.enabled) {
set({
isAuthenticated: true,
isLoading: false,
@@ -114,36 +113,6 @@ export const useAuthStore = create<AuthStore>()(
}
},
loginToken: async (token) => {
try {
set({ isLoading: true, error: null });
const response = await authApi.loginToken(token);
const { token: sessionToken, user } = response.data;
localStorage.setItem('auth-token', sessionToken);
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch (error) {
const errorMessage =
(error as { response?: { data?: { error?: { message?: string } } } })
.response?.data?.error?.message ||
(error instanceof Error ? error.message : 'Login failed');
set({
error: errorMessage,
isLoading: false,
isAuthenticated: false,
user: null,
});
throw error;
}
},
loginOIDC: () => {
// Redirect to OIDC login endpoint
window.location.href = '/auth/oidc/login';
-3
View File
@@ -6,9 +6,6 @@ export interface AuthConfig {
enabled: boolean;
provider?: string;
};
token: {
enabled: boolean;
};
}
export interface AuthUser {
-9
View File
@@ -232,15 +232,6 @@ export interface MultiNodeStatisticsResponse {
error: Record<string, string>;
}
export interface GarageCapabilities {
garageApiVersion: string;
features: {
clusterStatistics: boolean;
nodeInfo: boolean;
nodeStatistics: boolean;
};
}
export interface NodeInfo {
nodeId: string;
version: string;
+1 -5
View File
@@ -84,13 +84,9 @@ config:
email_attribute: "email"
username_attribute: "preferred_username"
name_attribute: "name"
# Role-based access control.
# Set admin_role (single value, backward-compatible) and/or admin_roles
# (list). Values from both are merged: a user is granted admin if ANY
# of their roles matches ANY entry across these two fields.
# Role-based access control
role_attribute_path: "resource_access.garage-ui.roles"
admin_role: "admin"
admin_roles: []
# TLS settings
tls_skip_verify: false
# Session settings