Skip to content

Commit feb9377

Browse files
kptdev#998: Do not allow deletion of a PR that is an upstream PR of a cloned PR (kptdev#453)
* Block upstream revision getting deleted if it has a downstream dependent * Add unit tests for upstream dependency check during deletes * Add log for dependency check failures * Remove dependency checks for main package revision * Add test checkUpstreamDependencies in porch registry * Update pkg/registry/porch/packagerevision.go Co-authored-by: Liam Fallon <35595825+liamfallon@users.noreply.github.com> * Remove edit task from upstream dependency checks * Address comments and fix safemap's Range function * Add docs for upstream delete protection and update unit test * Add package tracking for delete log * Update unit tests and remove references to 'dependent' in docs * Change all refs of 'dependency/dependent' --------- Co-authored-by: Liam Fallon <35595825+liamfallon@users.noreply.github.com>
1 parent b0517ec commit feb9377

17 files changed

Lines changed: 621 additions & 6 deletions

File tree

docs/content/en/docs/2_concepts/upstream-downstream.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,28 @@ management through Porch.
9393
- Customizations in downstream packages are preserved during updates
9494
- A package can be both upstream (to others) and downstream (from another)
9595
- PackageVariant automates the upstream → downstream lifecycle
96+
- **Deletion protection**: Upstream PackageRevisions cannot be deleted while downstream PackageRevisions exist
97+
98+
## Deletion Protection
99+
100+
Porch prevents deletion of upstream PackageRevisions when downstream PackageRevisions exist.
101+
102+
**Protection mechanism**: Attempting to delete an upstream PackageRevision with existing downstream PackageRevisions will:
103+
- Fail with a Forbidden error
104+
- Identify which downstream PackageRevision blocks the deletion
105+
- Require deletion of downstream PackageRevisions first
106+
107+
**Deletion order**: Always delete from downstream to upstream:
108+
1. Delete all downstream PackageRevisions
109+
2. Delete the upstream PackageRevision
110+
111+
**Example**:
112+
```
113+
blueprints/nginx-template:v1 (upstream)
114+
115+
deployments/prod-nginx:v1 (downstream)
116+
```
117+
118+
To delete nginx-template:v1, first delete prod-nginx:v1, then delete nginx-template:v1.
119+
120+
This ensures downstream packages don't lose their source reference.

docs/content/en/docs/4_tutorials_and_how-tos/working_with_package_revisions/deleting-packages.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,11 @@ graph TD
319319
- Published deletions remove Git tags and references
320320
- Deletion is permanent and cannot be undone
321321

322-
**Dependency Considerations:**
322+
**Upstream Reference Considerations:**
323323

324-
- Check if other PackageRevisions depend on the one being deleted
325-
- Deleting upstream packages may affect downstream clones
324+
- Upstream PackageRevisions cannot be deleted while downstream PackageRevisions reference them
325+
- Delete downstream PackageRevisions first, then you can delete the upstream PackageRevisions
326+
- The error message identifies which downstream PackageRevision is blocking deletion
326327
- Consider the impact on deployed workloads
327328

328329
---
@@ -348,6 +349,15 @@ Error: cannot delete published package revision directly, use propose-delete fir
348349
- Check RBAC permissions with the `kubectl auth can-i delete packagerevisions -n default` command
349350
- Verify your service account has proper deletion roles
350351

352+
**Cannot delete upstream PackageRevision:**
353+
354+
```bash
355+
Error from server (Forbidden): cannot delete package revision, it is referenced as upstream by: <downstream-packagerevision-name>
356+
```
357+
358+
- See "Upstream Reference Considerations" in Safety Considerations above
359+
- Delete the downstream PackageRevision first, then retry
360+
351361
**Deletion proposal stuck:**
352362

353363
- Check the PackageRevision status with the `porchctl rpkg get <name> -o yaml` command

