From 5c13e6ae3085093cb901765bc3c16d1dd6738e1e Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Wed, 20 May 2026 17:03:11 -0500 Subject: [PATCH] feat: add chaos-network workload for network chaos injection Implements chaos-network workload using tc/netem for network chaos injection. Supports configurable latency (default 100ms) and packet loss (default 5%). Features: - Single VM workload model - Uses tc qdisc and netem for network emulation - Systemd service for continuous operation - Assumes iproute-tc pre-installed via golden image - Comprehensive test coverage Updates: - Add ChaosNetworkWorkload struct and factory - Register in DefaultRegistry - Update AllWorkloadNames - Add test suite with 11 test cases - Update main_test.go for new workload count Resolves #17 Signed-off-by: Melvin Hillsman --- cmd/virtwork/main_test.go | 6 +- internal/workloads/chaos-network.go | 118 ++++++++++++++++++++ internal/workloads/chaos-network_test.go | 134 +++++++++++++++++++++++ internal/workloads/registry.go | 3 + internal/workloads/registry_test.go | 27 ++++- 5 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 internal/workloads/chaos-network.go create mode 100644 internal/workloads/chaos-network_test.go diff --git a/cmd/virtwork/main_test.go b/cmd/virtwork/main_test.go index 9bf8e89..653b60b 100644 --- a/cmd/virtwork/main_test.go +++ b/cmd/virtwork/main_test.go @@ -628,7 +628,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 9 VMs: chaos-process=1 + cpu=1 + memory=1 + disk=1 + database=1 + network=2 + tps=2 + // Default run creates 10 VMs: chaos-process=1 + chaos-network=1 + cpu=1 + memory=1 + disk=1 + database=1 + network=2 + tps=2 registry := workloads.DefaultRegistry() totalVMs := 0 for _, name := range workloads.AllWorkloadNames() { @@ -646,8 +646,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 + tps=2 = 8 - Expect(totalVMs).To(Equal(9)) + // chaos-process=1 + chaos-network=1 + cpu=1 + database=1 + disk=1 + memory=1 + network=2 + tps=2 = 10 + Expect(totalVMs).To(Equal(10)) }) }) diff --git a/internal/workloads/chaos-network.go b/internal/workloads/chaos-network.go new file mode 100644 index 0000000..75f4f50 --- /dev/null +++ b/internal/workloads/chaos-network.go @@ -0,0 +1,118 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads + +import ( + "fmt" + + "github.com/opdev/virtwork/internal/config" +) + +const chaosNetworkStartScript = `#!/bin/bash +set -euo pipefail + +DEV=$(ip route show default | awk '{print $5; exit}') +if [ -z "$DEV" ]; then + echo "no default route interface found" >&2 + exit 1 +fi + +modprobe sch_netem 2>/dev/null || true + +exec /usr/sbin/tc qdisc add dev "$DEV" root netem delay %sms loss %s%% limit 1000 +` + +const chaosNetworkStopScript = `#!/bin/bash +DEV=$(ip route show default | awk '{print $5; exit}') +[ -n "$DEV" ] && /usr/sbin/tc qdisc del dev "$DEV" root 2>/dev/null +exit 0 +` + +const chaosNetworkSystemdUnit = `[Unit] +Description=Virtwork network chaos workload (tc/netem) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/virtwork-chaos-network-start.sh +ExecStop=/usr/local/bin/virtwork-chaos-network-stop.sh +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +` + +// ChaosNetworkWorkload generates cloud-init userdata for network chaos injection +// using tc (traffic control) and netem (network emulation). +type ChaosNetworkWorkload struct { + BaseWorkload + Latency int // Latency in milliseconds (default: 100) + PacketLoss float64 // Packet loss percentage (default: 5.0) +} + +// NewChaosNetworkWorkload creates a ChaosNetworkWorkload with the given configuration +// and SSH credentials. +func NewChaosNetworkWorkload( + cfg config.WorkloadConfig, + sshUser, sshPassword string, + sshKeys []string, +) *ChaosNetworkWorkload { + latency := 100 + packetLoss := 5.0 + + // Allow configuration via WorkloadConfig if needed in future + // For now, use sensible defaults as specified in the issue + + return &ChaosNetworkWorkload{ + BaseWorkload: BaseWorkload{ + Config: cfg, + SSHUser: sshUser, + SSHPassword: sshPassword, + SSHAuthorizedKeys: sshKeys, + }, + Latency: latency, + PacketLoss: packetLoss, + } +} + +// Name returns "chaos-network". +func (w *ChaosNetworkWorkload) Name() string { + return "chaos-network" +} + +// CloudInitUserdata returns cloud-init YAML that configures tc/netem for network chaos +// injection via systemd. Assumes iproute-tc is pre-installed (golden image dependency). +func (w *ChaosNetworkWorkload) CloudInitUserdata() (string, error) { + startScript := fmt.Sprintf(chaosNetworkStartScript, + fmt.Sprintf("%d", w.Latency), + fmt.Sprintf("%.1f", w.PacketLoss)) + + return w.BuildCloudConfig(CloudConfigOpts{ + Packages: []string{"iproute-tc"}, + WriteFiles: []WriteFile{ + { + Path: "/usr/local/bin/virtwork-chaos-network-start.sh", + Content: startScript, + Permissions: "0755", + }, + { + Path: "/usr/local/bin/virtwork-chaos-network-stop.sh", + Content: chaosNetworkStopScript, + Permissions: "0755", + }, + { + Path: "/etc/systemd/system/virtwork-chaos-network.service", + Content: chaosNetworkSystemdUnit, + Permissions: "0644", + }, + }, + RunCmd: [][]string{ + {"bash", "-c", "dnf install -y kernel-modules-extra-$(uname -r)"}, + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", "virtwork-chaos-network.service"}, + }, + }) +} diff --git a/internal/workloads/chaos-network_test.go b/internal/workloads/chaos-network_test.go new file mode 100644 index 0000000..6bfc69b --- /dev/null +++ b/internal/workloads/chaos-network_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/workloads" +) + +var _ = Describe("ChaosNetworkWorkload", func() { + var w *workloads.ChaosNetworkWorkload + + BeforeEach(func() { + w = workloads.NewChaosNetworkWorkload(config.WorkloadConfig{ + Enabled: true, + VMCount: 1, + CPUCores: 1, + Memory: "1Gi", + }, "virtwork", "", nil) + }) + + It("should return 'chaos-network' for Name", func() { + Expect(w.Name()).To(Equal("chaos-network")) + }) + + It("should include iproute-tc package", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + Expect(parsed).To(HaveKey("packages")) + packages := parsed["packages"].([]interface{}) + Expect(packages).To(ContainElement("iproute-tc")) + }) + + It("should include start script, stop script, and systemd service in cloud-init", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + Expect(parsed).To(HaveKey("write_files")) + files := parsed["write_files"].([]interface{}) + Expect(files).To(HaveLen(3)) + + filesByPath := map[string]string{} + for _, f := range files { + fm := f.(map[string]interface{}) + filesByPath[fm["path"].(string)] = fm["content"].(string) + } + + startScript := filesByPath["/usr/local/bin/virtwork-chaos-network-start.sh"] + Expect(startScript).To(ContainSubstring("tc qdisc")) + Expect(startScript).To(ContainSubstring("netem")) + Expect(startScript).To(ContainSubstring("delay 100ms"), "should include default 100ms latency") + Expect(startScript).To(ContainSubstring("loss 5.0%"), "should include default 5% packet loss") + Expect(startScript).To(ContainSubstring("ip route show default"), "should auto-detect interface") + Expect(startScript).To(ContainSubstring("modprobe sch_netem"), "should load netem kernel module") + + Expect(filesByPath).To(HaveKey("/usr/local/bin/virtwork-chaos-network-stop.sh")) + Expect(filesByPath).To(HaveKey("/etc/systemd/system/virtwork-chaos-network.service")) + }) + + It("should install kernel-modules-extra for running kernel before enabling service", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + Expect(parsed).To(HaveKey("runcmd")) + runcmds := parsed["runcmd"].([]interface{}) + + foundKernelModules := false + foundEnable := false + kernelModulesIdx := -1 + enableIdx := -1 + for i, cmd := range runcmds { + cmdSlice := cmd.([]interface{}) + if len(cmdSlice) >= 3 { + if cmdSlice[0] == "bash" && cmdSlice[1] == "-c" { + cmdStr := cmdSlice[2].(string) + if cmdStr == "dnf install -y kernel-modules-extra-$(uname -r)" { + foundKernelModules = true + kernelModulesIdx = i + } + } + if cmdSlice[0] == "systemctl" && cmdSlice[1] == "enable" { + foundEnable = true + enableIdx = i + Expect(cmdSlice[2]).To(Equal("--now")) + } + } + } + Expect(foundKernelModules).To(BeTrue(), "should install kernel-modules-extra for running kernel") + Expect(foundEnable).To(BeTrue(), "should have systemctl enable command") + Expect(kernelModulesIdx).To( + BeNumerically("<", enableIdx), + "kernel modules install should come before service enable") + }) + + It("should produce valid YAML", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HavePrefix("#cloud-config\n")) + + parsed := parseYAML(result) + Expect(parsed).NotTo(BeNil()) + }) + + It("should have no extra disks", func() { + Expect(w.ExtraDisks()).To(BeNil()) + }) + + It("should have no data volumes", func() { + Expect(w.DataVolumeTemplates()).To(BeNil()) + }) + + It("should have no service", func() { + Expect(w.RequiresService()).To(BeFalse()) + Expect(w.ServiceSpec()).To(BeNil()) + }) + + It("should reflect config in VMResources", func() { + res := w.VMResources() + Expect(res.CPUCores).To(Equal(1)) + Expect(res.Memory).To(Equal("1Gi")) + }) + + It("should have VMCount of 1", func() { + Expect(w.VMCount()).To(Equal(1)) + }) +}) diff --git a/internal/workloads/registry.go b/internal/workloads/registry.go index e0c4e69..38105d6 100644 --- a/internal/workloads/registry.go +++ b/internal/workloads/registry.go @@ -65,6 +65,9 @@ func DefaultRegistry() Registry { "chaos-process": func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { return NewChaosProcessWorkload(cfg, opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) }, + "chaos-network": func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { + return NewChaosNetworkWorkload(cfg, opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) + }, "cpu": func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { return NewCPUWorkload(cfg, opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) }, diff --git a/internal/workloads/registry_test.go b/internal/workloads/registry_test.go index a909a29..e305701 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 7 entries registered", func() { - Expect(reg.List()).To(HaveLen(7)) + It("should have 8 entries registered", func() { + Expect(reg.List()).To(HaveLen(8)) }) It("should return CPU workload by name", func() { @@ -88,11 +88,23 @@ var _ = Describe("Registry", func() { Expect(w.Name()).To(Equal("chaos-process")) }) + It("should return chaos-network workload by name", func() { + w, err := reg.Get("chaos-network", config.WorkloadConfig{ + Enabled: true, + VMCount: 1, + CPUCores: 1, + Memory: "1Gi", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(w.Name()).To(Equal("chaos-network")) + }) + It("should return error for unknown name with available names", func() { _, err := reg.Get("unknown", config.WorkloadConfig{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("unknown")) Expect(err.Error()).To(ContainSubstring("chaos-process")) + Expect(err.Error()).To(ContainSubstring("chaos-network")) Expect(err.Error()).To(ContainSubstring("cpu")) Expect(err.Error()).To(ContainSubstring("database")) Expect(err.Error()).To(ContainSubstring("disk")) @@ -103,7 +115,16 @@ var _ = Describe("Registry", func() { It("should list all names sorted alphabetically", func() { names := reg.List() - Expect(names).To(Equal([]string{"chaos-process", "cpu", "database", "disk", "memory", "network", "tps"})) + Expect(names).To(Equal([]string{ + "chaos-network", + "chaos-process", + "cpu", + "database", + "disk", + "memory", + "network", + "tps", + })) }) It("should return tps workload by name", func() {