Skip to content

Commit a842267

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

File tree

2 files changed

+306
-0
lines changed

2 files changed

+306
-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: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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 cleanupErr := cleanupV2StoreCustomContent(lg, store); cleanupErr != nil {
167+
lg.Error("failed to cleanup v2store", zap.Error(cleanupErr))
168+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to cleanup v2store: %w", cleanupErr))
169+
}
170+
171+
// Save the cleaned store back to snapshot
172+
if saveErr := saveV2Store(lg, checkDataDir, store); saveErr != nil {
173+
lg.Error("failed to save cleaned v2store", zap.Error(saveErr))
174+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to save cleaned v2store: %w", saveErr))
175+
}
176+
177+
// Verify cleanup was successful
178+
var verifyMetaOnly bool
179+
verifyMetaOnly, err = membership.IsMetaStoreOnly(store)
180+
if err != nil {
181+
lg.Error("failed to verify cleanup", zap.Error(err))
182+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("failed to verify cleanup: %w", err))
183+
}
184+
185+
if !verifyMetaOnly {
186+
lg.Error("cleanup verification failed: v2store still contains custom content")
187+
cobrautl.ExitWithError(cobrautl.ExitError, fmt.Errorf("cleanup verification failed"))
188+
}
189+
190+
fmt.Println("v2store cleanup completed successfully")
191+
lg.Info("v2store cleanup completed successfully")
192+
}
193+
194+
// loadV2Store loads the v2store from the latest snapshot in the data directory
195+
func loadV2Store(lg *zap.Logger, dataDir string) (v2store.Store, error) {
196+
snapshot, err := getLatestV2Snapshot(lg, dataDir)
197+
if err != nil {
198+
return nil, fmt.Errorf("failed to get latest snapshot: %w", err)
199+
}
200+
201+
if snapshot == nil {
202+
// No snapshot exists, create empty store
203+
lg.Info("no snapshot found, creating empty v2store")
204+
return v2store.New(), nil
205+
}
206+
207+
// Create store and recover from snapshot
208+
store := v2store.New()
209+
if err := store.Recovery(snapshot.Data); err != nil {
210+
return nil, fmt.Errorf("failed to recover v2store from snapshot: %w", err)
211+
}
212+
213+
lg.Info("successfully loaded v2store from snapshot",
214+
zap.Uint64("snapshot-index", snapshot.Metadata.Index))
215+
return store, nil
216+
}
217+
218+
// cleanupV2StoreCustomContent removes all custom content from v2store, keeping only meta information
219+
func cleanupV2StoreCustomContent(lg *zap.Logger, store v2store.Store) error {
220+
event, err := store.Get("/", true, false)
221+
if err != nil {
222+
return fmt.Errorf("failed to get root node: %w", err)
223+
}
224+
225+
storePrefix := "/0" // membership and version info (keep)
226+
storePermsPrefix := "/2" // auth data (remove custom content)
227+
228+
var deletions []string
229+
230+
for _, n := range event.Node.Nodes {
231+
switch n.Key {
232+
case storePrefix:
233+
// Keep membership and version information - this is meta content
234+
lg.Debug("keeping meta content", zap.String("key", n.Key))
235+
continue
236+
237+
case storePermsPrefix:
238+
// Remove any custom auth content, but preserve empty structure if needed
239+
if n.Nodes.Len() > 0 {
240+
for _, child := range n.Nodes {
241+
if child.Nodes.Len() > 0 {
242+
// This child has custom content, mark for deletion
243+
deletions = append(deletions, child.Key)
244+
lg.Debug("marking auth content for deletion", zap.String("key", child.Key))
245+
}
246+
}
247+
}
248+
continue
249+
250+
default:
251+
// Any other top-level content is custom and should be removed
252+
deletions = append(deletions, n.Key)
253+
lg.Debug("marking custom content for deletion", zap.String("key", n.Key))
254+
}
255+
}
256+
257+
// Perform deletions
258+
for _, key := range deletions {
259+
lg.Info("deleting custom content", zap.String("key", key))
260+
if _, err := store.Delete(key, true, true); err != nil {
261+
lg.Warn("failed to delete key (may not exist)", zap.String("key", key), zap.Error(err))
262+
// Continue with other deletions even if one fails
263+
}
264+
}
265+
266+
lg.Info("completed v2store cleanup", zap.Int("deletions", len(deletions)))
267+
return nil
268+
}
269+
270+
// saveV2Store saves the v2store back to a snapshot
271+
func saveV2Store(lg *zap.Logger, dataDir string, store v2store.Store) error {
272+
snapDir := datadir.ToSnapDir(dataDir)
273+
274+
// Ensure snapshot directory exists
275+
if !fileutil.Exist(snapDir) {
276+
if err := fileutil.CreateDirAll(lg, snapDir); err != nil {
277+
return fmt.Errorf("failed to create snapshot directory: %w", err)
278+
}
279+
}
280+
281+
// Save store data
282+
data, err := store.Save()
283+
if err != nil {
284+
return fmt.Errorf("failed to save v2store data: %w", err)
285+
}
286+
287+
// Create snapshotter and save snapshot
288+
ss := snap.New(lg, snapDir)
289+
290+
// Use a dummy snapshot with index 1 to indicate this is a cleaned snapshot
291+
snapshot := raftpb.Snapshot{
292+
Data: data,
293+
Metadata: raftpb.SnapshotMetadata{
294+
Index: 1,
295+
Term: 1,
296+
},
297+
}
298+
299+
if err := ss.SaveSnap(snapshot); err != nil {
300+
return fmt.Errorf("failed to save snapshot: %w", err)
301+
}
302+
303+
lg.Info("successfully saved cleaned v2store snapshot", zap.String("snap-dir", snapDir))
304+
return nil
305+
}

0 commit comments

Comments
 (0)