mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d502dac457 | |||
| 67d8f633b0 | |||
| 22be89b2ff | |||
| ae97dd8f01 | |||
| 45f8770799 | |||
| e3191c2686 | |||
| 24997db960 | |||
| 3c69cc5f26 | |||
| 4b0e98008b | |||
| bc67e50606 | |||
| fe1765597c | |||
| 1c9043c697 | |||
| b80cfef844 | |||
| 186af18d54 | |||
| 4b3a562acb | |||
| 5427758eaa | |||
| c30400cf84 | |||
| d05b9ce324 | |||
| 1f16edd39c | |||
| 36ec8e800e | |||
| 1b645b0c2c | |||
| dc9ea5716a | |||
| 699f11afaa | |||
| 657d919331 | |||
| 01c4c16778 | |||
| 3521e63073 | |||
| 71d3c8446a | |||
| 709b9f2ad3 |
@@ -5,6 +5,7 @@ on:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -4,10 +4,13 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'garage-ui-chart-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -26,7 +29,7 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.1
|
||||
with:
|
||||
version: v3.19.3
|
||||
version: v4.1.3
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
@@ -35,3 +38,43 @@ jobs:
|
||||
skip_existing: true
|
||||
env:
|
||||
CR_TOKEN: ${{ secrets.HELM_RELEASE_TOKEN }}
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v4.1.2
|
||||
|
||||
- name: Log in to ghcr.io for cosign
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Package and push chart to ghcr.io (OCI)
|
||||
id: oci_push
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chart_version=$(grep -E '^version:' helm/garage-ui/Chart.yaml | awk '{print $2}')
|
||||
echo "Packaging chart version ${chart_version}"
|
||||
helm package helm/garage-ui --destination /tmp/chart
|
||||
|
||||
echo "${GHCR_TOKEN}" | helm registry login ghcr.io \
|
||||
--username "${GHCR_USER}" --password-stdin
|
||||
|
||||
push_output=$(helm push "/tmp/chart/garage-ui-${chart_version}.tgz" \
|
||||
oci://ghcr.io/noooste/charts 2>&1 | tee /dev/stderr)
|
||||
|
||||
digest=$(echo "$push_output" | grep -oE 'sha256:[a-f0-9]{64}' | head -n1)
|
||||
if [ -z "$digest" ]; then
|
||||
echo "Failed to parse pushed digest from helm push output" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
echo "Pushed oci://ghcr.io/noooste/charts/garage-ui:${chart_version} (${digest})"
|
||||
|
||||
- name: Sign chart with cosign (keyless)
|
||||
run: |
|
||||
cosign sign --yes \
|
||||
"ghcr.io/noooste/charts/garage-ui@${{ steps.oci_push.outputs.digest }}"
|
||||
|
||||
@@ -13,8 +13,99 @@ jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- 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: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Sync Chart.yaml appVersion on release PR
|
||||
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
|
||||
|
||||
- name: Cut chart patch when backend released without chart
|
||||
if: ${{ steps.rp.outputs.releases_created == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BACKEND_RELEASED: ${{ steps.rp.outputs.release_created }}
|
||||
CHART_RELEASED: ${{ steps.rp.outputs['helm/garage-ui--release_created'] }}
|
||||
BACKEND_TAG: ${{ steps.rp.outputs.tag_name }}
|
||||
BACKEND_VERSION: ${{ steps.rp.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BACKEND_RELEASED" != "true" ] || [ "$CHART_RELEASED" = "true" ]; then
|
||||
echo "Backend not released, or chart already released this cycle. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
workdir=$(mktemp -d)
|
||||
git clone \
|
||||
"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]"
|
||||
|
||||
chart_old=$(jq -r '."helm/garage-ui"' .release-please-manifest.json)
|
||||
chart_new=$(echo "$chart_old" | awk -F. '{printf "%d.%d.%d", $1, $2, $3+1}')
|
||||
chart_tag="garage-ui-chart-v${chart_new}"
|
||||
|
||||
jq --arg v "$chart_new" '."helm/garage-ui" = $v' \
|
||||
.release-please-manifest.json > .release-please-manifest.json.tmp
|
||||
mv .release-please-manifest.json.tmp .release-please-manifest.json
|
||||
|
||||
sed -i -E "s|^version:.*|version: ${chart_new}|" helm/garage-ui/Chart.yaml
|
||||
sed -i -E "s|^appVersion:.*|appVersion: ${BACKEND_TAG}|" helm/garage-ui/Chart.yaml
|
||||
|
||||
today=$(date -u +%Y-%m-%d)
|
||||
compare_url="https://github.com/${REPO}/compare/garage-ui-chart-v${chart_old}...${chart_tag}"
|
||||
entry=$(printf '## [%s](%s) (%s)\n\n\n### Miscellaneous Chores\n\n* sync chart appVersion to %s\n' \
|
||||
"$chart_new" "$compare_url" "$today" "$BACKEND_TAG")
|
||||
awk -v entry="$entry" 'NR==1 && /^# Changelog/ { print; print ""; print entry; next } { print }' \
|
||||
helm/garage-ui/CHANGELOG.md > helm/garage-ui/CHANGELOG.md.tmp
|
||||
mv helm/garage-ui/CHANGELOG.md.tmp helm/garage-ui/CHANGELOG.md
|
||||
|
||||
git add .release-please-manifest.json helm/garage-ui/Chart.yaml helm/garage-ui/CHANGELOG.md
|
||||
git commit -m "chore: bump chart to ${chart_new} for backend ${BACKEND_TAG}"
|
||||
git push origin main
|
||||
|
||||
git tag "$chart_tag"
|
||||
git push origin "$chart_tag"
|
||||
|
||||
gh release create "$chart_tag" \
|
||||
--repo "$REPO" \
|
||||
--title "$chart_tag" \
|
||||
--notes "Chart patch synced to backend ${BACKEND_TAG}. No chart template changes."
|
||||
|
||||
+2
-2
@@ -65,5 +65,5 @@ backend/docs/
|
||||
config.yaml
|
||||
docs/**/*.md
|
||||
!docs/garage-setup.md
|
||||
# Superpowers brainstorm / session scratch
|
||||
.superpowers/
|
||||
|
||||
**/worktrees
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
".": "0.6.1",
|
||||
"helm/garage-ui": "0.4.1"
|
||||
".": "0.8.2",
|
||||
"helm/garage-ui": "0.6.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.2](https://github.com/Noooste/garage-ui/compare/v0.8.1...v0.8.2) (2026-06-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** prevent OIDC login loop from empty cookie name ([#76](https://github.com/Noooste/garage-ui/issues/76)) ([22be89b](https://github.com/Noooste/garage-ui/commit/22be89b2ff86465abb90dab0344ef9366ab181b3))
|
||||
|
||||
## [0.8.1](https://github.com/Noooste/garage-ui/compare/v0.8.0...v0.8.1) (2026-05-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** align three-dot menu item icon spacing and text alignment ([#72](https://github.com/Noooste/garage-ui/issues/72)) ([45f8770](https://github.com/Noooste/garage-ui/commit/45f87707996e92d0f8f75e79c8f60a13556eaf6e))
|
||||
|
||||
## [0.8.0](https://github.com/Noooste/garage-ui/compare/v0.7.0...v0.8.0) (2026-05-31)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
|
||||
|
||||
### Features
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
|
||||
* **docs:** add documentation generation command to Makefile ([186af18](https://github.com/Noooste/garage-ui/commit/186af18d54f739bd9b467cbf3ab9ccc2e92ddf62))
|
||||
|
||||
## [0.7.0](https://github.com/Noooste/garage-ui/compare/v0.6.2...v0.7.0) (2026-05-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backend,frontend:** enable quotas support in bucket settings ([#64](https://github.com/Noooste/garage-ui/issues/64)) ([1f16edd](https://github.com/Noooste/garage-ui/commit/1f16edd39cfa2f3a51cb576871642c9d23545781))
|
||||
* **backend:** Support _FILE suffix on sensitive env variables ([#63](https://github.com/Noooste/garage-ui/issues/63)) ([36ec8e8](https://github.com/Noooste/garage-ui/commit/36ec8e800ea0ff5306e4d0f9f4aea4f72a5a109b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** remove auto-enable token auth logic ([1b645b0](https://github.com/Noooste/garage-ui/commit/1b645b0c2c6e05dea98a7fc5d7595ca015913770))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+1
-2
@@ -48,7 +48,7 @@ RUN addgroup -g 1000 garageui && \
|
||||
adduser -D -u 1000 -G garageui garageui
|
||||
|
||||
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
COPY --from=frontend-builder --chown=garageui:garageui /app/frontend/dist ./frontend/dist
|
||||
|
||||
USER garageui
|
||||
|
||||
@@ -58,4 +58,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["./garage-ui"]
|
||||
|
||||
|
||||
@@ -154,3 +154,9 @@ test-cover:
|
||||
## test-smoke: Run the docker compose smoke test (requires Docker + compose v2)
|
||||
test-smoke:
|
||||
cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/...
|
||||
|
||||
docs:
|
||||
@echo "Generating documentation..."
|
||||
@mkdir -p docs
|
||||
@echo "Documentation generated in the 'docs' directory."
|
||||
swag init -g backend/cmd/garage-ui/main.go -o docs --parseDependency --parseInternal
|
||||
@@ -1,6 +1,6 @@
|
||||
<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://github.com/Noooste/garage-ui/actions/workflows/chart-release.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/chart-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>
|
||||
@@ -127,6 +127,16 @@ garage:
|
||||
region: "garage"
|
||||
```
|
||||
|
||||
Server bind host is configured by `server.host` (default: `::`). IPv6 literals like `::` and `::1` are supported.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "::" # IPv6 wildcard (dual-stack-preferred)
|
||||
port: 8080
|
||||
```
|
||||
|
||||
If your environment needs explicit IPv4-only binding, set `server.host: "0.0.0.0"`.
|
||||
|
||||
See [config.example.yaml](config.example.yaml) for all options including authentication, CORS, and logging.
|
||||
|
||||
### Environment Variables
|
||||
@@ -139,6 +149,37 @@ GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
|
||||
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
|
||||
```
|
||||
|
||||
#### Loading sensitive values from files (`_FILE` suffix)
|
||||
|
||||
For Docker/Kubernetes secret integration, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui reads the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file causes startup to fail.
|
||||
|
||||
Supported vars:
|
||||
|
||||
- `GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE`
|
||||
- `GARAGE_UI_AUTH_ADMIN_USERNAME_FILE`
|
||||
- `GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE`
|
||||
- `GARAGE_UI_AUTH_JWT_PRIVATE_KEY_FILE`
|
||||
- `GARAGE_UI_AUTH_OIDC_CLIENT_ID_FILE`
|
||||
- `GARAGE_UI_AUTH_OIDC_CLIENT_SECRET_FILE`
|
||||
|
||||
Example with Docker Compose secrets:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
garage-ui:
|
||||
image: noooste/garage-ui:latest
|
||||
environment:
|
||||
GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
|
||||
secrets:
|
||||
- admin_password
|
||||
|
||||
secrets:
|
||||
admin_password:
|
||||
file: ./admin_password.txt
|
||||
```
|
||||
|
||||
This matches the convention used by the official Postgres and MySQL Docker images. Helm users do not need this — the chart already injects secrets via `existingSecret` references.
|
||||
|
||||
## Garage Configuration
|
||||
|
||||
Garage UI requires these settings in your `garage.toml`:
|
||||
|
||||
+18
-16
@@ -6,13 +6,14 @@ require (
|
||||
github.com/Noooste/azuretls-client v1.13.2
|
||||
github.com/Noooste/swagger v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/gofiber/fiber/v3 v3.1.0
|
||||
github.com/gofiber/fiber/v3 v3.3.0
|
||||
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/minio/minio-go/v7 v7.1.0
|
||||
github.com/pelletier/go-toml/v2 v2.3.1
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
@@ -25,10 +26,11 @@ require (
|
||||
github.com/Noooste/websocket v1.0.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gaukas/clienthellod v0.4.2 // indirect
|
||||
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
@@ -45,13 +47,13 @@ require (
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gofiber/schema v1.7.1 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.3 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.6 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
@@ -64,17 +66,17 @@ require (
|
||||
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
|
||||
github.com/valyala/fasthttp v1.71.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
)
|
||||
|
||||
@@ -20,6 +20,9 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
|
||||
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/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
@@ -34,6 +37,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
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/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
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=
|
||||
@@ -80,10 +85,14 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
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/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8=
|
||||
github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA=
|
||||
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.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI=
|
||||
github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
|
||||
github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs=
|
||||
github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -96,6 +105,8 @@ 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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -109,18 +120,24 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/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.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||
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/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -135,6 +152,8 @@ 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.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
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=
|
||||
@@ -161,10 +180,14 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
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=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
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=
|
||||
@@ -173,16 +196,22 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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=
|
||||
@@ -192,12 +221,18 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
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=
|
||||
|
||||
@@ -322,8 +322,21 @@ func extractRoles(claims map[string]interface{}, path string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractStringArray converts an interface{} to []string if possible
|
||||
// extractStringArray converts an interface{} to []string if possible.
|
||||
//
|
||||
// A scalar string is treated as a single-element list: IdPs commonly emit a
|
||||
// single role as a bare string (e.g. "garage_role": "garage-ui-admin") rather
|
||||
// than a one-element array, and discarding it would make admin_role checks
|
||||
// fail with a spurious 403, see https://github.com/Noooste/garage-ui/issues/75
|
||||
func extractStringArray(value interface{}) []string {
|
||||
// Try a scalar string (single role emitted as a bare value)
|
||||
if str, ok := value.(string); ok {
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{str}
|
||||
}
|
||||
|
||||
// Try direct string array
|
||||
if strArray, ok := value.([]string); ok {
|
||||
return strArray
|
||||
|
||||
@@ -564,8 +564,11 @@ func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
|
||||
// Final value is a plain string, not an array — extractStringArray returns nil.
|
||||
func TestExtractRolesFromAccessToken_ScalarStringRoleReturnsSingleElement(t *testing.T) {
|
||||
// A role_attribute_path that resolves to a scalar string (common when an IdP
|
||||
// emits a single role, e.g. "garage_role": "garage-ui-admin") must be treated
|
||||
// as a one-element role list, not silently discarded. Discarding it caused
|
||||
// admin_role (singular) + scalar claim to yield roles=[] and a spurious 403.
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"roles": "admin",
|
||||
})
|
||||
@@ -574,8 +577,25 @@ func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
got := svc.ExtractRolesFromAccessToken(tok)
|
||||
if len(got) != 1 || got[0] != "admin" {
|
||||
t.Errorf("got %v, want [admin]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_EmptyScalarStringReturnsNil(t *testing.T) {
|
||||
// An empty scalar must not produce a [""] role, which would never match a
|
||||
// configured admin role and only muddies logs.
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"roles": "",
|
||||
})
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
|
||||
t.Errorf("expected nil for non-array roles, got %v", got)
|
||||
t.Errorf("expected nil for empty scalar role, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
@@ -158,12 +162,16 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
// Built-in defaults (lowest priority)
|
||||
viper.SetDefault("server.host", "0.0.0.0")
|
||||
viper.SetDefault("server.host", "::")
|
||||
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")
|
||||
viper.SetDefault("auth.oidc.cookie_name", "garage_session")
|
||||
viper.SetDefault("auth.oidc.cookie_http_only", true)
|
||||
viper.SetDefault("auth.oidc.cookie_same_site", "lax")
|
||||
viper.SetDefault("auth.oidc.session_max_age", 86400)
|
||||
|
||||
// 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
|
||||
@@ -188,6 +196,13 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
// Env vars override config file values
|
||||
bindEnvVars()
|
||||
|
||||
// Resolve `_FILE`-suffixed env vars for sensitive values (e.g.
|
||||
// {ENV}_FILE=/run/secrets/foo). Must run after bindEnvVars so the
|
||||
// warning about both forms being set fires correctly.
|
||||
if err := applyFileBackedEnvVars(); err != nil {
|
||||
return nil, fmt.Errorf("error resolving _FILE env vars: %w", err)
|
||||
}
|
||||
|
||||
// Read the config file (optional - will use defaults and env vars if not found)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
@@ -276,6 +291,50 @@ func bindEnvVars() {
|
||||
viper.BindEnv("logging.format", "GARAGE_UI_LOGGING_FORMAT")
|
||||
}
|
||||
|
||||
// fileBackedEnvVars maps env var names to viper config keys for variables that
|
||||
// support the `_FILE` suffix convention. Operators may set `{ENV}_FILE` to a
|
||||
// file path; the file's contents (with trailing whitespace trimmed) become the
|
||||
// effective value. This pattern is used by Docker Official Images (postgres,
|
||||
// mysql) to inject secrets via mounted files instead of plain env vars,
|
||||
// avoiding exposure through `docker inspect`, process listings, or crash logs.
|
||||
//
|
||||
// Scope is intentionally limited to values that an operator would reasonably
|
||||
// store in a Kubernetes Secret or Docker secret. Non-sensitive config (host,
|
||||
// port, endpoints, etc.) is excluded.
|
||||
var fileBackedEnvVars = map[string]string{
|
||||
"GARAGE_UI_GARAGE_ADMIN_TOKEN": "garage.admin_token",
|
||||
"GARAGE_UI_AUTH_ADMIN_USERNAME": "auth.admin.username",
|
||||
"GARAGE_UI_AUTH_ADMIN_PASSWORD": "auth.admin.password",
|
||||
"GARAGE_UI_AUTH_JWT_PRIVATE_KEY": "auth.jwt_private_key",
|
||||
"GARAGE_UI_AUTH_OIDC_CLIENT_ID": "auth.oidc.client_id",
|
||||
"GARAGE_UI_AUTH_OIDC_CLIENT_SECRET": "auth.oidc.client_secret",
|
||||
}
|
||||
|
||||
// applyFileBackedEnvVars resolves `_FILE`-suffixed env vars listed in
|
||||
// fileBackedEnvVars. For each entry where `{ENV}_FILE` is set, the file is
|
||||
// read and its contents (trimmed of trailing CR/LF) become the value via
|
||||
// viper.Set, which is the highest-priority source — so a `_FILE` value wins
|
||||
// over both `{ENV}` and YAML. A missing or unreadable file is a hard error.
|
||||
func applyFileBackedEnvVars() error {
|
||||
for envVar, configKey := range fileBackedEnvVars {
|
||||
path := os.Getenv(envVar + "_FILE")
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s_FILE (%s): %w", envVar, path, err)
|
||||
}
|
||||
if os.Getenv(envVar) != "" {
|
||||
logger.Warn().
|
||||
Str("env", envVar).
|
||||
Msg("both VAR and VAR_FILE are set; VAR_FILE takes precedence")
|
||||
}
|
||||
viper.Set(configKey, strings.TrimRight(string(data), "\r\n"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
// Validate server config
|
||||
@@ -328,18 +387,9 @@ func (c *Config) Validate() error {
|
||||
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)
|
||||
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
|
||||
}
|
||||
|
||||
// IsDevelopment returns true if running in development mode
|
||||
|
||||
@@ -83,6 +83,9 @@ func TestLoad_EnvOnly_MissingFile(t *testing.T) {
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.Host != "::" {
|
||||
t.Errorf("Server.Host = %q, want :: (default)", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "env-token" {
|
||||
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
|
||||
}
|
||||
@@ -367,6 +370,8 @@ func TestGetAddress(t *testing.T) {
|
||||
}{
|
||||
{"localhost", 8080, "localhost:8080"},
|
||||
{"0.0.0.0", 80, "0.0.0.0:80"},
|
||||
{"::", 80, "[::]:80"},
|
||||
{"::1", 443, "[::1]:443"},
|
||||
{"", 443, ":443"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -485,43 +490,100 @@ func TestLoad_EnvOverridesToml(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
// oidcValidYAML is a minimal configuration that enables OIDC and passes
|
||||
// Validate, but deliberately omits auth.oidc.cookie_name.
|
||||
const oidcValidYAML = `
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
root_url: "https://garage.example.com"
|
||||
garage:
|
||||
endpoint: http://garage:3900
|
||||
admin_endpoint: http://garage:3903
|
||||
admin_token: supersecret
|
||||
auth:
|
||||
oidc:
|
||||
enabled: true
|
||||
client_id: "garage-ui"
|
||||
issuer_url: "https://idp.example.com/realms/main"
|
||||
scopes:
|
||||
- openid
|
||||
admin_roles:
|
||||
- "garage-ui-admin"
|
||||
`
|
||||
|
||||
func TestLoad_OIDCCookieNameDefaultsWhenUnset(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, oidcValidYAML)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// An empty cookie name makes Fiber silently drop the session Set-Cookie
|
||||
// (net/http rejects empty cookie names), which manifests as an OIDC login
|
||||
// loop. A non-empty default prevents that footgun.
|
||||
if cfg.Auth.OIDC.CookieName != "garage_session" {
|
||||
t.Errorf("CookieName = %q, want garage_session (default)", cfg.Auth.OIDC.CookieName)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestLoad_OIDCCookieNameExplicitValueWins(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, oidcValidYAML+" cookie_name: \"custom_session\"\n")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieName != "custom_session" {
|
||||
t.Errorf("CookieName = %q, want custom_session (explicit override)", cfg.Auth.OIDC.CookieName)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestLoad_OIDCCookieDefaultsWhenUnset(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, oidcValidYAML)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// HTTPOnly must default to true: a session cookie readable from JavaScript
|
||||
// is an XSS token-theft risk.
|
||||
if !cfg.Auth.OIDC.CookieHTTPOnly {
|
||||
t.Errorf("CookieHTTPOnly = false, want true (default)")
|
||||
}
|
||||
// SessionMaxAge must default to a positive value so the cookie's MaxAge
|
||||
// agrees with the 24h JWT instead of becoming a session-only cookie.
|
||||
if cfg.Auth.OIDC.SessionMaxAge != 86400 {
|
||||
t.Errorf("SessionMaxAge = %d, want 86400 (default)", cfg.Auth.OIDC.SessionMaxAge)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieSameSite != "lax" {
|
||||
t.Errorf("CookieSameSite = %q, want lax (default)", cfg.Auth.OIDC.CookieSameSite)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestLoad_OIDCCookieDefaultsCanBeOverridden(t *testing.T) {
|
||||
resetViper(t)
|
||||
yaml := oidcValidYAML +
|
||||
" cookie_http_only: false\n" +
|
||||
" session_max_age: 3600\n" +
|
||||
" cookie_same_site: \"strict\"\n"
|
||||
path := writeConfigFile(t, yaml)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieHTTPOnly {
|
||||
t.Errorf("CookieHTTPOnly = true, want false (explicit override)")
|
||||
}
|
||||
if cfg.Auth.OIDC.SessionMaxAge != 3600 {
|
||||
t.Errorf("SessionMaxAge = %d, want 3600 (explicit override)", cfg.Auth.OIDC.SessionMaxAge)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieSameSite != "strict" {
|
||||
t.Errorf("CookieSameSite = %q, want strict (explicit override)", cfg.Auth.OIDC.CookieSameSite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +613,129 @@ func TestEffectiveAdminRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeSecretFile is a test helper that writes content to a temp file and
|
||||
// returns the absolute path. Uses t.TempDir so cleanup is automatic.
|
||||
func writeSecretFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "secret")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp secret: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestApplyFileBackedEnvVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVar string
|
||||
configKey string
|
||||
fileBody string
|
||||
alsoSetEnv string
|
||||
useMissingFile bool
|
||||
wantValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "reads value from file",
|
||||
envVar: "GARAGE_UI_AUTH_ADMIN_PASSWORD",
|
||||
configKey: "auth.admin.password",
|
||||
fileBody: "s3cret",
|
||||
wantValue: "s3cret",
|
||||
},
|
||||
{
|
||||
name: "trims trailing newline",
|
||||
envVar: "GARAGE_UI_GARAGE_ADMIN_TOKEN",
|
||||
configKey: "garage.admin_token",
|
||||
fileBody: "tok\n",
|
||||
wantValue: "tok",
|
||||
},
|
||||
{
|
||||
name: "trims trailing CRLF",
|
||||
envVar: "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET",
|
||||
configKey: "auth.oidc.client_secret",
|
||||
fileBody: "secret\r\n",
|
||||
wantValue: "secret",
|
||||
},
|
||||
{
|
||||
name: "_FILE wins over plain env var",
|
||||
envVar: "GARAGE_UI_AUTH_ADMIN_USERNAME",
|
||||
configKey: "auth.admin.username",
|
||||
fileBody: "from-file",
|
||||
alsoSetEnv: "from-env",
|
||||
wantValue: "from-file",
|
||||
},
|
||||
{
|
||||
name: "missing file returns error",
|
||||
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
|
||||
configKey: "auth.jwt_private_key",
|
||||
useMissingFile: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiline PEM preserved internally, only trailing whitespace trimmed",
|
||||
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
|
||||
configKey: "auth.jwt_private_key",
|
||||
fileBody: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n",
|
||||
wantValue: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resetViper(t)
|
||||
|
||||
if tc.useMissingFile {
|
||||
t.Setenv(tc.envVar+"_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
} else {
|
||||
path := writeSecretFile(t, tc.fileBody)
|
||||
t.Setenv(tc.envVar+"_FILE", path)
|
||||
}
|
||||
if tc.alsoSetEnv != "" {
|
||||
t.Setenv(tc.envVar, tc.alsoSetEnv)
|
||||
}
|
||||
|
||||
err := applyFileBackedEnvVars()
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := viper.GetString(tc.configKey); got != tc.wantValue {
|
||||
t.Fatalf("viper.GetString(%q) = %q, want %q", tc.configKey, got, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileBackedEnvVars_NoFileEnvSet_NoOp(t *testing.T) {
|
||||
resetViper(t)
|
||||
if err := applyFileBackedEnvVars(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := viper.GetString("auth.admin.password"); got != "" {
|
||||
t.Fatalf("expected empty password, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) {
|
||||
resetViper(t)
|
||||
yamlPath := writeConfigFile(t, minimalValidYAML)
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
|
||||
_, err := Load(yamlPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Load when _FILE points at a missing file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "error resolving _FILE env vars") {
|
||||
t.Errorf("error %q does not contain wrapped prefix from Load", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProduction(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
|
||||
@@ -74,6 +74,7 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
Size: &detailedInfo.Bytes,
|
||||
WebsiteAccess: detailedInfo.WebsiteAccess,
|
||||
WebsiteConfig: detailedInfo.WebsiteConfig,
|
||||
Quotas: detailedInfo.Quotas,
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
@@ -419,3 +420,74 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
// UpdateBucketQuotas updates the quota settings for a bucket
|
||||
//
|
||||
// @Summary Update bucket quotas
|
||||
// @Description Sets or clears the max size (bytes) and max object count quotas for a bucket. A null field clears that quota (unlimited).
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.UpdateBucketQuotasRequest true "Quota configuration"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Quotas updated"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket"
|
||||
// @Router /api/v1/buckets/{name}/quotas [put]
|
||||
func (h *BucketHandler) UpdateBucketQuotas(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
var req models.UpdateBucketQuotasRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if req.MaxSize != nil && *req.MaxSize <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "maxSize must be greater than 0"),
|
||||
)
|
||||
}
|
||||
if req.MaxObjects != nil && *req.MaxObjects <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "maxObjects must be greater than 0"),
|
||||
)
|
||||
}
|
||||
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
updateReq := models.UpdateBucketRequest{
|
||||
Quotas: &models.BucketQuotas{
|
||||
MaxSize: req.MaxSize,
|
||||
MaxObjects: req.MaxObjects,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket quotas: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
app.Delete("/buckets/:name", h.DeleteBucket)
|
||||
app.Post("/buckets/:name/permissions", h.GrantBucketPermission)
|
||||
app.Put("/buckets/:name/website", h.UpdateBucketWebsite)
|
||||
app.Put("/buckets/:name/quotas", h.UpdateBucketQuotas)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
@@ -426,3 +427,196 @@ func TestUpdateBucketWebsite_Disable(t *testing.T) {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_SetBoth(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil")
|
||||
}
|
||||
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 53687091200 {
|
||||
t.Errorf("MaxSize = %v, want 53687091200", req.Quotas.MaxSize)
|
||||
}
|
||||
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 10000 {
|
||||
t.Errorf("MaxObjects = %v, want 10000", req.Quotas.MaxObjects)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
|
||||
}
|
||||
|
||||
maxSize := int64(53687091200)
|
||||
maxObjects := int64(10000)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize, MaxObjects: &maxObjects})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.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 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_SetOnlyMaxSize(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil")
|
||||
}
|
||||
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 1024 {
|
||||
t.Errorf("MaxSize = %v, want 1024", req.Quotas.MaxSize)
|
||||
}
|
||||
if req.Quotas.MaxObjects != nil {
|
||||
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
|
||||
}
|
||||
|
||||
maxSize := int64(1024)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.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 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_SetOnlyMaxObjects(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil")
|
||||
}
|
||||
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 500 {
|
||||
t.Errorf("MaxObjects = %v, want 500", req.Quotas.MaxObjects)
|
||||
}
|
||||
if req.Quotas.MaxSize != nil {
|
||||
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
|
||||
}
|
||||
|
||||
maxObjects := int64(500)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &maxObjects})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.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 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_ClearBoth(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil (envelope must be present so service clears both)")
|
||||
}
|
||||
if req.Quotas.MaxSize != nil {
|
||||
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
|
||||
}
|
||||
if req.Quotas.MaxObjects != nil {
|
||||
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.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 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_RejectsZeroMaxSize(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
zero := int64(0)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &zero})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.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.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_RejectsNegativeMaxObjects(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
neg := int64(-1)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &neg})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.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.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_NotFound(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
maxSize := int64(1024)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/missing/quotas", bytes.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.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_MalformedJSONReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader([]byte("{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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,11 @@ type UpdateBucketWebsiteRequest struct {
|
||||
IndexDocument string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketQuotasRequest represents a request to update bucket quota settings.
|
||||
// A nil field means "clear this quota" (unlimited). A non-nil field must be > 0;
|
||||
// Garage rejects 0.
|
||||
type UpdateBucketQuotasRequest struct {
|
||||
MaxSize *int64 `json:"maxSize,omitempty"`
|
||||
MaxObjects *int64 `json:"maxObjects,omitempty"`
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type BucketInfo struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// BucketListResponse represents a list of buckets
|
||||
|
||||
@@ -64,6 +64,7 @@ func SetupRoutes(
|
||||
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
|
||||
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
|
||||
buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
|
||||
buckets.Put("/:name/quotas", bucketHandler.UpdateBucketQuotas) // Update bucket quotas
|
||||
}
|
||||
|
||||
// Object routes
|
||||
|
||||
+2
-4
@@ -87,9 +87,6 @@ func main() {
|
||||
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,
|
||||
@@ -222,11 +219,12 @@ func main() {
|
||||
addr := cfg.GetAddress()
|
||||
logger.Info().
|
||||
Str("address", addr).
|
||||
Str("network", fiber.NetworkTCP).
|
||||
Str("health_endpoint", fmt.Sprintf("http://%s/health", addr)).
|
||||
Str("api_docs", fmt.Sprintf("http://%s/api/v1/", addr)).
|
||||
Msg("Server starting")
|
||||
|
||||
if err := app.Listen(addr); err != nil {
|
||||
if err := app.Listen(addr, fiber.ListenConfig{ListenerNetwork: fiber.NetworkTCP}); err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
}()
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "::" # IPv6 wildcard; dual-stack behavior depends on OS/runtime socket settings
|
||||
port: 8080
|
||||
environment: "development" # development, production
|
||||
domain: "localhost" # Domain name for the application
|
||||
|
||||
Generated
+13
-13
@@ -12,7 +12,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.15.2",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -21,7 +21,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -2861,12 +2861,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -4839,9 +4839,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
|
||||
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
|
||||
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -4861,12 +4861,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
|
||||
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
|
||||
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.1"
|
||||
"react-router": "7.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.15.2",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -151,7 +151,7 @@ const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<H
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none',
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-6 w-11 rounded-full border transition-colors',
|
||||
'relative h-6 w-11 rounded-full border-2 transition-colors',
|
||||
checked ? 'border-[#ff9329] bg-[#ff9329]' : 'border-[#6b7280] bg-[#6b7280]',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
@@ -28,8 +28,10 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute left-[2px] h-5 w-5 rounded-full bg-white shadow transition-transform"
|
||||
style={{ top: '50%', transform: `translateY(-50%) translateX(${checked ? '20px' : '0px'})` }}
|
||||
className={cn(
|
||||
'absolute top-0 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-full' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -63,6 +63,27 @@ export function useGrantBucketPermission() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBucketQuotas() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
bucketName,
|
||||
maxSize,
|
||||
maxObjects,
|
||||
}: {
|
||||
bucketName: string;
|
||||
maxSize: number | null;
|
||||
maxObjects: number | null;
|
||||
}) => bucketsApi.updateBucketQuotas(bucketName, { maxSize, maxObjects }),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucketName) });
|
||||
toast.success('Quotas updated successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function useObjects(bucket: string, prefix?: string, enabled = true) {
|
||||
return useQuery({
|
||||
|
||||
@@ -234,6 +234,22 @@ export const bucketsApi = {
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
updateBucketQuotas: async (
|
||||
name: string,
|
||||
payload: { maxSize: number | null; maxObjects: number | null }
|
||||
) => {
|
||||
// Map nulls to undefined so they are omitted from the JSON body —
|
||||
// backend treats a missing field as "clear this quota".
|
||||
const body: { maxSize?: number; maxObjects?: number } = {};
|
||||
if (payload.maxSize !== null) body.maxSize = payload.maxSize;
|
||||
if (payload.maxObjects !== null) body.maxObjects = payload.maxObjects;
|
||||
const response = await api.put<ApiResponse<any>>(
|
||||
`/v1/buckets/${encodeURIComponent(name)}/quotas`,
|
||||
body
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Objects API
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export type QuotaUnit = 'MB' | 'GB' | 'TB';
|
||||
|
||||
export const QUOTA_UNIT_BYTES: Record<QuotaUnit, number> = {
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024,
|
||||
TB: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
// Convert a byte count to a {value, unit} pair using the largest unit that
|
||||
// yields an integer. Falls back to GB if the value is 0 or doesn't divide
|
||||
// evenly into any unit.
|
||||
export function bytesToQuotaValue(bytes: number): { value: number; unit: QuotaUnit } {
|
||||
const units: QuotaUnit[] = ['TB', 'GB', 'MB'];
|
||||
for (const unit of units) {
|
||||
const factor = QUOTA_UNIT_BYTES[unit];
|
||||
if (bytes >= factor && bytes % factor === 0) {
|
||||
return { value: bytes / factor, unit };
|
||||
}
|
||||
}
|
||||
// Doesn't divide evenly — pick the largest unit where the value is >= 1,
|
||||
// rounded for display. The user is free to change it.
|
||||
for (const unit of units) {
|
||||
const factor = QUOTA_UNIT_BYTES[unit];
|
||||
if (bytes >= factor) {
|
||||
return { value: Math.round((bytes / factor) * 100) / 100, unit };
|
||||
}
|
||||
}
|
||||
return { value: 0, unit: 'GB' };
|
||||
}
|
||||
|
||||
export function quotaValueToBytes(value: number, unit: QuotaUnit): number {
|
||||
return Math.round(value * QUOTA_UNIT_BYTES[unit]);
|
||||
}
|
||||
@@ -27,7 +27,9 @@ import {Tabs, TabsContent} from '@/components/ui/tabs';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
import {Select, SelectOption} from '@/components/ui/select';
|
||||
import {useQueryClient} from '@tanstack/react-query';
|
||||
import {accessApi, bucketsApi} from '@/lib/api';
|
||||
import {queryKeys} from '@/lib/query-client';
|
||||
import {formatDate} from '@/lib/utils';
|
||||
import type {AccessKey, Bucket, BucketPermission} from '@/types';
|
||||
import {AlertTriangle, Calendar, Check, Copy, Database, Edit, Eye, EyeOff, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
|
||||
@@ -107,6 +109,7 @@ function CredentialField({
|
||||
}
|
||||
|
||||
export function AccessControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -209,6 +212,10 @@ export function AccessControl() {
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
if (createGrantPermissions && createSelectedBucket) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(createSelectedBucket) });
|
||||
}
|
||||
toast.success(`API Key "${newKeyName}" created successfully`);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
@@ -257,6 +264,8 @@ export function AccessControl() {
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
toast.success(`API Key "${keyName}" deleted successfully`);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
@@ -302,6 +311,7 @@ export function AccessControl() {
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
|
||||
setSettingsDialogOpen(false);
|
||||
toast.success(`Key settings updated successfully`);
|
||||
@@ -404,6 +414,8 @@ export function AccessControl() {
|
||||
// Refresh keys list to update permissions
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(selectedBucket) });
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
console.error('Grant permission error:', error);
|
||||
@@ -623,7 +635,7 @@ export function AccessControl() {
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,27 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import { useBuckets, useDeleteBucket } from '@/hooks/useApi';
|
||||
import { AlertTriangle, Gauge, Info } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectOption } from '@/components/ui/select';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate as formatDateUtil } from '@/lib/utils';
|
||||
import {
|
||||
bytesToQuotaValue,
|
||||
quotaValueToBytes,
|
||||
QUOTA_UNIT_BYTES,
|
||||
type QuotaUnit,
|
||||
} from '@/lib/quota-utils';
|
||||
|
||||
const formatBytesOrDash = (n?: number) => (n == null ? '—' : formatBytes(n));
|
||||
const formatDateOrDash = (iso?: string) => (iso ? formatDateUtil(iso) : '—');
|
||||
|
||||
const quotaFormSchema = z
|
||||
.object({
|
||||
maxSizeEnabled: z.boolean(),
|
||||
maxSizeValue: z.string(),
|
||||
maxSizeUnit: z.enum(['MB', 'GB', 'TB']),
|
||||
maxObjectsEnabled: z.boolean(),
|
||||
maxObjectsValue: z.string(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.maxSizeEnabled) {
|
||||
const n = Number(data.maxSizeValue);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['maxSizeValue'],
|
||||
message: 'Enter a positive whole number',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.maxObjectsEnabled) {
|
||||
const n = Number(data.maxObjectsValue);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['maxObjectsValue'],
|
||||
message: 'Enter a positive whole number',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type QuotaFormValues = z.infer<typeof quotaFormSchema>;
|
||||
|
||||
function deriveDefaults(quotas: { maxSize?: number; maxObjects?: number } | null | undefined): QuotaFormValues {
|
||||
const size = quotas?.maxSize;
|
||||
const objects = quotas?.maxObjects;
|
||||
if (size != null) {
|
||||
const { value, unit } = bytesToQuotaValue(size);
|
||||
return {
|
||||
maxSizeEnabled: true,
|
||||
maxSizeValue: String(value),
|
||||
maxSizeUnit: unit,
|
||||
maxObjectsEnabled: objects != null,
|
||||
maxObjectsValue: objects != null ? String(objects) : '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxSizeEnabled: false,
|
||||
maxSizeValue: '',
|
||||
maxSizeUnit: 'GB',
|
||||
maxObjectsEnabled: objects != null,
|
||||
maxObjectsValue: objects != null ? String(objects) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function BucketSettings() {
|
||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: buckets = [], isLoading } = useBuckets();
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const deleteMutation = useDeleteBucket();
|
||||
const updateQuotasMutation = useUpdateBucketQuotas();
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const defaults = useMemo(() => deriveDefaults(bucket?.quotas), [bucket?.quotas]);
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<QuotaFormValues>({
|
||||
resolver: zodResolver(quotaFormSchema),
|
||||
values: defaults,
|
||||
});
|
||||
|
||||
const watched = watch();
|
||||
|
||||
const currentSize = bucket?.size ?? 0;
|
||||
const currentObjects = bucket?.objectCount ?? 0;
|
||||
|
||||
const newMaxSizeBytes =
|
||||
watched.maxSizeEnabled && watched.maxSizeValue !== '' && !Number.isNaN(Number(watched.maxSizeValue))
|
||||
? quotaValueToBytes(Number(watched.maxSizeValue), watched.maxSizeUnit)
|
||||
: null;
|
||||
const newMaxObjects =
|
||||
watched.maxObjectsEnabled && watched.maxObjectsValue !== '' && !Number.isNaN(Number(watched.maxObjectsValue))
|
||||
? Number(watched.maxObjectsValue)
|
||||
: null;
|
||||
|
||||
const sizeBelowCurrent =
|
||||
newMaxSizeBytes !== null && bucket?.size != null && newMaxSizeBytes < currentSize;
|
||||
const objectsBelowCurrent =
|
||||
newMaxObjects !== null && bucket?.objectCount != null && newMaxObjects < currentObjects;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading…</div>;
|
||||
}
|
||||
@@ -48,6 +149,14 @@ export function BucketSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const maxSize = values.maxSizeEnabled
|
||||
? quotaValueToBytes(Number(values.maxSizeValue), values.maxSizeUnit)
|
||||
: null;
|
||||
const maxObjects = values.maxObjectsEnabled ? Number(values.maxObjectsValue) : null;
|
||||
await updateQuotasMutation.mutateAsync({ bucketName: bucket.name, maxSize, maxObjects });
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-7 py-6">
|
||||
{/* Info */}
|
||||
@@ -73,6 +182,118 @@ export function BucketSettings() {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Quotas */}
|
||||
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
|
||||
<Gauge className="h-4 w-4 text-[var(--primary)]" />
|
||||
<h2 className="text-[15px] font-semibold">Quotas</h2>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6 px-5 py-5">
|
||||
{/* Max size row */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxSizeEnabled"
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2 text-[14px]">
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<span>Limit total size</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-32"
|
||||
disabled={!watched.maxSizeEnabled}
|
||||
{...register('maxSizeValue')}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxSizeUnit"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onChange={(v) => field.onChange(v as QuotaUnit)}
|
||||
disabled={!watched.maxSizeEnabled}
|
||||
className="w-24"
|
||||
>
|
||||
{(Object.keys(QUOTA_UNIT_BYTES) as QuotaUnit[]).map((u) => (
|
||||
<SelectOption key={u} value={u}>
|
||||
{u}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[13px] text-[var(--muted-foreground)]">
|
||||
Current: {formatBytesOrDash(bucket.size)}
|
||||
</p>
|
||||
{errors.maxSizeValue && (
|
||||
<p className="text-[13px] text-[var(--destructive)]">{errors.maxSizeValue.message}</p>
|
||||
)}
|
||||
{sizeBelowCurrent && (
|
||||
<p className="text-[13px] text-amber-600 dark:text-amber-400">
|
||||
Current size ({formatBytes(currentSize)}) exceeds this limit. New writes will be rejected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Max objects row */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxObjectsEnabled"
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2 text-[14px]">
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<span>Limit object count</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-40"
|
||||
disabled={!watched.maxObjectsEnabled}
|
||||
{...register('maxObjectsValue')}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[13px] text-[var(--muted-foreground)]">
|
||||
Current: {bucket.objectCount != null ? bucket.objectCount.toLocaleString() : '—'}
|
||||
</p>
|
||||
{errors.maxObjectsValue && (
|
||||
<p className="text-[13px] text-[var(--destructive)]">{errors.maxObjectsValue.message}</p>
|
||||
)}
|
||||
{objectsBelowCurrent && (
|
||||
<p className="text-[13px] text-amber-600 dark:text-amber-400">
|
||||
Current object count ({currentObjects.toLocaleString()}) exceeds this limit. New writes will be rejected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => reset(defaults)}
|
||||
disabled={!isDirty || isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
||||
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// Bucket types
|
||||
export interface BucketQuotas {
|
||||
maxSize?: number;
|
||||
maxObjects?: number;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
name: string;
|
||||
creationDate: string;
|
||||
@@ -10,6 +15,7 @@ export interface Bucket {
|
||||
indexDocument: string;
|
||||
errorDocument?: string;
|
||||
};
|
||||
quotas?: BucketQuotas | null;
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.6.0...garage-ui-chart-v0.6.1) (2026-05-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Publish Helm chart to GHCR ([#70](https://github.com/Noooste/garage-ui/issues/70)) ([1c9043c](https://github.com/Noooste/garage-ui/commit/1c9043c6973c3ffc3fc29cc65b342b35dfa84ae0))
|
||||
|
||||
## [0.6.0](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.5.0...garage-ui-chart-v0.6.0) (2026-05-31)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
|
||||
|
||||
### Features
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
|
||||
|
||||
## [0.5.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.5.0...garage-ui-chart-v0.5.1) (2026-05-25)
|
||||
|
||||
* **bump AppVersion:** 0.6.2 -> 0.7.0
|
||||
|
||||
## [0.5.0](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.4.2...garage-ui-chart-v0.5.0) (2026-05-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **helm:** add support for extra Kubernetes manifests in values.yaml ([699f11a](https://github.com/Noooste/garage-ui/commit/699f11afaa340fdd8d1c24bda073ffcc94fe86d8))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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.4.1
|
||||
appVersion: 0.6.1
|
||||
version: 0.6.1
|
||||
appVersion: v0.8.2
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
|
||||
@@ -93,6 +93,28 @@ If you've cloned the repository:
|
||||
helm install garage-ui ./helm/garage-ui -f my-values.yaml
|
||||
```
|
||||
|
||||
### Installing from the OCI registry (ghcr.io)
|
||||
|
||||
The chart is published as an OCI artifact to GitHub Container Registry. No
|
||||
`helm repo add` is required:
|
||||
|
||||
```bash
|
||||
helm install garage-ui oci://ghcr.io/noooste/charts/garage-ui \
|
||||
--version <x.y.z> -f my-values.yaml
|
||||
```
|
||||
|
||||
The chart is signed with [cosign](https://docs.sigstore.dev/) using keyless
|
||||
signing. To verify the signature before installing:
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/noooste/charts/garage-ui:<x.y.z> \
|
||||
--certificate-identity-regexp 'https://github.com/Noooste/garage-ui/.+' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
```
|
||||
|
||||
The chart also remains available from the classic Helm repository at
|
||||
`https://helm.noste.dev`.
|
||||
|
||||
### Installing with inline values
|
||||
|
||||
You can also set values directly on the command line:
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default (ternary .Chart.AppVersion (printf "v%s" .Chart.AppVersion) (hasPrefix "v" .Chart.AppVersion)) }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{{- range .Values.extraObjects }}
|
||||
---
|
||||
{{ tpl (toYaml .) $ }}
|
||||
{{- end }}
|
||||
@@ -69,8 +69,8 @@
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to (0.0.0.0 for all interfaces)",
|
||||
"default": "0.0.0.0"
|
||||
"description": "Network interface to bind to (use :: for IPv6 wildcard / dual-stack-preferred, or 0.0.0.0 for IPv4 wildcard)",
|
||||
"default": "::"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -14,9 +14,29 @@ fullnameOverride: ""
|
||||
|
||||
extraEnvs: []
|
||||
|
||||
# Extra Kubernetes manifests to deploy alongside the chart.
|
||||
# Each entry is rendered through `tpl`, so Helm templating is supported.
|
||||
# Useful for ExternalSecret, HTTPRoute, CiliumNetworkPolicy, etc.
|
||||
extraObjects: []
|
||||
# - apiVersion: external-secrets.io/v1beta1
|
||||
# kind: ExternalSecret
|
||||
# metadata:
|
||||
# name: garage-ui-admin
|
||||
# spec:
|
||||
# refreshInterval: 1h
|
||||
# secretStoreRef:
|
||||
# name: vault
|
||||
# kind: ClusterSecretStore
|
||||
# target:
|
||||
# name: garage-ui-admin
|
||||
# data:
|
||||
# - secretKey: admin-token
|
||||
# remoteRef:
|
||||
# key: garage/admin-token
|
||||
|
||||
config:
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "::"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
domain: "garage-ui.example.com"
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
"component": "garage-ui",
|
||||
"include-component-in-tag": false,
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"exclude-paths": ["helm/garage-ui"],
|
||||
"extra-files": [
|
||||
{
|
||||
"type": "yaml",
|
||||
"path": "helm/garage-ui/Chart.yaml",
|
||||
"jsonpath": "$.appVersion",
|
||||
"glob": false
|
||||
}
|
||||
"exclude-paths": [
|
||||
"helm/garage-ui",
|
||||
".github",
|
||||
"docs",
|
||||
"scripts"
|
||||
]
|
||||
},
|
||||
"helm/garage-ui": {
|
||||
|
||||
Reference in New Issue
Block a user