Skip to content

Commit 6634073

Browse files
committed
add fakeclint in etcdctl, add UT for alarm command
Signed-off-by: hwdef <[email protected]>
1 parent 584c7cc commit 6634073

File tree

5 files changed

+284
-69
lines changed

5 files changed

+284
-69
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 command
16+
17+
import (
18+
"context"
19+
"strings"
20+
"testing"
21+
22+
"github.com/spf13/cobra"
23+
24+
pb "go.etcd.io/etcd/api/v3/etcdserverpb"
25+
clientv3 "go.etcd.io/etcd/client/v3"
26+
"go.etcd.io/etcd/etcdctl/v3/ctlv3/command/fakeclient"
27+
)
28+
29+
func newTestRoot() *cobra.Command {
30+
root := &cobra.Command{Use: "etcdctl"}
31+
root.AddGroup(NewKVGroup(), NewClusterMaintenanceGroup(), NewConcurrencyGroup(), NewAuthenticationGroup(), NewUtilityGroup())
32+
RegisterGlobalFlags(root)
33+
return root
34+
}
35+
36+
func TestAlarmList_PrintsAlarms(t *testing.T) {
37+
var gotReq int
38+
fc := &fakeclient.Client{
39+
AlarmListFn: func(ctx context.Context) (*clientv3.AlarmResponse, error) {
40+
gotReq++
41+
resp := clientv3.AlarmResponse{
42+
Alarms: []*pb.AlarmMember{
43+
{MemberID: 1, Alarm: pb.AlarmType_NOSPACE},
44+
{MemberID: 2, Alarm: pb.AlarmType_CORRUPT},
45+
},
46+
}
47+
return &resp, nil
48+
},
49+
}
50+
root := newTestRoot()
51+
root.SetContext(WithClient(context.Background(), fakeclient.WrapAsClientV3(fc)))
52+
root.AddCommand(NewAlarmCommand())
53+
root.SetArgs([]string{"alarm", "list"})
54+
55+
out := withStdoutCapture(t, func() { _ = root.Execute() })
56+
57+
if gotReq != 1 {
58+
t.Fatalf("expected AlarmList to be called once, got %d", gotReq)
59+
}
60+
if !strings.Contains(out, "NOSPACE") || !strings.Contains(out, "CORRUPT") {
61+
t.Fatalf("unexpected output: %q", out)
62+
}
63+
}
64+
65+
func TestAlarmDisarm_DisarmsAll(t *testing.T) {
66+
var disarmCalled int
67+
fc := &fakeclient.Client{
68+
AlarmDisarmFn: func(ctx context.Context, m *clientv3.AlarmMember) (*clientv3.AlarmResponse, error) {
69+
disarmCalled++
70+
return &clientv3.AlarmResponse{Alarms: []*pb.AlarmMember{}}, nil
71+
},
72+
}
73+
root := newTestRoot()
74+
root.SetContext(WithClient(context.Background(), fakeclient.WrapAsClientV3(fc)))
75+
root.AddCommand(NewAlarmCommand())
76+
root.SetArgs([]string{"alarm", "disarm"})
77+
78+
_ = withStdoutCapture(t, func() { _ = root.Execute() })
79+
80+
if disarmCalled != 1 {
81+
t.Fatalf("expected disarm to be called, got %d", disarmCalled)
82+
}
83+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 fakeclient
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"io"
21+
22+
clientv3 "go.etcd.io/etcd/client/v3"
23+
)
24+
25+
// Client is a lightweight fake that satisfies the small subset of etcdctl usage.
26+
// It embeds function fields for behaviors; unimplemented methods return error.
27+
// This keeps the fake reusable across most commands by stubbing only used calls.
28+
type Client struct {
29+
AlarmListFn func(ctx context.Context) (*clientv3.AlarmResponse, error)
30+
AlarmDisarmFn func(ctx context.Context, m *clientv3.AlarmMember) (*clientv3.AlarmResponse, error)
31+
}
32+
33+
// WrapAsClientV3 constructs a minimal *clientv3.Client using NewCtxClient and attaches fake Maintenance.
34+
func WrapAsClientV3(fc *Client) *clientv3.Client {
35+
c := clientv3.NewCtxClient(context.Background())
36+
c.Maintenance = &maintenance{fake: fc}
37+
return c
38+
}
39+
40+
type maintenance struct{ fake *Client }
41+
42+
func (m *maintenance) AlarmList(ctx context.Context) (*clientv3.AlarmResponse, error) {
43+
if m.fake.AlarmListFn != nil {
44+
return m.fake.AlarmListFn(ctx)
45+
}
46+
return &clientv3.AlarmResponse{}, nil
47+
}
48+
49+
func (m *maintenance) AlarmDisarm(ctx context.Context, am *clientv3.AlarmMember) (*clientv3.AlarmResponse, error) {
50+
if m.fake.AlarmDisarmFn != nil {
51+
return m.fake.AlarmDisarmFn(ctx, am)
52+
}
53+
return &clientv3.AlarmResponse{}, nil
54+
}
55+
56+
func (m *maintenance) Defragment(ctx context.Context, endpoint string) (*clientv3.DefragmentResponse, error) {
57+
var resp clientv3.DefragmentResponse
58+
return &resp, nil
59+
}
60+
61+
func (m *maintenance) Status(ctx context.Context, endpoint string) (*clientv3.StatusResponse, error) {
62+
var resp clientv3.StatusResponse
63+
return &resp, nil
64+
}
65+
66+
func (m *maintenance) HashKV(ctx context.Context, endpoint string, rev int64) (*clientv3.HashKVResponse, error) {
67+
var resp clientv3.HashKVResponse
68+
return &resp, nil
69+
}
70+
71+
func (m *maintenance) SnapshotWithVersion(ctx context.Context) (*clientv3.SnapshotResponse, error) {
72+
return &clientv3.SnapshotResponse{Snapshot: io.NopCloser(bytes.NewReader(nil))}, nil
73+
}
74+
75+
func (m *maintenance) Snapshot(ctx context.Context) (io.ReadCloser, error) {
76+
return io.NopCloser(bytes.NewReader(nil)), nil
77+
}
78+
79+
func (m *maintenance) MoveLeader(ctx context.Context, transfereeID uint64) (*clientv3.MoveLeaderResponse, error) {
80+
var resp clientv3.MoveLeaderResponse
81+
return &resp, nil
82+
}
83+
84+
func (m *maintenance) Downgrade(ctx context.Context, action clientv3.DowngradeAction, version string) (*clientv3.DowngradeResponse, error) {
85+
var resp clientv3.DowngradeResponse
86+
return &resp, nil
87+
}

