vstorm uses Bats (Bash Automated Testing System) for unit tests and GitHub Actions for CI.
All tests use dry-run mode (-n or -q) unless they need live-mode behavior (StorageProfile auto-detection, WFFC handling), in which case they use a mock oc script. No real cluster is required.
Each test:
- Runs
vstormwith a fixed--batch-idand specific flags - Captures the full stdout/stderr via Bats
run - Asserts on the generated YAML: resource kinds, names, namespaces, labels, template fields, and counts
This validates that the correct templates are selected, variables are substituted, and the creation flow (DV, snapshot/no-snapshot, VM, cloud-init Secret, UDN, vm-service) matches expectations.
# Run all tests
bats tests/
# Run a single test by name
bats tests/vstorm.bats --filter "QS: default DataSource"Bats is available via most package managers (apt install bats, brew install bats-core).
One test per README Quick Start example. Each validates the full YAML output end-to-end:
| Test | Command | What it validates |
|---|---|---|
| QS-1 | --vms=10 --namespaces=2 |
DataSource DV, snapshot, 10 VMs, auto cloud-init, labels, VM spec |
| QS-2 | --datasource=fedora --vms=5 |
Fedora DataSource in DV, snapshot path, auto cloud-init |
| QS-3 | --dv-url=... --vms=10 |
URL import DV with explicit size, snapshot, 10 VMs, no auto cloud-init |
| QS-4 | --cloudinit=...stress... --vms=10 |
Custom cloud-init Secret per namespace, secretRef, not auto-applied |
| QS-5 | --datasource=centos-stream9 --vms=5 |
Different DataSource with default cloud-init auto-applied |
| QS-6 | --storage-class=my-nfs-sc --vms=10 |
Non-OCS storage class auto-disables snapshots, direct DataSource clone (no base DV) |
| QS-7 | --storage-class=X --snapshot-class=Y --vms=10 |
Custom storage + snapshot class pair keeps snapshots enabled |
| QS-8 | --no-snapshot --vms=10 |
Explicit no-snapshot mode, direct DataSource clone, auto cloud-init |
| QS-9 | -n --vms=10 |
Dry-run outputs YAML, no oc apply, no completion message |
| QS-10 | --delete=a3f7b2 |
Delete dry-run shows correct oc delete command |
| QS-11 | --udn-l2=10.132.10.0/16 --service --vms=10 --namespaces=2 |
UDN CR per namespace, l2bridge VMs, NodePort vm-service (port 22) |
The file workload/cloudinit-dirty-mem-pages.yaml
installs gcc, writes dirty-mem-pages.c, compiles to /usr/local/bin/dirty-mem-pages, and starts
dirty-mem-pages.service. The RAM fraction is passed from the host with
--env DIRTY_RATE_FRACTION=value (0.1–0.9). If --env is not used, the service defaults to
0.5 via Environment= in the unit file.
Example dry-run:
vstorm -n --batch-id=dr-test --datasource=rhel9 --vms=1 --namespaces=1 \
--cloudinit=workload/cloudinit-dirty-mem-pages.yaml --env DIRTY_RATE_FRACTION=0.4| Test | What it validates |
|---|---|
| YAML file saved | Dry-run creates a file with all resource types and document separators |
| Batch ID and namespaces | Correct batch ID and namespace names in the saved YAML file |
| No-snapshot YAML | DataSource clone (no PVC clone, no snapshot) in saved file |
| Quiet mode | -q mode does not create a YAML file |
- Batch ID -- auto-generated 6-character hex ID
- Namespace naming --
vm-{batch}-ns-{N}pattern - VM distribution -- even spread with remainder in first namespaces
--deletewithout a value is rejected- Non-numeric positional arguments are rejected
--cloudinitwith a missing file fails--dv-url=with no URL fails- Too many positional arguments rejected with diagnostic count
- Misplaced options after positional arguments are detected (ERR-23 through ERR-26)
--end-of-options marker does not trigger false positives (ERR-27)
Each Kubernetes resource type has a dedicated structure test:
| Test | Validates |
|---|---|
| DataSource DV | storage: API, accessModes, volumeMode, explicit size for WFFC compatibility |
| URL DV | source.http.url, explicit storage: 50Gi |
| VirtualMachine | metadata, runStrategy, dataVolumeTemplates, CPU/memory, devices, firmware, scheduling, volumes |
| VolumeSnapshot | apiVersion, snapshotClassName, PVC source |
| Namespace | apiVersion, name, batch-id label |
| Cloud-init Secret | apiVersion, name, namespace, type, userdata, labels |
--run-strategy=Halted |
runStrategy: Halted |
Tests for --no-snapshot which skips VolumeSnapshots. With a DataSource, each VM's inline DataVolumeTemplate clones directly from the DataSource (no intermediate base DV). With a URL import, VMs still clone from a base PVC:
| Test | Command | What it validates |
|---|---|---|
| NS-1 | --no-snapshot --vms=3 |
Skips VolumeSnapshot creation, skips base DV, uses inline DataVolumeTemplates |
| NS-2 | --no-snapshot --vms=2 |
VMs use sourceRef (DataSource) not source.pvc, no rhel9-base reference |
| NS-3 | --no-snapshot --dv-url=... |
URL import mode still creates base DV and uses PVC clone |
| NS-4 | --no-snapshot --cloudinit=... |
Cloud-init works with direct DataSource clone |
| NS-5 | --no-snapshot --vms=10 --namespaces=3 |
Multiple namespaces with direct DataSource clone |
| NS-6 | --no-snapshot --storage-class=my-sc |
Custom storage class applied, no base DV |
| NS-7 | --snapshot-class=... |
Explicit --snapshot-class produces snapshot-based flow |
| NS-8 | --no-snapshot --cores=4 --memory=8Gi |
vm-datasource.yaml template is well-formed with all fields |
Tests for the direct DataSource clone path (no-snapshot + DataSource), where each VM's inline DataVolumeTemplate clones directly from the DataSource. This eliminates the intermediate base DV/PVC, avoiding WaitForFirstConsumer deadlocks:
| Test | Command | What it validates |
|---|---|---|
| DC-1 | --no-snapshot --datasource=fedora |
Custom DataSource name propagates into each VM's inline DV sourceRef |
| DC-2 | --no-snapshot --datasource=win2k22 --basename=win2k22 |
DataSource name and namespace appear correctly in inline DV |
| DC-3 | --no-snapshot --storage-size=50Gi |
Custom storage size propagates into inline DV's storage request |
| DC-4 | --no-snapshot --vms=3 |
Each VM gets a uniquely named DV (rhel9-{batch}-1, -2, -3), no rhel9-base |
| DC-5 | --no-snapshot --vms=4 --namespaces=2 |
Multi-namespace: no per-namespace base DV, each VM references DataSource |
| DC-6 | --no-snapshot --dv-url=... |
URL import still creates base DV and uses PVC clone (old path preserved) |
| DC-7 | --snapshot-class=... |
Snapshot mode still creates base DV + VolumeSnapshot (old path preserved) |
| DC-8 | --no-snapshot --access-mode=ReadWriteOnce |
RWO access mode correctly applied to inline DV in vm-datasource.yaml |
| DC-9 | (live mode, mock oc) | Completion message shows "direct DataSource clone, no base DVs" |
| DC-10 | --no-snapshot --run-strategy=Halted |
runStrategy: Halted works with direct DataSource clone path |
Tests for automatic snapshot mode detection based on --storage-class and --snapshot-class:
| Test | Command | What it validates |
|---|---|---|
| AD-1 | --storage-class=my-nfs-sc |
Custom storage class without snapshot-class auto-disables snapshots, uses direct DataSource clone |
| AD-2 | --storage-class=my-rbd-sc --snapshot-class=my-snap |
Both classes provided keeps snapshots enabled |
| AD-3 | --storage-class=my-ceph-sc --snapshot-class=my-snap |
Explicit --snapshot-class keeps snapshots enabled |
| AD-4 | (no storage flags) | Default OCS storage class keeps snapshots enabled |
Tests for --access-mode CLI option:
| Test | Command | What it validates |
|---|---|---|
| AM-1 | (default) | Default access mode is ReadWriteMany |
| AM-2 | --access-mode=ReadWriteOnce --no-snapshot |
RWO sets ReadWriteOnce on all resources, no ReadWriteMany |
| AM-3 | --access-mode=ReadWriteOnce --no-snapshot |
Long-form option works |
| AM-4 | --access-mode=ReadWriteMany |
RWX sets ReadWriteMany |
| AM-5 | --access-mode=ReadWriteOnce --snapshot-class=... |
RWO applies to snapshot-based VMs too |
| AM-6 | --access-mode=ReadWriteOnce --no-snapshot --dv-url=... |
RWO with URL import mode |
Tests that ensure every CLI option has at least one test case:
| Test | Command | What it validates |
|---|---|---|
| OPT-1 | --pvc-base-name=custom-base --snapshot-class=... |
Custom PVC name propagates into VolumeSnapshot persistentVolumeClaimName |
| OPT-2 | --request-cpu=500m |
CPU request appears in resources.requests.cpu in VM spec |
| OPT-3 | --request-memory=512Mi |
Memory request appears in resources.requests.memory in VM spec |
| OPT-4 | --request-cpu=2 --request-memory=4Gi |
Both CPU and memory requests present together |
| OPT-5 | --vms-per-namespace=3 --namespaces=2 |
Calculates total VMs (6), distributes 3 per namespace |
| OPT-6 | --run-strategy=RerunOnFailure |
Custom run strategy value propagates to VM YAML |
| OPT-7 | --run-strategy=Always |
Sets runStrategy: Always |
| OPT-8 | --wait |
Option accepted without error |
| OPT-9 | --wait=false |
Option accepted without error |
| OPT-10 | --create-existing-vm |
Option accepted without error |
| OPT-11 | --no-create-existing-vm |
Option accepted without error |
| OPT-12 | -h |
Displays help/usage text |
| OPT-13 | 8 3 (positional args) |
8 VMs across 3 namespaces via positional arguments |
Live-mode tests using a mock oc that returns configured StorageProfile access modes:
| Test | Mock returns | What it validates |
|---|---|---|
| SP-1 | ReadWriteOnce |
Auto-detects RWO from StorageProfile (e.g. LVMS) |
| SP-2 | ReadWriteMany |
Auto-detects RWX from StorageProfile (e.g. OCS/Ceph) |
| SP-3 | (unavailable) | Falls back to default ReadWriteMany with warning |
| SP-4 | ReadWriteMany + --access-mode=ReadWriteOnce |
Explicit RWO overrides StorageProfile RWX |
| SP-5 | ReadWriteOnce + --access-mode=ReadWriteMany |
Explicit RWX overrides StorageProfile RWO |
Live-mode tests using a mock oc that simulates WaitForFirstConsumer (WFFC) storage classes:
| Test | Scenario | What it validates |
|---|---|---|
| WFFC-1 | WFFC + DataSource + no-snapshot | Skips base DV entirely (direct DataSource clone avoids deadlock) |
| WFFC-2 | WFFC + URL import + no-snapshot | Skips DV wait, proceeds to VM creation (VMs trigger PVC binding) |
| WFFC-3 | Immediate binding + URL import | Normal DV wait (no skip), all DVs complete before VMs |
| WFFC-4 | WFFC + explicit --snapshot-class |
Snapshot mode auto-disabled with warning, falls back to direct DataSource clone |
| WFFC-5 | WFFC in dry-run | WFFC warning shown when oc is available in dry-run |
Multi-option combination tests that validate interactions between 3+ options used together. Organized into 9 categories:
| Test | Combination | What it validates |
|---|---|---|
| COMBO-1 | --storage-class=X --access-mode=ReadWriteOnce --no-snapshot |
Custom SC + access mode on inline DataSource DV |
| COMBO-2 | --storage-class=X --snapshot-class=Y --access-mode=ReadWriteOnce |
All 3 storage options in snapshot path |
| COMBO-3 | --storage-class=X --access-mode=ReadWriteOnce --dv-url=... |
Custom SC + access mode on URL base DV + PVC clone VM |
| COMBO-4 | --storage-class=X --storage-size=50Gi --dv-url=... |
Custom SC + size on URL import DV |
| COMBO-5 | --storage-size=50Gi --no-snapshot |
Custom size on inline DataSource DV |
| COMBO-6 | --storage-size=50Gi --snapshot-class=... |
Custom size on base DV + snapshot flow |
| COMBO-7 | --access-mode=ReadWriteMany --dv-url=... --snapshot-class=... |
RWX on URL + snapshot path |
| COMBO-8 | --access-mode=ReadWriteOnce --storage-class=X --no-snapshot --storage-size=50Gi |
All storage options on DataSource clone path |
| COMBO-9 | --access-mode=ReadWriteOnce --storage-class=X --snapshot-class=Y |
Access mode with custom SC pair |
| Test | Combination | What it validates |
|---|---|---|
| COMBO-10 | --dv-url=... --snapshot-class=... --cloudinit=FILE |
URL + snapshot + custom cloud-init |
| COMBO-11 | --dv-url=... --no-snapshot --cloudinit=FILE |
URL + no-snapshot + custom cloud-init |
| COMBO-12 | --no-snapshot --cloudinit=FILE --namespaces=3 |
Secret created per namespace in DataSource clone |
| COMBO-13 | --dv-url=... --snapshot-class=... (no --cloudinit) |
URL+snapshot: no auto cloud-init applied |
| COMBO-14 | --no-snapshot --basename=fedora --cloudinit=FILE |
Custom basename affects Secret name + DataSource clone |
| Test | Combination | What it validates |
|---|---|---|
| COMBO-15 | --request-cpu=2 --request-memory=4Gi --snapshot-class=... |
Resource requests in vm-snap.yaml template |
| COMBO-16 | --request-cpu=2 --request-memory=4Gi --dv-url=... --no-snapshot |
Resource requests in vm-clone.yaml template |
| COMBO-17 | --cores=4 --memory=8Gi --request-cpu=2 --request-memory=4Gi |
Limits and requests all together (snapshot path) |
| COMBO-18 | --cores=4 --memory=8Gi --request-cpu=2 --request-memory=4Gi --no-snapshot |
Same on DataSource clone path |
| Test | Combination | What it validates |
|---|---|---|
| COMBO-19 | --run-strategy=Halted --snapshot-class=... |
runStrategy: Halted in snapshot VM template |
| COMBO-20 | --run-strategy=Halted --dv-url=... --no-snapshot |
Halted with URL import PVC clone |
| COMBO-21 | --run-strategy=Always --no-snapshot |
runStrategy: Always explicit on DataSource clone |
| COMBO-22 | --run-strategy=Manual --snapshot-class=... |
Custom strategy on snapshot path |
| COMBO-23 | --run-strategy=Manual --no-snapshot |
Custom strategy on DataSource clone |
| COMBO-24 | --run-strategy=Manual --dv-url=... --no-snapshot |
Custom strategy on URL PVC clone |
| Test | Combination | What it validates |
|---|---|---|
| COMBO-25 | --vms-per-namespace=3 --namespaces=2 --no-snapshot |
VMs-per-ns with DataSource direct clone |
| COMBO-26 | --vms-per-namespace=3 --namespaces=2 --snapshot-class=... |
VMs-per-ns with snapshot flow |
| COMBO-27 | --vms-per-namespace=4 --namespaces=3 --cloudinit=FILE |
VMs-per-ns + cloud-init Secret per namespace |
| COMBO-28 | positional 7 3 + --no-snapshot --cloudinit=FILE |
Positional args with clone path + cloud-init |
| COMBO-29 | positional 5 2 + --cores=4 --memory=8Gi |
Positional args with VM config options |
| Test | Combination | What it validates |
|---|---|---|
| COMBO-30 | --basename=myvm --pvc-base-name=myvm-base --snapshot-class=... |
Both naming options set (different from defaults) |
| COMBO-31 | --basename=myvm --snapshot-class=... (default pvc-base-name) |
basename changes VM/DV/snapshot names, PVC name stays rhel9-base |
| COMBO-32 | --datasource=fedora --basename=custom-vm --no-snapshot |
DataSource and basename are different values |
| COMBO-33 | --basename=myvm --no-snapshot --namespaces=2 |
Custom basename on DataSource clone across namespaces |
| COMBO-34 | --basename=myvm --dv-url=... --no-snapshot |
Custom basename on URL import path |
| Test | Combination | What it validates |
|---|---|---|
| COMBO-35 | --vms-per-namespace=3 --vms=10 --namespaces=2 |
--vms-per-namespace overrides --vms (total=6, not 10) |
| COMBO-36 | --vms=10 + positional 5 |
Positional arg overrides --vms |
| COMBO-37 | --snapshot-class=X --no-snapshot |
Explicit --no-snapshot wins over --snapshot-class |
| COMBO-38 | --snapshot-class=X (no --storage-class) |
--snapshot-class alone keeps snapshot mode enabled |
| COMBO-39 | --run-strategy=Halted --wait |
Both accepted without error in dry-run |
| COMBO-40 | --dv-url=... --datasource=fedora |
--dv-url clears DataSource |
| COMBO-41 | --run-strategy=Always --run-strategy=Halted |
Last flag wins (Halted) |
| COMBO-42 | --run-strategy=Halted --run-strategy=Always |
Last wins (Always) |
Live-mode tests using a mock oc that simulates WFFC storage combined with other features:
| Test | Combination | What it validates |
|---|---|---|
| COMBO-43 | WFFC + --cloudinit=FILE --no-snapshot |
Cloud-init Secret still created under WFFC |
| COMBO-44 | WFFC + --dv-url=... (auto-detected RWO) |
WFFC + URL import skips DV wait |
| COMBO-45 | WFFC + --vms-per-namespace=3 --namespaces=2 |
WFFC at scale |
| COMBO-46 | WFFC + --snapshot-class=... --cloudinit=FILE |
WFFC auto-disables snapshot, cloud-init still works |
| Test | Combination | What it validates |
|---|---|---|
| COMBO-47 | -q --no-snapshot --vms=3 |
Quiet mode with DataSource clone path |
| COMBO-48 | -q --dv-url=... --vms=2 |
Quiet mode with URL import |
| COMBO-49 | -q --delete=abc123 |
Quiet mode with delete |
Tests in tests/14-udn.bats cover the --udn-l2[=CIDR] and --service flags. All use dry-run mode with the shared mock oc.
| Test | Command | What it validates |
|---|---|---|
| Standalone service | --service (no --udn-l2) |
Service created without UDN |
| ERR-UDN-2 | --service=invalid |
Invalid service type rejected |
| ERR-UDN-2b | --service=nodeport:70000 |
Invalid port rejected |
| ERR-UDN-3 | --udn-l2=not-a-cidr |
Invalid CIDR format rejected |
| ERR-UDN-4 | --udn-l2=10.0.0.0/33 |
CIDR prefix out of range rejected |
| ERR-UDN-5 | partial templates | Missing udn-l2.yaml template fails |
| ERR-UDN-6 | partial templates | Missing vm-*-udn.yaml template fails |
| ERR-UDN-7 | partial templates | Missing vm-service.yaml template fails |
| UDN-1 | --udn-l2 --vms=2 --namespaces=2 |
UserDefinedNetwork CR per namespace, primary UDN label, default subnet |
| UDN-2 | --udn-l2=10.200.0.0/16 |
Custom subnet in CR and summary |
| UDN-3 | --udn-l2 |
VM uses l2bridge / primary-udn-net, not masquerade |
| UDN-4 | --udn-l2 --containerdisk |
Container disk path with UDN VM template |
| UDN-5 | --udn-l2 --no-snapshot --datasource=rhel9 |
Direct DataSource clone with UDN VM template |
| UDN-6 | (no UDN flags) | Default masquerade networking unchanged |
| UDN-7 | --udn-l2 --containerdisk |
Auto cloud-init includes DHCP networkData |
| UDN-8 | --udn-l2 --cloudinit=... |
Explicit cloud-init includes DHCP networkData |
| UDN-9 | --udn-l2 --dv-url=... --no-snapshot |
No networkData when cloud-init not applied |
| UDN-10 | --udn-l2 --service --namespaces=2 |
NodePort Service per namespace, port 22 (32222+) |
| UDN-11 | --udn-l2 --service=clusterip |
ClusterIP Service, port 22, no nodePort |
| UDN-12 | --udn-l2 --service --namespaces=2 |
NodePort auto-increment logged per namespace |
| UDN-13 | --udn-l2 --service=clusterip |
ClusterIP service name matches in-cluster hostname pattern |
| UDN-14 | --udn-l2 (no --service) |
No Service created |
| UDN-15 | --udn-l2 --service=nodeport:8080 |
Custom port in Service spec (port and targetPort both 8080) |
| UDN-16 | --udn-l2 --service=nodeport:22:2222 |
Service port and targetPort set separately |
vstorm uses three different clone strategies depending on the mode:
1. Snapshot mode (default for OCS storage):
DataSource → base DV/PVC → VolumeSnapshot → VM DVs clone from snapshot
2. No-snapshot + DataSource (default for non-OCS storage):
DataSource → VM DVs clone directly from DataSource (no base DV)
3. No-snapshot + URL import (--dv-url):
URL → base DV/PVC → VM DVs clone from base PVC
Path 2 was introduced to eliminate the intermediate base DV, which caused WaitForFirstConsumer deadlocks with local storage (e.g. LVMS). Each VM's Pod directly acts as the WFFC consumer for its own DV.
GitHub Actions runs three jobs on every push and PR to main:
| Job | Tool | Scope |
|---|---|---|
test |
bats |
All tests in tests/ |
lint-yaml |
yamllint |
helpers/*.yaml, workload/*.yaml, monitoring/yaml/*.yaml, monitoring/tests/fixtures/*.yaml, .github/workflows/*.yaml |
lint-markdown |
markdownlint-cli2 |
All *.md files |
Configuration files:
.yamllint-- relaxes line length (200), disables document-start and truthy checks, allows#cloud-configcomments.markdownlint.yaml-- increases line length (400), disables MD040 and MD060