diff --git a/api/package_test.go b/api/package_test.go index d677b240059..62870afe99c 100644 --- a/api/package_test.go +++ b/api/package_test.go @@ -60,6 +60,7 @@ func (*ImportSuite) TestImports(c *gc.C) { "internal/charm/assumes", "internal/charm/hooks", "internal/charm/resource", + "internal/errors", "internal/featureflag", "internal/http", "internal/logger", diff --git a/core/migration/package_test.go b/core/migration/package_test.go index 93ec3044094..5abaca6fc80 100644 --- a/core/migration/package_test.go +++ b/core/migration/package_test.go @@ -31,6 +31,7 @@ func (*ImportTest) TestImports(c *gc.C) { "core/network", "core/resource", "internal/charm/resource", + "internal/errors", "internal/logger", "internal/uuid", }) diff --git a/core/resource/state.go b/core/resource/state.go new file mode 100644 index 00000000000..ef3b0ff7c8a --- /dev/null +++ b/core/resource/state.go @@ -0,0 +1,46 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package resource + +import ( + "github.com/juju/juju/internal/errors" +) + +// These are the valid resource states. +const ( + // StateAvailable represents a resource which will be used by any units at + // this point in time + StateAvailable State = "available" + + // StatePotential indicates there is a different revision of the resource + // available in a repository. Used to let users know a resource can be + // upgraded. + StatePotential State = "potential" +) + +// State identifies the resource state in an application +type State string + +// ParseState converts the provided string into an State. +// If it is not a known state then an error is returned. +func ParseState(value string) (State, error) { + state := State(value) + return state, state.Validate() +} + +// String returns the printable representation of the state. +func (o State) String() string { + return string(o) +} + +// Validate ensures that the state is correct. +func (o State) Validate() error { + if _, ok := map[State]bool{ + StateAvailable: true, + StatePotential: true, + }[o]; !ok { + return errors.Errorf("state %q invalid", o) + } + return nil +} diff --git a/core/resource/state_test.go b/core/resource/state_test.go new file mode 100644 index 00000000000..a429e025b4f --- /dev/null +++ b/core/resource/state_test.go @@ -0,0 +1,54 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package resource + +import ( + "github.com/juju/testing" + jc "github.com/juju/testing/checkers" + gc "gopkg.in/check.v1" +) + +type StateSuite struct { + testing.IsolationSuite +} + +var _ = gc.Suite(&StateSuite{}) + +func (StateSuite) TestParseStateKnown(c *gc.C) { + recognized := map[string]State{ + "potential": StatePotential, + "available": StateAvailable, + } + for value, expected := range recognized { + state, err := ParseState(value) + + c.Check(err, jc.ErrorIsNil) + c.Check(state, gc.Equals, expected) + } +} + +func (StateSuite) TestParseStateUnknown(c *gc.C) { + _, err := ParseState("") + + c.Check(err, gc.ErrorMatches, `.*state "" invalid.*`) +} + +func (StateSuite) TestValidateKnown(c *gc.C) { + recognized := []State{ + StatePotential, + StateAvailable, + } + for _, state := range recognized { + err := state.Validate() + + c.Check(err, jc.ErrorIsNil) + } +} + +func (StateSuite) TestValidateUnknown(c *gc.C) { + var state State + err := state.Validate() + + c.Check(err, gc.ErrorMatches, `.*state "" invalid.*`) +} diff --git a/core/watcher/eventsource/package_test.go b/core/watcher/eventsource/package_test.go index 897a4ad7698..38d8742bdf3 100644 --- a/core/watcher/eventsource/package_test.go +++ b/core/watcher/eventsource/package_test.go @@ -45,6 +45,7 @@ func (*ImportTest) TestImports(c *gc.C) { "core/status", "core/watcher", "internal/charm/resource", + "internal/errors", "internal/logger", "internal/uuid", }) diff --git a/core/watcher/package_test.go b/core/watcher/package_test.go index 1646e4fac73..936989a31ae 100644 --- a/core/watcher/package_test.go +++ b/core/watcher/package_test.go @@ -33,6 +33,7 @@ func (s *ImportTest) TestImports(c *gc.C) { "core/secrets", "core/status", "internal/charm/resource", + "internal/errors", "internal/logger", "internal/uuid", }) diff --git a/domain/resource/errors/errors.go b/domain/resource/errors/errors.go index c6bc0434d1c..63847fa44db 100644 --- a/domain/resource/errors/errors.go +++ b/domain/resource/errors/errors.go @@ -3,9 +3,15 @@ package errors -import "github.com/juju/juju/internal/errors" +import ( + "github.com/juju/juju/internal/errors" +) const ( + // ApplicationIDNotValid describes an error when the application ID is + // not valid. + ApplicationIDNotValid = errors.ConstError("application ID not valid") + // ApplicationNotFound describes an error that occurs when the application // being operated on does not exist. ApplicationNotFound = errors.ConstError("application not found") @@ -29,4 +35,17 @@ const ( // UnitNotFound describes an error that occurs when the unit being operated on // does not exist. UnitNotFound = errors.ConstError("unit not found") + + // UnitUUIDNotValid describes an error when the unit UUID is + // not valid. + UnitUUIDNotValid = errors.ConstError("unit UUID not valid") + + // ResourceStateNotValid describes an error where the resource state is not + // valid. + ResourceStateNotValid = errors.ConstError("resource state not valid") + + // InvalidCleanUpState describes an error where the application state is + // during cleanup. It means that application dependencies are deleted in + // an incorrect order. + InvalidCleanUpState = errors.ConstError("invalid cleanup state") ) diff --git a/domain/resource/service/package_mock_test.go b/domain/resource/service/package_mock_test.go index 2f246d39f74..bff3953134b 100644 --- a/domain/resource/service/package_mock_test.go +++ b/domain/resource/service/package_mock_test.go @@ -45,6 +45,82 @@ func (m *MockState) EXPECT() *MockStateMockRecorder { return m.recorder } +// DeleteApplicationResources mocks base method. +func (m *MockState) DeleteApplicationResources(arg0 context.Context, arg1 application.ID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteApplicationResources", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteApplicationResources indicates an expected call of DeleteApplicationResources. +func (mr *MockStateMockRecorder) DeleteApplicationResources(arg0, arg1 any) *MockStateDeleteApplicationResourcesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationResources", reflect.TypeOf((*MockState)(nil).DeleteApplicationResources), arg0, arg1) + return &MockStateDeleteApplicationResourcesCall{Call: call} +} + +// MockStateDeleteApplicationResourcesCall wrap *gomock.Call +type MockStateDeleteApplicationResourcesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStateDeleteApplicationResourcesCall) Return(arg0 error) *MockStateDeleteApplicationResourcesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStateDeleteApplicationResourcesCall) Do(f func(context.Context, application.ID) error) *MockStateDeleteApplicationResourcesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStateDeleteApplicationResourcesCall) DoAndReturn(f func(context.Context, application.ID) error) *MockStateDeleteApplicationResourcesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// DeleteUnitResources mocks base method. +func (m *MockState) DeleteUnitResources(arg0 context.Context, arg1 unit.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUnitResources", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUnitResources indicates an expected call of DeleteUnitResources. +func (mr *MockStateMockRecorder) DeleteUnitResources(arg0, arg1 any) *MockStateDeleteUnitResourcesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUnitResources", reflect.TypeOf((*MockState)(nil).DeleteUnitResources), arg0, arg1) + return &MockStateDeleteUnitResourcesCall{Call: call} +} + +// MockStateDeleteUnitResourcesCall wrap *gomock.Call +type MockStateDeleteUnitResourcesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStateDeleteUnitResourcesCall) Return(arg0 error) *MockStateDeleteUnitResourcesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStateDeleteUnitResourcesCall) Do(f func(context.Context, unit.UUID) error) *MockStateDeleteUnitResourcesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStateDeleteUnitResourcesCall) DoAndReturn(f func(context.Context, unit.UUID) error) *MockStateDeleteUnitResourcesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // GetApplicationResourceID mocks base method. func (m *MockState) GetApplicationResourceID(arg0 context.Context, arg1 resource0.GetApplicationResourceIDArgs) (resource.UUID, error) { m.ctrl.T.Helper() diff --git a/domain/resource/service/resource.go b/domain/resource/service/resource.go index f30c668d6b4..529681f6d2c 100644 --- a/domain/resource/service/resource.go +++ b/domain/resource/service/resource.go @@ -18,8 +18,16 @@ import ( charmresource "github.com/juju/juju/internal/charm/resource" ) -// ResourceState describes retrieval and persistence methods for resource. +// State describes retrieval and persistence methods for resource. type State interface { + // DeleteApplicationResources removes all associated resources of a given + // application identified by applicationID. + DeleteApplicationResources(ctx context.Context, applicationID coreapplication.ID) error + + // DeleteUnitResources deletes the association of resources with a specific + // unit. + DeleteUnitResources(ctx context.Context, uuid coreunit.UUID) error + // GetApplicationResourceID returns the ID of the application resource // specified by natural key of application and resource name. GetApplicationResourceID(ctx context.Context, args resource.GetApplicationResourceIDArgs) (coreresource.UUID, error) @@ -39,8 +47,8 @@ type State interface { // OpenApplicationResource returns the metadata for an application's resource. OpenApplicationResource(ctx context.Context, resourceUUID coreresource.UUID) (resource.Resource, error) - // OpenUnitResource returns the metadata for a resource a. A unit resource is - // created to track the given unit and which resource its using. + // OpenUnitResource returns the metadata for a resource. A unit resource + // is created to track the given unit and which resource its using. OpenUnitResource(ctx context.Context, resourceUUID coreresource.UUID, unitID coreunit.UUID) (resource.Resource, error) // SetRepositoryResources sets the "polled" resource for the @@ -76,14 +84,49 @@ func NewService( } } -// GetApplicationResourceID returns the ID of the application resource specified by -// natural key of application and resource name. +// DeleteApplicationResources removes the resources of a specified application. +// It should be called after all resources have been unlinked from potential +// units by DeleteUnitResources and their associated data removed from store. // // The following error types can be expected to be returned: -// - resourceerrors.ResourceNameNotValid if no resource name is provided in -// the args. -// - errors.NotValid is returned if the application ID is not valid. -// - resourceerrors.ResourceNotFound if no resource with name exists for +// - [resourceerrors.ApplicationIDNotValid] is returned if the application +// ID is not valid. +// - [resourceerrors.InvalidCleanUpState] is returned is there is +// remaining units or stored resources which are still associated with +// application resources. +func (s *Service) DeleteApplicationResources( + ctx context.Context, + applicationID coreapplication.ID, +) error { + if err := applicationID.Validate(); err != nil { + return resourceerrors.ApplicationIDNotValid + } + return s.st.DeleteApplicationResources(ctx, applicationID) +} + +// DeleteUnitResources unlinks the resources associated to a unit by its UUID. +// +// The following error types can be expected to be returned: +// - [resourceerrors.UnitUUIDNotValid] is returned if the unit ID is not +// valid. +func (s *Service) DeleteUnitResources( + ctx context.Context, + uuid coreunit.UUID, +) error { + if err := uuid.Validate(); err != nil { + return resourceerrors.UnitUUIDNotValid + } + return s.st.DeleteUnitResources(ctx, uuid) +} + +// GetApplicationResourceID returns the ID of the application resource specified +// by natural key of application and resource name. +// +// The following error types can be expected to be returned: +// - [resourceerrors.ResourceNameNotValid] if no resource name is provided +// in the args. +// - [coreerrors.NotValid] is returned if the application ID is not valid. +// - [resourceerrors.ResourceNotFound] if no resource with name exists for // given application. func (s *Service) GetApplicationResourceID( ctx context.Context, @@ -103,10 +146,11 @@ func (s *Service) GetApplicationResourceID( // for machine units. Repository resource data is included if it exists. // // The following error types can be expected to be returned: -// - errors.NotValid is returned if the application ID is not valid. -// - application.ApplicationDyingOrDead for dead or dying applications. -// - application.ApplicationNotFound when the specified application does -// not exist. +// - [coreerrors.NotValid] is returned if the application ID is not valid. +// - [resourceerrors.ApplicationDyingOrDead] for dead or dying +// applications. +// - [resourceerrors.ApplicationNotFound] when the specified application +// does not exist. // // No error is returned if the provided application has no resource. func (s *Service) ListResources( @@ -122,9 +166,7 @@ func (s *Service) ListResources( // GetResource returns the identified application resource. // // The following error types can be expected to be returned: -// - errors.NotValid is returned if the application ID is not valid. -// - application.ApplicationDyingOrDead for dead or dying applications. -// - application.ApplicationNotFound if the specified application does +// - [resourceerrors.ApplicationNotFound] if the specified application does // not exist. func (s *Service) GetResource( ctx context.Context, @@ -139,11 +181,11 @@ func (s *Service) GetResource( // SetResource adds the application resource to blob storage and updates the metadata. // // The following error types can be expected to be returned: -// - errors.NotValid is returned if the application ID is not valid. -// - errors.NotValid is returned if the resource is not valid. -// - errors.NotValid is returned if the RetrievedByType is unknown while +// - [coreerrors.NotValid] is returned if the application ID is not valid. +// - [coreerrors.NotValid] is returned if the resource is not valid. +// - [coreerrors.NotValid] is returned if the RetrievedByType is unknown while // RetrievedBy has a value. -// - resourceerrors.ApplicationNotFound if the specified application does +// - [resourceerrors.ApplicationNotFound] if the specified application does // not exist. func (s *Service) SetResource( ctx context.Context, @@ -165,8 +207,8 @@ func (s *Service) SetResource( // SetUnitResource sets the resource metadata for a specific unit. // // The following error types can be expected to be returned: -// - [errors.NotValid] is returned if the unit UUID is not valid. -// - [errors.NotValid] is returned if the resource UUID is not valid. +// - [coreerrors.NotValid] is returned if the unit UUID is not valid. +// - [coreerrors.NotValid] is returned if the resource UUID is not valid. // - [resourceerrors.ArgumentNotValid] is returned if the RetrievedByType is unknown while // RetrievedBy has a value. // - [resourceerrors.ResourceNotFound] if the specified resource doesn't exist @@ -191,8 +233,8 @@ func (s *Service) SetUnitResource( // OpenApplicationResource returns the details of and a reader for the resource. // // The following error types can be expected to be returned: -// - errors.NotValid is returned if the resource.UUID is not valid. -// - resourceerrors.ResourceNotFound if the specified resource does +// - [coreerrors.NotValid] is returned if the resource.UUID is not valid. +// - [resourceerrors.ResourceNotFound] if the specified resource does // not exist. func (s *Service) OpenApplicationResource( ctx context.Context, @@ -211,11 +253,11 @@ func (s *Service) OpenApplicationResource( // exhausted. Typically used for File resource. // // The following error types can be returned: -// - errors.NotValid is returned if the resource.UUID is not valid. -// - errors.NotValid is returned if the unit UUID is not valid. -// - resourceerrors.ResourceNotFound if the specified resource does +// - [coreerrors.NotValid] is returned if the resource.UUID is not valid. +// - [coreerrors.NotValid] is returned if the unit UUID is not valid. +// - [resourceerrors.ResourceNotFound] if the specified resource does // not exist. -// - resourceerrors.UnitNotFound if the specified unit does +// - [resourceerrors.UnitNotFound] if the specified unit does // not exist. func (s *Service) OpenUnitResource( ctx context.Context, @@ -237,10 +279,10 @@ func (s *Service) OpenUnitResource( // the application. // // The following error types can be expected to be returned: -// - errors.NotValid is returned if the Application ID is not valid. -// - resourceerrors.ArgumentNotValid is returned if LastPolled is zero. -// - resourceerrors.ArgumentNotValid is returned if the length of Info is zero. -// - resourceerrors.ApplicationNotFound if the specified application does +// - [coreerrors.NotValid] is returned if the Application ID is not valid. +// - [resourceerrors.ArgumentNotValid] is returned if LastPolled is zero. +// - [resourceerrors.ArgumentNotValid] is returned if the length of Info is zero. +// - [resourceerrors.ApplicationNotFound] if the specified application does // not exist. func (s *Service) SetRepositoryResources( ctx context.Context, diff --git a/domain/resource/service/resource_test.go b/domain/resource/service/resource_test.go index fe2d6b208ad..2284cf3d790 100644 --- a/domain/resource/service/resource_test.go +++ b/domain/resource/service/resource_test.go @@ -33,6 +33,81 @@ type resourceServiceSuite struct { var _ = gc.Suite(&resourceServiceSuite{}) +func (s *resourceServiceSuite) TestDeleteApplicationResources(c *gc.C) { + defer s.setupMocks(c).Finish() + + appUUID := applicationtesting.GenApplicationUUID(c) + + s.state.EXPECT().DeleteApplicationResources(gomock.Any(), + appUUID).Return(nil) + + err := s.service.DeleteApplicationResources(context. + Background(), appUUID) + c.Assert(err, jc.ErrorIsNil) +} + +func (s *resourceServiceSuite) TestDeleteApplicationResourcesBadArgs(c *gc.C) { + defer s.setupMocks(c).Finish() + + err := s.service.DeleteApplicationResources(context. + Background(), "not an application ID") + c.Assert(err, jc.ErrorIs, resourceerrors.ApplicationIDNotValid, + gc.Commentf("Application ID should be stated as not valid")) +} + +func (s *resourceServiceSuite) TestDeleteApplicationResourcesUnexpectedError(c *gc.C) { + defer s.setupMocks(c).Finish() + + stateError := errors.New("unexpected error") + + appUUID := applicationtesting.GenApplicationUUID(c) + + s.state.EXPECT().DeleteApplicationResources(gomock.Any(), + appUUID).Return(stateError) + + err := s.service.DeleteApplicationResources(context. + Background(), appUUID) + c.Assert(err, jc.ErrorIs, stateError, + gc.Commentf("Should return underlying error")) +} + +func (s *resourceServiceSuite) TestDeleteUnitResources(c *gc.C) { + defer s.setupMocks(c).Finish() + + unitUUID := unittesting.GenUnitUUID(c) + + s.state.EXPECT().DeleteUnitResources(gomock.Any(), + unitUUID).Return(nil) + + err := s.service.DeleteUnitResources(context. + Background(), unitUUID) + c.Assert(err, jc.ErrorIsNil) +} + +func (s *resourceServiceSuite) TestDeleteUnitResourcesBadArgs(c *gc.C) { + defer s.setupMocks(c).Finish() + + err := s.service.DeleteUnitResources(context. + Background(), "not an unit UUID") + c.Assert(err, jc.ErrorIs, resourceerrors.UnitUUIDNotValid, + gc.Commentf("Unit UUID should be stated as not valid")) +} + +func (s *resourceServiceSuite) TestDeleteUnitResourcesUnexpectedError(c *gc.C) { + defer s.setupMocks(c).Finish() + + stateError := errors.New("unexpected error") + unitUUID := unittesting.GenUnitUUID(c) + + s.state.EXPECT().DeleteUnitResources(gomock.Any(), + unitUUID).Return(stateError) + + err := s.service.DeleteUnitResources(context. + Background(), unitUUID) + c.Assert(err, jc.ErrorIs, stateError, + gc.Commentf("Should return underlying error")) +} + func (s *resourceServiceSuite) TestGetApplicationResourceID(c *gc.C) { defer s.setupMocks(c).Finish() diff --git a/domain/resource/state/resource.go b/domain/resource/state/resource.go index 20ab3a7a4e3..a35f36c8fb4 100644 --- a/domain/resource/state/resource.go +++ b/domain/resource/state/resource.go @@ -41,6 +41,180 @@ func NewState(factory database.TxnRunnerFactory, clock clock.Clock, logger logge } } +// DeleteApplicationResources deletes all resources associated with a given +// application ID. It checks that resources are not linked to a file store, +// image store, or unit before deletion. +// The method uses several SQL statements to prepare and execute the deletion +// process within a transaction. If related records are found in any store, +// deletion is halted and an error is returned, preventing any deletion which +// can led to inconsistent state due to foreign key constraints. +func (st *State) DeleteApplicationResources( + ctx context.Context, + applicationID application.ID, +) error { + db, err := st.DB() + if err != nil { + return errors.Capture(err) + } + + type uuids []string + appIdentity := resourceIdentity{ApplicationUUID: applicationID.String()} + + // SQL statement to list all resources for an application. + listAppResourcesStmt, err := st.Prepare(` +SELECT resource_uuid AS &resourceIdentity.uuid +FROM application_resource +WHERE application_uuid = $resourceIdentity.application_uuid`, appIdentity) + if err != nil { + return errors.Capture(err) + } + + // SQL statement to check there is no related resources in resource_file_store. + noFileStoreStmt, err := st.Prepare(` +SELECT resource_uuid AS &resourceIdentity.uuid +FROM resource_file_store +WHERE resource_uuid IN ($uuids[:])`, resourceIdentity{}, uuids{}) + if err != nil { + return errors.Capture(err) + } + + // SQL statement to check there is no related resources in resource_image_store. + noImageStoreStmt, err := st.Prepare(` +SELECT resource_uuid AS &resourceIdentity.uuid +FROM resource_image_store +WHERE resource_uuid IN ($uuids[:])`, resourceIdentity{}, uuids{}) + if err != nil { + return errors.Capture(err) + } + + // SQL statement to check there is no related resources in unit_resource. + noUnitResourceStmt, err := st.Prepare(` +SELECT resource_uuid AS &resourceIdentity.uuid +FROM unit_resource +WHERE resource_uuid IN ($uuids[:])`, resourceIdentity{}, uuids{}) + if err != nil { + return errors.Capture(err) + } + + // SQL statement to delete resources from resource_retrieved_by. + deleteFromRetrievedByStmt, err := st.Prepare(` +DELETE FROM resource_retrieved_by +WHERE resource_uuid IN ($uuids[:])`, uuids{}) + if err != nil { + return errors.Capture(err) + } + + // SQL statement to delete resources from application_resource. + deleteFromApplicationResourceStmt, err := st.Prepare(` +DELETE FROM application_resource +WHERE resource_uuid IN ($uuids[:])`, uuids{}) + if err != nil { + return errors.Capture(err) + } + + // SQL statement to delete resources from resource. + deleteFromResourceStmt, err := st.Prepare(` +DELETE FROM resource +WHERE uuid IN ($uuids[:])`, uuids{}) + if err != nil { + return errors.Capture(err) + } + + return db.Txn(ctx, func(ctx context.Context, tx *sqlair.TX) (err error) { + // list all resources for an application. + var resources []resourceIdentity + err = tx.Query(ctx, listAppResourcesStmt, appIdentity).GetAll(&resources) + if err != nil && !errors.Is(err, sqlair.ErrNoRows) { + return err + } + resUUIDs := make(uuids, 0, len(resources)) + for _, res := range resources { + resUUIDs = append(resUUIDs, res.UUID) + } + + checkLink := func(message string, stmt *sqlair.Statement) error { + var resources []resourceIdentity + err := tx.Query(ctx, stmt, resUUIDs).GetAll(&resources) + switch { + case errors.Is(err, sqlair.ErrNoRows): // Happy path + return nil + case err != nil: + return err + } + return errors.Errorf("%s: %w", message, resourceerrors.InvalidCleanUpState) + } + + // check there are no related resources in resource_file_store. + if err = checkLink("resource linked to file store data", noFileStoreStmt); err != nil { + return errors.Capture(err) + } + + // check there are no related resources in resource_image_store. + if err = checkLink("resource linked to image store data", noImageStoreStmt); err != nil { + return errors.Capture(err) + } + + // check there are no related resources in unit_resource. + if err = checkLink("resource linked to unit", noUnitResourceStmt); err != nil { + return errors.Capture(err) + } + + // delete resources from resource_retrieved_by. + if err = tx.Query(ctx, deleteFromRetrievedByStmt, resUUIDs).Run(); err != nil { + return errors.Capture(err) + } + + safedelete := func(stmt *sqlair.Statement) error { + var outcome sqlair.Outcome + err = tx.Query(ctx, stmt, resUUIDs).Get(&outcome) + if err != nil { + return errors.Capture(err) + } + num, err := outcome.Result().RowsAffected() + if err != nil { + return errors.Capture(err) + } + if num != int64(len(resUUIDs)) { + return errors.Errorf("expected %d rows to be deleted, got %d", len(resUUIDs), num) + } + return nil + } + + // delete resources from application_resource. + err = safedelete(deleteFromApplicationResourceStmt) + if err != nil { + return errors.Capture(err) + } + + // delete resources from resource. + return safedelete(deleteFromResourceStmt) + }) +} + +// DeleteUnitResources removes the association of a unit, identified by UUID, +// with any of its' application's resources. It initiates a transaction and +// executes an SQL statement to delete rows from the unit_resource table. +// Returns an error if the operation fails at any point in the process. +func (st *State) DeleteUnitResources( + ctx context.Context, + uuid coreunit.UUID, +) error { + db, err := st.DB() + if err != nil { + return errors.Capture(err) + } + + unit := unitResource{UnitUUID: uuid.String()} + stmt, err := st.Prepare(`DELETE FROM unit_resource WHERE unit_uuid = $unitResource.unit_uuid`, unit) + if err != nil { + return errors.Capture(err) + } + + return db.Txn(ctx, func(ctx context.Context, tx *sqlair.TX) error { + return errors.Capture(tx.Query(ctx, stmt, unit).Run()) + }) +} + // GetApplicationResourceID returns the ID of the application resource // specified by natural key of application and resource name. func (st *State) GetApplicationResourceID( diff --git a/domain/resource/state/resource_test.go b/domain/resource/state/resource_test.go index f223f62f25d..aae7f9ef29f 100644 --- a/domain/resource/state/resource_test.go +++ b/domain/resource/state/resource_test.go @@ -83,6 +83,289 @@ func (s *resourceSuite) SetUpTest(c *gc.C) { c.Assert(err, jc.ErrorIsNil, gc.Commentf("failed to populate DB with applications: %v", errors.ErrorStack(err))) } +// TestDeleteApplicationResources is a test method that verifies the deletion of resources +// associated with a specific application in the database. +func (s *resourceSuite) TestDeleteApplicationResources(c *gc.C) { + // Arrange: populate db with some resources, belonging to app1 (2 res) and app2 (1 res) + res1 := resourceData{ + UUID: "app1-res1-uuid", + ApplicationUUID: s.constants.fakeApplicationUUID1, + Name: "res1", + // populate table "resource_retrieved_by" + RetrievedByType: "user", + RetrievedByName: "john", + } + res2 := resourceData{ + UUID: "app1-res2-uuid", + Name: "res2", + ApplicationUUID: s.constants.fakeApplicationUUID1, + } + other := resourceData{ + UUID: "res-uuid", + Name: "res3", + ApplicationUUID: s.constants.fakeApplicationUUID2, + } + err := s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + for _, input := range []resourceData{res1, res2, other} { + if err := input.insert(context.Background(), tx); err != nil { + return errors.Capture(err) + } + } + return nil + }) + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Arrange) failed to populate DB: %v", errors.ErrorStack(err))) + + // Act: delete resources from application 1 + err = s.state.DeleteApplicationResources(context.Background(), application.ID(s.constants.fakeApplicationUUID1)) + + // Assert: check that resources have been deleted in expected tables + // without errors + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Assert) failed to delete resources from application 1: %v", errors.ErrorStack(err))) + var remainingResources []resourceData + var noRowsInRessourceRetrievedBy bool + err = s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + // fetch resources + rows, err := tx.Query(` +SELECT uuid, charm_resource_name, application_uuid +FROM resource AS r +LEFT JOIN application_resource AS ar ON r.uuid = ar.resource_uuid`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var uuid string + var resName string + var appUUID string + if err := rows.Scan(&uuid, &resName, &appUUID); err != nil { + return err + } + remainingResources = append(remainingResources, + resourceData{UUID: uuid, ApplicationUUID: appUUID, + Name: resName}) + } + // fetch resource_retrieved_by + var discard string + err = tx.QueryRow(`SELECT resource_uuid from resource_retrieved_by`). + Scan(&discard) + if errors.Is(err, sql.ErrNoRows) { + noRowsInRessourceRetrievedBy = true + return nil + } + return err + }) + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Assert) failed to check db: %v", + errors.ErrorStack(err))) + c.Check(noRowsInRessourceRetrievedBy, gc.Equals, true, gc.Commentf("(Assert) resource_retrieved_by table should be empty")) + c.Check(remainingResources, jc.DeepEquals, []resourceData{other}, + gc.Commentf("(Assert) only resource from %q should be there", + s.constants.fakeApplicationUUID2)) +} + +// TestDeleteApplicationResourcesErrorRemainingUnits tests resource deletion with linked units. +// +// This method populates the database with a resource linked to a unit, attempts to delete +// the application's resources, then verifies that an error is returned due to the remaining unit +// and that no resources have been deleted. This enforces constraints on cleaning up resources +// with active dependencies. +func (s *resourceSuite) TestDeleteApplicationResourcesErrorRemainingUnits(c *gc.C) { + // Arrange: populate db with some resource a resource linked to a unit + input := resourceData{ + UUID: "app1-res1-uuid", + ApplicationUUID: s.constants.fakeApplicationUUID1, + Name: "res1", + // Populate table resource_unit + UnitUUID: s.constants.fakeUnitUUID1, + } + err := s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + return input.insert(context.Background(), tx) + }) + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Arrange) failed to populate DB: %v", errors.ErrorStack(err))) + + // Act: delete resources from application 1 + err = s.state.DeleteApplicationResources(context.Background(), application.ID(s.constants.fakeApplicationUUID1)) + + // Assert: check an error is returned and no resource deleted + c.Check(err, jc.ErrorIs, resourceerrors.InvalidCleanUpState, + gc.Commentf("(Assert) unexpected error: %v", errors.ErrorStack(err))) + err = s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + // fetch resources + var discard string + return tx.QueryRow(` +SELECT uuid FROM v_resource +WHERE uuid = ? AND application_uuid = ? AND name = ?`, + input.UUID, input.ApplicationUUID, input.Name, + ).Scan(&discard) + }) + c.Check(err, jc.ErrorIsNil, gc.Commentf("(Assert) resource deleted or cannot check db: %v", + errors.ErrorStack(err))) +} + +// TestDeleteApplicationResourcesErrorRemainingObjectStoreData verifies that attempting to delete application +// resources will fail when there are remaining object store data linked to the resource, +// and no resource will be deleted. +func (s *resourceSuite) TestDeleteApplicationResourcesErrorRemainingObjectStoreData(c *gc.C) { + // Arrange: populate db with some resource linked with some data + input := resourceData{ + UUID: "res1-uuid", + ApplicationUUID: s.constants.fakeApplicationUUID1, + Name: "res1", + } + err := s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + // Insert the data + if err := input.insert(context.Background(), tx); err != nil { + return errors.Capture(err) + } + // Insert some data linked to the resource + if _, err := tx.Exec(` +INSERT INTO object_store_metadata (uuid, sha_256, sha_384,size) +VALUES ('store-uuid','','',0)`); err != nil { + return errors.Capture(err) + } + if _, err := tx.Exec(` +INSERT INTO resource_file_store (resource_uuid, store_uuid) +VALUES (?,'store-uuid')`, input.UUID); err != nil { + return errors.Capture(err) + } + return + }) + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Arrange) failed to populate DB: %v", errors.ErrorStack(err))) + + // Act: delete resources from application 1 + err = s.state.DeleteApplicationResources(context.Background(), application.ID(s.constants.fakeApplicationUUID1)) + + // Assert: check an error is returned and no resource deleted + c.Check(err, jc.ErrorIs, resourceerrors.InvalidCleanUpState, + gc.Commentf("(Assert) unexpected error: %v", errors.ErrorStack(err))) + err = s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + // fetch resources + var discard string + return tx.QueryRow(` +SELECT uuid FROM v_resource +WHERE uuid = ? AND application_uuid = ? AND name = ?`, + input.UUID, input.ApplicationUUID, input.Name, + ).Scan(&discard) + }) + c.Check(err, jc.ErrorIsNil, gc.Commentf("(Assert) resource deleted or cannot check db: %v", + errors.ErrorStack(err))) +} + +// TestDeleteApplicationResourcesErrorRemainingImageStoreData verifies that attempting to delete application +// resources will fail when there are remaining image store data linked to the resource, +// and no resource will be deleted. +func (s *resourceSuite) TestDeleteApplicationResourcesErrorRemainingImageStoreData(c *gc.C) { + // Arrange: populate db with some resource linked with some data + input := resourceData{ + UUID: "res1-uuid", + ApplicationUUID: s.constants.fakeApplicationUUID1, + Name: "res1", + } + err := s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + // Insert the data + if err := input.insert(context.Background(), tx); err != nil { + return errors.Capture(err) + } + // Insert some data linked to the resource + if _, err := tx.Exec(` +INSERT INTO resource_container_image_metadata_store (storage_key, registry_path) +VALUES ('store-uuid','')`); err != nil { + return errors.Capture(err) + } + if _, err := tx.Exec(` +INSERT INTO resource_image_store (resource_uuid, store_storage_key) +VALUES (?,'store-uuid')`, input.UUID); err != nil { + return errors.Capture(err) + } + return + }) + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Arrange) failed to populate DB: %v", errors.ErrorStack(err))) + + // Act: delete resources from application 1 + err = s.state.DeleteApplicationResources(context.Background(), application.ID(s.constants.fakeApplicationUUID1)) + + // Assert: check an error is returned and no resource deleted + c.Check(err, jc.ErrorIs, resourceerrors.InvalidCleanUpState, + gc.Commentf("(Assert) unexpected error: %v", errors.ErrorStack(err))) + err = s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + // fetch resources + var discard string + return tx.QueryRow(` +SELECT uuid FROM v_resource +WHERE uuid = ? AND application_uuid = ? AND name = ?`, + input.UUID, input.ApplicationUUID, input.Name, + ).Scan(&discard) + }) + c.Check(err, jc.ErrorIsNil, gc.Commentf("(Assert) resource deleted or cannot check db: %v", + errors.ErrorStack(err))) +} + +// TestDeleteUnitResources verifies that resources linked to a specific unit are deleted correctly. +func (s *resourceSuite) TestDeleteUnitResources(c *gc.C) { + // Arrange: populate db with some resource a resource linked to a unit + resUnit1 := resourceData{ + UUID: "res-unit1-uuid", + ApplicationUUID: s.constants.fakeApplicationUUID1, + Name: "res-unit1", + // Populate table resource_unit + UnitUUID: s.constants.fakeUnitUUID1, + } + other := resourceData{ + UUID: "res-unit2-uuid", + ApplicationUUID: s.constants.fakeApplicationUUID1, + Name: "res-unit2", + // Populate table resource_unit + UnitUUID: s.constants.fakeUnitUUID2, + } + err := s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + for _, input := range []resourceData{resUnit1, other} { + if err := input.insert(context.Background(), tx); err != nil { + return errors.Capture(err) + } + } + return nil + }) + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Arrange) failed to populate DB: %v", errors.ErrorStack(err))) + + // Act: delete resources from application 1 + err = s.state.DeleteUnitResources(context.Background(), unit.UUID(s.constants.fakeUnitUUID1)) + + // Assert: check that resources link to unit 1 have been deleted in expected tables + // without errors + c.Assert(err, jc.ErrorIsNil, + gc.Commentf("(Assert) failed to delete resources link to unit 1: %v", + errors.ErrorStack(err))) + var obtained []resourceData + err = s.TxnRunner().StdTxn(context.Background(), func(ctx context.Context, tx *sql.Tx) (err error) { + // fetch resources + rows, err := tx.Query(` +SELECT uuid, name, application_uuid, unit_uuid +FROM v_resource AS rv +LEFT JOIN unit_resource AS ur ON rv.uuid = ur.resource_uuid`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var uuid string + var resName string + var appUUID string + var unitUUID *string + if err := rows.Scan(&uuid, &resName, &appUUID, &unitUUID); err != nil { + return err + } + obtained = append(obtained, + resourceData{UUID: uuid, ApplicationUUID: appUUID, + Name: resName, UnitUUID: zeroPtr(unitUUID)}) + } + return err + }) + c.Assert(err, jc.ErrorIsNil, gc.Commentf("(Assert) failed to check db: %v", + errors.ErrorStack(err))) + expectedResUnit1 := resUnit1 + expectedResUnit1.UnitUUID = "" + c.Assert(obtained, jc.SameContents, []resourceData{expectedResUnit1, other}, gc.Commentf("(Assert) unexpected resources: %v", obtained)) +} + // TestGetApplicationResourceID tests that the resource ID can be correctly // retrieved from the database, given a name and an application func (s *resourceSuite) TestGetApplicationResourceID(c *gc.C) { @@ -840,7 +1123,8 @@ func (input resourceData) insert(ctx context.Context, tx *sql.Tx) (err error) { return } -// nilZero returns a pointer to the input value unless the value is its type's zero value, in which case it returns nil. +// nilZero returns a pointer to the input value unless the value is its type's +// zero value, in which case it returns nil. func nilZero[T comparable](t T) *T { var zero T if t == zero { @@ -848,3 +1132,13 @@ func nilZero[T comparable](t T) *T { } return &t } + +// zeroPtr returns the value pointed to by t or the zero value if the pointer is +// nil. +func zeroPtr[T comparable](t *T) T { + var zero T + if t == nil { + return zero + } + return *t +} diff --git a/domain/resource/types.go b/domain/resource/types.go index 00257f2e046..055c4ae6a51 100644 --- a/domain/resource/types.go +++ b/domain/resource/types.go @@ -10,7 +10,7 @@ import ( "github.com/juju/juju/core/application" coreresource "github.com/juju/juju/core/resource" "github.com/juju/juju/core/unit" - "github.com/juju/juju/internal/charm/resource" + charmresource "github.com/juju/juju/internal/charm/resource" ) // IncrementCharmModifiedVersionType is the argument type for incrementing @@ -45,7 +45,7 @@ type ApplicationResources struct { // was polled. Each entry here corresponds to the same indexed entry // in the Resources field. An entry may be empty if data has not // yet been retrieve from the repository. - RepositoryResources []resource.Resource + RepositoryResources []charmresource.Resource // UnitResources reports the currently-in-use version of file type // resources for each unit. @@ -70,7 +70,7 @@ type ApplicationResources struct { // // Fingerprint, Size type Resource struct { - resource.Resource + charmresource.Resource // UUID uniquely identifies a resource within the model. UUID coreresource.UUID @@ -124,7 +124,7 @@ type SetResourceArgs struct { ApplicationID application.ID SuppliedBy string SuppliedByType RetrievedByType - Resource resource.Resource + Resource charmresource.Resource Reader io.Reader Increment IncrementCharmModifiedVersionType } @@ -151,7 +151,7 @@ type SetRepositoryResourcesArgs struct { // ApplicationID is the id of the application having these resources. ApplicationID application.ID // Info is a slice of resource data received from the repository. - Info []resource.Resource + Info []charmresource.Resource // LastPolled indicates when the resource data was last polled. LastPolled time.Time }