diff --git a/etcdutl/ctl.go b/etcdutl/ctl.go index a044547c63c6..dbdc5cc7810d 100644 --- a/etcdutl/ctl.go +++ b/etcdutl/ctl.go @@ -41,6 +41,7 @@ func init() { etcdutl.NewDefragCommand(), etcdutl.NewSnapshotCommand(), etcdutl.NewVersionCommand(), + etcdutl.NewCheckCommand(), ) } diff --git a/etcdutl/etcdutl/check_command.go b/etcdutl/etcdutl/check_command.go new file mode 100644 index 000000000000..fc4b439f5664 --- /dev/null +++ b/etcdutl/etcdutl/check_command.go @@ -0,0 +1,110 @@ +// Copyright 2024 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package etcdutl + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "go.etcd.io/etcd/pkg/v3/cobrautl" + "go.etcd.io/etcd/server/v3/etcdserver" + "go.etcd.io/etcd/server/v3/etcdserver/api/membership" + "go.etcd.io/etcd/server/v3/etcdserver/api/snap" + "go.etcd.io/etcd/server/v3/etcdserver/api/v2store" + "go.etcd.io/etcd/server/v3/wal" +) + +// NewCheckCommand returns the cobra command for "check". +func NewCheckCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "check ", + Short: "commands for checking properties", + } + cmd.AddCommand(NewCheckV2StoreCommand()) + return cmd +} + +var ( + argCheckV2StoreDataDir string +) + +// NewCheckV2StoreCommand returns the cobra command for "check v2store". +func NewCheckV2StoreCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "v2store", + Short: "Check custom content in v2store", + Run: checkV2StoreRunFunc, + } + cmd.Flags().StringVar(&argCheckV2StoreDataDir, "data-dir", "", "Required. A data directory not in use by etcd.") + cmd.MarkFlagRequired("data-dir") + return cmd +} + +func checkV2StoreRunFunc(_ *cobra.Command, _ []string) { + err := checkV2StoreDataDir(argCheckV2StoreDataDir) + if err != nil { + cobrautl.ExitWithError(cobrautl.ExitError, err) + } +} + +func checkV2StoreDataDir(dataDir string) error { + var ( + lg = GetLogger() + + walDir = filepath.Join(dataDir, "member", "wal") + snapDir = filepath.Join(dataDir, "member", "snap") + ) + + walSnaps, err := wal.ValidSnapshotEntries(lg, walDir) + if err != nil { + if errors.Is(err, wal.ErrFileNotFound) { + return nil + } + return err + } + + ss := snap.New(lg, snapDir) + snapshot, err := ss.LoadNewestAvailable(walSnaps) + if err != nil { + if errors.Is(err, snap.ErrNoSnapshot) { + return nil + } + return err + } + if snapshot == nil { + return nil + } + + st := v2store.New(etcdserver.StoreClusterPrefix, etcdserver.StoreKeysPrefix) + + if err := st.Recovery(snapshot.Data); err != nil { + return fmt.Errorf("failed to recover v2store from snapshot: %w", err) + } + return assertNoV2StoreContent(st) +} + +func assertNoV2StoreContent(st v2store.Store) error { + metaOnly, err := membership.IsMetaStoreOnly(st) + if err != nil { + return err + } + if metaOnly { + return nil + } + return fmt.Errorf("detected custom content in v2store") +} diff --git a/tests/e2e/v2store_deprecation_test.go b/tests/e2e/v2store_deprecation_test.go index 52dec549b075..6fe12475b627 100644 --- a/tests/e2e/v2store_deprecation_test.go +++ b/tests/e2e/v2store_deprecation_test.go @@ -97,3 +97,60 @@ func TestV2DeprecationWriteOnlyNoV2Api(t *testing.T) { _, err = proc.Expect("--enable-v2 and --v2-deprecation=write-only are mutually exclusive") assert.NoError(t, err) } + +func TestV2DeprecationCheckCustomContentOffline(t *testing.T) { + e2e.BeforeTest(t) + + t.Run("WithCustomContent", func(t *testing.T) { + dataDirPath := t.TempDir() + + createV2store(t, dataDirPath) + + assertVerifyCheckCustomContentOffline(t, dataDirPath) + }) + + t.Run("WithoutCustomContent", func(t *testing.T) { + dataDirPath := "" + + func() { + cCtx := getDefaultCtlCtx(t) + + cfg := cCtx.cfg + cfg.ClusterSize = 3 + cfg.SnapshotCount = 5 + cfg.EnableV2 = true + + // create a cluster with 3 members + epc, err := e2e.NewEtcdProcessCluster(t, &cfg) + assert.NoError(t, err) + + cCtx.epc = epc + dataDirPath = epc.Procs[0].Config().DataDirPath + + defer func() { + assert.NoError(t, epc.Stop()) + }() + + // create key-values with v3 api + for i := 0; i < 10; i++ { + assert.NoError(t, ctlV3Put(cCtx, fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i), "")) + } + }() + + proc, err := e2e.SpawnCmd([]string{e2e.BinDir + "/etcdutl", "check", "v2store", "--data-dir=" + dataDirPath}, nil) + assert.NoError(t, err) + + proc.Wait() + assert.NotContains(t, proc.Lines(), "detected custom content in v2store") + }) +} + +func assertVerifyCheckCustomContentOffline(t *testing.T, dataDirPath string) { + t.Logf("Checking custom content in v2store - %s", dataDirPath) + + proc, err := e2e.SpawnCmd([]string{e2e.BinDir + "/etcdutl", "check", "v2store", "--data-dir=" + dataDirPath}, nil) + assert.NoError(t, err) + + _, err = proc.Expect("detected custom content in v2store") + assert.NoError(t, err) +}