pkg/cache/crcache/cache.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,41 @@ func (c *Cache) GetRepository(repoKey repository.RepositoryKey) repository.Repos
119119
func (c *Cache) CheckRepositoryConnectivity(ctx context.Context, repositorySpec *configapi.Repository) error {
120120
return externalrepo.CheckRepositoryConnection(ctx, repositorySpec, c.options.ExternalRepoOptions)
121121
}
122+
123+
func (c *Cache) FindAllUpstreamReferencesInRepositories(ctx context.Context, namespace, prName string) (string, error) {
124+
var downstreamName string
125+
c.repositories.Range(func(key, value any) bool {
126+
cachedRepo := value.(*cachedRepository)
127+
if cachedRepo.Key().Namespace != namespace {
128+
return true
129+
}
130+
cachedRepo.mutex.RLock()
131+
for _, pr := range cachedRepo.cachedPackageRevisions {
132+
// Skip main branch packages (revision = -1) as they are auto-managed
133+
if pr.Key().Revision == -1 {
134+
continue
135+
}
136+
apiPR, err := pr.GetPackageRevision(ctx)
137+
if err != nil {
138+
continue
139+
}
140+
for _, task := range apiPR.Spec.Tasks {
141+
var matched bool
142+
switch task.Type {
143+
case "clone":
144+
matched = task.Clone != nil && task.Clone.Upstream.UpstreamRef != nil && task.Clone.Upstream.UpstreamRef.Name == prName
145+
case "upgrade":
146+
matched = task.Upgrade != nil && task.Upgrade.NewUpstream.Name == prName
147+
}
148+
if matched {
149+
downstreamName = pr.KubeObjectName()
150+
cachedRepo.mutex.RUnlock()
151+
return false
152+
}
153+
}
154+
}
155+
cachedRepo.mutex.RUnlock()
156+
return true
157+
})
158+
return downstreamName, nil
159+
}

