mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
22 Commits
v0.1.14
...
v0.3.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 047a653446 | |||
| f63ce3452e | |||
| 5e68a77e15 | |||
| 46f904fe59 | |||
| 0baf31422a | |||
| 98842d7c7e | |||
| bd4c3b1c5f | |||
| 68d773d4af | |||
| 54a5e42db6 | |||
| 43ba68a4f9 | |||
| a0458fb5d0 | |||
| 6dc0feb374 | |||
| 33d8705431 | |||
| e7db44f45d | |||
| 126e138382 | |||
| 2efb16e248 | |||
| d494c811a0 | |||
| e800ac0ed1 | |||
| 26ae2ef515 | |||
| 4486697ca5 | |||
| 94ad142edf | |||
| b8b0b6b0fa |
@@ -51,6 +51,7 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: VERSION=${{ steps.meta.outputs.version }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
+2
-1
@@ -60,4 +60,5 @@ meta/
|
||||
garage.toml
|
||||
|
||||
backend/docs/
|
||||
config.yaml
|
||||
config.yaml
|
||||
docs/**/*.md
|
||||
+3
-1
@@ -29,12 +29,14 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
|
||||
COPY backend .
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
swag init
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o garage-ui .
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -ldflags "-X main.version=${VERSION}" -o garage-ui .
|
||||
|
||||
FROM alpine:3.23.3
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -227,6 +228,36 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// ExtractRolesFromAccessToken parses the access token JWT payload and extracts
|
||||
// roles using the configured role_attribute_path. Keycloak emits resource_access
|
||||
// claims only in the access token by default, so this is required to support
|
||||
// the common Keycloak client-role setup without extra mapper configuration.
|
||||
//
|
||||
// The access token was obtained via a verified code exchange with the provider,
|
||||
// so parsing its claims without re-verifying the signature is safe here.
|
||||
func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
|
||||
if accessToken == "" || a.authConfig.OIDC.RoleAttributePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(accessToken, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.authConfig.OIDC.AdminRole == "" {
|
||||
@@ -325,9 +356,18 @@ func (a *Service) ValidateAndConsumeState(token string) bool {
|
||||
return a.jwtService.ValidateAndConsumeState(token)
|
||||
}
|
||||
|
||||
// GenerateSessionToken generates a JWT session token for the user
|
||||
// GenerateSessionToken generates a JWT session token for the user.
|
||||
//
|
||||
// SessionMaxAge lives under the OIDC config block, but the JWT is shared with
|
||||
// admin-only deployments that never set it. Treat a non-positive value as
|
||||
// "not configured" and fall back to 24h so the token isn't issued already
|
||||
// expired (which would make every subsequent request fail with 401).
|
||||
func (a *Service) GenerateSessionToken(userInfo *UserInfo) (string, error) {
|
||||
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
|
||||
maxAge := a.authConfig.OIDC.SessionMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = 86400
|
||||
}
|
||||
return a.jwtService.GenerateToken(userInfo, maxAge)
|
||||
}
|
||||
|
||||
// ValidateSessionToken validates a JWT session token and returns user info
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
)
|
||||
|
||||
func TestExtractRolesFromAccessToken_Keycloak(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"resource_access": map[string]interface{}{
|
||||
"GarageAdminUi": map[string]interface{}{
|
||||
"roles": []interface{}{"admin"},
|
||||
},
|
||||
"account": map[string]interface{}{
|
||||
"roles": []interface{}{"view-profile"},
|
||||
},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
|
||||
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{
|
||||
RoleAttributePath: "resource_access.GarageAdminUi.roles",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
roles := svc.ExtractRolesFromAccessToken(token)
|
||||
if len(roles) != 1 || roles[0] != "admin" {
|
||||
t.Fatalf("expected [admin], got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_NoRoleClaim(t *testing.T) {
|
||||
payload := map[string]interface{}{"sub": "user"}
|
||||
raw, _ := json.Marshal(payload)
|
||||
token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
|
||||
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"},
|
||||
},
|
||||
}
|
||||
|
||||
if roles := svc.ExtractRolesFromAccessToken(token); roles != nil {
|
||||
t.Fatalf("expected nil, got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_Malformed(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"},
|
||||
},
|
||||
}
|
||||
if roles := svc.ExtractRolesFromAccessToken("not-a-jwt"); roles != nil {
|
||||
t.Fatalf("expected nil for malformed token, got %v", roles)
|
||||
}
|
||||
if roles := svc.ExtractRolesFromAccessToken(""); roles != nil {
|
||||
t.Fatalf("expected nil for empty token, got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for issue #16: admin-only deployments never set
|
||||
// OIDC.SessionMaxAge, leaving it at 0. Before the fix, that produced a JWT
|
||||
// whose exp == iat, so every request after /auth/login was rejected as
|
||||
// expired and the client saw UNAUTHORIZED.
|
||||
func TestGenerateSessionToken_ZeroSessionMaxAge_IsNotImmediatelyExpired(t *testing.T) {
|
||||
jwtSvc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
|
||||
// OIDC disabled; SessionMaxAge left at zero value.
|
||||
},
|
||||
jwtService: jwtSvc,
|
||||
}
|
||||
|
||||
token, err := svc.GenerateSessionToken(&UserInfo{Username: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.ValidateSessionToken(token); err != nil {
|
||||
t.Fatalf("freshly issued admin session token failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,13 @@ func (c *Config) Validate() error {
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||
}
|
||||
// Every authenticated route on this service grants full admin
|
||||
// access — there is no separate authorization layer. An empty
|
||||
// admin_role would therefore promote every user in the IdP realm
|
||||
// to cluster admin. Require operators to opt in explicitly.
|
||||
if c.Auth.OIDC.AdminRole == "" {
|
||||
return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// writeConfigFile writes yaml content to a temp path and returns it.
|
||||
func writeConfigFile(t *testing.T, yaml string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// resetViper clears all global viper state between tests.
|
||||
func resetViper(t *testing.T) {
|
||||
t.Helper()
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
// minimalValidYAML is the smallest configuration that passes Validate.
|
||||
const minimalValidYAML = `
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: development
|
||||
garage:
|
||||
endpoint: http://garage:3900
|
||||
admin_endpoint: http://garage:3903
|
||||
admin_token: supersecret
|
||||
`
|
||||
|
||||
func TestLoad_YAMLOnly(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, minimalValidYAML)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("Server.Host = %q, want 0.0.0.0", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.Environment != "development" {
|
||||
t.Errorf("Server.Environment = %q, want development", cfg.Server.Environment)
|
||||
}
|
||||
if cfg.Garage.Endpoint != "http://garage:3900" {
|
||||
t.Errorf("Garage.Endpoint = %q", cfg.Garage.Endpoint)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "supersecret" {
|
||||
t.Errorf("Garage.AdminToken = %q", cfg.Garage.AdminToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOnly_MissingFile(t *testing.T) {
|
||||
resetViper(t)
|
||||
// Point at a path that definitely does not exist. Load tolerates missing
|
||||
// files and falls back to env + viper defaults.
|
||||
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
|
||||
|
||||
// Every required field provided via env.
|
||||
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ENDPOINT", "http://g:3900")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_ENDPOINT", "http://g:3903")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-token")
|
||||
|
||||
cfg, err := Load(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("Load with env-only: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "env-token" {
|
||||
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOverridesYAML(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, minimalValidYAML)
|
||||
|
||||
// YAML has port=8080; env should win.
|
||||
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("Server.Port = %d, want 9090 (env override)", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "env-wins" {
|
||||
t.Errorf("Garage.AdminToken = %q, want env-wins", cfg.Garage.AdminToken)
|
||||
}
|
||||
// Host was not overridden; YAML value should persist.
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("Server.Host = %q, want 0.0.0.0 (from YAML)", cfg.Server.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MalformedYAMLReturnsError(t *testing.T) {
|
||||
resetViper(t)
|
||||
// Deliberately broken YAML: unindented key after a mapping start.
|
||||
path := writeConfigFile(t, "server:\n port: 8080\n:: not: valid ::\n")
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed YAML, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "error reading config file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ValidationFailurePropagates(t *testing.T) {
|
||||
resetViper(t)
|
||||
// Valid YAML syntax but Garage.Endpoint is blank → Validate must fail.
|
||||
path := writeConfigFile(t, `
|
||||
server:
|
||||
port: 8080
|
||||
garage:
|
||||
endpoint: ""
|
||||
admin_endpoint: http://g:3903
|
||||
admin_token: t
|
||||
`)
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid configuration") {
|
||||
t.Errorf("expected wrapped invalid-config error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "garage endpoint is required") {
|
||||
t.Errorf("expected endpoint-required message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// validBaseConfig returns a deep copy of a minimal Config that passes Validate.
|
||||
func validBaseConfig() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{Port: 8080},
|
||||
Garage: GarageConfig{
|
||||
Endpoint: "http://g:3900",
|
||||
AdminEndpoint: "http://g:3903",
|
||||
AdminToken: "t",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyValidOIDC fills OIDC with all required fields.
|
||||
func applyValidOIDC(c *Config) {
|
||||
c.Auth.OIDC.Enabled = true
|
||||
c.Auth.OIDC.ClientID = "client-xyz"
|
||||
c.Auth.OIDC.IssuerURL = "https://idp.example/realms/test"
|
||||
c.Auth.OIDC.Scopes = []string{"openid"}
|
||||
c.Auth.OIDC.AdminRole = "admin"
|
||||
c.Server.RootURL = "https://garage-ui.example"
|
||||
}
|
||||
|
||||
// Note on spec coverage: spec/2026-04-17-backend-test-suite-design.md lists
|
||||
// "invalid log level/format" as a Validate case, but the current Validate does
|
||||
// not check Logging.Level or Logging.Format. That's a code-vs-spec gap to
|
||||
// resolve in a follow-up plan; Stage 2 tests the current behavior only.
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErrContains string // empty = expect no error
|
||||
}{
|
||||
{
|
||||
name: "valid minimal config",
|
||||
mutate: func(c *Config) {},
|
||||
},
|
||||
{
|
||||
name: "port zero is invalid",
|
||||
mutate: func(c *Config) { c.Server.Port = 0 },
|
||||
wantErrContains: "invalid server port",
|
||||
},
|
||||
{
|
||||
name: "port negative is invalid",
|
||||
mutate: func(c *Config) { c.Server.Port = -1 },
|
||||
wantErrContains: "invalid server port",
|
||||
},
|
||||
{
|
||||
name: "port above 65535 is invalid",
|
||||
mutate: func(c *Config) { c.Server.Port = 70000 },
|
||||
wantErrContains: "invalid server port",
|
||||
},
|
||||
{
|
||||
name: "port at 65535 is valid",
|
||||
mutate: func(c *Config) { c.Server.Port = 65535 },
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "missing garage endpoint",
|
||||
mutate: func(c *Config) { c.Garage.Endpoint = "" },
|
||||
wantErrContains: "garage endpoint is required",
|
||||
},
|
||||
{
|
||||
name: "missing garage admin_endpoint",
|
||||
mutate: func(c *Config) { c.Garage.AdminEndpoint = "" },
|
||||
wantErrContains: "admin_endpoint is required",
|
||||
},
|
||||
{
|
||||
name: "missing garage admin_token",
|
||||
mutate: func(c *Config) { c.Garage.AdminToken = "" },
|
||||
wantErrContains: "admin_token is required",
|
||||
},
|
||||
{
|
||||
name: "admin auth enabled without username",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Password = "p"
|
||||
},
|
||||
wantErrContains: "admin auth username and password are required",
|
||||
},
|
||||
{
|
||||
name: "admin auth enabled without password",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "u"
|
||||
},
|
||||
wantErrContains: "admin auth username and password are required",
|
||||
},
|
||||
{
|
||||
name: "admin auth enabled with both set is valid",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "u"
|
||||
c.Auth.Admin.Password = "p"
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "admin auth disabled ignores missing credentials",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = false
|
||||
c.Auth.Admin.Username = ""
|
||||
c.Auth.Admin.Password = ""
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without client_id",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.ClientID = ""
|
||||
},
|
||||
wantErrContains: "oidc client_id is required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without issuer_url",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.IssuerURL = ""
|
||||
},
|
||||
wantErrContains: "oidc issuer_url is required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without server.root_url",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Server.RootURL = ""
|
||||
},
|
||||
wantErrContains: "server.root_url is required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without scopes",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.Scopes = nil
|
||||
},
|
||||
wantErrContains: "oidc scopes are required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without admin_role rejected for safety",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.AdminRole = ""
|
||||
},
|
||||
wantErrContains: "oidc admin_role is required",
|
||||
},
|
||||
{
|
||||
name: "oidc fully configured is valid",
|
||||
mutate: applyValidOIDC,
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "oidc disabled ignores missing client_id",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.OIDC.Enabled = false
|
||||
c.Auth.OIDC.ClientID = ""
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := validBaseConfig()
|
||||
tc.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
|
||||
if tc.wantErrContains == "" {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErrContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErrContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"localhost", 8080, "localhost:8080"},
|
||||
{"0.0.0.0", 80, "0.0.0.0:80"},
|
||||
{"", 443, ":443"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
cfg := &Config{Server: ServerConfig{Host: tc.host, Port: tc.port}}
|
||||
if got := cfg.GetAddress(); got != tc.want {
|
||||
t.Errorf("GetAddress() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDevelopment(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
{"development", true},
|
||||
{"production", false},
|
||||
{"", false},
|
||||
// Case-sensitive per current impl; lock in that behavior.
|
||||
{"Development", false},
|
||||
{"DEV", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.env, func(t *testing.T) {
|
||||
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
|
||||
if got := cfg.IsDevelopment(); got != tc.want {
|
||||
t.Errorf("IsDevelopment(%q) = %v, want %v", tc.env, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProduction(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
{"production", true},
|
||||
{"development", false},
|
||||
{"", false},
|
||||
{"Production", false},
|
||||
{"PROD", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.env, func(t *testing.T) {
|
||||
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
|
||||
if got := cfg.IsProduction(); got != tc.want {
|
||||
t.Errorf("IsProduction(%q) = %v, want %v", tc.env, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// BucketHandler handles bucket-related operations
|
||||
// BucketHandler handles bucket-related HTTP requests.
|
||||
type BucketHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
adminService services.AdminService
|
||||
s3Service services.S3Storage
|
||||
}
|
||||
|
||||
// NewBucketHandler creates a new bucket handler
|
||||
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
|
||||
// NewBucketHandler creates a new bucket handler.
|
||||
func NewBucketHandler(adminService services.AdminService, s3Service services.S3Storage) *BucketHandler {
|
||||
return &BucketHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
@@ -67,11 +67,13 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
bucketInfo := models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "", // Garage doesn't have regions
|
||||
ObjectCount: &detailedInfo.Objects,
|
||||
Size: &detailedInfo.Bytes,
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "",
|
||||
ObjectCount: &detailedInfo.Objects,
|
||||
Size: &detailedInfo.Bytes,
|
||||
WebsiteAccess: detailedInfo.WebsiteAccess,
|
||||
WebsiteConfig: detailedInfo.WebsiteConfig,
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
@@ -306,3 +308,77 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
// UpdateBucketWebsite updates the website access configuration for a bucket
|
||||
//
|
||||
// @Summary Update bucket website configuration
|
||||
// @Description Enables or disables static website hosting for a bucket
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.UpdateBucketWebsiteRequest true "Website configuration"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Website configuration 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}/website [put]
|
||||
func (h *BucketHandler) UpdateBucketWebsite(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.UpdateBucketWebsiteRequest
|
||||
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.Enabled && req.IndexDocument == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "indexDocument is required when enabling website access"),
|
||||
)
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
websiteAccess := &models.UpdateBucketWebsiteAccess{
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
if req.Enabled {
|
||||
websiteAccess.IndexDocument = &req.IndexDocument
|
||||
if req.ErrorDocument != "" {
|
||||
websiteAccess.ErrorDocument = &req.ErrorDocument
|
||||
}
|
||||
}
|
||||
|
||||
updateReq := models.UpdateBucketRequest{
|
||||
WebsiteAccess: websiteAccess,
|
||||
}
|
||||
|
||||
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket website: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ClusterHandler handles cluster management operations
|
||||
// ClusterHandler handles cluster-status HTTP requests.
|
||||
type ClusterHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
adminService services.AdminService
|
||||
}
|
||||
|
||||
// NewClusterHandler creates a new cluster handler
|
||||
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
|
||||
// NewClusterHandler creates a new cluster handler.
|
||||
func NewClusterHandler(adminService services.AdminService) *ClusterHandler {
|
||||
return &ClusterHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// MonitoringHandler handles monitoring operations
|
||||
// MonitoringHandler handles metrics and dashboard HTTP requests.
|
||||
type MonitoringHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
adminService services.AdminService
|
||||
s3Service services.S3Storage
|
||||
}
|
||||
|
||||
// NewMonitoringHandler creates a new monitoring handler
|
||||
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
|
||||
// NewMonitoringHandler creates a new monitoring handler.
|
||||
func NewMonitoringHandler(adminService services.AdminService, s3Service services.S3Storage) *MonitoringHandler {
|
||||
return &MonitoringHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
@@ -11,13 +15,66 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ObjectHandler handles object-related operations
|
||||
type ObjectHandler struct {
|
||||
s3Service *services.S3Service
|
||||
// unsafeInlineContentTypes are MIME types that a browser can execute as
|
||||
// JavaScript in the response's origin when rendered inline. Since the SPA is
|
||||
// served from the same origin as the API, any uploader could otherwise plant
|
||||
// stored XSS by uploading a file with one of these Content-Types.
|
||||
var unsafeInlineContentTypes = map[string]struct{}{
|
||||
"text/html": {},
|
||||
"application/xhtml+xml": {},
|
||||
"image/svg+xml": {},
|
||||
"application/xml": {},
|
||||
"text/xml": {},
|
||||
"application/javascript": {},
|
||||
"text/javascript": {},
|
||||
}
|
||||
|
||||
// NewObjectHandler creates a new object handler
|
||||
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
|
||||
// safeContentType rewrites Content-Types that the browser would treat as
|
||||
// executable to application/octet-stream.
|
||||
func safeContentType(ct string) string {
|
||||
base := strings.TrimSpace(strings.ToLower(ct))
|
||||
if i := strings.IndexByte(base, ';'); i >= 0 {
|
||||
base = strings.TrimSpace(base[:i])
|
||||
}
|
||||
if _, bad := unsafeInlineContentTypes[base]; bad {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
// contentDispositionHeader builds an RFC 6266 / RFC 5987 Content-Disposition
|
||||
// header value with the user-controlled object key safely encoded. Strips
|
||||
// path components and control characters before emitting the ASCII fallback,
|
||||
// then appends the percent-encoded UTF-8 filename*= for full fidelity.
|
||||
func contentDispositionHeader(disposition, key string) string {
|
||||
name := path.Base(key)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
name = "download"
|
||||
}
|
||||
// ASCII-safe fallback: drop anything that could break the quoted value.
|
||||
var asciiFallback strings.Builder
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r > 0x7e {
|
||||
asciiFallback.WriteByte('_')
|
||||
continue
|
||||
}
|
||||
asciiFallback.WriteRune(r)
|
||||
}
|
||||
fallback := asciiFallback.String()
|
||||
if fallback == "" {
|
||||
fallback = "download"
|
||||
}
|
||||
encoded := url.PathEscape(name)
|
||||
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
|
||||
}
|
||||
|
||||
// ObjectHandler handles object-related HTTP requests.
|
||||
type ObjectHandler struct {
|
||||
s3Service services.S3Storage
|
||||
}
|
||||
|
||||
// NewObjectHandler creates a new object handler.
|
||||
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
|
||||
return &ObjectHandler{
|
||||
s3Service: s3Service,
|
||||
}
|
||||
@@ -176,21 +233,29 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", objectInfo.ContentType)
|
||||
c.Set("Content-Length", string(rune(objectInfo.Size)))
|
||||
// The uploader controls Content-Type. Rewrite executable MIME types to
|
||||
// application/octet-stream and always disable sniffing so stored HTML/SVG
|
||||
// cannot run as XSS in the SPA origin when fetched inline.
|
||||
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
|
||||
c.Set("X-Content-Type-Options", "nosniff")
|
||||
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
|
||||
c.Set("ETag", objectInfo.ETag)
|
||||
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
|
||||
|
||||
// Check if client wants to download or view inline
|
||||
// The object key is attacker-controlled — build the header via the safe
|
||||
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
|
||||
disposition := "inline"
|
||||
if c.Query("download") == "true" {
|
||||
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
|
||||
disposition = "attachment"
|
||||
}
|
||||
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
|
||||
|
||||
// Stream the object body to the client
|
||||
return c.SendStream(body)
|
||||
// Stream the object body to the client without buffering the entire file
|
||||
return c.SendStreamWriter(func(w *bufio.Writer) {
|
||||
defer body.Close()
|
||||
io.Copy(w, body)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// UserHandler handles user/key management operations using Garage Admin API
|
||||
// UserHandler handles user and access key HTTP requests.
|
||||
type UserHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
adminService services.AdminService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
|
||||
// NewUserHandler creates a new user handler.
|
||||
func NewUserHandler(adminService services.AdminService) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -20,10 +21,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
origin := c.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
|
||||
// Check if origin is allowed. When credentials are allowed we refuse
|
||||
// to treat "*" as a match: reflecting an arbitrary Origin alongside
|
||||
// Access-Control-Allow-Credentials: true lets any site read responses
|
||||
// cross-origin with the user's session cookie.
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins, cfg.AllowCredentials) {
|
||||
// Set CORS headers
|
||||
c.Set("Access-Control-Allow-Origin", origin)
|
||||
c.Set("Vary", "Origin")
|
||||
|
||||
if cfg.AllowCredentials {
|
||||
c.Set("Access-Control-Allow-Credentials", "true")
|
||||
@@ -41,7 +46,7 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
|
||||
// Set max age for preflight cache
|
||||
if cfg.MaxAge > 0 {
|
||||
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
|
||||
c.Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +59,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedOrigin checks if an origin is in the allowed list
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||
// isAllowedOrigin checks if an origin is in the allowed list.
|
||||
// When allowCredentials is true, "*" is NOT honored — exact match is required.
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string, allowCredentials bool) bool {
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
if allowed == origin {
|
||||
return true
|
||||
}
|
||||
if allowed == "*" && !allowCredentials {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,3 +59,10 @@ type UpdateUserRequest struct {
|
||||
Status *string `json:"status,omitempty"` // "active" or "inactive"
|
||||
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
|
||||
}
|
||||
|
||||
// UpdateBucketWebsiteRequest represents a request to update bucket website configuration
|
||||
type UpdateBucketWebsiteRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IndexDocument string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ type HealthResponse struct {
|
||||
|
||||
// BucketInfo represents information about a bucket
|
||||
type BucketInfo struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
ObjectCount *int64 `json:"objectCount,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
ObjectCount *int64 `json:"objectCount,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
}
|
||||
|
||||
// BucketListResponse represents a list of buckets
|
||||
|
||||
@@ -60,6 +60,7 @@ func SetupRoutes(
|
||||
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
|
||||
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
|
||||
}
|
||||
|
||||
// Object routes
|
||||
@@ -241,6 +242,35 @@ func SetupRoutes(
|
||||
})
|
||||
}
|
||||
|
||||
// Enforce admin role if configured. Roles are often absent from the
|
||||
// ID token and the userinfo endpoint (Keycloak emits resource_access
|
||||
// only in the access token by default), so fall back to the access
|
||||
// token and then the userinfo endpoint before denying access.
|
||||
if cfg.Auth.OIDC.AdminRole != "" {
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 {
|
||||
userInfo.Roles = roles
|
||||
}
|
||||
}
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if uiFromUserinfo, uiErr := authService.GetUserInfo(ctx, token); uiErr == nil {
|
||||
if len(uiFromUserinfo.Roles) > 0 {
|
||||
userInfo.Roles = uiFromUserinfo.Roles
|
||||
}
|
||||
}
|
||||
}
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
logger.Warn().
|
||||
Str("username", userInfo.Username).
|
||||
Str("required_role", cfg.Auth.OIDC.AdminRole).
|
||||
Strs("roles", userInfo.Roles).
|
||||
Msg("OIDC login denied: user does not have required admin role")
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "User does not have the required admin role",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := authService.GenerateSessionToken(userInfo)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
)
|
||||
|
||||
// AdminService is the set of Garage Admin API operations used by HTTP handlers.
|
||||
// It is implemented by *GarageAdminService in admin.go. Kept narrow so that
|
||||
// hand-rolled mocks in tests don't need to cover admin methods the handlers
|
||||
// never call.
|
||||
type AdminService interface {
|
||||
// Access keys
|
||||
ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error)
|
||||
CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error)
|
||||
GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error)
|
||||
UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error)
|
||||
DeleteKey(ctx context.Context, keyID string) error
|
||||
|
||||
// Buckets
|
||||
ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error)
|
||||
GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error)
|
||||
GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error)
|
||||
CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error)
|
||||
UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
|
||||
DeleteBucket(ctx context.Context, bucketID string) error
|
||||
AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
|
||||
|
||||
// Cluster
|
||||
GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error)
|
||||
GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error)
|
||||
GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error)
|
||||
GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
|
||||
GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
|
||||
|
||||
// Monitoring
|
||||
HealthCheck(ctx context.Context) error
|
||||
GetMetrics(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// S3Storage is the set of S3 operations used by HTTP handlers. It is
|
||||
// implemented by *S3Service in s3.go. Methods on *S3Service that are not
|
||||
// called by handlers (ListBuckets, CreateBucket, DeleteBucket,
|
||||
// GetBucketStatistics) are intentionally excluded.
|
||||
type S3Storage interface {
|
||||
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
||||
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
||||
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
||||
ObjectExists(ctx context.Context, bucketName, key string) (bool, error)
|
||||
DeleteObject(ctx context.Context, bucketName, key string) error
|
||||
GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
|
||||
GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
|
||||
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error
|
||||
UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []UploadResult
|
||||
}
|
||||
|
||||
// Compile-time guarantees that the concrete services implement the interfaces.
|
||||
var (
|
||||
_ AdminService = (*GarageAdminService)(nil)
|
||||
_ S3Storage = (*S3Service)(nil)
|
||||
)
|
||||
@@ -317,13 +317,19 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
var object *minio.Object
|
||||
|
||||
// Call MinIO GetObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var getErr error
|
||||
object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
object, getErr = client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
return getErr
|
||||
})
|
||||
if err != nil {
|
||||
@@ -351,10 +357,16 @@ 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Call MinIO RemoveObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ import (
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
const version = "0.1.0"
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// Parse command-line flags
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serializeLoggerTests guards the global mutations (os.Stdout, globalLogger,
|
||||
// zerolog global). These tests cannot run in parallel with each other.
|
||||
var serializeLoggerTests sync.Mutex
|
||||
|
||||
// captureStdout swaps os.Stdout for a pipe, calls fn, restores stdout, and
|
||||
// returns everything written during fn.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
t.Cleanup(func() { os.Stdout = old })
|
||||
|
||||
// Run fn and close writer so the reader unblocks.
|
||||
doneWrite := make(chan struct{})
|
||||
go func() {
|
||||
fn()
|
||||
_ = w.Close()
|
||||
close(doneWrite)
|
||||
}()
|
||||
|
||||
var buf strings.Builder
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
buf.WriteString(scanner.Text())
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
// Drain any residual (shouldn't happen after Close, but safe):
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
<-doneWrite
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestInit_JSONFormatProducesParseableOutput(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "json"})
|
||||
Info().Str("user", "alice").Msg("hello")
|
||||
})
|
||||
|
||||
// Find the first non-empty line; parse as JSON.
|
||||
var line string
|
||||
for l := range strings.SplitSeq(out, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
line = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if line == "" {
|
||||
t.Fatalf("no log output captured; stdout = %q", out)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
||||
t.Fatalf("log line is not valid JSON: %v\nline: %s", err, line)
|
||||
}
|
||||
|
||||
// Field assertions — zerolog uses "message" for the msg and "level" for level.
|
||||
if got, _ := parsed["message"].(string); got != "hello" {
|
||||
t.Errorf("message = %v, want hello", parsed["message"])
|
||||
}
|
||||
if got, _ := parsed["user"].(string); got != "alice" {
|
||||
t.Errorf("user field = %v, want alice", parsed["user"])
|
||||
}
|
||||
if got, _ := parsed["level"].(string); got != "info" {
|
||||
t.Errorf("level = %v, want info", parsed["level"])
|
||||
}
|
||||
if _, ok := parsed["time"]; !ok {
|
||||
t.Errorf("expected time field; got keys %v", keysOf(parsed))
|
||||
}
|
||||
if _, ok := parsed["caller"]; !ok {
|
||||
t.Errorf("expected caller field; got keys %v", keysOf(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_LevelFilterDropsBelowThreshold(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "warn", Format: "json"})
|
||||
Debug().Msg("debug-dropped")
|
||||
Info().Msg("info-dropped")
|
||||
Warn().Msg("warn-kept")
|
||||
Error().Msg("error-kept")
|
||||
})
|
||||
|
||||
if strings.Contains(out, "debug-dropped") {
|
||||
t.Errorf("debug event leaked through warn filter: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "info-dropped") {
|
||||
t.Errorf("info event leaked through warn filter: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "warn-kept") {
|
||||
t.Errorf("warn event missing: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "error-kept") {
|
||||
t.Errorf("error event missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_UnknownLevelDefaultsToInfo(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "gibberish", Format: "json"})
|
||||
Debug().Msg("debug-should-be-dropped")
|
||||
Info().Msg("info-should-appear")
|
||||
})
|
||||
|
||||
if strings.Contains(out, "debug-should-be-dropped") {
|
||||
t.Errorf("debug leaked at default info level: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "info-should-appear") {
|
||||
t.Errorf("info missing at default info level: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_TextFormatDoesNotCrashAndIsNotJSON(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "text"})
|
||||
Info().Str("k", "v").Msg("plain")
|
||||
})
|
||||
|
||||
if !strings.Contains(out, "plain") {
|
||||
t.Errorf("text output missing message: %s", out)
|
||||
}
|
||||
// Console writer output is ANSI-colored key=value form, not JSON.
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal([]byte(strings.Split(out, "\n")[0]), &parsed) == nil {
|
||||
t.Errorf("text format unexpectedly parsed as JSON: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AutoInitializesWhenUnused(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
// Forcibly clear the global so Get() hits the lazy-init branch.
|
||||
globalLogger = nil
|
||||
|
||||
l := Get()
|
||||
if l == nil {
|
||||
t.Fatal("Get() returned nil; lazy init did not run")
|
||||
}
|
||||
if globalLogger == nil {
|
||||
t.Fatal("globalLogger still nil after Get()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithComponent_AddsComponentField(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "json"})
|
||||
comp := WithComponent("buckets")
|
||||
comp.Info().Msg("tagged")
|
||||
})
|
||||
|
||||
line := firstNonEmptyLine(out)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
if got, _ := parsed["component"].(string); got != "buckets" {
|
||||
t.Errorf("component = %v, want buckets", parsed["component"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_WithContext_AddsFields(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "json"})
|
||||
l := Get().WithContext(map[string]any{
|
||||
"request_id": "req-42",
|
||||
"attempt": 2,
|
||||
})
|
||||
l.Info().Msg("ctx")
|
||||
})
|
||||
|
||||
line := firstNonEmptyLine(out)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
if parsed["request_id"] != "req-42" {
|
||||
t.Errorf("request_id = %v", parsed["request_id"])
|
||||
}
|
||||
// JSON numbers decode to float64.
|
||||
if got, _ := parsed["attempt"].(float64); got != 2 {
|
||||
t.Errorf("attempt = %v, want 2", parsed["attempt"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func firstNonEmptyLine(s string) string {
|
||||
for l := range strings.SplitSeq(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCache_GetMissReturnsNil(t *testing.T) {
|
||||
c := NewCache()
|
||||
if v := c.Get("nope"); v != nil {
|
||||
t.Errorf("expected nil for missing key, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetThenGetReturnsValue(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v", time.Minute)
|
||||
got := c.Get("k")
|
||||
if got != "v" {
|
||||
t.Errorf("Get(k) = %v, want v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetWithDifferentTypes(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("str", "hello", time.Minute)
|
||||
c.Set("int", 42, time.Minute)
|
||||
c.Set("slice", []int{1, 2, 3}, time.Minute)
|
||||
|
||||
if got := c.Get("str"); got != "hello" {
|
||||
t.Errorf("str: got %v", got)
|
||||
}
|
||||
if got := c.Get("int"); got != 42 {
|
||||
t.Errorf("int: got %v", got)
|
||||
}
|
||||
if got, ok := c.Get("slice").([]int); !ok || len(got) != 3 {
|
||||
t.Errorf("slice: got %v", c.Get("slice"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GetExpiredReturnsNil(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v", 10*time.Millisecond)
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
if got := c.Get("k"); got != nil {
|
||||
t.Errorf("expected nil after TTL, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_DeleteRemovesItem(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v", time.Minute)
|
||||
c.Delete("k")
|
||||
if got := c.Get("k"); got != nil {
|
||||
t.Errorf("expected nil after Delete, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_DeleteMissingKeyIsNoOp(t *testing.T) {
|
||||
c := NewCache()
|
||||
// Should not panic or error.
|
||||
c.Delete("never-set")
|
||||
}
|
||||
|
||||
func TestCache_ClearRemovesAllItems(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("a", 1, time.Minute)
|
||||
c.Set("b", 2, time.Minute)
|
||||
c.Set("c", 3, time.Minute)
|
||||
|
||||
c.Clear()
|
||||
|
||||
if c.Get("a") != nil || c.Get("b") != nil || c.Get("c") != nil {
|
||||
t.Errorf("expected all items cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetOverwrites(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v1", time.Minute)
|
||||
c.Set("k", "v2", time.Minute)
|
||||
if got := c.Get("k"); got != "v2" {
|
||||
t.Errorf("expected v2 after overwrite, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCache_ConcurrentAccess exercises the RWMutex under load. Run with
|
||||
// `go test -race` to catch data races. Uses bounded concurrency so the test
|
||||
// stays deterministic.
|
||||
func TestCache_ConcurrentAccess(t *testing.T) {
|
||||
c := NewCache()
|
||||
const goroutines = 50
|
||||
const opsPerGoroutine = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for i := range opsPerGoroutine {
|
||||
key := fmt.Sprintf("k%d", (id+i)%10)
|
||||
c.Set(key, i, time.Minute)
|
||||
_ = c.Get(key)
|
||||
if i%10 == 0 {
|
||||
c.Delete(key)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// If we got here without a panic and `-race` is clean, the RWMutex is
|
||||
// protecting the map correctly.
|
||||
}
|
||||
|
||||
// TestGlobalCache_IsUsable is a smoke test for the package-level var.
|
||||
// It doesn't Clear() afterwards because the global is shared state that
|
||||
// other packages may depend on at test time.
|
||||
func TestGlobalCache_IsUsable(t *testing.T) {
|
||||
key := "stage2-smoke-key"
|
||||
GlobalCache.Set(key, "x", time.Minute)
|
||||
t.Cleanup(func() { GlobalCache.Delete(key) })
|
||||
if got := GlobalCache.Get(key); got != "x" {
|
||||
t.Errorf("GlobalCache.Get = %v, want x", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsConnectionRefused(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil error returns false",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated error returns false",
|
||||
err: errors.New("something else went wrong"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "bare ECONNREFUSED returns true (fallback errors.Is branch)",
|
||||
err: syscall.ECONNREFUSED,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped ECONNREFUSED returns true (fallback errors.Is branch)",
|
||||
err: fmt.Errorf("context: %w", syscall.ECONNREFUSED),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OpError dial+ECONNREFUSED returns true (primary branch)",
|
||||
err: &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: syscall.ECONNREFUSED,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OpError read+ECONNREFUSED returns true (primary branch)",
|
||||
err: &net.OpError{
|
||||
Op: "read",
|
||||
Net: "tcp",
|
||||
Err: syscall.ECONNREFUSED,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OpError dial+ETIMEDOUT returns false (primary branch, wrong errno)",
|
||||
err: &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: syscall.ETIMEDOUT,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "OpError dial+plain error falls through to errors.Is and returns false (inner As miss)",
|
||||
err: &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: errors.New("not a syscall errno"),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "OpError write+ECONNREFUSED returns true via fallback errors.Is",
|
||||
err: &net.OpError{
|
||||
Op: "write",
|
||||
Net: "tcp",
|
||||
Err: syscall.ECONNREFUSED,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsConnectionRefused(tc.err); got != tc.want {
|
||||
t.Errorf("IsConnectionRefused(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fastRetryConfig keeps test runtime in the low-millisecond range.
|
||||
func fastRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_SuccessOnFirstAttempt(t *testing.T) {
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
|
||||
calls++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("want 1 call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_NonRetryableErrorReturnedImmediately(t *testing.T) {
|
||||
sentinel := errors.New("boom")
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
|
||||
calls++
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("want wrapped sentinel, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("want 1 call (no retry on non-conn-refused), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_SuccessAfterTransientRefusals(t *testing.T) {
|
||||
cfg := fastRetryConfig()
|
||||
cfg.MaxRetries = 5 // allow up to 6 attempts
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return syscall.ECONNREFUSED
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("want 3 calls (2 failures + 1 success), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_MaxRetriesExceededReturnsWrappedError(t *testing.T) {
|
||||
cfg := fastRetryConfig()
|
||||
cfg.MaxRetries = 2 // 3 total attempts (attempt 0, 1, 2)
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
calls++
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error after exhausting retries, got nil")
|
||||
}
|
||||
if !errors.Is(err, syscall.ECONNREFUSED) {
|
||||
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
|
||||
}
|
||||
// The loop runs attempt = 0..MaxRetries inclusive.
|
||||
if calls != cfg.MaxRetries+1 {
|
||||
t.Errorf("want %d calls, got %d", cfg.MaxRetries+1, calls)
|
||||
}
|
||||
// Error message includes the retry count for operator diagnostics.
|
||||
if !containsAll(err.Error(), "max retries", "2") {
|
||||
t.Errorf("error message missing retry count: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_ZeroMaxRetriesReturnsImmediately(t *testing.T) {
|
||||
cfg := RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialBackoff: 1 * time.Second, // large on purpose; must not sleep
|
||||
MaxBackoff: 5 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
calls := 0
|
||||
start := time.Now()
|
||||
err := RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
calls++
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, syscall.ECONNREFUSED) {
|
||||
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("want 1 call (no retry budget), got %d", calls)
|
||||
}
|
||||
// The only sleep would be after the attempt, but attempt == MaxRetries is
|
||||
// short-circuited before the sleep select. So total runtime must be well
|
||||
// under InitialBackoff.
|
||||
if elapsed >= 500*time.Millisecond {
|
||||
t.Errorf("no-retry path should not have slept; elapsed %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_ContextCancelledDuringBackoff(t *testing.T) {
|
||||
// Use a slow backoff so cancellation is guaranteed to land during the sleep.
|
||||
cfg := RetryConfig{
|
||||
MaxRetries: 5,
|
||||
InitialBackoff: 50 * time.Millisecond,
|
||||
MaxBackoff: 1 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel shortly after the first failed attempt starts its backoff.
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
calls := 0
|
||||
err := RetryWithBackoff(ctx, cfg, func() error {
|
||||
calls++
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context, got nil")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected wrapped context.Canceled, got %v", err)
|
||||
}
|
||||
if calls < 1 {
|
||||
t.Errorf("expected at least 1 call before cancellation, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_WaitsBetweenAttempts(t *testing.T) {
|
||||
// Lower-bound timing check — with InitialBackoff=20ms and BackoffFactor=2,
|
||||
// three failed attempts sleep ~20ms + ~40ms = ~60ms before giving up.
|
||||
// Assert >= 50ms to absorb scheduler jitter.
|
||||
cfg := RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 100 * time.Millisecond,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
start := time.Now()
|
||||
_ = RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if elapsed < 50*time.Millisecond {
|
||||
t.Errorf("expected at least ~60ms of backoff delay, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// containsAll reports whether s contains every substring in subs.
|
||||
func containsAll(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if !strings.Contains(s, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import type { Bucket } from '@/types';
|
||||
@@ -23,6 +23,7 @@ interface BucketListViewProps {
|
||||
onOpenSettings: (bucket: Bucket) => void;
|
||||
onCreateBucket: () => void;
|
||||
onDeleteBucket: (bucket: Bucket) => void;
|
||||
onWebsiteSettings: (bucket: Bucket) => void;
|
||||
}
|
||||
|
||||
export function BucketListView({
|
||||
@@ -34,6 +35,7 @@ export function BucketListView({
|
||||
onOpenSettings,
|
||||
onCreateBucket,
|
||||
onDeleteBucket,
|
||||
onWebsiteSettings,
|
||||
}: BucketListViewProps) {
|
||||
const filteredBuckets = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@@ -94,7 +96,15 @@ export function BucketListView({
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onViewBucket(bucket.name)}
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px]">
|
||||
<span className="truncate">{bucket.name}</span>
|
||||
{bucket.websiteAccess && (
|
||||
<Badge variant="outline" className="text-xs ml-2">
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
Website
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||
</TableCell>
|
||||
@@ -123,6 +133,13 @@ export function BucketListView({
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onWebsiteSettings(bucket);
|
||||
}}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Website Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { accessApi } from '@/lib/api';
|
||||
import type { AccessKey, Bucket } from '@/types';
|
||||
import { useAccessKeys } from '@/hooks/useApi';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BucketSettingsDialogProps {
|
||||
@@ -22,7 +22,7 @@ interface BucketSettingsDialogProps {
|
||||
}
|
||||
|
||||
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
|
||||
const [availableKeys, setAvailableKeys] = useState<AccessKey[]>([]);
|
||||
const { data: availableKeys = [] } = useAccessKeys();
|
||||
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
|
||||
const [permissionRead, setPermissionRead] = useState(false);
|
||||
const [permissionWrite, setPermissionWrite] = useState(false);
|
||||
@@ -30,20 +30,10 @@ export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermis
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
loadAccessKeys();
|
||||
resetForm();
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const loadAccessKeys = async () => {
|
||||
try {
|
||||
const keys = await accessApi.listKeys();
|
||||
setAvailableKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('Failed to load access keys:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedAccessKey('');
|
||||
setPermissionRead(false);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketWebsiteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bucket: Bucket | null;
|
||||
onSave: (
|
||||
bucketName: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function BucketWebsiteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
bucket,
|
||||
onSave,
|
||||
}: BucketWebsiteDialogProps) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [indexDocument, setIndexDocument] = useState('index.html');
|
||||
const [errorDocument, setErrorDocument] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
setEnabled(bucket.websiteAccess);
|
||||
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
|
||||
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!bucket) return;
|
||||
setSaving(true);
|
||||
const success = await onSave(bucket.name, {
|
||||
enabled,
|
||||
indexDocument: enabled ? indexDocument : undefined,
|
||||
errorDocument: enabled && errorDocument ? errorDocument : undefined,
|
||||
});
|
||||
setSaving(false);
|
||||
if (success) onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Website Hosting — {bucket?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure this bucket to serve a static website.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Website access</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Allow public HTTP access to bucket objects
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={enabled ? 'default' : 'secondary'}>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Index document <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={indexDocument}
|
||||
onChange={(e) => setIndexDocument(e.target.value)}
|
||||
placeholder="index.html"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The file served when a directory is requested (e.g. index.html)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Error document</label>
|
||||
<Input
|
||||
value={errorDocument}
|
||||
onChange={(e) => setErrorDocument(e.target.value)}
|
||||
placeholder="404.html (optional)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The file served when an object is not found (optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant={!enabled && bucket?.websiteAccess ? 'destructive' : 'default'}
|
||||
disabled={saving || (enabled && !indexDocument)}
|
||||
>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: !enabled && bucket?.websiteAccess
|
||||
? 'Disable Website'
|
||||
: 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -64,7 +64,6 @@ export function ObjectsTable({
|
||||
const navigate = useNavigate();
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
|
||||
const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]);
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
@@ -86,14 +85,13 @@ export function ObjectsTable({
|
||||
}
|
||||
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||
|
||||
const sortObjects = (objList: S3Object[]): S3Object[] => {
|
||||
const sorted = [...objList].sort((a, b) => {
|
||||
// Always put folders before files
|
||||
const filteredObjects = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aIsFolder = a.isFolder ? 1 : 0;
|
||||
const bIsFolder = b.isFolder ? 1 : 0;
|
||||
if (aIsFolder !== bIsFolder) {
|
||||
return bIsFolder - aIsFolder;
|
||||
}
|
||||
if (aIsFolder !== bIsFolder) return bIsFolder - aIsFolder;
|
||||
|
||||
let compareValue = 0;
|
||||
switch (sortColumn) {
|
||||
@@ -116,20 +114,7 @@ export function ObjectsTable({
|
||||
|
||||
return sortDirection === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
// Effect 1: Apply client-side filtering and sorting (NO pagination reset)
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
|
||||
// Do NOT reset pagination - search/sort are client-side operations
|
||||
}, [searchQuery, objects, sortColumn, sortDirection]);
|
||||
}, [objects, searchQuery, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
// Effect 2: Reset pagination ONLY on path navigation
|
||||
useEffect(() => {
|
||||
@@ -192,6 +177,7 @@ export function ObjectsTable({
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<TooltipProvider>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -301,56 +287,58 @@ export function ObjectsTable({
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>
|
||||
{obj.lastModified ?
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{new Date(obj.lastModified).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
{obj.lastModified ? (() => {
|
||||
const d = new Date(obj.lastModified);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{d.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {d.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(new Date(obj.lastModified))}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{d.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(d)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{d.toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{new Date(obj.lastModified).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>: null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})() : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
@@ -386,6 +374,7 @@ export function ObjectsTable({
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {cn} from '@/lib/utils';
|
||||
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
|
||||
import {useAuthStore} from '@/store/auth-store';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { healthApi, garageApi } from '@/lib/api';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -46,6 +48,24 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
logout();
|
||||
};
|
||||
|
||||
const { data: uiVersion } = useQuery({
|
||||
queryKey: ['ui-version'],
|
||||
queryFn: healthApi.getVersion,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: nodeInfo } = useQuery({
|
||||
queryKey: ['garage-version'],
|
||||
queryFn: () => garageApi.getNodeInfo('self'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const garageVersion = nodeInfo
|
||||
? Object.values(nodeInfo.success)[0]?.garageVersion
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -107,6 +127,13 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(uiVersion || garageVersion) && (
|
||||
<div className="px-4 pb-3 text-xs text-muted-foreground text-center">
|
||||
{uiVersion && `UI ${uiVersion}`}
|
||||
{uiVersion && garageVersion && ' | '}
|
||||
{garageVersion && `Garage ${garageVersion}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, ...props }, ref) => {
|
||||
return (
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-6 w-11 rounded-full border 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',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<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'})` }}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object, UploadTask } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
@@ -13,8 +13,9 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [previousPath, setPreviousPath] = useState<string>(currentPath);
|
||||
const previousPathRef = useRef<string>(currentPath);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
const clearTasksTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
@@ -46,24 +47,26 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
useEffect(() => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Detect if this is a path change (navigation) or initial load
|
||||
const isPathChange = previousPath !== currentPath && objects.length > 0;
|
||||
setPreviousPath(currentPath);
|
||||
const isPathChange = previousPathRef.current !== currentPath && objects.length > 0;
|
||||
previousPathRef.current = currentPath;
|
||||
|
||||
// Use navigation mode if it's a path change, otherwise use normal loading
|
||||
fetchObjects(undefined, false, isPathChange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uploadFiles = useCallback(async (files: File[]) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
// Check if files are from a folder upload
|
||||
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
|
||||
const hasRelativePaths = files.some((file) => !!file.webkitRelativePath);
|
||||
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
files.forEach((file) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
@@ -72,9 +75,8 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize upload tasks
|
||||
const tasks: UploadTask[] = files.map((file, index) => {
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const relativePath = file.webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
return {
|
||||
id: `${Date.now()}-${index}`,
|
||||
@@ -88,53 +90,36 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
|
||||
setUploadTasks(tasks);
|
||||
|
||||
// Upload files with progress tracking and error handling
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Upload files one by one
|
||||
const concurrency = 1;
|
||||
const uploadPromises: Promise<void>[] = [];
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
||||
));
|
||||
|
||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
||||
const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length));
|
||||
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
||||
setUploadTasks(prev => prev.map(t => {
|
||||
if (t.id !== task.id || t.progress === progress) return t;
|
||||
return { ...t, progress };
|
||||
}));
|
||||
});
|
||||
|
||||
const batchPromises = batch.map(async (task) => {
|
||||
try {
|
||||
// Update task status to uploading
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
||||
));
|
||||
|
||||
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, progress } : t
|
||||
));
|
||||
});
|
||||
|
||||
// Update task status to completed
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
||||
));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// Update task status to error but continue with other uploads
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
||||
));
|
||||
errorCount++;
|
||||
console.error(`Failed to upload ${task.key}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
uploadPromises.push(...batchPromises);
|
||||
await Promise.all(batchPromises);
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
||||
));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
||||
));
|
||||
errorCount++;
|
||||
console.error(`Failed to upload ${task.key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// Show summary toast
|
||||
if (errorCount === 0) {
|
||||
if (hasRelativePaths && folders.size > 0) {
|
||||
const folderNames = Array.from(folders).join(', ');
|
||||
@@ -148,9 +133,10 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
// Clear upload tasks after a delay
|
||||
setTimeout(() => {
|
||||
if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
|
||||
clearTasksTimerRef.current = setTimeout(() => {
|
||||
setUploadTasks([]);
|
||||
clearTasksTimerRef.current = null;
|
||||
}, 3000);
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
@@ -161,7 +147,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the object from the UI
|
||||
setObjects(prev => prev.filter(obj => obj.key !== key));
|
||||
|
||||
await objectsApi.delete(bucketName, key);
|
||||
@@ -170,7 +155,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete object error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
@@ -180,7 +164,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
if (!bucketName || keys.length === 0) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the objects from the UI
|
||||
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
|
||||
|
||||
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
|
||||
@@ -189,7 +172,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Bulk delete error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
@@ -200,7 +182,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
|
||||
try {
|
||||
const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`;
|
||||
await objectsApi.upload(bucketName, dirKey, new File([], ''));
|
||||
await objectsApi.upload(bucketName, dirKey, new File([], '.keep'));
|
||||
toast.success(`Directory "${dirName}" created successfully`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import {toast} from 'sonner';
|
||||
import type {
|
||||
AccessKey,
|
||||
ApiResponse,
|
||||
Bucket,
|
||||
BucketDetails,
|
||||
ClusterHealth,
|
||||
@@ -158,6 +159,14 @@ export const authApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Health API
|
||||
export const healthApi = {
|
||||
getVersion: async (): Promise<string> => {
|
||||
const response = await api.get('/v1/health');
|
||||
return response.data.data.version as string;
|
||||
},
|
||||
};
|
||||
|
||||
// Bucket API
|
||||
export const bucketsApi = {
|
||||
list: async (): Promise<Bucket[]> => {
|
||||
@@ -192,6 +201,17 @@ export const bucketsApi = {
|
||||
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
|
||||
await api.patch(`/v1/buckets/${name}/settings`, settings);
|
||||
},
|
||||
|
||||
updateBucketWebsite: async (
|
||||
name: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
) => {
|
||||
const response = await api.put<ApiResponse<any>>(
|
||||
`/v1/buckets/${encodeURIComponent(name)}/website`,
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Objects API
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
@@ -31,7 +31,6 @@ import {toast} from 'sonner';
|
||||
|
||||
export function AccessControl() {
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
@@ -83,7 +82,6 @@ export function AccessControl() {
|
||||
setIsLoading(true);
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
setFilteredKeys(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch keys:', error);
|
||||
} finally {
|
||||
@@ -94,14 +92,15 @@ export function AccessControl() {
|
||||
fetchKeys();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = keys.filter(
|
||||
(key) =>
|
||||
key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredKeys(filtered);
|
||||
}, [searchQuery, keys]);
|
||||
const filteredKeys = useMemo(
|
||||
() =>
|
||||
keys.filter(
|
||||
(key) =>
|
||||
key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
),
|
||||
[keys, searchQuery]
|
||||
);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
|
||||
@@ -8,8 +9,10 @@ import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||
import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog';
|
||||
import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog';
|
||||
import { BucketWebsiteDialog } from '@/components/buckets/BucketWebsiteDialog';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { bucketsApi } from '@/lib/api';
|
||||
|
||||
export function Buckets() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -21,6 +24,8 @@ export function Buckets() {
|
||||
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsBucket, setSettingsBucket] = useState<Bucket | null>(null);
|
||||
const [websiteDialogOpen, setWebsiteDialogOpen] = useState(false);
|
||||
const [websiteBucket, setWebsiteBucket] = useState<Bucket | null>(null);
|
||||
|
||||
// Object browser state - initialize from URL params
|
||||
const [viewingBucket, setViewingBucket] = useState<string | null>(searchParams.get('bucket'));
|
||||
@@ -51,6 +56,7 @@ export function Buckets() {
|
||||
}, [searchParams]);
|
||||
|
||||
// Custom hooks
|
||||
const queryClient = useQueryClient();
|
||||
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
|
||||
const createBucketMutation = useCreateBucket();
|
||||
const deleteBucketMutation = useDeleteBucket();
|
||||
@@ -139,6 +145,25 @@ export function Buckets() {
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenWebsiteSettings = (bucket: Bucket) => {
|
||||
setWebsiteBucket(bucket);
|
||||
setWebsiteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveWebsite = async (
|
||||
bucketName: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await bucketsApi.updateBucketWebsite(bucketName, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['buckets'] });
|
||||
toast.success('Website configuration updated');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshObjects = async () => {
|
||||
if (isRefreshing) return;
|
||||
try {
|
||||
@@ -224,6 +249,7 @@ export function Buckets() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onViewBucket={handleViewBucket}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onWebsiteSettings={handleOpenWebsiteSettings}
|
||||
onCreateBucket={() => setCreateDialogOpen(true)}
|
||||
onDeleteBucket={(bucket) => {
|
||||
setSelectedBucket(bucket);
|
||||
@@ -252,6 +278,13 @@ export function Buckets() {
|
||||
bucket={settingsBucket}
|
||||
onGrantPermission={grantPermission}
|
||||
/>
|
||||
|
||||
<BucketWebsiteDialog
|
||||
open={websiteDialogOpen}
|
||||
onOpenChange={setWebsiteDialogOpen}
|
||||
bucket={websiteBucket}
|
||||
onSave={handleSaveWebsite}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ export interface Bucket {
|
||||
objectCount?: number;
|
||||
size?: number;
|
||||
region?: string;
|
||||
websiteAccess: boolean;
|
||||
websiteConfig?: {
|
||||
indexDocument: string;
|
||||
errorDocument?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
|
||||
@@ -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.1.13
|
||||
appVersion: "v0.1.2"
|
||||
version: 0.2.2
|
||||
appVersion: "v0.2.2"
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
data:
|
||||
|
||||
@@ -2,6 +2,7 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-token
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
@@ -15,6 +16,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-password
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
@@ -31,6 +33,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
@@ -43,6 +46,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-jwt-key
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
@@ -61,6 +65,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
|
||||
@@ -2,6 +2,7 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceMonitor.labels }}
|
||||
|
||||
Reference in New Issue
Block a user