etcdctl/ctlv3/command/global.go

Lines changed: 69 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package command
1616

1717
import (
18+
"context"
1819
"errors"
1920
"fmt"
2021
"io"
@@ -30,39 +31,11 @@ import (
3031

3132
"go.etcd.io/etcd/client/pkg/v3/logutil"
3233
"go.etcd.io/etcd/client/pkg/v3/srv"
33-
"go.etcd.io/etcd/client/pkg/v3/transport"
3434
clientv3 "go.etcd.io/etcd/client/v3"
3535
"go.etcd.io/etcd/pkg/v3/cobrautl"
3636
"go.etcd.io/etcd/pkg/v3/flags"
3737
)
3838

39-
// GlobalFlags are flags that defined globally
40-
// and are inherited to all sub-commands.
41-
type GlobalFlags struct {
42-
Insecure bool
43-
InsecureSkipVerify bool
44-
InsecureDiscovery bool
45-
Endpoints []string
46-
DialTimeout time.Duration
47-
CommandTimeOut time.Duration
48-
KeepAliveTime time.Duration
49-
KeepAliveTimeout time.Duration
50-
MaxCallSendMsgSize int
51-
MaxCallRecvMsgSize int
52-
DNSClusterServiceName string
53-
54-
TLS transport.TLSInfo
55-
56-
OutputFormat string
57-
IsHex bool
58-
59-
User string
60-
Password string
61-
Token string
62-
63-
Debug bool
64-
}
65-
6639
type discoveryCfg struct {
6740
domain string
6841
insecure bool
@@ -71,6 +44,68 @@ type discoveryCfg struct {
7144

7245
var display printer = &simplePrinter{}
7346

47+
var newClientFunc = clientv3.New
48+
49+
func RegisterGlobalFlags(cmd *cobra.Command) {
50+
cmd.PersistentFlags().StringSlice("endpoints", []string{"127.0.0.1:2379"}, "gRPC endpoints")
51+
cmd.PersistentFlags().Bool("debug", false, "enable client-side debug logging")
52+
cmd.PersistentFlags().StringP("write-out", "w", "simple", "set the output format (fields, json, protobuf, simple, table)")
53+
cmd.PersistentFlags().Bool("hex", false, "print byte strings as hex encoded strings")
54+
cmd.PersistentFlags().Duration("dial-timeout", 2*time.Second, "dial timeout for client connections")
55+
cmd.PersistentFlags().Duration("command-timeout", 5*time.Second, "timeout for short running command (excluding dial timeout)")
56+
cmd.PersistentFlags().Duration("keepalive-time", 2*time.Second, "keepalive time for client connections")
57+
cmd.PersistentFlags().Duration("keepalive-timeout", 6*time.Second, "keepalive timeout for client connections")
58+
cmd.PersistentFlags().Int("max-request-bytes", 0, "client-side request send limit in bytes (if 0, it defaults to 2.0 MiB (2 * 1024 * 1024).)")
59+
cmd.PersistentFlags().Int("max-recv-bytes", 0, "client-side response receive limit in bytes (if 0, it defaults to \"math.MaxInt32\")")
60+
cmd.PersistentFlags().Bool("insecure-transport", true, "disable transport security for client connections")
61+
cmd.PersistentFlags().Bool("insecure-discovery", true, "accept insecure SRV records describing cluster endpoints")
62+
cmd.PersistentFlags().Bool("insecure-skip-tls-verify", false, "skip server certificate verification (CAUTION: this option should be enabled only for testing purposes)")
63+
cmd.PersistentFlags().String("cert", "", "identify secure client using this TLS certificate file")
64+
cmd.PersistentFlags().String("key", "", "identify secure client using this TLS key file")
65+
cmd.PersistentFlags().String("cacert", "", "verify certificates of TLS-enabled secure servers using this CA bundle")
66+
cmd.PersistentFlags().String("auth-jwt-token", "", "JWT token used for authentication (if this option is used, --user and --password should not be set)")
67+
cmd.PersistentFlags().String("user", "", "username[:password] for authentication (prompt if password is not supplied)")
68+
cmd.PersistentFlags().String("password", "", "password for authentication (if this option is used, --user option shouldn't include password)")
69+
cmd.PersistentFlags().StringP("discovery-srv", "d", "", "domain name to query for SRV records describing cluster endpoints")
70+
cmd.PersistentFlags().String("discovery-srv-name", "", "service name to query when using DNS discovery")
71+
}
72+
73+
type ClientFactory func(clientv3.Config) (*clientv3.Client, error)
74+
75+
type clientFactoryKey struct{}
76+
77+
// WithClientFactory attaches a custom client factory to the provided context.
78+
// Tests can inject fakes without mutating the global client constructor.
79+
func WithClientFactory(ctx context.Context, factory ClientFactory) context.Context {
80+
if ctx == nil {
81+
ctx = context.Background()
82+
}
83+
if factory == nil {
84+
return ctx
85+
}
86+
return context.WithValue(ctx, clientFactoryKey{}, factory)
87+
}
88+
89+
func WithClient(ctx context.Context, cli *clientv3.Client) context.Context {
90+
if cli == nil {
91+
return ctx
92+
}
93+
return WithClientFactory(ctx, func(clientv3.Config) (*clientv3.Client, error) {
94+
return cli, nil
95+
})
96+
}
97+
98+
func clientFactoryFromCmd(cmd *cobra.Command) ClientFactory {
99+
if cmd != nil {
100+
if ctx := cmd.Context(); ctx != nil {
101+
if factory, ok := ctx.Value(clientFactoryKey{}).(ClientFactory); ok && factory != nil {
102+
return factory
103+
}
104+
}
105+
}
106+
return newClientFunc
107+
}
108+
74109
func initDisplayFromCmd(cmd *cobra.Command) {
75110
isHex, err := cmd.Flags().GetBool("hex")
76111
if err != nil {
@@ -153,17 +188,21 @@ func mustClientCfgFromCmd(cmd *cobra.Command) *clientv3.Config {
153188

154189
func mustClientFromCmd(cmd *cobra.Command) *clientv3.Client {
155190
cfg := clientConfigFromCmd(cmd)
156-
return mustClient(cfg)
191+
return mustClientWithFactory(cmd, cfg)
157192
}
158193

159194
func mustClient(cc *clientv3.ConfigSpec) *clientv3.Client {
195+
return mustClientWithFactory(nil, cc)
196+
}
197+
198+
func mustClientWithFactory(cmd *cobra.Command, cc *clientv3.ConfigSpec) *clientv3.Client {
160199
lg, _ := logutil.CreateDefaultZapLogger(zap.InfoLevel)
161200
cfg, err := clientv3.NewClientConfig(cc, lg)
162201
if err != nil {
163202
cobrautl.ExitWithError(cobrautl.ExitBadArgs, err)
164203
}
165204

166-
client, err := clientv3.New(*cfg)
205+
client, err := clientFactoryFromCmd(cmd)(*cfg)
167206
if err != nil {
168207
cobrautl.ExitWithError(cobrautl.ExitBadConnection, err)
169208
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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 command
16+
17+
import (
18+
"bytes"
19+
"os"
20+
"testing"
21+
)
22+
23+
func withStdoutCapture(t *testing.T, fn func()) string {
24+
t.Helper()
25+
old := os.Stdout
26+
r, w, err := os.Pipe()
27+
if err != nil {
28+
t.Fatalf("failed to create pipe: %v", err)
29+
}
30+
os.Stdout = w
31+
defer func() { os.Stdout = old }()
32+
33+
fn()
34+
35+
w.Close()
36+
var buf bytes.Buffer
37+
_, _ = buf.ReadFrom(r)
38+
return buf.String()
39+
}

0 commit comments

Comments
 (0)