From 9f4b40a82ef4713a9dc3b646747932099a9273e2 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Tue, 17 Mar 2026 10:31:11 +1000 Subject: [PATCH 01/20] Add commands for event and secret data ingestion --- .../runkperf/commands/data/events/event.go | 410 ++++++++++++++++++ .../runkperf/commands/data/secrets/secret.go | 389 +++++++++++++++++ contrib/cmd/runkperf/commands/root.go | 4 + 3 files changed, 803 insertions(+) create mode 100644 contrib/cmd/runkperf/commands/data/events/event.go create mode 100644 contrib/cmd/runkperf/commands/data/secrets/secret.go diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go new file mode 100644 index 0000000..ccdc068 --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -0,0 +1,410 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package events + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "os" + "strings" + "text/tabwriter" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/Azure/kperf/cmd/kperf/commands/utils" + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + + "github.com/urfave/cli" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +var appLabel = "runkperf" + +var Command = cli.Command{ + Name: "event", + ShortName: "ev", + Usage: "Manage events", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "kubeconfig", + Usage: "Path to the kubeconfig file", + Value: utils.DefaultKubeConfigPath, + }, + cli.StringFlag{ + Name: "namespace", + Usage: "Namespace to use with commands. If the namespace does not exist, it will be created.", + Value: "default", + }, + }, + Subcommands: []cli.Command{ + eventAddCommand, + eventDelCommand, + eventListCommand, + }, +} + +var eventAddCommand = cli.Command{ + Name: "add", + Usage: "Add events set", + ArgsUsage: "NAME of the events set", + Flags: []cli.Flag{ + cli.IntFlag{ + Name: "total", + Usage: "Total number of events to create", + Value: 10, + }, + cli.IntFlag{ + Name: "group-size", + Usage: "Number of events to create in parallel per batch", + Value: 30, + }, + cli.StringFlag{ + Name: "reason", + Usage: "The reason string for the events (e.g. ScaleUp, FailedScheduling)", + Value: "BenchmarkEvent", + }, + cli.StringFlag{ + Name: "type", + Usage: "The type of the events: Normal or Warning", + Value: "Normal", + }, + cli.IntFlag{ + Name: "message-size", + Usage: "The size of the event message in bytes (default targets ~5KB total event size; k8s metadata overhead is ~500-700 bytes)", + Value: 5000, + }, + cli.StringFlag{ + Name: "involved-object-kind", + Usage: "The kind of the involved object (e.g. Pod, Node, Deployment)", + Value: "Pod", + }, + cli.StringFlag{ + Name: "involved-object-name", + Usage: "The name of the involved object. If empty, a name will be generated.", + Value: "", + }, + cli.Float64Flag{ + Name: "qps", + Usage: "QPS for the Kubernetes client rate limiter to control event operations", + Value: 100, + }, + cli.IntFlag{ + Name: "burst", + Usage: "Burst for the Kubernetes client rate limiter to control event operations", + Value: 200, + }, + }, + Action: func(cliCtx *cli.Context) error { + if cliCtx.NArg() != 1 { + return fmt.Errorf("required only one argument as events set name: %v", cliCtx.Args()) + } + evName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(evName) == 0 { + return fmt.Errorf("required non-empty event set name") + } + + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + namespace := cliCtx.GlobalString("namespace") + total := cliCtx.Int("total") + groupSize := cliCtx.Int("group-size") + reason := cliCtx.String("reason") + eventType := cliCtx.String("type") + messageSize := cliCtx.Int("message-size") + involvedObjectKind := cliCtx.String("involved-object-kind") + involvedObjectName := cliCtx.String("involved-object-name") + qps := float32(cliCtx.Float64("qps")) + burst := cliCtx.Int("burst") + + if total <= 0 { + return fmt.Errorf("total must be greater than 0") + } + if groupSize <= 0 { + return fmt.Errorf("group-size must be greater than 0") + } + if groupSize > total { + return fmt.Errorf("group-size must be less than or equal to total") + } + if eventType != "Normal" && eventType != "Warning" { + return fmt.Errorf("type must be either Normal or Warning") + } + if messageSize <= 0 { + return fmt.Errorf("message-size must be greater than 0") + } + + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) + if err != nil { + return err + } + + err = prepareNamespace(clientset, namespace) + if err != nil { + return err + } + + err = createEvents(clientset, namespace, evName, total, groupSize, reason, eventType, messageSize, involvedObjectKind, involvedObjectName) + if err != nil { + return err + } + fmt.Printf("Created %d events (set=%s, reason=%s, type=%s) in namespace %s\n", + total, evName, reason, eventType, namespace) + return nil + }, +} + +var eventDelCommand = cli.Command{ + Name: "delete", + ShortName: "del", + ArgsUsage: "NAME", + Usage: "Delete an events set", + Action: func(cliCtx *cli.Context) error { + if cliCtx.NArg() != 1 { + return fmt.Errorf("required only one events set name") + } + evName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(evName) == 0 { + return fmt.Errorf("required non-empty events set name") + } + + namespace := cliCtx.GlobalString("namespace") + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + labelSelector := fmt.Sprintf("app=%s,evName=%s", appLabel, evName) + + clientset, err := data.NewClientset(kubeCfgPath) + if err != nil { + return err + } + + err = deleteEvents(clientset, labelSelector, namespace) + if err != nil { + return err + } + + fmt.Printf("Deleted events set %s in %s namespace\n", evName, namespace) + return nil + }, +} + +var eventListCommand = cli.Command{ + Name: "list", + Usage: "List generated event sets. Lists all if no arguments are given; otherwise, provide event set names separated by spaces.", + Action: func(cliCtx *cli.Context) error { + namespace := cliCtx.GlobalString("namespace") + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + clientset, err := data.NewClientset(kubeCfgPath) + if err != nil { + return err + } + + const ( + minWidth = 1 + tabWidth = 12 + padding = 3 + padChar = ' ' + flags = 0 + ) + tw := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags) + fmt.Fprintln(tw, "NAME\tTYPE\tREASON\tTOTAL\t") + + var labelSelector string + if cliCtx.NArg() == 0 { + labelSelector = fmt.Sprintf("app=%s,evName", appLabel) + } else { + args := cliCtx.Args() + namesStr := strings.Join(args, ",") + labelSelector = fmt.Sprintf("app=%s, evName in (%s)", appLabel, namesStr) + } + + evMap, err := listEventsByName(clientset, labelSelector, namespace) + if err != nil { + return err + } + + for name, info := range evMap { + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\n", + name, + info.eventType, + info.reason, + info.total, + ) + } + return tw.Flush() + }, +} + +type eventSetInfo struct { + eventType string + reason string + total int +} + +func prepareNamespace(clientset *kubernetes.Clientset, namespace string) error { + if namespace == "" { + return fmt.Errorf("namespace cannot be empty") + } + + if namespace == "default" { + return nil + } + + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + }, metav1.CreateOptions{}) + if err != nil { + if errors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("failed to create namespace %s: %v", namespace, err) + } + return nil +} + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randString(n int) (string, error) { + if n <= 0 { + return "", fmt.Errorf("length must be positive") + } + + b := make([]rune, n) + for i := range b { + random, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) + if err != nil { + return "", fmt.Errorf("error generating random number: %w", err) + } + b[i] = letterRunes[int(random.Int64())] + } + return string(b), nil +} + +func createEvents(clientset *kubernetes.Clientset, namespace, evName string, total, groupSize int, reason, eventType string, messageSize int, involvedObjectKind, involvedObjectName string) error { + now := time.Now() + + for i := 0; i < total; i += groupSize { + g := new(errgroup.Group) + for j := i; j < i+groupSize && j < total; j++ { + idx := j + g.Go(func() error { + name := fmt.Sprintf("%s-ev-%s-%d", appLabel, evName, idx) + + message, err := randString(messageSize) + if err != nil { + return fmt.Errorf("failed to generate random message for event %s: %v", name, err) + } + + objName := involvedObjectName + if objName == "" { + objName = fmt.Sprintf("%s-obj-%d", evName, idx) + } + + event := &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "app": appLabel, + "evName": evName, + }, + }, + InvolvedObject: corev1.ObjectReference{ + Kind: involvedObjectKind, + Name: objName, + Namespace: namespace, + }, + Reason: reason, + Message: message, + Type: eventType, + Count: 1, + FirstTimestamp: metav1.NewTime(now), + LastTimestamp: metav1.NewTime(now), + Source: corev1.EventSource{ + Component: "runkperf-bench", + }, + } + + _, err = clientset.CoreV1().Events(namespace).Create(context.TODO(), event, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create event %s: %v", name, err) + } + return nil + }) + } + if err := g.Wait(); err != nil { + return err + } + } + return nil +} + +func deleteEvents(clientset *kubernetes.Clientset, labelSelector, namespace string) error { + eventList, err := listEvents(clientset, labelSelector, namespace) + if err != nil { + return err + } + + if len(eventList.Items) == 0 { + return fmt.Errorf("no events set found in namespace: %s", namespace) + } + + n, batch := len(eventList.Items), 300 + for i := 0; i < n; i += batch { + g := new(errgroup.Group) + for j := i; j < i+batch && j < n; j++ { + idx := j + g.Go(func() error { + err := clientset.CoreV1().Events(namespace).Delete(context.TODO(), eventList.Items[idx].Name, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete event %s: %v", eventList.Items[idx].Name, err) + } + return nil + }) + } + if err := g.Wait(); err != nil { + return err + } + } + return nil +} + +func listEvents(clientset *kubernetes.Clientset, labelSelector, namespace string) (*corev1.EventList, error) { + eventList, err := clientset.CoreV1().Events(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: labelSelector}) + if err != nil { + return nil, fmt.Errorf("failed to list events: %v", err) + } + return eventList, nil +} + +func listEventsByName(clientset *kubernetes.Clientset, labelSelector, namespace string) (map[string]*eventSetInfo, error) { + eventList, err := listEvents(clientset, labelSelector, namespace) + if err != nil { + return nil, err + } + + evMap := make(map[string]*eventSetInfo) + for _, ev := range eventList.Items { + name, ok := ev.Labels["evName"] + if !ok { + continue + } + + info, ok := evMap[name] + if !ok { + info = &eventSetInfo{ + eventType: ev.Type, + reason: ev.Reason, + } + evMap[name] = info + } + info.total++ + } + return evMap, nil +} diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go new file mode 100644 index 0000000..dff497b --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package secrets + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "os" + "strconv" + "strings" + "text/tabwriter" + + "golang.org/x/sync/errgroup" + + "github.com/Azure/kperf/cmd/kperf/commands/utils" + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + + "github.com/urfave/cli" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +var appLabel = "runkperf" + +var Command = cli.Command{ + Name: "secret", + ShortName: "sec", + Usage: "Manage secrets", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "kubeconfig", + Usage: "Path to the kubeconfig file", + Value: utils.DefaultKubeConfigPath, + }, + cli.StringFlag{ + Name: "namespace", + Usage: "Namespace to use with commands. If the namespace does not exist, it will be created.", + Value: "default", + }, + }, + Subcommands: []cli.Command{ + secretAddCommand, + secretDelCommand, + secretListCommand, + }, +} + +var secretAddCommand = cli.Command{ + Name: "add", + Usage: "Add secrets set", + ArgsUsage: "NAME of the secrets set", + Flags: []cli.Flag{ + cli.IntFlag{ + Name: "size", + Usage: "The size of each secret value (Unit: Byte)", + Value: 100, + }, + cli.IntFlag{ + Name: "group-size", + Usage: "The number of secrets to create in parallel per batch", + Value: 30, + }, + cli.IntFlag{ + Name: "total", + Usage: "Total number of secrets to create", + Value: 10, + }, + cli.Float64Flag{ + Name: "qps", + Usage: "QPS for the Kubernetes client rate limiter to control secret operations", + Value: 100, + }, + cli.IntFlag{ + Name: "burst", + Usage: "Burst for the Kubernetes client rate limiter to control secret operations", + Value: 200, + }, + }, + Action: func(cliCtx *cli.Context) error { + if cliCtx.NArg() != 1 { + return fmt.Errorf("required only one argument as secrets set name: %v", cliCtx.Args()) + } + secName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(secName) == 0 { + return fmt.Errorf("required non-empty secret set name") + } + + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + size := cliCtx.Int("size") + groupSize := cliCtx.Int("group-size") + total := cliCtx.Int("total") + + err := checkSecretParams(size, groupSize, total) + if err != nil { + return err + } + + namespace := cliCtx.GlobalString("namespace") + qps := float32(cliCtx.Float64("qps")) + burst := cliCtx.Int("burst") + + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) + if err != nil { + return err + } + + err = prepareNamespace(clientset, namespace) + if err != nil { + return err + } + + err = createSecrets(clientset, namespace, secName, size, groupSize, total) + if err != nil { + return err + } + fmt.Printf("Created secret %s with size %d B, group-size %d, total %d\n", secName, size, groupSize, total) + return nil + }, +} + +var secretDelCommand = cli.Command{ + Name: "delete", + ShortName: "del", + ArgsUsage: "NAME", + Usage: "Delete a secrets set", + Action: func(cliCtx *cli.Context) error { + if cliCtx.NArg() != 1 { + return fmt.Errorf("required only one secrets set name") + } + secName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(secName) == 0 { + return fmt.Errorf("required non-empty secrets set name") + } + + namespace := cliCtx.GlobalString("namespace") + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + labelSelector := fmt.Sprintf("app=%s,secName=%s", appLabel, secName) + + clientset, err := data.NewClientset(kubeCfgPath) + if err != nil { + return err + } + + err = deleteSecrets(clientset, labelSelector, namespace) + if err != nil { + return err + } + + fmt.Printf("Deleted secrets set %s in %s namespace\n", secName, namespace) + return nil + }, +} + +var secretListCommand = cli.Command{ + Name: "list", + Usage: "List generated secrets. Lists all if no arguments are given; otherwise, provide secret set names separated by spaces.", + ArgsUsage: "NAME", + Action: func(cliCtx *cli.Context) error { + namespace := cliCtx.GlobalString("namespace") + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + clientset, err := data.NewClientset(kubeCfgPath) + if err != nil { + return err + } + + const ( + minWidth = 1 + tabWidth = 12 + padding = 3 + padChar = ' ' + flags = 0 + ) + tw := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags) + fmt.Fprintln(tw, "NAME\tSIZE\tGROUP_SIZE\tTOTAL\t") + + var labelSelector string + if cliCtx.NArg() == 0 { + labelSelector = fmt.Sprintf("app=%s,secName", appLabel) + } else { + args := cliCtx.Args() + namesStr := strings.Join(args, ",") + labelSelector = fmt.Sprintf("app=%s, secName in (%s)", appLabel, namesStr) + } + + secMap := make(map[string][]int) + err = listSecretsByName(clientset, labelSelector, namespace, secMap) + if err != nil { + return err + } + + for key, value := range secMap { + fmt.Fprintf(tw, "%s\t%d\t%d\t%d\n", + key, + value[0], + value[1], + value[2], + ) + } + return tw.Flush() + }, +} + +func prepareNamespace(clientset *kubernetes.Clientset, namespace string) error { + if namespace == "" { + return fmt.Errorf("namespace cannot be empty") + } + + if namespace == "default" { + return nil + } + + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + }, metav1.CreateOptions{}) + if err != nil { + if errors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("failed to create namespace %s: %v", namespace, err) + } + return nil +} + +func checkSecretParams(size int, groupSize int, total int) error { + if size <= 0 { + return fmt.Errorf("size must be greater than 0") + } + if groupSize <= 0 { + return fmt.Errorf("group-size must be greater than 0") + } + if total <= 0 { + return fmt.Errorf("total amount must be greater than 0") + } + if groupSize > total { + return fmt.Errorf("group-size must be less than or equal to total") + } + return nil +} + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +func randBytes(n int) ([]byte, error) { + if n <= 0 { + return nil, fmt.Errorf("length must be positive") + } + + b := make([]byte, n) + for i := range b { + random, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) + if err != nil { + return nil, fmt.Errorf("error generating random number: %w", err) + } + b[i] = byte(letterRunes[int(random.Int64())]) + } + return b, nil +} + +func createSecrets(clientset *kubernetes.Clientset, namespace string, secName string, size int, groupSize int, total int) error { + for i := 0; i < total; i += groupSize { + ownerID := i + g := new(errgroup.Group) + for j := i; j < i+groupSize && j < total; j++ { + idx := j + g.Go(func() error { + name := fmt.Sprintf("%s-sec-%s-%d", appLabel, secName, idx) + + data, err := randBytes(size) + if err != nil { + return fmt.Errorf("failed to generate random data for secret %s: %v", name, err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "ownerID": strconv.Itoa(ownerID), + "app": appLabel, + "secName": secName, + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "data": data, + }, + } + + _, err = clientset.CoreV1().Secrets(namespace).Create(context.TODO(), secret, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create secret %s: %v", name, err) + } + return nil + }) + } + if err := g.Wait(); err != nil { + return err + } + } + return nil +} + +func deleteSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string) error { + secrets, err := listSecrets(clientset, labelSelector, namespace) + if err != nil { + return err + } + + if len(secrets.Items) == 0 { + return fmt.Errorf("no secrets set found in namespace: %s", namespace) + } + + n, batch := len(secrets.Items), 300 + for i := 0; i < n; i += batch { + g := new(errgroup.Group) + for j := i; j < i+batch && j < n; j++ { + idx := j + g.Go(func() error { + err := clientset.CoreV1().Secrets(namespace).Delete(context.TODO(), secrets.Items[idx].Name, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete secret %s: %v", secrets.Items[idx].Name, err) + } + return nil + }) + } + if err := g.Wait(); err != nil { + return err + } + } + return nil +} + +func listSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string) (*corev1.SecretList, error) { + secrets, err := clientset.CoreV1().Secrets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: labelSelector}) + if err != nil { + return nil, fmt.Errorf("failed to list secrets: %v", err) + } + return secrets, nil +} + +func listSecretsByName(clientset *kubernetes.Clientset, labelSelector string, namespace string, secMap map[string][]int) error { + secrets, err := listSecrets(clientset, labelSelector, namespace) + if err != nil { + return err + } + + for _, sec := range secrets.Items { + name, ok := sec.Labels["secName"] + if !ok { + return fmt.Errorf("failed to find the secName of secret %s", sec.Name) + } + + _, ok = secMap[name] + if !ok { + // Initialize: size, group-size, total + secMap[name] = []int{0, 0, 0} + + if data, exists := sec.Data["data"]; exists { + secMap[name][0] = len(data) + } + } + + secMap[name][2]++ + + if secMap[name][1] != 0 { + continue + } + + ownerID, ok := sec.Labels["ownerID"] + if !ok { + return fmt.Errorf("failed to find the ownerID of secret %s", name) + } + + if ownerIDInt, err := strconv.Atoi(ownerID); err == nil { + if ownerIDInt > secMap[name][1] { + secMap[name][1] = ownerIDInt + } + } else { + return fmt.Errorf("failed to convert ownerID %s to int: %v", ownerID, err) + } + } + return nil +} diff --git a/contrib/cmd/runkperf/commands/root.go b/contrib/cmd/runkperf/commands/root.go index 50796d1..ab86680 100644 --- a/contrib/cmd/runkperf/commands/root.go +++ b/contrib/cmd/runkperf/commands/root.go @@ -13,6 +13,8 @@ import ( "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/configmaps" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/daemonsets" + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/events" + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/secrets" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/warmup" "github.com/urfave/cli" @@ -23,6 +25,8 @@ func init() { data.RegisterSubcommands( configmaps.Command, daemonsets.Command, + events.Command, + secrets.Command, ) } From b09c440fe11184ed963c7738ff9c666f560fadf3 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Tue, 17 Mar 2026 10:33:05 +1000 Subject: [PATCH 02/20] add git ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d8b5cd0..df1d344 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ tmp/ # VSCode settings .vscode/ + +# Claude Code +.claude/ From 5aa70f9b72a7b5c919a51ba66541573e34e4fa93 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Tue, 17 Mar 2026 12:44:26 +1000 Subject: [PATCH 03/20] Reuse PrepareNamespace func --- contrib/cmd/runkperf/commands/data/client.go | 31 +++++++++++ .../commands/data/configmaps/configmap.go | 26 +-------- .../commands/data/daemonsets/daemonset.go | 55 ++----------------- .../runkperf/commands/data/events/event.go | 25 +-------- .../runkperf/commands/data/secrets/secret.go | 25 +-------- 5 files changed, 39 insertions(+), 123 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/client.go b/contrib/cmd/runkperf/commands/data/client.go index 47e6df2..156db49 100644 --- a/contrib/cmd/runkperf/commands/data/client.go +++ b/contrib/cmd/runkperf/commands/data/client.go @@ -4,6 +4,12 @@ package data import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/flowcontrol" @@ -29,3 +35,28 @@ func NewClientsetWithRateLimiter(kubeCfgPath string, qps float32, burst int) (*k config.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(qps, burst) return kubernetes.NewForConfig(config) } + +// PrepareNamespace creates the namespace if it does not already exist. +// It skips creation for the "default" namespace. +func PrepareNamespace(clientset *kubernetes.Clientset, namespace string) error { + if namespace == "" { + return fmt.Errorf("namespace cannot be empty") + } + + if namespace == "default" { + return nil + } + + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + }, metav1.CreateOptions{}) + if err != nil { + if errors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("failed to create namespace %s: %v", namespace, err) + } + return nil +} diff --git a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go index 6a5b1d3..a7fb7f0 100644 --- a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go +++ b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go @@ -116,7 +116,7 @@ var configmapAddCommand = cli.Command{ return err } - err = prepareNamespace(clientset, namespace) + err = data.PrepareNamespace(clientset, namespace) if err != nil { return err } @@ -248,30 +248,6 @@ var configmapListCommand = cli.Command{ }, } -func prepareNamespace(clientset *kubernetes.Clientset, namespace string) error { - if namespace == "" { - return fmt.Errorf("namespace cannot be empty") - } - - if namespace == "default" { - return nil - } - - _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: namespace, - }, - }, metav1.CreateOptions{}) - if err != nil { - // If the namespace already exists, ignore the error - if errors.IsAlreadyExists(err) { - return nil - } - return fmt.Errorf("failed to create namespace %s: %v", namespace, err) - } - return nil -} - func checkConfigmapParams(size int, groupSize int, total int) error { if size <= 0 { return fmt.Errorf("size must be greater than 0") diff --git a/contrib/cmd/runkperf/commands/data/daemonsets/daemonset.go b/contrib/cmd/runkperf/commands/data/daemonsets/daemonset.go index 9c7b79f..79d3b06 100644 --- a/contrib/cmd/runkperf/commands/data/daemonsets/daemonset.go +++ b/contrib/cmd/runkperf/commands/data/daemonsets/daemonset.go @@ -11,8 +11,7 @@ import ( "text/tabwriter" "github.com/Azure/kperf/cmd/kperf/commands/utils" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/util/flowcontrol" + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" "github.com/urfave/cli" @@ -76,12 +75,12 @@ var daemonsetAddCommand = cli.Command{ return fmt.Errorf("count must be greater than 0") } - err := prepareNamespace(kubeCfgPath, namespace) + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, 30, 10) if err != nil { return err } - clientset, err := newClientsetWithRateLimiter(kubeCfgPath, 30, 10) + err = data.PrepareNamespace(clientset, namespace) if err != nil { return err } @@ -113,7 +112,7 @@ var daemonsetDelCommand = cli.Command{ namespace := cliCtx.GlobalString("namespace") kubeCfgPath := cliCtx.GlobalString("kubeconfig") - clientset, err := newClientsetWithRateLimiter(kubeCfgPath, 30, 10) + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, 30, 10) if err != nil { return err } @@ -139,7 +138,7 @@ var daemonsetListCommand = cli.Command{ Action: func(cliCtx *cli.Context) error { namespace := cliCtx.GlobalString("namespace") kubeCfgPath := cliCtx.GlobalString("kubeconfig") - clientset, err := newClientsetWithRateLimiter(kubeCfgPath, 30, 10) + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, 30, 10) if err != nil { return err } @@ -192,50 +191,6 @@ var daemonsetListCommand = cli.Command{ }, } -func prepareNamespace(kubeCfgPath string, namespace string) error { - if namespace == "" { - return fmt.Errorf("namespace cannot be empty") - } - - if namespace == "default" { - return nil - } - - clientset, err := newClientsetWithRateLimiter(kubeCfgPath, 30, 10) - if err != nil { - return err - } - - _, err = clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: namespace, - }, - }, metav1.CreateOptions{}) - if err != nil { - // If the namespace already exists, ignore the error - if errors.IsAlreadyExists(err) { - return nil - } - return fmt.Errorf("failed to create namespace %s: %v", namespace, err) - } - return nil -} - -func newClientsetWithRateLimiter(kubeCfgPath string, qps float32, burst int) (*kubernetes.Clientset, error) { - config, err := clientcmd.BuildConfigFromFlags("", kubeCfgPath) - if err != nil { - return nil, err - } - - config.QPS = qps - config.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(qps, burst) - clientset, err := kubernetes.NewForConfig(config) - if err != nil { - return nil, err - } - return clientset, nil -} - func createDaemonsets(clientset *kubernetes.Clientset, namespace string, dsName string, count int) error { for i := 0; i < count; i++ { ds := &appsv1.DaemonSet{ diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index ccdc068..0d955c8 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -144,7 +144,7 @@ var eventAddCommand = cli.Command{ return err } - err = prepareNamespace(clientset, namespace) + err = data.PrepareNamespace(clientset, namespace) if err != nil { return err } @@ -245,29 +245,6 @@ type eventSetInfo struct { total int } -func prepareNamespace(clientset *kubernetes.Clientset, namespace string) error { - if namespace == "" { - return fmt.Errorf("namespace cannot be empty") - } - - if namespace == "default" { - return nil - } - - _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: namespace, - }, - }, metav1.CreateOptions{}) - if err != nil { - if errors.IsAlreadyExists(err) { - return nil - } - return fmt.Errorf("failed to create namespace %s: %v", namespace, err) - } - return nil -} - var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func randString(n int) (string, error) { diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index dff497b..8c9b4bf 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -110,7 +110,7 @@ var secretAddCommand = cli.Command{ return err } - err = prepareNamespace(clientset, namespace) + err = data.PrepareNamespace(clientset, namespace) if err != nil { return err } @@ -206,29 +206,6 @@ var secretListCommand = cli.Command{ }, } -func prepareNamespace(clientset *kubernetes.Clientset, namespace string) error { - if namespace == "" { - return fmt.Errorf("namespace cannot be empty") - } - - if namespace == "default" { - return nil - } - - _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: namespace, - }, - }, metav1.CreateOptions{}) - if err != nil { - if errors.IsAlreadyExists(err) { - return nil - } - return fmt.Errorf("failed to create namespace %s: %v", namespace, err) - } - return nil -} - func checkSecretParams(size int, groupSize int, total int) error { if size <= 0 { return fmt.Errorf("size must be greater than 0") From bc0cc0705d3315f0ad54056678f7f39334a16bf5 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Tue, 17 Mar 2026 13:05:53 +1000 Subject: [PATCH 04/20] reuse RandBytes --- contrib/cmd/runkperf/commands/data/client.go | 30 ++++++++++++++++ .../commands/data/configmaps/configmap.go | 34 ++++--------------- .../runkperf/commands/data/events/event.go | 22 +----------- .../runkperf/commands/data/secrets/secret.go | 24 ++----------- 4 files changed, 40 insertions(+), 70 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/client.go b/contrib/cmd/runkperf/commands/data/client.go index 156db49..6b2ddcf 100644 --- a/contrib/cmd/runkperf/commands/data/client.go +++ b/contrib/cmd/runkperf/commands/data/client.go @@ -5,7 +5,9 @@ package data import ( "context" + "crypto/rand" "fmt" + "math/big" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -60,3 +62,31 @@ func PrepareNamespace(clientset *kubernetes.Clientset, namespace string) error { } return nil } + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +// RandBytes generates a random byte slice of length n using alphanumeric characters. +func RandBytes(n int) ([]byte, error) { + if n <= 0 { + return nil, fmt.Errorf("length must be positive") + } + + b := make([]byte, n) + for i := range b { + random, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) + if err != nil { + return nil, fmt.Errorf("error generating random number: %w", err) + } + b[i] = byte(letterRunes[int(random.Int64())]) + } + return b, nil +} + +// RandString generates a random string of length n using alphanumeric characters. +func RandString(n int) (string, error) { + b, err := RandBytes(n) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go index a7fb7f0..c0dedc8 100644 --- a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go +++ b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go @@ -5,10 +5,8 @@ package configmaps import ( "context" - "crypto/rand" "fmt" "math" - "math/big" "os" "strconv" "strings" @@ -31,7 +29,7 @@ import ( "k8s.io/client-go/kubernetes" ) -var appLebel = "runkperf" +var appLabel = "runkperf" var Command = cli.Command{ Name: "configmap", @@ -222,12 +220,12 @@ var configmapListCommand = cli.Command{ // If args are provided, list all configmaps with the label app=runkperf and cmName in (args) var labelSelector string if cliCtx.NArg() == 0 { - labelSelector = fmt.Sprintf("app=%s", appLebel) + labelSelector = fmt.Sprintf("app=%s", appLabel) } else { args := cliCtx.Args() namesStr := strings.Join(args, ",") - labelSelector = fmt.Sprintf("app=%s, cmName in (%s)", appLebel, namesStr) + labelSelector = fmt.Sprintf("app=%s, cmName in (%s)", appLabel, namesStr) } cmMap := make(map[string][]int) err = listConfigmapsByName(clientset, labelSelector, namespace, cmMap) @@ -318,24 +316,6 @@ func isRetriableError(err error) bool { errors.IsServiceUnavailable(err) } -var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - -func randString(n int) (string, error) { - if n <= 0 { - return "", fmt.Errorf("length must be positive") - } - - b := make([]rune, n) - for i := range b { - random, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) - if err != nil { - return "", fmt.Errorf("error generating random number: %w", err) - } - b[i] = letterRunes[int(random.Int64())] - } - return string(b), nil -} - func createConfigmaps(clientset *kubernetes.Clientset, namespace string, cmName string, size int, groupSize int, total int) error { // Generate configmaps in parallel with fixed group size // and random data @@ -346,22 +326,22 @@ func createConfigmaps(clientset *kubernetes.Clientset, namespace string, cmName g.Go(func() error { cli := clientset.CoreV1().ConfigMaps(namespace) - name := fmt.Sprintf("%s-cm-%s-%d", appLebel, cmName, j) + name := fmt.Sprintf("%s-cm-%s-%d", appLabel, cmName, j) cm := &corev1.ConfigMap{} cm.Name = name // Set the labels for the configmap to easily identify in delete or list commands cm.Labels = map[string]string{ "ownerID": strconv.Itoa(ownerID), - "app": appLebel, + "app": appLabel, "cmName": cmName, } - data, err := randString(size) + randData, err := data.RandString(size) if err != nil { return fmt.Errorf("failed to generate random string for configmap %s: %v", name, err) } cm.Data = map[string]string{ - "data": data, + "data": randData, } err = retryOnTransient(func() error { diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index 0d955c8..9881fb5 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -5,9 +5,7 @@ package events import ( "context" - "crypto/rand" "fmt" - "math/big" "os" "strings" "text/tabwriter" @@ -245,24 +243,6 @@ type eventSetInfo struct { total int } -var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - -func randString(n int) (string, error) { - if n <= 0 { - return "", fmt.Errorf("length must be positive") - } - - b := make([]rune, n) - for i := range b { - random, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) - if err != nil { - return "", fmt.Errorf("error generating random number: %w", err) - } - b[i] = letterRunes[int(random.Int64())] - } - return string(b), nil -} - func createEvents(clientset *kubernetes.Clientset, namespace, evName string, total, groupSize int, reason, eventType string, messageSize int, involvedObjectKind, involvedObjectName string) error { now := time.Now() @@ -273,7 +253,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, evName string, tot g.Go(func() error { name := fmt.Sprintf("%s-ev-%s-%d", appLabel, evName, idx) - message, err := randString(messageSize) + message, err := data.RandString(messageSize) if err != nil { return fmt.Errorf("failed to generate random message for event %s: %v", name, err) } diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index 8c9b4bf..65da362 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -5,9 +5,7 @@ package secrets import ( "context" - "crypto/rand" "fmt" - "math/big" "os" "strconv" "strings" @@ -222,24 +220,6 @@ func checkSecretParams(size int, groupSize int, total int) error { return nil } -var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -func randBytes(n int) ([]byte, error) { - if n <= 0 { - return nil, fmt.Errorf("length must be positive") - } - - b := make([]byte, n) - for i := range b { - random, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) - if err != nil { - return nil, fmt.Errorf("error generating random number: %w", err) - } - b[i] = byte(letterRunes[int(random.Int64())]) - } - return b, nil -} - func createSecrets(clientset *kubernetes.Clientset, namespace string, secName string, size int, groupSize int, total int) error { for i := 0; i < total; i += groupSize { ownerID := i @@ -249,7 +229,7 @@ func createSecrets(clientset *kubernetes.Clientset, namespace string, secName st g.Go(func() error { name := fmt.Sprintf("%s-sec-%s-%d", appLabel, secName, idx) - data, err := randBytes(size) + randData, err := data.RandBytes(size) if err != nil { return fmt.Errorf("failed to generate random data for secret %s: %v", name, err) } @@ -265,7 +245,7 @@ func createSecrets(clientset *kubernetes.Clientset, namespace string, secName st }, Type: corev1.SecretTypeOpaque, Data: map[string][]byte{ - "data": data, + "data": randData, }, } From fa063f99664c2a9b0bd1766aa132168a06ad5b57 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Tue, 17 Mar 2026 18:23:32 +1000 Subject: [PATCH 05/20] Apply rate limiter for deletion --- contrib/cmd/runkperf/commands/data/client.go | 11 +- .../runkperf/commands/data/events/event.go | 152 ++++++++++------ .../runkperf/commands/data/secrets/secret.go | 164 +++++++++++------- 3 files changed, 203 insertions(+), 124 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/client.go b/contrib/cmd/runkperf/commands/data/client.go index 6b2ddcf..56bb846 100644 --- a/contrib/cmd/runkperf/commands/data/client.go +++ b/contrib/cmd/runkperf/commands/data/client.go @@ -5,9 +5,8 @@ package data import ( "context" - "crypto/rand" "fmt" - "math/big" + "math/rand" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -58,7 +57,7 @@ func PrepareNamespace(clientset *kubernetes.Clientset, namespace string) error { if errors.IsAlreadyExists(err) { return nil } - return fmt.Errorf("failed to create namespace %s: %v", namespace, err) + return fmt.Errorf("failed to create namespace %s: %w", namespace, err) } return nil } @@ -73,11 +72,7 @@ func RandBytes(n int) ([]byte, error) { b := make([]byte, n) for i := range b { - random, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) - if err != nil { - return nil, fmt.Errorf("error generating random number: %w", err) - } - b[i] = byte(letterRunes[int(random.Int64())]) + b[i] = byte(letterRunes[rand.Intn(len(letterRunes))]) } return b, nil } diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index 9881fb5..f307aec 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -26,6 +26,10 @@ import ( var appLabel = "runkperf" +// defaultBatchSize is the number of events to list or delete per batch. +// It is used as the page size for paginated API calls and the concurrency limit for parallel deletions. +const defaultBatchSize int64 = 300 + var Command = cli.Command{ Name: "event", ShortName: "ev", @@ -104,8 +108,8 @@ var eventAddCommand = cli.Command{ if cliCtx.NArg() != 1 { return fmt.Errorf("required only one argument as events set name: %v", cliCtx.Args()) } - evName := strings.TrimSpace(cliCtx.Args().Get(0)) - if len(evName) == 0 { + eventSetName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(eventSetName) == 0 { return fmt.Errorf("required non-empty event set name") } @@ -147,12 +151,12 @@ var eventAddCommand = cli.Command{ return err } - err = createEvents(clientset, namespace, evName, total, groupSize, reason, eventType, messageSize, involvedObjectKind, involvedObjectName) + err = createEvents(clientset, namespace, eventSetName, total, groupSize, reason, eventType, messageSize, involvedObjectKind, involvedObjectName) if err != nil { return err } fmt.Printf("Created %d events (set=%s, reason=%s, type=%s) in namespace %s\n", - total, evName, reason, eventType, namespace) + total, eventSetName, reason, eventType, namespace) return nil }, } @@ -162,37 +166,58 @@ var eventDelCommand = cli.Command{ ShortName: "del", ArgsUsage: "NAME", Usage: "Delete an events set", + Flags: []cli.Flag{ + cli.Float64Flag{ + Name: "qps", + Usage: "QPS for the Kubernetes client rate limiter to control event deletion", + Value: 100, + }, + cli.IntFlag{ + Name: "burst", + Usage: "Burst for the Kubernetes client rate limiter to control event deletion", + Value: 200, + }, + cli.IntFlag{ + Name: "group-size", + Usage: "Number of events to delete in parallel per batch", + Value: 30, + }, + }, Action: func(cliCtx *cli.Context) error { if cliCtx.NArg() != 1 { return fmt.Errorf("required only one events set name") } - evName := strings.TrimSpace(cliCtx.Args().Get(0)) - if len(evName) == 0 { + eventSetName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(eventSetName) == 0 { return fmt.Errorf("required non-empty events set name") } namespace := cliCtx.GlobalString("namespace") kubeCfgPath := cliCtx.GlobalString("kubeconfig") - labelSelector := fmt.Sprintf("app=%s,evName=%s", appLabel, evName) + qps := float32(cliCtx.Float64("qps")) + burst := cliCtx.Int("burst") + groupSize := cliCtx.Int("group-size") + labelSelector := fmt.Sprintf("app=%s,eventSetName=%s", appLabel, eventSetName) - clientset, err := data.NewClientset(kubeCfgPath) + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) if err != nil { return err } - err = deleteEvents(clientset, labelSelector, namespace) + err = deleteEvents(clientset, labelSelector, namespace, groupSize) if err != nil { return err } - fmt.Printf("Deleted events set %s in %s namespace\n", evName, namespace) + fmt.Printf("Deleted events set %s in %s namespace\n", eventSetName, namespace) return nil }, } var eventListCommand = cli.Command{ - Name: "list", - Usage: "List generated event sets. Lists all if no arguments are given; otherwise, provide event set names separated by spaces.", + Name: "list", + Usage: "List generated event sets. Lists all if no arguments are given; otherwise, provide event set names separated by spaces.", + ArgsUsage: "[NAME ...]", Action: func(cliCtx *cli.Context) error { namespace := cliCtx.GlobalString("namespace") kubeCfgPath := cliCtx.GlobalString("kubeconfig") @@ -213,14 +238,31 @@ var eventListCommand = cli.Command{ var labelSelector string if cliCtx.NArg() == 0 { - labelSelector = fmt.Sprintf("app=%s,evName", appLabel) + labelSelector = fmt.Sprintf("app=%s", appLabel) } else { args := cliCtx.Args() namesStr := strings.Join(args, ",") - labelSelector = fmt.Sprintf("app=%s, evName in (%s)", appLabel, namesStr) + labelSelector = fmt.Sprintf("app=%s, eventSetName in (%s)", appLabel, namesStr) } - evMap, err := listEventsByName(clientset, labelSelector, namespace) + evMap := make(map[string]*eventSetInfo) + err = listEvents(clientset, labelSelector, namespace, defaultBatchSize, func(ev corev1.Event) error { + name, ok := ev.Labels["eventSetName"] + if !ok { + return nil + } + + info, ok := evMap[name] + if !ok { + info = &eventSetInfo{ + eventType: ev.Type, + reason: ev.Reason, + } + evMap[name] = info + } + info.total++ + return nil + }) if err != nil { return err } @@ -243,7 +285,7 @@ type eventSetInfo struct { total int } -func createEvents(clientset *kubernetes.Clientset, namespace, evName string, total, groupSize int, reason, eventType string, messageSize int, involvedObjectKind, involvedObjectName string) error { +func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName string, total, groupSize int, reason, eventType string, messageSize int, involvedObjectKind, involvedObjectName string) error { now := time.Now() for i := 0; i < total; i += groupSize { @@ -251,7 +293,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, evName string, tot for j := i; j < i+groupSize && j < total; j++ { idx := j g.Go(func() error { - name := fmt.Sprintf("%s-ev-%s-%d", appLabel, evName, idx) + name := fmt.Sprintf("%s-ev-%s-%d", appLabel, eventSetName, idx) message, err := data.RandString(messageSize) if err != nil { @@ -260,7 +302,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, evName string, tot objName := involvedObjectName if objName == "" { - objName = fmt.Sprintf("%s-obj-%d", evName, idx) + objName = fmt.Sprintf("%s-obj-%d", eventSetName, idx) } event := &corev1.Event{ @@ -268,8 +310,8 @@ func createEvents(clientset *kubernetes.Clientset, namespace, evName string, tot Name: name, Namespace: namespace, Labels: map[string]string{ - "app": appLabel, - "evName": evName, + "app": appLabel, + "eventSetName": eventSetName, }, }, InvolvedObject: corev1.ObjectReference{ @@ -302,25 +344,30 @@ func createEvents(clientset *kubernetes.Clientset, namespace, evName string, tot return nil } -func deleteEvents(clientset *kubernetes.Clientset, labelSelector, namespace string) error { - eventList, err := listEvents(clientset, labelSelector, namespace) +func deleteEvents(clientset *kubernetes.Clientset, labelSelector, namespace string, groupSize int) error { + var names []string + err := listEvents(clientset, labelSelector, namespace, defaultBatchSize, func(ev corev1.Event) error { + names = append(names, ev.Name) + return nil + }) if err != nil { return err } - if len(eventList.Items) == 0 { - return fmt.Errorf("no events set found in namespace: %s", namespace) + if len(names) == 0 { + fmt.Printf("No events found in namespace %s, nothing to delete\n", namespace) + return nil } - n, batch := len(eventList.Items), 300 - for i := 0; i < n; i += batch { + n := len(names) + for i := 0; i < n; i += groupSize { g := new(errgroup.Group) - for j := i; j < i+batch && j < n; j++ { + for j := i; j < i+groupSize && j < n; j++ { idx := j g.Go(func() error { - err := clientset.CoreV1().Events(namespace).Delete(context.TODO(), eventList.Items[idx].Name, metav1.DeleteOptions{}) + err := clientset.CoreV1().Events(namespace).Delete(context.TODO(), names[idx], metav1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete event %s: %v", eventList.Items[idx].Name, err) + return fmt.Errorf("failed to delete event %s: %v", names[idx], err) } return nil }) @@ -332,36 +379,31 @@ func deleteEvents(clientset *kubernetes.Clientset, labelSelector, namespace stri return nil } -func listEvents(clientset *kubernetes.Clientset, labelSelector, namespace string) (*corev1.EventList, error) { - eventList, err := clientset.CoreV1().Events(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: labelSelector}) - if err != nil { - return nil, fmt.Errorf("failed to list events: %v", err) - } - return eventList, nil -} - -func listEventsByName(clientset *kubernetes.Clientset, labelSelector, namespace string) (map[string]*eventSetInfo, error) { - eventList, err := listEvents(clientset, labelSelector, namespace) - if err != nil { - return nil, err +func listEvents(clientset *kubernetes.Clientset, labelSelector, namespace string, limit int64, fn func(corev1.Event) error) error { + if limit <= 0 { + limit = defaultBatchSize } - - evMap := make(map[string]*eventSetInfo) - for _, ev := range eventList.Items { - name, ok := ev.Labels["evName"] - if !ok { - continue + var continueToken string + for { + eventList, err := clientset.CoreV1().Events(namespace).List(context.TODO(), metav1.ListOptions{ + LabelSelector: labelSelector, + Limit: limit, + Continue: continueToken, + }) + if err != nil { + return fmt.Errorf("failed to list events: %v", err) } - info, ok := evMap[name] - if !ok { - info = &eventSetInfo{ - eventType: ev.Type, - reason: ev.Reason, + for _, ev := range eventList.Items { + if err := fn(ev); err != nil { + return err } - evMap[name] = info } - info.total++ + + continueToken = eventList.Continue + if continueToken == "" { + break + } } - return evMap, nil + return nil } diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index 65da362..08eedc1 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -26,6 +26,10 @@ import ( var appLabel = "runkperf" +// defaultBatchSize is the number of secrets to list or delete per batch. +// It is used as the page size for paginated API calls and the concurrency limit for parallel deletions. +const defaultBatchSize int64 = 300 + var Command = cli.Command{ Name: "secret", ShortName: "sec", @@ -127,6 +131,23 @@ var secretDelCommand = cli.Command{ ShortName: "del", ArgsUsage: "NAME", Usage: "Delete a secrets set", + Flags: []cli.Flag{ + cli.Float64Flag{ + Name: "qps", + Usage: "QPS for the Kubernetes client rate limiter to control secret deletion", + Value: 100, + }, + cli.IntFlag{ + Name: "burst", + Usage: "Burst for the Kubernetes client rate limiter to control secret deletion", + Value: 200, + }, + cli.IntFlag{ + Name: "group-size", + Usage: "Number of secrets to delete in parallel per batch", + Value: 30, + }, + }, Action: func(cliCtx *cli.Context) error { if cliCtx.NArg() != 1 { return fmt.Errorf("required only one secrets set name") @@ -138,14 +159,17 @@ var secretDelCommand = cli.Command{ namespace := cliCtx.GlobalString("namespace") kubeCfgPath := cliCtx.GlobalString("kubeconfig") + qps := float32(cliCtx.Float64("qps")) + burst := cliCtx.Int("burst") + groupSize := cliCtx.Int("group-size") labelSelector := fmt.Sprintf("app=%s,secName=%s", appLabel, secName) - clientset, err := data.NewClientset(kubeCfgPath) + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) if err != nil { return err } - err = deleteSecrets(clientset, labelSelector, namespace) + err = deleteSecrets(clientset, labelSelector, namespace, groupSize) if err != nil { return err } @@ -158,7 +182,7 @@ var secretDelCommand = cli.Command{ var secretListCommand = cli.Command{ Name: "list", Usage: "List generated secrets. Lists all if no arguments are given; otherwise, provide secret set names separated by spaces.", - ArgsUsage: "NAME", + ArgsUsage: "[NAME ...]", Action: func(cliCtx *cli.Context) error { namespace := cliCtx.GlobalString("namespace") kubeCfgPath := cliCtx.GlobalString("kubeconfig") @@ -179,25 +203,61 @@ var secretListCommand = cli.Command{ var labelSelector string if cliCtx.NArg() == 0 { - labelSelector = fmt.Sprintf("app=%s,secName", appLabel) + labelSelector = fmt.Sprintf("app=%s", appLabel) } else { args := cliCtx.Args() namesStr := strings.Join(args, ",") labelSelector = fmt.Sprintf("app=%s, secName in (%s)", appLabel, namesStr) } - secMap := make(map[string][]int) - err = listSecretsByName(clientset, labelSelector, namespace, secMap) + type secretSetInfo struct { + size int + total int + ownerID map[string]int // ownerID -> count of secrets in that group + } + secMap := make(map[string]*secretSetInfo) + err = listSecrets(clientset, labelSelector, namespace, defaultBatchSize, func(sec corev1.Secret) error { + name, ok := sec.Labels["secName"] + if !ok { + return fmt.Errorf("failed to find the secName of secret %s", sec.Name) + } + + info, ok := secMap[name] + if !ok { + info = &secretSetInfo{ + ownerID: make(map[string]int), + } + if d, exists := sec.Data["data"]; exists { + info.size = len(d) + } + secMap[name] = info + } + + info.total++ + + ownerID, ok := sec.Labels["ownerID"] + if !ok { + return fmt.Errorf("failed to find the ownerID of secret %s", name) + } + info.ownerID[ownerID]++ + return nil + }) if err != nil { return err } - for key, value := range secMap { + for name, info := range secMap { + // Determine group size from the first ownerID group count + groupSize := 0 + for _, count := range info.ownerID { + groupSize = count + break + } fmt.Fprintf(tw, "%s\t%d\t%d\t%d\n", - key, - value[0], - value[1], - value[2], + name, + info.size, + groupSize, + info.total, ) } return tw.Flush() @@ -263,25 +323,30 @@ func createSecrets(clientset *kubernetes.Clientset, namespace string, secName st return nil } -func deleteSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string) error { - secrets, err := listSecrets(clientset, labelSelector, namespace) +func deleteSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string, groupSize int) error { + var names []string + err := listSecrets(clientset, labelSelector, namespace, defaultBatchSize, func(sec corev1.Secret) error { + names = append(names, sec.Name) + return nil + }) if err != nil { return err } - if len(secrets.Items) == 0 { - return fmt.Errorf("no secrets set found in namespace: %s", namespace) + if len(names) == 0 { + fmt.Printf("No secrets found in namespace %s, nothing to delete\n", namespace) + return nil } - n, batch := len(secrets.Items), 300 - for i := 0; i < n; i += batch { + n := len(names) + for i := 0; i < n; i += groupSize { g := new(errgroup.Group) - for j := i; j < i+batch && j < n; j++ { + for j := i; j < i+groupSize && j < n; j++ { idx := j g.Go(func() error { - err := clientset.CoreV1().Secrets(namespace).Delete(context.TODO(), secrets.Items[idx].Name, metav1.DeleteOptions{}) + err := clientset.CoreV1().Secrets(namespace).Delete(context.TODO(), names[idx], metav1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete secret %s: %v", secrets.Items[idx].Name, err) + return fmt.Errorf("failed to delete secret %s: %v", names[idx], err) } return nil }) @@ -293,53 +358,30 @@ func deleteSecrets(clientset *kubernetes.Clientset, labelSelector string, namesp return nil } -func listSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string) (*corev1.SecretList, error) { - secrets, err := clientset.CoreV1().Secrets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: labelSelector}) - if err != nil { - return nil, fmt.Errorf("failed to list secrets: %v", err) - } - return secrets, nil -} - -func listSecretsByName(clientset *kubernetes.Clientset, labelSelector string, namespace string, secMap map[string][]int) error { - secrets, err := listSecrets(clientset, labelSelector, namespace) - if err != nil { - return err +func listSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string, limit int64, fn func(corev1.Secret) error) error { + if limit <= 0 { + limit = defaultBatchSize } - - for _, sec := range secrets.Items { - name, ok := sec.Labels["secName"] - if !ok { - return fmt.Errorf("failed to find the secName of secret %s", sec.Name) + var continueToken string + for { + secrets, err := clientset.CoreV1().Secrets(namespace).List(context.TODO(), metav1.ListOptions{ + LabelSelector: labelSelector, + Limit: limit, + Continue: continueToken, + }) + if err != nil { + return fmt.Errorf("failed to list secrets: %v", err) } - _, ok = secMap[name] - if !ok { - // Initialize: size, group-size, total - secMap[name] = []int{0, 0, 0} - - if data, exists := sec.Data["data"]; exists { - secMap[name][0] = len(data) + for _, sec := range secrets.Items { + if err := fn(sec); err != nil { + return err } } - secMap[name][2]++ - - if secMap[name][1] != 0 { - continue - } - - ownerID, ok := sec.Labels["ownerID"] - if !ok { - return fmt.Errorf("failed to find the ownerID of secret %s", name) - } - - if ownerIDInt, err := strconv.Atoi(ownerID); err == nil { - if ownerIDInt > secMap[name][1] { - secMap[name][1] = ownerIDInt - } - } else { - return fmt.Errorf("failed to convert ownerID %s to int: %v", ownerID, err) + continueToken = secrets.Continue + if continueToken == "" { + break } } return nil From 4f76a99e59d76ddfb517f7a330322a81d67615c6 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Tue, 17 Mar 2026 23:43:37 +1000 Subject: [PATCH 06/20] fix linting --- contrib/cmd/runkperf/commands/data/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/cmd/runkperf/commands/data/client.go b/contrib/cmd/runkperf/commands/data/client.go index 56bb846..eefa824 100644 --- a/contrib/cmd/runkperf/commands/data/client.go +++ b/contrib/cmd/runkperf/commands/data/client.go @@ -72,7 +72,7 @@ func RandBytes(n int) ([]byte, error) { b := make([]byte, n) for i := range b { - b[i] = byte(letterRunes[rand.Intn(len(letterRunes))]) + b[i] = byte(letterRunes[rand.Intn(len(letterRunes))]) //nolint:gosec // G404: intentionally using math/rand for benchmark data, cryptographic randomness not needed } return b, nil } From 635ddd8111f8f099d7fe93c833357ff860cbe2d3 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Wed, 18 Mar 2026 11:45:03 +1000 Subject: [PATCH 07/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- contrib/cmd/runkperf/commands/data/events/event.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index f307aec..40b859e 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -27,7 +27,7 @@ import ( var appLabel = "runkperf" // defaultBatchSize is the number of events to list or delete per batch. -// It is used as the page size for paginated API calls and the concurrency limit for parallel deletions. +// It is used as the page size for paginated API calls. const defaultBatchSize int64 = 300 var Command = cli.Command{ From 1b16decef18f72c4e1a0fae960f08dbabaea4fc3 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Wed, 18 Mar 2026 11:45:13 +1000 Subject: [PATCH 08/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- contrib/cmd/runkperf/commands/data/secrets/secret.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index 08eedc1..85cf291 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -27,7 +27,7 @@ import ( var appLabel = "runkperf" // defaultBatchSize is the number of secrets to list or delete per batch. -// It is used as the page size for paginated API calls and the concurrency limit for parallel deletions. +// It is used as the page size for paginated API calls. const defaultBatchSize int64 = 300 var Command = cli.Command{ From 43423643ac532bfb3a9cdeeecdf8982e6f9b41f0 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Wed, 18 Mar 2026 12:25:48 +1000 Subject: [PATCH 09/20] Address copilot comments --- contrib/cmd/runkperf/commands/data/events/event.go | 3 +++ contrib/cmd/runkperf/commands/data/secrets/secret.go | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index 40b859e..e1a4b8c 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -197,6 +197,9 @@ var eventDelCommand = cli.Command{ qps := float32(cliCtx.Float64("qps")) burst := cliCtx.Int("burst") groupSize := cliCtx.Int("group-size") + if groupSize <= 0 { + return fmt.Errorf("group-size must be greater than 0") + } labelSelector := fmt.Sprintf("app=%s,eventSetName=%s", appLabel, eventSetName) clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index 85cf291..bc2641c 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -121,7 +121,7 @@ var secretAddCommand = cli.Command{ if err != nil { return err } - fmt.Printf("Created secret %s with size %d B, group-size %d, total %d\n", secName, size, groupSize, total) + fmt.Printf("Created secret set %s with size %d B, group-size %d, total %d\n", secName, size, groupSize, total) return nil }, } @@ -162,6 +162,9 @@ var secretDelCommand = cli.Command{ qps := float32(cliCtx.Float64("qps")) burst := cliCtx.Int("burst") groupSize := cliCtx.Int("group-size") + if groupSize <= 0 { + return fmt.Errorf("group-size must be greater than 0") + } labelSelector := fmt.Sprintf("app=%s,secName=%s", appLabel, secName) clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) @@ -247,11 +250,12 @@ var secretListCommand = cli.Command{ } for name, info := range secMap { - // Determine group size from the first ownerID group count + // Group size should be the max count across all ownerID groups groupSize := 0 for _, count := range info.ownerID { - groupSize = count - break + if count > groupSize { + groupSize = count + } } fmt.Fprintf(tw, "%s\t%d\t%d\t%d\n", name, From dd953e06bc017e3bdf9fdc99cf6ea4fc1fee042b Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Fri, 20 Mar 2026 15:05:06 +1000 Subject: [PATCH 10/20] Consolidate AppLabel --- contrib/cmd/runkperf/commands/data/client.go | 3 +++ .../runkperf/commands/data/configmaps/configmap.go | 12 +++++------- contrib/cmd/runkperf/commands/data/events/event.go | 12 +++++------- contrib/cmd/runkperf/commands/data/secrets/secret.go | 12 +++++------- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/client.go b/contrib/cmd/runkperf/commands/data/client.go index eefa824..3c894fe 100644 --- a/contrib/cmd/runkperf/commands/data/client.go +++ b/contrib/cmd/runkperf/commands/data/client.go @@ -16,6 +16,9 @@ import ( "k8s.io/client-go/util/flowcontrol" ) +// default label value used to identify resources created by runkperf. +const AppLabel = "runkperf" + // NewClientset creates a Kubernetes clientset with default rate limiting. func NewClientset(kubeCfgPath string) (*kubernetes.Clientset, error) { config, err := clientcmd.BuildConfigFromFlags("", kubeCfgPath) diff --git a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go index c0dedc8..973e73e 100644 --- a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go +++ b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go @@ -29,8 +29,6 @@ import ( "k8s.io/client-go/kubernetes" ) -var appLabel = "runkperf" - var Command = cli.Command{ Name: "configmap", ShortName: "cm", @@ -220,12 +218,12 @@ var configmapListCommand = cli.Command{ // If args are provided, list all configmaps with the label app=runkperf and cmName in (args) var labelSelector string if cliCtx.NArg() == 0 { - labelSelector = fmt.Sprintf("app=%s", appLabel) + labelSelector = fmt.Sprintf("app=%s", data.AppLabel) } else { args := cliCtx.Args() namesStr := strings.Join(args, ",") - labelSelector = fmt.Sprintf("app=%s, cmName in (%s)", appLabel, namesStr) + labelSelector = fmt.Sprintf("app=%s, cmName in (%s)", data.AppLabel, namesStr) } cmMap := make(map[string][]int) err = listConfigmapsByName(clientset, labelSelector, namespace, cmMap) @@ -326,14 +324,14 @@ func createConfigmaps(clientset *kubernetes.Clientset, namespace string, cmName g.Go(func() error { cli := clientset.CoreV1().ConfigMaps(namespace) - name := fmt.Sprintf("%s-cm-%s-%d", appLabel, cmName, j) + name := fmt.Sprintf("%s-cm-%s-%d", data.AppLabel, cmName, j) cm := &corev1.ConfigMap{} cm.Name = name // Set the labels for the configmap to easily identify in delete or list commands cm.Labels = map[string]string{ "ownerID": strconv.Itoa(ownerID), - "app": appLabel, + "app": data.AppLabel, "cmName": cmName, } randData, err := data.RandString(size) @@ -387,7 +385,7 @@ func deleteConfigmapsByName(clientset *kubernetes.Clientset, namespace, cmName s var succeeded int64 g := new(errgroup.Group) for j := start; j < end; j++ { - name := fmt.Sprintf("%s-cm-%s-%d", appLebel, cmName, j) + name := fmt.Sprintf("%s-cm-%s-%d", data.AppLabel, cmName, j) g.Go(func() error { err := retryOnTransient(func() error { return cmClient.Delete(context.TODO(), name, metav1.DeleteOptions{}) diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index e1a4b8c..379a1aa 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -24,8 +24,6 @@ import ( "k8s.io/client-go/kubernetes" ) -var appLabel = "runkperf" - // defaultBatchSize is the number of events to list or delete per batch. // It is used as the page size for paginated API calls. const defaultBatchSize int64 = 300 @@ -200,7 +198,7 @@ var eventDelCommand = cli.Command{ if groupSize <= 0 { return fmt.Errorf("group-size must be greater than 0") } - labelSelector := fmt.Sprintf("app=%s,eventSetName=%s", appLabel, eventSetName) + labelSelector := fmt.Sprintf("app=%s,eventSetName=%s", data.AppLabel, eventSetName) clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) if err != nil { @@ -241,11 +239,11 @@ var eventListCommand = cli.Command{ var labelSelector string if cliCtx.NArg() == 0 { - labelSelector = fmt.Sprintf("app=%s", appLabel) + labelSelector = fmt.Sprintf("app=%s", data.AppLabel) } else { args := cliCtx.Args() namesStr := strings.Join(args, ",") - labelSelector = fmt.Sprintf("app=%s, eventSetName in (%s)", appLabel, namesStr) + labelSelector = fmt.Sprintf("app=%s, eventSetName in (%s)", data.AppLabel, namesStr) } evMap := make(map[string]*eventSetInfo) @@ -296,7 +294,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName strin for j := i; j < i+groupSize && j < total; j++ { idx := j g.Go(func() error { - name := fmt.Sprintf("%s-ev-%s-%d", appLabel, eventSetName, idx) + name := fmt.Sprintf("%s-ev-%s-%d", data.AppLabel, eventSetName, idx) message, err := data.RandString(messageSize) if err != nil { @@ -313,7 +311,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName strin Name: name, Namespace: namespace, Labels: map[string]string{ - "app": appLabel, + "app": data.AppLabel, "eventSetName": eventSetName, }, }, diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index bc2641c..bb95bab 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -24,8 +24,6 @@ import ( "k8s.io/client-go/kubernetes" ) -var appLabel = "runkperf" - // defaultBatchSize is the number of secrets to list or delete per batch. // It is used as the page size for paginated API calls. const defaultBatchSize int64 = 300 @@ -165,7 +163,7 @@ var secretDelCommand = cli.Command{ if groupSize <= 0 { return fmt.Errorf("group-size must be greater than 0") } - labelSelector := fmt.Sprintf("app=%s,secName=%s", appLabel, secName) + labelSelector := fmt.Sprintf("app=%s,secName=%s", data.AppLabel, secName) clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) if err != nil { @@ -206,11 +204,11 @@ var secretListCommand = cli.Command{ var labelSelector string if cliCtx.NArg() == 0 { - labelSelector = fmt.Sprintf("app=%s", appLabel) + labelSelector = fmt.Sprintf("app=%s", data.AppLabel) } else { args := cliCtx.Args() namesStr := strings.Join(args, ",") - labelSelector = fmt.Sprintf("app=%s, secName in (%s)", appLabel, namesStr) + labelSelector = fmt.Sprintf("app=%s, secName in (%s)", data.AppLabel, namesStr) } type secretSetInfo struct { @@ -291,7 +289,7 @@ func createSecrets(clientset *kubernetes.Clientset, namespace string, secName st for j := i; j < i+groupSize && j < total; j++ { idx := j g.Go(func() error { - name := fmt.Sprintf("%s-sec-%s-%d", appLabel, secName, idx) + name := fmt.Sprintf("%s-sec-%s-%d", data.AppLabel, secName, idx) randData, err := data.RandBytes(size) if err != nil { @@ -303,7 +301,7 @@ func createSecrets(clientset *kubernetes.Clientset, namespace string, secName st Name: name, Labels: map[string]string{ "ownerID": strconv.Itoa(ownerID), - "app": appLabel, + "app": data.AppLabel, "secName": secName, }, }, From 1c266e54779d707a8db5c1a8cd1eb770dc6d62f4 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Fri, 20 Mar 2026 15:12:00 +1000 Subject: [PATCH 11/20] update comments --- contrib/cmd/runkperf/commands/data/configmaps/configmap.go | 2 +- contrib/cmd/runkperf/commands/data/events/event.go | 2 +- contrib/cmd/runkperf/commands/data/secrets/secret.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go index 973e73e..3cd6447 100644 --- a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go +++ b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go @@ -41,7 +41,7 @@ var Command = cli.Command{ }, cli.StringFlag{ Name: "namespace", - Usage: "Namespace to use with commands. If the namespace does not exist, it will be created.", + Usage: "Namespace to use with commands. If the namespace does not exist, it will be created when executing add subcommand", Value: "default", }, }, diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index 379a1aa..dee4246 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -40,7 +40,7 @@ var Command = cli.Command{ }, cli.StringFlag{ Name: "namespace", - Usage: "Namespace to use with commands. If the namespace does not exist, it will be created.", + Usage: "Namespace to use with commands. If the namespace does not exist, it will be created when executing add subcommand.", Value: "default", }, }, diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index bb95bab..6741ae7 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -40,7 +40,7 @@ var Command = cli.Command{ }, cli.StringFlag{ Name: "namespace", - Usage: "Namespace to use with commands. If the namespace does not exist, it will be created.", + Usage: "Namespace to use with commands. If the namespace does not exist, it will be created when executing add subcommand", Value: "default", }, }, From 58665e673f78fbb38b5c55a075a747e603d66329 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Fri, 20 Mar 2026 16:39:07 +1000 Subject: [PATCH 12/20] Update contrib/cmd/runkperf/commands/data/secrets/secret.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- contrib/cmd/runkperf/commands/data/secrets/secret.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index 6741ae7..cd89b84 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -238,7 +238,7 @@ var secretListCommand = cli.Command{ ownerID, ok := sec.Labels["ownerID"] if !ok { - return fmt.Errorf("failed to find the ownerID of secret %s", name) + return fmt.Errorf("failed to find the ownerID of secret %s", sec.Name) } info.ownerID[ownerID]++ return nil From fc71c4988219136c84966fecbb659843d8f144fd Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Mon, 27 Apr 2026 09:59:16 +1000 Subject: [PATCH 13/20] Rename --message-size to --size in events add command for consistency with secrets and configmap commands --- contrib/cmd/runkperf/commands/data/events/event.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index dee4246..35c5f87 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -77,7 +77,7 @@ var eventAddCommand = cli.Command{ Value: "Normal", }, cli.IntFlag{ - Name: "message-size", + Name: "size", Usage: "The size of the event message in bytes (default targets ~5KB total event size; k8s metadata overhead is ~500-700 bytes)", Value: 5000, }, @@ -117,7 +117,7 @@ var eventAddCommand = cli.Command{ groupSize := cliCtx.Int("group-size") reason := cliCtx.String("reason") eventType := cliCtx.String("type") - messageSize := cliCtx.Int("message-size") + messageSize := cliCtx.Int("size") involvedObjectKind := cliCtx.String("involved-object-kind") involvedObjectName := cliCtx.String("involved-object-name") qps := float32(cliCtx.Float64("qps")) @@ -136,7 +136,7 @@ var eventAddCommand = cli.Command{ return fmt.Errorf("type must be either Normal or Warning") } if messageSize <= 0 { - return fmt.Errorf("message-size must be greater than 0") + return fmt.Errorf("size must be greater than 0") } clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) From 8da6c98b3e1a834f8784cbb8ca0d880dbb2199c0 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Wed, 6 May 2026 00:03:00 +1000 Subject: [PATCH 14/20] Remove unnecessary group-size > total check in events, secrets, and configmaps add commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch loop already caps work with `j < total`, so groupSize larger than total just results in a single batch — the validation adds no value. --- contrib/cmd/runkperf/commands/data/configmaps/configmap.go | 3 --- contrib/cmd/runkperf/commands/data/events/event.go | 3 --- contrib/cmd/runkperf/commands/data/secrets/secret.go | 3 --- 3 files changed, 9 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go index 3cd6447..f7098ec 100644 --- a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go +++ b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go @@ -254,9 +254,6 @@ func checkConfigmapParams(size int, groupSize int, total int) error { if total <= 0 { return fmt.Errorf("total amount must be greater than 0") } - if groupSize > total { - return fmt.Errorf("group-size must be less than or equal to total") - } return nil } diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index 35c5f87..1e685f8 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -129,9 +129,6 @@ var eventAddCommand = cli.Command{ if groupSize <= 0 { return fmt.Errorf("group-size must be greater than 0") } - if groupSize > total { - return fmt.Errorf("group-size must be less than or equal to total") - } if eventType != "Normal" && eventType != "Warning" { return fmt.Errorf("type must be either Normal or Warning") } diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index cd89b84..98f1148 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -276,9 +276,6 @@ func checkSecretParams(size int, groupSize int, total int) error { if total <= 0 { return fmt.Errorf("total amount must be greater than 0") } - if groupSize > total { - return fmt.Errorf("group-size must be less than or equal to total") - } return nil } From af9d46fe73f53f83ab38e9a8e386d22b2717a98e Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Tue, 29 Sep 2026 15:05:20 +1000 Subject: [PATCH 15/20] Add pod data ingestion subcommand to runkperf Add `runkperf data pod` (alias `po`) with add/delete/list subcommands following the events/secrets pattern. Pods are bare unschedulable pause containers padded via a runkperf.io/padding annotation (default 10 KiB) to load the apiserver/etcd with pod objects. Includes unit tests using a fake clientset. --- contrib/cmd/runkperf/commands/data/pod/pod.go | 380 ++++++++++++++++++ .../runkperf/commands/data/pod/pod_test.go | 101 +++++ contrib/cmd/runkperf/commands/root.go | 2 + 3 files changed, 483 insertions(+) create mode 100644 contrib/cmd/runkperf/commands/data/pod/pod.go create mode 100644 contrib/cmd/runkperf/commands/data/pod/pod_test.go diff --git a/contrib/cmd/runkperf/commands/data/pod/pod.go b/contrib/cmd/runkperf/commands/data/pod/pod.go new file mode 100644 index 0000000..bb972bf --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/pod/pod.go @@ -0,0 +1,380 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package pod + +import ( + "context" + "fmt" + "os" + "strings" + "text/tabwriter" + + "golang.org/x/sync/errgroup" + + "github.com/Azure/kperf/cmd/kperf/commands/utils" + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + + "github.com/urfave/cli" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// defaultBatchSize is the number of pods to list or delete per batch. +// It is used as the page size for paginated API calls. +const defaultBatchSize int64 = 300 + +// paddingAnnotation holds random bytes used to inflate each pod's object size. +const paddingAnnotation = "runkperf.io/padding" + +// pauseImage is the placeholder image used for the bare pods. The pods are not +// expected to be scheduled; they only load the apiserver/etcd with pod objects. +const pauseImage = "registry.k8s.io/pause:3.9" + +var Command = cli.Command{ + Name: "pod", + ShortName: "po", + Usage: "Manage pods", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "kubeconfig", + Usage: "Path to the kubeconfig file", + Value: utils.DefaultKubeConfigPath, + }, + cli.StringFlag{ + Name: "namespace", + Usage: "Namespace to use with commands. If the namespace does not exist, it will be created when executing add subcommand.", + Value: "default", + }, + }, + Subcommands: []cli.Command{ + podAddCommand, + podDelCommand, + podListCommand, + }, +} + +var podAddCommand = cli.Command{ + Name: "add", + Usage: "Add pods set", + ArgsUsage: "NAME of the pods set", + Flags: []cli.Flag{ + cli.IntFlag{ + Name: "total", + Usage: "Total number of pods to create", + Value: 10, + }, + cli.IntFlag{ + Name: "group-size", + Usage: "Number of pods to create in parallel per batch", + Value: 30, + }, + cli.IntFlag{ + Name: "size", + Usage: "The size of the padding stored in each pod annotation (Unit: Byte)", + Value: 10240, + }, + cli.Float64Flag{ + Name: "qps", + Usage: "QPS for the Kubernetes client rate limiter to control pod operations", + Value: 100, + }, + cli.IntFlag{ + Name: "burst", + Usage: "Burst for the Kubernetes client rate limiter to control pod operations", + Value: 200, + }, + }, + Action: func(cliCtx *cli.Context) error { + if cliCtx.NArg() != 1 { + return fmt.Errorf("required only one argument as pods set name: %v", cliCtx.Args()) + } + podSetName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(podSetName) == 0 { + return fmt.Errorf("required non-empty pod set name") + } + + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + namespace := cliCtx.GlobalString("namespace") + total := cliCtx.Int("total") + groupSize := cliCtx.Int("group-size") + size := cliCtx.Int("size") + qps := float32(cliCtx.Float64("qps")) + burst := cliCtx.Int("burst") + + if err := checkPodParams(size, groupSize, total); err != nil { + return err + } + + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) + if err != nil { + return err + } + + err = data.PrepareNamespace(clientset, namespace) + if err != nil { + return err + } + + err = createPods(clientset, namespace, podSetName, total, groupSize, size) + if err != nil { + return err + } + fmt.Printf("Created %d pods (set=%s, size=%d B) in namespace %s\n", + total, podSetName, size, namespace) + return nil + }, +} + +var podDelCommand = cli.Command{ + Name: "delete", + ShortName: "del", + ArgsUsage: "NAME", + Usage: "Delete a pods set", + Flags: []cli.Flag{ + cli.Float64Flag{ + Name: "qps", + Usage: "QPS for the Kubernetes client rate limiter to control pod deletion", + Value: 100, + }, + cli.IntFlag{ + Name: "burst", + Usage: "Burst for the Kubernetes client rate limiter to control pod deletion", + Value: 200, + }, + cli.IntFlag{ + Name: "group-size", + Usage: "Number of pods to delete in parallel per batch", + Value: 30, + }, + }, + Action: func(cliCtx *cli.Context) error { + if cliCtx.NArg() != 1 { + return fmt.Errorf("required only one pods set name") + } + podSetName := strings.TrimSpace(cliCtx.Args().Get(0)) + if len(podSetName) == 0 { + return fmt.Errorf("required non-empty pods set name") + } + + namespace := cliCtx.GlobalString("namespace") + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + qps := float32(cliCtx.Float64("qps")) + burst := cliCtx.Int("burst") + groupSize := cliCtx.Int("group-size") + if groupSize <= 0 { + return fmt.Errorf("group-size must be greater than 0") + } + labelSelector := fmt.Sprintf("app=%s,podSetName=%s", data.AppLabel, podSetName) + + clientset, err := data.NewClientsetWithRateLimiter(kubeCfgPath, qps, burst) + if err != nil { + return err + } + + err = deletePods(clientset, labelSelector, namespace, groupSize) + if err != nil { + return err + } + + fmt.Printf("Deleted pods set %s in %s namespace\n", podSetName, namespace) + return nil + }, +} + +var podListCommand = cli.Command{ + Name: "list", + Usage: "List generated pod sets. Lists all if no arguments are given; otherwise, provide pod set names separated by spaces.", + ArgsUsage: "[NAME ...]", + Action: func(cliCtx *cli.Context) error { + namespace := cliCtx.GlobalString("namespace") + kubeCfgPath := cliCtx.GlobalString("kubeconfig") + clientset, err := data.NewClientset(kubeCfgPath) + if err != nil { + return err + } + + const ( + minWidth = 1 + tabWidth = 12 + padding = 3 + padChar = ' ' + flags = 0 + ) + tw := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags) + fmt.Fprintln(tw, "NAME\tSIZE\tTOTAL\t") + + var labelSelector string + if cliCtx.NArg() == 0 { + labelSelector = fmt.Sprintf("app=%s", data.AppLabel) + } else { + args := cliCtx.Args() + namesStr := strings.Join(args, ",") + labelSelector = fmt.Sprintf("app=%s, podSetName in (%s)", data.AppLabel, namesStr) + } + + podMap := make(map[string]*podSetInfo) + err = listPods(clientset, labelSelector, namespace, defaultBatchSize, func(pod corev1.Pod) error { + name, ok := pod.Labels["podSetName"] + if !ok { + return nil + } + + info, ok := podMap[name] + if !ok { + info = &podSetInfo{ + size: len(pod.Annotations[paddingAnnotation]), + } + podMap[name] = info + } + info.total++ + return nil + }) + if err != nil { + return err + } + + for name, info := range podMap { + fmt.Fprintf(tw, "%s\t%d\t%d\n", + name, + info.size, + info.total, + ) + } + return tw.Flush() + }, +} + +type podSetInfo struct { + size int + total int +} + +func checkPodParams(size, groupSize, total int) error { + if size <= 0 { + return fmt.Errorf("size must be greater than 0") + } + if groupSize <= 0 { + return fmt.Errorf("group-size must be greater than 0") + } + if total <= 0 { + return fmt.Errorf("total must be greater than 0") + } + return nil +} + +func createPods(clientset kubernetes.Interface, namespace, podSetName string, total, groupSize, size int) error { + for i := 0; i < total; i += groupSize { + g := new(errgroup.Group) + for j := i; j < i+groupSize && j < total; j++ { + idx := j + g.Go(func() error { + name := fmt.Sprintf("%s-pod-%s-%d", data.AppLabel, podSetName, idx) + + padding, err := data.RandString(size) + if err != nil { + return fmt.Errorf("failed to generate random padding for pod %s: %v", name, err) + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "app": data.AppLabel, + "podSetName": podSetName, + }, + Annotations: map[string]string{ + paddingAnnotation: padding, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: pauseImage, + }, + }, + }, + } + + _, err = clientset.CoreV1().Pods(namespace).Create(context.TODO(), pod, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create pod %s: %v", name, err) + } + return nil + }) + } + if err := g.Wait(); err != nil { + return err + } + } + return nil +} + +func deletePods(clientset kubernetes.Interface, labelSelector, namespace string, groupSize int) error { + var names []string + err := listPods(clientset, labelSelector, namespace, defaultBatchSize, func(pod corev1.Pod) error { + names = append(names, pod.Name) + return nil + }) + if err != nil { + return err + } + + if len(names) == 0 { + fmt.Printf("No pods found in namespace %s, nothing to delete\n", namespace) + return nil + } + + n := len(names) + for i := 0; i < n; i += groupSize { + g := new(errgroup.Group) + for j := i; j < i+groupSize && j < n; j++ { + idx := j + g.Go(func() error { + err := clientset.CoreV1().Pods(namespace).Delete(context.TODO(), names[idx], metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete pod %s: %v", names[idx], err) + } + return nil + }) + } + if err := g.Wait(); err != nil { + return err + } + } + return nil +} + +func listPods(clientset kubernetes.Interface, labelSelector, namespace string, limit int64, fn func(corev1.Pod) error) error { + if limit <= 0 { + limit = defaultBatchSize + } + var continueToken string + for { + podList, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{ + LabelSelector: labelSelector, + Limit: limit, + Continue: continueToken, + }) + if err != nil { + return fmt.Errorf("failed to list pods: %v", err) + } + + for _, pod := range podList.Items { + if err := fn(pod); err != nil { + return err + } + } + + continueToken = podList.Continue + if continueToken == "" { + break + } + } + return nil +} diff --git a/contrib/cmd/runkperf/commands/data/pod/pod_test.go b/contrib/cmd/runkperf/commands/data/pod/pod_test.go new file mode 100644 index 0000000..4b673b8 --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/pod/pod_test.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package pod + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" +) + +func TestCheckPodParams(t *testing.T) { + assert.NoError(t, checkPodParams(1, 1, 1)) + assert.Error(t, checkPodParams(0, 1, 1)) + assert.Error(t, checkPodParams(1, 0, 1)) + assert.Error(t, checkPodParams(1, 1, 0)) +} + +func TestCreatePods(t *testing.T) { + clientset := fake.NewSimpleClientset() + const ( + namespace = "default" + podSetName = "set-a" + total = 7 + groupSize = 3 + size = 128 + ) + + err := createPods(clientset, namespace, podSetName, total, groupSize, size) + require.NoError(t, err) + + pods, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, pods.Items, total) + + for _, pod := range pods.Items { + assert.Equal(t, data.AppLabel, pod.Labels["app"]) + assert.Equal(t, podSetName, pod.Labels["podSetName"]) + assert.Len(t, pod.Annotations[paddingAnnotation], size) + require.Len(t, pod.Spec.Containers, 1) + assert.Equal(t, pauseImage, pod.Spec.Containers[0].Image) + } +} + +func TestListPods(t *testing.T) { + clientset := fake.NewSimpleClientset() + const namespace = "default" + + require.NoError(t, createPods(clientset, namespace, "set-a", 5, 3, 64)) + require.NoError(t, createPods(clientset, namespace, "set-b", 2, 3, 32)) + + podMap := make(map[string]*podSetInfo) + selector := "app=" + data.AppLabel + err := listPods(clientset, selector, namespace, defaultBatchSize, func(pod corev1.Pod) error { + name := pod.Labels["podSetName"] + info, ok := podMap[name] + if !ok { + info = &podSetInfo{size: len(pod.Annotations[paddingAnnotation])} + podMap[name] = info + } + info.total++ + return nil + }) + require.NoError(t, err) + + require.Contains(t, podMap, "set-a") + require.Contains(t, podMap, "set-b") + assert.Equal(t, 5, podMap["set-a"].total) + assert.Equal(t, 64, podMap["set-a"].size) + assert.Equal(t, 2, podMap["set-b"].total) + assert.Equal(t, 32, podMap["set-b"].size) +} + +func TestDeletePods(t *testing.T) { + clientset := fake.NewSimpleClientset() + const namespace = "default" + + require.NoError(t, createPods(clientset, namespace, "set-a", 4, 2, 16)) + require.NoError(t, createPods(clientset, namespace, "set-b", 3, 2, 16)) + + selectorA := "app=" + data.AppLabel + ",podSetName=set-a" + require.NoError(t, deletePods(clientset, selectorA, namespace, 2)) + + remaining, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + require.NoError(t, err) + assert.Len(t, remaining.Items, 3) + for _, pod := range remaining.Items { + assert.Equal(t, "set-b", pod.Labels["podSetName"]) + } + + // Deleting a set that does not exist is a no-op and returns no error. + require.NoError(t, deletePods(clientset, "app="+data.AppLabel+",podSetName=missing", namespace, 2)) +} diff --git a/contrib/cmd/runkperf/commands/root.go b/contrib/cmd/runkperf/commands/root.go index ab86680..bc3342d 100644 --- a/contrib/cmd/runkperf/commands/root.go +++ b/contrib/cmd/runkperf/commands/root.go @@ -14,6 +14,7 @@ import ( "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/configmaps" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/daemonsets" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/events" + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/pod" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data/secrets" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/warmup" @@ -26,6 +27,7 @@ func init() { configmaps.Command, daemonsets.Command, events.Command, + pod.Command, secrets.Command, ) } From a35a2240f5f24eaf7340358c4c83957da1c54ae5 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Wed, 21 Oct 2026 16:17:52 +1000 Subject: [PATCH 16/20] Consolidate random payload generation in request package Reuse crypto/rand-backed helpers across benchmark data commands and expand payload tests. Revert the branch-specific Claude ignore entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 -- contrib/cmd/runkperf/commands/data/client.go | 25 ----------- .../commands/data/configmaps/configmap.go | 8 ++-- .../runkperf/commands/data/events/event.go | 8 ++-- contrib/cmd/runkperf/commands/data/pod/pod.go | 8 ++-- .../runkperf/commands/data/secrets/secret.go | 3 +- request/random.go | 31 ++++++++++---- request/random_test.go | 42 ++++++++++++++----- 8 files changed, 65 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index df1d344..d8b5cd0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,3 @@ tmp/ # VSCode settings .vscode/ - -# Claude Code -.claude/ diff --git a/contrib/cmd/runkperf/commands/data/client.go b/contrib/cmd/runkperf/commands/data/client.go index 3c894fe..a4473f4 100644 --- a/contrib/cmd/runkperf/commands/data/client.go +++ b/contrib/cmd/runkperf/commands/data/client.go @@ -6,7 +6,6 @@ package data import ( "context" "fmt" - "math/rand" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -64,27 +63,3 @@ func PrepareNamespace(clientset *kubernetes.Clientset, namespace string) error { } return nil } - -var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -// RandBytes generates a random byte slice of length n using alphanumeric characters. -func RandBytes(n int) ([]byte, error) { - if n <= 0 { - return nil, fmt.Errorf("length must be positive") - } - - b := make([]byte, n) - for i := range b { - b[i] = byte(letterRunes[rand.Intn(len(letterRunes))]) //nolint:gosec // G404: intentionally using math/rand for benchmark data, cryptographic randomness not needed - } - return b, nil -} - -// RandString generates a random string of length n using alphanumeric characters. -func RandString(n int) (string, error) { - b, err := RandBytes(n) - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go index f7098ec..c44149a 100644 --- a/contrib/cmd/runkperf/commands/data/configmaps/configmap.go +++ b/contrib/cmd/runkperf/commands/data/configmaps/configmap.go @@ -18,6 +18,7 @@ import ( "github.com/Azure/kperf/cmd/kperf/commands/utils" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + "github.com/Azure/kperf/request" "github.com/urfave/cli" @@ -331,15 +332,12 @@ func createConfigmaps(clientset *kubernetes.Clientset, namespace string, cmName "app": data.AppLabel, "cmName": cmName, } - randData, err := data.RandString(size) - if err != nil { - return fmt.Errorf("failed to generate random string for configmap %s: %v", name, err) - } + randData := request.RandomPayload(size) cm.Data = map[string]string{ "data": randData, } - err = retryOnTransient(func() error { + err := retryOnTransient(func() error { _, createErr := cli.Create(context.TODO(), cm, metav1.CreateOptions{}) // A retry after a dropped connection may hit an object that the // previous (lost) attempt already created; treat it as success. diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index 1e685f8..b2e45c7 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -15,6 +15,7 @@ import ( "github.com/Azure/kperf/cmd/kperf/commands/utils" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + "github.com/Azure/kperf/request" "github.com/urfave/cli" @@ -293,10 +294,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName strin g.Go(func() error { name := fmt.Sprintf("%s-ev-%s-%d", data.AppLabel, eventSetName, idx) - message, err := data.RandString(messageSize) - if err != nil { - return fmt.Errorf("failed to generate random message for event %s: %v", name, err) - } + message := request.RandomPayload(messageSize) objName := involvedObjectName if objName == "" { @@ -328,7 +326,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName strin }, } - _, err = clientset.CoreV1().Events(namespace).Create(context.TODO(), event, metav1.CreateOptions{}) + _, err := clientset.CoreV1().Events(namespace).Create(context.TODO(), event, metav1.CreateOptions{}) if err != nil { return fmt.Errorf("failed to create event %s: %v", name, err) } diff --git a/contrib/cmd/runkperf/commands/data/pod/pod.go b/contrib/cmd/runkperf/commands/data/pod/pod.go index bb972bf..92e0e8a 100644 --- a/contrib/cmd/runkperf/commands/data/pod/pod.go +++ b/contrib/cmd/runkperf/commands/data/pod/pod.go @@ -14,6 +14,7 @@ import ( "github.com/Azure/kperf/cmd/kperf/commands/utils" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + "github.com/Azure/kperf/request" "github.com/urfave/cli" @@ -274,10 +275,7 @@ func createPods(clientset kubernetes.Interface, namespace, podSetName string, to g.Go(func() error { name := fmt.Sprintf("%s-pod-%s-%d", data.AppLabel, podSetName, idx) - padding, err := data.RandString(size) - if err != nil { - return fmt.Errorf("failed to generate random padding for pod %s: %v", name, err) - } + padding := request.RandomPayload(size) pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -301,7 +299,7 @@ func createPods(clientset kubernetes.Interface, namespace, podSetName string, to }, } - _, err = clientset.CoreV1().Pods(namespace).Create(context.TODO(), pod, metav1.CreateOptions{}) + _, err := clientset.CoreV1().Pods(namespace).Create(context.TODO(), pod, metav1.CreateOptions{}) if err != nil { return fmt.Errorf("failed to create pod %s: %v", name, err) } diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index 98f1148..b00ac1d 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -15,6 +15,7 @@ import ( "github.com/Azure/kperf/cmd/kperf/commands/utils" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + "github.com/Azure/kperf/request" "github.com/urfave/cli" @@ -288,7 +289,7 @@ func createSecrets(clientset *kubernetes.Clientset, namespace string, secName st g.Go(func() error { name := fmt.Sprintf("%s-sec-%s-%d", data.AppLabel, secName, idx) - randData, err := data.RandBytes(size) + randData, err := request.RandBytes(size) if err != nil { return fmt.Errorf("failed to generate random data for secret %s: %v", name, err) } diff --git a/request/random.go b/request/random.go index 070133f..f5d1967 100644 --- a/request/random.go +++ b/request/random.go @@ -386,7 +386,7 @@ func renderPatchBody(raw string) []byte { if !strings.Contains(raw, patchRandomPlaceholder) { return []byte(raw) } - return []byte(strings.ReplaceAll(raw, patchRandomPlaceholder, randomPayload(8))) + return []byte(strings.ReplaceAll(raw, patchRandomPlaceholder, RandomPayload(8))) } func newRequestPatchBuilder(src *types.RequestPatch, resourceVersion string, maxRetries int) *requestPatchBuilder { @@ -526,7 +526,7 @@ func (b *requestPostDelBuilder) Build(cli rest.Interface) Requester { body, err := utils.RenderTemplate(b.resource, map[string]interface{}{ "namePattern": name, "namespace": b.namespace, - "payload": randomPayload(b.payloadSize), + "payload": RandomPayload(b.payloadSize), "nodeSelector": b.nodeSelector, }) if err != nil { @@ -624,7 +624,7 @@ func (b *requestPutBuilder) Build(cli rest.Interface) Requester { Name: finalName, Namespace: b.namespace, }, - Data: map[string]string{"payload": randomPayload(b.payloadSize)}, + Data: map[string]string{"payload": RandomPayload(b.payloadSize)}, } body, _ := json.Marshal(cm) @@ -636,13 +636,26 @@ func (b *requestPutBuilder) Build(cli rest.Interface) Requester { } } -// randomPayload returns a string of exactly n bytes, uniformly sampled from +// RandomPayload returns a string of exactly n bytes, uniformly sampled from // [a-zA-Z0-9] via crypto/rand with rejection sampling to avoid modulo bias. -// Returns "" when n <= 0. -func randomPayload(n int) string { +// It returns "" when n <= 0 and panics if reading random bytes fails. +func RandomPayload(n int) string { if n <= 0 { return "" } + b, err := RandBytes(n) + if err != nil { + panic(err) + } + return string(b) +} + +// RandBytes returns n uniformly sampled alphanumeric bytes using crypto/rand. +// It returns an error for non-positive lengths or if reading random bytes fails. +func RandBytes(n int) ([]byte, error) { + if n <= 0 { + return nil, fmt.Errorf("length must be positive") + } const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // Only use byte values in [0, threshold). Since len(alphabet)=62 and // 248 is the largest multiple of 62 below 256, mapping b%62 is uniform @@ -659,7 +672,7 @@ func randomPayload(n int) string { for i := 0; i < n; { if _, err := rand.Read(buf); err != nil { - panic(fmt.Errorf("failed to read random bytes: %w", err)) + return nil, fmt.Errorf("failed to read random bytes: %w", err) } for _, b := range buf { @@ -669,10 +682,10 @@ func randomPayload(n int) string { out[i] = alphabet[int(b)%len(alphabet)] i++ if i == n { - return string(out) + return out, nil } } } - return string(out) + return out, nil } diff --git a/request/random_test.go b/request/random_test.go index 865958f..81e6996 100644 --- a/request/random_test.go +++ b/request/random_test.go @@ -4,6 +4,7 @@ package request import ( + "strconv" "sync" "testing" @@ -34,17 +35,38 @@ func TestRenderPatchBody(t *testing.T) { } func TestRandomPayload(t *testing.T) { - const n = 8 - s := randomPayload(n) - assert.Len(t, s, n) - for _, r := range s { - isAlnum := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') - assert.True(t, isAlnum, "unexpected char %q", r) + for _, n := range []int{-1, 0, 1, 8, 62, 4095, 4096, 4097, 16384} { + t.Run(strconv.Itoa(n), func(t *testing.T) { + t.Parallel() + s := RandomPayload(n) + if n <= 0 { + assert.Empty(t, s) + return + } + assert.Len(t, s, n) + assert.Regexp(t, `^[a-zA-Z0-9]+$`, s) + }) + } + assert.NotEqual(t, RandomPayload(32), RandomPayload(32)) +} + +func TestRandBytes(t *testing.T) { + for _, n := range []int{-1, 0, 1, 8, 62, 4095, 4096, 4097, 16384} { + t.Run(strconv.Itoa(n), func(t *testing.T) { + t.Parallel() + b, err := RandBytes(n) + if n <= 0 { + assert.EqualError(t, err, "length must be positive") + assert.Nil(t, b) + return + } + if !assert.NoError(t, err) { + return + } + assert.Len(t, b, n) + assert.Regexp(t, `^[a-zA-Z0-9]+$`, string(b)) + }) } - // n <= 0 returns an empty string. - assert.Equal(t, "", randomPayload(0)) - // Two calls should not collide. - assert.NotEqual(t, randomPayload(n), randomPayload(n)) } func TestRequestPatchBuilderRendersUniqueBodies(t *testing.T) { From 7019f6bf46a65ae875a7d418847d40967e7281c0 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Thu, 22 Oct 2026 10:30:30 +1000 Subject: [PATCH 17/20] Keep ingestion pods unscheduled with a dedicated scheduler name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contrib/cmd/runkperf/commands/data/pod/pod.go | 7 +++++-- contrib/cmd/runkperf/commands/data/pod/pod_test.go | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/pod/pod.go b/contrib/cmd/runkperf/commands/data/pod/pod.go index 92e0e8a..9c3b38f 100644 --- a/contrib/cmd/runkperf/commands/data/pod/pod.go +++ b/contrib/cmd/runkperf/commands/data/pod/pod.go @@ -31,8 +31,10 @@ const defaultBatchSize int64 = 300 // paddingAnnotation holds random bytes used to inflate each pod's object size. const paddingAnnotation = "runkperf.io/padding" -// pauseImage is the placeholder image used for the bare pods. The pods are not -// expected to be scheduled; they only load the apiserver/etcd with pod objects. +// Use an unhandled scheduler name to keep ingestion pods unscheduled. +const dataOnlySchedulerName = "runkperf-data-only" + +// pauseImage is the placeholder image used for the bare ingestion pods. const pauseImage = "registry.k8s.io/pause:3.9" var Command = cli.Command{ @@ -290,6 +292,7 @@ func createPods(clientset kubernetes.Interface, namespace, podSetName string, to }, }, Spec: corev1.PodSpec{ + SchedulerName: dataOnlySchedulerName, Containers: []corev1.Container{ { Name: "main", diff --git a/contrib/cmd/runkperf/commands/data/pod/pod_test.go b/contrib/cmd/runkperf/commands/data/pod/pod_test.go index 4b673b8..f9b059c 100644 --- a/contrib/cmd/runkperf/commands/data/pod/pod_test.go +++ b/contrib/cmd/runkperf/commands/data/pod/pod_test.go @@ -45,6 +45,7 @@ func TestCreatePods(t *testing.T) { assert.Equal(t, data.AppLabel, pod.Labels["app"]) assert.Equal(t, podSetName, pod.Labels["podSetName"]) assert.Len(t, pod.Annotations[paddingAnnotation], size) + assert.Equal(t, "runkperf-data-only", pod.Spec.SchedulerName) require.Len(t, pod.Spec.Containers, 1) assert.Equal(t, pauseImage, pod.Spec.Containers[0].Image) } From 2ac294dc40d736e14707e73176b74ad88fc7d950 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Thu, 22 Oct 2026 14:48:07 +1000 Subject: [PATCH 18/20] Share pagination and batched deletion across data commands Standardize Event, Secret, and Pod helpers on kubernetes.Interface, group Event creation arguments into options, and add regression tests for shared operations and resource adapters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../runkperf/commands/data/events/event.go | 129 +++++------ .../commands/data/events/event_test.go | 114 ++++++++++ .../cmd/runkperf/commands/data/operations.go | 74 +++++++ .../runkperf/commands/data/operations_test.go | 202 ++++++++++++++++++ contrib/cmd/runkperf/commands/data/pod/pod.go | 73 ++----- .../runkperf/commands/data/pod/pod_test.go | 60 +++++- .../runkperf/commands/data/secrets/secret.go | 79 ++----- .../commands/data/secrets/secret_test.go | 101 +++++++++ 8 files changed, 628 insertions(+), 204 deletions(-) create mode 100644 contrib/cmd/runkperf/commands/data/events/event_test.go create mode 100644 contrib/cmd/runkperf/commands/data/operations.go create mode 100644 contrib/cmd/runkperf/commands/data/operations_test.go create mode 100644 contrib/cmd/runkperf/commands/data/secrets/secret_test.go diff --git a/contrib/cmd/runkperf/commands/data/events/event.go b/contrib/cmd/runkperf/commands/data/events/event.go index b2e45c7..ef33756 100644 --- a/contrib/cmd/runkperf/commands/data/events/event.go +++ b/contrib/cmd/runkperf/commands/data/events/event.go @@ -20,15 +20,10 @@ import ( "github.com/urfave/cli" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) -// defaultBatchSize is the number of events to list or delete per batch. -// It is used as the page size for paginated API calls. -const defaultBatchSize int64 = 300 - var Command = cli.Command{ Name: "event", ShortName: "ev", @@ -147,7 +142,17 @@ var eventAddCommand = cli.Command{ return err } - err = createEvents(clientset, namespace, eventSetName, total, groupSize, reason, eventType, messageSize, involvedObjectKind, involvedObjectName) + err = createEvents(clientset, createOptions{ + Namespace: namespace, + EventSetName: eventSetName, + Total: total, + GroupSize: groupSize, + Reason: reason, + EventType: eventType, + MessageSize: messageSize, + InvolvedObjectKind: involvedObjectKind, + InvolvedObjectName: involvedObjectName, + }) if err != nil { return err } @@ -245,7 +250,7 @@ var eventListCommand = cli.Command{ } evMap := make(map[string]*eventSetInfo) - err = listEvents(clientset, labelSelector, namespace, defaultBatchSize, func(ev corev1.Event) error { + err = listEvents(clientset, labelSelector, namespace, data.DefaultPageSize, func(ev corev1.Event) error { name, ok := ev.Labels["eventSetName"] if !ok { return nil @@ -284,40 +289,52 @@ type eventSetInfo struct { total int } -func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName string, total, groupSize int, reason, eventType string, messageSize int, involvedObjectKind, involvedObjectName string) error { +type createOptions struct { + Namespace string + EventSetName string + Total int + GroupSize int + Reason string + EventType string + MessageSize int + InvolvedObjectKind string + InvolvedObjectName string +} + +func createEvents(clientset kubernetes.Interface, opts createOptions) error { now := time.Now() - for i := 0; i < total; i += groupSize { + for i := 0; i < opts.Total; i += opts.GroupSize { g := new(errgroup.Group) - for j := i; j < i+groupSize && j < total; j++ { + for j := i; j < i+opts.GroupSize && j < opts.Total; j++ { idx := j g.Go(func() error { - name := fmt.Sprintf("%s-ev-%s-%d", data.AppLabel, eventSetName, idx) + name := fmt.Sprintf("%s-ev-%s-%d", data.AppLabel, opts.EventSetName, idx) - message := request.RandomPayload(messageSize) + message := request.RandomPayload(opts.MessageSize) - objName := involvedObjectName + objName := opts.InvolvedObjectName if objName == "" { - objName = fmt.Sprintf("%s-obj-%d", eventSetName, idx) + objName = fmt.Sprintf("%s-obj-%d", opts.EventSetName, idx) } event := &corev1.Event{ ObjectMeta: metav1.ObjectMeta{ Name: name, - Namespace: namespace, + Namespace: opts.Namespace, Labels: map[string]string{ "app": data.AppLabel, - "eventSetName": eventSetName, + "eventSetName": opts.EventSetName, }, }, InvolvedObject: corev1.ObjectReference{ - Kind: involvedObjectKind, + Kind: opts.InvolvedObjectKind, Name: objName, - Namespace: namespace, + Namespace: opts.Namespace, }, - Reason: reason, + Reason: opts.Reason, Message: message, - Type: eventType, + Type: opts.EventType, Count: 1, FirstTimestamp: metav1.NewTime(now), LastTimestamp: metav1.NewTime(now), @@ -326,7 +343,7 @@ func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName strin }, } - _, err := clientset.CoreV1().Events(namespace).Create(context.TODO(), event, metav1.CreateOptions{}) + _, err := clientset.CoreV1().Events(opts.Namespace).Create(context.TODO(), event, metav1.CreateOptions{}) if err != nil { return fmt.Errorf("failed to create event %s: %v", name, err) } @@ -340,66 +357,22 @@ func createEvents(clientset *kubernetes.Clientset, namespace, eventSetName strin return nil } -func deleteEvents(clientset *kubernetes.Clientset, labelSelector, namespace string, groupSize int) error { - var names []string - err := listEvents(clientset, labelSelector, namespace, defaultBatchSize, func(ev corev1.Event) error { - names = append(names, ev.Name) - return nil +func deleteEvents(clientset kubernetes.Interface, labelSelector, namespace string, groupSize int) error { + return data.BatchDelete("event", namespace, groupSize, func(visit func(string) error) error { + return listEvents(clientset, labelSelector, namespace, data.DefaultPageSize, func(ev corev1.Event) error { + return visit(ev.Name) + }) + }, func(name string) error { + return clientset.CoreV1().Events(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) }) - if err != nil { - return err - } - - if len(names) == 0 { - fmt.Printf("No events found in namespace %s, nothing to delete\n", namespace) - return nil - } - - n := len(names) - for i := 0; i < n; i += groupSize { - g := new(errgroup.Group) - for j := i; j < i+groupSize && j < n; j++ { - idx := j - g.Go(func() error { - err := clientset.CoreV1().Events(namespace).Delete(context.TODO(), names[idx], metav1.DeleteOptions{}) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete event %s: %v", names[idx], err) - } - return nil - }) - } - if err := g.Wait(); err != nil { - return err - } - } - return nil } -func listEvents(clientset *kubernetes.Clientset, labelSelector, namespace string, limit int64, fn func(corev1.Event) error) error { - if limit <= 0 { - limit = defaultBatchSize - } - var continueToken string - for { - eventList, err := clientset.CoreV1().Events(namespace).List(context.TODO(), metav1.ListOptions{ - LabelSelector: labelSelector, - Limit: limit, - Continue: continueToken, - }) +func listEvents(clientset kubernetes.Interface, labelSelector, namespace string, limit int64, fn func(corev1.Event) error) error { + return data.ListPaginated(metav1.ListOptions{LabelSelector: labelSelector, Limit: limit}, func(opts metav1.ListOptions) ([]corev1.Event, string, error) { + eventList, err := clientset.CoreV1().Events(namespace).List(context.TODO(), opts) if err != nil { - return fmt.Errorf("failed to list events: %v", err) - } - - for _, ev := range eventList.Items { - if err := fn(ev); err != nil { - return err - } + return nil, "", fmt.Errorf("failed to list events: %w", err) } - - continueToken = eventList.Continue - if continueToken == "" { - break - } - } - return nil + return eventList.Items, eventList.Continue, nil + }, fn) } diff --git a/contrib/cmd/runkperf/commands/data/events/event_test.go b/contrib/cmd/runkperf/commands/data/events/event_test.go new file mode 100644 index 0000000..addfbe6 --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/events/event_test.go @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package events + +import ( + "context" + "errors" + "testing" + + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func TestCreateAndDeleteEvents(t *testing.T) { + client := fake.NewSimpleClientset() + opts := createOptions{ + Namespace: "default", EventSetName: "set-a", Total: 5, GroupSize: 2, + Reason: "Benchmark", EventType: "Warning", MessageSize: 32, InvolvedObjectKind: "Pod", + } + require.NoError(t, createEvents(client, opts)) + other := opts + other.EventSetName = "set-b" + other.Total = 1 + other.InvolvedObjectName = "explicit" + require.NoError(t, createEvents(client, other)) + events, err := client.CoreV1().Events(opts.Namespace).List(context.TODO(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, events.Items, 6) + for _, event := range events.Items { + assert.Equal(t, data.AppLabel, event.Labels["app"]) + assert.Equal(t, opts.Namespace, event.Namespace) + assert.Equal(t, opts.Namespace, event.InvolvedObject.Namespace) + assert.Equal(t, opts.InvolvedObjectKind, event.InvolvedObject.Kind) + assert.Equal(t, opts.Reason, event.Reason) + assert.Equal(t, opts.EventType, event.Type) + assert.Len(t, event.Message, opts.MessageSize) + assert.Equal(t, int32(1), event.Count) + assert.Equal(t, "runkperf-bench", event.Source.Component) + assert.False(t, event.FirstTimestamp.IsZero()) + assert.Equal(t, event.FirstTimestamp, event.LastTimestamp) + if event.Labels["eventSetName"] == "set-b" { + assert.Equal(t, "explicit", event.InvolvedObject.Name) + } else { + assert.Contains(t, event.InvolvedObject.Name, "set-a-obj-") + } + } + require.NoError(t, deleteEvents(client, "app=runkperf,eventSetName=set-a", opts.Namespace, 2)) + events, err = client.CoreV1().Events(opts.Namespace).List(context.TODO(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, events.Items, 1) + assert.Equal(t, "set-b", events.Items[0].Labels["eventSetName"]) +} + +func TestDeleteEventsPages(t *testing.T) { + client := fake.NewSimpleClientset() + calls := 0 + client.PrependReactor("list", "events", func(action ktesting.Action) (bool, runtime.Object, error) { + opts := action.(ktesting.ListActionImpl).GetListOptions() + assert.Equal(t, "default", action.GetNamespace()) + assert.Equal(t, "app=runkperf", opts.LabelSelector) + assert.Equal(t, data.DefaultPageSize, opts.Limit) + list := &corev1.EventList{} + if calls == 0 { + assert.Empty(t, opts.Continue) + list.Continue = "next" + list.Items = []corev1.Event{{ObjectMeta: metav1.ObjectMeta{Name: "first", Labels: map[string]string{"app": "runkperf"}}}} + } else { + assert.Equal(t, 1, calls) + assert.Equal(t, "next", opts.Continue) + list.Items = []corev1.Event{{ObjectMeta: metav1.ObjectMeta{Name: "second", Labels: map[string]string{"app": "runkperf"}}}} + } + calls++ + return true, list, nil + }) + var deleted []string + client.PrependReactor("delete", "events", func(action ktesting.Action) (bool, runtime.Object, error) { + assert.Equal(t, 2, calls, "all pages must be fetched before deletion") + name := action.(ktesting.DeleteAction).GetName() + deleted = append(deleted, name) + return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "events"}, name) + }) + require.NoError(t, deleteEvents(client, "app=runkperf", "default", 1)) + assert.Equal(t, []string{"first", "second"}, deleted) +} + +func TestEventOperationErrors(t *testing.T) { + for _, verb := range []string{"list", "delete"} { + t.Run(verb, func(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "event", Namespace: "default"}}) + sentinel := errors.New("denied") + client.PrependReactor(verb, "events", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, sentinel + }) + err := deleteEvents(client, "", "default", 1) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Contains(t, err.Error(), "failed to "+verb+" event") + if verb == "list" { + for _, action := range client.Actions() { + assert.NotEqual(t, "delete", action.GetVerb()) + } + } + }) + } +} diff --git a/contrib/cmd/runkperf/commands/data/operations.go b/contrib/cmd/runkperf/commands/data/operations.go new file mode 100644 index 0000000..29c67fa --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/operations.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package data + +import ( + "fmt" + + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DefaultPageSize is the list page size for Event, Secret, and Pod data commands. +const DefaultPageSize int64 = 300 + +// ListPaginated visits items from successive pages until the continuation token +// is empty. List and visit errors stop traversal immediately. +func ListPaginated[T any](opts metav1.ListOptions, list func(metav1.ListOptions) ([]T, string, error), visit func(T) error) error { + if opts.Limit <= 0 { + opts.Limit = DefaultPageSize + } + for { + items, next, err := list(opts) + if err != nil { + return err + } + for _, item := range items { + if err := visit(item); err != nil { + return err + } + } + if next == "" { + return nil + } + opts.Continue = next + } +} + +// BatchDelete collects all names before deleting in fixed concurrent batches. +// resource is the singular resource name used in messages. Missing objects are +// ignored; other errors stop deletion after the current batch finishes. +func BatchDelete(resource, namespace string, groupSize int, list func(func(string) error) error, deleteFn func(string) error) error { + if groupSize <= 0 { + return fmt.Errorf("group-size must be greater than 0") + } + var names []string + if err := list(func(name string) error { + names = append(names, name) + return nil + }); err != nil { + return err + } + if len(names) == 0 { + fmt.Printf("No %ss found in namespace %s, nothing to delete\n", resource, namespace) + return nil + } + for start := 0; start < len(names); start += groupSize { + g := new(errgroup.Group) + for i := start; i < len(names) && i-start < groupSize; i++ { + name := names[i] + g.Go(func() error { + if err := deleteFn(name); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete %s %s: %w", resource, name, err) + } + return nil + }) + } + if err := g.Wait(); err != nil { + return err + } + } + return nil +} diff --git a/contrib/cmd/runkperf/commands/data/operations_test.go b/contrib/cmd/runkperf/commands/data/operations_test.go new file mode 100644 index 0000000..75a05db --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/operations_test.go @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package data + +import ( + "context" + "errors" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestListPaginated(t *testing.T) { + for _, limit := range []int64{-1, 0, 2} { + t.Run(strconv.FormatInt(limit, 10), func(t *testing.T) { + expectedLimit := limit + if limit <= 0 { + expectedLimit = DefaultPageSize + } + pages := []struct { + items []int + next string + }{{[]int{1, 2}, "second"}, {nil, "third"}, {[]int{3}, ""}} + tokens := []string{"", "second", "third"} + calls := 0 + var visited []int + err := ListPaginated(metav1.ListOptions{LabelSelector: "app=runkperf", Limit: limit}, + func(opts metav1.ListOptions) ([]int, string, error) { + require.Less(t, calls, len(pages)) + assert.Equal(t, "app=runkperf", opts.LabelSelector) + assert.Equal(t, expectedLimit, opts.Limit) + assert.Equal(t, tokens[calls], opts.Continue) + page := pages[calls] + calls++ + return page.items, page.next, nil + }, func(item int) error { + visited = append(visited, item) + return nil + }) + require.NoError(t, err) + assert.Equal(t, 3, calls) + assert.Equal(t, []int{1, 2, 3}, visited) + }) + } +} + +func TestListPaginatedErrors(t *testing.T) { + sentinel := errors.New("failed") + t.Run("list", func(t *testing.T) { + err := ListPaginated(metav1.ListOptions{}, func(metav1.ListOptions) ([]int, string, error) { + return nil, "", sentinel + }, func(int) error { + t.Fatal("must not visit items after a list error") + return nil + }) + assert.ErrorIs(t, err, sentinel) + }) + t.Run("visit", func(t *testing.T) { + calls, visits := 0, 0 + err := ListPaginated(metav1.ListOptions{}, func(metav1.ListOptions) ([]int, string, error) { + calls++ + return []int{1, 2}, "next", nil + }, func(int) error { + visits++ + return sentinel + }) + assert.ErrorIs(t, err, sentinel) + assert.Equal(t, 1, calls) + assert.Equal(t, 1, visits) + }) +} + +func enumerateNames(names ...string) func(func(string) error) error { + return func(visit func(string) error) error { + for _, name := range names { + if err := visit(name); err != nil { + return err + } + } + return nil + } +} + +func TestBatchDeleteWaves(t *testing.T) { + for _, groupSize := range []int{1, 2, 10} { + t.Run(strconv.Itoa(groupSize), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + names := []string{"0", "1", "2", "3", "4"} + releases := make([]chan struct{}, (len(names)+groupSize-1)/groupSize) + for i := range releases { + releases[i] = make(chan struct{}) + } + started := make(chan int, len(names)) + done := make(chan error, 1) + var completed, active atomic.Int32 + listed := false + go func() { + done <- BatchDelete("pod", "default", groupSize, func(visit func(string) error) error { + err := enumerateNames(names...)(visit) + listed = true + return err + }, func(name string) error { + assert.True(t, listed, "listing must finish before deletion") + idx, err := strconv.Atoi(name) + if err != nil { + return err + } + wave := idx / groupSize + assert.GreaterOrEqual(t, completed.Load(), int32(wave*groupSize), "previous wave must finish") + assert.LessOrEqual(t, active.Add(1), int32(groupSize)) + defer active.Add(-1) + started <- idx + select { + case <-releases[wave]: + completed.Add(1) + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + }() + for wave := range releases { + count := groupSize + if remaining := len(names) - wave*groupSize; remaining < count { + count = remaining + } + for i := 0; i < count; i++ { + select { + case idx := <-started: + assert.Equal(t, wave, idx/groupSize) + case <-ctx.Done(): + t.Fatal("timed out waiting for deletion batch") + } + } + close(releases[wave]) + } + require.NoError(t, <-done) + assert.Equal(t, int32(len(names)), completed.Load()) + }) + } +} + +func TestBatchDeleteErrors(t *testing.T) { + sentinel := errors.New("denied") + for _, groupSize := range []int{-1, 0} { + err := BatchDelete("pod", "default", groupSize, func(func(string) error) error { + t.Fatal("invalid batch size must be rejected before listing") + return nil + }, nil) + assert.EqualError(t, err, "group-size must be greater than 0") + } + t.Run("list failure", func(t *testing.T) { + err := BatchDelete("pod", "default", 1, func(visit func(string) error) error { + require.NoError(t, visit("partial")) + return sentinel + }, func(string) error { + t.Fatal("must not delete after a list failure") + return nil + }) + assert.ErrorIs(t, err, sentinel) + }) + t.Run("empty", func(t *testing.T) { + require.NoError(t, BatchDelete("pod", "default", 1, enumerateNames(), func(string) error { + t.Fatal("must not delete when the list is empty") + return nil + })) + }) + t.Run("missing", func(t *testing.T) { + var calls int + require.NoError(t, BatchDelete("pod", "default", 1, enumerateNames("gone", "present"), func(name string) error { + calls++ + if name == "gone" { + return apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, name) + } + return nil + })) + assert.Equal(t, 2, calls) + }) + t.Run("delete failure finishes current wave", func(t *testing.T) { + var calls atomic.Int32 + err := BatchDelete("pod", "default", 2, enumerateNames("bad", "current", "later"), func(name string) error { + calls.Add(1) + assert.NotEqual(t, "later", name) + if name == "bad" { + return sentinel + } + return nil + }) + assert.ErrorIs(t, err, sentinel) + assert.EqualError(t, err, "failed to delete pod bad: denied") + assert.Equal(t, int32(2), calls.Load()) + }) +} diff --git a/contrib/cmd/runkperf/commands/data/pod/pod.go b/contrib/cmd/runkperf/commands/data/pod/pod.go index 9c3b38f..f553cb4 100644 --- a/contrib/cmd/runkperf/commands/data/pod/pod.go +++ b/contrib/cmd/runkperf/commands/data/pod/pod.go @@ -19,15 +19,10 @@ import ( "github.com/urfave/cli" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) -// defaultBatchSize is the number of pods to list or delete per batch. -// It is used as the page size for paginated API calls. -const defaultBatchSize int64 = 300 - // paddingAnnotation holds random bytes used to inflate each pod's object size. const paddingAnnotation = "runkperf.io/padding" @@ -220,7 +215,7 @@ var podListCommand = cli.Command{ } podMap := make(map[string]*podSetInfo) - err = listPods(clientset, labelSelector, namespace, defaultBatchSize, func(pod corev1.Pod) error { + err = listPods(clientset, labelSelector, namespace, data.DefaultPageSize, func(pod corev1.Pod) error { name, ok := pod.Labels["podSetName"] if !ok { return nil @@ -317,65 +312,21 @@ func createPods(clientset kubernetes.Interface, namespace, podSetName string, to } func deletePods(clientset kubernetes.Interface, labelSelector, namespace string, groupSize int) error { - var names []string - err := listPods(clientset, labelSelector, namespace, defaultBatchSize, func(pod corev1.Pod) error { - names = append(names, pod.Name) - return nil + return data.BatchDelete("pod", namespace, groupSize, func(visit func(string) error) error { + return listPods(clientset, labelSelector, namespace, data.DefaultPageSize, func(pod corev1.Pod) error { + return visit(pod.Name) + }) + }, func(name string) error { + return clientset.CoreV1().Pods(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) }) - if err != nil { - return err - } - - if len(names) == 0 { - fmt.Printf("No pods found in namespace %s, nothing to delete\n", namespace) - return nil - } - - n := len(names) - for i := 0; i < n; i += groupSize { - g := new(errgroup.Group) - for j := i; j < i+groupSize && j < n; j++ { - idx := j - g.Go(func() error { - err := clientset.CoreV1().Pods(namespace).Delete(context.TODO(), names[idx], metav1.DeleteOptions{}) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete pod %s: %v", names[idx], err) - } - return nil - }) - } - if err := g.Wait(); err != nil { - return err - } - } - return nil } func listPods(clientset kubernetes.Interface, labelSelector, namespace string, limit int64, fn func(corev1.Pod) error) error { - if limit <= 0 { - limit = defaultBatchSize - } - var continueToken string - for { - podList, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{ - LabelSelector: labelSelector, - Limit: limit, - Continue: continueToken, - }) + return data.ListPaginated(metav1.ListOptions{LabelSelector: labelSelector, Limit: limit}, func(opts metav1.ListOptions) ([]corev1.Pod, string, error) { + podList, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), opts) if err != nil { - return fmt.Errorf("failed to list pods: %v", err) + return nil, "", fmt.Errorf("failed to list pods: %w", err) } - - for _, pod := range podList.Items { - if err := fn(pod); err != nil { - return err - } - } - - continueToken = podList.Continue - if continueToken == "" { - break - } - } - return nil + return podList.Items, podList.Continue, nil + }, fn) } diff --git a/contrib/cmd/runkperf/commands/data/pod/pod_test.go b/contrib/cmd/runkperf/commands/data/pod/pod_test.go index f9b059c..5d9f66d 100644 --- a/contrib/cmd/runkperf/commands/data/pod/pod_test.go +++ b/contrib/cmd/runkperf/commands/data/pod/pod_test.go @@ -5,14 +5,19 @@ package pod import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" ) @@ -60,7 +65,7 @@ func TestListPods(t *testing.T) { podMap := make(map[string]*podSetInfo) selector := "app=" + data.AppLabel - err := listPods(clientset, selector, namespace, defaultBatchSize, func(pod corev1.Pod) error { + err := listPods(clientset, selector, namespace, data.DefaultPageSize, func(pod corev1.Pod) error { name := pod.Labels["podSetName"] info, ok := podMap[name] if !ok { @@ -100,3 +105,56 @@ func TestDeletePods(t *testing.T) { // Deleting a set that does not exist is a no-op and returns no error. require.NoError(t, deletePods(clientset, "app="+data.AppLabel+",podSetName=missing", namespace, 2)) } + +func TestDeletePodsPages(t *testing.T) { + client := fake.NewSimpleClientset() + calls := 0 + client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + opts := action.(ktesting.ListActionImpl).GetListOptions() + assert.Equal(t, "default", action.GetNamespace()) + assert.Equal(t, "app=runkperf", opts.LabelSelector) + assert.Equal(t, data.DefaultPageSize, opts.Limit) + list := &corev1.PodList{} + if calls == 0 { + assert.Empty(t, opts.Continue) + list.Continue = "next" + list.Items = []corev1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: "first", Labels: map[string]string{"app": "runkperf"}}}} + } else { + assert.Equal(t, 1, calls) + assert.Equal(t, "next", opts.Continue) + list.Items = []corev1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: "second", Labels: map[string]string{"app": "runkperf"}}}} + } + calls++ + return true, list, nil + }) + var deleted []string + client.PrependReactor("delete", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + assert.Equal(t, 2, calls, "all pages must be fetched before deletion") + name := action.(ktesting.DeleteAction).GetName() + deleted = append(deleted, name) + return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, name) + }) + require.NoError(t, deletePods(client, "app=runkperf", "default", 1)) + assert.Equal(t, []string{"first", "second"}, deleted) +} + +func TestPodOperationErrors(t *testing.T) { + for _, verb := range []string{"list", "delete"} { + t.Run(verb, func(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "default"}}) + sentinel := errors.New("denied") + client.PrependReactor(verb, "pods", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, sentinel + }) + err := deletePods(client, "", "default", 1) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Contains(t, err.Error(), "failed to "+verb+" pod") + if verb == "list" { + for _, action := range client.Actions() { + assert.NotEqual(t, "delete", action.GetVerb()) + } + } + }) + } +} diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret.go b/contrib/cmd/runkperf/commands/data/secrets/secret.go index b00ac1d..d44a9d9 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret.go @@ -20,15 +20,10 @@ import ( "github.com/urfave/cli" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) -// defaultBatchSize is the number of secrets to list or delete per batch. -// It is used as the page size for paginated API calls. -const defaultBatchSize int64 = 300 - var Command = cli.Command{ Name: "secret", ShortName: "sec", @@ -218,7 +213,7 @@ var secretListCommand = cli.Command{ ownerID map[string]int // ownerID -> count of secrets in that group } secMap := make(map[string]*secretSetInfo) - err = listSecrets(clientset, labelSelector, namespace, defaultBatchSize, func(sec corev1.Secret) error { + err = listSecrets(clientset, labelSelector, namespace, data.DefaultPageSize, func(sec corev1.Secret) error { name, ok := sec.Labels["secName"] if !ok { return fmt.Errorf("failed to find the secName of secret %s", sec.Name) @@ -280,7 +275,7 @@ func checkSecretParams(size int, groupSize int, total int) error { return nil } -func createSecrets(clientset *kubernetes.Clientset, namespace string, secName string, size int, groupSize int, total int) error { +func createSecrets(clientset kubernetes.Interface, namespace string, secName string, size int, groupSize int, total int) error { for i := 0; i < total; i += groupSize { ownerID := i g := new(errgroup.Group) @@ -323,66 +318,22 @@ func createSecrets(clientset *kubernetes.Clientset, namespace string, secName st return nil } -func deleteSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string, groupSize int) error { - var names []string - err := listSecrets(clientset, labelSelector, namespace, defaultBatchSize, func(sec corev1.Secret) error { - names = append(names, sec.Name) - return nil +func deleteSecrets(clientset kubernetes.Interface, labelSelector string, namespace string, groupSize int) error { + return data.BatchDelete("secret", namespace, groupSize, func(visit func(string) error) error { + return listSecrets(clientset, labelSelector, namespace, data.DefaultPageSize, func(sec corev1.Secret) error { + return visit(sec.Name) + }) + }, func(name string) error { + return clientset.CoreV1().Secrets(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) }) - if err != nil { - return err - } - - if len(names) == 0 { - fmt.Printf("No secrets found in namespace %s, nothing to delete\n", namespace) - return nil - } - - n := len(names) - for i := 0; i < n; i += groupSize { - g := new(errgroup.Group) - for j := i; j < i+groupSize && j < n; j++ { - idx := j - g.Go(func() error { - err := clientset.CoreV1().Secrets(namespace).Delete(context.TODO(), names[idx], metav1.DeleteOptions{}) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete secret %s: %v", names[idx], err) - } - return nil - }) - } - if err := g.Wait(); err != nil { - return err - } - } - return nil } -func listSecrets(clientset *kubernetes.Clientset, labelSelector string, namespace string, limit int64, fn func(corev1.Secret) error) error { - if limit <= 0 { - limit = defaultBatchSize - } - var continueToken string - for { - secrets, err := clientset.CoreV1().Secrets(namespace).List(context.TODO(), metav1.ListOptions{ - LabelSelector: labelSelector, - Limit: limit, - Continue: continueToken, - }) +func listSecrets(clientset kubernetes.Interface, labelSelector string, namespace string, limit int64, fn func(corev1.Secret) error) error { + return data.ListPaginated(metav1.ListOptions{LabelSelector: labelSelector, Limit: limit}, func(opts metav1.ListOptions) ([]corev1.Secret, string, error) { + secrets, err := clientset.CoreV1().Secrets(namespace).List(context.TODO(), opts) if err != nil { - return fmt.Errorf("failed to list secrets: %v", err) + return nil, "", fmt.Errorf("failed to list secrets: %w", err) } - - for _, sec := range secrets.Items { - if err := fn(sec); err != nil { - return err - } - } - - continueToken = secrets.Continue - if continueToken == "" { - break - } - } - return nil + return secrets.Items, secrets.Continue, nil + }, fn) } diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret_test.go b/contrib/cmd/runkperf/commands/data/secrets/secret_test.go new file mode 100644 index 0000000..1dfec05 --- /dev/null +++ b/contrib/cmd/runkperf/commands/data/secrets/secret_test.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package secrets + +import ( + "context" + "errors" + "testing" + + "github.com/Azure/kperf/contrib/cmd/runkperf/commands/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func TestCreateAndDeleteSecrets(t *testing.T) { + client := fake.NewSimpleClientset() + require.NoError(t, createSecrets(client, "default", "set-a", 32, 2, 5)) + require.NoError(t, createSecrets(client, "default", "set-b", 16, 10, 1)) + secrets, err := client.CoreV1().Secrets("default").List(context.TODO(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, secrets.Items, 6) + groups := map[string]int{} + for _, secret := range secrets.Items { + assert.Equal(t, data.AppLabel, secret.Labels["app"]) + assert.Equal(t, corev1.SecretTypeOpaque, secret.Type) + if secret.Labels["secName"] == "set-a" { + assert.Len(t, secret.Data["data"], 32) + groups[secret.Labels["ownerID"]]++ + } else { + assert.Equal(t, "set-b", secret.Labels["secName"]) + assert.Len(t, secret.Data["data"], 16) + } + } + assert.Equal(t, map[string]int{"0": 2, "2": 2, "4": 1}, groups) + require.NoError(t, deleteSecrets(client, "app=runkperf,secName=set-a", "default", 2)) + secrets, err = client.CoreV1().Secrets("default").List(context.TODO(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, secrets.Items, 1) + assert.Equal(t, "set-b", secrets.Items[0].Labels["secName"]) +} + +func TestDeleteSecretsPages(t *testing.T) { + client := fake.NewSimpleClientset() + calls := 0 + client.PrependReactor("list", "secrets", func(action ktesting.Action) (bool, runtime.Object, error) { + opts := action.(ktesting.ListActionImpl).GetListOptions() + assert.Equal(t, "default", action.GetNamespace()) + assert.Equal(t, "app=runkperf", opts.LabelSelector) + assert.Equal(t, data.DefaultPageSize, opts.Limit) + list := &corev1.SecretList{} + if calls == 0 { + assert.Empty(t, opts.Continue) + list.Continue = "next" + list.Items = []corev1.Secret{{ObjectMeta: metav1.ObjectMeta{Name: "first", Labels: map[string]string{"app": "runkperf"}}}} + } else { + assert.Equal(t, 1, calls) + assert.Equal(t, "next", opts.Continue) + list.Items = []corev1.Secret{{ObjectMeta: metav1.ObjectMeta{Name: "second", Labels: map[string]string{"app": "runkperf"}}}} + } + calls++ + return true, list, nil + }) + var deleted []string + client.PrependReactor("delete", "secrets", func(action ktesting.Action) (bool, runtime.Object, error) { + assert.Equal(t, 2, calls, "all pages must be fetched before deletion") + name := action.(ktesting.DeleteAction).GetName() + deleted = append(deleted, name) + return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, name) + }) + require.NoError(t, deleteSecrets(client, "app=runkperf", "default", 1)) + assert.Equal(t, []string{"first", "second"}, deleted) +} + +func TestSecretOperationErrors(t *testing.T) { + for _, verb := range []string{"list", "delete"} { + t.Run(verb, func(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "default"}}) + sentinel := errors.New("denied") + client.PrependReactor(verb, "secrets", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, sentinel + }) + err := deleteSecrets(client, "", "default", 1) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Contains(t, err.Error(), "failed to "+verb+" secret") + if verb == "list" { + for _, action := range client.Actions() { + assert.NotEqual(t, "delete", action.GetVerb()) + } + } + }) + } +} From a8dd7bb67fca7a0f226018811c8a7ba415c5b2ba Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Thu, 22 Oct 2026 15:02:46 +1000 Subject: [PATCH 19/20] Fix narrowing conversions in batch deletion tests Use 64-bit atomic counters to satisfy gosec G115 checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contrib/cmd/runkperf/commands/data/operations_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/operations_test.go b/contrib/cmd/runkperf/commands/data/operations_test.go index 75a05db..98544f9 100644 --- a/contrib/cmd/runkperf/commands/data/operations_test.go +++ b/contrib/cmd/runkperf/commands/data/operations_test.go @@ -101,7 +101,7 @@ func TestBatchDeleteWaves(t *testing.T) { } started := make(chan int, len(names)) done := make(chan error, 1) - var completed, active atomic.Int32 + var completed, active atomic.Int64 listed := false go func() { done <- BatchDelete("pod", "default", groupSize, func(visit func(string) error) error { @@ -115,8 +115,8 @@ func TestBatchDeleteWaves(t *testing.T) { return err } wave := idx / groupSize - assert.GreaterOrEqual(t, completed.Load(), int32(wave*groupSize), "previous wave must finish") - assert.LessOrEqual(t, active.Add(1), int32(groupSize)) + assert.GreaterOrEqual(t, completed.Load(), int64(wave*groupSize), "previous wave must finish") + assert.LessOrEqual(t, active.Add(1), int64(groupSize)) defer active.Add(-1) started <- idx select { @@ -144,7 +144,7 @@ func TestBatchDeleteWaves(t *testing.T) { close(releases[wave]) } require.NoError(t, <-done) - assert.Equal(t, int32(len(names)), completed.Load()) + assert.Equal(t, int64(len(names)), completed.Load()) }) } } From d1227890f2451e0e1d951038c1ba8baeec10a667 Mon Sep 17 00:00:00 2001 From: Liyu Ma Date: Mon, 26 Oct 2026 23:46:46 +1000 Subject: [PATCH 20/20] Replace deprecated fake client constructors in data tests Use fake.NewClientset to satisfy staticcheck SA1019. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cmd/runkperf/commands/data/events/event_test.go | 6 +++--- contrib/cmd/runkperf/commands/data/pod/pod_test.go | 10 +++++----- .../cmd/runkperf/commands/data/secrets/secret_test.go | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/contrib/cmd/runkperf/commands/data/events/event_test.go b/contrib/cmd/runkperf/commands/data/events/event_test.go index addfbe6..ef6e771 100644 --- a/contrib/cmd/runkperf/commands/data/events/event_test.go +++ b/contrib/cmd/runkperf/commands/data/events/event_test.go @@ -21,7 +21,7 @@ import ( ) func TestCreateAndDeleteEvents(t *testing.T) { - client := fake.NewSimpleClientset() + client := fake.NewClientset() opts := createOptions{ Namespace: "default", EventSetName: "set-a", Total: 5, GroupSize: 2, Reason: "Benchmark", EventType: "Warning", MessageSize: 32, InvolvedObjectKind: "Pod", @@ -61,7 +61,7 @@ func TestCreateAndDeleteEvents(t *testing.T) { } func TestDeleteEventsPages(t *testing.T) { - client := fake.NewSimpleClientset() + client := fake.NewClientset() calls := 0 client.PrependReactor("list", "events", func(action ktesting.Action) (bool, runtime.Object, error) { opts := action.(ktesting.ListActionImpl).GetListOptions() @@ -95,7 +95,7 @@ func TestDeleteEventsPages(t *testing.T) { func TestEventOperationErrors(t *testing.T) { for _, verb := range []string{"list", "delete"} { t.Run(verb, func(t *testing.T) { - client := fake.NewSimpleClientset(&corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "event", Namespace: "default"}}) + client := fake.NewClientset(&corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "event", Namespace: "default"}}) sentinel := errors.New("denied") client.PrependReactor(verb, "events", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, sentinel diff --git a/contrib/cmd/runkperf/commands/data/pod/pod_test.go b/contrib/cmd/runkperf/commands/data/pod/pod_test.go index 5d9f66d..db33eac 100644 --- a/contrib/cmd/runkperf/commands/data/pod/pod_test.go +++ b/contrib/cmd/runkperf/commands/data/pod/pod_test.go @@ -30,7 +30,7 @@ func TestCheckPodParams(t *testing.T) { } func TestCreatePods(t *testing.T) { - clientset := fake.NewSimpleClientset() + clientset := fake.NewClientset() const ( namespace = "default" podSetName = "set-a" @@ -57,7 +57,7 @@ func TestCreatePods(t *testing.T) { } func TestListPods(t *testing.T) { - clientset := fake.NewSimpleClientset() + clientset := fake.NewClientset() const namespace = "default" require.NoError(t, createPods(clientset, namespace, "set-a", 5, 3, 64)) @@ -86,7 +86,7 @@ func TestListPods(t *testing.T) { } func TestDeletePods(t *testing.T) { - clientset := fake.NewSimpleClientset() + clientset := fake.NewClientset() const namespace = "default" require.NoError(t, createPods(clientset, namespace, "set-a", 4, 2, 16)) @@ -107,7 +107,7 @@ func TestDeletePods(t *testing.T) { } func TestDeletePodsPages(t *testing.T) { - client := fake.NewSimpleClientset() + client := fake.NewClientset() calls := 0 client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { opts := action.(ktesting.ListActionImpl).GetListOptions() @@ -141,7 +141,7 @@ func TestDeletePodsPages(t *testing.T) { func TestPodOperationErrors(t *testing.T) { for _, verb := range []string{"list", "delete"} { t.Run(verb, func(t *testing.T) { - client := fake.NewSimpleClientset(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "default"}}) + client := fake.NewClientset(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "default"}}) sentinel := errors.New("denied") client.PrependReactor(verb, "pods", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, sentinel diff --git a/contrib/cmd/runkperf/commands/data/secrets/secret_test.go b/contrib/cmd/runkperf/commands/data/secrets/secret_test.go index 1dfec05..7a0dfc9 100644 --- a/contrib/cmd/runkperf/commands/data/secrets/secret_test.go +++ b/contrib/cmd/runkperf/commands/data/secrets/secret_test.go @@ -21,7 +21,7 @@ import ( ) func TestCreateAndDeleteSecrets(t *testing.T) { - client := fake.NewSimpleClientset() + client := fake.NewClientset() require.NoError(t, createSecrets(client, "default", "set-a", 32, 2, 5)) require.NoError(t, createSecrets(client, "default", "set-b", 16, 10, 1)) secrets, err := client.CoreV1().Secrets("default").List(context.TODO(), metav1.ListOptions{}) @@ -48,7 +48,7 @@ func TestCreateAndDeleteSecrets(t *testing.T) { } func TestDeleteSecretsPages(t *testing.T) { - client := fake.NewSimpleClientset() + client := fake.NewClientset() calls := 0 client.PrependReactor("list", "secrets", func(action ktesting.Action) (bool, runtime.Object, error) { opts := action.(ktesting.ListActionImpl).GetListOptions() @@ -82,7 +82,7 @@ func TestDeleteSecretsPages(t *testing.T) { func TestSecretOperationErrors(t *testing.T) { for _, verb := range []string{"list", "delete"} { t.Run(verb, func(t *testing.T) { - client := fake.NewSimpleClientset(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "default"}}) + client := fake.NewClientset(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "default"}}) sentinel := errors.New("denied") client.PrependReactor(verb, "secrets", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, sentinel