Compare commits

..

44 Commits

Author SHA1 Message Date
garage-ui-release-bot[bot] e0cd59dcdf chore(main): release 0.8.3 (#81)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-22 12:23:53 +02:00
Noooste 1c1e4d4b2b chore: update Helm chart version to 0.8.2 and adjust README badges
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-21 23:20:44 +02:00
Noooste ae245c8a31 Merge remote-tracking branch 'origin/main' 2026-06-21 22:58:38 +02:00
Noooste 28c186f3eb fix(helm): update version badges in README for Garage UI
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-21 22:58:32 +02:00
Noste 46aa3752c8 fix(backend): improve API version detection with retry logic for health probes
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-06-17 19:14:06 +02:00
garage-ui-release-bot[bot] d502dac457 chore: release main (#77)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.2

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-08 00:35:53 +02:00
dependabot[bot] 67d8f633b0 chore(deps): bump react-router and react-router-dom in /frontend (#74)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.16.0 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 7.14.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.16.0/packages/react-router)

Updates `react-router-dom` from 7.14.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.16.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.16.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 00:13:10 +02:00
Noste 22be89b2ff fix(backend): prevent OIDC login loop from empty cookie name (#76)
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-08 00:11:45 +02:00
garage-ui-release-bot[bot] ae97dd8f01 chore: release main (#73)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.1

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-01 00:54:09 +02:00
Florian Gareis 45f8770799 fix(frontend): align three-dot menu item icon spacing and text alignment (#72) 2026-06-01 00:53:22 +02:00
Noste e3191c2686 fix(ci): add workflow_dispatch
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:57:58 +02:00
Noste 24997db960 fix(ci): add docker login for cosign
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:55:40 +02:00
Noste 3c69cc5f26 Merge remote-tracking branch 'origin/main' 2026-05-31 12:52:34 +02:00
Noste 4b0e98008b chore(release): remove pin version
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:52:04 +02:00
garage-ui-release-bot[bot] bc67e50606 chore: release main (#71)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 12:51:23 +02:00
Noste fe1765597c chore(release): exclude .github from app, pin chart to 0.6.1 patch
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:50:15 +02:00
Noste 1c9043c697 feat: Publish Helm chart to GHCR (#70)
* feat(ci): add GitHub Actions workflow to publish Helm chart to GHCR

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

* docs(helm): update README to include installation instructions from OCI registry and signature verification

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

---------

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:31:53 +02:00
garage-ui-release-bot[bot] b80cfef844 chore: release main (#69)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.0

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 11:57:40 +02:00
Noste 186af18d54 feat(docs): add documentation generation command to Makefile
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 11:54:43 +02:00
dependabot[bot] 4b3a562acb chore(deps): bump axios from 1.15.2 to 1.16.0 in /frontend (#67)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 11:39:32 +02:00
Alistair Young 5427758eaa feat(backend,helm)!: bind to IPv6 wildcard by default for dual-stack support
* fix: Enable Garage UI to work on IPv6-based clusters.

* fix: building on nonstandard-uid machines.

* fix: Enable Garage UI to work on IPv6-based clusters.
2026-05-31 11:39:04 +02:00
garage-ui-release-bot[bot] c30400cf84 chore: release main (#62)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.7.0

* Bump version to 0.5.1 in Chart.yaml

* chore: bump version

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-25 18:43:44 +02:00
Noste d05b9ce324 docs: update README to include loading sensitive values from files with _FILE suffix
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-23 15:43:26 +02:00
Noste 1f16edd39c feat(backend,frontend): enable quotas support in bucket settings (#64) 2026-05-23 15:29:04 +02:00
Noste 36ec8e800e feat(backend): Support _FILE suffix on sensitive env variables (#63)
* feat(env): add _FILE suffix support for env variables

* fix(frontend): dynamic refresh on key creation

* chore(deps): update packages

* fix(test): coverage
2026-05-23 14:55:52 +02:00
Noste 1b645b0c2c fix(auth): remove auto-enable token auth logic
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-23 12:46:38 +02:00
garage-ui-release-bot[bot] dc9ea5716a chore: release main (#57)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.6.2

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-16 10:27:30 +02:00
Noste 699f11afaa feat(helm): add support for extra Kubernetes manifests in values.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-16 10:26:04 +02:00
Noste 657d919331 chore: update .gitignore
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-16 09:30:02 +02:00
Noste 01c4c16778 ci: add workflow_dispatch trigger to build.yml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-16 09:21:57 +02:00
garage-ui-release-bot[bot] 3521e63073 chore: release main (#55)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 23:29:54 +02:00
Noste 71d3c8446a ci: sync Chart.yaml appVersion via post-release-please step
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 23:28:28 +02:00
Noste 709b9f2ad3 fix(helm): update appVersion format and improve image tag handling
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 23:11:51 +02:00
github-actions[bot] fd49c4e4c9 chore: release main (#54)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-15 18:56:43 +02:00
Noste ff46ff6232 fix: Helm image tag (#53)
* fix(helm): update image tag logic to handle version prefixes correctly

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

* fix(ci): exclude helm/garage-ui from changelog generation

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

---------

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:52:49 +02:00
github-actions[bot] 66abbc64b3 chore: release main (#52)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-15 18:37:40 +02:00
Noste cb3839e618 fix(ci): remove CHANGELOG.md seeds so release-please owns them (#51)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:34:00 +02:00
Noste f04c38168d fix(ci): correct appVersion tracking and remove changelog seeds (#49)
* feat(ci): add automated release workflow and changelog management

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

* fix(ci): correct appVersion tracking and remove changelog seeds

---------

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:29:07 +02:00
Noste c45846535c ci: add automated release workflow and changelog management (#47)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:14:15 +02:00
Noste c8cb3c4923 feat: enhance bucket credential retrieval to support read/write operations and improve caching logic (#46)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 15:37:07 +02:00
madestreel c8337de3a8 feat: add extraEnvs to helm chart to allow config override (#45)
Co-authored-by: Maxime de Streel <mst@escaux.com>
2026-05-15 15:36:44 +02:00
dependabot[bot] 5cc4b02114 chore(deps-dev): bump postcss from 8.5.6 to 8.5.12 in /frontend (#32)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.12.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 09:20:12 +02:00
dependabot[bot] 741232c797 chore(deps): bump axios from 1.15.0 to 1.15.2 in /frontend (#34)
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 09:19:54 +02:00
Noste a9337ff59b chore: update version and appVersion in Chart.yaml and README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-14 09:17:26 +02:00
47 changed files with 1827 additions and 198 deletions
+3 -1
View File
@@ -3,7 +3,9 @@ name: build
on:
push:
tags:
- 'v*'
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
jobs:
build:
+81
View File
@@ -0,0 +1,81 @@
name: chart-release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
permissions:
contents: write
pages: write
packages: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v4.1.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
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 }}"
+38
View File
@@ -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"
+27
View File
@@ -0,0 +1,27 @@
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 }}
-37
View File
@@ -1,37 +0,0 @@
name: release
on:
push:
branches:
- main
paths:
- 'helm/garage-ui/**'
- '!helm/garage-ui/README.md'
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v3.19.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
env:
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
+2 -2
View File
@@ -65,5 +65,5 @@ backend/docs/
config.yaml
docs/**/*.md
!docs/garage-setup.md
# Superpowers brainstorm / session scratch
.superpowers/
**/worktrees
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.8.3"
}
+76
View File
@@ -0,0 +1,76 @@
# Changelog
## [0.8.3](https://github.com/Noooste/garage-ui/compare/v0.8.2...v0.8.3) (2026-06-21)
### Bug Fixes
* **backend:** improve API version detection with retry logic for health probes ([46aa375](https://github.com/Noooste/garage-ui/commit/46aa3752c81788787388d1c67e29cef786bdabff))
* **helm:** update version badges in README for Garage UI ([28c186f](https://github.com/Noooste/garage-ui/commit/28c186f3eba1a1c111100712f1eaec22a5d18eb2))
## [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)
### 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))
+58
View File
@@ -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 -2
View File
@@ -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"]
+6
View File
@@ -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
+42 -1
View File
@@ -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
View File
@@ -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
)
+35
View File
@@ -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=
+14 -1
View File
@@ -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
+23 -3
View File
@@ -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)
}
}
+61 -11
View File
@@ -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
+213 -28
View File
@@ -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
+72
View File
@@ -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))
}
+194
View File
@@ -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)
}
}
+8
View File
@@ -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"`
}
+1
View File
@@ -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
+1
View File
@@ -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
+41 -2
View File
@@ -39,8 +39,14 @@ type AdminServiceResult struct {
APIVersion string
}
// errProbeNotFound means the probed route returned 404. Garage v2.x also serves
// /v1/health, so a 404 on /v2 is the only reliable "this is a v1 server" signal.
var errProbeNotFound = errors.New("probe endpoint not found")
func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceResult, error) {
if err := probeEndpoint(cfg, "/v2/GetClusterHealth"); err == nil {
// retry so a startup fails doesn't lock us to a v1 client.
err := probeEndpointWithRetry(cfg, "/v2/GetClusterHealth")
if err == nil {
logger.Info().Str("api_version", "v2").Msg("Detected Garage admin API v2")
svc := NewGarageV2AdminService(cfg, logLevel)
return &AdminServiceResult{
@@ -50,7 +56,17 @@ func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceRe
}, nil
}
if err := probeEndpoint(cfg, "/v1/health"); err == nil {
// only fall back to v1 on a real 404
// other errors mean the server is up but the probe failed transiently; picking v1 against
// a v2.x server breaks /v1/status with "v1/ endpoint is no longer supported" (issue #78).
if !errors.Is(err, errProbeNotFound) {
return nil, fmt.Errorf(
"could not detect Garage admin API version at %s: %w. Ensure Garage v1.1+ is running and the admin API is reachable",
cfg.AdminEndpoint, err,
)
}
if err := probeEndpointWithRetry(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")
@@ -68,6 +84,26 @@ func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceRe
)
}
const probeAttempts = 4
// probeEndpointWithRetry retries transient failures with backoff, but returns a
// 404 immediately, a missing route won't appear on a retry.
func probeEndpointWithRetry(cfg *config.GarageConfig, path string) error {
var err error
backoff := 250 * time.Millisecond
for attempt := range probeAttempts {
err = probeEndpoint(cfg, path)
if err == nil || errors.Is(err, errProbeNotFound) {
return err
}
if attempt < probeAttempts-1 {
time.Sleep(backoff)
backoff *= 2
}
}
return err
}
func probeEndpoint(cfg *config.GarageConfig, path string) error {
session := azuretls.NewSession()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -86,6 +122,9 @@ func probeEndpoint(cfg *config.GarageConfig, path string) error {
}
defer resp.RawBody.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("probe %s returned status 404: %w", path, errProbeNotFound)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("probe %s returned status %d", path, resp.StatusCode)
}
@@ -89,3 +89,61 @@ func TestDetectVersion_Unreachable(t *testing.T) {
t.Fatal("expected error for unreachable server")
}
}
// Garage v2.x serves /v1/health too, so a transient failure of the /v2 probe
// must not cause a permanent downgrade to the (broken on v2.x) v1 client.
// Regression test for https://github.com/Noooste/garage-ui/issues/78
func TestDetectVersion_V2_TransientProbeFailure(t *testing.T) {
var v2Hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/GetClusterHealth":
v2Hits++
if v2Hits < 3 { // fail the first two attempts, then recover
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
case "/v1/health": // v2.x still answers this
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
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 after transient probe failure, got %s", result.APIVersion)
}
}
// A server that answers /v1/health but returns a server error (not 404) for
// /v2/GetClusterHealth must NOT be detected as v1, because v2.x servers also
// answer /v1/health. Falling through to v1 here is the issue #78 misdetection.
func TestDetectVersion_DoesNotDowngradeOnV2ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/GetClusterHealth":
w.WriteHeader(http.StatusServiceUnavailable)
case "/v1/health":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err == nil && result.APIVersion == "v1" {
t.Fatal("must not downgrade to v1 when /v2 returns a server error; v2.x also serves /v1/health")
}
}
+66 -45
View File
@@ -52,56 +52,77 @@ func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Servic
}
}
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 {
+192 -8
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -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")
}
+2 -4
View File
@@ -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
View File
@@ -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
+17 -17
View File
@@ -12,7 +12,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.15.0",
"axios": "^1.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",
@@ -40,7 +40,7 @@
"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",
@@ -2861,12 +2861,12 @@
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.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"
}
@@ -4663,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": [
{
@@ -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"
+3 -3
View File
@@ -14,7 +14,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.15.0",
"axios": "^1.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",
@@ -42,7 +42,7 @@
"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",
+1 -1
View File
@@ -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) => {
+5 -3
View File
@@ -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>
+21
View File
@@ -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({
+16
View File
@@ -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
+33
View File
@@ -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]);
}
+13 -1
View File
@@ -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>
+224 -3
View File
@@ -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">
+6
View File
@@ -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 {
+75
View File
@@ -0,0 +1,75 @@
# 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)
### 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 -3
View File
@@ -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.5
appVersion: "v0.4.2"
version: 0.8.3
appVersion: v0.8.2
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'
+24 -2
View File
@@ -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.
[![Version](https://img.shields.io/badge/version-0.2.5-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.4.2-green.svg)](Chart.yaml)
[![Version](https://img.shields.io/badge/version-0.8.3)](Chart.yaml) <!-- x-release-please-version -->
[![App Version](https://img.shields.io/badge/app%20version-v0.8.3)](Chart.yaml) <!-- x-release-please-version -->
## Table of Contents
@@ -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:
+3
View File
@@ -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:
@@ -0,0 +1,4 @@
{{- range .Values.extraObjects }}
---
{{ tpl (toYaml .) $ }}
{{- end }}
+2 -2
View File
@@ -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",
+23 -1
View File
@@ -12,9 +12,31 @@ imagePullSecrets: []
nameOverride: ""
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"
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"packages": {
".": {
"release-type": "go",
"component": "garage-ui",
"include-component-in-tag": false,
"changelog-path": "CHANGELOG.md",
"extra-files": [
"helm/garage-ui/Chart.yaml",
"helm/garage-ui/README.md"
]
}
}
}