diff --git a/pkg/apis/git/v1alpha1/defaults.go b/pkg/apis/git/v1alpha1/defaults.go index 44d4bb0..7b63788 100644 --- a/pkg/apis/git/v1alpha1/defaults.go +++ b/pkg/apis/git/v1alpha1/defaults.go @@ -16,13 +16,7 @@ func SetDefaults_GitPushTransaction(t *GitPushTransaction) { } } -func addDefaultingFuncs(scheme *runtime.Scheme) error { - return RegisterDefaults(scheme) -} - // RegisterDefaults adds default functions to the given scheme. -// This is a placeholder that will be populated by code generation. -// For now we register defaults manually. func RegisterDefaults(scheme *runtime.Scheme) error { scheme.AddTypeDefaultingFunc(&GitRepository{}, func(obj interface{}) { SetDefaults_GitRepository(obj.(*GitRepository)) diff --git a/pkg/client/clientset.go b/pkg/client/clientset.go index ddbd5a5..e8ac973 100644 --- a/pkg/client/clientset.go +++ b/pkg/client/clientset.go @@ -47,8 +47,13 @@ func WithClient(ctx context.Context, client *GitV1alpha1Client) context.Context } // Get retrieves the GitV1alpha1Client from the context. +// Panics if no client has been stored via WithClient. func Get(ctx context.Context) *GitV1alpha1Client { - return ctx.Value(contextKey{}).(*GitV1alpha1Client) + v, ok := ctx.Value(contextKey{}).(*GitV1alpha1Client) + if !ok { + panic("git-k8s client not found in context; use client.WithClient to inject it") + } + return v } var ( diff --git a/pkg/reconciler/internal/key.go b/pkg/reconciler/internal/key.go new file mode 100644 index 0000000..ec50510 --- /dev/null +++ b/pkg/reconciler/internal/key.go @@ -0,0 +1,14 @@ +package internal + +import "strings" + +// SplitKey splits a "namespace/name" key into its components. +// If the key has no slash, the namespace is empty and the whole key is the name. +func SplitKey(key string) (namespace, name string) { + namespace, name, _ = strings.Cut(key, "/") + if name == "" { + // No slash found: Cut returns (key, "", false). The key is the name. + return "", namespace + } + return namespace, name +} diff --git a/pkg/reconciler/internal/key_test.go b/pkg/reconciler/internal/key_test.go new file mode 100644 index 0000000..6f59a6a --- /dev/null +++ b/pkg/reconciler/internal/key_test.go @@ -0,0 +1,51 @@ +package internal + +import ( + "testing" +) + +func TestSplitKey(t *testing.T) { + tests := []struct { + key string + wantNS string + wantName string + }{ + { + key: "default/my-resource", + wantNS: "default", + wantName: "my-resource", + }, + { + key: "kube-system/controller", + wantNS: "kube-system", + wantName: "controller", + }, + { + key: "no-namespace", + wantNS: "", + wantName: "no-namespace", + }, + { + key: "ns/name/with/slashes", + wantNS: "ns", + wantName: "name/with/slashes", + }, + { + key: "", + wantNS: "", + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + ns, name := SplitKey(tt.key) + if ns != tt.wantNS { + t.Errorf("namespace = %q, want %q", ns, tt.wantNS) + } + if name != tt.wantName { + t.Errorf("name = %q, want %q", name, tt.wantName) + } + }) + } +} diff --git a/pkg/reconciler/push/controller.go b/pkg/reconciler/push/controller.go index 75762b1..371576c 100644 --- a/pkg/reconciler/push/controller.go +++ b/pkg/reconciler/push/controller.go @@ -35,7 +35,6 @@ func NewController(ctx context.Context, cmw configmap.Watcher) *controller.Impl } gitClient := gitclient.NewFromDynamic(dynClient) - ctx = gitclient.WithClient(ctx, gitClient) // Create dynamic informer for GitPushTransaction. factory := dynamicinformer.NewDynamicSharedInformerFactory(dynClient, 30*time.Second) diff --git a/pkg/reconciler/push/push.go b/pkg/reconciler/push/push.go index a333e98..2010539 100644 --- a/pkg/reconciler/push/push.go +++ b/pkg/reconciler/push/push.go @@ -11,14 +11,17 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/dynamic" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" "knative.dev/pkg/logging" "knative.dev/pkg/reconciler" gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" gitclient "github.com/imjasonh/git-k8s/pkg/client" + "github.com/imjasonh/git-k8s/pkg/reconciler/internal" ) // Reconciler implements the reconcile logic for GitPushTransaction. @@ -31,13 +34,14 @@ type Reconciler struct { func (r *Reconciler) ReconcileKind(ctx context.Context, key string) reconciler.Event { logger := logging.FromContext(ctx) - namespace, name, err := splitKey(key) - if err != nil { - return err - } + namespace, name := internal.SplitKey(key) // Fetch the GitPushTransaction. txn, err := r.gitClient.GitPushTransactions(namespace).Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + logger.Debugf("GitPushTransaction %s/%s no longer exists", namespace, name) + return nil + } if err != nil { return fmt.Errorf("getting GitPushTransaction %s/%s: %w", namespace, name, err) } @@ -158,7 +162,7 @@ func (r *Reconciler) resolveAuth(ctx context.Context, namespace string, repo *gi return nil, fmt.Errorf("getting secret %q: %w", repo.Spec.Auth.SecretRef.Name, err) } - data, found, err := unstructuredNestedStringMap(u.Object, "data") + data, found, err := unstructured.NestedStringMap(u.Object, "data") if err != nil || !found { return nil, fmt.Errorf("reading secret data: found=%v err=%v", found, err) } @@ -181,42 +185,6 @@ func (r *Reconciler) resolveAuth(ctx context.Context, namespace string, repo *gi }, nil } -// unstructuredNestedStringMap extracts a map[string]string from an unstructured object path. -func unstructuredNestedStringMap(obj map[string]interface{}, fields ...string) (map[string]string, bool, error) { - val, found, err := nestedFieldNoCopy(obj, fields...) - if !found || err != nil { - return nil, found, err - } - m, ok := val.(map[string]interface{}) - if !ok { - return nil, false, fmt.Errorf("expected map, got %T", val) - } - result := make(map[string]string, len(m)) - for k, v := range m { - s, ok := v.(string) - if !ok { - continue - } - result[k] = s - } - return result, true, nil -} - -func nestedFieldNoCopy(obj map[string]interface{}, fields ...string) (interface{}, bool, error) { - var val interface{} = obj - for _, field := range fields { - m, ok := val.(map[string]interface{}) - if !ok { - return nil, false, nil - } - val, ok = m[field] - if !ok { - return nil, false, nil - } - } - return val, true, nil -} - // failTransaction marks a GitPushTransaction as Failed with the given message. func (r *Reconciler) failTransaction(ctx context.Context, txn *gitv1alpha1.GitPushTransaction, message string) error { logger := logging.FromContext(ctx) @@ -234,13 +202,14 @@ func (r *Reconciler) failTransaction(ctx context.Context, txn *gitv1alpha1.GitPu // updateBranches updates GitBranch CRs after a successful push. func (r *Reconciler) updateBranches(ctx context.Context, namespace string, txn *gitv1alpha1.GitPushTransaction) error { + // List branches once rather than per-refspec. + branches, err := r.gitClient.GitBranches(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("listing branches: %w", err) + } + now := metav1.Now() for _, rs := range txn.Spec.RefSpecs { - // List branches matching the destination ref. - branches, err := r.gitClient.GitBranches(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return fmt.Errorf("listing branches: %w", err) - } for i := range branches.Items { branch := &branches.Items[i] destRef := fmt.Sprintf("refs/heads/%s", branch.Spec.BranchName) @@ -256,16 +225,6 @@ func (r *Reconciler) updateBranches(ctx context.Context, namespace string, txn * return nil } -// splitKey splits a namespace/name key. -func splitKey(key string) (string, string, error) { - for i := range key { - if key[i] == '/' { - return key[:i], key[i+1:], nil - } - } - return "", key, nil -} - // Ensure Reconciler implements the reconciler interface. var _ reconciler.LeaderAware = (*Reconciler)(nil) diff --git a/pkg/reconciler/push/push_test.go b/pkg/reconciler/push/push_test.go index 73776eb..1da11b6 100644 --- a/pkg/reconciler/push/push_test.go +++ b/pkg/reconciler/push/push_test.go @@ -5,215 +5,6 @@ import ( "testing" ) -func TestSplitKey(t *testing.T) { - tests := []struct { - key string - wantNS string - wantName string - }{ - { - key: "default/my-resource", - wantNS: "default", - wantName: "my-resource", - }, - { - key: "kube-system/controller", - wantNS: "kube-system", - wantName: "controller", - }, - { - key: "no-namespace", - wantNS: "", - wantName: "no-namespace", - }, - { - key: "ns/name/with/slashes", - wantNS: "ns", - wantName: "name/with/slashes", - }, - { - key: "", - wantNS: "", - wantName: "", - }, - } - - for _, tt := range tests { - t.Run(tt.key, func(t *testing.T) { - ns, name, err := splitKey(tt.key) - if err != nil { - t.Fatalf("splitKey(%q) error = %v", tt.key, err) - } - if ns != tt.wantNS { - t.Errorf("namespace = %q, want %q", ns, tt.wantNS) - } - if name != tt.wantName { - t.Errorf("name = %q, want %q", name, tt.wantName) - } - }) - } -} - -func TestNestedFieldNoCopy(t *testing.T) { - tests := []struct { - name string - obj map[string]interface{} - fields []string - want interface{} - found bool - }{ - { - name: "simple field", - obj: map[string]interface{}{ - "key": "value", - }, - fields: []string{"key"}, - want: "value", - found: true, - }, - { - name: "nested field", - obj: map[string]interface{}{ - "level1": map[string]interface{}{ - "level2": "deep-value", - }, - }, - fields: []string{"level1", "level2"}, - want: "deep-value", - found: true, - }, - { - name: "missing field", - obj: map[string]interface{}{ - "key": "value", - }, - fields: []string{"missing"}, - want: nil, - found: false, - }, - { - name: "non-map intermediate", - obj: map[string]interface{}{ - "key": "not-a-map", - }, - fields: []string{"key", "subkey"}, - want: nil, - found: false, - }, - { - name: "empty object", - obj: map[string]interface{}{}, - fields: []string{"key"}, - want: nil, - found: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, found, err := nestedFieldNoCopy(tt.obj, tt.fields...) - if err != nil { - t.Fatalf("nestedFieldNoCopy() error = %v", err) - } - if found != tt.found { - t.Errorf("found = %v, want %v", found, tt.found) - } - if found && got != tt.want { - t.Errorf("value = %v, want %v", got, tt.want) - } - }) - } -} - -func TestUnstructuredNestedStringMap(t *testing.T) { - tests := []struct { - name string - obj map[string]interface{} - fields []string - wantMap map[string]string - wantFound bool - wantErr bool - }{ - { - name: "valid string map", - obj: map[string]interface{}{ - "data": map[string]interface{}{ - "username": base64.StdEncoding.EncodeToString([]byte("admin")), - "password": base64.StdEncoding.EncodeToString([]byte("secret")), - }, - }, - fields: []string{"data"}, - wantMap: map[string]string{ - "username": base64.StdEncoding.EncodeToString([]byte("admin")), - "password": base64.StdEncoding.EncodeToString([]byte("secret")), - }, - wantFound: true, - }, - { - name: "missing data field", - obj: map[string]interface{}{ - "metadata": map[string]interface{}{}, - }, - fields: []string{"data"}, - wantFound: false, - }, - { - name: "non-string values are skipped", - obj: map[string]interface{}{ - "data": map[string]interface{}{ - "string-key": "string-value", - "int-key": 42, - "bool-key": true, - }, - }, - fields: []string{"data"}, - wantMap: map[string]string{ - "string-key": "string-value", - }, - wantFound: true, - }, - { - name: "not a map type", - obj: map[string]interface{}{ - "data": "just-a-string", - }, - fields: []string{"data"}, - wantErr: true, - }, - { - name: "empty map", - obj: map[string]interface{}{ - "data": map[string]interface{}{}, - }, - fields: []string{"data"}, - wantMap: map[string]string{}, - wantFound: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, found, err := unstructuredNestedStringMap(tt.obj, tt.fields...) - if (err != nil) != tt.wantErr { - t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) - } - if found != tt.wantFound { - t.Errorf("found = %v, want %v", found, tt.wantFound) - } - if tt.wantMap != nil { - if len(got) != len(tt.wantMap) { - t.Errorf("map length = %d, want %d", len(got), len(tt.wantMap)) - } - for k, v := range tt.wantMap { - if got[k] != v { - t.Errorf("map[%q] = %q, want %q", k, got[k], v) - } - } - } - }) - } -} - func TestBase64Decoding(t *testing.T) { // Verify that values typically stored in Kubernetes secrets // (base64-encoded via dynamic client) decode correctly. diff --git a/pkg/reconciler/resolver/resolver.go b/pkg/reconciler/resolver/resolver.go index 5bc3d0e..74efb57 100644 --- a/pkg/reconciler/resolver/resolver.go +++ b/pkg/reconciler/resolver/resolver.go @@ -9,6 +9,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/storage/memory" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" @@ -17,6 +18,7 @@ import ( gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" gitclient "github.com/imjasonh/git-k8s/pkg/client" + "github.com/imjasonh/git-k8s/pkg/reconciler/internal" ) // Reconciler implements the reconcile logic for resolving conflicted GitRepoSyncs. @@ -29,13 +31,14 @@ type Reconciler struct { func (r *Reconciler) Reconcile(ctx context.Context, key string) error { logger := logging.FromContext(ctx) - namespace, name, err := splitKey(key) - if err != nil { - return err - } + namespace, name := internal.SplitKey(key) // Fetch the GitRepoSync. syncObj, err := r.gitClient.GitRepoSyncs(namespace).Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + logger.Debugf("GitRepoSync %s/%s no longer exists", namespace, name) + return nil + } if err != nil { return fmt.Errorf("getting GitRepoSync %s/%s: %w", namespace, name, err) } @@ -87,7 +90,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, key string) error { // attemptMerge performs a 3-way merge in memory using go-git. // It compares the trees of both commits against the merge base to detect conflicts. -// Returns the hash of the merge commit if the merge is clean. +// If there are no file-level conflicts (disjoint changes), it builds a merged tree +// that includes changes from both branches and creates a merge commit. func (r *Reconciler) attemptMerge(ctx context.Context, repoURL, commitAHash, commitBHash, mergeBaseHash string) (string, error) { logger := logging.FromContext(ctx) @@ -160,27 +164,29 @@ func (r *Reconciler) attemptMerge(ctx context.Context, repoURL, commitAHash, com } } - // No file-level conflicts. We can create a merge commit. - // Use tree A as the base (it has A's changes), and we need to apply B's changes. - // Since there are no overlapping files, we can use B's tree for non-conflicting merge. - // For a true merge, we'd reconstruct a merged tree. For this file-disjoint case, - // we create a merge commit with A's tree as the result (simplified). - // In production, we'd build a proper merged tree object. - logger.Info("No file-level conflicts detected, creating merge commit") + // No file-level conflicts. Build a merged tree that starts from A's tree + // (which already has A's changes) and applies B's changes on top. + logger.Info("No file-level conflicts detected, building merged tree") + mergedTreeHash, err := buildMergedTree(storer, treeA, treeB, diffB) + if err != nil { + return "", fmt.Errorf("building merged tree: %w", err) + } + + now := time.Now() mergeCommit := &object.Commit{ Author: object.Signature{ Name: "git-k8s-resolver", Email: "resolver@git-k8s.imjasonh.com", - When: time.Now(), + When: now, }, Committer: object.Signature{ Name: "git-k8s-resolver", Email: "resolver@git-k8s.imjasonh.com", - When: time.Now(), + When: now, }, Message: fmt.Sprintf("Merge %s and %s\n\nAutomated merge by git-k8s conflict resolver.", commitAHash[:8], commitBHash[:8]), - TreeHash: treeA.Hash, + TreeHash: mergedTreeHash, ParentHashes: []plumbing.Hash{ hashA, hashB, @@ -200,6 +206,56 @@ func (r *Reconciler) attemptMerge(ctx context.Context, repoURL, commitAHash, com return mergeHash.String(), nil } +// buildMergedTree constructs a tree that contains A's tree with B's non-conflicting +// changes applied. It processes diffB to apply additions, modifications, and deletions +// from branch B onto tree A. +func buildMergedTree(storer *memory.Storage, treeA, treeB *object.Tree, diffB object.Changes) (plumbing.Hash, error) { + // Start with all entries from tree A. + entries := make(map[string]object.TreeEntry) + for _, entry := range treeA.Entries { + entries[entry.Name] = entry + } + + // Apply B's changes. + for _, change := range diffB { + if change.To.Name != "" { + // File added or modified in B: use B's version. + entry, err := treeB.FindEntry(change.To.Name) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("finding entry %q in tree B: %w", change.To.Name, err) + } + entries[change.To.Name] = *entry + } + if change.From.Name != "" && change.To.Name == "" { + // File deleted in B: remove from merged tree. + delete(entries, change.From.Name) + } + if change.From.Name != "" && change.To.Name != "" && change.From.Name != change.To.Name { + // File renamed in B: remove old name. + delete(entries, change.From.Name) + } + } + + // Build sorted tree entries. + sortedEntries := make([]object.TreeEntry, 0, len(entries)) + for _, entry := range entries { + sortedEntries = append(sortedEntries, entry) + } + + mergedTree := &object.Tree{Entries: sortedEntries} + encodedObj := storer.NewEncodedObject() + if err := mergedTree.Encode(encodedObj); err != nil { + return plumbing.ZeroHash, fmt.Errorf("encoding merged tree: %w", err) + } + + hash, err := storer.SetEncodedObject(encodedObj) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("storing merged tree: %w", err) + } + + return hash, nil +} + // changeName extracts the file name from a tree change. func changeName(change *object.Change) string { if change.From.Name != "" { @@ -293,15 +349,6 @@ func (r *Reconciler) markManualIntervention(ctx context.Context, syncObj *gitv1a return err } -func splitKey(key string) (string, string, error) { - for i := range key { - if key[i] == '/' { - return key[:i], key[i+1:], nil - } - } - return "", key, nil -} - var _ reconciler.LeaderAware = (*Reconciler)(nil) func (r *Reconciler) Promote(bkt reconciler.Bucket, enq func(reconciler.Bucket, types.NamespacedName)) error { diff --git a/pkg/reconciler/resolver/resolver_test.go b/pkg/reconciler/resolver/resolver_test.go index 1805a48..28f06df 100644 --- a/pkg/reconciler/resolver/resolver_test.go +++ b/pkg/reconciler/resolver/resolver_test.go @@ -6,50 +6,6 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -func TestSplitKey(t *testing.T) { - tests := []struct { - key string - wantNS string - wantName string - }{ - { - key: "default/my-sync", - wantNS: "default", - wantName: "my-sync", - }, - { - key: "git-system/resolver", - wantNS: "git-system", - wantName: "resolver", - }, - { - key: "just-name", - wantNS: "", - wantName: "just-name", - }, - { - key: "", - wantNS: "", - wantName: "", - }, - } - - for _, tt := range tests { - t.Run(tt.key, func(t *testing.T) { - ns, name, err := splitKey(tt.key) - if err != nil { - t.Fatalf("splitKey(%q) error = %v", tt.key, err) - } - if ns != tt.wantNS { - t.Errorf("namespace = %q, want %q", ns, tt.wantNS) - } - if name != tt.wantName { - t.Errorf("name = %q, want %q", name, tt.wantName) - } - }) - } -} - func TestChangeName(t *testing.T) { tests := []struct { name string diff --git a/pkg/reconciler/sync/sync.go b/pkg/reconciler/sync/sync.go index 10a3f62..b49ee21 100644 --- a/pkg/reconciler/sync/sync.go +++ b/pkg/reconciler/sync/sync.go @@ -3,9 +3,11 @@ package sync import ( "context" "fmt" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/storage/memory" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" @@ -14,6 +16,7 @@ import ( gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" gitclient "github.com/imjasonh/git-k8s/pkg/client" + "github.com/imjasonh/git-k8s/pkg/reconciler/internal" ) // Reconciler implements the reconcile logic for GitRepoSync. @@ -26,13 +29,14 @@ type Reconciler struct { func (r *Reconciler) Reconcile(ctx context.Context, key string) error { logger := logging.FromContext(ctx) - namespace, name, err := splitKey(key) - if err != nil { - return err - } + namespace, name := internal.SplitKey(key) // Fetch the GitRepoSync resource. syncObj, err := r.gitClient.GitRepoSyncs(namespace).Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + logger.Debugf("GitRepoSync %s/%s no longer exists", namespace, name) + return nil + } if err != nil { return fmt.Errorf("getting GitRepoSync %s/%s: %w", namespace, name, err) } @@ -208,15 +212,6 @@ func (r *Reconciler) updateSyncStatus(ctx context.Context, syncObj *gitv1alpha1. return err } -func splitKey(key string) (string, string, error) { - for i := range key { - if key[i] == '/' { - return key[:i], key[i+1:], nil - } - } - return "", key, nil -} - // Ensure Reconciler implements the reconciler interface. var _ reconciler.LeaderAware = (*Reconciler)(nil) @@ -226,4 +221,3 @@ func (r *Reconciler) Promote(bkt reconciler.Bucket, enq func(reconciler.Bucket, func (r *Reconciler) Demote(bkt reconciler.Bucket) { } - diff --git a/pkg/reconciler/sync/sync_test.go b/pkg/reconciler/sync/sync_test.go index a328932..1ca2a85 100644 --- a/pkg/reconciler/sync/sync_test.go +++ b/pkg/reconciler/sync/sync_test.go @@ -1,54 +1 @@ package sync - -import ( - "testing" -) - -func TestSplitKey(t *testing.T) { - tests := []struct { - key string - wantNS string - wantName string - }{ - { - key: "default/my-sync", - wantNS: "default", - wantName: "my-sync", - }, - { - key: "git-system/sync-controller", - wantNS: "git-system", - wantName: "sync-controller", - }, - { - key: "just-name", - wantNS: "", - wantName: "just-name", - }, - { - key: "", - wantNS: "", - wantName: "", - }, - { - key: "ns/name/extra", - wantNS: "ns", - wantName: "name/extra", - }, - } - - for _, tt := range tests { - t.Run(tt.key, func(t *testing.T) { - ns, name, err := splitKey(tt.key) - if err != nil { - t.Fatalf("splitKey(%q) error = %v", tt.key, err) - } - if ns != tt.wantNS { - t.Errorf("namespace = %q, want %q", ns, tt.wantNS) - } - if name != tt.wantName { - t.Errorf("name = %q, want %q", name, tt.wantName) - } - }) - } -}