pkg/cache/crcache/cache_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ import (
3030
fakecache "github.com/nephio-project/porch/pkg/cache/fake"
3131
"github.com/nephio-project/porch/pkg/cache/repomap"
3232
cachetypes "github.com/nephio-project/porch/pkg/cache/types"
33+
"github.com/nephio-project/porch/pkg/externalrepo/fake"
3334
"github.com/nephio-project/porch/pkg/externalrepo/git"
3435
externalrepotypes "github.com/nephio-project/porch/pkg/externalrepo/types"
3536
"github.com/nephio-project/porch/pkg/repository"
37+
"github.com/stretchr/testify/assert"
38+
"github.com/stretchr/testify/require"
3639
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3740
"k8s.io/apimachinery/pkg/runtime"
3841
k8sfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -323,3 +326,122 @@ func createMetadataStoreFromArchive(t *testing.T, testPath, name string) meta.Me
323326
Metas: metas,
324327
}
325328
}
329+
330+
func TestFindUpstreamReference(t *testing.T) {
331+
ctx := context.Background()
332+
cache := &Cache{repositories: repomap.SafeRepoMap{}}
333+
repoKey := repository.RepositoryKey{Namespace: "test-ns", Name: "test-repo"}
334+
mockRepo := &cachedRepository{
335+
key: repoKey,
336+
cachedPackageRevisions: make(map[repository.PackageRevisionKey]*cachedPackageRevision),
337+
}
338+
_, _ = cache.repositories.LoadOrCreate(repoKey, func() (repository.Repository, error) {
339+
return mockRepo, nil
340+
})
341+
342+
// Create second repository for cross-repo testing
343+
repo2Key := repository.RepositoryKey{Namespace: "test-ns", Name: "test-repo2"}
344+
mockRepo2 := &cachedRepository{
345+
key: repo2Key,
346+
cachedPackageRevisions: make(map[repository.PackageRevisionKey]*cachedPackageRevision),
347+
}
348+
_, _ = cache.repositories.LoadOrCreate(repo2Key, func() (repository.Repository, error) {
349+
return mockRepo2, nil
350+
})
351+
352+
addDownstream := func(repo *cachedRepository, downstreamPkgName, taskType, upstreamRefName string) {
353+
key := repository.PackageRevisionKey{
354+
PkgKey: repository.PackageKey{RepoKey: repo.key, Package: downstreamPkgName},
355+
WorkspaceName: "v1",
356+
}
357+
var task porchapi.Task
358+
switch taskType {
359+
case "clone":
360+
task = porchapi.Task{
361+
Type: "clone",
362+
Clone: &porchapi.PackageCloneTaskSpec{Upstream: porchapi.UpstreamPackage{UpstreamRef: &porchapi.PackageRevisionRef{Name: upstreamRefName}}},
363+
}
364+
case "upgrade":
365+
task = porchapi.Task{
366+
Type: "upgrade",
367+
Upgrade: &porchapi.PackageUpgradeTaskSpec{NewUpstream: porchapi.PackageRevisionRef{Name: upstreamRefName}},
368+
}
369+
}
370+
repo.cachedPackageRevisions[key] = &cachedPackageRevision{
371+
PackageRevision: &fake.FakePackageRevision{
372+
PrKey: key,
373+
PackageRevision: &porchapi.PackageRevision{Spec: porchapi.PackageRevisionSpec{Tasks: []porchapi.Task{task}}},
374+
},
375+
}
376+
}
377+
378+
tests := map[string]struct {
379+
namespace string
380+
upstreamPkgToDelete string
381+
repo *cachedRepository
382+
downstreamPkgName string
383+
upstreamRefName string
384+
taskType string
385+
wantDep string
386+
}{
387+
"no downstream": {
388+
namespace: "test-ns",
389+
upstreamPkgToDelete: "test-repo.upstream.v1",
390+
wantDep: "",
391+
},
392+
"find clone downstream": {
393+
namespace: "test-ns",
394+
upstreamPkgToDelete: "test-repo.upstream.v1",
395+
repo: mockRepo,
396+
downstreamPkgName: "downstream",
397+
upstreamRefName: "test-repo.upstream.v1",
398+
taskType: "clone",
399+
wantDep: "test-repo.downstream.v1",
400+
},
401+
"find upgrade downstream": {
402+
namespace: "test-ns",
403+
upstreamPkgToDelete: "test-repo.upstream.v1",
404+
repo: mockRepo,
405+
downstreamPkgName: "upgrade",
406+
upstreamRefName: "test-repo.upstream.v1",
407+
taskType: "upgrade",
408+
wantDep: "test-repo.upgrade.v1",
409+
},
410+
"task without downstream": {
411+
namespace: "test-ns",
412+
upstreamPkgToDelete: "test-repo.upstream.v1",
413+
repo: mockRepo,
414+
downstreamPkgName: "other",
415+
upstreamRefName: "test-repo.different.v1",
416+
taskType: "clone",
417+
wantDep: "",
418+
},
419+
"different namespace": {
420+
namespace: "other-ns",
421+
upstreamPkgToDelete: "test-repo.upstream.v1",
422+
wantDep: "",
423+
},
424+
"cross-repo downstream": {
425+
namespace: "test-ns",
426+
upstreamPkgToDelete: "test-repo.base-pkg.v1",
427+
repo: mockRepo2,
428+
downstreamPkgName: "derived",
429+
upstreamRefName: "test-repo.base-pkg.v1",
430+
taskType: "clone",
431+
wantDep: "test-repo2.derived.v1",
432+
},
433+
}
434+
435+
for name, tt := range tests {
436+
t.Run(name, func(t *testing.T) {
437+
mockRepo.cachedPackageRevisions = make(map[repository.PackageRevisionKey]*cachedPackageRevision)
438+
mockRepo2.cachedPackageRevisions = make(map[repository.PackageRevisionKey]*cachedPackageRevision)
439+
if tt.taskType != "" {
440+
addDownstream(tt.repo, tt.downstreamPkgName, tt.taskType, tt.upstreamRefName)
441+
}
442+
dep, err := cache.FindAllUpstreamReferencesInRepositories(ctx, tt.namespace, tt.upstreamPkgToDelete)
443+
require.NoError(t, err)
444+
assert.Equal(t, tt.wantDep, dep)
445+
})
446+
}
447+
}

