Skip to content

Commit 857068a

Browse files
authored
Merge pull request #4208 from ntnn/fix-etcd-parsing
Fix skipping etcd keys to delete during migration
2 parents a6d13c9 + ab299b0 commit 857068a

3 files changed

Lines changed: 228 additions & 34 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ require (
3333
github.com/spf13/pflag v1.0.10
3434
github.com/stretchr/testify v1.11.1
3535
github.com/xrstf/mockoidc v0.0.0-20250721141841-711cc4e835f6
36+
go.etcd.io/etcd/api/v3 v3.6.8
3637
go.etcd.io/etcd/client/pkg/v3 v3.6.8
3738
go.etcd.io/etcd/client/v3 v3.6.8
3839
go.uber.org/goleak v1.3.1-0.20251210191316-2b7fd8a0d244
@@ -180,7 +181,6 @@ require (
180181
github.com/x448/float16 v0.8.4 // indirect
181182
github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 // indirect
182183
go.etcd.io/bbolt v1.4.3 // indirect
183-
go.etcd.io/etcd/api/v3 v3.6.8 // indirect
184184
go.etcd.io/etcd/pkg/v3 v3.6.8 // indirect
185185
go.etcd.io/etcd/server/v3 v3.6.8 // indirect
186186
go.etcd.io/raft/v3 v3.6.0 // indirect

pkg/reconciler/migration/logicalclustermigration/datacleanup.go

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ func (c *Controller) deleteOriginData(ctx context.Context, lcName logicalcluster
4747
errCh := make(chan error, 1)
4848

4949
go func() {
50-
defer close(errCh)
51-
errCh <- c.scanEtcdKeys(ctx, prefix, lcName, prefixes)
50+
errCh <- scanEtcdKeys(ctx, c.etcdClient, prefix, lcName, kcpetcd.ScanPageSize, prefixes)
5251
}()
5352

5453
var deleteErrors []error
@@ -70,24 +69,20 @@ func (c *Controller) deleteOriginData(ctx context.Context, lcName logicalcluster
7069
return nil
7170
}
7271

73-
// scanEtcdKeys scans etcd and emits per-cluster deletion prefixes for all keys belonging to targetLogicalCluster.
74-
func (c *Controller) scanEtcdKeys(ctx context.Context, prefix string, targetLogicalCluster logicalcluster.Name, out chan<- string) error {
72+
// scanEtcdKeys scans etcd under prefix and emits one cluster-scoped prefix
73+
// per (group, resource[, segment]) family that contains keys belonging to
74+
// targetLogicalCluster.
75+
func scanEtcdKeys(ctx context.Context, kv clientv3.KV, prefix string, targetLogicalCluster logicalcluster.Name, pageSize int64, out chan<- string) error {
7576
defer close(out)
7677

78+
seen := make(map[string]struct{})
79+
7780
key := prefix
7881
for {
79-
// While this logic is pulling batches of keys it only processes
80-
// a batch until it found a matching key; then the prefix for
81-
// this "tree" is written to the channel for the consumer to
82-
// delete the key.
83-
// That means in small trees we might retrieve the same keys
84-
// a few times.
85-
// TODO(ntnn): Arguably it would be better to process the
86-
// full batch but this is simpler for now.
87-
resp, err := c.etcdClient.Get(ctx, key,
82+
resp, err := kv.Get(ctx, key,
8883
clientv3.WithRange(clientv3.GetPrefixRangeEnd(prefix)),
8984
clientv3.WithKeysOnly(),
90-
clientv3.WithLimit(kcpetcd.ScanPageSize),
85+
clientv3.WithLimit(pageSize),
9186
)
9287
if err != nil {
9388
return fmt.Errorf("failed to list etcd keys: %w", err)
@@ -98,35 +93,25 @@ func (c *Controller) scanEtcdKeys(ctx context.Context, prefix string, targetLogi
9893
return err
9994
}
10095

101-
k := string(kv.Key)
102-
split, ok := kcpetcd.SplitKey(prefix, k, targetLogicalCluster)
96+
split, ok := kcpetcd.SplitKey(prefix, string(kv.Key), targetLogicalCluster)
10397
if !ok {
104-
// not a relevant etcd key
98+
continue
99+
}
100+
if split.Cluster != targetLogicalCluster {
105101
continue
106102
}
107103

108104
builtPrefix := split.ClusterPrefix(prefix)
109-
110-
if split.Cluster == targetLogicalCluster {
111-
// key belongs to this cluster, pass the prefix into the channel
112-
out <- builtPrefix
105+
if _, dup := seen[builtPrefix]; dup {
106+
continue
113107
}
114-
115-
// skip to the next subtree
116-
key = split.ClusterPrefix(builtPrefix) + "\x00"
117-
break
108+
seen[builtPrefix] = struct{}{}
109+
out <- builtPrefix
118110
}
119111

120112
if !resp.More {
121113
return nil
122114
}
123-
124-
if !strings.HasSuffix(key, "\x00") {
125-
// Didn't instruct to skip any keys, continue from last key
126-
// in the batch
127-
key = string(resp.Kvs[len(resp.Kvs)-1].Key) + "\x00"
128-
}
129-
130-
continue
115+
key = string(resp.Kvs[len(resp.Kvs)-1].Key) + "\x00"
131116
}
132117
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
Copyright 2026 The kcp Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package logicalclustermigration
18+
19+
import (
20+
"context"
21+
"sort"
22+
"testing"
23+
24+
"github.com/stretchr/testify/require"
25+
"go.etcd.io/etcd/api/v3/mvccpb"
26+
clientv3 "go.etcd.io/etcd/client/v3"
27+
28+
"github.com/kcp-dev/logicalcluster/v3"
29+
)
30+
31+
func TestScanEtcdKeys_singlePageEmitsAllPrefixFamilies(t *testing.T) {
32+
t.Parallel()
33+
34+
prefix := "/registry/"
35+
target := logicalcluster.Name("root:ws")
36+
37+
// All keys fit in one page. Three distinct (group, resource) families
38+
// for the target, plus an unrelated cluster's data that must NOT be
39+
// emitted.
40+
kv := newFakeKV(map[string]string{
41+
"/registry/apps/deployments/root:ws/default/foo": "v",
42+
"/registry/apps/deployments/root:ws/default/bar": "v",
43+
"/registry/core/configmaps/root:ws/default/cm1": "v",
44+
"/registry/core/secrets/root:ws/default/s1": "v",
45+
// other cluster, must not be emitted
46+
"/registry/apps/deployments/root:other/default/x": "v",
47+
"/registry/core/configmaps/root:other/default/y": "v",
48+
})
49+
50+
got, err := collectScan(t, kv, prefix, target, 1000)
51+
require.NoError(t, err)
52+
53+
want := []string{
54+
"/registry/apps/deployments/root:ws",
55+
"/registry/core/configmaps/root:ws",
56+
"/registry/core/secrets/root:ws",
57+
}
58+
sort.Strings(got)
59+
sort.Strings(want)
60+
require.Equal(t, want, got)
61+
}
62+
63+
func TestScanEtcdKeys_multiPageEmitsAllPrefixFamilies(t *testing.T) {
64+
t.Parallel()
65+
66+
prefix := "/registry/"
67+
target := logicalcluster.Name("root:ws")
68+
69+
// 5 distinct prefix families for the target. With pageSize=2, the
70+
// scanner must paginate at least 3 times and still emit every family
71+
// exactly once.
72+
kv := newFakeKV(map[string]string{
73+
"/registry/apps/deployments/root:ws/default/a": "v",
74+
"/registry/apps/deployments/root:ws/default/b": "v",
75+
"/registry/apps/replicasets/root:ws/default/a": "v",
76+
"/registry/core/configmaps/root:ws/default/a": "v",
77+
"/registry/core/secrets/root:ws/default/a": "v",
78+
"/registry/rbac.authorization.k8s.io/roles/root:ws/default/a": "v",
79+
// noise from another cluster
80+
"/registry/apps/deployments/root:other/default/x": "v",
81+
"/registry/core/secrets/root:other/default/y": "v",
82+
})
83+
84+
got, err := collectScan(t, kv, prefix, target, 2)
85+
require.NoError(t, err)
86+
87+
want := []string{
88+
"/registry/apps/deployments/root:ws",
89+
"/registry/apps/replicasets/root:ws",
90+
"/registry/core/configmaps/root:ws",
91+
"/registry/core/secrets/root:ws",
92+
"/registry/rbac.authorization.k8s.io/roles/root:ws",
93+
}
94+
sort.Strings(got)
95+
sort.Strings(want)
96+
require.Equal(t, want, got)
97+
}
98+
99+
func TestScanEtcdKeys_noTargetKeysEmitsNothing(t *testing.T) {
100+
t.Parallel()
101+
102+
prefix := "/registry/"
103+
target := logicalcluster.Name("root:ws")
104+
105+
kv := newFakeKV(map[string]string{
106+
"/registry/apps/deployments/root:other/default/a": "v",
107+
"/registry/core/configmaps/root:other/default/b": "v",
108+
})
109+
110+
got, err := collectScan(t, kv, prefix, target, 1000)
111+
require.NoError(t, err)
112+
require.Empty(t, got)
113+
}
114+
115+
func TestScanEtcdKeys_crdAndIdentityResources(t *testing.T) {
116+
t.Parallel()
117+
118+
prefix := "/registry/"
119+
target := logicalcluster.Name("root:ws")
120+
121+
kv := newFakeKV(map[string]string{
122+
"/registry/widgets.example.io/widgets/customresources/root:ws/default/w1": "v",
123+
"/registry/widgets.example.io/widgets/customresources/root:ws/default/w2": "v",
124+
"/registry/things.example.io/things/abc123def/root:ws/default/t1": "v",
125+
"/registry/apps/deployments/root:ws/default/d1": "v",
126+
})
127+
128+
got, err := collectScan(t, kv, prefix, target, 1000)
129+
require.NoError(t, err)
130+
131+
want := []string{
132+
"/registry/apps/deployments/root:ws",
133+
"/registry/things.example.io/things/abc123def/root:ws",
134+
"/registry/widgets.example.io/widgets/customresources/root:ws",
135+
}
136+
sort.Strings(got)
137+
sort.Strings(want)
138+
require.Equal(t, want, got)
139+
}
140+
141+
// collectScan invokes scanEtcdKeys with the given page size and collects
142+
// every prefix it emits. It exists so each test reads as a single
143+
// require.Equal.
144+
func collectScan(t *testing.T, kv *fakeKV, prefix string, target logicalcluster.Name, pageSize int64) ([]string, error) {
145+
t.Helper()
146+
147+
out := make(chan string)
148+
errCh := make(chan error, 1)
149+
150+
go func() {
151+
errCh <- scanEtcdKeys(context.Background(), kv, prefix, target, pageSize, out)
152+
}()
153+
154+
var got []string
155+
for p := range out {
156+
got = append(got, p)
157+
}
158+
return got, <-errCh
159+
}
160+
161+
// fakeKV is a minimal in-memory fake of clientv3.KV for unit-testing range
162+
// scans. It supports only the operations scanEtcdKeys uses: Get with a
163+
// range end, optional limit, and optional keys-only. Other methods are not
164+
// implemented and panic if called.
165+
type fakeKV struct {
166+
kvs map[string]string
167+
}
168+
169+
func newFakeKV(kvs map[string]string) *fakeKV { return &fakeKV{kvs: kvs} }
170+
171+
func (f *fakeKV) Get(_ context.Context, key string, opts ...clientv3.OpOption) (*clientv3.GetResponse, error) {
172+
op := clientv3.OpGet(key, opts...)
173+
rangeEnd := string(op.RangeBytes())
174+
limit := op.Limit()
175+
176+
keys := make([]string, 0, len(f.kvs))
177+
for k := range f.kvs {
178+
if k >= key && (rangeEnd == "" || k < rangeEnd) {
179+
keys = append(keys, k)
180+
}
181+
}
182+
sort.Strings(keys)
183+
184+
more := false
185+
if limit > 0 && int64(len(keys)) > limit {
186+
keys = keys[:limit]
187+
more = true
188+
}
189+
190+
resp := &clientv3.GetResponse{More: more}
191+
for _, k := range keys {
192+
resp.Kvs = append(resp.Kvs, &mvccpb.KeyValue{Key: []byte(k), Value: []byte(f.kvs[k])})
193+
}
194+
return resp, nil
195+
}
196+
197+
func (f *fakeKV) Put(context.Context, string, string, ...clientv3.OpOption) (*clientv3.PutResponse, error) {
198+
panic("not implemented")
199+
}
200+
func (f *fakeKV) Delete(context.Context, string, ...clientv3.OpOption) (*clientv3.DeleteResponse, error) {
201+
panic("not implemented")
202+
}
203+
func (f *fakeKV) Compact(context.Context, int64, ...clientv3.CompactOption) (*clientv3.CompactResponse, error) {
204+
panic("not implemented")
205+
}
206+
func (f *fakeKV) Do(context.Context, clientv3.Op) (clientv3.OpResponse, error) {
207+
panic("not implemented")
208+
}
209+
func (f *fakeKV) Txn(context.Context) clientv3.Txn { panic("not implemented") }

0 commit comments

Comments
 (0)