From 61b0c336d2df28c3feb29661ca89904d32a7e087 Mon Sep 17 00:00:00 2001 From: Ian Booth Date: Tue, 27 Feb 2024 12:37:23 +1000 Subject: [PATCH] Add storage pool state and service packages for managing storage pools. --- .../servicefactory_mock_test.go | 34 +- domain/servicefactory/model.go | 12 + domain/servicefactory/testing/service.go | 7 + domain/storage/errors/errors.go | 15 + domain/storage/package_test.go | 14 + domain/storage/service/package_test.go | 15 + domain/storage/service/service.go | 234 ++++++++++++ domain/storage/service/service_test.go | 209 +++++++++++ domain/storage/service/state_mock_test.go | 113 ++++++ domain/storage/state/package_test.go | 14 + domain/storage/state/state.go | 332 +++++++++++++++++ domain/storage/state/state_test.go | 348 ++++++++++++++++++ domain/storage/state/types.go | 75 ++++ domain/storage/types.go | 23 ++ .../migration/servicefactory_mock_test.go | 34 +- internal/servicefactory/interface.go | 4 + .../servicefactory_mock_test.go | 60 ++- 17 files changed, 1510 insertions(+), 33 deletions(-) create mode 100644 domain/storage/errors/errors.go create mode 100644 domain/storage/package_test.go create mode 100644 domain/storage/service/package_test.go create mode 100644 domain/storage/service/service.go create mode 100644 domain/storage/service/service_test.go create mode 100644 domain/storage/service/state_mock_test.go create mode 100644 domain/storage/state/package_test.go create mode 100644 domain/storage/state/state.go create mode 100644 domain/storage/state/state_test.go create mode 100644 domain/storage/state/types.go create mode 100644 domain/storage/types.go diff --git a/apiserver/facades/controller/migrationtarget/servicefactory_mock_test.go b/apiserver/facades/controller/migrationtarget/servicefactory_mock_test.go index 5c3f06e43ba..e3798287603 100644 --- a/apiserver/facades/controller/migrationtarget/servicefactory_mock_test.go +++ b/apiserver/facades/controller/migrationtarget/servicefactory_mock_test.go @@ -29,10 +29,12 @@ import ( service13 "github.com/juju/juju/domain/modelmanager/service" service14 "github.com/juju/juju/domain/network/service" service15 "github.com/juju/juju/domain/objectstore/service" - service16 "github.com/juju/juju/domain/unit/service" - service17 "github.com/juju/juju/domain/upgrade/service" - service18 "github.com/juju/juju/domain/user/service" + service16 "github.com/juju/juju/domain/storage/service" + service17 "github.com/juju/juju/domain/unit/service" + service18 "github.com/juju/juju/domain/upgrade/service" + service19 "github.com/juju/juju/domain/user/service" servicefactory "github.com/juju/juju/internal/servicefactory" + storage "github.com/juju/juju/internal/storage" gomock "go.uber.org/mock/gomock" ) @@ -348,11 +350,25 @@ func (mr *MockServiceFactoryMockRecorder) Space() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Space", reflect.TypeOf((*MockServiceFactory)(nil).Space)) } +// Storage mocks base method. +func (m *MockServiceFactory) Storage(arg0 storage.ProviderRegistry) *service16.Service { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Storage", arg0) + ret0, _ := ret[0].(*service16.Service) + return ret0 +} + +// Storage indicates an expected call of Storage. +func (mr *MockServiceFactoryMockRecorder) Storage(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Storage", reflect.TypeOf((*MockServiceFactory)(nil).Storage), arg0) +} + // Unit mocks base method. -func (m *MockServiceFactory) Unit() *service16.Service { +func (m *MockServiceFactory) Unit() *service17.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Unit") - ret0, _ := ret[0].(*service16.Service) + ret0, _ := ret[0].(*service17.Service) return ret0 } @@ -363,10 +379,10 @@ func (mr *MockServiceFactoryMockRecorder) Unit() *gomock.Call { } // Upgrade mocks base method. -func (m *MockServiceFactory) Upgrade() *service17.WatchableService { +func (m *MockServiceFactory) Upgrade() *service18.WatchableService { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Upgrade") - ret0, _ := ret[0].(*service17.WatchableService) + ret0, _ := ret[0].(*service18.WatchableService) return ret0 } @@ -377,10 +393,10 @@ func (mr *MockServiceFactoryMockRecorder) Upgrade() *gomock.Call { } // User mocks base method. -func (m *MockServiceFactory) User() *service18.Service { +func (m *MockServiceFactory) User() *service19.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "User") - ret0, _ := ret[0].(*service18.Service) + ret0, _ := ret[0].(*service19.Service) return ret0 } diff --git a/domain/servicefactory/model.go b/domain/servicefactory/model.go index bf6c6e41333..8d6e9b408d2 100644 --- a/domain/servicefactory/model.go +++ b/domain/servicefactory/model.go @@ -20,8 +20,11 @@ import ( networkstate "github.com/juju/juju/domain/network/state" objectstoreservice "github.com/juju/juju/domain/objectstore/service" objectstorestate "github.com/juju/juju/domain/objectstore/state" + storageservice "github.com/juju/juju/domain/storage/service" + storagestate "github.com/juju/juju/domain/storage/state" unitservice "github.com/juju/juju/domain/unit/service" unitstate "github.com/juju/juju/domain/unit/state" + "github.com/juju/juju/internal/storage" ) // ModelFactory provides access to the services required by the apiserver. @@ -114,3 +117,12 @@ func (s *ModelFactory) Annotation() *annotationService.Service { annotationState.NewState(changestream.NewTxnRunnerFactory(s.modelDB)), ) } + +// Storage returns the model's storage service. +func (s *ModelFactory) Storage(registry storage.ProviderRegistry) *storageservice.Service { + return storageservice.NewService( + storagestate.NewState(changestream.NewTxnRunnerFactory(s.modelDB)), + s.logger.Child("storage"), + registry, + ) +} diff --git a/domain/servicefactory/testing/service.go b/domain/servicefactory/testing/service.go index 79959e10d68..403e92e65e6 100644 --- a/domain/servicefactory/testing/service.go +++ b/domain/servicefactory/testing/service.go @@ -21,10 +21,12 @@ import ( modelmanagerservice "github.com/juju/juju/domain/modelmanager/service" networkservice "github.com/juju/juju/domain/network/service" objectstoreservice "github.com/juju/juju/domain/objectstore/service" + storageservice "github.com/juju/juju/domain/storage/service" unitservice "github.com/juju/juju/domain/unit/service" upgradeservice "github.com/juju/juju/domain/upgrade/service" userservice "github.com/juju/juju/domain/user/service" "github.com/juju/juju/internal/servicefactory" + "github.com/juju/juju/internal/storage" ) // TestingServiceFactory provides access to the services required by the apiserver. @@ -133,6 +135,11 @@ func (s *TestingServiceFactory) Annotation() *annotationservice.Service { return nil } +// Storage returns the storage service. +func (s *TestingServiceFactory) Storage(storage.ProviderRegistry) *storageservice.Service { + return nil +} + // FactoryForModel returns a service factory for the given model uuid. // This will late bind the model service factory to the actual service factory. func (s *TestingServiceFactory) FactoryForModel(modelUUID string) servicefactory.ServiceFactory { diff --git a/domain/storage/errors/errors.go b/domain/storage/errors/errors.go new file mode 100644 index 00000000000..9f817049c1f --- /dev/null +++ b/domain/storage/errors/errors.go @@ -0,0 +1,15 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package errors + +import ( + "github.com/juju/errors" +) + +const ( + // MissingPoolTypeError is used when a provider type is empty. + MissingPoolTypeError = errors.ConstError("pool provider type is missing") + // MissingPoolNameError is used when a name is empty. + MissingPoolNameError = errors.ConstError("pool name is missing") +) diff --git a/domain/storage/package_test.go b/domain/storage/package_test.go new file mode 100644 index 00000000000..72f4c46395b --- /dev/null +++ b/domain/storage/package_test.go @@ -0,0 +1,14 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package storage + +import ( + "testing" + + gc "gopkg.in/check.v1" +) + +func TestPackage(t *testing.T) { + gc.TestingT(t) +} diff --git a/domain/storage/service/package_test.go b/domain/storage/service/package_test.go new file mode 100644 index 00000000000..dac8bb695cd --- /dev/null +++ b/domain/storage/service/package_test.go @@ -0,0 +1,15 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package service + +import ( + "testing" + + gc "gopkg.in/check.v1" +) + +//go:generate go run go.uber.org/mock/mockgen -package service -destination state_mock_test.go github.com/juju/juju/domain/storage/service State +func TestPackage(t *testing.T) { + gc.TestingT(t) +} diff --git a/domain/storage/service/service.go b/domain/storage/service/service.go new file mode 100644 index 00000000000..1dc4373b062 --- /dev/null +++ b/domain/storage/service/service.go @@ -0,0 +1,234 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package service + +import ( + "context" + "fmt" + + "github.com/juju/collections/transform" + "github.com/juju/errors" + + domainstorage "github.com/juju/juju/domain/storage" + storageerrors "github.com/juju/juju/domain/storage/errors" + "github.com/juju/juju/internal/storage" +) + +// State defines an interface for interacting with the underlying state. +type State interface { + CreateStoragePool(ctx context.Context, pool domainstorage.StoragePoolDetails) error + DeleteStoragePool(ctx context.Context, name string) error + ReplaceStoragePool(ctx context.Context, pool domainstorage.StoragePoolDetails) error + ListStoragePools(ctx context.Context, filter domainstorage.StoragePoolFilter) ([]domainstorage.StoragePoolDetails, error) + GetStoragePoolByName(ctx context.Context, name string) (domainstorage.StoragePoolDetails, error) +} + +// Logger facilitates emitting log messages. +type Logger interface { + Debugf(string, ...interface{}) +} + +// Service defines a service for interacting with the underlying state. +type Service struct { + st State + logger Logger + registry storage.ProviderRegistry +} + +// NewService returns a new Service for interacting with the underlying state. +func NewService(st State, logger Logger, registry storage.ProviderRegistry) *Service { + return &Service{ + st: st, + logger: logger, + registry: registry, + } +} + +// PoolAttrs define the attributes of a storage pool. +type PoolAttrs map[string]any + +// CreateStoragePool creates a storage pool, returning an error satisfying [errors.AlreadyExists] +// if a pool with the same name already exists. +func (s *Service) CreateStoragePool(ctx context.Context, name string, providerType storage.ProviderType, attrs PoolAttrs) error { + if name == "" { + return storageerrors.MissingPoolNameError + } + if providerType == "" { + return storageerrors.MissingPoolTypeError + } + + err := s.validateConfig(name, providerType, attrs) + if err != nil { + return errors.Trace(err) + } + + attrsToSave := transform.Map(attrs, func(k string, v any) (string, string) { return k, fmt.Sprint(v) }) + sp := domainstorage.StoragePoolDetails{ + Name: name, + Provider: string(providerType), + Attrs: attrsToSave, + } + err = s.st.CreateStoragePool(ctx, sp) + return errors.Annotatef(err, "creating storage pool %q", name) +} + +func (s *Service) validateConfig(name string, providerType storage.ProviderType, attrs map[string]interface{}) error { + if s.registry == nil { + return errors.New("cannot validate storage provider config without a registry") + } + cfg, err := storage.NewConfig(name, providerType, attrs) + if err != nil { + return errors.Trace(err) + } + p, err := s.registry.StorageProvider(providerType) + if err != nil { + return errors.Trace(err) + } + if err := p.ValidateConfig(cfg); err != nil { + return errors.Annotate(err, "validating storage provider config") + } + return nil +} + +// DeleteStoragePool deletes a storage pool, returning an error satisfying +// [errors.NotFound] if it doesn't exist. +func (s *Service) DeleteStoragePool(ctx context.Context, name string) error { + // TODO(storage) - check in use when we have storage in dqlite + // Below is the code from state that will need to be ported. + /* + var inUse bool + cfg, err := sb.config(context.Background()) + if err != nil { + return errors.Trace(err) + } + operatorStorage, ok := cfg.AllAttrs()[k8sconstants.OperatorStorageKey] + if sb.modelType == ModelTypeCAAS && ok && operatorStorage == poolName { + apps, err := sb.allApplications() + if err != nil { + return errors.Trace(err) + } + inUse = len(apps) > 0 + } else { + query := bson.D{{"constraints.pool", bson.D{{"$eq", poolName}}}} + pools, err := storageCollection.Find(query).Count() + if err != nil { + return errors.Trace(err) + } + inUse = pools > 0 + } + if inUse { + return errors.Errorf("storage pool %q in use", poolName) + } + */ + err := s.st.DeleteStoragePool(ctx, name) + return errors.Annotatef(err, "deleting storage pool %q", name) +} + +// ReplaceStoragePool replaces an existing storage pool, returning an error +// satisfying [errors.NotFound] if a pool with the name does not exist. +func (s *Service) ReplaceStoragePool(ctx context.Context, name string, providerType storage.ProviderType, attrs PoolAttrs) error { + // Use the existing provider type unless explicitly overwritten. + if providerType == "" { + existingConfig, err := s.st.GetStoragePoolByName(ctx, name) + if err != nil { + return errors.Trace(err) + } + providerType = storage.ProviderType(existingConfig.Provider) + } + + err := s.validateConfig(name, providerType, attrs) + if err != nil { + return errors.Trace(err) + } + + attrsToSave := transform.Map(attrs, func(k string, v any) (string, string) { return k, fmt.Sprint(v) }) + sp := domainstorage.StoragePoolDetails{ + Name: name, + Provider: string(providerType), + Attrs: attrsToSave, + } + err = s.st.ReplaceStoragePool(ctx, sp) + return errors.Annotatef(err, "replacing storage pool %q", name) +} + +// ListStoragePools returns the storage pools matching the specified filter. +func (s *Service) ListStoragePools(ctx context.Context, filter domainstorage.StoragePoolFilter) ([]*storage.Config, error) { + if err := s.validatePoolListFilter(filter); err != nil { + return nil, errors.Trace(err) + } + + sp, err := s.st.ListStoragePools(ctx, filter) + if err != nil { + return nil, errors.Trace(err) + } + results := make([]*storage.Config, len(sp)) + for i, r := range sp { + results[i], err = s.toStorageConfig(r) + if err != nil { + return nil, errors.Trace(err) + } + } + return results, nil +} + +func (a *Service) validatePoolListFilter(filter domainstorage.StoragePoolFilter) error { + if err := a.validateProviderCriteria(filter.Providers); err != nil { + return errors.Trace(err) + } + if err := a.validateNameCriteria(filter.Names); err != nil { + return errors.Trace(err) + } + return nil +} + +func (a *Service) validateNameCriteria(names []string) error { + for _, n := range names { + if !storage.IsValidPoolName(n) { + return errors.NotValidf("pool name %q", n) + } + } + return nil +} + +func (s *Service) validateProviderCriteria(providers []string) error { + if s.registry == nil { + return errors.New("cannot filter storage providers without a registry") + } + for _, p := range providers { + _, err := s.registry.StorageProvider(storage.ProviderType(p)) + if err != nil { + return errors.Trace(err) + } + } + return nil +} + +// GetStoragePoolByName returns the storage pool with the specified name, returning an error +// satisfying [errors.NotFound] if it doesn't exist. +func (s *Service) GetStoragePoolByName(ctx context.Context, name string) (*storage.Config, error) { + sp, err := s.st.GetStoragePoolByName(ctx, name) + if err != nil { + return nil, errors.Trace(err) + } + return s.toStorageConfig(sp) +} + +func (s *Service) toStorageConfig(sp domainstorage.StoragePoolDetails) (*storage.Config, error) { + if s.registry == nil { + return nil, errors.New("cannot load storage pools without a registry") + } + attr := transform.Map(sp.Attrs, func(k, v string) (string, any) { return k, v }) + cfg, err := storage.NewConfig(sp.Name, storage.ProviderType(sp.Provider), attr) + if err != nil { + return nil, errors.Trace(err) + } + p, err := s.registry.StorageProvider(cfg.Provider()) + if err != nil { + return nil, errors.Trace(err) + } + if err := p.ValidateConfig(cfg); err != nil { + return nil, errors.Trace(err) + } + return cfg, nil +} diff --git a/domain/storage/service/service_test.go b/domain/storage/service/service_test.go new file mode 100644 index 00000000000..e1990a88e69 --- /dev/null +++ b/domain/storage/service/service_test.go @@ -0,0 +1,209 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package service + +import ( + "context" + "fmt" + + "github.com/juju/errors" + "github.com/juju/loggo/v2" + "github.com/juju/testing" + jc "github.com/juju/testing/checkers" + "go.uber.org/mock/gomock" + gc "gopkg.in/check.v1" + + domainstorage "github.com/juju/juju/domain/storage" + "github.com/juju/juju/internal/storage" + dummystorage "github.com/juju/juju/internal/storage/provider/dummy" +) + +type serviceSuite struct { + testing.IsolationSuite + + state *MockState + registry storage.ProviderRegistry +} + +var _ = gc.Suite(&serviceSuite{}) + +func (s *serviceSuite) setupMocks(c *gc.C) *gomock.Controller { + ctrl := gomock.NewController(c) + + s.state = NewMockState(ctrl) + + s.registry = storage.StaticProviderRegistry{ + Providers: map[storage.ProviderType]storage.Provider{ + "ebs": &dummystorage.StorageProvider{ + ValidateConfigFunc: func(sp *storage.Config) error { + if _, ok := sp.Attrs()["foo"]; !ok { + return fmt.Errorf("missing attribute foo") + } + return nil + }, + }, + }, + } + + return ctrl +} + +func (s *serviceSuite) service() *Service { + return NewService(s.state, loggo.GetLogger("test"), s.registry) +} + +func (s *serviceSuite) TestCreateStoragePool(c *gc.C) { + defer s.setupMocks(c).Finish() + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + }, + } + s.state.EXPECT().CreateStoragePool(gomock.Any(), sp).Return(nil) + + err := s.service().CreateStoragePool(context.Background(), "ebs-fast", "ebs", PoolAttrs{"foo": "foo val"}) + c.Assert(err, jc.ErrorIsNil) +} + +func (s *serviceSuite) TestCreateStoragePoolValidates(c *gc.C) { + defer s.setupMocks(c).Finish() + + err := s.service().CreateStoragePool(context.Background(), "ebs-fast", "ebs", PoolAttrs{"bar": "bar val"}) + c.Assert(err, gc.ErrorMatches, `.* missing attribute foo`) +} + +func (s *serviceSuite) TestDeleteStoragePool(c *gc.C) { + defer s.setupMocks(c).Finish() + + s.state.EXPECT().DeleteStoragePool(gomock.Any(), "ebs-fast").Return(nil) + + err := s.service().DeleteStoragePool(context.Background(), "ebs-fast") + c.Assert(err, jc.ErrorIsNil) +} + +func (s *serviceSuite) TestReplaceStoragePool(c *gc.C) { + defer s.setupMocks(c).Finish() + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + }, + } + s.state.EXPECT().ReplaceStoragePool(gomock.Any(), sp).Return(nil) + + err := s.service().ReplaceStoragePool(context.Background(), "ebs-fast", "ebs", PoolAttrs{"foo": "foo val"}) + c.Assert(err, jc.ErrorIsNil) +} + +func (s *serviceSuite) TestReplaceStoragePoolExistingProvider(c *gc.C) { + defer s.setupMocks(c).Finish() + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + }, + } + s.state.EXPECT().GetStoragePoolByName(gomock.Any(), "ebs-fast").Return(sp, nil) + s.state.EXPECT().ReplaceStoragePool(gomock.Any(), sp).Return(nil) + + err := s.service().ReplaceStoragePool(context.Background(), "ebs-fast", "", PoolAttrs{"foo": "foo val"}) + c.Assert(err, jc.ErrorIsNil) +} + +func (s *serviceSuite) TestReplaceStoragePoolValidates(c *gc.C) { + defer s.setupMocks(c).Finish() + + err := s.service().ReplaceStoragePool(context.Background(), "ebs-fast", "ebs", PoolAttrs{"bar": "bar val"}) + c.Assert(err, gc.ErrorMatches, `.* missing attribute foo`) +} + +func (s *serviceSuite) TestListStoragePoolsNoFilter(c *gc.C) { + defer s.setupMocks(c).Finish() + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + }, + } + s.state.EXPECT().ListStoragePools(gomock.Any(), domainstorage.StoragePoolFilter{}).Return([]domainstorage.StoragePoolDetails{sp}, nil) + + got, err := s.service().ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{}) + c.Assert(err, jc.ErrorIsNil) + expected, err := storage.NewConfig("ebs-fast", "ebs", storage.Attrs{"foo": "foo val"}) + c.Assert(err, jc.ErrorIsNil) + c.Assert(got, jc.DeepEquals, []*storage.Config{expected}) +} + +func (s *serviceSuite) TestListStoragePoolsValidFilter(c *gc.C) { + defer s.setupMocks(c).Finish() + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + }, + } + s.state.EXPECT().ListStoragePools(gomock.Any(), domainstorage.StoragePoolFilter{ + Names: []string{"ebs-fast"}, + Providers: []string{"ebs"}, + }).Return([]domainstorage.StoragePoolDetails{sp}, nil) + + got, err := s.service().ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{ + Names: []string{"ebs-fast"}, + Providers: []string{"ebs"}, + }) + c.Assert(err, jc.ErrorIsNil) + expected, err := storage.NewConfig("ebs-fast", "ebs", storage.Attrs{"foo": "foo val"}) + c.Assert(err, jc.ErrorIsNil) + c.Assert(got, jc.DeepEquals, []*storage.Config{expected}) +} + +func (s *serviceSuite) TestListStoragePoolsInvalidFilterName(c *gc.C) { + defer s.setupMocks(c).Finish() + + _, err := s.service().ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{ + Names: []string{"666invalid"}, + }) + c.Assert(err, jc.ErrorIs, errors.NotValid) + c.Assert(err, gc.ErrorMatches, `pool name "666invalid" not valid`) +} + +func (s *serviceSuite) TestListStoragePoolsInvalidFilterProvider(c *gc.C) { + defer s.setupMocks(c).Finish() + + _, err := s.service().ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{ + Providers: []string{"loop"}, + }) + c.Assert(err, jc.ErrorIs, errors.NotFound) + c.Assert(err, gc.ErrorMatches, `storage provider "loop" not found`) +} + +func (s *serviceSuite) TestGetStoragePoolByName(c *gc.C) { + defer s.setupMocks(c).Finish() + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + }, + } + s.state.EXPECT().GetStoragePoolByName(gomock.Any(), "ebs-fast").Return(sp, nil) + + got, err := s.service().GetStoragePoolByName(context.Background(), "ebs-fast") + c.Assert(err, jc.ErrorIsNil) + expected, err := storage.NewConfig("ebs-fast", "ebs", storage.Attrs{"foo": "foo val"}) + c.Assert(err, jc.ErrorIsNil) + c.Assert(got, jc.DeepEquals, expected) +} diff --git a/domain/storage/service/state_mock_test.go b/domain/storage/service/state_mock_test.go new file mode 100644 index 00000000000..03e79e94067 --- /dev/null +++ b/domain/storage/service/state_mock_test.go @@ -0,0 +1,113 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/juju/juju/domain/storage/service (interfaces: State) +// +// Generated by this command: +// +// mockgen -package service -destination state_mock_test.go github.com/juju/juju/domain/storage/service State +// + +// Package service is a generated GoMock package. +package service + +import ( + context "context" + reflect "reflect" + + storage "github.com/juju/juju/domain/storage" + gomock "go.uber.org/mock/gomock" +) + +// MockState is a mock of State interface. +type MockState struct { + ctrl *gomock.Controller + recorder *MockStateMockRecorder +} + +// MockStateMockRecorder is the mock recorder for MockState. +type MockStateMockRecorder struct { + mock *MockState +} + +// NewMockState creates a new mock instance. +func NewMockState(ctrl *gomock.Controller) *MockState { + mock := &MockState{ctrl: ctrl} + mock.recorder = &MockStateMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockState) EXPECT() *MockStateMockRecorder { + return m.recorder +} + +// CreateStoragePool mocks base method. +func (m *MockState) CreateStoragePool(arg0 context.Context, arg1 storage.StoragePoolDetails) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateStoragePool", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateStoragePool indicates an expected call of CreateStoragePool. +func (mr *MockStateMockRecorder) CreateStoragePool(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateStoragePool", reflect.TypeOf((*MockState)(nil).CreateStoragePool), arg0, arg1) +} + +// DeleteStoragePool mocks base method. +func (m *MockState) DeleteStoragePool(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteStoragePool", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteStoragePool indicates an expected call of DeleteStoragePool. +func (mr *MockStateMockRecorder) DeleteStoragePool(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteStoragePool", reflect.TypeOf((*MockState)(nil).DeleteStoragePool), arg0, arg1) +} + +// GetStoragePoolByName mocks base method. +func (m *MockState) GetStoragePoolByName(arg0 context.Context, arg1 string) (storage.StoragePoolDetails, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStoragePoolByName", arg0, arg1) + ret0, _ := ret[0].(storage.StoragePoolDetails) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetStoragePoolByName indicates an expected call of GetStoragePoolByName. +func (mr *MockStateMockRecorder) GetStoragePoolByName(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStoragePoolByName", reflect.TypeOf((*MockState)(nil).GetStoragePoolByName), arg0, arg1) +} + +// ListStoragePools mocks base method. +func (m *MockState) ListStoragePools(arg0 context.Context, arg1 storage.StoragePoolFilter) ([]storage.StoragePoolDetails, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListStoragePools", arg0, arg1) + ret0, _ := ret[0].([]storage.StoragePoolDetails) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListStoragePools indicates an expected call of ListStoragePools. +func (mr *MockStateMockRecorder) ListStoragePools(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListStoragePools", reflect.TypeOf((*MockState)(nil).ListStoragePools), arg0, arg1) +} + +// ReplaceStoragePool mocks base method. +func (m *MockState) ReplaceStoragePool(arg0 context.Context, arg1 storage.StoragePoolDetails) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReplaceStoragePool", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReplaceStoragePool indicates an expected call of ReplaceStoragePool. +func (mr *MockStateMockRecorder) ReplaceStoragePool(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceStoragePool", reflect.TypeOf((*MockState)(nil).ReplaceStoragePool), arg0, arg1) +} diff --git a/domain/storage/state/package_test.go b/domain/storage/state/package_test.go new file mode 100644 index 00000000000..689a9b62b54 --- /dev/null +++ b/domain/storage/state/package_test.go @@ -0,0 +1,14 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package state + +import ( + "testing" + + gc "gopkg.in/check.v1" +) + +func TestPackage(t *testing.T) { + gc.TestingT(t) +} diff --git a/domain/storage/state/state.go b/domain/storage/state/state.go new file mode 100644 index 00000000000..31a388f836a --- /dev/null +++ b/domain/storage/state/state.go @@ -0,0 +1,332 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package state + +import ( + "context" + "fmt" + + "github.com/canonical/sqlair" + "github.com/juju/errors" + + coredatabase "github.com/juju/juju/core/database" + "github.com/juju/juju/domain" + domainstorage "github.com/juju/juju/domain/storage" + "github.com/juju/juju/internal/uuid" +) + +// State represents database interactions dealing with block devices. +type State struct { + *domain.StateBase +} + +// NewState returns a new block device state +// based on the input database factory method. +func NewState(factory coredatabase.TxnRunnerFactory) *State { + return &State{ + StateBase: domain.NewStateBase(factory), + } +} + +type poolAttributes map[string]string + +// CreateStoragePool creates a storage pool, returning an error satisfying [errors.AlreadyExists] +// if a pool with the same name already exists. +func (st State) CreateStoragePool(ctx context.Context, pool domainstorage.StoragePoolDetails) error { + db, err := st.DB() + if err != nil { + return errors.Trace(err) + } + + selectUUIDStmt, err := sqlair.Prepare("SELECT &StoragePool.uuid FROM storage_pool WHERE name = $StoragePool.name", StoragePool{}) + if err != nil { + return errors.Trace(domain.CoerceError(err)) + } + err = db.Txn(ctx, func(ctx context.Context, tx *sqlair.TX) error { + dbPool := StoragePool{Name: pool.Name} + err := tx.Query(ctx, selectUUIDStmt, dbPool).Get(&dbPool) + if err != nil && !errors.Is(err, sqlair.ErrNoRows) { + return errors.Trace(domain.CoerceError(err)) + } + if err == nil { + return fmt.Errorf("storage pool %q %w", pool.Name, errors.AlreadyExists) + } + poolUUID := uuid.MustNewUUID().String() + + if err := upsertStoragePool(ctx, tx, poolUUID, pool.Name, pool.Provider); err != nil { + return errors.Annotate(domain.CoerceError(err), "creating storage pool") + } + + if err := updatePoolAttributes(ctx, tx, poolUUID, pool.Attrs); err != nil { + return errors.Annotatef(domain.CoerceError(err), "creating storage pool %s attributes", poolUUID) + } + return nil + }) + + return errors.Trace(err) +} + +func upsertStoragePool(ctx context.Context, tx *sqlair.TX, poolUUID string, name string, providerType string) error { + if name == "" { + return fmt.Errorf("storage pool name cannot be empty%w", errors.Hide(errors.NotValid)) + } + if providerType == "" { + return fmt.Errorf("storage pool type cannot be empty%w", errors.Hide(errors.NotValid)) + } + + dbPool := StoragePool{ + ID: poolUUID, + Name: name, + ProviderType: providerType, + } + + insertQuery := ` +INSERT INTO storage_pool (uuid, name, type) +VALUES ( + $StoragePool.uuid, + $StoragePool.name, + $StoragePool.type +) +ON CONFLICT(uuid) DO UPDATE SET name=excluded.name, + type=excluded.type +` + + insertStmt, err := sqlair.Prepare(insertQuery, StoragePool{}) + if err != nil { + return errors.Trace(err) + } + + err = tx.Query(ctx, insertStmt, dbPool).Run() + if err != nil { + return errors.Trace(err) + } + return nil +} + +func updatePoolAttributes(ctx context.Context, tx *sqlair.TX, storagePoolUUID string, attr domainstorage.Attrs) error { + // Delete any keys no longer in the attributes map. + // TODO(wallyworld) - use sqlair NOT IN operation + deleteQuery := fmt.Sprintf(` +DELETE FROM storage_pool_attribute +WHERE storage_pool_uuid = $M.uuid +-- AND key NOT IN (?) +`) + + deleteStmt, err := sqlair.Prepare(deleteQuery, sqlair.M{}) + if err != nil { + return errors.Trace(err) + } + if err := tx.Query(ctx, deleteStmt, sqlair.M{"uuid": storagePoolUUID}).Run(); err != nil { + return errors.Trace(domain.CoerceError(err)) + } + + insertQuery := ` +INSERT INTO storage_pool_attribute +VALUES ( + $poolAttribute.storage_pool_uuid, + $poolAttribute.key, + $poolAttribute.value +) +ON CONFLICT(storage_pool_uuid, key) DO UPDATE SET key=excluded.key, + value=excluded.value +` + insertStmt, err := sqlair.Prepare(insertQuery, poolAttribute{}) + if err != nil { + return errors.Trace(err) + } + + for key, value := range attr { + if err := tx.Query(ctx, insertStmt, poolAttribute{ + ID: storagePoolUUID, + Key: key, + Value: value, + }).Run(); err != nil { + return errors.Trace(domain.CoerceError(err)) + } + } + return nil +} + +// DeleteStoragePool deletes a storage pool, returning an error satisfying +// [errors.NotFound] if it doesn't exist. +func (st State) DeleteStoragePool(ctx context.Context, name string) error { + db, err := st.DB() + if err != nil { + return errors.Trace(err) + } + + poolAttributeDeleteQ := ` +DELETE FROM storage_pool_attribute +WHERE storage_pool_attribute.storage_pool_uuid = (select uuid FROM storage_pool WHERE name = $M.name) +` + + poolDeleteQ := ` +DELETE FROM storage_pool +WHERE storage_pool.uuid = (select uuid FROM storage_pool WHERE name = $M.name) +` + + poolAttributeDeleteStmt, err := sqlair.Prepare(poolAttributeDeleteQ, sqlair.M{}) + if err != nil { + return errors.Trace(err) + } + poolDeleteStmt, err := sqlair.Prepare(poolDeleteQ, sqlair.M{}) + if err != nil { + return errors.Trace(err) + } + + return db.Txn(ctx, func(ctx context.Context, tx *sqlair.TX) error { + nameMap := sqlair.M{"name": name} + if err := tx.Query(ctx, poolAttributeDeleteStmt, nameMap).Run(); err != nil { + return errors.Annotate(domain.CoerceError(err), "deleting storage pool attributes") + } + var outcome = sqlair.Outcome{} + err = tx.Query(ctx, poolDeleteStmt, nameMap).Get(&outcome) + if err != nil { + return errors.Trace(domain.CoerceError(err)) + } + rowsAffected, err := outcome.Result().RowsAffected() + if err != nil { + return errors.Annotate(domain.CoerceError(err), "deleting storage pool") + } + if rowsAffected == 0 { + return fmt.Errorf("storage pool %q %w", name, errors.NotFound) + } + return errors.Annotate(domain.CoerceError(err), "deleting storage pool") + }) +} + +// ReplaceStoragePool replaces an existing storage pool, returning an error +// satisfying [errors.NotFound] if a pool with the name does not exist. +func (st State) ReplaceStoragePool(ctx context.Context, pool domainstorage.StoragePoolDetails) error { + db, err := st.DB() + if err != nil { + return errors.Trace(err) + } + + selectUUIDStmt, err := sqlair.Prepare("SELECT &StoragePool.uuid FROM storage_pool WHERE name = $StoragePool.name", StoragePool{}) + if err != nil { + return errors.Trace(domain.CoerceError(err)) + } + err = db.Txn(ctx, func(ctx context.Context, tx *sqlair.TX) error { + dbPool := StoragePool{Name: pool.Name} + err := tx.Query(ctx, selectUUIDStmt, dbPool).Get(&dbPool) + if err != nil && !errors.Is(err, sqlair.ErrNoRows) { + return errors.Trace(domain.CoerceError(err)) + } + if err != nil { + return fmt.Errorf("storage pool %q %w", pool.Name, errors.NotFound) + } + poolUUID := dbPool.ID + if err := upsertStoragePool(ctx, tx, poolUUID, pool.Name, pool.Provider); err != nil { + return errors.Annotate(domain.CoerceError(err), "updating storage pool") + } + + if err := updatePoolAttributes(ctx, tx, poolUUID, pool.Attrs); err != nil { + return errors.Annotatef(domain.CoerceError(err), "updating storage pool %s attributes", poolUUID) + } + return nil + }) + + return errors.Trace(err) +} + +func (st State) loadStoragePools(ctx context.Context, tx *sqlair.TX, filter domainstorage.StoragePoolFilter) ([]domainstorage.StoragePoolDetails, error) { + query := ` +SELECT (sp.uuid, sp.name, sp.type) AS (&StoragePool.*), + (sp_attr.key, sp_attr.value) AS (&poolAttribute.*) +FROM storage_pool sp + LEFT JOIN storage_pool_attribute sp_attr ON sp_attr.storage_pool_uuid = sp.uuid +` + + types := []any{ + StoragePool{}, + poolAttribute{}, + } + + var queryArgs []any + condition, args := st.buildFilter(filter) + if len(args) > 0 { + query = query + "WHERE " + condition + types = append(types, args...) + queryArgs = append([]any{}, args...) + } + + queryStmt, err := sqlair.Prepare(query, types...) + if err != nil { + return nil, errors.Trace(err) + } + + var ( + dbRows StoragePools + keyValues []poolAttribute + ) + err = tx.Query(ctx, queryStmt, queryArgs...).GetAll(&dbRows, &keyValues) + if err != nil { + return nil, errors.Annotate(domain.CoerceError(err), "loading storage pool") + } + return dbRows.toStoragePools(keyValues) +} + +// ListStoragePools returns the storage pools matching the specified filter. +func (st State) ListStoragePools(ctx context.Context, filter domainstorage.StoragePoolFilter) ([]domainstorage.StoragePoolDetails, error) { + db, err := st.DB() + if err != nil { + return nil, errors.Trace(err) + } + + var result []domainstorage.StoragePoolDetails + err = db.Txn(ctx, func(ctx context.Context, tx *sqlair.TX) error { + var err error + result, err = st.loadStoragePools(ctx, tx, filter) + return errors.Trace(err) + }) + return result, errors.Trace(err) +} + +func (st State) buildFilter(filter domainstorage.StoragePoolFilter) (string, []any) { + if len(filter.Names) == 0 && len(filter.Providers) == 0 { + return "", nil + } + + if len(filter.Names) > 0 && len(filter.Providers) > 0 { + condition := "sp.name IN ($StoragePoolNames[:]) AND sp.type IN ($StorageProviderTypes[:])" + return condition, []any{StoragePoolNames(filter.Names), StorageProviderTypes(filter.Providers)} + } + + if len(filter.Names) > 0 { + condition := "sp.name IN ($StoragePoolNames[:])" + return condition, []any{StoragePoolNames(filter.Names)} + } + + condition := "sp.type IN ($StorageProviderTypes[:])" + return condition, []any{StorageProviderTypes(filter.Providers)} +} + +// GetStoragePoolByName returns the storage pool with the specified name, returning an error +// satisfying [errors.NotFound] if it doesn't exist. +func (st State) GetStoragePoolByName(ctx context.Context, name string) (domainstorage.StoragePoolDetails, error) { + db, err := st.DB() + if err != nil { + return domainstorage.StoragePoolDetails{}, errors.Trace(err) + } + + var storagePools []domainstorage.StoragePoolDetails + err = db.Txn(ctx, func(ctx context.Context, tx *sqlair.TX) error { + var err error + storagePools, err = st.loadStoragePools(ctx, tx, domainstorage.StoragePoolFilter{ + Names: []string{name}, + }) + return errors.Trace(err) + }) + if err != nil { + return domainstorage.StoragePoolDetails{}, errors.Trace(err) + } + if len(storagePools) == 0 { + return domainstorage.StoragePoolDetails{}, fmt.Errorf("storage pool %q %w", name, errors.NotFound) + } + if len(storagePools) > 1 { + return domainstorage.StoragePoolDetails{}, errors.Errorf("expected 1 storage pool, got %d", len(storagePools)) + } + return storagePools[0], errors.Trace(err) +} diff --git a/domain/storage/state/state_test.go b/domain/storage/state/state_test.go new file mode 100644 index 00000000000..0d7c4f3e372 --- /dev/null +++ b/domain/storage/state/state_test.go @@ -0,0 +1,348 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package state + +import ( + "context" + + "github.com/juju/errors" + jc "github.com/juju/testing/checkers" + gc "gopkg.in/check.v1" + + "github.com/juju/juju/domain/schema/testing" + domainstorage "github.com/juju/juju/domain/storage" +) + +type storagePoolSuite struct { + testing.ModelSuite +} + +var _ = gc.Suite(&storagePoolSuite{}) + +func (s *storagePoolSuite) TestCreateStoragePool(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.GetStoragePoolByName(ctx, "ebs-fast") + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.DeepEquals, sp) +} + +func (s *storagePoolSuite) TestCreateStoragePoolNoAttributes(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.GetStoragePoolByName(ctx, "ebs-fast") + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.DeepEquals, sp) +} + +func (s *storagePoolSuite) TestCreateStoragePoolAlreadyExists(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + + err = st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIs, errors.AlreadyExists) +} + +func (s *storagePoolSuite) TestUpdateCloudCredentialMissingName(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Provider: "ebs", + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(errors.Is(err, errors.NotValid), jc.IsTrue) +} + +func (s *storagePoolSuite) TestUpdateCloudCredentialMissingProvider(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(errors.Is(err, errors.NotValid), jc.IsTrue) +} + +func (s *storagePoolSuite) TestReplaceStoragePool(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + + sp2 := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "baz": "baz val", + }, + } + err = st.ReplaceStoragePool(ctx, sp2) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.GetStoragePoolByName(ctx, "ebs-fast") + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.DeepEquals, sp2) +} + +func (s *storagePoolSuite) TestReplaceStoragePoolNoAttributes(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + + sp2 := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + } + err = st.ReplaceStoragePool(ctx, sp2) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.GetStoragePoolByName(ctx, "ebs-fast") + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.DeepEquals, sp2) +} + +func (s *storagePoolSuite) TestReplaceStoragePoolNotFound(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "baz": "baz val", + }, + } + ctx := context.Background() + err := st.ReplaceStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIs, errors.NotFound) +} + +func (s *storagePoolSuite) TestDeleteStoragePool(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + + err = st.DeleteStoragePool(ctx, "ebs-fast") + c.Assert(err, jc.ErrorIsNil) + + _, err = st.GetStoragePoolByName(ctx, "ebs-fast") + c.Assert(err, jc.ErrorIs, errors.NotFound) +} + +func (s *storagePoolSuite) TestDeleteStoragePoolNotFound(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + ctx := context.Background() + err := st.DeleteStoragePool(ctx, "ebs-fast") + c.Assert(err, jc.ErrorIs, errors.NotFound) +} + +func (s *storagePoolSuite) TestListStoragePools(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + sp2 := domainstorage.StoragePoolDetails{ + Name: "ebs-faster", + Provider: "ebs", + Attrs: map[string]string{ + "baz": "baz val", + }, + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + err = st.CreateStoragePool(ctx, sp2) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{}) + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.SameContents, []domainstorage.StoragePoolDetails{sp, sp2}) +} + +func (s *storagePoolSuite) TestStoragePoolsEmpty(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + creds, err := st.ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{}) + c.Assert(err, jc.ErrorIsNil) + c.Assert(creds, gc.HasLen, 0) +} + +func (s *storagePoolSuite) TestGetStoragePoolByName(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + sp2 := domainstorage.StoragePoolDetails{ + Name: "loop", + Provider: "loop", + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + err = st.CreateStoragePool(ctx, sp2) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.GetStoragePoolByName(context.Background(), "ebs-fast") + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.DeepEquals, sp) +} + +func (s *storagePoolSuite) TestListStoragePoolsFilterOnNameAndProvider(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + sp2 := domainstorage.StoragePoolDetails{ + Name: "loop", + Provider: "loop", + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + err = st.CreateStoragePool(ctx, sp2) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{ + Names: []string{"ebs-fast"}, + Providers: []string{"ebs"}, + }) + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.SameContents, []domainstorage.StoragePoolDetails{sp}) +} + +func (s *storagePoolSuite) TestListStoragePoolsFilterOnName(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + sp2 := domainstorage.StoragePoolDetails{ + Name: "loop", + Provider: "loop", + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + err = st.CreateStoragePool(ctx, sp2) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{ + Names: []string{"loop"}, + }) + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.SameContents, []domainstorage.StoragePoolDetails{sp2}) +} + +func (s *storagePoolSuite) TestListStoragePoolsFilterOnProvider(c *gc.C) { + st := NewState(s.TxnRunnerFactory()) + + sp := domainstorage.StoragePoolDetails{ + Name: "ebs-fast", + Provider: "ebs", + Attrs: map[string]string{ + "foo": "foo val", + "bar": "bar val", + }, + } + sp2 := domainstorage.StoragePoolDetails{ + Name: "loop", + Provider: "loop", + } + ctx := context.Background() + err := st.CreateStoragePool(ctx, sp) + c.Assert(err, jc.ErrorIsNil) + err = st.CreateStoragePool(ctx, sp2) + c.Assert(err, jc.ErrorIsNil) + + out, err := st.ListStoragePools(context.Background(), domainstorage.StoragePoolFilter{ + Providers: []string{"ebs"}, + }) + c.Assert(err, jc.ErrorIsNil) + c.Assert(out, jc.SameContents, []domainstorage.StoragePoolDetails{sp}) +} diff --git a/domain/storage/state/types.go b/domain/storage/state/types.go new file mode 100644 index 00000000000..522f549c5d6 --- /dev/null +++ b/domain/storage/state/types.go @@ -0,0 +1,75 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package state + +import ( + "github.com/juju/errors" + + "github.com/juju/juju/domain/storage" +) + +// These structs represent the persistent storage pool entity schema in the database. + +type StoragePool struct { + ID string `db:"uuid"` + + Name string `db:"name"` + ProviderType string `db:"type"` +} + +type poolAttribute struct { + // ID holds the cloud uuid. + ID string `db:"storage_pool_uuid"` + + // Key is the key value. + Key string `db:"key"` + + // Value is the value associated with key. + Value string `db:"value"` +} + +type StoragePoolNames []string + +type StorageProviderTypes []string + +type StoragePools []StoragePool + +func (rows StoragePools) toStoragePools(keyValues []poolAttribute) ([]storage.StoragePoolDetails, error) { + if n := len(rows); n != len(keyValues) { + // Should never happen. + return nil, errors.New("row length mismatch") + } + + var result []storage.StoragePoolDetails + recordResult := func(row *StoragePool, attrs poolAttributes) { + result = append(result, storage.StoragePoolDetails{ + Name: row.Name, + Provider: row.ProviderType, + Attrs: storage.Attrs(attrs), + }) + } + + var ( + current *StoragePool + attrs poolAttributes + ) + for i, row := range rows { + if current != nil && row.ID != current.ID { + recordResult(current, attrs) + attrs = nil + } + if keyValues[i].Key != "" { + if attrs == nil { + attrs = make(poolAttributes) + } + attrs[keyValues[i].Key] = keyValues[i].Value + } + rowCopy := row + current = &rowCopy + } + if current != nil { + recordResult(current, attrs) + } + return result, nil +} diff --git a/domain/storage/types.go b/domain/storage/types.go new file mode 100644 index 00000000000..009f7908cdd --- /dev/null +++ b/domain/storage/types.go @@ -0,0 +1,23 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package storage + +// Attrs defines storage attributes. +type Attrs map[string]string + +// StoragePoolDetails defines the details of a storage pool to save. +// This type is also used when returning query results from state. +type StoragePoolDetails struct { + Name string + Provider string + Attrs Attrs +} + +// StoragePoolFilter defines attributes used to filter storage pools. +type StoragePoolFilter struct { + // Names are pool's names to filter on. + Names []string + // Providers are pool's storage provider types to filter on. + Providers []string +} diff --git a/internal/migration/servicefactory_mock_test.go b/internal/migration/servicefactory_mock_test.go index c77d858e46b..7e2c43c3f78 100644 --- a/internal/migration/servicefactory_mock_test.go +++ b/internal/migration/servicefactory_mock_test.go @@ -29,10 +29,12 @@ import ( service13 "github.com/juju/juju/domain/modelmanager/service" service14 "github.com/juju/juju/domain/network/service" service15 "github.com/juju/juju/domain/objectstore/service" - service16 "github.com/juju/juju/domain/unit/service" - service17 "github.com/juju/juju/domain/upgrade/service" - service18 "github.com/juju/juju/domain/user/service" + service16 "github.com/juju/juju/domain/storage/service" + service17 "github.com/juju/juju/domain/unit/service" + service18 "github.com/juju/juju/domain/upgrade/service" + service19 "github.com/juju/juju/domain/user/service" servicefactory "github.com/juju/juju/internal/servicefactory" + storage "github.com/juju/juju/internal/storage" gomock "go.uber.org/mock/gomock" ) @@ -348,11 +350,25 @@ func (mr *MockServiceFactoryMockRecorder) Space() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Space", reflect.TypeOf((*MockServiceFactory)(nil).Space)) } +// Storage mocks base method. +func (m *MockServiceFactory) Storage(arg0 storage.ProviderRegistry) *service16.Service { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Storage", arg0) + ret0, _ := ret[0].(*service16.Service) + return ret0 +} + +// Storage indicates an expected call of Storage. +func (mr *MockServiceFactoryMockRecorder) Storage(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Storage", reflect.TypeOf((*MockServiceFactory)(nil).Storage), arg0) +} + // Unit mocks base method. -func (m *MockServiceFactory) Unit() *service16.Service { +func (m *MockServiceFactory) Unit() *service17.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Unit") - ret0, _ := ret[0].(*service16.Service) + ret0, _ := ret[0].(*service17.Service) return ret0 } @@ -363,10 +379,10 @@ func (mr *MockServiceFactoryMockRecorder) Unit() *gomock.Call { } // Upgrade mocks base method. -func (m *MockServiceFactory) Upgrade() *service17.WatchableService { +func (m *MockServiceFactory) Upgrade() *service18.WatchableService { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Upgrade") - ret0, _ := ret[0].(*service17.WatchableService) + ret0, _ := ret[0].(*service18.WatchableService) return ret0 } @@ -377,10 +393,10 @@ func (mr *MockServiceFactoryMockRecorder) Upgrade() *gomock.Call { } // User mocks base method. -func (m *MockServiceFactory) User() *service18.Service { +func (m *MockServiceFactory) User() *service19.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "User") - ret0, _ := ret[0].(*service18.Service) + ret0, _ := ret[0].(*service19.Service) return ret0 } diff --git a/internal/servicefactory/interface.go b/internal/servicefactory/interface.go index 032fab7f36a..74d6c4acb3a 100644 --- a/internal/servicefactory/interface.go +++ b/internal/servicefactory/interface.go @@ -21,9 +21,11 @@ import ( modelmanagerservice "github.com/juju/juju/domain/modelmanager/service" networkservice "github.com/juju/juju/domain/network/service" objectstoreservice "github.com/juju/juju/domain/objectstore/service" + storageservice "github.com/juju/juju/domain/storage/service" unitservice "github.com/juju/juju/domain/unit/service" upgradeservice "github.com/juju/juju/domain/upgrade/service" userservice "github.com/juju/juju/domain/user/service" + "github.com/juju/juju/internal/storage" ) // ControllerServiceFactory provides access to the services required by the @@ -78,6 +80,8 @@ type ModelServiceFactory interface { Space() *networkservice.SpaceService // Annotation returns the annotation service. Annotation() *annotationService.Service + // Storage returns the storage service. + Storage(registry storage.ProviderRegistry) *storageservice.Service } // ServiceFactory provides access to the services required by the apiserver. diff --git a/internal/worker/servicefactory/servicefactory_mock_test.go b/internal/worker/servicefactory/servicefactory_mock_test.go index 88232be454d..5744fee5c0a 100644 --- a/internal/worker/servicefactory/servicefactory_mock_test.go +++ b/internal/worker/servicefactory/servicefactory_mock_test.go @@ -29,10 +29,12 @@ import ( service13 "github.com/juju/juju/domain/modelmanager/service" service14 "github.com/juju/juju/domain/network/service" service15 "github.com/juju/juju/domain/objectstore/service" - service16 "github.com/juju/juju/domain/unit/service" - service17 "github.com/juju/juju/domain/upgrade/service" - service18 "github.com/juju/juju/domain/user/service" + service16 "github.com/juju/juju/domain/storage/service" + service17 "github.com/juju/juju/domain/unit/service" + service18 "github.com/juju/juju/domain/upgrade/service" + service19 "github.com/juju/juju/domain/user/service" servicefactory "github.com/juju/juju/internal/servicefactory" + storage "github.com/juju/juju/internal/storage" gomock "go.uber.org/mock/gomock" ) @@ -214,10 +216,10 @@ func (mr *MockControllerServiceFactoryMockRecorder) ModelManager() *gomock.Call } // Upgrade mocks base method. -func (m *MockControllerServiceFactory) Upgrade() *service17.WatchableService { +func (m *MockControllerServiceFactory) Upgrade() *service18.WatchableService { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Upgrade") - ret0, _ := ret[0].(*service17.WatchableService) + ret0, _ := ret[0].(*service18.WatchableService) return ret0 } @@ -228,10 +230,10 @@ func (mr *MockControllerServiceFactoryMockRecorder) Upgrade() *gomock.Call { } // User mocks base method. -func (m *MockControllerServiceFactory) User() *service18.Service { +func (m *MockControllerServiceFactory) User() *service19.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "User") - ret0, _ := ret[0].(*service18.Service) + ret0, _ := ret[0].(*service19.Service) return ret0 } @@ -362,11 +364,25 @@ func (mr *MockModelServiceFactoryMockRecorder) Space() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Space", reflect.TypeOf((*MockModelServiceFactory)(nil).Space)) } +// Storage mocks base method. +func (m *MockModelServiceFactory) Storage(arg0 storage.ProviderRegistry) *service16.Service { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Storage", arg0) + ret0, _ := ret[0].(*service16.Service) + return ret0 +} + +// Storage indicates an expected call of Storage. +func (mr *MockModelServiceFactoryMockRecorder) Storage(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Storage", reflect.TypeOf((*MockModelServiceFactory)(nil).Storage), arg0) +} + // Unit mocks base method. -func (m *MockModelServiceFactory) Unit() *service16.Service { +func (m *MockModelServiceFactory) Unit() *service17.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Unit") - ret0, _ := ret[0].(*service16.Service) + ret0, _ := ret[0].(*service17.Service) return ret0 } @@ -651,11 +667,25 @@ func (mr *MockServiceFactoryMockRecorder) Space() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Space", reflect.TypeOf((*MockServiceFactory)(nil).Space)) } +// Storage mocks base method. +func (m *MockServiceFactory) Storage(arg0 storage.ProviderRegistry) *service16.Service { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Storage", arg0) + ret0, _ := ret[0].(*service16.Service) + return ret0 +} + +// Storage indicates an expected call of Storage. +func (mr *MockServiceFactoryMockRecorder) Storage(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Storage", reflect.TypeOf((*MockServiceFactory)(nil).Storage), arg0) +} + // Unit mocks base method. -func (m *MockServiceFactory) Unit() *service16.Service { +func (m *MockServiceFactory) Unit() *service17.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Unit") - ret0, _ := ret[0].(*service16.Service) + ret0, _ := ret[0].(*service17.Service) return ret0 } @@ -666,10 +696,10 @@ func (mr *MockServiceFactoryMockRecorder) Unit() *gomock.Call { } // Upgrade mocks base method. -func (m *MockServiceFactory) Upgrade() *service17.WatchableService { +func (m *MockServiceFactory) Upgrade() *service18.WatchableService { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Upgrade") - ret0, _ := ret[0].(*service17.WatchableService) + ret0, _ := ret[0].(*service18.WatchableService) return ret0 } @@ -680,10 +710,10 @@ func (mr *MockServiceFactoryMockRecorder) Upgrade() *gomock.Call { } // User mocks base method. -func (m *MockServiceFactory) User() *service18.Service { +func (m *MockServiceFactory) User() *service19.Service { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "User") - ret0, _ := ret[0].(*service18.Service) + ret0, _ := ret[0].(*service19.Service) return ret0 }