pkg/cache/dbcache/dbcache.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,7 @@ func (c *dbCache) GetRepository(repoKey repository.RepositoryKey) repository.Rep
151151
func (c *dbCache) CheckRepositoryConnectivity(ctx context.Context, repositorySpec *configapi.Repository) error {
152152
return externalrepo.CheckRepositoryConnection(ctx, repositorySpec, c.options.ExternalRepoOptions)
153153
}
154+
155+
func (c *dbCache) FindAllUpstreamReferencesInRepositories(ctx context.Context, namespace, prName string) (string, error) {
156+
return findUpstreamRefsFromDB(ctx, namespace, prName)
157+
}

pkg/cache/dbcache/dbpackagerevisionsql.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,28 @@ func pkgRevDeleteFromDB(ctx context.Context, prk repository.PackageRevisionKey)
454454

455455
return err
456456
}
457+
458+
func findUpstreamRefsFromDB(ctx context.Context, namespace, prName string) (string, error) {
459+
_, span := tracer.Start(ctx, "dbpackagerevisionsql::findUpstreamRefsFromDB")
460+
defer span.End()
461+
462+
// Match newUpstreamRef (upgrade) or nested upstreamRef (clone)
463+
// Exclude main branch packages (revision = -1) as they are auto-managed
464+
sqlStatement := `
465+
SELECT k8s_name FROM package_revisions
466+
WHERE k8s_name_space=$1
467+
AND revision != -1
468+
AND tasks::text ~ ('"(upstreamRef|newUpstreamRef)":\{"name":"' || $2 || '"')
469+
LIMIT 1
470+
`
471+
472+
var downstreamName string
473+
err := GetDB().db.QueryRow(ctx, sqlStatement, namespace, prName).Scan(&downstreamName)
474+
if err == sql.ErrNoRows {
475+
return "", nil
476+
}
477+
if err != nil {
478+
return "", err
479+
}
480+
return downstreamName, nil
481+
}

pkg/cache/dbcache/dbpackagerevisionsql_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package dbcache
1616

