diff --git a/README.rst b/README.rst index 14908033ac..09d30f13f4 100644 --- a/README.rst +++ b/README.rst @@ -212,6 +212,7 @@ The following options can be configured on the server: pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true **Storage** + storage.session.memcached.address [] List of Memcached server addresses. These can be a simple 'host:port' or a Memcached connection URL with scheme, auth and other options. storage.session.redis.address Redis session database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. If not set it, defaults to an in-memory database. storage.session.redis.database Redis session database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. storage.session.redis.password Redis session database password. If set, it overrides the username in the connection URL. diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index e4c2c47f3f..f972825bbd 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -47,6 +47,7 @@ pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true **Storage** + storage.session.memcached.address [] List of Memcached server addresses. These can be a simple 'host:port' or a Memcached connection URL with scheme, auth and other options. storage.session.redis.address Redis session database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. If not set it, defaults to an in-memory database. storage.session.redis.database Redis session database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. storage.session.redis.password Redis session database password. If set, it overrides the username in the connection URL. diff --git a/storage/cmd/cmd.go b/storage/cmd/cmd.go index 833ed6b97f..8c2e0cd503 100644 --- a/storage/cmd/cmd.go +++ b/storage/cmd/cmd.go @@ -43,12 +43,14 @@ func FlagSet() *pflag.FlagSet { "Note: using SQLite is not recommended in production environments. "+ "If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL').") - flagSet.String("storage.session.redis.address", defs.Redis.Address, "Redis session database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. "+ + flagSet.StringSlice("storage.session.memcached.address", defs.Session.Memcached.Address, "List of Memcached server addresses. These can be a simple 'host:port' or a Memcached connection URL with scheme, auth and other options.") + + flagSet.String("storage.session.redis.address", defs.Session.Redis.Address, "Redis session database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. "+ "If not set it, defaults to an in-memory database.") - flagSet.String("storage.session.redis.username", defs.Redis.Username, "Redis session database username. If set, it overrides the username in the connection URL.") - flagSet.String("storage.session.redis.password", defs.Redis.Password, "Redis session database password. If set, it overrides the username in the connection URL.") - flagSet.String("storage.session.redis.database", defs.Redis.Database, "Redis session database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance.") - flagSet.String("storage.session.redis.tls.truststorefile", defs.Redis.TLS.TrustStoreFile, "PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address).") + flagSet.String("storage.session.redis.username", defs.Session.Redis.Username, "Redis session database username. If set, it overrides the username in the connection URL.") + flagSet.String("storage.session.redis.password", defs.Session.Redis.Password, "Redis session database password. If set, it overrides the username in the connection URL.") + flagSet.String("storage.session.redis.database", defs.Session.Redis.Database, "Redis session database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance.") + flagSet.String("storage.session.redis.tls.truststorefile", defs.Session.Redis.TLS.TrustStoreFile, "PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address).") return flagSet } diff --git a/storage/config.go b/storage/config.go index d44e814feb..3e33af2c38 100644 --- a/storage/config.go +++ b/storage/config.go @@ -42,6 +42,8 @@ type SQLConfig struct { // SessionConfig specifies config for the session storage engine. type SessionConfig struct { - // Type is the type of session storage engine to use. + // Memcached specifies config for the Memcached session storage engine. + Memcached MemcachedConfig `koanf:"memcached"` + // Redis specifies config for the Redis session storage engine. Redis RedisConfig `koanf:"redis"` } diff --git a/storage/engine.go b/storage/engine.go index 6826d0a3be..a20909f763 100644 --- a/storage/engine.go +++ b/storage/engine.go @@ -178,6 +178,10 @@ func (e *engine) Configure(config core.ServerConfig) error { // session storage redisConfig := e.config.Session.Redis + memcachedConfig := e.config.Session.Memcached + if redisConfig.isConfigured() && memcachedConfig.isConfigured() { + return errors.New("only one of 'storage.session.redis' and 'storage.session.memcached' can be configured") + } if redisConfig.isConfigured() { redisDB, err := createRedisDatabase(redisConfig) if err != nil { @@ -188,6 +192,14 @@ func (e *engine) Configure(config core.ServerConfig) error { return fmt.Errorf("unable to configure redis client: %w", err) } e.sessionDatabase = NewRedisSessionDatabase(client, redisConfig.Database) + log.Logger().Info("Redis session storage support enabled.") + } else if memcachedConfig.isConfigured() { + memcachedClient, err := newMemcachedClient(memcachedConfig) + if err != nil { + return fmt.Errorf("unable to initialize memcached client: %w", err) + } + e.sessionDatabase = NewMemcachedSessionDatabase(memcachedClient) + log.Logger().Info("Memcached session storage support enabled.") } else { e.sessionDatabase = NewInMemorySessionDatabase() } diff --git a/storage/engine_test.go b/storage/engine_test.go index 0e6b68aff3..3f61185a89 100644 --- a/storage/engine_test.go +++ b/storage/engine_test.go @@ -20,6 +20,13 @@ package storage import ( "errors" + "fmt" + "os" + "path" + "strings" + "testing" + "time" + "github.com/alicebob/miniredis/v2" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" @@ -27,11 +34,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "os" - "path" - "strings" - "testing" - "time" ) func Test_New(t *testing.T) { @@ -261,7 +263,7 @@ func TestEngine_CheckHealth(t *testing.T) { }) } -func Test_engine_redisSessionDatabase(t *testing.T) { +func Test_engine_sessionDatabase(t *testing.T) { t.Run("redis", func(t *testing.T) { redis := miniredis.RunT(t) e := New().(*engine) @@ -278,4 +280,31 @@ func Test_engine_redisSessionDatabase(t *testing.T) { }) assert.IsType(t, redisSessionDatabase{}, e.GetSessionDatabase()) }) + t.Run("memcached", func(t *testing.T) { + memcached := memcachedTestServer(t) + e := New().(*engine) + e.config = Config{ + Session: SessionConfig{ + Memcached: MemcachedConfig{Address: []string{fmt.Sprintf("localhost:%d", memcached.Port())}}, + }, + } + dataDir := io.TestDirectory(t) + require.NoError(t, e.Configure(core.ServerConfig{Datadir: dataDir})) + require.NoError(t, e.Start()) + t.Cleanup(func() { + _ = e.Shutdown() + }) + assert.IsType(t, &MemcachedSessionDatabase{}, e.GetSessionDatabase()) + }) + t.Run("error on both redis and memcached", func(t *testing.T) { + e := New().(*engine) + e.config = Config{ + Session: SessionConfig{ + Memcached: MemcachedConfig{Address: []string{"localhost:1111"}}, + Redis: RedisConfig{Address: "localhost:1111"}, + }, + } + dataDir := io.TestDirectory(t) + require.Error(t, e.Configure(core.ServerConfig{Datadir: dataDir})) + }) } diff --git a/storage/memcached.go b/storage/memcached.go new file mode 100644 index 0000000000..0dfaeb1c54 --- /dev/null +++ b/storage/memcached.go @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package storage + +import "github.com/bradfitz/gomemcache/memcache" + +// MemcachedConfig holds the configuration for the memcached storage. +type MemcachedConfig struct { + Address []string `koanf:"address"` +} + +// isConfigured returns true if config the indicates Redis support should be enabled. +func (r MemcachedConfig) isConfigured() bool { + return len(r.Address) > 0 +} + +// newMemcachedClient creates a memcache.Client and performs a Ping() +func newMemcachedClient(config MemcachedConfig) (*memcache.Client, error) { + client := memcache.New(config.Address...) + err := client.Ping() + if err != nil { + _ = client.Close() + return nil, err + } + return client, err +} diff --git a/storage/memcached_test.go b/storage/memcached_test.go new file mode 100644 index 0000000000..83c415a86e --- /dev/null +++ b/storage/memcached_test.go @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package storage + +import ( + "fmt" + "github.com/daangn/minimemcached" + "github.com/stretchr/testify/require" + "testing" +) + +func Test_newMemcachedClient(t *testing.T) { + port, err := getRandomAvailablePort() + if err != nil { + t.Fatal(err) + } + + cfg := &minimemcached.Config{ + Port: uint16(port), + } + m, err := minimemcached.Run(cfg) + if err != nil { + t.Fatal(err) + } + + client, err := newMemcachedClient(MemcachedConfig{Address: []string{ + fmt.Sprintf("localhost:%d", m.Port()), + }}) + + defer client.Close() + defer m.Close() + + require.NoError(t, err) + require.NotNil(t, client) +} diff --git a/storage/session_memcached.go b/storage/session_memcached.go index b892bd82cc..6893ccb5dc 100644 --- a/storage/session_memcached.go +++ b/storage/session_memcached.go @@ -29,13 +29,15 @@ import ( ) type MemcachedSessionDatabase struct { + client *memcache.Client underlying *cache.Cache[[]byte] } -func NewMemcachedSessionDatabase(addresses ...string) *MemcachedSessionDatabase { - redisStore := memcachestore.NewMemcache(memcache.New(addresses...), store.WithExpiration(5*time.Minute)) +// NewMemcachedSessionDatabase creates a new MemcachedSessionDatabase using an initialized memcache.Client. +func NewMemcachedSessionDatabase(client *memcache.Client) *MemcachedSessionDatabase { + memcachedStore := memcachestore.NewMemcache(client, store.WithExpiration(5*time.Minute)) return &MemcachedSessionDatabase{ - underlying: cache.New[[]byte](redisStore), + underlying: cache.New[[]byte](memcachedStore), } } @@ -49,7 +51,10 @@ func (s MemcachedSessionDatabase) GetStore(ttl time.Duration, keys ...string) Se } func (s MemcachedSessionDatabase) close() { - // nop + // noop + if s.client != nil { + _ = s.client.Close() + } } func (s MemcachedSessionDatabase) getFullKey(prefixes []string, key string) string { diff --git a/storage/session_memcached_test.go b/storage/session_memcached_test.go index 17607314b1..577f715435 100644 --- a/storage/session_memcached_test.go +++ b/storage/session_memcached_test.go @@ -20,6 +20,7 @@ package storage import ( "fmt" + "github.com/bradfitz/gomemcache/memcache" "github.com/daangn/minimemcached" "net" "testing" @@ -30,13 +31,13 @@ import ( ) func TestNewMemcachedSessionDatabase(t *testing.T) { - db := createMemcachedDatabase(t) + db := memcachedTestDatabase(t) assert.NotNil(t, db) } func TestNewMemcachedSessionDatabase_GetStore(t *testing.T) { - db := createMemcachedDatabase(t) + db := memcachedTestDatabase(t) store := db.GetStore(time.Minute, "key1", "key2").(SessionStoreImpl[[]byte]) @@ -46,7 +47,7 @@ func TestNewMemcachedSessionDatabase_GetStore(t *testing.T) { } func TestNewMemcachedSessionDatabase_Get(t *testing.T) { - db := createMemcachedDatabase(t) + db := memcachedTestDatabase(t) store := db.GetStore(time.Minute, "prefix").(SessionStoreImpl[[]byte]) t.Run("string value is retrieved correctly", func(t *testing.T) { @@ -100,7 +101,7 @@ func TestNewMemcachedSessionDatabase_Get(t *testing.T) { } func TestNewMemcachedSessionDatabase_Delete(t *testing.T) { - db := createMemcachedDatabase(t) + db := memcachedTestDatabase(t) store := db.GetStore(time.Minute, "prefix").(SessionStoreImpl[[]byte]) t.Run("value is deleted", func(t *testing.T) { @@ -120,7 +121,7 @@ func TestNewMemcachedSessionDatabase_Delete(t *testing.T) { } func TestNewMemcachedSessionDatabase_GetAndDelete(t *testing.T) { - db := createMemcachedDatabase(t) + db := memcachedTestDatabase(t) store := db.GetStore(time.Minute, "prefix").(SessionStoreImpl[[]byte]) t.Run("ok", func(t *testing.T) { @@ -152,7 +153,16 @@ func getRandomAvailablePort() (int, error) { return addr.Port, nil } -func createMemcachedDatabase(t *testing.T) *MemcachedSessionDatabase { +func memcachedTestDatabase(t *testing.T) *MemcachedSessionDatabase { + m := memcachedTestServer(t) + client := memcache.New(fmt.Sprintf("localhost:%d", m.Port())) + t.Cleanup(func() { + _ = client.Close() + }) + return NewMemcachedSessionDatabase(client) +} + +func memcachedTestServer(t *testing.T) *minimemcached.MiniMemcached { // get random available port port, err := getRandomAvailablePort() if err != nil { @@ -166,6 +176,8 @@ func createMemcachedDatabase(t *testing.T) *MemcachedSessionDatabase { if err != nil { t.Fatal(err) } - t.Cleanup(func() { m.Close() }) - return NewMemcachedSessionDatabase(fmt.Sprintf("localhost:%d", m.Port())) + t.Cleanup(func() { + m.Close() + }) + return m }