mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-08-01 02:11:15 -04:00
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:
14
services/web/.mockery.yaml
Normal file
14
services/web/.mockery.yaml
Normal 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: {}
|
||||
1363
services/web/mocks/key_value.go
Normal file
1363
services/web/mocks/key_value.go
Normal file
File diff suppressed because it is too large
Load Diff
349
services/web/mocks/key_value_entry.go
Normal file
349
services/web/mocks/key_value_entry.go
Normal 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
|
||||
}
|
||||
@@ -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(µstore.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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user