Skip to content

Commit 172635d

Browse files
committed
Revert repo list on namespace and add indexers
- Remove namespace-only filter from webhook conflict detection - Add field indexes on spec.git.repo and spec.git.branch for optimization - Add comprehensive test coverage for indexing functions - Condense and improve webhook query comments Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech>
1 parent cdc50f8 commit 172635d

6 files changed

Lines changed: 650 additions & 49 deletions

File tree

controllers/repositories/pkg/controllers/repository/config.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@
1515
package repository
1616

1717
import (
18+
"context"
1819
"flag"
20+
"fmt"
1921
"time"
2022

23+
configapi "github.com/kptdev/porch/api/porchconfig/v1alpha1"
2124
"github.com/kptdev/porch/controllers/repositories/pkg/webhooks"
2225
cachetypes "github.com/kptdev/porch/pkg/cache/types"
2326
ctrl "sigs.k8s.io/controller-runtime"
27+
"sigs.k8s.io/controller-runtime/pkg/client"
2428
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
2529
)
2630

@@ -91,10 +95,34 @@ func (r *RepositoryReconciler) validateConfig() {
9195
}
9296

9397
// Init wires runtime dependencies that require the manager.
94-
// It registers the Repository validating webhook on the shared webhook server.
98+
// It registers the Repository validating webhook on the shared webhook server
99+
// and sets up field indexes for efficient conflict detection queries.
95100
func (r *RepositoryReconciler) Init(mgr ctrl.Manager) error {
96101
log := ctrl.Log.WithName(r.Name())
97102

103+
// Set up field indexes for Repository conflict detection
104+
// These indexes allow efficient querying by git location without listing all repositories
105+
ctx := context.Background()
106+
if err := mgr.GetFieldIndexer().IndexField(ctx, &configapi.Repository{}, "spec.git.repo", func(o client.Object) []string {
107+
repository := o.(*configapi.Repository)
108+
if repository.Spec.Git == nil || repository.Spec.Git.Repo == "" {
109+
return nil
110+
}
111+
return []string{repository.Spec.Git.Repo}
112+
}); err != nil {
113+
return fmt.Errorf("error indexing Repository by git.repo: %w", err)
114+
}
115+
116+
if err := mgr.GetFieldIndexer().IndexField(ctx, &configapi.Repository{}, "spec.git.branch", func(o client.Object) []string {
117+
repository := o.(*configapi.Repository)
118+
if repository.Spec.Git == nil || repository.Spec.Git.Branch == "" {
119+
return nil
120+
}
121+
return []string{repository.Spec.Git.Branch}
122+
}); err != nil {
123+
return fmt.Errorf("error indexing Repository by git.branch: %w", err)
124+
}
125+
98126
// Register Repository validating webhook.
99127
// The validator implements admission.Handler interface via its Handle method.
100128
// Use GetAPIReader() for strong consistency during admission validation.

controllers/repositories/pkg/controllers/repository/config_test.go

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import (
2121
"testing"
2222
"time"
2323

24+
configapi "github.com/kptdev/porch/api/porchconfig/v1alpha1"
2425
"github.com/stretchr/testify/assert"
2526
"github.com/stretchr/testify/require"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2628
"sigs.k8s.io/controller-runtime/pkg/client"
2729
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2830
"sigs.k8s.io/controller-runtime/pkg/healthz"
@@ -308,11 +310,20 @@ func TestValidateConfig(t *testing.T) {
308310
}
309311
}
310312

313+
// fakeFieldIndexer is a minimal field.Indexer for testing that stores index functions but doesn't actually index.
314+
type fakeFieldIndexer struct{}
315+
316+
func (f *fakeFieldIndexer) IndexField(ctx context.Context, obj client.Object, field string, fn client.IndexerFunc) error {
317+
// No-op for testing - just accept the index function registration
318+
return nil
319+
}
320+
311321
// fakeManager is a minimal manager.Manager for unit testing Init().
312-
// Only GetClient(), GetAPIReader(), and GetWebhookServer() are implemented; all other methods will panic if called.
322+
// Only GetClient(), GetAPIReader(), GetWebhookServer(), and GetFieldIndexer() are implemented; all other methods will panic if called.
313323
type fakeManager struct {
314324
manager.Manager
315-
client client.Client
325+
client client.Client
326+
indexer *fakeFieldIndexer
316327
}
317328

318329
func (f *fakeManager) GetClient() client.Client {
@@ -327,6 +338,10 @@ func (f *fakeManager) GetWebhookServer() webhook.Server {
327338
return &fakeWebhookServer{}
328339
}
329340

341+
func (f *fakeManager) GetFieldIndexer() client.FieldIndexer {
342+
return f.indexer
343+
}
344+
330345
// fakeWebhookServer is a minimal webhook.Server for testing.
331346
type fakeWebhookServer struct{}
332347

@@ -365,7 +380,10 @@ func TestInit(t *testing.T) {
365380
for _, tt := range tests {
366381
t.Run(tt.name, func(t *testing.T) {
367382
reconciler := &RepositoryReconciler{}
368-
mgr := &fakeManager{client: fake.NewClientBuilder().Build()}
383+
mgr := &fakeManager{
384+
client: fake.NewClientBuilder().Build(),
385+
indexer: &fakeFieldIndexer{},
386+
}
369387

370388
err := reconciler.Init(mgr)
371389

@@ -377,3 +395,153 @@ func TestInit(t *testing.T) {
377395
})
378396
}
379397
}
398+
399+
// TestGitRepoIndexingFunction tests the git.repo index function used in Init
400+
func TestGitRepoIndexingFunction(t *testing.T) {
401+
indexFunc := func(o client.Object) []string {
402+
repository := o.(*configapi.Repository)
403+
if repository.Spec.Git == nil || repository.Spec.Git.Repo == "" {
404+
return nil
405+
}
406+
return []string{repository.Spec.Git.Repo}
407+
}
408+
409+
tests := []struct {
410+
name string
411+
repo *configapi.Repository
412+
expected []string
413+
}{
414+
{
415+
name: "git repository with valid URL",
416+
repo: &configapi.Repository{
417+
ObjectMeta: metav1.ObjectMeta{Name: "repo1", Namespace: "default"},
418+
Spec: configapi.RepositorySpec{
419+
Git: &configapi.GitRepository{
420+
Repo: "http://gitea.local/org/repo.git",
421+
},
422+
},
423+
},
424+
expected: []string{"http://gitea.local/org/repo.git"},
425+
},
426+
{
427+
name: "OCI repository (nil Git)",
428+
repo: &configapi.Repository{
429+
ObjectMeta: metav1.ObjectMeta{Name: "repo-oci", Namespace: "default"},
430+
Spec: configapi.RepositorySpec{
431+
Type: configapi.RepositoryTypeOCI,
432+
},
433+
},
434+
expected: nil,
435+
},
436+
{
437+
name: "git repository with empty URL",
438+
repo: &configapi.Repository{
439+
ObjectMeta: metav1.ObjectMeta{Name: "repo2", Namespace: "default"},
440+
Spec: configapi.RepositorySpec{
441+
Git: &configapi.GitRepository{Repo: ""},
442+
},
443+
},
444+
expected: nil,
445+
},
446+
{
447+
name: "repository with git spec but empty URL",
448+
repo: &configapi.Repository{
449+
ObjectMeta: metav1.ObjectMeta{Name: "repo3", Namespace: "default"},
450+
Spec: configapi.RepositorySpec{
451+
Git: &configapi.GitRepository{
452+
Repo: "https://github.com/example/pkg.git",
453+
},
454+
},
455+
},
456+
expected: []string{"https://github.com/example/pkg.git"},
457+
},
458+
}
459+
460+
for _, tc := range tests {
461+
t.Run(tc.name, func(t *testing.T) {
462+
result := indexFunc(tc.repo)
463+
assert.Equal(t, tc.expected, result)
464+
})
465+
}
466+
}
467+
468+
// TestGitBranchIndexingFunction tests the git.branch index function used in Init
469+
func TestGitBranchIndexingFunction(t *testing.T) {
470+
indexFunc := func(o client.Object) []string {
471+
repository := o.(*configapi.Repository)
472+
if repository.Spec.Git == nil || repository.Spec.Git.Branch == "" {
473+
return nil
474+
}
475+
return []string{repository.Spec.Git.Branch}
476+
}
477+
478+
tests := []struct {
479+
name string
480+
repo *configapi.Repository
481+
expected []string
482+
}{
483+
{
484+
name: "repository with main branch",
485+
repo: &configapi.Repository{
486+
ObjectMeta: metav1.ObjectMeta{Name: "repo1", Namespace: "default"},
487+
Spec: configapi.RepositorySpec{
488+
Git: &configapi.GitRepository{
489+
Branch: "main",
490+
},
491+
},
492+
},
493+
expected: []string{"main"},
494+
},
495+
{
496+
name: "repository with develop branch",
497+
repo: &configapi.Repository{
498+
ObjectMeta: metav1.ObjectMeta{Name: "repo2", Namespace: "default"},
499+
Spec: configapi.RepositorySpec{
500+
Git: &configapi.GitRepository{
501+
Branch: "develop",
502+
},
503+
},
504+
},
505+
expected: []string{"develop"},
506+
},
507+
{
508+
name: "OCI repository (nil Git)",
509+
repo: &configapi.Repository{
510+
ObjectMeta: metav1.ObjectMeta{Name: "repo-oci", Namespace: "default"},
511+
Spec: configapi.RepositorySpec{},
512+
},
513+
expected: nil,
514+
},
515+
{
516+
name: "repository with release branch",
517+
repo: &configapi.Repository{
518+
ObjectMeta: metav1.ObjectMeta{Name: "repo3", Namespace: "prod"},
519+
Spec: configapi.RepositorySpec{
520+
Git: &configapi.GitRepository{
521+
Branch: "release-v1.0",
522+
},
523+
},
524+
},
525+
expected: []string{"release-v1.0"},
526+
},
527+
{
528+
name: "repository with feature branch",
529+
repo: &configapi.Repository{
530+
ObjectMeta: metav1.ObjectMeta{Name: "repo4", Namespace: "staging"},
531+
Spec: configapi.RepositorySpec{
532+
Git: &configapi.GitRepository{
533+
Branch: "feature/new-feature",
534+
},
535+
},
536+
},
537+
expected: []string{"feature/new-feature"},
538+
},
539+
}
540+
541+
for _, tc := range tests {
542+
t.Run(tc.name, func(t *testing.T) {
543+
result := indexFunc(tc.repo)
544+
assert.Equal(t, tc.expected, result)
545+
})
546+
}
547+
}

controllers/repositories/pkg/webhooks/repository_webhook.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,22 @@ func (v *RepositoryValidator) handleCreateOrUpdate(ctx context.Context, req admi
8080
// NOTE: Immutability checks (URL, branch, directory) are handled by CEL validation in the CRD.
8181
// This webhook only performs complex cross-resource conflict detection that CEL cannot do.
8282

83+
// Only Git repositories need conflict detection (OCI repos don't have the same multi-namespace issues)
84+
if attempted.Spec.Git == nil {
85+
logger.V(3).Info("repository is OCI type, skipping conflict check",
86+
"namespace", attempted.Namespace, "name", attempted.Name)
87+
return admission.Allowed("OCI repositories do not require conflict detection")
88+
}
89+
90+
// Query repositories with matching git location (repo + branch).
91+
// Field indexes optimize this query to O(1) lookups when available.
8392
var repoList configapi.RepositoryList
84-
opts := []client.ListOption{client.InNamespace(attempted.Namespace)}
93+
opts := []client.ListOption{
94+
client.MatchingFields{
95+
"spec.git.repo": attempted.Spec.Git.Repo,
96+
"spec.git.branch": attempted.Spec.Git.Branch,
97+
},
98+
}
8599
if err := v.client.List(ctx, &repoList, opts...); err != nil {
86100
logger.Error(err, "failed to list repositories for conflict check")
87101
return admission.Errored(http.StatusInternalServerError,
@@ -120,7 +134,14 @@ func NormalizeURL(url string) string {
120134
// 1. Same URL, branch, and directory in the same namespace
121135
// 2. Root directory conflicts with any subdirectory under the same URL and branch
122136
// 3. Nested directory conflicts (one path is a prefix of another)
137+
//
138+
// Returns false if either repository doesn't have a Git spec (no conflict possible).
123139
func IsConflict(existing, attempted *configapi.Repository) bool {
140+
// Only Git repositories can conflict with each other
141+
if existing.Spec.Git == nil || attempted.Spec.Git == nil {
142+
return false
143+
}
144+
124145
existingURL := NormalizeURL(existing.Spec.Git.Repo)
125146
attemptedURL := NormalizeURL(attempted.Spec.Git.Repo)
126147

0 commit comments

Comments
 (0)