From 4e571c2c656463c1b6f67fee25f4ba580bd582df Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Tue, 19 May 2026 18:41:59 -0500 Subject: [PATCH 1/4] feat: add Params map to WorkloadConfig for workload-specific settings Workloads like TPS need custom parameters (file-size, iterations, duration) that don't apply to all workloads. This adds a generic Params map[string]string field to WorkloadConfig, merged from YAML config in the run command orchestration. Signed-off-by: Melvin Hillsman --- cmd/virtwork/main.go | 3 +++ cmd/virtwork/main_test.go | 6 +++--- internal/config/config.go | 3 ++- internal/config/config_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index f861ca4..800c438 100644 --- a/cmd/virtwork/main.go +++ b/cmd/virtwork/main.go @@ -200,6 +200,9 @@ func runE(cmd *cobra.Command, args []string) error { if fileCfg.VMCount > 0 { wlCfg.VMCount = fileCfg.VMCount } + if len(fileCfg.Params) > 0 { + wlCfg.Params = fileCfg.Params + } } w, err := registry.Get(name, wlCfg, registryOpts...) diff --git a/cmd/virtwork/main_test.go b/cmd/virtwork/main_test.go index bc83541..14f6ecc 100644 --- a/cmd/virtwork/main_test.go +++ b/cmd/virtwork/main_test.go @@ -625,7 +625,7 @@ var _ = Describe("CLI end-to-end scenarios", func() { Context("when running with default arguments", func() { It("should create VMs for all workloads", func() { - // Default run creates 6 VMs: cpu=1 + memory=1 + disk=1 + database=1 + network=2 + // Default run creates 8 VMs: cpu=1 + memory=1 + disk=1 + database=1 + network=2 + tps=2 registry := workloads.DefaultRegistry() totalVMs := 0 for _, name := range workloads.AllWorkloadNames { @@ -643,8 +643,8 @@ var _ = Describe("CLI end-to-end scenarios", func() { Expect(err).NotTo(HaveOccurred()) totalVMs += w.VMCount() } - // cpu=1 + database=1 + disk=1 + memory=1 + network=2 = 6 - Expect(totalVMs).To(Equal(6)) + // cpu=1 + database=1 + disk=1 + memory=1 + network=2 + tps=2 = 8 + Expect(totalVMs).To(Equal(8)) }) }) diff --git a/internal/config/config.go b/internal/config/config.go index c725f40..fee8e12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,8 @@ type WorkloadConfig struct { Enabled bool `mapstructure:"enabled"` VMCount int `mapstructure:"vm-count"` CPUCores int `mapstructure:"cpu-cores"` - Memory string `mapstructure:"memory"` + Memory string `mapstructure:"memory"` + Params map[string]string `mapstructure:"params"` } // Config holds the complete application configuration. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 00ec173..c73b954 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -362,5 +362,33 @@ workloads: Expect(cfg.Workloads["cpu"].Memory).To(Equal("4Gi")) Expect(cfg.Workloads["disk"].Enabled).To(BeFalse()) }) + + It("should load workload params from YAML", func() { + tmpDir, err := os.MkdirTemp("", "virtwork-config-test-*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + log.Println("cleaning temporary directory failed") + } + }() + + path := writeConfigFile(tmpDir, ` +workloads: + tps: + enabled: true + vm-count: 2 + params: + file-size: 200M + mode: file-transfer +`) + err1 := cmd.Flags().Set("config", path) + Expect(err1).NotTo(HaveOccurred()) + + cfg, err := config.LoadConfig(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Workloads).To(HaveKey("tps")) + Expect(cfg.Workloads["tps"].Params).To(HaveKeyWithValue("file-size", "200M")) + Expect(cfg.Workloads["tps"].Params).To(HaveKeyWithValue("mode", "file-transfer")) + }) }) }) From f770ebbd64c7b817c3cdd62dea85456cb4131243 Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Tue, 19 May 2026 18:42:34 -0500 Subject: [PATCH 2/4] feat: add TPS workload with netperf TCP_RR and HTTP file transfer Add a tps workload that measures transactions per second between server/client VM pairs using two complementary methods: - netperf TCP_RR: transport-layer request/response latency - HTTP file transfer: application-layer TPS via looped GET downloads Key design decisions: - Single systemd unit per role for unified journalctl output - Server readiness wait (curl probe on :8080) prevents wasted iterations - Data port pinned to 12866 for masquerade NAT compatibility - No UDP_RR (unreliable through OpenShift masquerade NAT) - Configurable via Params: file-size, iterations, duration Signed-off-by: Melvin Hillsman --- internal/workloads/registry.go | 5 +- internal/workloads/registry_test.go | 21 +- internal/workloads/tps.go | 383 +++++++++++++++++++++++ internal/workloads/tps_test.go | 468 ++++++++++++++++++++++++++++ 4 files changed, 871 insertions(+), 6 deletions(-) create mode 100644 internal/workloads/tps.go create mode 100644 internal/workloads/tps_test.go diff --git a/internal/workloads/registry.go b/internal/workloads/registry.go index 950d55e..28933d9 100644 --- a/internal/workloads/registry.go +++ b/internal/workloads/registry.go @@ -54,7 +54,7 @@ type WorkloadFactory func(config.WorkloadConfig, *RegistryOpts) Workload type Registry map[string]WorkloadFactory // AllWorkloadNames is a sorted list of all built-in workload names. -var AllWorkloadNames = []string{"cpu", "database", "disk", "memory", "network"} +var AllWorkloadNames = []string{"cpu", "database", "disk", "memory", "network", "tps"} // DefaultRegistry returns a Registry pre-populated with all built-in workloads. func DefaultRegistry() Registry { @@ -74,6 +74,9 @@ func DefaultRegistry() Registry { "network": func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { return NewNetworkWorkload(cfg, opts.Namespace, opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) }, + "tps": func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { + return NewTPSWorkload(cfg, opts.Namespace, opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) + }, } } diff --git a/internal/workloads/registry_test.go b/internal/workloads/registry_test.go index 4041b1c..a078be4 100644 --- a/internal/workloads/registry_test.go +++ b/internal/workloads/registry_test.go @@ -18,8 +18,8 @@ var _ = Describe("Registry", func() { reg = workloads.DefaultRegistry() }) - It("should have 5 entries registered", func() { - Expect(reg.List()).To(HaveLen(5)) + It("should have 6 entries registered", func() { + Expect(reg.List()).To(HaveLen(6)) }) It("should return CPU workload by name", func() { @@ -90,7 +90,18 @@ var _ = Describe("Registry", func() { It("should list all names sorted alphabetically", func() { names := reg.List() - Expect(names).To(Equal([]string{"cpu", "database", "disk", "memory", "network"})) + Expect(names).To(Equal([]string{"cpu", "database", "disk", "memory", "network", "tps"})) + }) + + It("should return tps workload by name", func() { + w, err := reg.Get("tps", config.WorkloadConfig{ + Enabled: true, + VMCount: 2, + CPUCores: 2, + Memory: "2Gi", + }, workloads.WithNamespace("virtwork")) + Expect(err).NotTo(HaveOccurred()) + Expect(w.Name()).To(Equal("tps")) }) It("should create workloads with provided config", func() { @@ -157,7 +168,7 @@ var _ = Describe("Registry", func() { }) var _ = Describe("AllWorkloadNames", func() { - It("should contain all five workload names sorted", func() { - Expect(workloads.AllWorkloadNames).To(Equal([]string{"cpu", "database", "disk", "memory", "network"})) + It("should contain all six workload names sorted", func() { + Expect(workloads.AllWorkloadNames).To(Equal([]string{"cpu", "database", "disk", "memory", "network", "tps"})) }) }) diff --git a/internal/workloads/tps.go b/internal/workloads/tps.go new file mode 100644 index 0000000..24562b7 --- /dev/null +++ b/internal/workloads/tps.go @@ -0,0 +1,383 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads + +import ( + "errors" + "fmt" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/opdev/virtwork/internal/config" +) + +const tpsServerSystemdUnit = `[Unit] +Description=Virtwork TPS server (netperf + HTTP) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/virtwork-tps-server.sh +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +` + +const tpsClientSystemdUnit = `[Unit] +Description=Virtwork TPS client (netperf TCP_RR + HTTP file transfer) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/virtwork-tps-client.sh +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +` + +var ErrUnknownTPSRole = errors.New("unexpected tps role; expected 'server' or 'client'") + +// TPSWorkload generates cloud-init userdata for a combined TPS benchmark. +// It creates server/client VM pairs. The server runs netserver and python3 +// http.server. The client runs TCP_RR tests and HTTP GET download loops, +// all within a single systemd unit for unified journalctl output. +type TPSWorkload struct { + BaseWorkload + Namespace string +} + +// NewTPSWorkload creates a TPSWorkload with the given configuration, +// namespace, and SSH credentials. +func NewTPSWorkload( + cfg config.WorkloadConfig, + namespace, sshUser, sshPassword string, + sshKeys []string, +) *TPSWorkload { + return &TPSWorkload{ + BaseWorkload: BaseWorkload{ + Config: cfg, + SSHUser: sshUser, + SSHPassword: sshPassword, + SSHAuthorizedKeys: sshKeys, + }, + Namespace: namespace, + } +} + +// Name returns "tps". +func (w *TPSWorkload) Name() string { + return "tps" +} + +// VMCount returns the total VM count — one server and one client per +// configured vm-count. +func (w *TPSWorkload) VMCount() int { + count := w.Config.VMCount + count = max(1, count) + return count * 2 +} + +// RequiresService returns true — the client needs a ClusterIP Service to reach +// the server by DNS. +func (w *TPSWorkload) RequiresService() bool { + return true +} + +// ServiceSpec returns a ClusterIP Service with ports for the netperf control +// channel (12865), a pinned data port (12866), and HTTP file transfer (8080). +func (w *TPSWorkload) ServiceSpec() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "virtwork-tps-server", + Namespace: w.Namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": "virtwork", + "app.kubernetes.io/managed-by": "virtwork", + "app.kubernetes.io/component": "tps", + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "virtwork/role": "server", + "app.kubernetes.io/component": "tps", + }, + Ports: []corev1.ServicePort{ + { + Name: "netperf-ctrl", + Port: 12865, + TargetPort: intstr.FromInt32(12865), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "netperf-data", + Port: 12866, + TargetPort: intstr.FromInt32(12866), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "http-data", + Port: 8080, + TargetPort: intstr.FromInt32(8080), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + +// CloudInitUserdata returns the server role userdata as the default. +func (w *TPSWorkload) CloudInitUserdata() (string, error) { + return w.UserdataForRole("server", w.Namespace) +} + +// UserdataForRole returns cloud-init YAML for the given role ("server" or "client"). +func (w *TPSWorkload) UserdataForRole(role string, namespace string) (string, error) { + switch role { + case "server": + return w.buildServerUserdata() + case "client": + return w.buildClientUserdata(namespace) + default: + return "", fmt.Errorf("unknown tps workload role: %q; %w", role, ErrUnknownTPSRole) + } +} + +func (w *TPSWorkload) serverDNSName(namespace string) string { + return fmt.Sprintf("virtwork-tps-server.%s.svc.cluster.local", namespace) +} + +func (w *TPSWorkload) fileSize() string { + if w.Config.Params != nil { + if size, ok := w.Config.Params["file-size"]; ok && size != "" { + return size + } + } + return "10M" +} + +func (w *TPSWorkload) iterations() string { + if w.Config.Params != nil { + if val, ok := w.Config.Params["iterations"]; ok && val != "" { + return val + } + } + return "30" +} + +func (w *TPSWorkload) duration() string { + if w.Config.Params != nil { + if val, ok := w.Config.Params["duration"]; ok && val != "" { + return val + } + } + return "60" +} + +func (w *TPSWorkload) fileSizeBytes() string { + raw := w.fileSize() + raw = strings.TrimSpace(raw) + suffix := raw[len(raw)-1:] + numStr := raw[:len(raw)-1] + num, err := strconv.Atoi(numStr) + if err != nil { + return "10485760" // 10M fallback + } + switch strings.ToUpper(suffix) { + case "G": + return strconv.Itoa(num * 1024 * 1024 * 1024) + case "M": + return strconv.Itoa(num * 1024 * 1024) + case "K": + return strconv.Itoa(num * 1024) + default: + return raw + } +} + +func (w *TPSWorkload) fileSizeMBCount() string { + raw := w.fileSize() + raw = strings.TrimSpace(raw) + suffix := raw[len(raw)-1:] + numStr := raw[:len(raw)-1] + num, err := strconv.Atoi(numStr) + if err != nil { + return "10" + } + switch strings.ToUpper(suffix) { + case "G": + return strconv.Itoa(num * 1024) + case "M": + return numStr + case "K": + return "1" // minimum 1MB + default: + return raw + } +} + +func (w *TPSWorkload) buildServerUserdata() (string, error) { + serverScript := fmt.Sprintf(`#!/usr/bin/env bash +set -e + +echo "=== Virtwork TPS Server ===" +echo "Starting netperf server on port 12865..." +netserver -D -p 12865 & +NETSERVER_PID=$! + +echo "Creating test file (%s)..." +mkdir -p /srv/virtwork +dd if=/dev/urandom of=/srv/virtwork/testfile bs=1M count=%s iflag=fullblock 2>&1 + +echo "Starting HTTP file server on port 8080..." +python3 -m http.server 8080 --directory /srv/virtwork & +HTTP_PID=$! + +echo "=== TPS Server ready ===" +echo " netperf: port 12865 (pid $NETSERVER_PID)" +echo " HTTP: port 8080 (pid $HTTP_PID)" +echo " testfile: /srv/virtwork/testfile (%s)" + +wait +`, w.fileSize(), w.fileSizeMBCount(), w.fileSize()) + + return w.BuildCloudConfig(CloudConfigOpts{ + Packages: []string{"netperf", "python3"}, + WriteFiles: []WriteFile{ + { + Path: "/usr/local/bin/virtwork-tps-server.sh", + Content: serverScript, + Permissions: "0755", + }, + { + Path: "/etc/systemd/system/virtwork-tps.service", + Content: tpsServerSystemdUnit, + Permissions: "0644", + }, + }, + RunCmd: [][]string{ + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", "virtwork-tps.service"}, + }, + }) +} + +func (w *TPSWorkload) buildClientUserdata(namespace string) (string, error) { + dnsName := w.serverDNSName(namespace) + + clientScript := fmt.Sprintf(`#!/usr/bin/env bash +SERVER=%s +ITERATIONS=%s +MSG_SIZE=64 +DURATION=%s +FILE_SIZE_BYTES=%s + +echo "=== Virtwork TPS Client ===" +echo "Server: $SERVER" +echo "Iterations: $ITERATIONS" +echo "Duration: ${DURATION}s per iteration" +echo "" + +# Wait for server to be reachable +echo "Waiting for server..." +until curl -s -o /dev/null -w '' "http://${SERVER}:8080/" 2>/dev/null; do + sleep 2 +done +echo "Server is ready." +echo "" + +# --- TCP_RR Tests --- +echo "==========================================" +echo " TCP_RR TPS Test" +echo "==========================================" +echo "Msg size: ${MSG_SIZE} bytes" +echo "" + +i=0 +while [ $i -lt $ITERATIONS ]; do + i=$((i + 1)) + echo "--- TCP_RR iteration $i/$ITERATIONS ---" + netperf \ + -H "$SERVER" \ + -p 12865 \ + -t TCP_RR \ + -l "$DURATION" \ + -- \ + -r "$MSG_SIZE,$MSG_SIZE" \ + -P ,12866 + echo "" + sleep 2 +done + +# --- HTTP File Transfer Tests --- +echo "==========================================" +echo " HTTP File Transfer TPS Test" +echo "==========================================" +echo "File size: %s" +echo "" + +i=0 +while [ $i -lt $ITERATIONS ]; do + i=$((i + 1)) + echo "--- HTTP iteration $i/$ITERATIONS ---" + + COUNT=0 + BYTES=0 + START=$(date +%%s) + END=$((START + DURATION)) + + while [ "$(date +%%s)" -lt "$END" ]; do + if curl -s -o /dev/null -w '' "http://${SERVER}:8080/testfile"; then + COUNT=$((COUNT + 1)) + BYTES=$((BYTES + FILE_SIZE_BYTES)) + fi + done + + ELAPSED=$(($(date +%%s) - START)) + if [ "$ELAPSED" -gt 0 ]; then + TPS=$((COUNT / ELAPSED)) + MBPS=$((BYTES / ELAPSED / 1048576)) + else + TPS=0 + MBPS=0 + fi + + echo "Completed: $COUNT transfers in ${ELAPSED}s" + echo "Throughput: ${TPS} transfers/sec, ${MBPS} MB/s" + echo "" + sleep 2 +done + +echo "==========================================" +echo " TPS testing complete" +echo "==========================================" +`, dnsName, w.iterations(), w.duration(), w.fileSizeBytes(), w.fileSize()) + + return w.BuildCloudConfig(CloudConfigOpts{ + Packages: []string{"netperf", "curl"}, + WriteFiles: []WriteFile{ + { + Path: "/usr/local/bin/virtwork-tps-client.sh", + Content: clientScript, + Permissions: "0755", + }, + { + Path: "/etc/systemd/system/virtwork-tps.service", + Content: tpsClientSystemdUnit, + Permissions: "0644", + }, + }, + RunCmd: [][]string{ + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", "virtwork-tps.service"}, + }, + }) +} diff --git a/internal/workloads/tps_test.go b/internal/workloads/tps_test.go new file mode 100644 index 0000000..dab2525 --- /dev/null +++ b/internal/workloads/tps_test.go @@ -0,0 +1,468 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/workloads" +) + +var _ = Describe("TPSWorkload", func() { + var w *workloads.TPSWorkload + + BeforeEach(func() { + w = workloads.NewTPSWorkload(config.WorkloadConfig{ + Enabled: true, + VMCount: 2, + CPUCores: 2, + Memory: "2Gi", + }, "virtwork", "virtwork", "", nil) + }) + + It("should return 'tps' for Name", func() { + Expect(w.Name()).To(Equal("tps")) + }) + + It("should return 2x VMCount for server/client pairs", func() { + Expect(w.VMCount()).To(Equal(4)) + }) + + It("should require service", func() { + Expect(w.RequiresService()).To(BeTrue()) + }) + + It("should produce server userdata with netserver and HTTP server", func() { + result, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-server.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).NotTo(BeEmpty()) + Expect(scriptContent).To(ContainSubstring("netserver")) + Expect(scriptContent).To(ContainSubstring("12865")) + Expect(scriptContent).To(ContainSubstring("http.server 8080")) + Expect(scriptContent).To(ContainSubstring("dd if=/dev/urandom")) + Expect(scriptContent).To(ContainSubstring("/srv/virtwork/testfile")) + }) + + It("should produce client userdata with TCP_RR and HTTP tests", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).NotTo(BeEmpty()) + Expect(scriptContent).To(ContainSubstring("netperf")) + Expect(scriptContent).To(ContainSubstring("TCP_RR")) + Expect(scriptContent).To(ContainSubstring("curl")) + Expect(scriptContent).To(ContainSubstring("virtwork-tps-server.virtwork.svc.cluster.local")) + }) + + It("should produce client userdata with custom namespace in DNS", func() { + result, err := w.UserdataForRole("client", "custom-ns") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("virtwork-tps-server.custom-ns.svc.cluster.local")) + }) + + It("should pin the data port to 12866", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("12866")) + }) + + It("should include iteration count in client script", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("ITERATIONS=30")) + }) + + It("should not include UDP_RR in client script", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).NotTo(ContainSubstring("UDP_RR")) + }) + + It("should wait for server readiness before starting tests", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("curl")) + Expect(scriptContent).To(ContainSubstring("8080")) + Expect(scriptContent).To(ContainSubstring("Waiting for server")) + }) + + It("should return error for unknown role", func() { + _, err := w.UserdataForRole("unknown", "virtwork") + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(workloads.ErrUnknownTPSRole)) + }) + + It("should include netperf in packages for server", func() { + result, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + pkgs, ok := parsed["packages"].([]interface{}) + Expect(ok).To(BeTrue()) + Expect(pkgs).To(ContainElement("netperf")) + }) + + It("should include netperf in packages for client", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + pkgs, ok := parsed["packages"].([]interface{}) + Expect(ok).To(BeTrue()) + Expect(pkgs).To(ContainElement("netperf")) + }) + + It("should have service spec with three ports", func() { + svc := w.ServiceSpec() + Expect(svc).NotTo(BeNil()) + Expect(svc.Spec.Ports).To(HaveLen(3)) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(12865))) + Expect(svc.Spec.Ports[0].Name).To(Equal("netperf-ctrl")) + Expect(svc.Spec.Ports[1].Port).To(Equal(int32(12866))) + Expect(svc.Spec.Ports[1].Name).To(Equal("netperf-data")) + Expect(svc.Spec.Ports[2].Port).To(Equal(int32(8080))) + Expect(svc.Spec.Ports[2].Name).To(Equal("http-data")) + }) + + It("should have service spec with correct selector", func() { + svc := w.ServiceSpec() + Expect(svc).NotTo(BeNil()) + Expect(svc.Spec.Selector).To(HaveKeyWithValue("virtwork/role", "server")) + Expect(svc.Spec.Selector).To(HaveKeyWithValue("app.kubernetes.io/component", "tps")) + }) + + It("should have service spec with correct name", func() { + svc := w.ServiceSpec() + Expect(svc).NotTo(BeNil()) + Expect(svc.Name).To(Equal("virtwork-tps-server")) + Expect(svc.Namespace).To(Equal("virtwork")) + }) + + It("should produce valid YAML for server role", func() { + result, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HavePrefix("#cloud-config\n")) + + parsed := parseYAML(result) + Expect(parsed).NotTo(BeNil()) + }) + + It("should produce valid YAML for client role", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HavePrefix("#cloud-config\n")) + + parsed := parseYAML(result) + Expect(parsed).NotTo(BeNil()) + }) + + It("should return server userdata from CloudInitUserdata", func() { + defaultResult, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + serverResult, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + Expect(defaultResult).To(Equal(serverResult)) + }) + + It("should have no extra disks", func() { + Expect(w.ExtraDisks()).To(BeNil()) + }) + + It("should have no extra volumes", func() { + Expect(w.ExtraVolumes()).To(BeNil()) + }) + + It("should have no data volume templates", func() { + Expect(w.DataVolumeTemplates()).To(BeNil()) + }) + + It("should reflect config in VMResources", func() { + res := w.VMResources() + Expect(res.CPUCores).To(Equal(2)) + Expect(res.Memory).To(Equal("2Gi")) + }) + + It("should implement MultiVMWorkload interface", func() { + var _ workloads.MultiVMWorkload = w + }) + + Context("combined single-service architecture", func() { + It("should use a single systemd unit for server", func() { + result, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + serviceCount := 0 + for _, f := range files { + file := f.(map[string]interface{}) + if strings.HasPrefix(file["path"].(string), "/etc/systemd/system/virtwork-tps") { + serviceCount++ + } + } + Expect(serviceCount).To(Equal(1)) + }) + + It("should use a single systemd unit for client", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + serviceCount := 0 + for _, f := range files { + file := f.(map[string]interface{}) + if strings.HasPrefix(file["path"].(string), "/etc/systemd/system/virtwork-tps") { + serviceCount++ + } + } + Expect(serviceCount).To(Equal(1)) + }) + + It("should enable only virtwork-tps.service in server runcmd", func() { + result, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + runcmd := parsed["runcmd"].([]interface{}) + + var cmds []string + for _, cmd := range runcmd { + parts := cmd.([]interface{}) + for _, p := range parts { + cmds = append(cmds, p.(string)) + } + } + joined := strings.Join(cmds, " ") + Expect(joined).To(ContainSubstring("virtwork-tps.service")) + Expect(strings.Count(joined, "virtwork-tps")).To(Equal(1)) + }) + + It("should enable only virtwork-tps.service in client runcmd", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + runcmd := parsed["runcmd"].([]interface{}) + + var cmds []string + for _, cmd := range runcmd { + parts := cmd.([]interface{}) + for _, p := range parts { + cmds = append(cmds, p.(string)) + } + } + joined := strings.Join(cmds, " ") + Expect(joined).To(ContainSubstring("virtwork-tps.service")) + Expect(strings.Count(joined, "virtwork-tps")).To(Equal(1)) + }) + + It("should include python3 in server packages", func() { + result, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + pkgs := parsed["packages"].([]interface{}) + Expect(pkgs).To(ContainElement("python3")) + }) + + It("should include curl in client packages", func() { + result, err := w.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + pkgs := parsed["packages"].([]interface{}) + Expect(pkgs).To(ContainElement("curl")) + }) + }) + + Context("configurable params", func() { + It("should default file size to 10M", func() { + result, err := w.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-server.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("count=10")) + }) + + It("should use custom file size from params", func() { + w2 := workloads.NewTPSWorkload(config.WorkloadConfig{ + Enabled: true, + VMCount: 2, + CPUCores: 2, + Memory: "2Gi", + Params: map[string]string{"file-size": "200M"}, + }, "virtwork", "virtwork", "", nil) + + result, err := w2.UserdataForRole("server", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-server.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("count=200")) + }) + + It("should use custom iterations from params", func() { + w2 := workloads.NewTPSWorkload(config.WorkloadConfig{ + Enabled: true, + VMCount: 2, + CPUCores: 2, + Memory: "2Gi", + Params: map[string]string{"iterations": "5"}, + }, "virtwork", "virtwork", "", nil) + + result, err := w2.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("ITERATIONS=5")) + }) + + It("should use custom duration from params", func() { + w2 := workloads.NewTPSWorkload(config.WorkloadConfig{ + Enabled: true, + VMCount: 2, + CPUCores: 2, + Memory: "2Gi", + Params: map[string]string{"duration": "10"}, + }, "virtwork", "virtwork", "", nil) + + result, err := w2.UserdataForRole("client", "virtwork") + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + file := f.(map[string]interface{}) + if file["path"].(string) == "/usr/local/bin/virtwork-tps-client.sh" { + scriptContent = file["content"].(string) + break + } + } + Expect(scriptContent).To(ContainSubstring("DURATION=10")) + }) + }) +}) From 721c41fd65b93c68fbd8c2060452986150a9c26e Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Tue, 19 May 2026 20:01:38 -0500 Subject: [PATCH 3/4] docs: add TPS to deploying-workloads guide Add TPS to the deploying-workloads guide VM table and available workloads list. Signed-off-by: Melvin Hillsman --- docs/guide/02-deploying-workloads.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/guide/02-deploying-workloads.md b/docs/guide/02-deploying-workloads.md index 3a61e48..07566cb 100644 --- a/docs/guide/02-deploying-workloads.md +++ b/docs/guide/02-deploying-workloads.md @@ -200,7 +200,7 @@ This creates 3 VMs, 3 cloud-init secrets, and 0 services: virtwork run ``` -With no `--workloads` flag, all five workload types deploy. This creates **6 VMs** total — the network workload creates a server/client pair: +With no `--workloads` flag, all workload types deploy. This creates **8 VMs** total — the network and tps workloads each create server/client pairs: | VM Name | Workload | Role | |---------|----------|------| @@ -210,8 +210,10 @@ With no `--workloads` flag, all five workload types deploy. This creates **6 VMs | `virtwork-memory-0` | Memory | — | | `virtwork-network-server-0` | Network | server | | `virtwork-network-client-0` | Network | client | +| `virtwork-tps-server-0` | TPS | server | +| `virtwork-tps-client-0` | TPS | client | -The network workload also creates a `virtwork-iperf3-server` ClusterIP Service on port 5201. +The network workload creates a `virtwork-iperf3-server` ClusterIP Service on port 5201. The tps workload creates a `virtwork-tps-server` ClusterIP Service on ports 12865 (netperf control), 12866 (netperf data), and 8080 (HTTP file transfer). ### Scaling up @@ -444,4 +446,4 @@ oc set env deploy/virtwork VIRTWORK_COMMAND=run VIRTWORK_ARGS="--workloads cpu,m | VMs stuck in `Provisioning` | CDI not installed or no default StorageClass | Install the OpenShift Virtualization operator and ensure a default StorageClass exists | | Timeout waiting for readiness | Slow image pull or boot | Increase `--timeout` (default is 600s), or use `--no-wait` and check manually | | Cloud-init failed inside VM | Package install failure or script error | SSH in and check `/var/log/cloud-init-output.log` | -| `unknown workload` error | Typo in `--workloads` | Available workloads: `cpu`, `database`, `disk`, `memory`, `network` | +| `unknown workload` error | Typo in `--workloads` | Available workloads: `cpu`, `database`, `disk`, `memory`, `network`, `tps` | From aaeb9b673275a6b4edb29dd83feee88aa52a4280 Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Tue, 19 May 2026 20:02:52 -0500 Subject: [PATCH 4/4] fix: resolve golangci-lint gci and modernize warnings Signed-off-by: Melvin Hillsman --- internal/config/config.go | 6 +++--- internal/wait/wait_test.go | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fee8e12..9d4a1dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,9 +16,9 @@ import ( // WorkloadConfig holds per-workload configuration. type WorkloadConfig struct { - Enabled bool `mapstructure:"enabled"` - VMCount int `mapstructure:"vm-count"` - CPUCores int `mapstructure:"cpu-cores"` + Enabled bool `mapstructure:"enabled"` + VMCount int `mapstructure:"vm-count"` + CPUCores int `mapstructure:"cpu-cores"` Memory string `mapstructure:"memory"` Params map[string]string `mapstructure:"params"` } diff --git a/internal/wait/wait_test.go b/internal/wait/wait_test.go index 91d1536..52c06c0 100644 --- a/internal/wait/wait_test.go +++ b/internal/wait/wait_test.go @@ -57,7 +57,7 @@ var _ = Describe("WaitForVMReady", func() { }, } - var callCount int32 + var callCount atomic.Int32 c := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(vmi). @@ -67,7 +67,7 @@ var _ = Describe("WaitForVMReady", func() { if err != nil { return err } - count := atomic.AddInt32(&callCount, 1) + count := callCount.Add(1) if vmiObj, ok := obj.(*kubevirtv1.VirtualMachineInstance); ok { if count >= 3 { vmiObj.Status.Phase = kubevirtv1.Running @@ -80,7 +80,7 @@ var _ = Describe("WaitForVMReady", func() { err := wait.WaitForVMReady(ctx, c, "eventual-vm", "default", 5*time.Second, 10*time.Millisecond) Expect(err).NotTo(HaveOccurred()) - Expect(atomic.LoadInt32(&callCount)).To(BeNumerically(">=", int32(3))) + Expect(callCount.Load()).To(BeNumerically(">=", int32(3))) }) It("should return error on timeout", func() { @@ -109,12 +109,12 @@ var _ = Describe("WaitForVMReady", func() { }) It("should succeed when VMI appears after initial not-found", func() { - var callCount int32 + var callCount atomic.Int32 c := fake.NewClientBuilder(). WithScheme(scheme). WithInterceptorFuncs(interceptor.Funcs{ Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - count := atomic.AddInt32(&callCount, 1) + count := callCount.Add(1) if count <= 2 { return cl.Get(ctx, key, obj, opts...) // not found from empty store } @@ -131,7 +131,7 @@ var _ = Describe("WaitForVMReady", func() { err := wait.WaitForVMReady(ctx, c, "delayed-vm", "default", 5*time.Second, 10*time.Millisecond) Expect(err).NotTo(HaveOccurred()) - Expect(atomic.LoadInt32(&callCount)).To(BeNumerically(">=", int32(3))) + Expect(callCount.Load()).To(BeNumerically(">=", int32(3))) }) It("should respect context cancellation", func() {