diff --git a/cmd/virtwork/main_test.go b/cmd/virtwork/main_test.go index 653b60b..de0e372 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 10 VMs: chaos-process=1 + chaos-network=1 + cpu=1 + memory=1 + disk=1 + database=1 + network=2 + tps=2 + // Default run creates 11 VMs: chaos-disk=1 + chaos-network=1 + chaos-process=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() } - // chaos-process=1 + chaos-network=1 + cpu=1 + database=1 + disk=1 + memory=1 + network=2 + tps=2 = 10 - Expect(totalVMs).To(Equal(10)) + // chaos-disk=1 + chaos-network=1 + chaos-process=1 + cpu=1 + database=1 + disk=1 + memory=1 + network=2 + tps=2 = 11 + Expect(totalVMs).To(Equal(11)) }) }) diff --git a/internal/workloads/chaos_disk.go b/internal/workloads/chaos_disk.go new file mode 100644 index 0000000..47e4713 --- /dev/null +++ b/internal/workloads/chaos_disk.go @@ -0,0 +1,141 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads + +import ( + kubevirtv1 "kubevirt.io/api/core/v1" + + "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/vm" +) + +const chaosDiskScript = `#!/bin/bash +set -euo pipefail + +MOUNT_POINT="${CHAOS_DISK_MOUNT:-/mnt/data}" +FILL_PERCENT="${CHAOS_DISK_FILL_PERCENT:-90}" +FILL_FILE="${MOUNT_POINT}/chaos-disk-fill" +RELEASE_SLEEP="${CHAOS_DISK_RELEASE_SLEEP:-30}" +FILL_SLEEP="${CHAOS_DISK_FILL_SLEEP:-60}" + +while true; do + TOTAL_KB=$(df -k "${MOUNT_POINT}" | awk 'NR==2 {print $2}') + USED_KB=$(df -k "${MOUNT_POINT}" | awk 'NR==2 {print $3}') + TARGET_KB=$(( TOTAL_KB * FILL_PERCENT / 100 )) + FILL_KB=$(( TARGET_KB - USED_KB )) + + if [ "${FILL_KB}" -gt 0 ]; then + fallocate -l "${FILL_KB}K" "${FILL_FILE}" 2>/dev/null || \ + dd if=/dev/zero of="${FILL_FILE}" bs=1K count="${FILL_KB}" status=none + fi + + sleep "${FILL_SLEEP}" + + rm -f "${FILL_FILE}" + sleep "${RELEASE_SLEEP}" +done +` + +const chaosDiskSystemdUnit = `[Unit] +Description=Virtwork chaos-disk fill/release workload +After=network.target local-fs.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/chaos-disk.sh +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +` + +// ChaosDiskWorkload generates cloud-init userdata for a disk fill/release chaos +// workload using fallocate and dd. +type ChaosDiskWorkload struct { + BaseWorkload + DataDiskSize string +} + +// NewChaosDiskWorkload creates a ChaosDiskWorkload with the given configuration, +// disk size, and SSH credentials. +func NewChaosDiskWorkload( + cfg config.WorkloadConfig, + dataDiskSize, sshUser, sshPassword string, + sshKeys []string, +) *ChaosDiskWorkload { + return &ChaosDiskWorkload{ + BaseWorkload: BaseWorkload{ + Config: cfg, + SSHUser: sshUser, + SSHPassword: sshPassword, + SSHAuthorizedKeys: sshKeys, + }, + DataDiskSize: dataDiskSize, + } +} + +// Name returns "chaos-disk". +func (w *ChaosDiskWorkload) Name() string { + return "chaos-disk" +} + +// CloudInitUserdata returns cloud-init YAML that writes a fill/release script +// and a systemd service that runs it in a loop. +func (w *ChaosDiskWorkload) CloudInitUserdata() (string, error) { + return w.BuildCloudConfig(CloudConfigOpts{ + WriteFiles: []WriteFile{ + { + Path: "/usr/local/bin/chaos-disk.sh", + Content: chaosDiskScript, + Permissions: "0755", + }, + { + Path: "/etc/systemd/system/virtwork-chaos-disk.service", + Content: chaosDiskSystemdUnit, + Permissions: "0644", + }, + }, + RunCmd: [][]string{ + {"mkdir", "-p", "/mnt/data"}, + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", "virtwork-chaos-disk.service"}, + }, + }) +} + +// DataVolumeTemplates returns a DataVolumeTemplateSpec for the data disk. +func (w *ChaosDiskWorkload) DataVolumeTemplates() []kubevirtv1.DataVolumeTemplateSpec { + return []kubevirtv1.DataVolumeTemplateSpec{ + vm.BuildDataVolumeTemplate("virtwork-chaos-disk-data", w.DataDiskSize), + } +} + +// ExtraDisks returns the data disk definition. +func (w *ChaosDiskWorkload) ExtraDisks() []kubevirtv1.Disk { + return []kubevirtv1.Disk{ + { + Name: "datadisk", + DiskDevice: kubevirtv1.DiskDevice{ + Disk: &kubevirtv1.DiskTarget{ + Bus: "virtio", + }, + }, + }, + } +} + +// ExtraVolumes returns the data volume sourced from the DataVolume. +func (w *ChaosDiskWorkload) ExtraVolumes() []kubevirtv1.Volume { + return []kubevirtv1.Volume{ + { + Name: "datadisk", + VolumeSource: kubevirtv1.VolumeSource{ + DataVolume: &kubevirtv1.DataVolumeSource{ + Name: "virtwork-chaos-disk-data", + }, + }, + }, + } +} diff --git a/internal/workloads/chaos_disk_test.go b/internal/workloads/chaos_disk_test.go new file mode 100644 index 0000000..170cfe2 --- /dev/null +++ b/internal/workloads/chaos_disk_test.go @@ -0,0 +1,172 @@ +// 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("ChaosDiskWorkload", func() { + var w *workloads.ChaosDiskWorkload + + BeforeEach(func() { + w = workloads.NewChaosDiskWorkload(config.WorkloadConfig{ + Enabled: true, + VMCount: 1, + CPUCores: 2, + Memory: "2Gi", + }, "10Gi", "virtwork", "", nil) + }) + + It("should return 'chaos-disk' for Name", func() { + Expect(w.Name()).To(Equal("chaos-disk")) + }) + + It("should include chaos-disk script in write_files", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + Expect(parsed).To(HaveKey("write_files")) + files := parsed["write_files"].([]interface{}) + + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.(map[string]interface{})["path"].(string) + } + Expect(paths).To(ContainElement("/usr/local/bin/chaos-disk.sh")) + Expect(paths).To(ContainElement("/etc/systemd/system/virtwork-chaos-disk.service")) + }) + + It("should include fallocate in the chaos-disk script", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + var scriptContent string + for _, f := range files { + fm := f.(map[string]interface{}) + if fm["path"] == "/usr/local/bin/chaos-disk.sh" { + scriptContent = fm["content"].(string) + break + } + } + Expect(scriptContent).NotTo(BeEmpty()) + Expect(scriptContent).To(ContainSubstring("fallocate")) + Expect(scriptContent).To(ContainSubstring("FILL_PERCENT")) + Expect(scriptContent).To(ContainSubstring("MOUNT_POINT")) + }) + + It("should make the chaos-disk script executable", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + for _, f := range files { + fm := f.(map[string]interface{}) + if fm["path"] == "/usr/local/bin/chaos-disk.sh" { + Expect(fm["permissions"]).To(Equal("0755")) + return + } + } + Fail("chaos-disk.sh not found in write_files") + }) + + It("should include systemd unit referencing the script", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + + for _, f := range files { + fm := f.(map[string]interface{}) + if fm["path"] == "/etc/systemd/system/virtwork-chaos-disk.service" { + content := fm["content"].(string) + Expect(content).To(ContainSubstring("/usr/local/bin/chaos-disk.sh")) + Expect(content).To(ContainSubstring("Restart=always")) + return + } + } + Fail("systemd unit not found in write_files") + }) + + It("should produce valid YAML with cloud-config header", 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 data volume template", func() { + dvts := w.DataVolumeTemplates() + Expect(dvts).To(HaveLen(1)) + Expect(dvts[0].Name).To(Equal("virtwork-chaos-disk-data")) + }) + + It("should have extra disk for data volume", func() { + disks := w.ExtraDisks() + Expect(disks).To(HaveLen(1)) + Expect(disks[0].Name).To(Equal("datadisk")) + + volumes := w.ExtraVolumes() + Expect(volumes).To(HaveLen(1)) + Expect(volumes[0].Name).To(Equal("datadisk")) + }) + + It("should not require a 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(2)) + Expect(res.Memory).To(Equal("2Gi")) + }) + + It("should not require packages (tools pre-installed via golden image)", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + _, hasPackages := parsed["packages"] + Expect(hasPackages).To(BeFalse()) + }) + + It("should mount data disk and enable service via runcmd", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + + parsed := parseYAML(result) + Expect(parsed).To(HaveKey("runcmd")) + + cmds := parsed["runcmd"].([]interface{}) + cmdStrings := make([]string, 0) + for _, c := range cmds { + parts := c.([]interface{}) + s := "" + for _, p := range parts { + if s != "" { + s += " " + } + s += p.(string) + } + cmdStrings = append(cmdStrings, s) + } + + Expect(cmdStrings).To(ContainElement(ContainSubstring("daemon-reload"))) + Expect(cmdStrings).To(ContainElement(ContainSubstring("virtwork-chaos-disk.service"))) + }) +}) diff --git a/internal/workloads/registry.go b/internal/workloads/registry.go index 38105d6..d1924d0 100644 --- a/internal/workloads/registry.go +++ b/internal/workloads/registry.go @@ -68,6 +68,9 @@ func DefaultRegistry() Registry { "chaos-network": func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { return NewChaosNetworkWorkload(cfg, opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) }, + "chaos-disk": func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { + return NewChaosDiskWorkload(cfg, opts.DataDiskSize, 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 e305701..f3ac570 100644 --- a/internal/workloads/registry_test.go +++ b/internal/workloads/registry_test.go @@ -18,8 +18,19 @@ var _ = Describe("Registry", func() { reg = workloads.DefaultRegistry() }) - It("should have 8 entries registered", func() { - Expect(reg.List()).To(HaveLen(8)) + It("should have 9 entries registered", func() { + Expect(reg.List()).To(HaveLen(9)) + }) + + It("should return chaos-disk workload by name", func() { + w, err := reg.Get("chaos-disk", config.WorkloadConfig{ + Enabled: true, + VMCount: 1, + CPUCores: 2, + Memory: "2Gi", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(w.Name()).To(Equal("chaos-disk")) }) It("should return CPU workload by name", func() { @@ -103,8 +114,9 @@ var _ = Describe("Registry", 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-disk")) Expect(err.Error()).To(ContainSubstring("chaos-network")) + Expect(err.Error()).To(ContainSubstring("chaos-process")) Expect(err.Error()).To(ContainSubstring("cpu")) Expect(err.Error()).To(ContainSubstring("database")) Expect(err.Error()).To(ContainSubstring("disk")) @@ -116,6 +128,7 @@ var _ = Describe("Registry", func() { It("should list all names sorted alphabetically", func() { names := reg.List() Expect(names).To(Equal([]string{ + "chaos-disk", "chaos-network", "chaos-process", "cpu",