Repository: nephio-project/porch
Pull request #233: fix for #834 adding logic to make porch API calls atomic
Author: login: mansoor17syed
Labels: name: approved
description: #ededed
name: lgtm
description: #ededed
Created: 2025-04-29T15:04:46Z | Merged: 2025-05-19T06:35:51Z



Diff:
diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go
index fcefaffe4..12fc8678c 100644
--- a/pkg/engine/engine.go
+++ b/pkg/engine/engine.go
@@ -151,21 +151,48 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 		return nil, err
 	}
 
+	// Create a draft package revision
 	draft, err := repo.CreatePackageRevisionDraft(ctx, obj)
 	if err != nil {
 		return nil, err
 	}
 
+	// Setup rollback function in case of errors
+	rollback := func() {
+		// Try to convert the draft to a PackageRevision for deletion
+		// If the conversion fails, we can't do much more since we can't delete a draft directly
+		if pkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0); err == nil {
+			if err := repo.DeletePackageRevision(ctx, pkgRev); err != nil {
+				klog.Warningf("Failed to rollback package revision creation: %v", err)
+			}
+		} else {
+			// If we can't convert the draft, log the error and continue
+			// The draft will be cleaned up by the repository's garbage collection
+			klog.Warningf("Failed to convert draft to package revision for rollback: %v", err)
+		}
+	}
+
+	// Apply tasks
 	if err := cad.taskHandler.ApplyTasks(ctx, draft, repositoryObj, obj, packageConfig); err != nil {
+		rollback()
 		return nil, err
 	}
 
+	// Update lifecycle
 	if err := draft.UpdateLifecycle(ctx, obj.Spec.Lifecycle); err != nil {
+		rollback()
 		return nil, err
 	}
 
-	// Updates are done.
-	return repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	// Close the draft
+	repoPkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	if err != nil {
+		// Don't call rollback() here since it would likely fail again
+		// Just return the error from the close operation
+		return nil, fmt.Errorf("failed to close package revision draft: %w", err)
+	}
+
+	return repoPkgRev, nil
 }
 
 // The workspaceName must be unique, because it used to generate the package revision's metadata.name.
diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go
new file mode 100644
index 000000000..15f186182
--- /dev/null
+++ b/pkg/engine/engine_test.go
@@ -0,0 +1,363 @@
+package engine
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	api "github.com/nephio-project/porch/api/porch/v1alpha1"
+	configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1"
+	"github.com/nephio-project/porch/internal/kpt/builtins"
+	"github.com/nephio-project/porch/internal/kpt/fnruntime"
+	v1 "github.com/nephio-project/porch/pkg/kpt/api/kptfile/v1"
+	"github.com/nephio-project/porch/pkg/kpt/fn"
+	"github.com/nephio-project/porch/pkg/repository"
+	mockrepo "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+)
+
+// Test fixtures for common test objects
+type testFixture struct {
+	mockRepo        *mockrepo.MockRepository
+	mockCache       *mockCache
+	mockTaskHandler *mockTaskHandler
+	repositoryObj   *configapi.Repository
+	packageRevision *api.PackageRevision
+	engine          *cadEngine
+}
+
+func newTestFixture(t *testing.T) *testFixture {
+	mockRepo := &mockrepo.MockRepository{}
+	mockCache := &mockCache{}
+	mockTaskHandler := &mockTaskHandler{}
+
+	repositoryObj := &configapi.Repository{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "test-repo",
+			Namespace: "default",
+		},
+		Spec: configapi.RepositorySpec{
+			Git: &configapi.GitRepository{
+				Directory: "test-dir",
+			},
+		},
+	}
+
+	packageRevision := &api.PackageRevision{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "test-pkg",
+		},
+		Spec: api.PackageRevisionSpec{
+			PackageName:    "test-package",
+			WorkspaceName:  "test-workspace",
+			RepositoryName: "test-repo",
+		},
+	}
+
+	// Setup common mock expectations
+	mockCache.On("OpenRepository", mock.Anything, mock.MatchedBy(func(repo *configapi.Repository) bool {
+		return repo.Name == "test-repo" && repo.Namespace == "default"
+	})).Return(mockRepo, nil).Once()
+
+	engine := &cadEngine{
+		cache:       mockCache,
+		taskHandler: mockTaskHandler,
+	}
+
+	return &testFixture{
+		mockRepo:        mockRepo,
+		mockCache:       mockCache,
+		mockTaskHandler: mockTaskHandler,
+		repositoryObj:   repositoryObj,
+		packageRevision: packageRevision,
+		engine:          engine,
+	}
+}
+
+func setupMockPackageRevision(t *testing.T) *mockPackageRevision {
+	mockPkgRev := &mockPackageRevision{}
+	// Setup common mock package revision expectations
+	mockPkgRev.On("Key").Return(repository.PackageRevisionKey{})
+	mockPkgRev.On("GetMeta").Return(metav1.ObjectMeta{})
+	mockPkgRev.On("GetKptfile", mock.Anything).Return(v1.KptFile{}, nil)
+	mockPkgRev.On("KubeObjectName").Return("test-pkg")
+	mockPkgRev.On("KubeObjectNamespace").Return("default")
+	mockPkgRev.On("UID").Return(types.UID("test-uid"))
+	mockPkgRev.On("Lifecycle", mock.Anything).Return(api.PackageRevisionLifecycleDraft)
+	mockPkgRev.On("GetPackageRevision", mock.Anything).Return(&api.PackageRevision{}, nil)
+	mockPkgRev.On("GetResources", mock.Anything).Return(&api.PackageRevisionResources{}, nil)
+	mockPkgRev.On("GetUpstreamLock", mock.Anything).Return(v1.Upstream{}, v1.UpstreamLock{}, nil)
+	mockPkgRev.On("GetLock").Return(v1.Upstream{}, v1.UpstreamLock{}, nil)
+	mockPkgRev.On("ResourceVersion").Return("1")
+	mockPkgRev.On("ToMainPackageRevision", mock.Anything).Return(mockPkgRev)
+	mockPkgRev.On("SetMeta", mock.Anything, mock.Anything).Return(nil)
+	return mockPkgRev
+}
+
+type mockPackageRevisionDraft struct {
+	mock.Mock
+}
+
+func (m *mockPackageRevisionDraft) Key() repository.PackageRevisionKey {
+	args := m.Called()
+	return args.Get(0).(repository.PackageRevisionKey)
+}
+
+func (m *mockPackageRevisionDraft) GetMeta() metav1.ObjectMeta {
+	args := m.Called()
+	return args.Get(0).(metav1.ObjectMeta)
+}
+
+func (m *mockPackageRevisionDraft) UpdateResources(ctx context.Context, new *api.PackageRevisionResources, task *api.Task) error {
+	args := m.Called(ctx, new, task)
+	return args.Error(0)
+}
+
+func (m *mockPackageRevisionDraft) UpdateLifecycle(ctx context.Context, new api.PackageRevisionLifecycle) error {
+	args := m.Called(ctx, new)
+	return args.Error(0)
+}
+
+type mockPackageRevision struct {
+	mock.Mock
+}
+
+func (m *mockPackageRevision) Key() repository.PackageRevisionKey {
+	args := m.Called()
+	return args.Get(0).(repository.PackageRevisionKey)
+}
+
+func (m *mockPackageRevision) GetMeta() metav1.ObjectMeta {
+	args := m.Called()
+	return args.Get(0).(metav1.ObjectMeta)
+}
+
+func (m *mockPackageRevision) UpdateResources(ctx context.Context, new *api.PackageRevisionResources, task *api.Task) error {
+	args := m.Called(ctx, new, task)
+	return args.Error(0)
+}
+
+func (m *mockPackageRevision) UpdateLifecycle(ctx context.Context, new api.PackageRevisionLifecycle) error {
+	args := m.Called(ctx, new)
+	return args.Error(0)
+}
+
+func (m *mockPackageRevision) GetKptfile(ctx context.Context) (v1.KptFile, error) {
+	args := m.Called(ctx)
+	return args.Get(0).(v1.KptFile), args.Error(1)
+}
+
+func (m *mockPackageRevision) KubeObjectName() string {
+	args := m.Called()
+	return args.String(0)
+}
+
+func (m *mockPackageRevision) KubeObjectNamespace() string {
+	args := m.Called()
+	return args.String(0)
+}
+
+func (m *mockPackageRevision) UID() types.UID {
+	args := m.Called()
+	return args.Get(0).(types.UID)
+}
+
+func (m *mockPackageRevision) Lifecycle(ctx context.Context) api.PackageRevisionLifecycle {
+	args := m.Called(ctx)
+	return args.Get(0).(api.PackageRevisionLifecycle)
+}
+
+func (m *mockPackageRevision) GetPackageRevision(ctx context.Context) (*api.PackageRevision, error) {
+	args := m.Called(ctx)
+	return args.Get(0).(*api.PackageRevision), args.Error(1)
+}
+
+func (m *mockPackageRevision) GetResources(ctx context.Context) (*api.PackageRevisionResources, error) {
+	args := m.Called(ctx)
+	return args.Get(0).(*api.PackageRevisionResources), args.Error(1)
+}
+
+func (m *mockPackageRevision) GetUpstreamLock(ctx context.Context) (v1.Upstream, v1.UpstreamLock, error) {
+	args := m.Called(ctx)
+	return args.Get(0).(v1.Upstream), args.Get(1).(v1.UpstreamLock), args.Error(2)
+}
+
+func (m *mockPackageRevision) GetLock() (v1.Upstream, v1.UpstreamLock, error) {
+	args := m.Called()
+	return args.Get(0).(v1.Upstream), args.Get(1).(v1.UpstreamLock), args.Error(2)
+}
+
+func (m *mockPackageRevision) ResourceVersion() string {
+	args := m.Called()
+	return args.String(0)
+}
+
+func (m *mockPackageRevision) ToMainPackageRevision(ctx context.Context) repository.PackageRevision {
+	args := m.Called(ctx)
+	return args.Get(0).(repository.PackageRevision)
+}
+
+func (m *mockPackageRevision) SetMeta(ctx context.Context, meta metav1.ObjectMeta) error {
+	args := m.Called(ctx, meta)
+	return args.Error(0)
+}
+
+func (m *mockPackageRevision) SetRepository(repo repository.Repository) {
+	m.Called(repo)
+}
+
+func TestCreatePackageRevisionRollback(t *testing.T) {
+	tests := []struct {
+		name          string
+		setupTest     func(*testFixture, *mockPackageRevision, *mockPackageRevisionDraft)
+		expectedError bool
+		errorContains string
+	}{
+		{
+			name: "rollback on task application failure",
+			setupTest: func(f *testFixture, mockPkgRev *mockPackageRevision, mockDraft *mockPackageRevisionDraft) {
+				mockDraft.On("UpdateResources", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+				mockDraft.On("UpdateLifecycle", mock.Anything, mock.Anything).Return(nil)
+
+				f.mockRepo.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{}, nil)
+				f.mockRepo.On("CreatePackageRevisionDraft", mock.Anything, mock.Anything).Return(mockDraft, nil)
+				f.mockRepo.On("ClosePackageRevisionDraft", mock.Anything, mock.Anything, mock.Anything).Return(mockPkgRev, nil)
+				f.mockRepo.On("DeletePackageRevision", mock.Anything, mock.Anything).Return(nil)
+				f.mockRepo.On("Close", mock.Anything).Return(nil)
+
+				f.mockTaskHandler.On("ApplyTasks", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(fmt.Errorf("task application failed"))
+			},
+			expectedError: true,
+			errorContains: "task application failed",
+		},
+		{
+			name: "rollback on lifecycle update failure",
+			setupTest: func(f *testFixture, mockPkgRev *mockPackageRevision, mockDraft *mockPackageRevisionDraft) {
+				mockDraft.On("UpdateResources", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+				mockDraft.On("UpdateLifecycle", mock.Anything, mock.Anything).Return(fmt.Errorf("lifecycle update failed"))
+
+				f.mockRepo.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{}, nil)
+				f.mockRepo.On("CreatePackageRevisionDraft", mock.Anything, mock.Anything).Return(mockDraft, nil)
+				f.mockRepo.On("ClosePackageRevisionDraft", mock.Anything, mock.Anything, mock.Anything).Return(mockPkgRev, nil)
+				f.mockRepo.On("DeletePackageRevision", mock.Anything, mock.Anything).Return(nil)
+				f.mockRepo.On("Close", mock.Anything).Return(nil)
+
+				f.mockTaskHandler.On("ApplyTasks", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
+			},
+			expectedError: true,
+			errorContains: "lifecycle update failed",
+		},
+		{
+			name: "rollback on close draft failure",
+			setupTest: func(f *testFixture, mockPkgRev *mockPackageRevision, mockDraft *mockPackageRevisionDraft) {
+				mockDraft.On("UpdateResources", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+				mockDraft.On("UpdateLifecycle", mock.Anything, mock.Anything).Return(nil)
+
+				f.mockRepo.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{}, nil)
+				f.mockRepo.On("CreatePackageRevisionDraft", mock.Anything, mock.Anything).Return(mockDraft, nil)
+				f.mockRepo.On("ClosePackageRevisionDraft", mock.Anything, mock.Anything, mock.Anything).Return(mockPkgRev, fmt.Errorf("close failed"))
+				f.mockRepo.On("Close", mock.Anything).Return(nil)
+
+				f.mockTaskHandler.On("ApplyTasks", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
+			},
+			expectedError: true,
+			errorContains: "close failed",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			f := newTestFixture(t)
+			mockPkgRev := setupMockPackageRevision(t)
+			mockDraft := &mockPackageRevisionDraft{}
+			tt.setupTest(f, mockPkgRev, mockDraft)
+
+			_, err := f.engine.CreatePackageRevision(context.Background(), f.repositoryObj, f.packageRevision, nil)
+			if tt.expectedError {
+				assert.Error(t, err)
+				assert.Contains(t, err.Error(), tt.errorContains)
+			} else {
+				assert.NoError(t, err)
+			}
+
+			f.mockRepo.Close(context.Background())
+
+			f.mockRepo.AssertExpectations(t)
+			f.mockTaskHandler.AssertExpectations(t)
+			f.mockCache.AssertExpectations(t)
+		})
+	}
+}
+
+// Mock implementations
+type mockTaskHandler struct {
+	mock.Mock
+}
+
+func (m *mockTaskHandler) ApplyTasks(ctx context.Context, draft repository.PackageRevisionDraft, repositoryObj *configapi.Repository, obj *api.PackageRevision, packageConfig *builtins.PackageConfig) error {
+	args := m.Called(ctx, draft, repositoryObj, obj, packageConfig)
+	return args.Error(0)
+}
+
+func (m *mockTaskHandler) DoPRMutations(ctx context.Context, namespace string, repoPr repository.PackageRevision, oldObj *api.PackageRevision, newObj *api.PackageRevision, draft repository.PackageRevisionDraft) error {
+	args := m.Called(ctx, namespace, repoPr, oldObj, newObj, draft)
+	return args.Error(0)
+}
+
+func (m *mockTaskHandler) DoPRResourceMutations(ctx context.Context, pr2Update repository.PackageRevision, draft repository.PackageRevisionDraft, oldRes *api.PackageRevisionResources, newRes *api.PackageRevisionResources) (*api.RenderStatus, error) {
+	args := m.Called(ctx, pr2Update, draft, oldRes, newRes)
+	return args.Get(0).(*api.RenderStatus), args.Error(1)
+}
+
+func (m *mockTaskHandler) GetRuntime() fn.FunctionRuntime {
+	args := m.Called()
+	return args.Get(0).(fn.FunctionRuntime)
+}
+
+func (m *mockTaskHandler) SetCredentialResolver(resolver repository.CredentialResolver) {
+	m.Called(resolver)
+}
+
+func (m *mockTaskHandler) SetReferenceResolver(resolver repository.ReferenceResolver) {
+	m.Called(resolver)
+}
+
+func (m *mockTaskHandler) SetRepoOpener(opener repository.RepositoryOpener) {
+	m.Called(opener)
+}
+
+func (m *mockTaskHandler) SetRunnerOptionsResolver(resolver func(namespace string) fnruntime.RunnerOptions) {
+	m.Called(resolver)
+}
+
+func (m *mockTaskHandler) SetRuntime(runtime fn.FunctionRuntime) {
+	m.Called(runtime)
+}
+
+type mockCache struct {
+	mock.Mock
+}
+
+func (m *mockCache) OpenRepository(ctx context.Context, repositoryObj *configapi.Repository) (repository.Repository, error) {
+	args := m.Called(ctx, repositoryObj)
+	return args.Get(0).(repository.Repository), args.Error(1)
+}
+
+func (m *mockCache) CloseRepository(ctx context.Context, repositoryObj *configapi.Repository, repositories []configapi.Repository) error {
+	args := m.Called(ctx, repositoryObj, repositories)
+	return args.Error(0)
+}
+
+func (m *mockCache) GetRepositories(ctx context.Context) []*configapi.Repository {
+	args := m.Called(ctx)
+	return args.Get(0).([]*configapi.Repository)
+}
+
+func (m *mockCache) UpdateRepository(ctx context.Context, repositoryObj *configapi.Repository) error {
+	args := m.Called(ctx, repositoryObj)
+	return args.Error(0)
+}
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
index 1f17854bf..fe4edfbf9 100644
--- a/test/e2e/e2e_test.go
+++ b/test/e2e/e2e_test.go
@@ -15,6 +15,7 @@
 package e2e
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -32,6 +33,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
 	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/fields"
@@ -3234,3 +3236,65 @@ func (t *PorchSuite) TestMetadataAfterDeleteAndBackgroundJob() {
 	t.MustExist(mainPrKey, &porchapi.PackageRevision{})
 	t.MustNotExist(pr)
 }
+func (t *PorchSuite) TestCreatePackageRevisionRollback() {
+	// Create a package revision that will fail during task application
+	// This should trigger the rollback functionality
+	ctx := context.Background()
+	packageName := "test-package-rollback"
+	workspaceName := "test-workspace-rollback"
+	repositoryName := "test-repo"
+
+	// Register the repository first
+	t.RegisterMainGitRepositoryF(repositoryName)
+
+	// Create a package revision with invalid task configuration
+	// that will cause task application to fail
+	pr := &porchapi.PackageRevision{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "PackageRevision",
+			APIVersion: porchapi.SchemeGroupVersion.Identifier(),
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      fmt.Sprintf("%s-%s", packageName, workspaceName),
+			Namespace: t.Namespace,
+		},
+		Spec: porchapi.PackageRevisionSpec{
+			PackageName:    packageName,
+			WorkspaceName:  workspaceName,
+			RepositoryName: repositoryName,
+			Tasks: []porchapi.Task{
+				{
+					Type: porchapi.TaskTypeClone,
+					Clone: &porchapi.PackageCloneTaskSpec{
+						Upstream: porchapi.UpstreamPackage{
+							Type: porchapi.RepositoryTypeGit,
+							Git: &porchapi.GitPackage{
+								Repo:      "https://github.com/nephio-project/porch.git",
+								Ref:       "main",
+								Directory: "testdata/test-repo",
+							},
+						},
+					},
+				},
+				{
+					Type: porchapi.TaskTypePatch,
+					Patch: &porchapi.PackagePatchTaskSpec{
+						Patches: []porchapi.PatchSpec{
+							{
+								File: "invalid-file.yaml", // This will cause task application to fail
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	// Attempt to create the package revision
+	_, err := t.Clientset.PorchV1alpha1().PackageRevisions(t.Namespace).Create(ctx, pr, metav1.CreateOptions{})
+	assert.Error(t, err, "Expected error when creating package revision with invalid task configuration")
+
+	// Verify that the package revision was not created
+	_, err = t.Clientset.PorchV1alpha1().PackageRevisions(t.Namespace).Get(ctx, pr.Name, metav1.GetOptions{})
+	assert.True(t, apierrors.IsNotFound(err), "Expected package revision to be deleted after rollback")
+}


Reviews:
user: login: liamfallon
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T08:49:20Z
user: login: liamfallon
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T08:51:03Z
user: login: mansoor17syed
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T15:32:01Z
user: login: mansoor17syed
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T15:32:42Z
user: login: liamfallon
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T16:36:20Z
user: login: liamfallon
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T16:38:18Z

Review comments:
diff_hunk: @@ -151,21 +151,44 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 		return nil, err
 	}
 
+	// Create a draft package revision
 	draft, err := repo.CreatePackageRevisionDraft(ctx, obj)
 	if err != nil {
 		return nil, err
 	}
 
+	// Setup rollback function in case of errors
+	rollback := func() {
+		// Convert draft to PackageRevision for deletion
+		if pkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0); err == nil {
path: pkg/engine/engine.go
user: login: liamfallon
body: We probably should amend the interface to allow deletion of drafts rather than having to close a draft so we can delete it, but it's much more work to do that so i think this is fine for now.

Would you be able to create a new issue to update the repository..PacakgeRevisionDraft interface to add a delete() function to the interface?
created_at: 2025-05-06T08:49:19Z
author_association: MEMBER
diff_hunk: @@ -151,21 +151,44 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 		return nil, err
 	}
 
+	// Create a draft package revision
 	draft, err := repo.CreatePackageRevisionDraft(ctx, obj)
 	if err != nil {
 		return nil, err
 	}
 
+	// Setup rollback function in case of errors
+	rollback := func() {
+		// Convert draft to PackageRevision for deletion
+		if pkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0); err == nil {
+			if err := repo.DeletePackageRevision(ctx, pkgRev); err != nil {
+				klog.Warningf("Failed to rollback package revision creation: %v", err)
+			}
+		} else {
+			klog.Warningf("Failed to convert draft to package revision for rollback: %v", err)
+		}
+	}
+
+	// Apply tasks
 	if err := cad.taskHandler.ApplyTasks(ctx, draft, repositoryObj, obj, packageConfig); err != nil {
+		rollback()
 		return nil, err
 	}
 
+	// Update lifecycle
 	if err := draft.UpdateLifecycle(ctx, obj.Spec.Lifecycle); err != nil {
+		rollback()
 		return nil, err
 	}
 
-	// Updates are done.
-	return repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	// Close the draft
+	repoPkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	if err != nil {
+		rollback()
path: pkg/engine/engine.go
user: login: liamfallon
body: If this call to ClosePacakgeRevisionDraft errors out, won't the rollback() function always error out too because it calls ClosePackageRevisionDraft again?
created_at: 2025-05-06T08:51:03Z
author_association: MEMBER
diff_hunk: @@ -151,21 +151,44 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 		return nil, err
 	}
 
+	// Create a draft package revision
 	draft, err := repo.CreatePackageRevisionDraft(ctx, obj)
 	if err != nil {
 		return nil, err
 	}
 
+	// Setup rollback function in case of errors
+	rollback := func() {
+		// Convert draft to PackageRevision for deletion
+		if pkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0); err == nil {
path: pkg/engine/engine.go
user: login: mansoor17syed
body: So the proposed change seems fine but we should  create a new issue to track adding a delete() function to the repository.PackageRevisionDraft interface? This would be a cleaner solution in the future. ?
created_at: 2025-05-06T15:32:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,21 +151,44 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 		return nil, err
 	}
 
+	// Create a draft package revision
 	draft, err := repo.CreatePackageRevisionDraft(ctx, obj)
 	if err != nil {
 		return nil, err
 	}
 
+	// Setup rollback function in case of errors
+	rollback := func() {
+		// Convert draft to PackageRevision for deletion
+		if pkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0); err == nil {
+			if err := repo.DeletePackageRevision(ctx, pkgRev); err != nil {
+				klog.Warningf("Failed to rollback package revision creation: %v", err)
+			}
+		} else {
+			klog.Warningf("Failed to convert draft to package revision for rollback: %v", err)
+		}
+	}
+
+	// Apply tasks
 	if err := cad.taskHandler.ApplyTasks(ctx, draft, repositoryObj, obj, packageConfig); err != nil {
+		rollback()
 		return nil, err
 	}
 
+	// Update lifecycle
 	if err := draft.UpdateLifecycle(ctx, obj.Spec.Lifecycle); err != nil {
+		rollback()
 		return nil, err
 	}
 
-	// Updates are done.
-	return repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	// Close the draft
+	repoPkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	if err != nil {
+		rollback()
path: pkg/engine/engine.go
user: login: mansoor17syed
body: What should happen if ClosePackageRevisionDraft fails during rollback?
Should we have a different rollback strategy when the initial close fails? 

Do let me know your suggestions
created_at: 2025-05-06T15:32:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,21 +151,44 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 		return nil, err
 	}
 
+	// Create a draft package revision
 	draft, err := repo.CreatePackageRevisionDraft(ctx, obj)
 	if err != nil {
 		return nil, err
 	}
 
+	// Setup rollback function in case of errors
+	rollback := func() {
+		// Convert draft to PackageRevision for deletion
+		if pkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0); err == nil {
path: pkg/engine/engine.go
user: login: liamfallon
body: Yes. That would be my suggestion.
created_at: 2025-05-06T16:36:20Z
author_association: MEMBER
diff_hunk: @@ -151,21 +151,44 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 		return nil, err
 	}
 
+	// Create a draft package revision
 	draft, err := repo.CreatePackageRevisionDraft(ctx, obj)
 	if err != nil {
 		return nil, err
 	}
 
+	// Setup rollback function in case of errors
+	rollback := func() {
+		// Convert draft to PackageRevision for deletion
+		if pkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0); err == nil {
+			if err := repo.DeletePackageRevision(ctx, pkgRev); err != nil {
+				klog.Warningf("Failed to rollback package revision creation: %v", err)
+			}
+		} else {
+			klog.Warningf("Failed to convert draft to package revision for rollback: %v", err)
+		}
+	}
+
+	// Apply tasks
 	if err := cad.taskHandler.ApplyTasks(ctx, draft, repositoryObj, obj, packageConfig); err != nil {
+		rollback()
 		return nil, err
 	}
 
+	// Update lifecycle
 	if err := draft.UpdateLifecycle(ctx, obj.Spec.Lifecycle); err != nil {
+		rollback()
 		return nil, err
 	}
 
-	// Updates are done.
-	return repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	// Close the draft
+	repoPkgRev, err := repo.ClosePackageRevisionDraft(ctx, draft, 0)
+	if err != nil {
+		rollback()
path: pkg/engine/engine.go
user: login: liamfallon
body: I don't think you can do anything because if the closePackageRevisionDraft fails it will probably fail again in the rollback() function. Its probably best to just return the error generated by the call at line 185 and not call the rollback() at all in this case.
created_at: 2025-05-06T16:38:18Z
author_association: MEMBER

PR comments:
user: login: liamfallon
created_at: 2025-05-06T08:52:00Z
body: We should add some unit tests and an e2e test to cover the case where a draft creation fails and the "rollback()" function is called.
author_association: MEMBER
user: login: mansoor17syed
created_at: 2025-05-06T15:33:49Z
body: > We should add some unit tests and an e2e test to cover the case where a draft creation fails and the "rollback()" function is called.

What specific test cases should we add if you have any in mind
Should we focus on unit tests first or e2e tests?

I will work on it based on your feedback
author_association: CONTRIBUTOR
user: login: liamfallon
created_at: 2025-05-06T16:53:52Z
body: > > We should add some unit tests and an e2e test to cover the case where a draft creation fails and the "rollback()" function is called.
> 
> What specific test cases should we add if you have any in mind Should we focus on unit tests first or e2e tests?
> 
> I will work on it based on your feedback

I think it would be best to do the unit tests first and do the e2e tests later.

On the e2e tests, you could probably add a test using the TestDeleteDraft() test as a template:
https://github.com/nephio-project/porch/blob/7dc71bb135ee11d49afffbeada26029175b4c3c9/test/e2e/e2e_test.go#L1137 
author_association: MEMBER
user: login: mansoor17syed
created_at: 2025-05-08T10:06:17Z
body: Hi @liamfallon 

I've addressed all the review comments and added the unit tests as well as attempted to include some end-to-end validations. I have created the issue also as suggested [https://github.com/nephio-project/nephio/issues/909](url) However, the tests are passing fine in my local environment but seem to be failing in the current CI run.

Could you please suggest any pointers or things I should check? It would be really helpful!


![image](https://github.com/user-attachments/assets/69f1bc29-34e3-4719-9b43-9b26914bd78e)


Thanks a lot for your time and support!
author_association: CONTRIBUTOR
user: login: liamfallon
created_at: 2025-05-08T11:51:35Z
body: /test presubmit-nephio-go-test
author_association: MEMBER
user: login: nephio-prow[bot]
created_at: 2025-05-08T11:51:39Z
body: @liamfallon: No presubmit jobs available for nephio-project/porch@main

<details>

In response to [this](https://github.com/nephio-project/porch/pull/233#issuecomment-2862766151):

>/test presubmit-nephio-go-test


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
author_association: CONTRIBUTOR
user: login: mansoor17syed
created_at: 2025-05-15T09:47:10Z
body: Hi @liamfallon ,

Any feedback for me here ?
Thanks
author_association: CONTRIBUTOR
user: login: efiacor
created_at: 2025-05-15T10:20:25Z
body: > Hi @liamfallon ,
> 
> Any feedback for me here ? Thanks

Looks like we may have to regenerate the mockery stuff.

panic: interface conversion: *engine.mockRepository is not repository.Repository: missing method Close [recovered]
	panic: interface conversion: *engine.mockRepository is not repository.Repository: missing method Close
	
https://github.com/nephio-project/porch/blob/main/default-mockery.mk#L30
author_association: COLLABORATOR
user: login: liamfallon
created_at: 2025-05-15T10:25:39Z
body: > > Hi @liamfallon ,
> > Any feedback for me here ? Thanks
> 
> Looks like we may have to regenerate the mockery stuff.
> 
> panic: interface conversion: *engine.mockRepository is not repository.Repository: missing method Close [recovered] panic: interface conversion: *engine.mockRepository is not repository.Repository: missing method Close
> 
> https://github.com/nephio-project/porch/blob/main/default-mockery.mk#L30

Yes, there-s a makefile target for that
```
make generate-mocks
```
author_association: MEMBER
user: login: liamfallon
created_at: 2025-05-15T10:34:27Z
body: > Hi @liamfallon ,
> 
> Any feedback for me here ? Thanks

Hi @mansoor17syed ,

Apologies, I should have got back to you on this before now.

It looks good. If you can generate the mocks and check them in and get the tests working we should be good to merge this.
author_association: MEMBER
user: login: mansoor17syed
created_at: 2025-05-15T14:35:42Z
body: Hi @liamfallon  and @efiacor 

Thank you for the suggestions. I applied the changes, and the output below appears to pass afterward as well. Am I missing something?


![image](https://github.com/user-attachments/assets/236c9433-c141-4d66-9194-1d398733ff25)

author_association: CONTRIBUTOR
user: login: liamfallon
created_at: 2025-05-15T15:10:37Z
body: Did you check in the code generated for the mocks? That could be why the tests are failing on the build.
author_association: MEMBER
user: login: liamfallon
created_at: 2025-05-18T11:40:53Z
body: Hi @mansoor17syed, when I pull your PR and try to run it locally, one of the setup scripts is breaking. I don't think its anything to do with your PR. I'll talk to the team in the morning and try to get to the bottom if it.

```
% scripts/setup-dev-env.sh 

** Install kind cluster: porch-test

Cluster already exists.
Set kubectl context to "kind-porch-test"

** Install MetalLB

namespace/metallb-system unchanged
customresourcedefinition.apiextensions.k8s.io/bfdprofiles.metallb.io unchanged
customresourcedefinition.apiextensions.k8s.io/bgpadvertisements.metallb.io unchanged
customresourcedefinition.apiextensions.k8s.io/bgppeers.metallb.io configured
customresourcedefinition.apiextensions.k8s.io/communities.metallb.io unchanged
customresourcedefinition.apiextensions.k8s.io/ipaddresspools.metallb.io unchanged
customresourcedefinition.apiextensions.k8s.io/l2advertisements.metallb.io unchanged
customresourcedefinition.apiextensions.k8s.io/servicel2statuses.metallb.io unchanged
serviceaccount/controller unchanged
serviceaccount/speaker unchanged
role.rbac.authorization.k8s.io/controller unchanged
role.rbac.authorization.k8s.io/pod-lister unchanged
clusterrole.rbac.authorization.k8s.io/metallb-system:controller unchanged
clusterrole.rbac.authorization.k8s.io/metallb-system:speaker unchanged
rolebinding.rbac.authorization.k8s.io/controller unchanged
rolebinding.rbac.authorization.k8s.io/pod-lister unchanged
clusterrolebinding.rbac.authorization.k8s.io/metallb-system:controller unchanged
clusterrolebinding.rbac.authorization.k8s.io/metallb-system:speaker unchanged
configmap/metallb-excludel2 unchanged
secret/metallb-webhook-cert unchanged
service/metallb-webhook-service unchanged
deployment.apps/controller unchanged
daemonset.apps/speaker unchanged
validatingwebhookconfiguration.admissionregistration.k8s.io/metallb-webhook-configuration configured
Waiting for controller to become ready...
deployment.apps/controller condition met
ipaddresspool.metallb.io/example unchanged
l2advertisement.metallb.io/empty unchanged

** Install Gitea and init test repos

Error: package must have an upstream reference 
```
author_association: MEMBER
user: login: liamfallon
created_at: 2025-05-18T11:41:25Z
body: In the meantime, if you could rebase your PR and push the rebased version, that would help a lot.
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-05-18T14:21:35Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=nephio-project_porch&pullRequest=233) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [66.7% Coverage on New Code](https://sonarcloud.io/component_measures?id=nephio-project_porch&pullRequest=233&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=nephio-project_porch&pullRequest=233)


author_association: NONE
user: login: mansoor17syed
created_at: 2025-05-18T14:32:10Z
body: Hey @liamfallon ,
Thanks for your feedback and support. I also did a rebase along with the some  additional changes for tests. After testing and validating, everything is now passing. Could you please help me review it?
author_association: CONTRIBUTOR
user: login: liamfallon
created_at: 2025-05-18T18:25:59Z
body: /approve

author_association: MEMBER
user: login: liamfallon
created_at: 2025-05-18T18:26:24Z
body: Thanks @mansoor17syed for all your efforts. Let's get one more /approve on it and we can merge it.
author_association: MEMBER
user: login: efiacor
created_at: 2025-05-18T22:35:34Z
body: /approve
author_association: COLLABORATOR
user: login: nephio-prow[bot]
created_at: 2025-05-18T22:35:38Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/nephio-project/porch/pull/233#issuecomment-2889249368" title="Approved">efiacor</a>*, *<a href="https://github.com/nephio-project/porch/pull/233#issuecomment-2889140696" title="Approved">liamfallon</a>*, *<a href="https://github.com/nephio-project/porch/pull/233#" title="Author self-approved">mansoor17syed</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=nephio-project%2Fporch).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/nephio-project/porch/blob/main/OWNERS)~~ [efiacor,liamfallon]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: liamfallon
created_at: 2025-05-19T06:35:25Z
body: /lgtm
author_association: MEMBER