diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index c01ea5af742..d93bbd91c14 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1603,6 +1603,19 @@ LEVEL = Info ;; If you'd like to enable it, you can set it to a value between 0 and 2. ;TYPE_BLEVE_MAX_FUZZINESS = 0 +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[redis] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; Shared redis connection, used as the default for every redis-backed subsystem +;; (cache, session, queue, global lock, etc.) whose own connection string is left empty. +;; A per-subsystem connection value always takes precedence over this shared default. +;; When left empty, each subsystem keeps its previous behavior (no shared default). +;; e.g. `redis://127.0.0.1:6379/0` (or `redis+cluster://127.0.0.1:6379/0` for a Redis cluster) +;CONN_STR = + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;[queue] @@ -1628,10 +1641,10 @@ LEVEL = Info ;; Batch size to send for batched queues ;BATCH_LENGTH = 20 ;; -;; Connection string for redis queues this will store the redis (or Redis cluster) connection string. ;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb ;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`. -;CONN_STR = "redis://127.0.0.1:6379/0" +;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR. +;CONN_STR = ;; ;; Provides the suffix of the default redis/disk queue name - specific queues can be overridden within in their [queue.name] sections. ;QUEUE_NAME = "_queue" @@ -1929,7 +1942,7 @@ LEVEL = Info ;INTERVAL = 60 ;; ;; For "redis" and "memcache", connection host address -;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` (or `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` for a Redis cluster) +;; redis: default to shared [redis] CONN_STR ;; memcache: `127.0.0.1:11211` ;; twoqueue: `{"size":50000,"recent_ratio":0.25,"ghost_ratio":0.5}` or `50000` ;HOST = @@ -1965,10 +1978,10 @@ LEVEL = Info ;; ;; Provider config options ;; memory: doesn't have any config yet -;; file: session file path, e.g. `data/sessions` -;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` (or `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` for a Redis cluster) +;; file: session file path, e.g. `data/sessions`, relative paths will be made absolute against _`AppWorkPath`_. +;; redis: default to [redis] CONN_STR ;; mysql: go-sql-driver/mysql dsn config string, e.g. `root:password@/session_table` -;PROVIDER_CONFIG = data/sessions ; Relative paths will be made absolute against _`AppWorkPath`_. +;PROVIDER_CONFIG = ;; ;; Session cookie name ;COOKIE_NAME = i_like_gitea @@ -3027,5 +3040,5 @@ LEVEL = Info ;[global_lock] ;; Lock service type, could be memory or redis ;SERVICE_TYPE = memory -;; Ignored for the "memory" type. For "redis" use something like `redis://127.0.0.1:6379/0` +;; Ignored for the "memory" type. For "redis", default to [redis] CONN_STR. ;SERVICE_CONN_STR = diff --git a/modules/setting/cache.go b/modules/setting/cache.go index c8ba04d9dfc..5f4db322f20 100644 --- a/modules/setting/cache.go +++ b/modules/setting/cache.go @@ -4,7 +4,6 @@ package setting import ( - "strings" "time" "gitea.dev/modules/log" @@ -14,7 +13,7 @@ import ( type Cache struct { Adapter string Interval int - Conn string + Conn string `ini:"-"` TTL time.Duration `ini:"ITEM_TTL"` } @@ -53,13 +52,12 @@ func loadCacheFrom(rootCfg ConfigProvider) { CacheService.Adapter = sec.Key("ADAPTER").In("memory", []string{"memory", "redis", "memcache", "twoqueue"}) switch CacheService.Adapter { case "memory": - case "redis", "memcache": - CacheService.Conn = strings.Trim(sec.Key("HOST").String(), "\" ") + case "memcache": + CacheService.Conn = sec.Key("HOST").String() + case "redis": + CacheService.Conn = sec.Key("HOST").MustString(Redis.ConnStr) case "twoqueue": - CacheService.Conn = strings.TrimSpace(sec.Key("HOST").String()) - if CacheService.Conn == "" { - CacheService.Conn = "50000" - } + CacheService.Conn = sec.Key("HOST").MustString("50000") default: log.Fatal("Unknown cache adapter: %s", CacheService.Adapter) } diff --git a/modules/setting/cache_test.go b/modules/setting/cache_test.go new file mode 100644 index 00000000000..65699083b73 --- /dev/null +++ b/modules/setting/cache_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestCacheRedisSharedConnFallback(t *testing.T) { + tests := []struct { + name string + iniStr string + wantConn string + }{ + { + name: "redis adapter with empty HOST falls back to shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[cache] +ADAPTER = redis +`, + wantConn: "redis://127.0.0.1:6379/0", + }, + { + name: "cache HOST wins over shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[cache] +ADAPTER = redis +HOST = redis://10.0.0.1:6379/1 +`, + wantConn: "redis://10.0.0.1:6379/1", + }, + { + name: "no shared [redis] keeps previous behavior (empty conn)", + iniStr: ` +[cache] +ADAPTER = redis +`, + wantConn: "", + }, + { + name: "memcache adapter is never affected by shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[cache] +ADAPTER = memcache +`, + wantConn: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer test.MockVariableValue(&Redis)() + cfg, err := NewConfigProviderFromData(tt.iniStr) + assert.NoError(t, err) + + loadRedisFrom(cfg) + loadCacheFrom(cfg) + assert.Equal(t, tt.wantConn, CacheService.Conn) + }) + } +} diff --git a/modules/setting/gloabl_lock.go b/modules/setting/global_lock.go similarity index 93% rename from modules/setting/gloabl_lock.go rename to modules/setting/global_lock.go index c4913e0fac9..6fff4c44a1a 100644 --- a/modules/setting/gloabl_lock.go +++ b/modules/setting/global_lock.go @@ -22,7 +22,7 @@ func loadGlobalLockFrom(rootCfg ConfigProvider) { switch GlobalLock.ServiceType { case "memory": case "redis": - connStr := sec.Key("SERVICE_CONN_STR").String() + connStr := sec.Key("SERVICE_CONN_STR").MustString(Redis.ConnStr) if connStr == "" { log.Fatal("SERVICE_CONN_STR is empty for redis") } diff --git a/modules/setting/global_lock_test.go b/modules/setting/global_lock_test.go index 5e15eb3483d..22bb4233f3d 100644 --- a/modules/setting/global_lock_test.go +++ b/modules/setting/global_lock_test.go @@ -6,20 +6,24 @@ package setting import ( "testing" + "gitea.dev/modules/test" + "github.com/stretchr/testify/assert" ) func TestLoadGlobalLockConfig(t *testing.T) { t.Run("DefaultGlobalLockConfig", func(t *testing.T) { + defer test.MockVariableValue(&Redis)() iniStr := `` cfg, err := NewConfigProviderFromData(iniStr) assert.NoError(t, err) - + loadRedisFrom(cfg) loadGlobalLockFrom(cfg) assert.Equal(t, "memory", GlobalLock.ServiceType) }) t.Run("RedisGlobalLockConfig", func(t *testing.T) { + defer test.MockVariableValue(&Redis)() iniStr := ` [global_lock] SERVICE_TYPE = redis @@ -27,9 +31,44 @@ SERVICE_CONN_STR = addrs=127.0.0.1:6379 db=0 ` cfg, err := NewConfigProviderFromData(iniStr) assert.NoError(t, err) - + loadRedisFrom(cfg) loadGlobalLockFrom(cfg) assert.Equal(t, "redis", GlobalLock.ServiceType) assert.Equal(t, "addrs=127.0.0.1:6379 db=0", GlobalLock.ServiceConnStr) }) + + t.Run("RedisGlobalLockFallsBackToSharedRedis", func(t *testing.T) { + defer test.MockVariableValue(&Redis)() + iniStr := ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[global_lock] +SERVICE_TYPE = redis +` + cfg, err := NewConfigProviderFromData(iniStr) + assert.NoError(t, err) + + loadRedisFrom(cfg) + loadGlobalLockFrom(cfg) + assert.Equal(t, "redis", GlobalLock.ServiceType) + assert.Equal(t, "redis://127.0.0.1:6379/0", GlobalLock.ServiceConnStr) + }) + + t.Run("RedisGlobalLockOwnConnWinsOverSharedRedis", func(t *testing.T) { + defer test.MockVariableValue(&Redis)() + iniStr := ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[global_lock] +SERVICE_TYPE = redis +SERVICE_CONN_STR = redis://10.0.0.1:6379/1 +` + cfg, err := NewConfigProviderFromData(iniStr) + assert.NoError(t, err) + + loadRedisFrom(cfg) + loadGlobalLockFrom(cfg) + assert.Equal(t, "redis", GlobalLock.ServiceType) + assert.Equal(t, "redis://10.0.0.1:6379/1", GlobalLock.ServiceConnStr) + }) } diff --git a/modules/setting/path.go b/modules/setting/path.go index 773be5e9168..3c16c61033e 100644 --- a/modules/setting/path.go +++ b/modules/setting/path.go @@ -21,6 +21,10 @@ var ( // AppWorkPath is the "working directory" of Gitea. It maps to the: WORK_PATH in app.ini, "--work-path" flag, environment variable GITEA_WORK_DIR. // If that is not set it is the default set here by the linker or failing that the directory of AppPath. // It is used as the base path for several other paths. + // Do remember: + // * Work path might not be the POSIX current working directory or the Gitea's binary directory, Gitea binary is not FHS-compliant + // * Work path sometimes is not writable (e.g.: /usr/local/bin) or not persistent (https://github.com/go-gitea/gitea/pull/35851) + // * Work path, custom path and data path, some of them are the same sometimes (e.g.: docker image, some downstream packages, some manual installations) AppWorkPath string CustomPath string // Custom directory path. Env: GITEA_CUSTOM CustomConf string diff --git a/modules/setting/queue.go b/modules/setting/queue.go index 85012e17841..44918c73aed 100644 --- a/modules/setting/queue.go +++ b/modules/setting/queue.go @@ -9,6 +9,7 @@ import ( "gitea.dev/modules/json" "gitea.dev/modules/log" + "gitea.dev/modules/util" ) // QueueSettings represent the settings for a queue from the ini @@ -77,8 +78,10 @@ func GetQueueSettings(rootCfg ConfigProvider, name string) (QueueSettings, error } cfg.Datadir = filepath.ToSlash(cfg.Datadir) + // redis fallback order: [queue]/[queue.name] CONN_STR (already merged above) wins, + // then the shared [redis] conn, then the built-in localhost default for compatibility if cfg.Type == "redis" && cfg.ConnStr == "" { - cfg.ConnStr = "redis://127.0.0.1:6379/0" + cfg.ConnStr = util.IfZero(Redis.ConnStr, "redis://127.0.0.1:6379/0") } if cfg.Length <= 0 { diff --git a/modules/setting/queue_test.go b/modules/setting/queue_test.go new file mode 100644 index 00000000000..4d4404886d8 --- /dev/null +++ b/modules/setting/queue_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestQueueRedisSharedConnFallback(t *testing.T) { + tests := []struct { + name string + iniStr string + queueName string + wantConn string + }{ + { + name: "redis queue with empty CONN_STR falls back to shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[queue] +TYPE = redis +`, + queueName: "test", + wantConn: "redis://127.0.0.1:6379/0", + }, + { + name: "base [queue] CONN_STR wins over shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[queue] +TYPE = redis +CONN_STR = redis://10.0.0.1:6379/1 +`, + queueName: "test", + wantConn: "redis://10.0.0.1:6379/1", + }, + { + name: "per-queue CONN_STR wins over shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[queue] +TYPE = redis +[queue.test] +CONN_STR = redis://10.0.0.1:6379/2 +`, + queueName: "test", + wantConn: "redis://10.0.0.1:6379/2", + }, + { + name: "other queues still fall back to shared [redis] when only one is overridden", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[queue] +TYPE = redis +[queue.test] +CONN_STR = redis://10.0.0.1:6379/2 +`, + queueName: "other", + wantConn: "redis://127.0.0.1:6379/0", + }, + { + name: "no shared [redis] keeps previous behavior (built-in localhost default)", + iniStr: ` +[queue] +TYPE = redis +`, + queueName: "test", + wantConn: "redis://127.0.0.1:6379/0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer test.MockVariableValue(&Redis)() + cfg, err := NewConfigProviderFromData(tt.iniStr) + assert.NoError(t, err) + + loadRedisFrom(cfg) + q, err := GetQueueSettings(cfg, tt.queueName) + assert.NoError(t, err) + assert.Equal(t, tt.wantConn, q.ConnStr) + }) + } +} diff --git a/modules/setting/redis.go b/modules/setting/redis.go new file mode 100644 index 00000000000..9b88fcb9b63 --- /dev/null +++ b/modules/setting/redis.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +type RedisType struct { + ConnStr string +} + +// Redis represents the shared redis configuration. Its connection string is used +// as the default for every redis-backed subsystem (cache, session, queue, global +// lock) whose own connection string is empty. A per-subsystem value always wins. +var Redis *RedisType + +func loadRedisFrom(rootCfg ConfigProvider) { + Redis = &RedisType{} // make sure the Redis config is correctly loaded before it is accessed by other subsystems + Redis.ConnStr = rootCfg.Section("redis").Key("CONN_STR").String() +} diff --git a/modules/setting/session.go b/modules/setting/session.go index 24545a78745..f8780e7e0b5 100644 --- a/modules/setting/session.go +++ b/modules/setting/session.go @@ -42,13 +42,23 @@ var SessionConfig = struct { func loadSessionFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("session") - SessionConfig.Provider = sec.Key("PROVIDER").In("memory", - []string{"memory", "file", "redis", "mysql", "postgres", "couchbase", "memcache", "db"}) - SessionConfig.ProviderConfig = strings.Trim(sec.Key("PROVIDER_CONFIG").MustString(filepath.Join(AppDataPath, "sessions")), "\" ") - if SessionConfig.Provider == "file" && !filepath.IsAbs(SessionConfig.ProviderConfig) { - SessionConfig.ProviderConfig = filepath.Join(AppWorkPath, SessionConfig.ProviderConfig) + SessionConfig.Provider = sec.Key("PROVIDER").In("memory", []string{"memory", "file", "redis", "mysql", "postgres", "couchbase", "memcache", "db"}) + + switch SessionConfig.Provider { + case "redis": + SessionConfig.ProviderConfig = sec.Key("PROVIDER_CONFIG").MustString(Redis.ConnStr) + case "file": + SessionConfig.ProviderConfig = sec.Key("PROVIDER_CONFIG").MustString(filepath.Join(AppDataPath, "sessions")) + if !filepath.IsAbs(SessionConfig.ProviderConfig) { + // Although the "data path" should be used as Gitea's "data" base directory (work path sometimes is not writable), + // document says the relative session path is based on the "work path", so keep the behavior + SessionConfig.ProviderConfig = filepath.Join(AppWorkPath, SessionConfig.ProviderConfig) + } checkOverlappedPath("[session].PROVIDER_CONFIG", SessionConfig.ProviderConfig) + default: + SessionConfig.ProviderConfig = sec.Key("PROVIDER_CONFIG").String() } + SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea") // HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath SessionConfig.CookiePath = util.IfZero(AppSubURL, "/") diff --git a/modules/setting/session_test.go b/modules/setting/session_test.go new file mode 100644 index 00000000000..0549ea11f99 --- /dev/null +++ b/modules/setting/session_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestSessionRedisSharedConnFallback(t *testing.T) { + tests := []struct { + name string + iniStr string + wantContain string + wantMissing string + }{ + { + name: "redis provider with empty PROVIDER_CONFIG falls back to shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[session] +PROVIDER = redis +`, + wantContain: "redis://127.0.0.1:6379/0", + }, + { + name: "session PROVIDER_CONFIG wins over shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[session] +PROVIDER = redis +PROVIDER_CONFIG = redis://10.0.0.1:6379/1 +`, + wantContain: "redis://10.0.0.1:6379/1", + wantMissing: "127.0.0.1", + }, + { + name: "no shared [redis]", + iniStr: ` +[session] +PROVIDER = redis +`, + wantContain: "", + wantMissing: "redis://", + }, + { + name: "file provider default path is untouched by shared [redis]", + iniStr: ` +[redis] +CONN_STR = redis://127.0.0.1:6379/0 +[session] +PROVIDER = file +`, + wantContain: "sessions", + wantMissing: "redis://", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer test.MockVariableValue(&Redis)() + cfg, err := NewConfigProviderFromData(tt.iniStr) + assert.NoError(t, err) + + loadRedisFrom(cfg) + loadSessionFrom(cfg) + // ProviderConfig is shadowed into a JSON blob at the end of loadSessionFrom + assert.Contains(t, SessionConfig.ProviderConfig, tt.wantContain) + if tt.wantMissing != "" { + assert.NotContains(t, SessionConfig.ProviderConfig, tt.wantMissing) + } + }) + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 483b99622cd..790b8fbb7ca 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -156,6 +156,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadGitFrom(cfg) loadMirrorFrom(cfg) loadMarkupFrom(cfg) + loadRedisFrom(cfg) loadGlobalLockFrom(cfg) loadOtherFrom(cfg) return nil