Skip to content

Commit 0cb411e

Browse files
committed
etcdutl:check cleanup
Signed-off-by: Mustafa Elbehery <[email protected]>
1 parent cb7e990 commit 0cb411e

File tree

2 files changed

+305
-0
lines changed

2 files changed

+305
-0
lines changed

etcdutl/ctl.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func init() {
4848
etcdutl.NewListBucketCommand(),
4949
etcdutl.NewIterateBucketCommand(),
5050
etcdutl.NewHashCommand(),
51+
etcdutl.NewCheckCommand(),
5152
)
5253
}
5354

etcdutl/etcdutl/check_command.go

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Copyright 2025 The etcd Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package etcdutl
16+
17+
import (
18+
"fmt"
19+
20+
"github.com/spf13/cobra"
21+
"go.uber.org/zap"
22+
23+
"go.etcd.io/etcd/client/pkg/v3/fileutil"
24+
"go.etcd.io/etcd/pkg/v3/cobrautl"
25+
"go.etcd.io/etcd/server/v3/etcdserver/api/membership"
26+
"go.etcd.io/etcd/server/v3/etcdserver/api/snap"
27+
"go.etcd.io/etcd/server/v3/etcdserver/api/v2store"
28+
"go.etcd.io/etcd/server/v3/storage/datadir"
29+
"go.etcd.io/raft/v3/raftpb"
30+
)
31+
32+
var checkDataDir string
33+
34+
// NewCheckCommand returns the cobra command for "check".
35+
func NewCheckCommand() *cobra.Command {
36+
cmd := &cobra.Command{
37+
Use: "check <subcommand>",
38+
Short: "Commands for checking properties of the etcd data",
39+
}
40+
41+
cmd.AddCommand(NewCheckV2StoreCommand())
42+
cmd.AddCommand(NewCleanupV2StoreCommand())
43+
44+
return cmd
45+
}
46+
47+
// NewCheckV2StoreCommand returns the cobra command for "check v2store".
48+
func NewCheckV2StoreCommand() *cobra.Command {
49+
cmd := &cobra.Command{
50+
Use: "v2store",
51+
Short: "Check whether v2store contains custom content",
52+
Long: `Check whether the v2store contains any custom content beyond meta-information.
53+
Meta-information includes cluster membership and version data that can be
54+
recovered from the v3 backend. Custom content includes any user-created keys
55+
or auth data that could block upgrade to etcd v3.6.
56+
57+
Exit codes:
58+
0: v2store contains only meta content (safe to upgrade)
59+
1: v2store contains custom content (cleanup required)
60+
2: error occurred during check`,
61+
Run: checkV2StoreCommandFunc,
62+
}
63+
64+
cmd.Flags().StringVar(&checkDataDir, "data-dir", "", "Path to the etcd data directory")
65+
cmd.MarkFlagRequired("data-dir")
66+
cmd.MarkFlagDirname("data-dir")
67+
68+
return cmd
69+
}
70+
71+
// NewCleanupV2StoreCommand returns the cobra command for "cleanup v2store".
72+
func NewCleanupV2StoreCommand() *cobra.Command {
73+
cmd := &cobra.Command{
74+
Use: "cleanup",
75+
Short: "Remove custom content from v2store",
76+
Long: `Remove any custom content from the v2store, keeping only meta-information.
77+
This command will delete all user-created keys and auth data from the v2store,
78+
making it safe to upgrade to etcd v3.6. Only meta-information (cluster
79+
membership and version data) will be preserved.
80+
81+
WARNING: This operation is irreversible. Any custom data in v2store will be
82+
permanently deleted. Make sure you have migrated all important data to v3
83+
before running this command.`,
84+
Run: cleanupV2StoreCommandFunc,
85+
}
86+
87+
cmd.Flags().StringVar(&checkDataDir, "data-dir", "", "Path to the etcd data directory")
88+
cmd.MarkFlagRequired("data-dir")
89+
cmd.MarkFlagDirname("data-dir")
90+
91+
return cmd
92+
}
93+
94+
func checkV2StoreCommandFunc(cmd *cobra.Command, args []string) {
95+
lg := GetLogger()
96+
97+
if !fileutil.Exist(checkDataDir) {
98+
lg.Error("data directory does not exist", zap.String("data-dir", checkDataDir))
99+
cobrautl.ExitWithError(cobrautl.ExitBadArgs, fmt.Errorf("data directory does not exist: %s", checkDataDir))
100+
}
101+
102+
snapDir := datadir.ToSnapDir(checkDataDir)
103+
if !fileutil.Exist(snapDir) {
104+
lg.Info("no snapshot directory found, v2store is empty", zap.String("snap-dir", snapDir))
105+
fmt.Println("v2store is empty (no snapshot directory found)")
106+
return
107+
}
108+
109+
store, err := loadV2Store(lg, checkDataDir)
110+
if err != nil {
111+
lg.Error("failed to load v2store", zap.Error(err))
112+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to load v2store: %w", err))
113+
}
114+
115+
metaOnly, err := membership.IsMetaStoreOnly(store)
116+
if err != nil {
117+
lg.Error("failed to check v2store content", zap.Error(err))
118+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to check v2store content: %w", err))
119+
}
120+
121+
if metaOnly {
122+
fmt.Println("v2store contains only meta content (safe to upgrade)")
123+
lg.Info("v2store check passed: contains only meta content")
124+
} else {
125+
fmt.Println("v2store contains custom content (cleanup required before upgrade)")
126+
lg.Warn("v2store check failed: contains custom content")
127+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("v2store contains custom content"))
128+
}
129+
}
130+
131+
func cleanupV2StoreCommandFunc(cmd *cobra.Command, args []string) {
132+
lg := GetLogger()
133+
134+
if !fileutil.Exist(checkDataDir) {
135+
lg.Error("data directory does not exist", zap.String("data-dir", checkDataDir))
136+
cobrautl.ExitWithError(cobrautl.ExitBadArgs, fmt.Errorf("data directory does not exist: %s", checkDataDir))
137+
}
138+
139+
snapDir := datadir.ToSnapDir(checkDataDir)
140+
if !fileutil.Exist(snapDir) {
141+
lg.Info("no snapshot directory found, v2store is already clean", zap.String("snap-dir", snapDir))
142+
fmt.Println("v2store is already clean (no snapshot directory found)")
143+
return
144+
}
145+
146+
store, err := loadV2Store(lg, checkDataDir)
147+
if err != nil {
148+
lg.Error("failed to load v2store", zap.Error(err))
149+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to load v2store: %w", err))
150+
}
151+
152+
// Check current state
153+
metaOnly, err := membership.IsMetaStoreOnly(store)
154+
if err != nil {
155+
lg.Error("failed to check v2store content", zap.Error(err))
156+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to check v2store content: %w", err))
157+
}
158+
159+
if metaOnly {
160+
fmt.Println("v2store already contains only meta content (no cleanup needed)")
161+
lg.Info("v2store cleanup skipped: already contains only meta content")
162+
return
163+
}
164+
165+
// Perform cleanup
166+
if err := cleanupV2StoreCustomContent(lg, store); err != nil {
167+
lg.Error("failed to cleanup v2store", zap.Error(err))
168+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to cleanup v2store: %w", err))
169+
}
170+
171+
// Save the cleaned store back to snapshot
172+
if err := saveV2Store(lg, checkDataDir, store); err != nil {
173+
lg.Error("failed to save cleaned v2store", zap.Error(err))
174+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to save cleaned v2store: %w", err))
175+
}
176+
177+
// Verify cleanup was successful
178+
verifyMetaOnly, err := membership.IsMetaStoreOnly(store)
179+
if err != nil {
180+
lg.Error("failed to verify cleanup", zap.Error(err))
181+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to verify cleanup: %w", err))
182+
}
183+
184+
if !verifyMetaOnly {
185+
lg.Error("cleanup verification failed: v2store still contains custom content")
186+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("cleanup verification failed"))
187+
}
188+
189+
fmt.Println("v2store cleanup completed successfully")
190+
lg.Info("v2store cleanup completed successfully")
191+
}
192+
193+
// loadV2Store loads the v2store from the latest snapshot in the data directory
194+
func loadV2Store(lg *zap.Logger, dataDir string) (v2store.Store, error) {
195+
snapshot, err := getLatestV2Snapshot(lg, dataDir)
196+
if err != nil {
197+
return nil, fmt.Errorf("failed to get latest snapshot: %w", err)
198+
}
199+
200+
if snapshot == nil {
201+
// No snapshot exists, create empty store
202+
lg.Info("no snapshot found, creating empty v2store")
203+
return v2store.New(), nil
204+
}
205+
206+
// Create store and recover from snapshot
207+
store := v2store.New()
208+
if err := store.Recovery(snapshot.Data); err != nil {
209+
return nil, fmt.Errorf("failed to recover v2store from snapshot: %w", err)
210+
}
211+
212+
lg.Info("successfully loaded v2store from snapshot",
213+
zap.Uint64("snapshot-index", snapshot.Metadata.Index))
214+
return store, nil
215+
}
216+
217+
// cleanupV2StoreCustomContent removes all custom content from v2store, keeping only meta information
218+
func cleanupV2StoreCustomContent(lg *zap.Logger, store v2store.Store) error {
219+
event, err := store.Get("/", true, false)
220+
if err != nil {
221+
return fmt.Errorf("failed to get root node: %w", err)
222+
}
223+
224+
storePrefix := "/0" // membership and version info (keep)
225+
storePermsPrefix := "/2" // auth data (remove custom content)
226+
227+
var deletions []string
228+
229+
for _, n := range event.Node.Nodes {
230+
switch n.Key {
231+
case storePrefix:
232+
// Keep membership and version information - this is meta content
233+
lg.Debug("keeping meta content", zap.String("key", n.Key))
234+
continue
235+
236+
case storePermsPrefix:
237+
// Remove any custom auth content, but preserve empty structure if needed
238+
if n.Nodes.Len() > 0 {
239+
for _, child := range n.Nodes {
240+
if child.Nodes.Len() > 0 {
241+
// This child has custom content, mark for deletion
242+
deletions = append(deletions, child.Key)
243+
lg.Debug("marking auth content for deletion", zap.String("key", child.Key))
244+
}
245+
}
246+
}
247+
continue
248+
249+
default:
250+
// Any other top-level content is custom and should be removed
251+
deletions = append(deletions, n.Key)
252+
lg.Debug("marking custom content for deletion", zap.String("key", n.Key))
253+
}
254+
}
255+
256+
// Perform deletions
257+
for _, key := range deletions {
258+
lg.Info("deleting custom content", zap.String("key", key))
259+
if _, err := store.Delete(key, true, true); err != nil {
260+
lg.Warn("failed to delete key (may not exist)", zap.String("key", key), zap.Error(err))
261+
// Continue with other deletions even if one fails
262+
}
263+
}
264+
265+
lg.Info("completed v2store cleanup", zap.Int("deletions", len(deletions)))
266+
return nil
267+
}
268+
269+
// saveV2Store saves the v2store back to a snapshot
270+
func saveV2Store(lg *zap.Logger, dataDir string, store v2store.Store) error {
271+
snapDir := datadir.ToSnapDir(dataDir)
272+
273+
// Ensure snapshot directory exists
274+
if !fileutil.Exist(snapDir) {
275+
if err := fileutil.CreateDirAll(lg, snapDir); err != nil {
276+
return fmt.Errorf("failed to create snapshot directory: %w", err)
277+
}
278+
}
279+
280+
// Save store data
281+
data, err := store.Save()
282+
if err != nil {
283+
return fmt.Errorf("failed to save v2store data: %w", err)
284+
}
285+
286+
// Create snapshotter and save snapshot
287+
ss := snap.New(lg, snapDir)
288+
289+
// Use a dummy snapshot with index 1 to indicate this is a cleaned snapshot
290+
snapshot := raftpb.Snapshot{
291+
Data: data,
292+
Metadata: raftpb.SnapshotMetadata{
293+
Index: 1,
294+
Term: 1,
295+
},
296+
}
297+
298+
if err := ss.SaveSnap(snapshot); err != nil {
299+
return fmt.Errorf("failed to save snapshot: %w", err)
300+
}
301+
302+
lg.Info("successfully saved cleaned v2store snapshot", zap.String("snap-dir", snapDir))
303+
return nil
304+
}

0 commit comments

Comments
 (0)