refactor: back the announcement store with plain NATS JetStream

Replace the go-micro store with a NATS JetStream key-value bucket, created via
reva's cache.NewNatsKeyValue, so the web service no longer depends on go-micro
stores. The store methods now take a context, threaded through the config.json
render path and the management handlers. Tests use a mockery mock of
jetstream.KeyValue.
This commit is contained in:
Dominik Schmidt
2026-07-29 10:22:43 +02:00
parent 7d59a83c18
commit 27bdda265f
11 changed files with 1856 additions and 114 deletions

View File

@@ -0,0 +1,14 @@
# maintain v2 separate mocks dir
dir: "{{.InterfaceDir}}/mocks"
structname: "{{.InterfaceName}}"
filename: "{{.InterfaceName | snakecase }}.go"
pkgname: mocks
template: testify
packages:
github.com/nats-io/nats.go/jetstream:
config:
dir: mocks
interfaces:
KeyValue: {}
KeyValueEntry: {}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,349 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"time"
"github.com/nats-io/nats.go/jetstream"
mock "github.com/stretchr/testify/mock"
)
// NewKeyValueEntry creates a new instance of KeyValueEntry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewKeyValueEntry(t interface {
mock.TestingT
Cleanup(func())
}) *KeyValueEntry {
mock := &KeyValueEntry{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// KeyValueEntry is an autogenerated mock type for the KeyValueEntry type
type KeyValueEntry struct {
mock.Mock
}
type KeyValueEntry_Expecter struct {
mock *mock.Mock
}
func (_m *KeyValueEntry) EXPECT() *KeyValueEntry_Expecter {
return &KeyValueEntry_Expecter{mock: &_m.Mock}
}
// Bucket provides a mock function for the type KeyValueEntry
func (_mock *KeyValueEntry) Bucket() string {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Bucket")
}
var r0 string
if returnFunc, ok := ret.Get(0).(func() string); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// KeyValueEntry_Bucket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Bucket'
type KeyValueEntry_Bucket_Call struct {
*mock.Call
}
// Bucket is a helper method to define mock.On call
func (_e *KeyValueEntry_Expecter) Bucket() *KeyValueEntry_Bucket_Call {
return &KeyValueEntry_Bucket_Call{Call: _e.mock.On("Bucket")}
}
func (_c *KeyValueEntry_Bucket_Call) Run(run func()) *KeyValueEntry_Bucket_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *KeyValueEntry_Bucket_Call) Return(s string) *KeyValueEntry_Bucket_Call {
_c.Call.Return(s)
return _c
}
func (_c *KeyValueEntry_Bucket_Call) RunAndReturn(run func() string) *KeyValueEntry_Bucket_Call {
_c.Call.Return(run)
return _c
}
// Created provides a mock function for the type KeyValueEntry
func (_mock *KeyValueEntry) Created() time.Time {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Created")
}
var r0 time.Time
if returnFunc, ok := ret.Get(0).(func() time.Time); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(time.Time)
}
return r0
}
// KeyValueEntry_Created_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Created'
type KeyValueEntry_Created_Call struct {
*mock.Call
}
// Created is a helper method to define mock.On call
func (_e *KeyValueEntry_Expecter) Created() *KeyValueEntry_Created_Call {
return &KeyValueEntry_Created_Call{Call: _e.mock.On("Created")}
}
func (_c *KeyValueEntry_Created_Call) Run(run func()) *KeyValueEntry_Created_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *KeyValueEntry_Created_Call) Return(time1 time.Time) *KeyValueEntry_Created_Call {
_c.Call.Return(time1)
return _c
}
func (_c *KeyValueEntry_Created_Call) RunAndReturn(run func() time.Time) *KeyValueEntry_Created_Call {
_c.Call.Return(run)
return _c
}
// Delta provides a mock function for the type KeyValueEntry
func (_mock *KeyValueEntry) Delta() uint64 {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Delta")
}
var r0 uint64
if returnFunc, ok := ret.Get(0).(func() uint64); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(uint64)
}
return r0
}
// KeyValueEntry_Delta_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delta'
type KeyValueEntry_Delta_Call struct {
*mock.Call
}
// Delta is a helper method to define mock.On call
func (_e *KeyValueEntry_Expecter) Delta() *KeyValueEntry_Delta_Call {
return &KeyValueEntry_Delta_Call{Call: _e.mock.On("Delta")}
}
func (_c *KeyValueEntry_Delta_Call) Run(run func()) *KeyValueEntry_Delta_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *KeyValueEntry_Delta_Call) Return(v uint64) *KeyValueEntry_Delta_Call {
_c.Call.Return(v)
return _c
}
func (_c *KeyValueEntry_Delta_Call) RunAndReturn(run func() uint64) *KeyValueEntry_Delta_Call {
_c.Call.Return(run)
return _c
}
// Key provides a mock function for the type KeyValueEntry
func (_mock *KeyValueEntry) Key() string {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Key")
}
var r0 string
if returnFunc, ok := ret.Get(0).(func() string); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// KeyValueEntry_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key'
type KeyValueEntry_Key_Call struct {
*mock.Call
}
// Key is a helper method to define mock.On call
func (_e *KeyValueEntry_Expecter) Key() *KeyValueEntry_Key_Call {
return &KeyValueEntry_Key_Call{Call: _e.mock.On("Key")}
}
func (_c *KeyValueEntry_Key_Call) Run(run func()) *KeyValueEntry_Key_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *KeyValueEntry_Key_Call) Return(s string) *KeyValueEntry_Key_Call {
_c.Call.Return(s)
return _c
}
func (_c *KeyValueEntry_Key_Call) RunAndReturn(run func() string) *KeyValueEntry_Key_Call {
_c.Call.Return(run)
return _c
}
// Operation provides a mock function for the type KeyValueEntry
func (_mock *KeyValueEntry) Operation() jetstream.KeyValueOp {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Operation")
}
var r0 jetstream.KeyValueOp
if returnFunc, ok := ret.Get(0).(func() jetstream.KeyValueOp); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(jetstream.KeyValueOp)
}
return r0
}
// KeyValueEntry_Operation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Operation'
type KeyValueEntry_Operation_Call struct {
*mock.Call
}
// Operation is a helper method to define mock.On call
func (_e *KeyValueEntry_Expecter) Operation() *KeyValueEntry_Operation_Call {
return &KeyValueEntry_Operation_Call{Call: _e.mock.On("Operation")}
}
func (_c *KeyValueEntry_Operation_Call) Run(run func()) *KeyValueEntry_Operation_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *KeyValueEntry_Operation_Call) Return(keyValueOp jetstream.KeyValueOp) *KeyValueEntry_Operation_Call {
_c.Call.Return(keyValueOp)
return _c
}
func (_c *KeyValueEntry_Operation_Call) RunAndReturn(run func() jetstream.KeyValueOp) *KeyValueEntry_Operation_Call {
_c.Call.Return(run)
return _c
}
// Revision provides a mock function for the type KeyValueEntry
func (_mock *KeyValueEntry) Revision() uint64 {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Revision")
}
var r0 uint64
if returnFunc, ok := ret.Get(0).(func() uint64); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(uint64)
}
return r0
}
// KeyValueEntry_Revision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revision'
type KeyValueEntry_Revision_Call struct {
*mock.Call
}
// Revision is a helper method to define mock.On call
func (_e *KeyValueEntry_Expecter) Revision() *KeyValueEntry_Revision_Call {
return &KeyValueEntry_Revision_Call{Call: _e.mock.On("Revision")}
}
func (_c *KeyValueEntry_Revision_Call) Run(run func()) *KeyValueEntry_Revision_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *KeyValueEntry_Revision_Call) Return(v uint64) *KeyValueEntry_Revision_Call {
_c.Call.Return(v)
return _c
}
func (_c *KeyValueEntry_Revision_Call) RunAndReturn(run func() uint64) *KeyValueEntry_Revision_Call {
_c.Call.Return(run)
return _c
}
// Value provides a mock function for the type KeyValueEntry
func (_mock *KeyValueEntry) Value() []byte {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Value")
}
var r0 []byte
if returnFunc, ok := ret.Get(0).(func() []byte); ok {
r0 = returnFunc()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
return r0
}
// KeyValueEntry_Value_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Value'
type KeyValueEntry_Value_Call struct {
*mock.Call
}
// Value is a helper method to define mock.On call
func (_e *KeyValueEntry_Expecter) Value() *KeyValueEntry_Value_Call {
return &KeyValueEntry_Value_Call{Call: _e.mock.On("Value")}
}
func (_c *KeyValueEntry_Value_Call) Run(run func()) *KeyValueEntry_Value_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *KeyValueEntry_Value_Call) Return(bytes []byte) *KeyValueEntry_Value_Call {
_c.Call.Return(bytes)
return _c
}
func (_c *KeyValueEntry_Value_Call) RunAndReturn(run func() []byte) *KeyValueEntry_Value_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -2,10 +2,11 @@
package announcement
import (
"context"
"encoding/json"
"errors"
microstore "go-micro.dev/v4/store"
"github.com/nats-io/nats.go/jetstream"
)
// _storeKey is the single key under which the announcement is persisted.
@@ -19,33 +20,29 @@ type Announcement struct {
InfoText string `json:"infoText"`
}
// Store persists a single announcement in a key-value store.
// Store persists a single announcement in a NATS JetStream key-value bucket.
type Store struct {
store microstore.Store
kv jetstream.KeyValue
}
// NewStore returns a new announcement Store backed by the given key-value store.
func NewStore(s microstore.Store) *Store {
return &Store{store: s}
// NewStore returns a new announcement Store backed by the given key-value bucket.
func NewStore(kv jetstream.KeyValue) *Store {
return &Store{kv: kv}
}
// Get returns the currently stored announcement. An unset announcement is returned as the zero value.
func (s *Store) Get() (Announcement, error) {
func (s *Store) Get(ctx context.Context) (Announcement, error) {
var a Announcement
records, err := s.store.Read(_storeKey)
entry, err := s.kv.Get(ctx, _storeKey)
if err != nil {
if errors.Is(err, microstore.ErrNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return a, nil
}
return a, err
}
if len(records) == 0 {
return a, nil
}
if err := json.Unmarshal(records[0].Value, &a); err != nil {
if err := json.Unmarshal(entry.Value(), &a); err != nil {
return a, err
}
@@ -53,21 +50,19 @@ func (s *Store) Get() (Announcement, error) {
}
// Set persists the given announcement, overwriting any existing one.
func (s *Store) Set(a Announcement) error {
func (s *Store) Set(ctx context.Context, a Announcement) error {
value, err := json.Marshal(a)
if err != nil {
return err
}
return s.store.Write(&microstore.Record{
Key: _storeKey,
Value: value,
})
_, err = s.kv.Put(ctx, _storeKey, value)
return err
}
// Delete removes the stored announcement. Deleting a missing announcement is a no-op.
func (s *Store) Delete() error {
if err := s.store.Delete(_storeKey); err != nil && !errors.Is(err, microstore.ErrNotFound) {
func (s *Store) Delete(ctx context.Context) error {
if err := s.kv.Delete(ctx, _storeKey); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return err
}
return nil

View File

@@ -1,6 +1,7 @@
package announcement_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -10,22 +11,20 @@ import (
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
cs3permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/nats-io/nats.go/jetstream"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/store"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/web/mocks"
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
)
func newStore() *announcement.Store {
return announcement.NewStore(store.Create(store.Store("memory")))
}
func newGatewaySelector(allowed bool) pool.Selectable[gateway.GatewayAPIClient] {
code := cs3rpc.Code_CODE_OK
name := "announcement-test-allowed"
@@ -54,6 +53,7 @@ func withUser(r *http.Request) *http.Request {
func newService(store *announcement.Store, allowed bool) announcement.Service {
svc, err := announcement.NewService(announcement.ServiceOptions{}.
WithLogger(log.NopLogger()).
WithStore(store).
WithGatewaySelector(newGatewaySelector(allowed)))
Expect(err).ToNot(HaveOccurred())
@@ -61,28 +61,47 @@ func newService(store *announcement.Store, allowed bool) announcement.Service {
}
var _ = Describe("Store", func() {
It("gets, sets and deletes the announcement", func() {
s := newStore()
It("reads the stored announcement", func() {
entry := mocks.NewKeyValueEntry(GinkgoT())
entry.EXPECT().Value().Return([]byte(`{"enabled":true,"bannerText":"hello","infoText":"world"}`))
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil)
got, err := s.Get()
Expect(err).ToNot(HaveOccurred())
Expect(got.BannerText).To(BeEmpty())
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed())
got, err = s.Get()
got, err := announcement.NewStore(kv).Get(context.Background())
Expect(err).ToNot(HaveOccurred())
Expect(got.Enabled).To(BeTrue())
Expect(got.BannerText).To(Equal("hello"))
Expect(got.InfoText).To(Equal("world"))
})
Expect(s.Delete()).To(Succeed())
got, err = s.Get()
It("returns the zero value when unset", func() {
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Get(mock.Anything, "announcement").Return(nil, jetstream.ErrKeyNotFound)
got, err := announcement.NewStore(kv).Get(context.Background())
Expect(err).ToNot(HaveOccurred())
Expect(got.BannerText).To(BeEmpty())
})
It("writes the announcement", func() {
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Put(mock.Anything, "announcement", mock.Anything).Return(uint64(1), nil)
Expect(announcement.NewStore(kv).Set(context.Background(), announcement.Announcement{BannerText: "hello"})).To(Succeed())
})
It("deletes the announcement", func() {
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Delete(mock.Anything, "announcement").Return(nil)
Expect(announcement.NewStore(kv).Delete(context.Background())).To(Succeed())
})
It("treats deleting a missing announcement as a no-op", func() {
Expect(newStore().Delete()).To(Succeed())
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Delete(mock.Anything, "announcement").Return(jetstream.ErrKeyNotFound)
Expect(announcement.NewStore(kv).Delete(context.Background())).To(Succeed())
})
})
@@ -95,7 +114,7 @@ var _ = Describe("Service", func() {
It("succeeds when options are valid", func() {
_, err := announcement.NewService(announcement.ServiceOptions{}.
WithStore(newStore()).
WithStore(announcement.NewStore(mocks.NewKeyValue(GinkgoT()))).
WithGatewaySelector(newGatewaySelector(true)))
Expect(err).ToNot(HaveOccurred())
})
@@ -103,12 +122,15 @@ var _ = Describe("Service", func() {
Describe("Get", func() {
It("returns the full stored announcement when permitted", func() {
s := newStore()
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed())
entry := mocks.NewKeyValueEntry(GinkgoT())
entry.EXPECT().Value().Return([]byte(`{"enabled":true,"bannerText":"hello","infoText":"world"}`))
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil)
req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil))
resp := httptest.NewRecorder()
newService(s, true).Get(resp, req)
newService(announcement.NewStore(kv), true).Get(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var got announcement.Announcement
@@ -122,7 +144,7 @@ var _ = Describe("Service", func() {
req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil))
resp := httptest.NewRecorder()
newService(newStore(), false).Get(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Get(resp, req)
Expect(resp.Code).To(Equal(http.StatusForbidden))
})
@@ -130,34 +152,31 @@ var _ = Describe("Service", func() {
Describe("Set", func() {
It("persists the message when permitted", func() {
s := newStore()
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Put(mock.Anything, "announcement", mock.Anything).Return(uint64(1), nil)
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`)))
resp := httptest.NewRecorder()
newService(s, true).Set(resp, req)
newService(announcement.NewStore(kv), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusNoContent))
got, _ := s.Get()
Expect(got.BannerText).To(Equal("hello"))
})
It("is forbidden without permission", func() {
s := newStore()
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`)))
resp := httptest.NewRecorder()
newService(s, false).Set(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusForbidden))
got, _ := s.Get()
Expect(got.BannerText).To(BeEmpty())
})
It("rejects an invalid body", func() {
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`not json`)))
resp := httptest.NewRecorder()
newService(newStore(), true).Set(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusBadRequest))
})
@@ -167,27 +186,22 @@ var _ = Describe("Service", func() {
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(body)))
resp := httptest.NewRecorder()
s := newStore()
newService(s, true).Set(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusRequestEntityTooLarge))
got, _ := s.Get()
Expect(got.BannerText).To(BeEmpty())
})
})
Describe("Set with an empty banner text", func() {
It("removes the stored announcement", func() {
s := newStore()
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello"})).To(Succeed())
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Delete(mock.Anything, "announcement").Return(nil)
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"enabled":false,"bannerText":"","infoText":""}`)))
resp := httptest.NewRecorder()
newService(s, true).Set(resp, req)
newService(announcement.NewStore(kv), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusNoContent))
got, _ := s.Get()
Expect(got.BannerText).To(BeEmpty())
})
})
})

View File

@@ -118,7 +118,7 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) {
return
}
a, err := s.store.Get()
a, err := s.store.Get(r.Context())
if err != nil {
s.logError(r, err, "could not read announcement from store")
w.WriteHeader(http.StatusInternalServerError)
@@ -177,12 +177,12 @@ func (s Service) Set(w http.ResponseWriter, r *http.Request) {
// an announcement without a banner text is nothing to show, so remove it entirely
if body.BannerText == "" {
if err := s.store.Delete(); err != nil {
if err := s.store.Delete(r.Context()); err != nil {
s.logError(r, err, "could not delete announcement from store")
w.WriteHeader(http.StatusInternalServerError)
return
}
} else if err := s.store.Set(body); err != nil {
} else if err := s.store.Set(r.Context(), body); err != nil {
s.logError(r, err, "could not write announcement to store")
w.WriteHeader(http.StatusInternalServerError)
return

View File

@@ -2,7 +2,6 @@ package config
import (
"context"
"time"
"github.com/opencloud-eu/opencloud/pkg/shared"
)
@@ -30,18 +29,16 @@ type Config struct {
Context context.Context `yaml:"-"`
}
// Store configures the persistent store used to keep runtime managed web settings, e.g. the announcement banner.
// Store configures the NATS JetStream key-value store used to keep runtime managed web settings,
// e.g. the announcement banner.
type Store struct {
Store string `yaml:"store" env:"OC_PERSISTENT_STORE;WEB_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"%%NEXT%%"`
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"%%NEXT%%"`
Table string `yaml:"table" env:"WEB_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"%%NEXT%%"`
TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;WEB_STORE_TTL" desc:"Time to live for entries in the store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the NATS JetStream store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The bucket name the store should use." introductionVersion:"%%NEXT%%"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store." introductionVersion:"%%NEXT%%"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store." introductionVersion:"%%NEXT%%"`
EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store." introductionVersion:"%%NEXT%%"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
}
// Asset defines the available asset configuration.

View File

@@ -86,11 +86,8 @@ func DefaultConfig() *config.Config {
},
GatewayAddress: "eu.opencloud.api.gateway",
Store: config.Store{
Store: "nats-js-kv",
Nodes: []string{"127.0.0.1:9233"},
Database: "web",
Table: "",
TTL: 0,
},
Web: config.Web{
ThemeServer: "https://localhost:9200",

View File

@@ -6,9 +6,8 @@ import (
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/store"
"github.com/opencloud-eu/reva/v2/pkg/storage/cache"
"go-micro.dev/v4"
microstore "go-micro.dev/v4/store"
"github.com/opencloud-eu/opencloud/pkg/cors"
"github.com/opencloud-eu/opencloud/pkg/middleware"
@@ -79,18 +78,20 @@ func Server(opts ...Option) (http.Service, error) {
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"),
)
// persistent store for runtime managed web settings, e.g. the announcement banner
announcementStore := announcement.NewStore(store.Create(
store.Store(options.Config.Store.Store),
store.TTL(options.Config.Store.TTL),
microstore.Nodes(options.Config.Store.Nodes...),
microstore.Database(options.Config.Store.Database),
microstore.Table(options.Config.Store.Table),
store.Authentication(options.Config.Store.AuthUsername, options.Config.Store.AuthPassword),
store.TLSEnabled(options.Config.Store.EnableTLS),
store.TLSInsecure(options.Config.Store.TLSInsecure),
store.TLSRootCA(options.Config.Store.TLSRootCACertificate),
))
// NATS JetStream key-value store for runtime managed web settings, e.g. the announcement banner
kv, err := cache.NewNatsKeyValue(cache.Config{
Nodes: options.Config.Store.Nodes,
Database: options.Config.Store.Database,
AuthUsername: options.Config.Store.AuthUsername,
AuthPassword: options.Config.Store.AuthPassword,
TLSEnabled: options.Config.Store.EnableTLS,
TLSInsecure: options.Config.Store.TLSInsecure,
TLSRootCACertificate: options.Config.Store.TLSRootCACertificate,
}, &options.Logger.Logger)
if err != nil {
return http.Service{}, fmt.Errorf("could not initialize announcement store: %w", err)
}
announcementStore := announcement.NewStore(kv)
handle, err := svc.NewService(
svc.Logger(options.Logger),

View File

@@ -1,6 +1,7 @@
package svc
import (
"context"
"encoding/json"
"io/fs"
"net/http"
@@ -136,7 +137,7 @@ func (p Web) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.mux.ServeHTTP(w, r)
}
func (p Web) getPayload() (payload []byte, err error) {
func (p Web) getPayload(ctx context.Context) (payload []byte, err error) {
// render dynamically using a copy of the config, so per-request values (e.g. the
// announcement) are not written into the shared config concurrently.
webConfig := p.config.Web.Config
@@ -160,7 +161,7 @@ func (p Web) getPayload() (payload []byte, err error) {
// the runtime store is authoritative once it manages an announcement (enabled or explicitly
// disabled); only when it holds nothing do we keep a statically configured one
// (web.config.options.announcement)
if a, managed := p.managedAnnouncement(); managed {
if a, managed := p.managedAnnouncement(ctx); managed {
webConfig.Options.Announcement = a
}
@@ -171,12 +172,12 @@ func (p Web) getPayload() (payload []byte, err error) {
// bool reports whether the store manages one at all: when true, the returned value (nil for an
// explicitly disabled announcement) overrides any static config; when false, the store holds
// nothing and a statically configured announcement is kept.
func (p Web) managedAnnouncement() (*config.Announcement, bool) {
func (p Web) managedAnnouncement(ctx context.Context) (*config.Announcement, bool) {
if p.announcementStore == nil {
return nil, false
}
a, err := p.announcementStore.Get()
a, err := p.announcementStore.Get(ctx)
if err != nil {
p.logger.Error().Err(err).Msg("could not read announcement from store")
return nil, false
@@ -197,8 +198,8 @@ func (p Web) managedAnnouncement() (*config.Announcement, bool) {
}
// Config implements the Service interface.
func (p Web) Config(w http.ResponseWriter, _ *http.Request) {
payload, err := p.getPayload()
func (p Web) Config(w http.ResponseWriter, r *http.Request) {
payload, err := p.getPayload(r.Context())
if err != nil {
http.Error(w, ErrConfigInvalid, http.StatusUnprocessableEntity)
return

View File

@@ -1,56 +1,67 @@
package svc
import (
"context"
"testing"
"github.com/opencloud-eu/reva/v2/pkg/store"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/web/mocks"
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
"github.com/opencloud-eu/opencloud/services/web/pkg/config"
)
func TestManagedAnnouncement(t *testing.T) {
newWeb := func(a *announcement.Store) Web {
return Web{logger: log.NopLogger(), announcementStore: a}
newWeb := func(store *announcement.Store) Web {
return Web{logger: log.NopLogger(), announcementStore: store}
}
newStore := func() *announcement.Store {
return announcement.NewStore(store.Create(store.Store("memory")))
// storeReturning builds a store whose backing bucket returns the given JSON for the announcement key.
storeReturning := func(t *testing.T, value string) *announcement.Store {
entry := mocks.NewKeyValueEntry(t)
entry.EXPECT().Value().Return([]byte(value))
kv := mocks.NewKeyValue(t)
kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil)
return announcement.NewStore(kv)
}
// emptyStore builds a store whose backing bucket has no announcement.
emptyStore := func(t *testing.T) *announcement.Store {
kv := mocks.NewKeyValue(t)
kv.EXPECT().Get(mock.Anything, "announcement").Return(nil, jetstream.ErrKeyNotFound)
return announcement.NewStore(kv)
}
t.Run("not managed when there is no store, so a static config is kept", func(t *testing.T) {
a, managed := newWeb(nil).managedAnnouncement()
a, managed := newWeb(nil).managedAnnouncement(context.Background())
require.Nil(t, a)
require.False(t, managed)
})
t.Run("not managed when the store is empty, so a static config is kept", func(t *testing.T) {
a, managed := newWeb(newStore()).managedAnnouncement()
a, managed := newWeb(emptyStore(t)).managedAnnouncement(context.Background())
require.Nil(t, a)
require.False(t, managed)
})
t.Run("managed but nil when disabled, so a static config is cleared", func(t *testing.T) {
s := newStore()
require.NoError(t, s.Set(announcement.Announcement{Enabled: false, BannerText: "hi", InfoText: "info"}))
a, managed := newWeb(s).managedAnnouncement()
s := storeReturning(t, `{"enabled":false,"bannerText":"hi","infoText":"info"}`)
a, managed := newWeb(s).managedAnnouncement(context.Background())
require.Nil(t, a)
require.True(t, managed)
})
t.Run("not managed when enabled but the banner text is empty", func(t *testing.T) {
s := newStore()
require.NoError(t, s.Set(announcement.Announcement{Enabled: true, InfoText: "info"}))
a, managed := newWeb(s).managedAnnouncement()
s := storeReturning(t, `{"enabled":true,"bannerText":"","infoText":"info"}`)
a, managed := newWeb(s).managedAnnouncement(context.Background())
require.Nil(t, a)
require.False(t, managed)
})
t.Run("managed with banner and info text when enabled with a banner text", func(t *testing.T) {
s := newStore()
require.NoError(t, s.Set(announcement.Announcement{Enabled: true, BannerText: "hi", InfoText: "info"}))
a, managed := newWeb(s).managedAnnouncement()
s := storeReturning(t, `{"enabled":true,"bannerText":"hi","infoText":"info"}`)
a, managed := newWeb(s).managedAnnouncement(context.Background())
require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, a)
require.True(t, managed)
})