Skip to content

Commit 5d18e2d

Browse files
Saremoxclaude
andauthored
refactor(soak): split the complex functions of the soak tester and of the cleanup command (#319)
Tests pin the behavior before the refactor: the error texts of the config validation, the defaults, the metric descriptions, the order of the API calls of Delete, fetch, rotateOffline and collect, golden plans of newPlan and planAutoscale, the window of execute, and the output of the cleanup command. The golden files were written on the base code. Mutations.validate, Config.setDefaults, Config.validate, Mutator.execute, Mutator.fetch, newPlan, planResources, planAutoscale, stuckChange, rotateOffline, Observer.apply, Observer.collect, Instance.Delete, metrics.New, run and runCleanup are split into helpers, so that each has a cyclomatic complexity of at most 15, a cognitive complexity of at most 20 and at most 80 lines. The reads of the instance move to mutator/fetch.go and the reads of the pods to observer/pods.go (moved code unchanged), so that each file has fewer than 800 lines. TestDeleteOrder ends its waits after a number of calls, so that it does not depend on the speed of the machine. No change of logic, messages or the order of API calls. Left over: validateVersions, Chaos.validate, validateChain, Observer.observeMaster and validateAutoscale are still above 15. Not tested: the rest of run, which needs a cluster. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE
1 parent db08783 commit 5d18e2d

27 files changed

Lines changed: 4598 additions & 884 deletions

‎cmd/instance/cleanup/cmd.go‎

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -67,40 +67,10 @@ func runCleanup(_ *cobra.Command, _ []string) error {
6767
var totalSize int64
6868

6969
for _, entry := range entries {
70-
if entry.IsDir() {
71-
continue
70+
if size, ok := cleanEntry(entry); ok {
71+
cleaned++
72+
totalSize += size
7273
}
73-
74-
name := entry.Name()
75-
76-
if !strings.HasSuffix(name, ".rdb") {
77-
continue
78-
}
79-
80-
if name == dbFilename {
81-
continue
82-
}
83-
84-
filePath := filepath.Join(dataDir, name)
85-
86-
fileInfo, err := entry.Info()
87-
if err != nil {
88-
fmt.Printf("Warning: failed to get info for %s: %v\n", name, err)
89-
continue
90-
}
91-
92-
if dryRun {
93-
fmt.Printf("Would delete: %s (%d bytes)\n", filePath, fileInfo.Size())
94-
} else {
95-
if err := os.Remove(filePath); err != nil {
96-
fmt.Printf("Warning: failed to remove %s: %v\n", filePath, err)
97-
continue
98-
}
99-
fmt.Printf("Deleted: %s (%d bytes)\n", filePath, fileInfo.Size())
100-
}
101-
102-
cleaned++
103-
totalSize += fileInfo.Size()
10474
}
10575

10676
if cleaned > 0 {
@@ -115,3 +85,42 @@ func runCleanup(_ *cobra.Command, _ []string) error {
11585

11686
return nil
11787
}
88+
89+
// cleanEntry removes the entry if it is a stale RDB file, or prints it in a
90+
// dry run. It returns the size of the file, and whether the file counts as
91+
// cleaned.
92+
func cleanEntry(entry os.DirEntry) (int64, bool) {
93+
if entry.IsDir() {
94+
return 0, false
95+
}
96+
97+
name := entry.Name()
98+
99+
if !strings.HasSuffix(name, ".rdb") {
100+
return 0, false
101+
}
102+
103+
if name == dbFilename {
104+
return 0, false
105+
}
106+
107+
filePath := filepath.Join(dataDir, name)
108+
109+
fileInfo, err := entry.Info()
110+
if err != nil {
111+
fmt.Printf("Warning: failed to get info for %s: %v\n", name, err)
112+
return 0, false
113+
}
114+
115+
if dryRun {
116+
fmt.Printf("Would delete: %s (%d bytes)\n", filePath, fileInfo.Size())
117+
} else {
118+
if err := os.Remove(filePath); err != nil {
119+
fmt.Printf("Warning: failed to remove %s: %v\n", filePath, err)
120+
return 0, false
121+
}
122+
fmt.Printf("Deleted: %s (%d bytes)\n", filePath, fileInfo.Size())
123+
}
124+
125+
return fileInfo.Size(), true
126+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package cleanup
2+
3+
import (
4+
"errors"
5+
"io"
6+
"io/fs"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// stdout returns what f prints on standard output.
16+
func stdout(t *testing.T, f func() error) string {
17+
t.Helper()
18+
r, w, err := os.Pipe()
19+
require.NoError(t, err)
20+
prev := os.Stdout
21+
os.Stdout = w
22+
out := make(chan string)
23+
go func() {
24+
b, _ := io.ReadAll(r)
25+
out <- string(b)
26+
}()
27+
ferr := f()
28+
os.Stdout = prev
29+
require.NoError(t, w.Close())
30+
require.NoError(t, ferr)
31+
return <-out
32+
}
33+
34+
// TestRunCleanupOutput pins the lines that cleanup prints for each entry and
35+
// for the summary.
36+
func TestRunCleanupOutput(t *testing.T) {
37+
dir := t.TempDir()
38+
stale := filepath.Join(dir, "temp-1.rdb")
39+
require.NoError(t, os.WriteFile(stale, []byte("stale"), 0o600))
40+
staleInfo, err := os.Stat(stale)
41+
require.NoError(t, err)
42+
entries := func(string) ([]fs.DirEntry, error) {
43+
return []fs.DirEntry{
44+
fakeDirEntry{name: "dump.rdb", info: staleInfo},
45+
fakeDirEntry{name: "notes.txt", info: staleInfo},
46+
fakeDirEntry{name: "no-info.rdb", infoErr: errors.New("injected info error")},
47+
fakeDirEntry{name: "gone.rdb", info: staleInfo},
48+
fakeDirEntry{name: "temp-1.rdb", info: staleInfo},
49+
}, nil
50+
}
51+
gone := filepath.Join(dir, "gone.rdb")
52+
53+
setCleanupFlags(t, dir, "dump.rdb", true)
54+
readDir = entries
55+
assert.Equal(t, "Warning: failed to get info for no-info.rdb: injected info error\n"+
56+
"Would delete: "+gone+" (5 bytes)\n"+
57+
"Would delete: "+stale+" (5 bytes)\n"+
58+
"Would delete 2 stale RDB file(s), freed 10 bytes\n", stdout(t, func() error { return runCleanup(nil, nil) }))
59+
60+
dryRun = false
61+
assert.Equal(t, "Warning: failed to get info for no-info.rdb: injected info error\n"+
62+
"Warning: failed to remove "+gone+": remove "+gone+": no such file or directory\n"+
63+
"Deleted: "+stale+" (5 bytes)\n"+
64+
"Deleted 1 stale RDB file(s), freed 5 bytes\n", stdout(t, func() error { return runCleanup(nil, nil) }))
65+
66+
readDir = os.ReadDir
67+
assert.Equal(t, "No stale RDB files found\n", stdout(t, func() error { return runCleanup(nil, nil) }))
68+
69+
dataDir = filepath.Join(dir, "missing")
70+
assert.Equal(t, "Data directory "+dataDir+" does not exist, skipping cleanup\n", stdout(t, func() error { return runCleanup(nil, nil) }))
71+
}

‎test/soak/cmd/soak/main.go‎

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,7 @@ func run(configPath, listen string, log *slog.Logger) error {
5656
if err != nil {
5757
return err
5858
}
59-
restCfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
60-
clientcmd.NewDefaultClientConfigLoadingRules(), nil).ClientConfig()
61-
if err != nil {
62-
return err
63-
}
64-
// The observers list pods, EndpointSlices and RedisFailovers for every
65-
// instance every few seconds.
66-
restCfg.QPS, restCfg.Burst = 50, 100
67-
kube, err := kubernetes.NewForConfig(restCfg)
68-
if err != nil {
69-
return err
70-
}
71-
rfs, err := versioned.NewForConfig(restCfg)
59+
kube, rfs, err := clients()
7260
if err != nil {
7361
return err
7462
}
@@ -90,15 +78,7 @@ func run(configPath, listen string, log *slog.Logger) error {
9078
return err
9179
}
9280
lock := &global.Lock{}
93-
observers := map[string]*observer.Observer{}
94-
datas := map[string]*data.Data{}
95-
for _, in := range cfg.Instances {
96-
o := observer.New(in, cfg, kube, rfs, sources[in.Name], lock, m, log)
97-
observers[in.Name] = o
98-
if in.Data != nil {
99-
datas[in.Name] = data.New(in, cfg, o, sources[in.Name], m, log)
100-
}
101-
}
81+
observers, datas := newObservers(cfg, kube, rfs, sources, lock, m, log)
10282

10383
var wg sync.WaitGroup
10484
wg.Go(func() { reportBuildInfo(ctx, kube, cfg.Operator, m, log) })
@@ -125,14 +105,7 @@ func run(configPath, listen string, log *slog.Logger) error {
125105
wg.Go(func() { mu.Run(ctx) })
126106
}
127107
wg.Go(func() { o.Run(ctx) })
128-
// An outage counts for the chaos action, or else the mutation, that
129-
// ran when it started.
130-
r.SetEvent(func() string {
131-
if k := lock.Chaos(); k != "" || mu == nil {
132-
return k
133-
}
134-
return mu.Current()
135-
})
108+
r.SetEvent(outageEvent(lock, mu))
136109
wg.Go(func() { r.Run(ctx, func() []string { return prober.Names(in, o.SentinelPath()) }) })
137110
lane = append(lane, chaos.Instance{Name: in.Name, Namespace: in.Namespace, Observer: o, Data: d})
138111
}
@@ -157,6 +130,54 @@ func run(configPath, listen string, log *slog.Logger) error {
157130
return err
158131
}
159132

133+
// outageEvent returns the event of an outage: the chaos action, or else the
134+
// mutation of mu, that ran when it started.
135+
func outageEvent(lock *global.Lock, mu *mutator.Mutator) func() string {
136+
return func() string {
137+
if k := lock.Chaos(); k != "" || mu == nil {
138+
return k
139+
}
140+
return mu.Current()
141+
}
142+
}
143+
144+
// clients returns the clients of the cluster that the kubeconfig or the
145+
// in-cluster config names.
146+
func clients() (*kubernetes.Clientset, *versioned.Clientset, error) {
147+
restCfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
148+
clientcmd.NewDefaultClientConfigLoadingRules(), nil).ClientConfig()
149+
if err != nil {
150+
return nil, nil, err
151+
}
152+
// The observers list pods, EndpointSlices and RedisFailovers for every
153+
// instance every few seconds.
154+
restCfg.QPS, restCfg.Burst = 50, 100
155+
kube, err := kubernetes.NewForConfig(restCfg)
156+
if err != nil {
157+
return nil, nil, err
158+
}
159+
rfs, err := versioned.NewForConfig(restCfg)
160+
if err != nil {
161+
return nil, nil, err
162+
}
163+
return kube, rfs, nil
164+
}
165+
166+
// newObservers returns the observer of each instance, and the data of each
167+
// instance with data, by instance name.
168+
func newObservers(cfg *config.Config, kube kubernetes.Interface, rfs versioned.Interface, sources map[string]*auth.Source, lock *global.Lock, m *metrics.Metrics, log *slog.Logger) (map[string]*observer.Observer, map[string]*data.Data) {
169+
observers := map[string]*observer.Observer{}
170+
datas := map[string]*data.Data{}
171+
for _, in := range cfg.Instances {
172+
o := observer.New(in, cfg, kube, rfs, sources[in.Name], lock, m, log)
173+
observers[in.Name] = o
174+
if in.Data != nil {
175+
datas[in.Name] = data.New(in, cfg, o, sources[in.Name], m, log)
176+
}
177+
}
178+
return observers, datas
179+
}
180+
160181
// ensureInstances creates each instance with a template that does not exist
161182
// yet, on its configured versions.
162183
func ensureInstances(ctx context.Context, kube kubernetes.Interface, rfs versioned.Interface, cfg *config.Config, log *slog.Logger) (map[string]*instances.Instance, error) {

‎test/soak/cmd/soak/main_test.go‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package main
2+
3+
import (
4+
"log/slog"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/prometheus/client_golang/prometheus"
12+
"k8s.io/client-go/kubernetes/fake"
13+
14+
rffake "github.com/saremox/redis-operator/client/k8s/clientset/versioned/fake"
15+
"github.com/saremox/redis-operator/test/soak/internal/auth"
16+
"github.com/saremox/redis-operator/test/soak/internal/config"
17+
"github.com/saremox/redis-operator/test/soak/internal/global"
18+
"github.com/saremox/redis-operator/test/soak/internal/metrics"
19+
"github.com/saremox/redis-operator/test/soak/internal/mutator"
20+
)
21+
22+
// run returns the error of the config, and of the client config, before it
23+
// starts anything.
24+
func TestRunEarlyErrors(t *testing.T) {
25+
dir := t.TempDir()
26+
log := slog.New(slog.DiscardHandler)
27+
if err := run(filepath.Join(dir, "missing.yaml"), ":0", log); err == nil || !strings.Contains(err.Error(), "no such file or directory") {
28+
t.Errorf("missing config: %v", err)
29+
}
30+
cfg := filepath.Join(dir, "config.yaml")
31+
if err := os.WriteFile(cfg, []byte("instances: [{name: a}]"), 0o600); err != nil {
32+
t.Fatal(err)
33+
}
34+
kubeconfig := filepath.Join(dir, "kubeconfig")
35+
if err := os.WriteFile(kubeconfig, []byte("{"), 0o600); err != nil {
36+
t.Fatal(err)
37+
}
38+
t.Setenv("KUBECONFIG", kubeconfig)
39+
t.Setenv("KUBERNETES_SERVICE_HOST", "")
40+
if err := run(cfg, ":0", log); err == nil || !strings.Contains(err.Error(), kubeconfig) {
41+
t.Errorf("bad kubeconfig: %v", err)
42+
}
43+
}
44+
45+
// clients reads the kubeconfig and raises the rate limit of the observers.
46+
func TestClients(t *testing.T) {
47+
kubeconfig := filepath.Join(t.TempDir(), "kubeconfig")
48+
const body = `apiVersion: v1
49+
kind: Config
50+
clusters: [{name: c, cluster: {server: "https://127.0.0.1:1"}}]
51+
users: [{name: u, user: {token: t}}]
52+
contexts: [{name: x, context: {cluster: c, user: u}}]
53+
current-context: x
54+
`
55+
if err := os.WriteFile(kubeconfig, []byte(body), 0o600); err != nil {
56+
t.Fatal(err)
57+
}
58+
t.Setenv("KUBECONFIG", kubeconfig)
59+
kube, rfs, err := clients()
60+
if err != nil {
61+
t.Fatal(err)
62+
}
63+
if qps := kube.CoreV1().RESTClient().GetRateLimiter().QPS(); qps != 50 || rfs == nil {
64+
t.Errorf("qps %v, rfs %v", qps, rfs)
65+
}
66+
}
67+
68+
// An outage counts for the chaos action, or else the mutation, that ran when
69+
// it started.
70+
func TestOutageEvent(t *testing.T) {
71+
lock := &global.Lock{}
72+
if got := outageEvent(lock, nil)(); got != "" {
73+
t.Errorf("no event: %q", got)
74+
}
75+
if got := outageEvent(lock, &mutator.Mutator{})(); got != "" {
76+
t.Errorf("no mutation: %q", got)
77+
}
78+
lock.SetChaos("operator_restart")
79+
for _, mu := range []*mutator.Mutator{nil, {}} {
80+
if got := outageEvent(lock, mu)(); got != "operator_restart" {
81+
t.Errorf("chaos: %q", got)
82+
}
83+
}
84+
}
85+
86+
// newObservers makes an observer of each instance, and the data of the
87+
// instances with data.
88+
func TestNewObservers(t *testing.T) {
89+
cfg, err := config.Parse([]byte("instances: [{name: a, data: {fill: {}}}, {name: b}]"))
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
sources := map[string]*auth.Source{"a": auth.New(nil), "b": auth.New(nil)}
94+
m := metrics.New(prometheus.NewRegistry(), time.Minute)
95+
observers, datas := newObservers(cfg, fake.NewClientset(), rffake.NewSimpleClientset(), sources, &global.Lock{}, m, slog.New(slog.DiscardHandler))
96+
if len(observers) != 2 || observers["a"] == nil || observers["b"] == nil {
97+
t.Errorf("observers %v", observers)
98+
}
99+
if len(datas) != 1 || datas["a"] == nil {
100+
t.Errorf("data %v", datas)
101+
}
102+
}

0 commit comments

Comments
 (0)