Compare commits

...

21 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
25 changed files with 415 additions and 79 deletions
+46 -2
View File
@@ -3,11 +3,15 @@ name: chart-release
on:
push:
tags:
- 'garage-ui-chart-v*'
- '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:
@@ -26,7 +30,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v3.19.3
version: v4.1.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
@@ -35,3 +39,43 @@ jobs:
skip_existing: true
env:
CR_TOKEN: ${{ secrets.HELM_RELEASE_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.2
- name: Log in to ghcr.io for cosign
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Package and push chart to ghcr.io (OCI)
id: oci_push
env:
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
chart_version=$(grep -E '^version:' helm/garage-ui/Chart.yaml | awk '{print $2}')
echo "Packaging chart version ${chart_version}"
helm package helm/garage-ui --destination /tmp/chart
echo "${GHCR_TOKEN}" | helm registry login ghcr.io \
--username "${GHCR_USER}" --password-stdin
push_output=$(helm push "/tmp/chart/garage-ui-${chart_version}.tgz" \
oci://ghcr.io/noooste/charts 2>&1 | tee /dev/stderr)
digest=$(echo "$push_output" | grep -oE 'sha256:[a-f0-9]{64}' | head -n1)
if [ -z "$digest" ]; then
echo "Failed to parse pushed digest from helm push output" >&2
exit 1
fi
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
echo "Pushed oci://ghcr.io/noooste/charts/garage-ui:${chart_version} (${digest})"
- name: Sign chart with cosign (keyless)
run: |
cosign sign --yes \
"ghcr.io/noooste/charts/garage-ui@${{ steps.oci_push.outputs.digest }}"
-30
View File
@@ -25,33 +25,3 @@ jobs:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ steps.app-token.outputs.token }}
- name: Sync Chart.yaml appVersion
if: ${{ steps.rp.outputs.prs_created == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PRS: ${{ steps.rp.outputs.prs }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
echo "$PRS" | jq -c '.[]' | while read -r pr; do
branch=$(echo "$pr" | jq -r '.headBranchName')
workdir=$(mktemp -d)
git clone --branch "$branch" --depth 1 \
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$workdir"
cd "$workdir"
git config user.email "garage-ui-release-bot[bot]@users.noreply.github.com"
git config user.name "garage-ui-release-bot[bot]"
version=$(jq -r '."."' .release-please-manifest.json)
target="v${version}"
sed -i -E "s|^appVersion:.*|appVersion: ${target}|" helm/garage-ui/Chart.yaml
if ! git diff --quiet helm/garage-ui/Chart.yaml; then
git add helm/garage-ui/Chart.yaml
git commit -m "chore: sync Chart.yaml appVersion to ${target}"
git push origin "$branch"
fi
cd -
rm -rf "$workdir"
done
+1 -2
View File
@@ -1,4 +1,3 @@
{
".": "0.7.0",
"helm/garage-ui": "0.5.0"
".": "0.8.3"
}
+34
View File
@@ -1,5 +1,39 @@
# 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)
+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
+10
View File
@@ -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
+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)
}
}
+8 -2
View File
@@ -2,7 +2,9 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/spf13/viper"
@@ -160,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
@@ -383,7 +389,7 @@ func (c *Config) Validate() error {
// 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
+102
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,6 +490,103 @@ func TestLoad_EnvOverridesToml(t *testing.T) {
}
}
// 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 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 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 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)
}
}
func TestEffectiveAdminRoles(t *testing.T) {
tests := []struct {
name string
+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")
}
}
+2 -1
View File
@@ -219,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
+13 -13
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.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -21,7 +21,7 @@
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.14.1",
"react-router-dom": "^7.16.0",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -2861,12 +2861,12 @@
}
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
@@ -4839,9 +4839,9 @@
}
},
"node_modules/react-router": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4861,12 +4861,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.1"
"react-router": "7.16.0"
},
"engines": {
"node": ">=20.0.0"
+2 -2
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.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -23,7 +23,7 @@
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.14.1",
"react-router-dom": "^7.16.0",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+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) => {
+1 -1
View File
@@ -635,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>
+18
View File
@@ -1,5 +1,23 @@
# 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
+2 -2
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.5.1
appVersion: v0.7.0
version: 0.8.3
appVersion: v0.8.2
keywords:
- garage
- s3
+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.3.0-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.5.0-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:
+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",
+1 -1
View File
@@ -36,7 +36,7 @@ extraObjects: []
config:
server:
host: "0.0.0.0"
host: "::"
port: 8080
environment: "production"
domain: "garage-ui.example.com"
+4 -9
View File
@@ -2,21 +2,16 @@
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"separate-pull-requests": false,
"packages": {
".": {
"release-type": "go",
"component": "garage-ui",
"include-component-in-tag": false,
"changelog-path": "CHANGELOG.md",
"exclude-paths": ["helm/garage-ui"]
},
"helm/garage-ui": {
"release-type": "helm",
"component": "garage-ui-chart",
"package-name": "garage-ui",
"include-component-in-tag": true,
"changelog-path": "CHANGELOG.md"
"extra-files": [
"helm/garage-ui/Chart.yaml",
"helm/garage-ui/README.md"
]
}
}
}