1717
import (
1818
"database/sql"
19+
"fmt"
1920
"time"
2021

2122
porchapi "github.com/nephio-project/porch/api/porch/v1alpha1"
@@ -656,3 +657,100 @@ func (t *DbTestSuite) assertPackageRevLatestIs(expectedLatest int, prList []*dbP
656657
t.Equal(expectedLatest, latestPrRev)
657658
}
658659
}
660+
661+
func (t *DbTestSuite) TestFindUpstreamReference() {
662+
mockCache := mockcachetypes.NewMockCache(t.T())
663+
cachetypes.CacheInstance = mockCache
664+
mockCache.EXPECT().GetRepository(mock.Anything).Return(&dbRepository{})
665+
666+
upstreamRepo := t.createTestRepo("test-ns", "upstream")
667+
downstreamRepo := t.createTestRepo("test-ns", "downstream")
668+
669+
upstreamPkg := t.createTestPkg(upstreamRepo.Key(), "basepkg")
670+
upstreamPkg.repo = upstreamRepo
671+
672+
upstreamPR := dbPackageRevision{
673+
pkgRevKey: repository.PackageRevisionKey{
674+
PkgKey: upstreamPkg.Key(),
675+
WorkspaceName: "v1",
676+
Revision: 1,
677+
},
678+
meta: metav1.ObjectMeta{Name: "upstream.basepkg.v1", Namespace: "test-ns"},
679+
lifecycle: porchapi.PackageRevisionLifecyclePublished,
680+
}
681+
err := pkgRevWriteToDB(t.Context(), &upstreamPR)
682+
t.Require().NoError(err)
683+
684+
tests := map[string]struct {
685+
namespace string
686+
pkgName string
687+
wsName string
688+
taskType string
689+
wantDep string
690+
}{
691+
"no downstream": {
692+
namespace: "test-ns",
693+
wantDep: "",
694+
},
695+
"clone task": {
696+
namespace: "test-ns",
697+
pkgName: "edge-cluster",
698+
wsName: "v1",
699+
taskType: "clone",
700+
wantDep: "downstream.edge-cluster.v1",
701+
},
702+
"upgrade task": {
703+
namespace: "test-ns",
704+
pkgName: "edge-cluster",
705+
wsName: "v2",
706+
taskType: "upgrade",
707+
wantDep: "downstream.edge-cluster.v2",
708+
},
709+
"different namespace": {
710+
namespace: "other-ns",
711+
wantDep: "",
712+
},
713+
"non-existent upstream": {
714+
namespace: "test-ns",
715+
wantDep: "",
716+
},
717+
}
718+
719+
for name, tt := range tests {
720+
t.Run(name, func() {
721+
if tt.taskType != "" {
722+
downstreamPkg := t.createTestPkg(downstreamRepo.Key(), tt.pkgName)
723+
downstreamPkg.repo = downstreamRepo
724+
725+
var task porchapi.Task
726+
switch tt.taskType {
727+
case "clone":
728+
task = porchapi.Task{Type: porchapi.TaskTypeClone, Clone: &porchapi.PackageCloneTaskSpec{
729+
Upstream: porchapi.UpstreamPackage{UpstreamRef: &porchapi.PackageRevisionRef{Name: upstreamPR.meta.Name}},
730+
}}
731+
case "upgrade":
732+
task = porchapi.Task{Type: porchapi.TaskTypeUpgrade, Upgrade: &porchapi.PackageUpgradeTaskSpec{
733+
NewUpstream: porchapi.PackageRevisionRef{Name: upstreamPR.meta.Name},
734+
}}
735+
}
736+
pr := dbPackageRevision{
737+
pkgRevKey: repository.PackageRevisionKey{PkgKey: downstreamPkg.Key(), WorkspaceName: tt.wsName},
738+
meta: metav1.ObjectMeta{Name: fmt.Sprintf("downstream.%s.%s", tt.pkgName, tt.wsName), Namespace: "test-ns"},
739+
lifecycle: porchapi.PackageRevisionLifecycleDraft,
740+
tasks: []porchapi.Task{task},
741+
}
742+
t.Require().NoError(pkgRevWriteToDB(t.Context(), &pr))
743+
defer pkgRevDeleteFromDB(t.Context(), pr.Key())
744+
defer pkgDeleteFromDB(t.Context(), downstreamPkg.Key())
745+
}
746+
downstream, err := findUpstreamRefsFromDB(t.Context(), tt.namespace, upstreamPR.meta.Name)
747+
t.Require().NoError(err)
748+
t.Equal(tt.wantDep, downstream)
749+
})
750+
}
751+
752+
err = repoDeleteFromDB(t.Context(), upstreamRepo.Key())
753+
t.NoError(err)
754+
err = repoDeleteFromDB(t.Context(), downstreamRepo.Key())
755+
t.NoError(err)
756+
}

pkg/cache/repomap/saferepomap.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,14 @@ func (s *SafeRepoMap) LoadAndDelete(key repository.RepositoryKey) (repository.Re
4242
}
4343

4444
func (s *SafeRepoMap) Range(f func(key, value any) bool) {
45-
s.syncMap.Range(f)
45+
s.syncMap.Range(func(key, value any) bool {
46+
loader := value.(*repoLoader)
47+
// Skip nil loaders (defensive) or repos that failed creation or haven't completed initialization
48+
if loader == nil || loader.repo == nil {
49+
return true
50+
}
51+
return f(key, loader.repo)
52+
})
4653
}
4754

4855
type repoLoader struct {

0 commit comments

Comments
 (0)