Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3521e63073 | |||
| 71d3c8446a | |||
| 709b9f2ad3 | |||
| fd49c4e4c9 | |||
| ff46ff6232 | |||
| 66abbc64b3 | |||
| cb3839e618 | |||
| f04c38168d | |||
| c45846535c | |||
| c8cb3c4923 | |||
| c8337de3a8 | |||
| 5cc4b02114 | |||
| 741232c797 | |||
| a9337ff59b | |||
| ff3977aeb5 | |||
| 5388f0da8f | |||
| 42055930ed | |||
| f1eeca60bf | |||
| a5f064761b | |||
| b90e4f71be | |||
| 21f10a0274 | |||
| 390ccd7893 | |||
| b8b4d039ff | |||
| 1e6ab4f3cc | |||
| 9feaaf7c04 | |||
| a6ecf2af05 | |||
| ee6de30b40 | |||
| c165d04c77 | |||
| e3a74c4791 | |||
| 93d7b3aeb2 | |||
| c7d7e8c215 | |||
| 4c92203023 | |||
| 6c3c13a321 | |||
| a293e0e007 | |||
| 153f95bf6b | |||
| 614bf5b04a | |||
| a74d00d81f | |||
| 13bbdac837 | |||
| 12b06a4565 | |||
| 093b3ebdcc | |||
| 4ebe8ec56f | |||
| 123f8d14f5 | |||
| aa3e8ac33b | |||
| c9ed8fed83 | |||
| 16e416420a | |||
| a7e79e5343 | |||
| f48d6f0812 | |||
| 1cd9e734bc | |||
| eb1e7fe96a | |||
| af7af32d4e | |||
| c951e5fa4b | |||
| a828020994 | |||
| dcef1e2cbd |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,11 @@
|
||||
# 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"
|
||||
@@ -3,7 +3,8 @@ name: build
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
name: release
|
||||
name: chart-release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'helm/garage-ui/**'
|
||||
- '!helm/garage-ui/README.md'
|
||||
tags:
|
||||
- 'garage-ui-chart-v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -33,5 +32,6 @@ jobs:
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
with:
|
||||
charts_dir: helm
|
||||
skip_existing: true
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
|
||||
CR_TOKEN: ${{ secrets.HELM_RELEASE_TOKEN }}
|
||||
@@ -0,0 +1,38 @@
|
||||
name: pr-title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
docs
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
ci
|
||||
build
|
||||
perf
|
||||
scopes: |
|
||||
backend
|
||||
frontend
|
||||
helm
|
||||
ci
|
||||
deps
|
||||
requireScope: false
|
||||
subjectPattern: ^[A-Za-z].+[^.]$
|
||||
subjectPatternError: |
|
||||
PR title subject must start with a letter and not end with a period.
|
||||
Example: "feat(helm): add support for extraEnvs"
|
||||
@@ -0,0 +1,57 @@
|
||||
name: release-please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_PLEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_PLEASE_APP_KEY }}
|
||||
|
||||
- id: rp
|
||||
uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Sync Chart.yaml appVersion
|
||||
if: ${{ steps.rp.outputs.prs_created == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PRS: ${{ steps.rp.outputs.prs }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$PRS" | jq -c '.[]' | while read -r pr; do
|
||||
branch=$(echo "$pr" | jq -r '.headBranchName')
|
||||
workdir=$(mktemp -d)
|
||||
git clone --branch "$branch" --depth 1 \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$workdir"
|
||||
cd "$workdir"
|
||||
git config user.email "garage-ui-release-bot[bot]@users.noreply.github.com"
|
||||
git config user.name "garage-ui-release-bot[bot]"
|
||||
|
||||
version=$(jq -r '."."' .release-please-manifest.json)
|
||||
target="v${version}"
|
||||
sed -i -E "s|^appVersion:.*|appVersion: ${target}|" helm/garage-ui/Chart.yaml
|
||||
|
||||
if ! git diff --quiet helm/garage-ui/Chart.yaml; then
|
||||
git add helm/garage-ui/Chart.yaml
|
||||
git commit -m "chore: sync Chart.yaml appVersion to ${target}"
|
||||
git push origin "$branch"
|
||||
fi
|
||||
cd -
|
||||
rm -rf "$workdir"
|
||||
done
|
||||
@@ -11,6 +11,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Unit tests + coverage gate
|
||||
@@ -33,7 +36,7 @@ jobs:
|
||||
- name: Run unit tests with race detector and coverage
|
||||
run: |
|
||||
cd backend
|
||||
go test -race -coverprofile=../coverage.out -coverpkg=./... ./...
|
||||
go test -race -count=1 -coverprofile=../coverage.out -coverpkg=./... ./...
|
||||
|
||||
- name: Enforce coverage gate
|
||||
run: bash scripts/coverage-gate.sh coverage.out
|
||||
|
||||
@@ -52,7 +52,7 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
.env*
|
||||
!config.yaml.example
|
||||
!config.example.yaml
|
||||
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/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
".": "0.6.2",
|
||||
"helm/garage-ui": "0.4.2"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.2](https://github.com/Noooste/garage-ui/compare/v0.6.1...v0.6.2) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **helm:** update appVersion format and improve image tag handling ([709b9f2](https://github.com/Noooste/garage-ui/commit/709b9f2ad33fb3852bc23be1d647bb1d9388169b))
|
||||
|
||||
## [0.6.1](https://github.com/Noooste/garage-ui/compare/v0.6.0...v0.6.1) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Helm image tag ([#53](https://github.com/Noooste/garage-ui/issues/53)) ([ff46ff6](https://github.com/Noooste/garage-ui/commit/ff46ff623299461abade27478128f7e5ce409557))
|
||||
|
||||
## [0.6.0](https://github.com/Noooste/garage-ui/compare/v0.5.0...v0.6.0) (2026-05-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add extraEnvs to helm chart to allow config override ([#45](https://github.com/Noooste/garage-ui/issues/45)) ([c8337de](https://github.com/Noooste/garage-ui/commit/c8337de3a885fc86a08a9be329a60c0846e52d62))
|
||||
* enhance bucket credential retrieval to support read/write operations and improve caching logic ([#46](https://github.com/Noooste/garage-ui/issues/46)) ([c8cb3c4](https://github.com/Noooste/garage-ui/commit/c8cb3c49239bcf21b0abacba86622d55a202c4bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** correct appVersion tracking and remove changelog seeds ([#49](https://github.com/Noooste/garage-ui/issues/49)) ([f04c381](https://github.com/Noooste/garage-ui/commit/f04c38168d026c9746c5fb9f8182cb9fadc06f53))
|
||||
* **ci:** remove CHANGELOG.md seeds so release-please owns them ([#51](https://github.com/Noooste/garage-ui/issues/51)) ([cb3839e](https://github.com/Noooste/garage-ui/commit/cb3839e6189ec1d2a6609ff500ab7a79d0f5cbd9))
|
||||
@@ -130,3 +130,61 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/Nooost
|
||||
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
|
||||
- You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. <!-- this should only be included if the project has a GUI -->
|
||||
- **Explain why this enhancement would be useful** to most Garage UI users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
|
||||
|
||||
|
||||
## Commit Messages
|
||||
|
||||
This repo uses [Conventional Commits](https://www.conventionalcommits.org/) on
|
||||
PR titles to drive automated releases and changelogs via
|
||||
[release-please](https://github.com/googleapis/release-please).
|
||||
|
||||
PRs are squash-merged, so **only the PR title needs to follow the format**.
|
||||
Branch commits can be anything.
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
<type>(<optional-scope>): <subject>
|
||||
```
|
||||
|
||||
### Allowed types
|
||||
|
||||
| Type | Use when… | Triggers release? |
|
||||
|------------|------------------------------------------|-------------------|
|
||||
| `feat` | adding new user-facing functionality | minor bump |
|
||||
| `fix` | fixing a bug | patch bump |
|
||||
| `feat!` | breaking change (pre-1.0: still minor) | minor bump |
|
||||
| `perf` | performance improvement | patch bump |
|
||||
| `docs` | documentation only | no |
|
||||
| `refactor` | code change that's not feat/fix | no |
|
||||
| `chore` | tooling, deps, build | no |
|
||||
| `test` | adding/fixing tests | no |
|
||||
| `ci` | CI workflow changes | no |
|
||||
| `build` | build system changes | no |
|
||||
|
||||
Pre-1.0 SemVer: while the project is `<1.0`, breaking changes (`feat!`) bump
|
||||
the **minor** version, not the major. Once the project declares `1.0.0`,
|
||||
`feat!` will bump major as per standard SemVer.
|
||||
|
||||
### Allowed scopes
|
||||
|
||||
- `backend` — Go API server
|
||||
- `frontend` — React app
|
||||
- `helm` — Helm chart
|
||||
- `ci` — CI workflows
|
||||
- `deps` — dependency updates
|
||||
|
||||
Scope is optional. Use it when the change is clearly scoped to one component
|
||||
(it routes the version bump to that component only).
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(backend): add bucket quota enforcement
|
||||
fix(frontend): correct theme toggle in Safari
|
||||
feat(helm): support extraEnvs in deployment template
|
||||
chore(deps): bump axios from 1.15.0 to 1.15.2
|
||||
docs: clarify OIDC setup in README
|
||||
```
|
||||
|
||||
The PR title is automatically validated by the `pr-title` GitHub Action.
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Garage UI
|
||||
<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>
|
||||
|
||||
A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object storage clusters.
|
||||
# Garage UI - Web Dashboard for Garage S3 Storage
|
||||
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
|
||||
[](https://codecov.io/gh/Noooste/garage-ui)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://artifacthub.io/packages/search?repo=garage-ui)
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,53 +26,30 @@ A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object st
|
||||
|
||||
## Features
|
||||
|
||||
- Bucket and object management
|
||||
- User access control
|
||||
- Cluster monitoring
|
||||
- Multiple authentication options (none/basic/OIDC)
|
||||
- Drag-and-drop file uploads
|
||||
- **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
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Garage S3 cluster (v2.1.0+) or use the included setup
|
||||
- A running Garage cluster (v2.1.0+) - [setup guide](docs/garage-setup.md) if you need one
|
||||
|
||||
### 1. Clone & Setup
|
||||
### 1. Clone & Configure
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Noooste/garage-ui.git
|
||||
cd garage-ui
|
||||
```
|
||||
|
||||
### 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
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml`).
|
||||
|
||||
### 4. Start UI
|
||||
### 2. Start
|
||||
|
||||
```bash
|
||||
docker compose up -d garage-ui
|
||||
@@ -78,43 +57,6 @@ 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
|
||||
@@ -135,22 +77,67 @@ helm install garage-ui garage-ui/garage-ui \
|
||||
--set garage.adminToken=your-token
|
||||
```
|
||||
|
||||
## Development
|
||||
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:
|
||||
|
||||
Backend (Go 1.25+):
|
||||
```bash
|
||||
cd backend
|
||||
go run main.go --config ../config.yaml
|
||||
./garage-ui --garage-toml /etc/garage.toml
|
||||
```
|
||||
|
||||
Frontend (Node.js 25+):
|
||||
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:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
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
|
||||
```
|
||||
|
||||
API docs: http://localhost:8080/api/v1/
|
||||
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
|
||||
```
|
||||
|
||||
## Garage Configuration
|
||||
|
||||
@@ -172,6 +159,23 @@ 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:**
|
||||
@@ -210,11 +214,12 @@ Ideas being considered. Contributions welcome.
|
||||
- [ ] Per-bucket usage graph over time
|
||||
|
||||
**Access keys**
|
||||
- [ ] Permission matrix view (keys × buckets)
|
||||
- [ ] Permission matrix view (keys x 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
|
||||
@@ -239,4 +244,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/)
|
||||
@@ -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,7 +36,6 @@ 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
|
||||
@@ -48,27 +47,24 @@ 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
|
||||
@@ -80,5 +76,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/yaml.v3 v3.0.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
)
|
||||
|
||||
@@ -2,8 +2,6 @@ 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=
|
||||
@@ -12,80 +10,57 @@ 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=
|
||||
@@ -94,22 +69,19 @@ 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=
|
||||
@@ -118,19 +90,13 @@ 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-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
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/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=
|
||||
@@ -139,63 +105,40 @@ 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.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/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/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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
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=
|
||||
@@ -212,78 +155,52 @@ 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.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -22,6 +24,7 @@ type Service struct {
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oauth2Config *oauth2.Config
|
||||
oidcClient *http.Client
|
||||
jwtService *JWTService
|
||||
}
|
||||
|
||||
@@ -58,7 +61,15 @@ func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig)
|
||||
|
||||
// initOIDC initializes the OIDC provider and configuration
|
||||
func (a *Service) initOIDC() error {
|
||||
ctx := context.Background()
|
||||
if a.authConfig.OIDC.TLSSkipVerify {
|
||||
a.oidcClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
ctx := a.oidcContext(context.Background())
|
||||
|
||||
// Create OIDC provider
|
||||
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
|
||||
@@ -92,6 +103,13 @@ 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
|
||||
@@ -123,7 +141,7 @@ func (a *Service) ExchangeCode(ctx context.Context, code string) (*oauth2.Token,
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
token, err := a.oauth2Config.Exchange(ctx, code)
|
||||
token, err := a.oauth2Config.Exchange(a.oidcContext(ctx), code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
@@ -138,7 +156,7 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn
|
||||
}
|
||||
|
||||
// Verify the ID token
|
||||
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
|
||||
idToken, err := a.oidcVerifier.Verify(a.oidcContext(ctx), rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify ID token: %w", err)
|
||||
}
|
||||
@@ -170,6 +188,8 @@ 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)
|
||||
|
||||
@@ -232,15 +252,18 @@ func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
|
||||
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
// IsAdmin checks if the user has any of the configured admin roles.
|
||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.authConfig.OIDC.AdminRole == "" {
|
||||
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
|
||||
if len(adminRoles) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, role := range userInfo.Roles {
|
||||
if role == a.authConfig.OIDC.AdminRole {
|
||||
return true
|
||||
for _, adminRole := range adminRoles {
|
||||
if role == adminRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,86 @@ 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{
|
||||
@@ -529,23 +609,35 @@ func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) {
|
||||
|
||||
func TestIsAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
adminRole string
|
||||
userRoles []string
|
||||
want bool
|
||||
name string
|
||||
adminRole string
|
||||
adminRoles []string
|
||||
userRoles []string
|
||||
want bool
|
||||
}{
|
||||
{"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},
|
||||
{"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},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{AdminRole: tc.adminRole},
|
||||
OIDC: config.OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles},
|
||||
},
|
||||
}
|
||||
if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -56,6 +57,12 @@ 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"`
|
||||
@@ -71,6 +78,7 @@ 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"`
|
||||
@@ -79,6 +87,29 @@ 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"`
|
||||
@@ -95,8 +126,28 @@ 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) (*Config, error) {
|
||||
func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
var lo loadOptions
|
||||
for _, fn := range opts {
|
||||
fn(&lo)
|
||||
}
|
||||
|
||||
// Set default config file name if not specified
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
@@ -106,6 +157,28 @@ func Load(configPath string) (*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()
|
||||
@@ -165,6 +238,9 @@ 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")
|
||||
@@ -179,6 +255,7 @@ 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")
|
||||
@@ -239,17 +316,27 @@ 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. 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")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -292,7 +293,33 @@ func TestValidate(t *testing.T) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.AdminRole = ""
|
||||
},
|
||||
wantErrContains: "oidc admin_role is required",
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "oidc fully configured is valid",
|
||||
@@ -374,6 +401,156 @@ 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
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
@@ -38,6 +40,9 @@ 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
|
||||
@@ -108,6 +113,47 @@ 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
|
||||
|
||||
@@ -250,6 +250,139 @@ 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)
|
||||
|
||||
@@ -287,23 +287,60 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Build the permission request for Garage Admin API
|
||||
permRequest := models.BucketKeyPermRequest{
|
||||
BucketID: bucketInfo.ID,
|
||||
AccessKeyID: req.AccessKeyID,
|
||||
Permissions: models.BucketKeyPermission{
|
||||
Read: req.Permissions.Read,
|
||||
Write: req.Permissions.Write,
|
||||
Owner: req.Permissions.Owner,
|
||||
},
|
||||
// Garage's AllowBucketKey is additive — false values are no-ops, not revokes.
|
||||
// To make this endpoint a true "set permissions" operation, split into Allow
|
||||
// for the requested-true perms and Deny for the requested-false perms.
|
||||
allow := models.BucketKeyPermission{
|
||||
Read: req.Permissions.Read,
|
||||
Write: req.Permissions.Write,
|
||||
Owner: req.Permissions.Owner,
|
||||
}
|
||||
deny := models.BucketKeyPermission{
|
||||
Read: !req.Permissions.Read,
|
||||
Write: !req.Permissions.Write,
|
||||
Owner: !req.Permissions.Owner,
|
||||
}
|
||||
|
||||
// Grant permissions using Garage Admin API
|
||||
result, err := h.adminService.AllowBucketKey(ctx, permRequest)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
|
||||
)
|
||||
var result *models.GarageBucketInfo
|
||||
|
||||
if allow.Read || allow.Write || allow.Owner {
|
||||
r, err := h.adminService.AllowBucketKey(ctx, models.BucketKeyPermRequest{
|
||||
BucketID: bucketInfo.ID,
|
||||
AccessKeyID: req.AccessKeyID,
|
||||
Permissions: allow,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
|
||||
)
|
||||
}
|
||||
result = r
|
||||
}
|
||||
|
||||
if deny.Read || deny.Write || deny.Owner {
|
||||
r, err := h.adminService.DenyBucketKey(ctx, models.BucketKeyPermRequest{
|
||||
BucketID: bucketInfo.ID,
|
||||
AccessKeyID: req.AccessKeyID,
|
||||
Permissions: deny,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
|
||||
)
|
||||
}
|
||||
result = r
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
// Caller passed all-false on a key with no existing perms — nothing to do.
|
||||
// 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()),
|
||||
)
|
||||
}
|
||||
result = r
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
|
||||
@@ -294,10 +294,19 @@ func TestGrantBucketPermission_Success(t *testing.T) {
|
||||
}
|
||||
admin.AllowBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("req = %+v", req)
|
||||
t.Errorf("allow req = %+v", req)
|
||||
}
|
||||
if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner {
|
||||
t.Errorf("perms = %+v", req.Permissions)
|
||||
t.Errorf("allow perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.DenyBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("deny req = %+v", req)
|
||||
}
|
||||
if req.Permissions.Read || req.Permissions.Write || !req.Permissions.Owner {
|
||||
t.Errorf("deny perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type CapabilitiesHandler struct {
|
||||
apiVersion string
|
||||
capabilities services.Capabilities
|
||||
}
|
||||
|
||||
func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities) *CapabilitiesHandler {
|
||||
return &CapabilitiesHandler{
|
||||
apiVersion: apiVersion,
|
||||
capabilities: capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CapabilitiesHandler) GetCapabilities(c fiber.Ctx) error {
|
||||
return c.JSON(models.SuccessResponse(fiber.Map{
|
||||
"garageApiVersion": h.apiVersion,
|
||||
"features": h.capabilities,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func TestCapabilities_V2(t *testing.T) {
|
||||
app := fiber.New()
|
||||
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2())
|
||||
app.Get("/capabilities", h.GetCapabilities)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
GarageApiVersion string `json:"garageApiVersion"`
|
||||
Features services.Capabilities `json:"features"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !body.Success {
|
||||
t.Fatal("expected success=true")
|
||||
}
|
||||
if body.Data.GarageApiVersion != "v2" {
|
||||
t.Errorf("garageApiVersion = %q, want v2", body.Data.GarageApiVersion)
|
||||
}
|
||||
if !body.Data.Features.ClusterStatistics || !body.Data.Features.NodeInfo || !body.Data.Features.NodeStatistics {
|
||||
t.Errorf("features = %+v, want all true", body.Data.Features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities_V1(t *testing.T) {
|
||||
app := fiber.New()
|
||||
h := NewCapabilitiesHandler("v1", services.CapabilitiesV1())
|
||||
app.Get("/capabilities", h.GetCapabilities)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var body struct {
|
||||
Data struct {
|
||||
GarageApiVersion string `json:"garageApiVersion"`
|
||||
Features services.Capabilities `json:"features"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Data.GarageApiVersion != "v1" {
|
||||
t.Errorf("garageApiVersion = %q, want v1", body.Data.GarageApiVersion)
|
||||
}
|
||||
if body.Data.Features.ClusterStatistics || body.Data.Features.NodeInfo || body.Data.Features.NodeStatistics {
|
||||
t.Errorf("features = %+v, want all false", body.Data.Features)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
@@ -69,14 +71,17 @@ func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
|
||||
// GET /api/v1/cluster/statistics
|
||||
func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
stats, err := h.adminService.GetClusterStatistics(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnsupported) {
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
|
||||
)
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
|
||||
@@ -95,20 +100,22 @@ func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
|
||||
func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
info, err := h.adminService.GetNodeInfo(ctx, nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnsupported) {
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
|
||||
)
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(info))
|
||||
}
|
||||
|
||||
@@ -127,19 +134,21 @@ func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
|
||||
func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnsupported) {
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
|
||||
)
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
@@ -124,6 +125,42 @@ func TestCluster_GetStatistics_ServiceErrorReturns500(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatistics_UnsupportedReturns501(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) {
|
||||
return nil, services.ErrUnsupported
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeInfo_UnsupportedReturns501(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
|
||||
return nil, services.ErrUnsupported
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeStatistics_UnsupportedReturns501(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
|
||||
return nil, services.ErrUnsupported
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeInfo_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeInfoFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
@@ -17,14 +19,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 {
|
||||
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled && !cfg.Token.Enabled {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
authHeader := c.Get("Authorization")
|
||||
|
||||
// Try admin auth if enabled and header is present.
|
||||
if cfg.Admin.Enabled && authHeader != "" {
|
||||
// Try bearer token auth (works for admin, token, or any JWT session)
|
||||
if (cfg.Admin.Enabled || cfg.Token.Enabled) && authHeader != "" {
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token := authHeader[7:]
|
||||
userInfo, err := authService.ValidateSessionToken(token)
|
||||
@@ -80,14 +82,18 @@ func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) {
|
||||
}
|
||||
|
||||
func authMethodsEnabled(cfg *config.AuthConfig) string {
|
||||
switch {
|
||||
case cfg.Admin.Enabled && cfg.OIDC.Enabled:
|
||||
return "admin+oidc"
|
||||
case cfg.Admin.Enabled:
|
||||
return "admin"
|
||||
case cfg.OIDC.Enabled:
|
||||
return "oidc"
|
||||
default:
|
||||
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 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(methods, "+")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GarageKeyInfo represents detailed information about a Garage access key
|
||||
type GarageKeyInfo struct {
|
||||
@@ -180,6 +183,24 @@ type ClusterHealth struct {
|
||||
PartitionsAllOk int `json:"partitionsAllOk"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON handles both "storageNodesOk" (Garage v2.0.0) and
|
||||
// "storageNodesUp" (Garage v2.1.0+, v1.x) field names.
|
||||
func (h *ClusterHealth) UnmarshalJSON(data []byte) error {
|
||||
type plain ClusterHealth
|
||||
var aux struct {
|
||||
plain
|
||||
StorageNodesOk int `json:"storageNodesOk"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
*h = ClusterHealth(aux.plain)
|
||||
if h.StorageNodesUp == 0 && aux.StorageNodesOk != 0 {
|
||||
h.StorageNodesUp = aux.StorageNodesOk
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClusterStatus represents the current status of the cluster
|
||||
type ClusterStatus struct {
|
||||
LayoutVersion int `json:"layoutVersion"`
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClusterHealth_StorageNodesOk_BackCompat(t *testing.T) {
|
||||
raw := `{
|
||||
"status": "healthy",
|
||||
"knownNodes": 3,
|
||||
"connectedNodes": 3,
|
||||
"storageNodes": 3,
|
||||
"storageNodesOk": 3,
|
||||
"partitions": 256,
|
||||
"partitionsQuorum": 256,
|
||||
"partitionsAllOk": 256
|
||||
}`
|
||||
var h ClusterHealth
|
||||
if err := json.Unmarshal([]byte(raw), &h); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if h.StorageNodesUp != 3 {
|
||||
t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterHealth_StorageNodesUp(t *testing.T) {
|
||||
raw := `{
|
||||
"status": "healthy",
|
||||
"knownNodes": 3,
|
||||
"connectedNodes": 3,
|
||||
"storageNodes": 3,
|
||||
"storageNodesUp": 3,
|
||||
"partitions": 256,
|
||||
"partitionsQuorum": 256,
|
||||
"partitionsAllOk": 256
|
||||
}`
|
||||
var h ClusterHealth
|
||||
if err := json.Unmarshal([]byte(raw), &h); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if h.StorageNodesUp != 3 {
|
||||
t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp)
|
||||
}
|
||||
}
|
||||
@@ -196,4 +196,5 @@ const (
|
||||
ErrCodeUploadFailed = "UPLOAD_FAILED"
|
||||
ErrCodeDeleteFailed = "DELETE_FAILED"
|
||||
ErrCodeListFailed = "LIST_FAILED"
|
||||
ErrCodeUnsupported = "UNSUPPORTED"
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ func SetupRoutes(
|
||||
userHandler *handlers.UserHandler,
|
||||
clusterHandler *handlers.ClusterHandler,
|
||||
monitoringHandler *handlers.MonitoringHandler,
|
||||
capabilitiesHandler *handlers.CapabilitiesHandler,
|
||||
) {
|
||||
// Apply CORS middleware globally
|
||||
app.Use(middleware.CORSMiddleware(&cfg.CORS))
|
||||
@@ -52,6 +53,8 @@ func SetupRoutes(
|
||||
// Apply authentication middleware to all API routes
|
||||
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
|
||||
|
||||
api.Get("/capabilities", capabilitiesHandler.GetCapabilities)
|
||||
|
||||
// Bucket routes
|
||||
buckets := api.Group("/buckets")
|
||||
{
|
||||
@@ -149,8 +152,13 @@ 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 {
|
||||
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled || cfg.Auth.Token.Enabled {
|
||||
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
|
||||
}
|
||||
|
||||
@@ -228,7 +236,8 @@ 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.
|
||||
if cfg.Auth.OIDC.AdminRole != "" {
|
||||
adminRoles := cfg.Auth.OIDC.EffectiveAdminRoles()
|
||||
if len(adminRoles) > 0 {
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 {
|
||||
userInfo.Roles = roles
|
||||
@@ -242,9 +251,9 @@ func SetupRoutes(
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
logger.Warn().
|
||||
Str("username", userInfo.Username).
|
||||
Str("required_role", cfg.Auth.OIDC.AdminRole).
|
||||
Strs("required_roles", adminRoles).
|
||||
Strs("roles", userInfo.Roles).
|
||||
Msg("OIDC login denied: user does not have required admin role")
|
||||
Msg("OIDC login denied: user does not have any required admin role")
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "User does not have the required admin role",
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
@@ -67,6 +68,7 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
|
||||
handlers.NewUserHandler(admin),
|
||||
handlers.NewClusterHandler(admin),
|
||||
handlers.NewMonitoringHandler(admin, s3),
|
||||
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2()),
|
||||
)
|
||||
|
||||
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// newAdminWithServer creates a GarageAdminService pointed at a test server
|
||||
// newAdminWithServer creates a GarageV2AdminService pointed at a test server
|
||||
// and returns it with a cleanup hook.
|
||||
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService {
|
||||
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageV2AdminService {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
@@ -26,7 +26,7 @@ func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminServ
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token",
|
||||
}
|
||||
return NewGarageAdminService(cfg, "info")
|
||||
return NewGarageV2AdminService(cfg, "info")
|
||||
}
|
||||
|
||||
// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
)
|
||||
|
||||
var ErrUnsupported = errors.New("operation not supported by this Garage version")
|
||||
|
||||
type Capabilities struct {
|
||||
ClusterStatistics bool `json:"clusterStatistics"`
|
||||
NodeInfo bool `json:"nodeInfo"`
|
||||
NodeStatistics bool `json:"nodeStatistics"`
|
||||
}
|
||||
|
||||
func CapabilitiesV2() Capabilities {
|
||||
return Capabilities{
|
||||
ClusterStatistics: true,
|
||||
NodeInfo: true,
|
||||
NodeStatistics: true,
|
||||
}
|
||||
}
|
||||
|
||||
func CapabilitiesV1() Capabilities {
|
||||
return Capabilities{}
|
||||
}
|
||||
|
||||
type AdminServiceResult struct {
|
||||
Service AdminService
|
||||
Capabilities Capabilities
|
||||
APIVersion string
|
||||
}
|
||||
|
||||
func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceResult, error) {
|
||||
if err := probeEndpoint(cfg, "/v2/GetClusterHealth"); err == nil {
|
||||
logger.Info().Str("api_version", "v2").Msg("Detected Garage admin API v2")
|
||||
svc := NewGarageV2AdminService(cfg, logLevel)
|
||||
return &AdminServiceResult{
|
||||
Service: svc,
|
||||
Capabilities: CapabilitiesV2(),
|
||||
APIVersion: "v2",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := probeEndpoint(cfg, "/v1/health"); err == nil {
|
||||
logger.Info().
|
||||
Str("api_version", "v1").
|
||||
Msg("Detected Garage admin API v1 — cluster statistics and per-node details will be unavailable")
|
||||
svc := NewGarageV1AdminService(cfg, logLevel)
|
||||
return &AdminServiceResult{
|
||||
Service: svc,
|
||||
Capabilities: CapabilitiesV1(),
|
||||
APIVersion: "v1",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"could not connect to Garage admin API at %s. Ensure Garage v1.1+ is running and the admin API is enabled",
|
||||
cfg.AdminEndpoint,
|
||||
)
|
||||
}
|
||||
|
||||
func probeEndpoint(cfg *config.GarageConfig, path string) error {
|
||||
session := azuretls.NewSession()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := session.Do(&azuretls.Request{
|
||||
Method: http.MethodGet,
|
||||
Url: cfg.AdminEndpoint + path,
|
||||
IgnoreBody: true,
|
||||
OrderedHeaders: azuretls.OrderedHeaders{
|
||||
{"Authorization", fmt.Sprintf("Bearer %s", cfg.AdminToken)},
|
||||
},
|
||||
}, ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("probe %s returned status %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
)
|
||||
|
||||
func TestErrUnsupportedIsSentinel(t *testing.T) {
|
||||
err := ErrUnsupported
|
||||
if !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatal("ErrUnsupported should match itself via errors.Is")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesV2AllTrue(t *testing.T) {
|
||||
caps := CapabilitiesV2()
|
||||
if !caps.ClusterStatistics || !caps.NodeInfo || !caps.NodeStatistics {
|
||||
t.Fatalf("v2 capabilities should all be true, got %+v", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesV1AllFalse(t *testing.T) {
|
||||
caps := CapabilitiesV1()
|
||||
if caps.ClusterStatistics || caps.NodeInfo || caps.NodeStatistics {
|
||||
t.Fatalf("v1 capabilities should all be false, got %+v", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersion_V2(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v2/GetClusterHealth" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"healthy"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
|
||||
result, err := NewAdminService(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.APIVersion != "v2" {
|
||||
t.Fatalf("expected v2, got %s", result.APIVersion)
|
||||
}
|
||||
if !result.Capabilities.ClusterStatistics {
|
||||
t.Fatal("v2 should have ClusterStatistics capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersion_V1(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v2/GetClusterHealth" {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/health" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"healthy"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
|
||||
result, err := NewAdminService(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.APIVersion != "v1" {
|
||||
t.Fatalf("expected v1, got %s", result.APIVersion)
|
||||
}
|
||||
if result.Capabilities.ClusterStatistics {
|
||||
t.Fatal("v1 should not have ClusterStatistics capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersion_Unreachable(t *testing.T) {
|
||||
cfg := &config.GarageConfig{AdminEndpoint: "http://127.0.0.1:1", AdminToken: "tok"}
|
||||
_, err := NewAdminService(cfg, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable server")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
)
|
||||
|
||||
type GarageV1AdminService struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *azuretls.Session
|
||||
}
|
||||
|
||||
func NewGarageV1AdminService(cfg *config.GarageConfig, logLevel string) *GarageV1AdminService {
|
||||
session := azuretls.NewSession()
|
||||
if logLevel == "debug" {
|
||||
session.Log()
|
||||
}
|
||||
return &GarageV1AdminService{
|
||||
baseURL: cfg.AdminEndpoint,
|
||||
token: cfg.AdminToken,
|
||||
httpClient: session,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
|
||||
var resp *azuretls.Response
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var reqErr error
|
||||
resp, reqErr = s.httpClient.Do(&azuretls.Request{
|
||||
Method: method,
|
||||
Url: s.baseURL + path,
|
||||
Body: body,
|
||||
IgnoreBody: true,
|
||||
OrderedHeaders: azuretls.OrderedHeaders{
|
||||
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
|
||||
},
|
||||
}, ctx)
|
||||
return reqErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/key?list=true", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result []models.ListKeysResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key?list", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v1/key?id=%s", keyID)
|
||||
if showSecret {
|
||||
path += "&showSecretKey=true"
|
||||
}
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v1/key?id=%s", keyID)
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) DeleteKey(ctx context.Context, keyID string) error {
|
||||
path := fmt.Sprintf("/v1/key?id=%s", keyID)
|
||||
resp, err := s.doRequest(ctx, http.MethodDelete, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key/import", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
log := logpkg.FromCtx(ctx).With().Str("component", "admin-v1").Str("operation", "list_buckets").Logger()
|
||||
log.Debug().Msg("listing buckets")
|
||||
start := time.Now()
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/bucket?list", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets request failed")
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result []models.ListBucketsResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
log.Debug().Float64("duration_ms", msSince(start)).Int("count", len(result)).Msg("listed buckets")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?globalAlias=%s", globalAlias), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPut, fmt.Sprintf("/v1/bucket?id=%s", bucketID), req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) DeleteBucket(ctx context.Context, bucketID string) error {
|
||||
resp, err := s.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/allow", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/deny", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
var path string
|
||||
if req.GlobalAlias != nil {
|
||||
path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias)
|
||||
} else if req.LocalAlias != nil && req.AccessKeyID != nil {
|
||||
path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias)
|
||||
} else {
|
||||
return nil, fmt.Errorf("AddBucketAlias requires either globalAlias or localAlias+accessKeyId")
|
||||
}
|
||||
resp, err := s.doRequest(ctx, http.MethodPut, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
var path string
|
||||
if req.GlobalAlias != nil {
|
||||
path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias)
|
||||
} else if req.LocalAlias != nil && req.AccessKeyID != nil {
|
||||
path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias)
|
||||
} else {
|
||||
return nil, fmt.Errorf("RemoveBucketAlias requires either globalAlias or localAlias+accessKeyId")
|
||||
}
|
||||
resp, err := s.doRequest(ctx, http.MethodDelete, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/health", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var result models.ClusterHealth
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
type v1StatusResponse struct {
|
||||
Node string `json:"node"`
|
||||
GarageVersion string `json:"garageVersion"`
|
||||
KnownNodes []v1KnownNode `json:"knownNodes"`
|
||||
Layout *v1Layout `json:"layout"`
|
||||
}
|
||||
|
||||
type v1KnownNode struct {
|
||||
ID string `json:"id"`
|
||||
Addr string `json:"addr"`
|
||||
IsUp bool `json:"isUp"`
|
||||
LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
type v1Layout struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/status", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
var raw v1StatusResponse
|
||||
if err := decodeResponse(resp, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
nodes := make([]models.NodeInfo, len(raw.KnownNodes))
|
||||
for i, n := range raw.KnownNodes {
|
||||
addr := n.Addr
|
||||
hostname := n.Hostname
|
||||
nodes[i] = models.NodeInfo{
|
||||
ID: n.ID,
|
||||
IsUp: n.IsUp,
|
||||
LastSeenSecsAgo: n.LastSeenSecsAgo,
|
||||
Hostname: &hostname,
|
||||
Addr: &addr,
|
||||
}
|
||||
}
|
||||
layoutVersion := 0
|
||||
if raw.Layout != nil {
|
||||
layoutVersion = raw.Layout.Version
|
||||
}
|
||||
return &models.ClusterStatus{
|
||||
LayoutVersion: layoutVersion,
|
||||
Nodes: nodes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("health check returned error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GarageV1AdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.RawBody.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(resp.RawBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
return string(bodyBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
)
|
||||
|
||||
func newV1TestServer(t *testing.T, handler http.Handler) *GarageV1AdminService {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
return NewGarageV1AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token",
|
||||
}, "")
|
||||
}
|
||||
|
||||
func TestV1_ListKeys(t *testing.T) {
|
||||
items := []models.ListKeysResponseItem{{ID: "GK1", Name: "key1"}}
|
||||
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/key" || r.URL.Query().Get("list") == "" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(items)
|
||||
}))
|
||||
result, err := svc.ListKeys(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].ID != "GK1" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetClusterHealth(t *testing.T) {
|
||||
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/health" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"healthy","knownNodes":3,"connectedNodes":3,"storageNodes":3,"storageNodesOk":3,"partitions":256,"partitionsQuorum":256,"partitionsAllOk":256}`))
|
||||
}))
|
||||
health, err := svc.GetClusterHealth(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if health.StorageNodesUp != 3 {
|
||||
t.Fatalf("expected StorageNodesUp=3, got %d", health.StorageNodesUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetClusterStatistics_Unsupported(t *testing.T) {
|
||||
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not make any HTTP request for unsupported operations")
|
||||
}))
|
||||
_, err := svc.GetClusterStatistics(context.Background())
|
||||
if !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("expected ErrUnsupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetNodeInfo_Unsupported(t *testing.T) {
|
||||
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not make any HTTP request for unsupported operations")
|
||||
}))
|
||||
_, err := svc.GetNodeInfo(context.Background(), "abc")
|
||||
if !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("expected ErrUnsupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetNodeStatistics_Unsupported(t *testing.T) {
|
||||
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not make any HTTP request for unsupported operations")
|
||||
}))
|
||||
_, err := svc.GetNodeStatistics(context.Background(), "abc")
|
||||
if !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("expected ErrUnsupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// v1RecordingHandler returns a handler that records the request and responds with JSON.
|
||||
func v1RecordingHandler(t *testing.T, status int, body any) (http.Handler, *recordedRequest) {
|
||||
t.Helper()
|
||||
rec := &recordedRequest{}
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rec.method = r.Method
|
||||
rec.path = r.URL.Path
|
||||
rec.rawURL = r.URL.RequestURI()
|
||||
rec.auth = r.Header.Get("Authorization")
|
||||
if r.Body != nil {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
rec.body = b
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if body != nil {
|
||||
json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
})
|
||||
return h, rec
|
||||
}
|
||||
|
||||
func newV1RecordingServer(t *testing.T, status int, body any) (*GarageV1AdminService, *recordedRequest) {
|
||||
t.Helper()
|
||||
h, rec := v1RecordingHandler(t, status, body)
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
svc := NewGarageV1AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token",
|
||||
}, "")
|
||||
return svc, rec
|
||||
}
|
||||
|
||||
func TestV1_CreateKey(t *testing.T) {
|
||||
name := "mykey"
|
||||
want := &models.GarageKeyInfo{AccessKeyID: "GK1", Name: name}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
got, err := svc.CreateKey(context.Background(), models.CreateKeyRequest{Name: &name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v1/key" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
if got.AccessKeyID != "GK1" {
|
||||
t.Errorf("AccessKeyID = %q, want GK1", got.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetKeyInfo(t *testing.T) {
|
||||
want := &models.GarageKeyInfo{AccessKeyID: "ABC"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
got, err := svc.GetKeyInfo(context.Background(), "ABC", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=ABC") || !strings.Contains(rec.rawURL, "showSecretKey=true") {
|
||||
t.Errorf("rawURL = %q, want id=ABC&showSecretKey=true", rec.rawURL)
|
||||
}
|
||||
if got.AccessKeyID != "ABC" {
|
||||
t.Errorf("got %q, want ABC", got.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_UpdateKey(t *testing.T) {
|
||||
want := &models.GarageKeyInfo{AccessKeyID: "K1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
_, err := svc.UpdateKey(context.Background(), "K1", models.UpdateKeyRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=K1") {
|
||||
t.Errorf("rawURL = %q, want id=K1", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_DeleteKey(t *testing.T) {
|
||||
svc, rec := newV1RecordingServer(t, 200, nil)
|
||||
|
||||
err := svc.DeleteKey(context.Background(), "K1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodDelete {
|
||||
t.Errorf("method = %q, want DELETE", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=K1") {
|
||||
t.Errorf("rawURL = %q, want id=K1", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_ImportKey(t *testing.T) {
|
||||
want := &models.GarageKeyInfo{AccessKeyID: "GKimported"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
got, err := svc.ImportKey(context.Background(), models.ImportKeyRequest{
|
||||
AccessKeyID: "GKimported",
|
||||
SecretAccessKey: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v1/key/import" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
if got.AccessKeyID != "GKimported" {
|
||||
t.Errorf("got %q", got.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_ListBuckets(t *testing.T) {
|
||||
want := []models.ListBucketsResponseItem{{ID: "b1", GlobalAliases: []string{"mybucket"}}}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
got, err := svc.ListBuckets(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodGet || rec.path != "/v1/bucket" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "b1" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetBucketInfo(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
got, err := svc.GetBucketInfo(context.Background(), "b1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=b1") {
|
||||
t.Errorf("rawURL = %q", rec.rawURL)
|
||||
}
|
||||
if got.ID != "b1" {
|
||||
t.Errorf("got %q", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetBucketInfoByAlias(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b2"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
got, err := svc.GetBucketInfoByAlias(context.Background(), "myalias")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "globalAlias=myalias") {
|
||||
t.Errorf("rawURL = %q", rec.rawURL)
|
||||
}
|
||||
if got.ID != "b2" {
|
||||
t.Errorf("got %q", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_CreateBucket(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "newb"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
alias := "test-bucket"
|
||||
got, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{GlobalAlias: &alias})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v1/bucket" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
if got.ID != "newb" {
|
||||
t.Errorf("got %q", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_UpdateBucket(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
_, err := svc.UpdateBucket(context.Background(), "b1", models.UpdateBucketRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPut {
|
||||
t.Errorf("method = %q, want PUT", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=b1") {
|
||||
t.Errorf("rawURL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_DeleteBucket(t *testing.T) {
|
||||
svc, rec := newV1RecordingServer(t, 200, nil)
|
||||
|
||||
err := svc.DeleteBucket(context.Background(), "b1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodDelete {
|
||||
t.Errorf("method = %q, want DELETE", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=b1") {
|
||||
t.Errorf("rawURL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_AllowBucketKey(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
_, err := svc.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{
|
||||
BucketID: "b1", AccessKeyID: "k1",
|
||||
Permissions: models.BucketKeyPermission{Read: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v1/bucket/allow" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_DenyBucketKey(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
_, err := svc.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{
|
||||
BucketID: "b1", AccessKeyID: "k1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v1/bucket/deny" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_AddBucketAlias_Global(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
alias := "myalias"
|
||||
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{
|
||||
BucketID: "b1", GlobalAlias: &alias,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/global" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=b1") || !strings.Contains(rec.rawURL, "alias=myalias") {
|
||||
t.Errorf("rawURL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_AddBucketAlias_Local(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
alias := "localname"
|
||||
keyID := "GK1"
|
||||
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{
|
||||
BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/local" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_RemoveBucketAlias_Global(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
|
||||
alias := "myalias"
|
||||
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{
|
||||
BucketID: "b1", GlobalAlias: &alias,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/global" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetClusterStatus(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"node": "abc123",
|
||||
"garageVersion": "1.3.0",
|
||||
"knownNodes": []map[string]any{
|
||||
{"id": "n1", "addr": "1.2.3.4:3901", "isUp": true, "hostname": "node1"},
|
||||
{"id": "n2", "addr": "5.6.7.8:3901", "isUp": false, "lastSeenSecsAgo": 60, "hostname": "node2"},
|
||||
},
|
||||
"layout": map[string]any{"version": 3},
|
||||
}
|
||||
svc, rec := newV1RecordingServer(t, 200, raw)
|
||||
|
||||
got, err := svc.GetClusterStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.path != "/v1/status" {
|
||||
t.Errorf("path = %q, want /v1/status", rec.path)
|
||||
}
|
||||
if got.LayoutVersion != 3 {
|
||||
t.Errorf("LayoutVersion = %d, want 3", got.LayoutVersion)
|
||||
}
|
||||
if len(got.Nodes) != 2 {
|
||||
t.Fatalf("len(Nodes) = %d, want 2", len(got.Nodes))
|
||||
}
|
||||
if got.Nodes[0].ID != "n1" || !got.Nodes[0].IsUp {
|
||||
t.Errorf("Nodes[0] = %+v", got.Nodes[0])
|
||||
}
|
||||
if got.Nodes[1].ID != "n2" || got.Nodes[1].IsUp {
|
||||
t.Errorf("Nodes[1] = %+v", got.Nodes[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_HealthCheck(t *testing.T) {
|
||||
svc, rec := newV1RecordingServer(t, 200, nil)
|
||||
|
||||
err := svc.HealthCheck(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.path != "/health" {
|
||||
t.Errorf("path = %q, want /health", rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_ErrorPaths(t *testing.T) {
|
||||
// Server that returns 500 for all requests to exercise error branches.
|
||||
srv500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(`{"error":"internal"}`))
|
||||
}))
|
||||
t.Cleanup(srv500.Close)
|
||||
svc := NewGarageV1AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv500.URL,
|
||||
AdminToken: "tok",
|
||||
}, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.ListKeys(ctx); err == nil {
|
||||
t.Error("ListKeys: expected error")
|
||||
}
|
||||
if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil {
|
||||
t.Error("CreateKey: expected error")
|
||||
}
|
||||
if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil {
|
||||
t.Error("GetKeyInfo: expected error")
|
||||
}
|
||||
if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil {
|
||||
t.Error("UpdateKey: expected error")
|
||||
}
|
||||
if err := svc.DeleteKey(ctx, "k"); err == nil {
|
||||
t.Error("DeleteKey: expected error")
|
||||
}
|
||||
if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil {
|
||||
t.Error("ImportKey: expected error")
|
||||
}
|
||||
if _, err := svc.ListBuckets(ctx); err == nil {
|
||||
t.Error("ListBuckets: expected error")
|
||||
}
|
||||
if _, err := svc.GetBucketInfo(ctx, "b"); err == nil {
|
||||
t.Error("GetBucketInfo: expected error")
|
||||
}
|
||||
if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil {
|
||||
t.Error("GetBucketInfoByAlias: expected error")
|
||||
}
|
||||
if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil {
|
||||
t.Error("CreateBucket: expected error")
|
||||
}
|
||||
if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil {
|
||||
t.Error("UpdateBucket: expected error")
|
||||
}
|
||||
if err := svc.DeleteBucket(ctx, "b"); err == nil {
|
||||
t.Error("DeleteBucket: expected error")
|
||||
}
|
||||
if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
|
||||
t.Error("AllowBucketKey: expected error")
|
||||
}
|
||||
if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
|
||||
t.Error("DenyBucketKey: expected error")
|
||||
}
|
||||
alias := "a"
|
||||
if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
|
||||
t.Error("AddBucketAlias: expected error")
|
||||
}
|
||||
if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
|
||||
t.Error("RemoveBucketAlias: expected error")
|
||||
}
|
||||
if _, err := svc.GetClusterHealth(ctx); err == nil {
|
||||
t.Error("GetClusterHealth: expected error")
|
||||
}
|
||||
if _, err := svc.GetClusterStatus(ctx); err == nil {
|
||||
t.Error("GetClusterStatus: expected error")
|
||||
}
|
||||
if err := svc.HealthCheck(ctx); err == nil {
|
||||
t.Error("HealthCheck: expected error")
|
||||
}
|
||||
if _, err := svc.GetMetrics(ctx); err == nil {
|
||||
t.Error("GetMetrics: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_RequestFailurePaths(t *testing.T) {
|
||||
// Use a cancelled context to make doRequest fail immediately (no retry wait).
|
||||
svc, _ := newV1RecordingServer(t, 200, nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
if _, err := svc.ListKeys(ctx); err == nil {
|
||||
t.Error("ListKeys: expected error")
|
||||
}
|
||||
if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil {
|
||||
t.Error("CreateKey: expected error")
|
||||
}
|
||||
if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil {
|
||||
t.Error("GetKeyInfo: expected error")
|
||||
}
|
||||
if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil {
|
||||
t.Error("UpdateKey: expected error")
|
||||
}
|
||||
if err := svc.DeleteKey(ctx, "k"); err == nil {
|
||||
t.Error("DeleteKey: expected error")
|
||||
}
|
||||
if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil {
|
||||
t.Error("ImportKey: expected error")
|
||||
}
|
||||
if _, err := svc.ListBuckets(ctx); err == nil {
|
||||
t.Error("ListBuckets: expected error")
|
||||
}
|
||||
if _, err := svc.GetBucketInfo(ctx, "b"); err == nil {
|
||||
t.Error("GetBucketInfo: expected error")
|
||||
}
|
||||
if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil {
|
||||
t.Error("GetBucketInfoByAlias: expected error")
|
||||
}
|
||||
if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil {
|
||||
t.Error("CreateBucket: expected error")
|
||||
}
|
||||
if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil {
|
||||
t.Error("UpdateBucket: expected error")
|
||||
}
|
||||
if err := svc.DeleteBucket(ctx, "b"); err == nil {
|
||||
t.Error("DeleteBucket: expected error")
|
||||
}
|
||||
if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
|
||||
t.Error("AllowBucketKey: expected error")
|
||||
}
|
||||
if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
|
||||
t.Error("DenyBucketKey: expected error")
|
||||
}
|
||||
alias := "a"
|
||||
if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
|
||||
t.Error("AddBucketAlias: expected error")
|
||||
}
|
||||
if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
|
||||
t.Error("RemoveBucketAlias: expected error")
|
||||
}
|
||||
if _, err := svc.GetClusterHealth(ctx); err == nil {
|
||||
t.Error("GetClusterHealth: expected error")
|
||||
}
|
||||
if _, err := svc.GetClusterStatus(ctx); err == nil {
|
||||
t.Error("GetClusterStatus: expected error")
|
||||
}
|
||||
if err := svc.HealthCheck(ctx); err == nil {
|
||||
t.Error("HealthCheck: expected error")
|
||||
}
|
||||
if _, err := svc.GetMetrics(ctx); err == nil {
|
||||
t.Error("GetMetrics: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_AddBucketAlias_MissingFields(t *testing.T) {
|
||||
svc, _ := newV1RecordingServer(t, 200, nil)
|
||||
// Neither globalAlias nor localAlias set
|
||||
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{BucketID: "b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing alias fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_RemoveBucketAlias_MissingFields(t *testing.T) {
|
||||
svc, _ := newV1RecordingServer(t, 200, nil)
|
||||
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{BucketID: "b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing alias fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_RemoveBucketAlias_Local(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "b1"}
|
||||
svc, rec := newV1RecordingServer(t, 200, want)
|
||||
alias := "localname"
|
||||
keyID := "GK1"
|
||||
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{
|
||||
BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/local" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1_GetMetrics(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("garage_up 1\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
svc := NewGarageV1AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "tok",
|
||||
}, "")
|
||||
|
||||
got, err := svc.GetMetrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, "garage_up") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -15,22 +15,22 @@ import (
|
||||
"github.com/Noooste/azuretls-client"
|
||||
)
|
||||
|
||||
// GarageAdminService handles interactions with the Garage Admin API
|
||||
type GarageAdminService struct {
|
||||
// GarageV2AdminService handles interactions with the Garage Admin API
|
||||
type GarageV2AdminService struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *azuretls.Session
|
||||
}
|
||||
|
||||
// NewGarageAdminService creates a new Garage Admin API service
|
||||
func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdminService {
|
||||
// NewGarageV2AdminService creates a new Garage Admin API service
|
||||
func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV2AdminService {
|
||||
session := azuretls.NewSession()
|
||||
|
||||
if logLevel == "debug" {
|
||||
session.Log()
|
||||
}
|
||||
|
||||
return &GarageAdminService{
|
||||
return &GarageV2AdminService{
|
||||
baseURL: cfg.AdminEndpoint,
|
||||
token: cfg.AdminToken,
|
||||
httpClient: session,
|
||||
@@ -38,7 +38,7 @@ func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdm
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
|
||||
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
|
||||
func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
|
||||
var resp *azuretls.Response
|
||||
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
@@ -82,7 +82,7 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
}
|
||||
|
||||
// ListKeys returns all access keys in the cluster
|
||||
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
func (s *GarageV2AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -97,7 +97,7 @@ func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysRes
|
||||
}
|
||||
|
||||
// CreateKey creates a new API access key
|
||||
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
func (s *GarageV2AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -112,7 +112,7 @@ func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKey
|
||||
}
|
||||
|
||||
// GetKeyInfo returns information about a specific access key
|
||||
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
func (s *GarageV2AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
|
||||
if showSecret {
|
||||
path += "&showSecretKey=true"
|
||||
@@ -132,7 +132,7 @@ func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showS
|
||||
}
|
||||
|
||||
// UpdateKey updates information about an access key
|
||||
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
func (s *GarageV2AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
|
||||
@@ -149,7 +149,7 @@ func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req mo
|
||||
}
|
||||
|
||||
// DeleteKey deletes an access key from the cluster
|
||||
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
|
||||
func (s *GarageV2AdminService) DeleteKey(ctx context.Context, keyID string) error {
|
||||
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
|
||||
@@ -165,7 +165,7 @@ func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error
|
||||
}
|
||||
|
||||
// ImportKey imports an existing API access key
|
||||
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
func (s *GarageV2AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -180,7 +180,7 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
|
||||
}
|
||||
|
||||
// ListBuckets returns all buckets in the cluster.
|
||||
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "list_buckets").
|
||||
@@ -216,7 +216,7 @@ func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBuck
|
||||
}
|
||||
|
||||
// GetBucketInfo returns detailed information about a bucket by ID.
|
||||
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "get_bucket_info").
|
||||
@@ -243,7 +243,7 @@ func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string)
|
||||
}
|
||||
|
||||
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias.
|
||||
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "get_bucket_info_by_alias").
|
||||
@@ -270,7 +270,7 @@ func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAli
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket via the Admin API.
|
||||
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
var alias string
|
||||
if req.GlobalAlias != nil {
|
||||
alias = *req.GlobalAlias
|
||||
@@ -301,7 +301,7 @@ func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.Create
|
||||
}
|
||||
|
||||
// UpdateBucket updates bucket settings.
|
||||
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "update_bucket").
|
||||
@@ -328,7 +328,7 @@ func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string,
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket.
|
||||
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
|
||||
func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string) error {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "delete_bucket").
|
||||
@@ -354,7 +354,7 @@ func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string)
|
||||
}
|
||||
|
||||
// AddBucketAlias adds an alias to a bucket
|
||||
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -369,7 +369,7 @@ func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddB
|
||||
}
|
||||
|
||||
// RemoveBucketAlias removes an alias from a bucket
|
||||
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -384,7 +384,7 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
|
||||
}
|
||||
|
||||
// AllowBucketKey grants permissions for a key on a bucket.
|
||||
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "allow_bucket_key").
|
||||
@@ -415,7 +415,7 @@ func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.Buck
|
||||
}
|
||||
|
||||
// DenyBucketKey revokes permissions for a key on a bucket
|
||||
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
func (s *GarageV2AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -430,7 +430,7 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke
|
||||
}
|
||||
|
||||
// GetClusterHealth returns the health status of the cluster
|
||||
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
func (s *GarageV2AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -445,7 +445,7 @@ func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.Clus
|
||||
}
|
||||
|
||||
// GetClusterStatus returns the current status of the cluster
|
||||
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
|
||||
func (s *GarageV2AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -460,7 +460,7 @@ func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.Clus
|
||||
}
|
||||
|
||||
// GetClusterStatistics returns global cluster statistics
|
||||
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
|
||||
func (s *GarageV2AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
@@ -475,7 +475,7 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.
|
||||
}
|
||||
|
||||
// GetNodeInfo returns information about a specific node
|
||||
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
func (s *GarageV2AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
@@ -492,7 +492,7 @@ func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*m
|
||||
}
|
||||
|
||||
// GetNodeStatistics returns statistics for a specific node
|
||||
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
func (s *GarageV2AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
@@ -509,7 +509,7 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin
|
||||
}
|
||||
|
||||
// HealthCheck checks if the Admin API is reachable
|
||||
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
|
||||
func (s *GarageV2AdminService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
@@ -528,7 +528,7 @@ func msSince(t time.Time) float64 {
|
||||
}
|
||||
|
||||
// GetMetrics returns Prometheus metrics from the Admin API
|
||||
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
func (s *GarageV2AdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
)
|
||||
|
||||
// newAdminTestServer wires an httptest.Server (with the supplied handler) to a
|
||||
// fresh *GarageAdminService configured with a known bearer token.
|
||||
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) {
|
||||
// fresh *GarageV2AdminService configured with a known bearer token.
|
||||
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageV2AdminService, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc := NewGarageAdminService(&config.GarageConfig{
|
||||
svc := NewGarageV2AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token-xyz",
|
||||
}, "")
|
||||
@@ -326,12 +326,12 @@ func TestDeleteBucket_PostWithIDQuery(t *testing.T) {
|
||||
func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService) error
|
||||
fn func(s *GarageV2AdminService) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "AddBucketAlias",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
fn: func(s *GarageV2AdminService) error {
|
||||
_, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{})
|
||||
return err
|
||||
},
|
||||
@@ -339,7 +339,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "RemoveBucketAlias",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
fn: func(s *GarageV2AdminService) error {
|
||||
_, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{})
|
||||
return err
|
||||
},
|
||||
@@ -347,7 +347,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "AllowBucketKey",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
fn: func(s *GarageV2AdminService) error {
|
||||
_, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{})
|
||||
return err
|
||||
},
|
||||
@@ -355,7 +355,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "DenyBucketKey",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
fn: func(s *GarageV2AdminService) error {
|
||||
_, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{})
|
||||
return err
|
||||
},
|
||||
@@ -379,12 +379,12 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
|
||||
func TestClusterEndpoints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService) error
|
||||
fn func(s *GarageV2AdminService) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "GetClusterHealth",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
fn: func(s *GarageV2AdminService) error {
|
||||
_, err := s.GetClusterHealth(context.Background())
|
||||
return err
|
||||
},
|
||||
@@ -392,7 +392,7 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "GetClusterStatus",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
fn: func(s *GarageV2AdminService) error {
|
||||
_, err := s.GetClusterStatus(context.Background())
|
||||
return err
|
||||
},
|
||||
@@ -400,7 +400,7 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "GetClusterStatistics",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
fn: func(s *GarageV2AdminService) error {
|
||||
_, err := s.GetClusterStatistics(context.Background())
|
||||
return err
|
||||
},
|
||||
@@ -427,12 +427,12 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService, id string) error
|
||||
fn func(s *GarageV2AdminService, id string) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "GetNodeInfo",
|
||||
fn: func(s *GarageAdminService, id string) error {
|
||||
fn: func(s *GarageV2AdminService, id string) error {
|
||||
_, err := s.GetNodeInfo(context.Background(), id)
|
||||
return err
|
||||
},
|
||||
@@ -440,7 +440,7 @@ func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "GetNodeStatistics",
|
||||
fn: func(s *GarageAdminService, id string) error {
|
||||
fn: func(s *GarageV2AdminService, id string) error {
|
||||
_, err := s.GetNodeStatistics(context.Background(), id)
|
||||
return err
|
||||
},
|
||||
@@ -576,10 +576,10 @@ func TestAllMethods_Non2xxReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageAdminService
|
||||
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageV2AdminService
|
||||
// branch that enables azuretls' session logging when logLevel == "debug".
|
||||
func TestDebugLogLevelEnablesSessionLog(t *testing.T) {
|
||||
svc := NewGarageAdminService(&config.GarageConfig{
|
||||
svc := NewGarageV2AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: "http://127.0.0.1:1",
|
||||
AdminToken: "t",
|
||||
}, "debug")
|
||||
@@ -598,7 +598,7 @@ func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
|
||||
svc := NewGarageAdminService(&config.GarageConfig{
|
||||
svc := NewGarageV2AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: "http://" + addr,
|
||||
AdminToken: "irrelevant",
|
||||
}, "")
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// AdminService is the set of Garage Admin API operations used by HTTP handlers.
|
||||
// It is implemented by *GarageAdminService in admin.go. Kept narrow so that
|
||||
// It is implemented by *GarageV2AdminService in admin_v2.go. Kept narrow so that
|
||||
// hand-rolled mocks in tests don't need to cover admin methods the handlers
|
||||
// never call.
|
||||
type AdminService interface {
|
||||
@@ -28,6 +28,7 @@ type AdminService interface {
|
||||
UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
|
||||
DeleteBucket(ctx context.Context, bucketID string) error
|
||||
AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
|
||||
DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
|
||||
|
||||
// Cluster
|
||||
GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error)
|
||||
@@ -64,6 +65,7 @@ type S3Storage interface {
|
||||
|
||||
// Compile-time guarantees that the concrete services implement the interfaces.
|
||||
var (
|
||||
_ AdminService = (*GarageAdminService)(nil)
|
||||
_ AdminService = (*GarageV2AdminService)(nil)
|
||||
_ AdminService = (*GarageV1AdminService)(nil)
|
||||
_ S3Storage = (*S3Service)(nil)
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ type AdminMock struct {
|
||||
UpdateBucketFn func(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
|
||||
DeleteBucketFn func(ctx context.Context, bucketID string) error
|
||||
AllowBucketKeyFn func(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
|
||||
DenyBucketKeyFn func(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
|
||||
|
||||
// Cluster
|
||||
GetClusterHealthFn func(ctx context.Context) (*models.ClusterHealth, error)
|
||||
@@ -168,6 +169,14 @@ func (m *AdminMock) AllowBucketKey(ctx context.Context, req models.BucketKeyPerm
|
||||
return m.AllowBucketKeyFn(ctx, req)
|
||||
}
|
||||
|
||||
func (m *AdminMock) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
m.record("DenyBucketKey", req)
|
||||
if m.DenyBucketKeyFn == nil {
|
||||
return nil, errNotConfigured("DenyBucketKey")
|
||||
}
|
||||
return m.DenyBucketKeyFn(ctx, req)
|
||||
}
|
||||
|
||||
// --- Cluster ---
|
||||
|
||||
func (m *AdminMock) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
|
||||
@@ -38,6 +38,7 @@ func TestAdminMock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
|
||||
{"UpdateBucket", func() error { _, e := m.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return e }},
|
||||
{"DeleteBucket", func() error { return m.DeleteBucket(ctx, "b") }},
|
||||
{"AllowBucketKey", func() error { _, e := m.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
|
||||
{"DenyBucketKey", func() error { _, e := m.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
|
||||
{"GetClusterHealth", func() error { _, e := m.GetClusterHealth(ctx); return e }},
|
||||
{"GetClusterStatus", func() error { _, e := m.GetClusterStatus(ctx); return e }},
|
||||
{"GetClusterStatistics", func() error { _, e := m.GetClusterStatistics(ctx); return e }},
|
||||
|
||||
@@ -21,11 +21,11 @@ import (
|
||||
type S3Service struct {
|
||||
client *minio.Client
|
||||
config *config.GarageConfig
|
||||
adminService *GarageAdminService
|
||||
adminService AdminService
|
||||
}
|
||||
|
||||
// NewS3Service creates a new S3 service instance using MinIO SDK
|
||||
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
|
||||
func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Service {
|
||||
// Create MinIO client for Garage
|
||||
// trim http or https from endpoint
|
||||
if strings.HasPrefix(cfg.Endpoint, "http://") {
|
||||
@@ -52,56 +52,77 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
|
||||
}
|
||||
}
|
||||
|
||||
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
|
||||
cacheKey := fmt.Sprintf("key:%s", bucketName)
|
||||
cacheData := utils.GlobalCache.Get(cacheKey)
|
||||
// Operation is a bitmask of S3 permissions a call needs. Combine with bitwise
|
||||
// OR (e.g. OpRead | OpWrite) when more than one is required.
|
||||
type Operation byte
|
||||
|
||||
if cacheData != nil {
|
||||
return cacheData.(*credentials.Credentials), nil
|
||||
const (
|
||||
OpRead Operation = 0x1
|
||||
OpWrite Operation = 0x2
|
||||
)
|
||||
|
||||
// satisfies reports whether perms grants every bit set in op.
|
||||
func (op Operation) satisfies(perms models.BucketKeyPermission) bool {
|
||||
if op&OpRead != 0 && !perms.Read {
|
||||
return false
|
||||
}
|
||||
if op&OpWrite != 0 && !perms.Write {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setKeyInCache(bucketName string, permissions models.BucketKeyPermission, creds *credentials.Credentials) {
|
||||
canWrite := permissions.Write
|
||||
canRead := permissions.Read
|
||||
|
||||
if canWrite {
|
||||
key := fmt.Sprintf("key:%s:%d", bucketName, OpWrite)
|
||||
utils.GlobalCache.Set(key, creds, time.Hour)
|
||||
}
|
||||
|
||||
if canRead {
|
||||
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead)
|
||||
utils.GlobalCache.Set(key, creds, time.Hour)
|
||||
}
|
||||
|
||||
if canRead && canWrite {
|
||||
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead|OpWrite)
|
||||
utils.GlobalCache.Set(key, creds, time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string, op Operation) (*credentials.Credentials, error) {
|
||||
cacheKey := fmt.Sprintf("key:%s:%d", bucketName, op)
|
||||
if cached := utils.GlobalCache.Get(cacheKey); cached != nil {
|
||||
return cached.(*credentials.Credentials), nil
|
||||
}
|
||||
|
||||
// Get bucket info from Garage Admin API
|
||||
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get bucket info: %w", err)
|
||||
}
|
||||
|
||||
// Find a key with read and write permissions
|
||||
var accessKeyID, secretAccessKey string
|
||||
for _, keyInfo := range bucketInfo.Keys {
|
||||
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
|
||||
if !op.satisfies(keyInfo.Permissions) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get key details with secret
|
||||
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get key info: %w", err)
|
||||
}
|
||||
|
||||
if keyDetails.SecretAccessKey != nil {
|
||||
accessKeyID = keyDetails.AccessKeyID
|
||||
secretAccessKey = *keyDetails.SecretAccessKey
|
||||
break
|
||||
if err != nil || keyDetails.SecretAccessKey == nil {
|
||||
continue
|
||||
}
|
||||
creds := credentials.NewStaticV4(keyDetails.AccessKeyID, *keyDetails.SecretAccessKey, "")
|
||||
setKeyInCache(bucketName, keyInfo.Permissions, creds)
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
if accessKeyID == "" || secretAccessKey == "" {
|
||||
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
|
||||
}
|
||||
|
||||
// Create credentials
|
||||
creds := credentials.NewStaticV4(accessKeyID, secretAccessKey, "")
|
||||
|
||||
// Cache credentials for 1 hour
|
||||
utils.GlobalCache.Set(cacheKey, creds, time.Hour)
|
||||
|
||||
return creds, nil
|
||||
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
|
||||
}
|
||||
|
||||
// getMinioClient creates a MinIO client for a specific bucket with dynamic credentials
|
||||
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*minio.Client, error) {
|
||||
creds, err := s.getBucketCredentials(ctx, bucketName)
|
||||
// getMinioClient creates a MinIO client for a specific bucket with credentials
|
||||
// that satisfy op.
|
||||
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string, op Operation) (*minio.Client, error) {
|
||||
creds, err := s.getBucketCredentials(ctx, bucketName, op)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -151,7 +172,7 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
|
||||
|
||||
// CreateBucket creates a new bucket in Garage
|
||||
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -172,7 +193,7 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
|
||||
// DeleteBucket deletes a bucket from Garage
|
||||
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -192,7 +213,7 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
// ListObjects lists objects in a bucket with optional prefix filter and pagination
|
||||
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -313,7 +334,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
// UploadObject uploads an object to a bucket
|
||||
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -351,7 +372,7 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
// size=0 forces a single PutObject request with Content-Length: 0, which
|
||||
// Garage accepts as a directory marker.
|
||||
func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) {
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -381,7 +402,7 @@ func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key s
|
||||
// GetObject retrieves an object from a bucket
|
||||
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -421,7 +442,7 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
|
||||
// DeleteObject deletes an object from a bucket
|
||||
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -441,7 +462,7 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
|
||||
// ObjectExists checks if an object exists in a bucket
|
||||
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -469,7 +490,7 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
|
||||
// GetObjectMetadata retrieves metadata for an object without downloading it
|
||||
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -505,7 +526,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
|
||||
}
|
||||
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -540,7 +561,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
|
||||
// This is useful for sharing files without exposing credentials
|
||||
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -579,7 +600,7 @@ func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string
|
||||
results := make([]UploadResult, len(files))
|
||||
|
||||
// Get bucket-specific MinIO client once for all uploads
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
// If we can't get the client, all uploads fail
|
||||
for i := range files {
|
||||
|
||||
@@ -62,7 +62,7 @@ func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service {
|
||||
srv := httptest.NewServer(combined)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
admin := NewGarageAdminService(&config.GarageConfig{
|
||||
admin := NewGarageV2AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test",
|
||||
}, "")
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -56,14 +57,14 @@ func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// adminBackedS3 wires an S3Service to a fresh GarageAdminService that talks
|
||||
// adminBackedS3 wires an S3Service to a fresh GarageV2AdminService that talks
|
||||
// to the supplied http.Handler.
|
||||
func adminBackedS3(t *testing.T, handler http.Handler) (*S3Service, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
admin := NewGarageAdminService(&config.GarageConfig{
|
||||
admin := NewGarageV2AdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token",
|
||||
}, "")
|
||||
@@ -80,7 +81,9 @@ func uniqueBucket(t *testing.T) string {
|
||||
t.Helper()
|
||||
name := "test-bucket-" + t.Name()
|
||||
t.Cleanup(func() {
|
||||
utils.GlobalCache.Delete("key:" + name)
|
||||
for _, op := range []Operation{OpRead, OpWrite, OpRead | OpWrite} {
|
||||
utils.GlobalCache.Delete(fmt.Sprintf("key:%s:%d", name, op))
|
||||
}
|
||||
})
|
||||
return name
|
||||
}
|
||||
@@ -111,7 +114,7 @@ func TestGetBucketCredentials_HappyPath(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
@@ -152,7 +155,7 @@ func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) {
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
for i := range 3 {
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket); err != nil {
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite); err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +167,82 @@ func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
|
||||
func TestGetBucketCredentials_RWKeyWarmsAllTiers(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "rw-secret"
|
||||
|
||||
var bucketCalls, keyCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
bucketCalls++
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
keyCalls++
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "RW",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
// Prime via OpRead — should populate OpRead, OpWrite, and OpRead|OpWrite.
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
|
||||
t.Fatalf("prime OpRead: %v", err)
|
||||
}
|
||||
for _, op := range []Operation{OpWrite, OpRead | OpWrite, OpRead} {
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, op); err != nil {
|
||||
t.Fatalf("op %d: %v", op, err)
|
||||
}
|
||||
}
|
||||
if bucketCalls != 1 {
|
||||
t.Errorf("GetBucketInfo called %d times, want 1 (RW key should warm every tier)", bucketCalls)
|
||||
}
|
||||
if keyCalls != 1 {
|
||||
t.Errorf("GetKeyInfo called %d times, want 1", keyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// A read-only key must NOT populate the write or RW cache slots, otherwise an
|
||||
// OpWrite call would receive credentials the cluster will reject.
|
||||
func TestGetBucketCredentials_ReadOnlyKeyDoesNotPoisonWriteCache(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "ro-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "READ-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
// Warm OpRead cache with the read-only key.
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
|
||||
t.Fatalf("prime OpRead: %v", err)
|
||||
}
|
||||
// OpWrite must still fail — the read-only key must not have leaked into
|
||||
// the write cache slot.
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite); err == nil {
|
||||
t.Fatal("OpWrite served credentials from a read-only key; cache was poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_OpReadWriteSkipsKeysMissingAnyBit(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "good-secret"
|
||||
|
||||
@@ -191,7 +269,7 @@ func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
@@ -204,6 +282,112 @@ func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for issue #44: read-only buckets must remain browsable when
|
||||
// no read+write key is assigned. Before the fix, this case returned
|
||||
// "no valid credentials found for bucket music" and the UI broke entirely.
|
||||
func TestGetBucketCredentials_ReadOnlyFallsBackToReadKey(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "ro-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "READ-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
v, err := creds.GetWithContext(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("creds.GetWithContext: %v", err)
|
||||
}
|
||||
if v.AccessKeyID != "READ-ONLY" {
|
||||
t.Errorf("AccessKeyID = %q, want READ-ONLY", v.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
// Even with only a read-only key available, asking for write credentials must
|
||||
// still fail loudly so uploads/deletes return a meaningful error instead of
|
||||
// silently using a key that the cluster will reject.
|
||||
func TestGetBucketCredentials_ReadOnlyBucketRejectsWriteRequest(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "ro-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "READ-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when only a read-only key exists, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no valid credentials") {
|
||||
t.Errorf("expected 'no valid credentials' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of issue #44 for write-only buckets: uploads must still succeed with a
|
||||
// write-only key, even though no key grants read access.
|
||||
func TestGetBucketCredentials_WriteOnlyFallsBackToWriteKey(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "wo-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "WRITE-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
v, err := creds.GetWithContext(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("creds.GetWithContext: %v", err)
|
||||
}
|
||||
if v.AccessKeyID != "WRITE-ONLY" {
|
||||
t.Errorf("AccessKeyID = %q, want WRITE-ONLY", v.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
|
||||
@@ -219,7 +403,7 @@ func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when bucket has no keys, got nil")
|
||||
}
|
||||
@@ -254,7 +438,7 @@ func TestGetBucketCredentials_KeyWithoutSecretIsSkipped(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
@@ -277,7 +461,7 @@ func TestGetBucketCredentials_AdminErrorPropagates(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when admin call fails, got nil")
|
||||
}
|
||||
|
||||
@@ -64,15 +64,32 @@ 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)
|
||||
cfg, err := config.Load(*configPath, loadOpts...)
|
||||
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,
|
||||
@@ -87,9 +104,21 @@ 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("Initializing Garage Admin service")
|
||||
adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level)
|
||||
logger.Info().Msg("Detecting Garage API version")
|
||||
adminResult, err := services.NewAdminService(&cfg.Garage, cfg.Logging.Level)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API")
|
||||
}
|
||||
adminService := adminResult.Service
|
||||
capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities)
|
||||
|
||||
logger.Info().Msg("Initializing S3 service")
|
||||
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
||||
@@ -102,6 +131,9 @@ 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")
|
||||
}
|
||||
@@ -182,6 +214,7 @@ func main() {
|
||||
userHandler,
|
||||
clusterHandler,
|
||||
monitoringHandler,
|
||||
capabilitiesHandler,
|
||||
)
|
||||
|
||||
// Start server in a goroutine
|
||||
|
||||
@@ -40,6 +40,12 @@ 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
|
||||
@@ -69,7 +75,14 @@ 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
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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
|
||||
@@ -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.13.2",
|
||||
"axios": "^1.15.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.9.6",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -40,11 +40,11 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -91,7 +91,6 @@
|
||||
"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",
|
||||
@@ -1547,9 +1546,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1561,9 +1560,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1575,9 +1574,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"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==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1589,9 +1588,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1603,9 +1602,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1617,9 +1616,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1631,9 +1630,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1645,9 +1644,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1659,9 +1658,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1673,9 +1672,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1687,9 +1686,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1701,9 +1714,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1715,9 +1742,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1729,9 +1756,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1743,9 +1770,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1757,9 +1784,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1771,9 +1798,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1784,10 +1811,24 @@
|
||||
"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.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==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1799,9 +1840,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1813,9 +1854,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1827,9 +1868,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1841,9 +1882,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2089,6 +2130,66 @@
|
||||
"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",
|
||||
@@ -2324,7 +2425,6 @@
|
||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -2335,7 +2435,6 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2346,7 +2445,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2402,7 +2500,6 @@
|
||||
"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",
|
||||
@@ -2557,13 +2654,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -2654,7 +2751,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2765,14 +2861,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -2823,7 +2919,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3343,7 +3438,6 @@
|
||||
"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",
|
||||
@@ -3624,16 +3718,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"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==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -4404,9 +4498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -4556,12 +4650,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4570,9 +4663,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4589,7 +4682,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -4634,10 +4726,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -4654,7 +4749,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -4664,7 +4758,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -4694,7 +4787,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -4718,7 +4810,6 @@
|
||||
"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"
|
||||
@@ -4748,9 +4839,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
|
||||
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -4770,12 +4861,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
|
||||
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.11.0"
|
||||
"react-router": "7.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -4819,8 +4910,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -4848,9 +4938,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4864,28 +4954,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -5072,7 +5165,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5185,12 +5277,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -5311,7 +5402,6 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.13.2",
|
||||
"axios": "^1.15.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.9.6",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -42,10 +42,10 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function ObjectDetailsView() {
|
||||
document.body.removeChild(a);
|
||||
toast.success('Download started');
|
||||
} catch {
|
||||
toast.error('Download failed');
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ObjectDetailsView() {
|
||||
toast.success('Object deleted');
|
||||
navigate(backHref);
|
||||
} catch {
|
||||
toast.error('Delete failed');
|
||||
// error toast handled by axios interceptor
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { capabilitiesApi } from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
|
||||
export function useCapabilities() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.capabilities.get(),
|
||||
queryFn: () => capabilitiesApi.get(),
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ClusterHealth,
|
||||
ClusterStatistics,
|
||||
ClusterStatus,
|
||||
GarageCapabilities,
|
||||
GarageMetrics,
|
||||
MultiNodeResponse,
|
||||
MultiNodeStatisticsResponse,
|
||||
@@ -87,6 +88,11 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 501 Not Implemented = expected for unsupported Garage version features
|
||||
if (error.response?.status === 501) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle axios errors
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
@@ -127,6 +133,7 @@ export const authApi = {
|
||||
const response = await authApiClient.get<{
|
||||
admin: { enabled: boolean };
|
||||
oidc: { enabled: boolean; provider?: string };
|
||||
token: { enabled: boolean };
|
||||
}>('/config');
|
||||
return response;
|
||||
},
|
||||
@@ -139,6 +146,13 @@ 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;
|
||||
@@ -167,6 +181,14 @@ 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[]> => {
|
||||
|
||||
@@ -40,4 +40,8 @@ export const queryKeys = {
|
||||
all: ['dashboard'] as const,
|
||||
metrics: () => [...queryKeys.dashboard.all, 'metrics'] as const,
|
||||
},
|
||||
capabilities: {
|
||||
all: ['capabilities'] as const,
|
||||
get: () => [...queryKeys.capabilities.all, 'get'] as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -131,6 +132,7 @@ export function AccessControl() {
|
||||
const [permissionRead, setPermissionRead] = useState(false);
|
||||
const [permissionWrite, setPermissionWrite] = useState(false);
|
||||
const [permissionOwner, setPermissionOwner] = useState(false);
|
||||
const [savingPermissions, setSavingPermissions] = useState(false);
|
||||
|
||||
// Key settings state (activation/expiration)
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
@@ -383,6 +385,7 @@ export function AccessControl() {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingPermissions(true);
|
||||
try {
|
||||
// Call backend API to grant bucket permissions
|
||||
await bucketsApi.grantPermission(selectedBucket, editingKey.accessKeyId, {
|
||||
@@ -404,6 +407,8 @@ export function AccessControl() {
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
console.error('Grant permission error:', error);
|
||||
} finally {
|
||||
setSavingPermissions(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -814,194 +819,125 @@ export function AccessControl() {
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Key Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedKey?.name}"? Applications using this key
|
||||
will lose access immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteKey}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={`Delete "${selectedKey?.name ?? ''}"?`}
|
||||
description="Applications using this key will lose access immediately."
|
||||
confirmLabel="Delete key"
|
||||
onConfirm={handleDeleteKey}
|
||||
/>
|
||||
|
||||
{/* Secret Key Dialog */}
|
||||
<Dialog open={secretKeyDialogOpen} onOpenChange={setSecretKeyDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<Dialog open={secretKeyDialogOpen} onOpenChange={setSecretKeyDialogOpen} size="form">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Secret Access Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy your secret access key now. For security reasons, it cannot be viewed again.
|
||||
</DialogDescription>
|
||||
<IconTile icon={<KeyRound />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate">{selectedKey?.name || 'Access key'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reveal and copy this key's credentials. The secret is fetched on demand.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{selectedKey?.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (selectedKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(selectedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedKey?.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (selectedKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(selectedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingSecretKey ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading secret key...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (revealedSecretKey) {
|
||||
navigator.clipboard.writeText(revealedSecretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{revealedSecretKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (revealedSecretKey) {
|
||||
navigator.clipboard.writeText(revealedSecretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
disabled={!revealedSecretKey}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogBody className="space-y-4">
|
||||
<CredentialField
|
||||
label="Access Key ID"
|
||||
value={selectedKey?.accessKeyId || ''}
|
||||
breakAll
|
||||
/>
|
||||
<CredentialField
|
||||
label="Secret Access Key"
|
||||
value={revealedSecretKey}
|
||||
breakAll
|
||||
maskable
|
||||
loading={isLoadingSecretKey}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setSecretKeyDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => setSecretKeyDialogOpen(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Settings Dialog */}
|
||||
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen} size="form">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Key Settings - {settingsKey?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage activation status and expiration date for this API key
|
||||
</DialogDescription>
|
||||
<IconTile icon={<ShieldCheck />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate">Key settings · {settingsKey?.name}</DialogTitle>
|
||||
<DialogDescription>Manage activation and expiration for this access key.</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Status */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="active"
|
||||
checked={keyStatus === 'active'}
|
||||
onChange={(e) => setKeyStatus(e.target.value as 'active' | 'inactive')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="inactive"
|
||||
checked={keyStatus === 'inactive'}
|
||||
onChange={(e) => setKeyStatus(e.target.value as 'active' | 'inactive')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Inactive</span>
|
||||
</label>
|
||||
<DialogBody className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
|
||||
Status
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['active', 'inactive'] as const).map((s) => {
|
||||
const selected = keyStatus === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setKeyStatus(s)}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-md border px-3 py-2.5 text-[13.5px] font-medium transition-colors',
|
||||
selected
|
||||
? 'border-[var(--primary)] bg-[var(--accent-primary-soft)] text-[var(--foreground)]'
|
||||
: 'border-[var(--border)] text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
|
||||
)}
|
||||
>
|
||||
{s === 'active' ? <ShieldCheck className="h-4 w-4" /> : <ShieldX className="h-4 w-4" />}
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Inactive keys cannot be used for authentication
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Inactive keys cannot be used for authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Expiration</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="never-expires"
|
||||
checked={neverExpires}
|
||||
onCheckedChange={(checked) => setNeverExpires(checked as boolean)}
|
||||
/>
|
||||
<span className="text-sm">Never expires</span>
|
||||
</label>
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<Checkbox
|
||||
id="never-expires"
|
||||
checked={neverExpires}
|
||||
className="mt-0.5"
|
||||
onCheckedChange={(checked) => setNeverExpires(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">Never expires</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Turn off to set an automatic expiration date.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!neverExpires && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Expiration Date & Time</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={expirationDate}
|
||||
onChange={(e) => setExpirationDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Key will automatically become inactive after this date
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!neverExpires && (
|
||||
<div className="space-y-1.5 border-t border-[var(--border)] pt-4">
|
||||
<label className="text-[13px] font-medium">Expiration date & time</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={expirationDate}
|
||||
onChange={(e) => setExpirationDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
The key will become inactive after this moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setSettingsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveKeySettings}>
|
||||
Save Settings
|
||||
</Button>
|
||||
<Button onClick={handleSaveKeySettings}>Save settings</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1119,173 +1055,134 @@ export function AccessControl() {
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Permissions Dialog */}
|
||||
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen} size="form">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Bucket Permissions - {editingKey?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grant this access key permissions on buckets
|
||||
</DialogDescription>
|
||||
<IconTile icon={<Edit />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate">Bucket permissions · {editingKey?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a bucket, then toggle the scopes this key should have on it.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Bucket Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Bucket</label>
|
||||
<Select
|
||||
value={selectedBucket}
|
||||
onChange={(value) => handleBucketChange(value)}
|
||||
>
|
||||
<SelectOption value="">-- Select a bucket --</SelectOption>
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[13px] font-medium">Bucket</label>
|
||||
<Select value={selectedBucket} onChange={(value) => handleBucketChange(value)}>
|
||||
<SelectOption value="">Select a bucket…</SelectOption>
|
||||
{availableBuckets.map((bucket) => (
|
||||
<SelectOption key={bucket.name} value={bucket.name}>
|
||||
{bucket.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which bucket this key should have permissions on. Current permissions will be displayed when selected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-3 border rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="edit-permission-read"
|
||||
checked={permissionRead}
|
||||
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="edit-permission-read"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Read
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="edit-permission-write"
|
||||
checked={permissionWrite}
|
||||
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="edit-permission-write"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Write
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="edit-permission-owner"
|
||||
checked={permissionOwner}
|
||||
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="edit-permission-owner"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Owner
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Permissions Info */}
|
||||
{selectedBucket && editingKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Current Status</label>
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
{(() => {
|
||||
const bucketPermission = editingKey.permissions.find(
|
||||
perm => perm.bucketName === selectedBucket || perm.bucketId === selectedBucket
|
||||
);
|
||||
|
||||
if (bucketPermission) {
|
||||
const hasPermissions = bucketPermission.read || bucketPermission.write || bucketPermission.owner;
|
||||
if (hasPermissions) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
This key currently has the following permissions on this bucket:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bucketPermission.read && (
|
||||
<Badge variant="neutral">Read</Badge>
|
||||
)}
|
||||
{bucketPermission.write && (
|
||||
<Badge variant="neutral">Write</Badge>
|
||||
)}
|
||||
{bucketPermission.owner && (
|
||||
<Badge variant="neutral">Owner</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Modify the checkboxes above to update permissions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This key has no permissions on this bucket yet. Select permissions above to grant access.
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Bucket Permissions List */}
|
||||
{editingKey && editingKey.permissions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Current Bucket Permissions</label>
|
||||
<div className="border rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{editingKey.permissions.map((perm, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-sm p-2 bg-muted/30 rounded">
|
||||
<span className="font-medium">{perm.bucketName}</span>
|
||||
<div className="flex gap-1">
|
||||
{perm.read && <Badge variant="neutral" className="text-xs">R</Badge>}
|
||||
{perm.write && <Badge variant="neutral" className="text-xs">W</Badge>}
|
||||
{perm.owner && <Badge variant="neutral" className="text-xs">O</Badge>}
|
||||
{selectedBucket && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[13px] font-medium">Permissions</label>
|
||||
<div className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
|
||||
{[
|
||||
{
|
||||
id: 'edit-permission-read',
|
||||
label: 'Read',
|
||||
desc: 'GetObject, HeadObject, ListObjects',
|
||||
checked: permissionRead,
|
||||
setChecked: setPermissionRead,
|
||||
},
|
||||
{
|
||||
id: 'edit-permission-write',
|
||||
label: 'Write',
|
||||
desc: 'PutObject, DeleteObject',
|
||||
checked: permissionWrite,
|
||||
setChecked: setPermissionWrite,
|
||||
},
|
||||
{
|
||||
id: 'edit-permission-owner',
|
||||
label: 'Owner',
|
||||
desc: 'DeleteBucket, PutBucketPolicy',
|
||||
checked: permissionOwner,
|
||||
setChecked: setPermissionOwner,
|
||||
},
|
||||
].map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
htmlFor={p.id}
|
||||
className="flex cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[var(--accent)]"
|
||||
>
|
||||
<Checkbox
|
||||
id={p.id}
|
||||
checked={p.checked}
|
||||
className="mt-0.5"
|
||||
onCheckedChange={(checked) => p.setChecked(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">{p.label}</div>
|
||||
<p className="mt-0.5 font-mono text-[12px] text-[var(--muted-foreground)]">
|
||||
{p.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingKey && (() => {
|
||||
const current = editingKey.permissions.find(
|
||||
(perm) => perm.bucketName === selectedBucket || perm.bucketId === selectedBucket,
|
||||
);
|
||||
const hasAny = current && (current.read || current.write || current.owner);
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-sunken)] px-3.5 py-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
|
||||
Currently granted
|
||||
</div>
|
||||
{hasAny ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{current!.read && <Badge variant="neutral">Read</Badge>}
|
||||
{current!.write && <Badge variant="neutral">Write</Badge>}
|
||||
{current!.owner && <Badge variant="warning">Owner</Badge>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-[12.5px] text-[var(--muted-foreground)]">
|
||||
No permissions on this bucket yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingKey && editingKey.permissions.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
|
||||
All bucket permissions for this key
|
||||
</label>
|
||||
<div className="max-h-48 divide-y divide-[var(--border)] overflow-y-auto rounded-md border border-[var(--border)]">
|
||||
{editingKey.permissions.map((perm, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between gap-3 px-3.5 py-2.5">
|
||||
<span className="truncate font-mono text-[13px]">{perm.bucketName}</span>
|
||||
<div className="flex flex-shrink-0 gap-1">
|
||||
{perm.read && <Badge variant="neutral">R</Badge>}
|
||||
{perm.write && <Badge variant="neutral">W</Badge>}
|
||||
{perm.owner && <Badge variant="warning">O</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setEditPermissionsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGrantBucketPermission}
|
||||
disabled={!selectedBucket}
|
||||
>
|
||||
Grant Permission
|
||||
<Button onClick={handleGrantBucketPermission} disabled={!selectedBucket || savingPermissions}>
|
||||
{savingPermissions && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{savingPermissions ? 'Saving...' : 'Save permissions'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -44,10 +44,9 @@ export function BucketPermissions() {
|
||||
accessKeyId: selectedKey,
|
||||
permissions: { read, write, owner },
|
||||
});
|
||||
toast.success('Permissions granted');
|
||||
setSelectedKey(''); setRead(false); setWrite(false); setOwner(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Grant failed');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate as formatDateUtil } from '@/lib/utils';
|
||||
|
||||
@@ -43,10 +42,8 @@ export function BucketSettings() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(bucket.name);
|
||||
toast.success('Bucket deleted');
|
||||
navigate('/buckets');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,8 +65,8 @@ export function BucketWebsite() {
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['buckets'] });
|
||||
toast.success(disabling ? 'Website disabled' : 'Website configuration updated');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function Buckets() {
|
||||
const navigate = useNavigate();
|
||||
@@ -24,10 +23,8 @@ export function Buckets() {
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({ name, region });
|
||||
toast.success(`Bucket "${name}" created`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Create failed');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -37,10 +34,9 @@ export function Buckets() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.name);
|
||||
toast.success('Bucket deleted');
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,33 @@ import {Badge} from '@/components/ui/badge';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import type {ClusterNode, LocalNodeInfo, NodeStatistics} from '@/types';
|
||||
import {useState} from 'react';
|
||||
import { useCapabilities } from '@/hooks/useCapabilities';
|
||||
|
||||
function UnsupportedFeatureCard({ title, description }: { title: string; description?: string }) {
|
||||
return (
|
||||
<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(),
|
||||
@@ -28,18 +51,19 @@ export function Cluster() {
|
||||
queryKey: ['cluster-statistics'],
|
||||
queryFn: () => garageApi.getClusterStatistics(),
|
||||
refetchInterval: 30000,
|
||||
enabled: features?.clusterStatistics !== false,
|
||||
});
|
||||
|
||||
const { data: nodeInfo, isLoading: nodeInfoLoading } = useQuery({
|
||||
queryKey: ['node-info', selectedNodeId || '*'],
|
||||
queryFn: () => garageApi.getNodeInfo(selectedNodeId || '*'),
|
||||
enabled: !!selectedNodeId || selectedNodeId === null,
|
||||
enabled: features?.nodeInfo !== false && (!!selectedNodeId || selectedNodeId === null),
|
||||
});
|
||||
|
||||
const { data: nodeStats } = useQuery({
|
||||
queryKey: ['node-statistics', selectedNodeId || '*'],
|
||||
queryFn: () => garageApi.getNodeStatistics(selectedNodeId || '*'),
|
||||
enabled: !!selectedNodeId,
|
||||
enabled: features?.nodeStatistics !== false && !!selectedNodeId,
|
||||
});
|
||||
|
||||
const isLoading = healthLoading || statusLoading || statisticsLoading;
|
||||
@@ -311,119 +335,129 @@ export function Cluster() {
|
||||
|
||||
{/* Statistics Tab */}
|
||||
<TabsContent value="statistics" className="space-y-4">
|
||||
<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>
|
||||
{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>
|
||||
</div>
|
||||
</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 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 ? (
|
||||
<>
|
||||
<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>
|
||||
{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="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="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">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">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">Database Engine</div>
|
||||
<div className="text-sm">{(info as LocalNodeInfo).dbEngine}</div>
|
||||
</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>
|
||||
|
||||
{(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 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>
|
||||
)}
|
||||
</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>
|
||||
{(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>
|
||||
<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 && (
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{features?.nodeStatistics === false ? (
|
||||
<UnsupportedFeatureCard title="Node Statistics" description="Per-node performance metrics" />
|
||||
) : nodeStats ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Node Statistics</CardTitle>
|
||||
@@ -464,7 +498,7 @@ export function Cluster() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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() {
|
||||
@@ -14,9 +15,7 @@ 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));
|
||||
});
|
||||
@@ -24,7 +23,6 @@ export function Login() {
|
||||
}, [loginSuccess, initialize, navigate, returnUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
// If already authenticated, redirect to return URL
|
||||
if (isAuthenticated && !loginSuccess) {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
}
|
||||
@@ -35,16 +33,27 @@ export function Login() {
|
||||
}
|
||||
|
||||
// No auth enabled, redirect to dashboard immediately
|
||||
if (config && !config.admin.enabled && !config.oidc.enabled) {
|
||||
if (config && !config.admin.enabled && !config.oidc.enabled && !config.token.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;
|
||||
|
||||
// If both are enabled, show both options in single modal
|
||||
// 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 (showAdmin && showOIDC) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
@@ -55,12 +64,12 @@ export function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show only OIDC if enabled
|
||||
// Only OIDC
|
||||
if (showOIDC) {
|
||||
return <OIDCLoginView />;
|
||||
}
|
||||
|
||||
// Show only admin if enabled
|
||||
// Only admin
|
||||
if (showAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
@@ -71,6 +80,5 @@ export function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading config
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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>;
|
||||
}
|
||||
@@ -45,7 +46,7 @@ export const useAuthStore = create<AuthStore>()(
|
||||
set({ config });
|
||||
|
||||
// If no auth is enabled, mark as authenticated immediately
|
||||
if (!config.admin.enabled && !config.oidc.enabled) {
|
||||
if (!config.admin.enabled && !config.oidc.enabled && !config.token.enabled) {
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -113,6 +114,36 @@ 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';
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface AuthConfig {
|
||||
enabled: boolean;
|
||||
provider?: string;
|
||||
};
|
||||
token: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
@@ -232,6 +232,15 @@ 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;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## [0.4.2](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.4.1...garage-ui-chart-v0.4.2) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **helm:** update appVersion format and improve image tag handling ([709b9f2](https://github.com/Noooste/garage-ui/commit/709b9f2ad33fb3852bc23be1d647bb1d9388169b))
|
||||
|
||||
## [0.4.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.4.0...garage-ui-chart-v0.4.1) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Helm image tag ([#53](https://github.com/Noooste/garage-ui/issues/53)) ([ff46ff6](https://github.com/Noooste/garage-ui/commit/ff46ff623299461abade27478128f7e5ce409557))
|
||||
|
||||
## [0.4.0](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.3.0...garage-ui-chart-v0.4.0) (2026-05-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add extraEnvs to helm chart to allow config override ([#45](https://github.com/Noooste/garage-ui/issues/45)) ([c8337de](https://github.com/Noooste/garage-ui/commit/c8337de3a885fc86a08a9be329a60c0846e52d62))
|
||||
* add initial Helm chart for Garage UI deployment ([067e00b](https://github.com/Noooste/garage-ui/commit/067e00b474cb16ea81cc1b3b0584627e2e86fae6))
|
||||
* add JWT key generator job and associated RBAC resources; create Kubernetes secret for JWT private key ([27cca27](https://github.com/Noooste/garage-ui/commit/27cca273d5eb3bba4a9973e684d27d4eb8748ec7))
|
||||
* add namespace to metadata in Kubernetes resource files ([98842d7](https://github.com/Noooste/garage-ui/commit/98842d7c7e1fd2a4a89b05412964234e851328ba))
|
||||
* add support for using Kubernetes secrets for admin token management ([d36f891](https://github.com/Noooste/garage-ui/commit/d36f8915acc0085c9e8c5fb7a28c895bb3ba687a))
|
||||
* bump Helm chart version to 0.1.6 and update endpoint pattern in values schema ([7aa5aeb](https://github.com/Noooste/garage-ui/commit/7aa5aeb4f8355cce0d046a6fd384559fb6f3ae26))
|
||||
* bump version to 0.1.10; update version badge in README ([bde726f](https://github.com/Noooste/garage-ui/commit/bde726fedff589f5aab034ce1ed35a53980f7f3e))
|
||||
* bump version to 0.1.11 ([b8056c6](https://github.com/Noooste/garage-ui/commit/b8056c6d79754273dddaf0c0c5e4b1a10440d6a7))
|
||||
* bump version to 0.1.4 and appVersion to 0.0.10; update authentication secrets in deployment and values ([62b7676](https://github.com/Noooste/garage-ui/commit/62b76769d0128faf8924cb05dfb2f8525f41b09d))
|
||||
* bump version to 0.1.7 and appVersion to 0.0.11; update version badges in README ([7b31490](https://github.com/Noooste/garage-ui/commit/7b314904884b9031c68aa13bcfcddd9cf99ea2d2))
|
||||
* bump version to 0.1.8 and appVersion to 0.1.0; update version badges in README ([f5deb03](https://github.com/Noooste/garage-ui/commit/f5deb0316b0d76255c06112d9eb9e6c94b1fcdd2))
|
||||
* bump version to 0.1.9; update JWT key handling in deployment and secret management; enhance README with JWT key management instructions ([c1a1d64](https://github.com/Noooste/garage-ui/commit/c1a1d64928940294412db87ffdd6de817868b4d6))
|
||||
* enhance admin role checks to support multiple roles configuration ([#43](https://github.com/Noooste/garage-ui/issues/43)) ([ff3977a](https://github.com/Noooste/garage-ui/commit/ff3977aeb532fcd284fbd65b3944e277721068f6))
|
||||
* enhance authentication and upload features; add JWT support and upload progress component ([80553c6](https://github.com/Noooste/garage-ui/commit/80553c64500df66a13f6d8c9b9835fdfce679c84))
|
||||
* enhance S3 service to use bucket-specific MinIO client for object retrieval and deletion ([b8b0b6b](https://github.com/Noooste/garage-ui/commit/b8b0b6b0fa961bc1d1392ab4421ee85931cd0dc0))
|
||||
* implement admin and OIDC authentication methods; add login and user info endpoints ([61dae6c](https://github.com/Noooste/garage-ui/commit/61dae6c605bfc29a2a79bb2c5d485041118a3b49))
|
||||
* refactor JWT key generation to create a Kubernetes secret directly; remove job and RBAC resources ([65447b9](https://github.com/Noooste/garage-ui/commit/65447b927a72fcb89d32daa2de2cc4215ba2e8cc))
|
||||
* update Helm chart version to 0.1.5 and add values schema for configuration ([b4bdd13](https://github.com/Noooste/garage-ui/commit/b4bdd1374394780aa19d8bdf1905ec137cdbcf0e))
|
||||
* update README version badge to 0.1.6 ([4864185](https://github.com/Noooste/garage-ui/commit/48641851838c910f50a0c772edf97f93ae848431))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** remove CHANGELOG.md seeds so release-please owns them ([#51](https://github.com/Noooste/garage-ui/issues/51)) ([cb3839e](https://github.com/Noooste/garage-ui/commit/cb3839e6189ec1d2a6609ff500ab7a79d0f5cbd9))
|
||||
* update appVersion to v0.0.4 in Chart.yaml ([983e6e5](https://github.com/Noooste/garage-ui/commit/983e6e5fa92788deda70000e28b594f4d7f65052))
|
||||
@@ -3,8 +3,8 @@ name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
icon: https://helm.noste.dev/garage.png
|
||||
type: application
|
||||
version: 0.2.3
|
||||
appVersion: "v0.3.0"
|
||||
version: 0.4.2
|
||||
appVersion: v0.6.1
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
@@ -17,4 +17,4 @@ sources:
|
||||
- https://github.com/Noooste/garage-ui
|
||||
maintainers:
|
||||
- name: Noooste
|
||||
kubeVersion: ">=1.19.0-0"
|
||||
kubeVersion: '>=1.19.0-0'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ spec:
|
||||
key: admin-password
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.extraEnvs }}
|
||||
{{- toYaml .Values.extraEnvs | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -12,6 +12,8 @@ imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
extraEnvs: []
|
||||
|
||||
config:
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
@@ -84,9 +86,13 @@ config:
|
||||
email_attribute: "email"
|
||||
username_attribute: "preferred_username"
|
||||
name_attribute: "name"
|
||||
# Role-based access control
|
||||
# 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_attribute_path: "resource_access.garage-ui.roles"
|
||||
admin_role: "admin"
|
||||
admin_roles: []
|
||||
# TLS settings
|
||||
tls_skip_verify: false
|
||||
# Session settings
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": false,
|
||||
"separate-pull-requests": false,
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "go",
|
||||
"component": "garage-ui",
|
||||
"include-component-in-tag": false,
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"exclude-paths": ["helm/garage-ui"]
|
||||
},
|
||||
"helm/garage-ui": {
|
||||
"release-type": "helm",
|
||||
"component": "garage-ui-chart",
|
||||
"package-name": "garage-ui",
|
||||
"include-component-in-tag": true,
|
||||
"changelog-path": "CHANGELOG.md"
|
||||
}
|
||||
}
|
||||
}
|
||||