Compare commits

..

2 Commits

Author SHA1 Message Date
Paige Patton
e514de3519 Merge branch 'main' of https://github.com/krkn-chaos/krkn into snyk-fix-e72d9a02c072cb1181e4f191ffbeb371 2025-12-19 10:07:52 -05:00
snyk-bot
192d8ea59f fix: utils/chaos_ai/requirements.txt to reduce vulnerabilities
The following vulnerabilities are fixed by pinning transitive dependencies:
- https://snyk.io/vuln/SNYK-PYTHON-WERKZEUG-14151620

Signed-off-by: Paige Patton <prubenda@redhat.com>
2025-12-02 09:01:01 -05:00
89 changed files with 2684 additions and 10339 deletions

View File

@@ -1,47 +1,27 @@
# Type of change
## Type of change
- [ ] Refactor
- [ ] New feature
- [ ] Bug fix
- [ ] Optimization
# Description
<-- Provide a brief description of the changes made in this PR. -->
## Description
<!-- Provide a brief description of the changes made in this PR. -->
## Related Tickets & Documents
If no related issue, please create one and start the converasation on wants of
- Related Issue #:
- Closes #:
- Related Issue #
- Closes #
# Documentation
## Documentation
- [ ] **Is documentation needed for this update?**
If checked, a documentation PR must be created and merged in the [website repository](https://github.com/krkn-chaos/website/).
## Related Documentation PR (if applicable)
<-- Add the link to the corresponding documentation PR in the website repository -->
<!-- Add the link to the corresponding documentation PR in the website repository -->
# Checklist before requesting a review
[ ] Ensure the changes and proposed solution have been discussed in the relevant issue and have received acknowledgment from the community or maintainers. See [contributing guidelines](https://krkn-chaos.dev/docs/contribution-guidelines/)
See [testing your changes](https://krkn-chaos.dev/docs/developers-guide/testing-changes/) and run on any Kubernetes or OpenShift cluster to validate your changes
- [ ] I have performed a self-review of my code by running krkn and specific scenario
- [ ] If it is a core feature, I have added thorough unit tests with above 80% coverage
## Checklist before requesting a review
*REQUIRED*:
Description of combination of tests performed and output of run
```bash
python run_kraken.py
...
<---insert test results output--->
```
OR
```bash
python -m coverage run -a -m unittest discover -s tests -v
...
<---insert test results output--->
```
- [ ] I have performed a self-review of my code.
- [ ] If it is a core feature, I have added thorough tests.

View File

@@ -1,52 +0,0 @@
name: Manage Stale Issues and Pull Requests
on:
schedule:
# Run daily at 1:00 AM UTC
- cron: '0 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
stale:
name: Mark and Close Stale Issues and PRs
runs-on: ubuntu-latest
steps:
- name: Mark and close stale issues and PRs
uses: actions/stale@v9
with:
days-before-issue-stale: 60
days-before-issue-close: 14
stale-issue-label: 'stale'
stale-issue-message: |
This issue has been automatically marked as stale because it has not had any activity in the last 60 days.
It will be closed in 14 days if no further activity occurs.
If this issue is still relevant, please leave a comment or remove the stale label.
Thank you for your contributions to krkn!
close-issue-message: |
This issue has been automatically closed due to inactivity.
If you believe this issue is still relevant, please feel free to reopen it or create a new issue with updated information.
Thank you for your understanding!
close-issue-reason: 'not_planned'
days-before-pr-stale: 90
days-before-pr-close: 14
stale-pr-label: 'stale'
stale-pr-message: |
This pull request has been automatically marked as stale because it has not had any activity in the last 90 days.
It will be closed in 14 days if no further activity occurs.
If this PR is still relevant, please rebase it, address any pending reviews, or leave a comment.
Thank you for your contributions to krkn!
close-pr-message: |
This pull request has been automatically closed due to inactivity.
If you believe this PR is still relevant, please feel free to reopen it or create a new pull request with updated changes.
Thank you for your understanding!
# Exempt labels
exempt-issue-labels: 'bug,enhancement,good first issue'
exempt-pr-labels: 'pending discussions,hold'
remove-stale-when-updated: true

View File

@@ -32,14 +32,13 @@ jobs:
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
python-version: '3.9'
architecture: 'x64'
- name: Install environment
run: |
sudo apt-get install build-essential python3-dev
pip install --upgrade pip
pip install -r requirements.txt
pip install coverage
- name: Deploy test workloads
run: |
@@ -77,7 +76,9 @@ jobs:
- name: Run unit tests
run: python -m coverage run -a -m unittest discover -s tests -v
- name: Setup Functional Tests
- name: Setup Pull Request Functional Tests
if: |
github.event_name == 'pull_request'
run: |
yq -i '.kraken.performance_monitoring="localhost:9090"' CI/config/common_test_config.yaml
yq -i '.elastic.elastic_port=9200' CI/config/common_test_config.yaml
@@ -85,21 +86,21 @@ jobs:
yq -i '.elastic.enable_elastic=False' CI/config/common_test_config.yaml
yq -i '.elastic.password="${{env.ELASTIC_PASSWORD}}"' CI/config/common_test_config.yaml
yq -i '.performance_monitoring.prometheus_url="http://localhost:9090"' CI/config/common_test_config.yaml
echo "test_service_hijacking" > ./CI/tests/functional_tests
echo "test_app_outages" >> ./CI/tests/functional_tests
echo "test_container" >> ./CI/tests/functional_tests
echo "test_cpu_hog" >> ./CI/tests/functional_tests
echo "test_customapp_pod" >> ./CI/tests/functional_tests
echo "test_io_hog" >> ./CI/tests/functional_tests
echo "test_memory_hog" >> ./CI/tests/functional_tests
echo "test_namespace" >> ./CI/tests/functional_tests
echo "test_net_chaos" >> ./CI/tests/functional_tests
echo "test_node" >> ./CI/tests/functional_tests
echo "test_container" >> ./CI/tests/functional_tests
echo "test_pod" >> ./CI/tests/functional_tests
echo "test_pod_error" >> ./CI/tests/functional_tests
echo "test_service_hijacking" >> ./CI/tests/functional_tests
echo "test_customapp_pod" >> ./CI/tests/functional_tests
echo "test_namespace" >> ./CI/tests/functional_tests
echo "test_net_chaos" >> ./CI/tests/functional_tests
echo "test_time" >> ./CI/tests/functional_tests
echo "test_cpu_hog" >> ./CI/tests/functional_tests
echo "test_memory_hog" >> ./CI/tests/functional_tests
echo "test_io_hog" >> ./CI/tests/functional_tests
echo "test_pod_network_filter" >> ./CI/tests/functional_tests
echo "test_pod_server" >> ./CI/tests/functional_tests
echo "test_time" >> ./CI/tests/functional_tests
echo "test_node" >> ./CI/tests/functional_tests
# echo "test_pvc" >> ./CI/tests/functional_tests
# Push on main only steps + all other functional to collect coverage
@@ -114,9 +115,31 @@ jobs:
- name: Setup Post Merge Request Functional Tests
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
yq -i '.kraken.performance_monitoring="localhost:9090"' CI/config/common_test_config.yaml
yq -i '.elastic.enable_elastic=False' CI/config/common_test_config.yaml
yq -i '.elastic.password="${{env.ELASTIC_PASSWORD}}"' CI/config/common_test_config.yaml
yq -i '.elastic.elastic_port=9200' CI/config/common_test_config.yaml
yq -i '.elastic.elastic_url="https://localhost"' CI/config/common_test_config.yaml
yq -i '.performance_monitoring.prometheus_url="http://localhost:9090"' CI/config/common_test_config.yaml
yq -i '.telemetry.username="${{secrets.TELEMETRY_USERNAME}}"' CI/config/common_test_config.yaml
yq -i '.telemetry.password="${{secrets.TELEMETRY_PASSWORD}}"' CI/config/common_test_config.yaml
echo "test_telemetry" >> ./CI/tests/functional_tests
echo "test_service_hijacking" >> ./CI/tests/functional_tests
echo "test_app_outages" >> ./CI/tests/functional_tests
echo "test_container" >> ./CI/tests/functional_tests
echo "test_pod" >> ./CI/tests/functional_tests
echo "test_telemetry" > ./CI/tests/functional_tests
echo "test_pod_error" >> ./CI/tests/functional_tests
echo "test_customapp_pod" >> ./CI/tests/functional_tests
echo "test_namespace" >> ./CI/tests/functional_tests
echo "test_net_chaos" >> ./CI/tests/functional_tests
echo "test_time" >> ./CI/tests/functional_tests
echo "test_cpu_hog" >> ./CI/tests/functional_tests
echo "test_memory_hog" >> ./CI/tests/functional_tests
echo "test_io_hog" >> ./CI/tests/functional_tests
echo "test_pod_network_filter" >> ./CI/tests/functional_tests
echo "test_pod_server" >> ./CI/tests/functional_tests
echo "test_node" >> ./CI/tests/functional_tests
# echo "test_pvc" >> ./CI/tests/functional_tests
# Final common steps
- name: Run Functional tests
env:
@@ -182,7 +205,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
python-version: 3.9
- name: Copy badge on GitHub Page Repo
env:
COLOR: yellow

View File

@@ -42,7 +42,7 @@ telemetry:
prometheus_backup: True # enables/disables prometheus data collection
full_prometheus_backup: False # if is set to False only the /prometheus/wal folder will be downloaded.
backup_threads: 5 # number of telemetry download/upload threads
archive_path: /tmp # local path where the archive files will be temporarily stored
archive_path: /tmp # local path where the archive files will be temporarly stored
max_retries: 0 # maximum number of upload retries (if 0 will retry forever)
run_tag: '' # if set, this will be appended to the run folder in the bucket (useful to group the runs)
archive_size: 10000 # the size of the prometheus data archive size in KB. The lower the size of archive is

View File

@@ -18,8 +18,9 @@ function functional_test_telemetry {
yq -i '.performance_monitoring.prometheus_url="http://localhost:9090"' CI/config/common_test_config.yaml
yq -i '.telemetry.run_tag=env(RUN_TAG)' CI/config/common_test_config.yaml
export scenario_type="pod_disruption_scenarios"
export scenario_file="scenarios/kind/pod_etcd.yml"
export scenario_type="hog_scenarios"
export scenario_file="scenarios/kube/cpu-hog.yml"
export post_config=""
envsubst < CI/config/common_test_config.yaml > CI/config/telemetry.yaml

273
CLAUDE.md
View File

@@ -1,273 +0,0 @@
# CLAUDE.md - Krkn Chaos Engineering Framework
## Project Overview
Krkn (Kraken) is a chaos engineering tool for Kubernetes/OpenShift clusters. It injects deliberate failures to validate cluster resilience. Plugin-based architecture with multi-cloud support (AWS, Azure, GCP, IBM Cloud, VMware, Alibaba, OpenStack).
## Repository Structure
```
krkn/
├── krkn/
│ ├── scenario_plugins/ # Chaos scenario plugins (pod, node, network, hogs, etc.)
│ ├── utils/ # Utility functions
│ ├── rollback/ # Rollback management
│ ├── prometheus/ # Prometheus integration
│ └── cerberus/ # Health monitoring
├── tests/ # Unit tests (unittest framework)
├── scenarios/ # Example scenario configs (openshift/, kube/, kind/)
├── config/ # Configuration files
└── CI/ # CI/CD test scripts
```
## Quick Start
```bash
# Setup (ALWAYS use virtual environment)
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Run Krkn
python run_kraken.py --config config/config.yaml
# Note: Scenarios are specified in config.yaml under kraken.chaos_scenarios
# There is no --scenario flag; edit config/config.yaml to select scenarios
# Run tests
python -m unittest discover -s tests -v
python -m coverage run -a -m unittest discover -s tests -v
```
## Critical Requirements
### Python Environment
- **Python 3.9+** required
- **NEVER install packages globally** - always use virtual environment
- **CRITICAL**: `docker` must be <7.0 and `requests` must be <2.32 (Unix socket compatibility)
### Key Dependencies
- **krkn-lib** (5.1.13): Core library for Kubernetes/OpenShift operations
- **kubernetes** (34.1.0): Kubernetes Python client
- **docker** (<7.0), **requests** (<2.32): DO NOT upgrade without verifying compatibility
- Cloud SDKs: boto3 (AWS), azure-mgmt-* (Azure), google-cloud-compute (GCP), ibm_vpc (IBM), pyVmomi (VMware)
## Plugin Architecture (CRITICAL)
**Strictly enforced naming conventions:**
### Naming Rules
- **Module files**: Must end with `_scenario_plugin.py` and use snake_case
- Example: `pod_disruption_scenario_plugin.py`
- **Class names**: Must be CamelCase and end with `ScenarioPlugin`
- Example: `PodDisruptionScenarioPlugin`
- Must match module filename (snake_case ↔ CamelCase)
- **Directory structure**: Plugin dirs CANNOT contain "scenario" or "plugin"
- Location: `krkn/scenario_plugins/<plugin_name>/`
### Plugin Implementation
Every plugin MUST:
1. Extend `AbstractScenarioPlugin`
2. Implement `run()` method
3. Implement `get_scenario_types()` method
```python
from krkn.scenario_plugins import AbstractScenarioPlugin
class PodDisruptionScenarioPlugin(AbstractScenarioPlugin):
def run(self, config, scenarios_list, kubeconfig_path, wait_duration):
pass
def get_scenario_types(self):
return ["pod_scenarios", "pod_outage"]
```
### Creating a New Plugin
1. Create directory: `krkn/scenario_plugins/<plugin_name>/`
2. Create module: `<plugin_name>_scenario_plugin.py`
3. Create class: `<PluginName>ScenarioPlugin` extending `AbstractScenarioPlugin`
4. Implement `run()` and `get_scenario_types()`
5. Create unit test: `tests/test_<plugin_name>_scenario_plugin.py`
6. Add example scenario: `scenarios/<platform>/<scenario>.yaml`
**DO NOT**: Violate naming conventions (factory will reject), include "scenario"/"plugin" in directory names, create plugins without tests.
## Testing
### Unit Tests
```bash
# Run all tests
python -m unittest discover -s tests -v
# Specific test
python -m unittest tests.test_pod_disruption_scenario_plugin
# With coverage
python -m coverage run -a -m unittest discover -s tests -v
python -m coverage html
```
**Test requirements:**
- Naming: `test_<module>_scenario_plugin.py`
- Mock external dependencies (Kubernetes API, cloud providers)
- Test success, failure, and edge cases
- Keep tests isolated and independent
### Functional Tests
Located in `CI/tests/`. Can be run locally on a kind cluster with Prometheus and Elasticsearch set up.
**Setup for local testing:**
1. Deploy Prometheus and Elasticsearch on your kind cluster:
- Prometheus setup: https://krkn-chaos.dev/docs/developers-guide/testing-changes/#prometheus
- Elasticsearch setup: https://krkn-chaos.dev/docs/developers-guide/testing-changes/#elasticsearch
2. Or disable monitoring features in `config/config.yaml`:
```yaml
performance_monitoring:
enable_alerts: False
enable_metrics: False
check_critical_alerts: False
```
**Note:** Functional tests run automatically in CI with full monitoring enabled.
## Cloud Provider Implementations
Node chaos scenarios are cloud-specific. Each in `krkn/scenario_plugins/node_actions/<provider>_node_scenarios.py`:
- AWS, Azure, GCP, IBM Cloud, VMware, Alibaba, OpenStack, Bare Metal
Implement: stop, start, reboot, terminate instances.
**When modifying**: Maintain consistency with other providers, handle API errors, add logging, update tests.
### Adding Cloud Provider Support
1. Create: `krkn/scenario_plugins/node_actions/<provider>_node_scenarios.py`
2. Extend: `abstract_node_scenarios.AbstractNodeScenarios`
3. Implement: `stop_instances`, `start_instances`, `reboot_instances`, `terminate_instances`
4. Add SDK to `requirements.txt`
5. Create unit test with mocked SDK
6. Add example scenario: `scenarios/openshift/<provider>_node_scenarios.yml`
## Configuration
**Main config**: `config/config.yaml`
- `kraken`: Core settings
- `cerberus`: Health monitoring
- `performance_monitoring`: Prometheus
- `elastic`: Elasticsearch telemetry
**Scenario configs**: `scenarios/` directory
```yaml
- config:
scenario_type: <type> # Must match plugin's get_scenario_types()
```
## Code Style
- **Import order**: Standard library, third-party, local imports
- **Naming**: snake_case (functions/variables), CamelCase (classes)
- **Logging**: Use Python's `logging` module
- **Error handling**: Return appropriate exit codes
- **Docstrings**: Required for public functions/classes
## Exit Codes
Krkn uses specific exit codes to communicate execution status:
- `0`: Success - all scenarios passed, no critical alerts
- `1`: Scenario failure - one or more scenarios failed
- `2`: Critical alerts fired during execution
- `3+`: Health check failure (Cerberus monitoring detected issues)
**When implementing scenarios:**
- Return `0` on success
- Return `1` on scenario-specific failures
- Propagate health check failures appropriately
- Log exit code reasons clearly
## Container Support
Krkn can run inside a container. See `containers/` directory.
**Building custom image:**
```bash
cd containers
./compile_dockerfile.sh # Generates Dockerfile from template
docker build -t krkn:latest .
```
**Running containerized:**
```bash
docker run -v ~/.kube:/root/.kube:Z \
-v $(pwd)/config:/config:Z \
-v $(pwd)/scenarios:/scenarios:Z \
krkn:latest
```
## Git Workflow
- **NEVER commit directly to main**
- **NEVER use `--force` without approval**
- **ALWAYS create feature branches**: `git checkout -b feature/description`
- **ALWAYS run tests before pushing**
**Conventional commits**: `feat:`, `fix:`, `test:`, `docs:`, `refactor:`
```bash
git checkout main && git pull origin main
git checkout -b feature/your-feature-name
# Make changes, write tests
python -m unittest discover -s tests -v
git add <specific-files>
git commit -m "feat: description"
git push -u origin feature/your-feature-name
```
## Environment Variables
- `KUBECONFIG`: Path to kubeconfig
- `AWS_*`, `AZURE_*`, `GOOGLE_APPLICATION_CREDENTIALS`: Cloud credentials
- `PROMETHEUS_URL`, `ELASTIC_URL`, `ELASTIC_PASSWORD`: Monitoring config
**NEVER commit credentials or API keys.**
## Common Pitfalls
1. Missing virtual environment - always activate venv
2. Running functional tests without cluster setup
3. Ignoring exit codes
4. Modifying krkn-lib directly (it's a separate package)
5. Upgrading docker/requests beyond version constraints
## Before Writing Code
1. Check for existing implementations
2. Review existing plugins as examples
3. Maintain consistency with cloud provider patterns
4. Plan rollback logic
5. Write tests alongside code
6. Update documentation
## When Adding Dependencies
1. Check if functionality exists in krkn-lib or current dependencies
2. Verify compatibility with existing versions
3. Pin specific versions in `requirements.txt`
4. Check for security vulnerabilities
5. Test thoroughly for conflicts
## Common Development Tasks
### Modifying Existing Plugin
1. Read plugin code and corresponding test
2. Make changes
3. Update/add unit tests
4. Run: `python -m unittest tests.test_<plugin>_scenario_plugin`
### Writing Unit Tests
1. Create: `tests/test_<module>_scenario_plugin.py`
2. Import `unittest` and plugin class
3. Mock external dependencies
4. Test success, failure, and edge cases
5. Run: `python -m unittest tests.test_<module>_scenario_plugin`

View File

@@ -26,7 +26,7 @@ Here is an excerpt:
## Maintainer Levels
### Contributor
Contributors contribute to the community. Anyone can become a contributor by participating in discussions, reporting bugs, or contributing code or documentation.
Contributors contributor to the community. Anyone can become a contributor by participating in discussions, reporting bugs, or contributing code or documentation.
#### Responsibilities:
@@ -80,4 +80,4 @@ Represent the project in the broader open-source community.
# Credits
Sections of this document have been borrowed from [Kubernetes governance](https://github.com/kubernetes/community/blob/master/governance.md)
Sections of this documents have been borrowed from [Kubernetes governance](https://github.com/kubernetes/community/blob/master/governance.md)

View File

@@ -16,5 +16,5 @@ Following are a list of enhancements that we are planning to work on adding supp
- [x] [Krknctl - client for running Krkn scenarios with ease](https://github.com/krkn-chaos/krknctl)
- [x] [AI Chat bot to help get started with Krkn and commands](https://github.com/krkn-chaos/krkn-lightspeed)
- [ ] [Ability to roll back cluster to original state if chaos fails](https://github.com/krkn-chaos/krkn/issues/804)
- [ ] Add recovery time metrics to each scenario for better regression analysis
- [ ] Add recovery time metrics to each scenario for each better regression analysis
- [ ] [Add resiliency scoring to chaos scenarios ran on cluster](https://github.com/krkn-chaos/krkn/issues/125)

View File

@@ -40,4 +40,4 @@ The security team currently consists of the [Maintainers of Krkn](https://github
## Process and Supported Releases
The Krkn security team will investigate and provide a fix in a timely manner depending on the severity. The fix will be included in the new release of Krkn and details will be included in the release notes.
The Krkn security team will investigate and provide a fix in a timely mannner depending on the severity. The fix will be included in the new release of Krkn and details will be included in the release notes.

View File

@@ -39,7 +39,7 @@ cerberus:
Sunday:
slack_team_alias: # The slack team alias to be tagged while reporting failures in the slack channel when no watcher is assigned
custom_checks: # Relative paths of files containing additional user defined checks
custom_checks: # Relative paths of files conataining additional user defined checks
tunings:
timeout: 3 # Number of seconds before requests fail

View File

@@ -56,7 +56,7 @@ kraken:
cerberus:
cerberus_enabled: False # Enable it when cerberus is previously installed
cerberus_url: # When cerberus_enabled is set to True, provide the url where cerberus publishes go/no-go signal
check_application_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
check_applicaton_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
performance_monitoring:
prometheus_url: '' # The prometheus url/route is automatically obtained in case of OpenShift, please set it when the distribution is Kubernetes.
@@ -93,7 +93,7 @@ telemetry:
prometheus_pod_name: "" # name of the prometheus pod (if distribution is kubernetes)
full_prometheus_backup: False # if is set to False only the /prometheus/wal folder will be downloaded.
backup_threads: 5 # number of telemetry download/upload threads
archive_path: /tmp # local path where the archive files will be temporarily stored
archive_path: /tmp # local path where the archive files will be temporarly stored
max_retries: 0 # maximum number of upload retries (if 0 will retry forever)
run_tag: '' # if set, this will be appended to the run folder in the bucket (useful to group the runs)
archive_size: 500000

View File

@@ -13,7 +13,7 @@ kraken:
cerberus:
cerberus_enabled: False # Enable it when cerberus is previously installed
cerberus_url: # When cerberus_enabled is set to True, provide the url where cerberus publishes go/no-go signal
check_application_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
check_applicaton_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
performance_monitoring:
prometheus_url: # The prometheus url/route is automatically obtained in case of OpenShift, please set it when the distribution is Kubernetes.
@@ -32,7 +32,7 @@ tunings:
telemetry:
enabled: False # enable/disables the telemetry collection feature
archive_path: /tmp # local path where the archive files will be temporarily stored
archive_path: /tmp # local path where the archive files will be temporarly stored
events_backup: False # enables/disables cluster events collection
logs_backup: False

View File

@@ -14,7 +14,7 @@ kraken:
cerberus:
cerberus_enabled: False # Enable it when cerberus is previously installed
cerberus_url: # When cerberus_enabled is set to True, provide the url where cerberus publishes go/no-go signal
check_application_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
check_applicaton_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
performance_monitoring:
prometheus_url: # The prometheus url/route is automatically obtained in case of OpenShift, please set it when the distribution is Kubernetes.

View File

@@ -35,7 +35,7 @@ kraken:
cerberus:
cerberus_enabled: True # Enable it when cerberus is previously installed
cerberus_url: http://0.0.0.0:8080 # When cerberus_enabled is set to True, provide the url where cerberus publishes go/no-go signal
check_application_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
check_applicaton_routes: False # When enabled will look for application unavailability using the routes specified in the cerberus config and fails the run
performance_monitoring:
deploy_dashboards: True # Install a mutable grafana and load the performance dashboards. Enable this only when running on OpenShift
@@ -61,7 +61,7 @@ telemetry:
prometheus_backup: True # enables/disables prometheus data collection
full_prometheus_backup: False # if is set to False only the /prometheus/wal folder will be downloaded.
backup_threads: 5 # number of telemetry download/upload threads
archive_path: /tmp # local path where the archive files will be temporarily stored
archive_path: /tmp # local path where the archive files will be temporarly stored
max_retries: 0 # maximum number of upload retries (if 0 will retry forever)
run_tag: '' # if set, this will be appended to the run folder in the bucket (useful to group the runs)
archive_size: 500000 # the size of the prometheus data archive size in KB. The lower the size of archive is

View File

@@ -41,7 +41,7 @@ ENV KUBECONFIG /home/krkn/.kube/config
# This overwrites any existing configuration in /etc/yum.repos.d/kubernetes.repo
RUN dnf update && dnf install -y --setopt=install_weak_deps=False \
git python3.11 jq yq gettext wget which ipmitool openssh-server &&\
git python39 jq yq gettext wget which ipmitool openssh-server &&\
dnf clean all
# copy oc client binary from oc-build image
@@ -63,15 +63,15 @@ RUN if [ -n "$PR_NUMBER" ]; then git fetch origin pull/${PR_NUMBER}/head:pr-${PR
# if it is a TAG trigger checkout the tag
RUN if [ -n "$TAG" ]; then git checkout "$TAG";fi
RUN python3.11 -m ensurepip --upgrade --default-pip
RUN python3.11 -m pip install --upgrade pip setuptools==78.1.1
RUN python3.9 -m ensurepip --upgrade --default-pip
RUN python3.9 -m pip install --upgrade pip setuptools==78.1.1
# removes the the vulnerable versions of setuptools and pip
RUN rm -rf "$(pip cache dir)"
RUN rm -rf /tmp/*
RUN rm -rf /usr/local/lib/python3.11/ensurepip/_bundled
RUN pip3.11 install -r requirements.txt
RUN pip3.11 install jsonschema
RUN rm -rf /usr/local/lib/python3.9/ensurepip/_bundled
RUN pip3.9 install -r requirements.txt
RUN pip3.9 install jsonschema
LABEL krknctl.title.global="Krkn Base Image"
LABEL krknctl.description.global="This is the krkn base image."

View File

@@ -14,7 +14,7 @@ def get_status(config, start_time, end_time):
if config["cerberus"]["cerberus_enabled"]:
cerberus_url = config["cerberus"]["cerberus_url"]
check_application_routes = \
config["cerberus"]["check_application_routes"]
config["cerberus"]["check_applicaton_routes"]
if not cerberus_url:
logging.error(
"url where Cerberus publishes True/False signal "

View File

@@ -214,7 +214,7 @@ def metrics(
end_time=datetime.datetime.fromtimestamp(end_time), granularity=30
)
else:
logging.info("didn't match keys")
logging.info('didnt match keys')
continue
for returned_metric in metrics_result:

View File

@@ -146,7 +146,7 @@ class AbstractScenarioPlugin(ABC):
if scenario_telemetry.exit_status != 0:
failed_scenarios.append(scenario_config)
scenario_telemetries.append(scenario_telemetry)
logging.info(f"waiting {wait_duration} before running the next scenario")
logging.info(f"wating {wait_duration} before running the next scenario")
time.sleep(wait_duration)
return failed_scenarios, scenario_telemetries

View File

@@ -53,7 +53,7 @@ class HogsScenarioPlugin(AbstractScenarioPlugin):
raise Exception("no available nodes to schedule workload")
if not has_selector:
available_nodes = [available_nodes[random.randint(0, len(available_nodes) - 1)]]
available_nodes = [available_nodes[random.randint(0, len(available_nodes))]]
if scenario_config.number_of_nodes and len(available_nodes) > scenario_config.number_of_nodes:
available_nodes = random.sample(available_nodes, scenario_config.number_of_nodes)

View File

@@ -27,7 +27,7 @@ def get_status(config, start_time, end_time):
application_routes_status = True
if config["cerberus"]["cerberus_enabled"]:
cerberus_url = config["cerberus"]["cerberus_url"]
check_application_routes = config["cerberus"]["check_application_routes"]
check_application_routes = config["cerberus"]["check_applicaton_routes"]
if not cerberus_url:
logging.error("url where Cerberus publishes True/False signal is not provided.")
sys.exit(1)

View File

@@ -27,7 +27,7 @@ def get_status(config, start_time, end_time):
application_routes_status = True
if config["cerberus"]["cerberus_enabled"]:
cerberus_url = config["cerberus"]["cerberus_url"]
check_application_routes = config["cerberus"]["check_application_routes"]
check_application_routes = config["cerberus"]["check_applicaton_routes"]
if not cerberus_url:
logging.error(
"url where Cerberus publishes True/False signal is not provided.")

View File

@@ -36,7 +36,7 @@ def get_test_pods(
- pods matching the label on which network policy
need to be applied
namespace (string)
namepsace (string)
- namespace in which the pod is present
kubecli (KrknKubernetes)

View File

@@ -76,7 +76,7 @@ class abstract_node_scenarios:
nodeaction.wait_for_unknown_status(node, timeout, self.kubecli, affected_node)
logging.info("The kubelet of the node %s has been stopped" % (node))
logging.info("stop_kubelet_scenario has been successfully injected!")
logging.info("stop_kubelet_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to stop the kubelet of the node. Encountered following "
@@ -108,7 +108,7 @@ class abstract_node_scenarios:
)
nodeaction.wait_for_ready_status(node, timeout, self.kubecli,affected_node)
logging.info("The kubelet of the node %s has been restarted" % (node))
logging.info("restart_kubelet_scenario has been successfully injected!")
logging.info("restart_kubelet_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to restart the kubelet of the node. Encountered following "
@@ -128,7 +128,7 @@ class abstract_node_scenarios:
"oc debug node/" + node + " -- chroot /host "
"dd if=/dev/urandom of=/proc/sysrq-trigger"
)
logging.info("node_crash_scenario has been successfully injected!")
logging.info("node_crash_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to crash the node. Encountered following exception: %s. "

View File

@@ -379,7 +379,7 @@ class aws_node_scenarios(abstract_node_scenarios):
logging.info(
"Node with instance ID: %s has been terminated" % (instance_id)
)
logging.info("node_termination_scenario has been successfully injected!")
logging.info("node_termination_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to terminate node instance. Encountered following exception:"
@@ -408,7 +408,7 @@ class aws_node_scenarios(abstract_node_scenarios):
logging.info(
"Node with instance ID: %s has been rebooted" % (instance_id)
)
logging.info("node_reboot_scenario has been successfully injected!")
logging.info("node_reboot_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to reboot node instance. Encountered following exception:"

View File

@@ -18,6 +18,8 @@ class Azure:
logging.info("azure " + str(self))
# Acquire a credential object using CLI-based authentication.
credentials = DefaultAzureCredential()
# az_account = runcommand.invoke("az account list -o yaml")
# az_account_yaml = yaml.safe_load(az_account, Loader=yaml.FullLoader)
logger = logging.getLogger("azure")
logger.setLevel(logging.WARNING)
subscription_id = os.getenv("AZURE_SUBSCRIPTION_ID")

View File

@@ -229,7 +229,7 @@ class bm_node_scenarios(abstract_node_scenarios):
nodeaction.wait_for_unknown_status(node, timeout, self.kubecli, affected_node)
nodeaction.wait_for_ready_status(node, timeout, self.kubecli, affected_node)
logging.info("Node with bmc address: %s has been rebooted" % (bmc_addr))
logging.info("node_reboot_scenario has been successfully injected!")
logging.info("node_reboot_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to reboot node instance. Encountered following exception:"

View File

@@ -11,7 +11,7 @@ def get_node_by_name(node_name_list, kubecli: KrknKubernetes):
for node_name in node_name_list:
if node_name not in killable_nodes:
logging.info(
f"Node with provided {node_name} does not exist or the node might "
f"Node with provided ${node_name} does not exist or the node might "
"be in NotReady state."
)
return

View File

@@ -237,7 +237,7 @@ class docker_node_scenarios(abstract_node_scenarios):
logging.info(
"Node with container ID: %s has been terminated" % (container_id)
)
logging.info("node_termination_scenario has been successfully injected!")
logging.info("node_termination_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to terminate node instance. Encountered following exception:"
@@ -264,7 +264,7 @@ class docker_node_scenarios(abstract_node_scenarios):
logging.info(
"Node with container ID: %s has been rebooted" % (container_id)
)
logging.info("node_reboot_scenario has been successfully injected!")
logging.info("node_reboot_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to reboot node instance. Encountered following exception:"

View File

@@ -309,7 +309,7 @@ class gcp_node_scenarios(abstract_node_scenarios):
logging.info(
"Node with instance ID: %s has been terminated" % instance_id
)
logging.info("node_termination_scenario has been successfully injected!")
logging.info("node_termination_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to terminate node instance. Encountered following exception:"
@@ -341,7 +341,7 @@ class gcp_node_scenarios(abstract_node_scenarios):
logging.info(
"Node with instance ID: %s has been rebooted" % instance_id
)
logging.info("node_reboot_scenario has been successfully injected!")
logging.info("node_reboot_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to reboot node instance. Encountered following exception:"

View File

@@ -184,7 +184,7 @@ class openstack_node_scenarios(abstract_node_scenarios):
nodeaction.wait_for_unknown_status(node, timeout, self.kubecli, affected_node)
nodeaction.wait_for_ready_status(node, timeout, self.kubecli, affected_node)
logging.info("Node with instance name: %s has been rebooted" % (node))
logging.info("node_reboot_scenario has been successfully injected!")
logging.info("node_reboot_scenario has been successfuly injected!")
except Exception as e:
logging.error(
"Failed to reboot node instance. Encountered following exception:"
@@ -249,7 +249,7 @@ class openstack_node_scenarios(abstract_node_scenarios):
node_ip.strip(), service, ssh_private_key, timeout
)
logging.info("Service status checked on %s" % (node_ip))
logging.info("Check service status is successfully injected!")
logging.info("Check service status is successfuly injected!")
except Exception as e:
logging.error(
"Failed to check service status. Encountered following exception:"

View File

@@ -43,7 +43,7 @@ class TimeActionsScenarioPlugin(AbstractScenarioPlugin):
cerberus.publish_kraken_status(
krkn_config, not_reset, start_time, end_time
)
except (RuntimeError, Exception) as e:
except (RuntimeError, Exception):
logging.error(
f"TimeActionsScenarioPlugin scenario {scenario} failed with exception: {e}"
)

View File

@@ -140,7 +140,7 @@ class ZoneOutageScenarioPlugin(AbstractScenarioPlugin):
network_association_ids[0], acl_id
)
# capture the original_acl_id, created_acl_id and
# capture the orginal_acl_id, created_acl_id and
# new association_id to use during the recovery
ids[new_association_id] = original_acl_id
@@ -156,7 +156,7 @@ class ZoneOutageScenarioPlugin(AbstractScenarioPlugin):
new_association_id, original_acl_id
)
logging.info(
"Waiting for 60 seconds to make sure " "the changes are in place"
"Wating for 60 seconds to make sure " "the changes are in place"
)
time.sleep(60)

View File

@@ -1,17 +1,10 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin
from krkn.scenario_plugins.scenario_plugin_factory import ScenarioPluginFactory
from krkn.scenario_plugins.native.plugins import PluginStep, Plugins, PLUGINS
from krkn.tests.test_classes.correct_scenario_plugin import (
CorrectScenarioPlugin,
)
import yaml
class TestPluginFactory(unittest.TestCase):
@@ -115,437 +108,3 @@ class TestPluginFactory(unittest.TestCase):
self.assertEqual(
message, "scenario plugin folder cannot contain `scenario` or `plugin` word"
)
class TestPluginStep(unittest.TestCase):
"""Test cases for PluginStep class"""
def setUp(self):
"""Set up test fixtures"""
# Create a mock schema
self.mock_schema = Mock()
self.mock_schema.id = "test_step"
# Create mock output
mock_output = Mock()
mock_output.serialize = Mock(return_value={"status": "success", "message": "test"})
self.mock_schema.outputs = {
"success": mock_output,
"error": mock_output
}
self.plugin_step = PluginStep(
schema=self.mock_schema,
error_output_ids=["error"]
)
def test_render_output(self):
"""Test render_output method"""
output_id = "success"
output_data = {"status": "success", "message": "test output"}
result = self.plugin_step.render_output(output_id, output_data)
# Verify it returns a JSON string
self.assertIsInstance(result, str)
# Verify it can be parsed as JSON
parsed = json.loads(result)
self.assertEqual(parsed["output_id"], output_id)
self.assertIn("output_data", parsed)
class TestPlugins(unittest.TestCase):
"""Test cases for Plugins class"""
def setUp(self):
"""Set up test fixtures"""
# Create mock steps with proper id attribute
self.mock_step1 = Mock()
self.mock_step1.id = "step1"
self.mock_step2 = Mock()
self.mock_step2.id = "step2"
self.plugin_step1 = PluginStep(schema=self.mock_step1, error_output_ids=["error"])
self.plugin_step2 = PluginStep(schema=self.mock_step2, error_output_ids=["error"])
def test_init_with_valid_steps(self):
"""Test Plugins initialization with valid steps"""
plugins = Plugins([self.plugin_step1, self.plugin_step2])
self.assertEqual(len(plugins.steps_by_id), 2)
self.assertIn("step1", plugins.steps_by_id)
self.assertIn("step2", plugins.steps_by_id)
def test_init_with_duplicate_step_ids(self):
"""Test Plugins initialization with duplicate step IDs raises exception"""
# Create two steps with the same ID
duplicate_step = PluginStep(schema=self.mock_step1, error_output_ids=["error"])
with self.assertRaises(Exception) as context:
Plugins([self.plugin_step1, duplicate_step])
self.assertIn("Duplicate step ID", str(context.exception))
def test_unserialize_scenario(self):
"""Test unserialize_scenario method"""
# Create a temporary YAML file
test_data = [
{"id": "test_step", "config": {"param": "value"}}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([self.plugin_step1])
result = plugins.unserialize_scenario(temp_file)
self.assertIsInstance(result, list)
finally:
Path(temp_file).unlink()
def test_run_with_invalid_scenario_not_list(self):
"""Test run method with scenario that is not a list"""
# Create a temporary YAML file with dict instead of list
test_data = {"id": "test_step", "config": {"param": "value"}}
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([self.plugin_step1])
with self.assertRaises(Exception) as context:
plugins.run(temp_file, "/path/to/kubeconfig", "/path/to/kraken_config", "test-uuid")
self.assertIn("expected list", str(context.exception))
finally:
Path(temp_file).unlink()
def test_run_with_invalid_entry_not_dict(self):
"""Test run method with entry that is not a dict"""
# Create a temporary YAML file with list of strings instead of dicts
test_data = ["invalid", "entries"]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([self.plugin_step1])
with self.assertRaises(Exception) as context:
plugins.run(temp_file, "/path/to/kubeconfig", "/path/to/kraken_config", "test-uuid")
self.assertIn("expected a list of dict's", str(context.exception))
finally:
Path(temp_file).unlink()
def test_run_with_missing_id_field(self):
"""Test run method with missing 'id' field"""
# Create a temporary YAML file with missing id
test_data = [
{"config": {"param": "value"}}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([self.plugin_step1])
with self.assertRaises(Exception) as context:
plugins.run(temp_file, "/path/to/kubeconfig", "/path/to/kraken_config", "test-uuid")
self.assertIn("missing 'id' field", str(context.exception))
finally:
Path(temp_file).unlink()
def test_run_with_missing_config_field(self):
"""Test run method with missing 'config' field"""
# Create a temporary YAML file with missing config
test_data = [
{"id": "step1"}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([self.plugin_step1])
with self.assertRaises(Exception) as context:
plugins.run(temp_file, "/path/to/kubeconfig", "/path/to/kraken_config", "test-uuid")
self.assertIn("missing 'config' field", str(context.exception))
finally:
Path(temp_file).unlink()
def test_run_with_invalid_step_id(self):
"""Test run method with invalid step ID"""
# Create a proper mock schema with string ID
mock_schema = Mock()
mock_schema.id = "valid_step"
plugin_step = PluginStep(schema=mock_schema, error_output_ids=["error"])
# Create a temporary YAML file with unknown step ID
test_data = [
{"id": "unknown_step", "config": {"param": "value"}}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([plugin_step])
with self.assertRaises(Exception) as context:
plugins.run(temp_file, "/path/to/kubeconfig", "/path/to/kraken_config", "test-uuid")
self.assertIn("Invalid step", str(context.exception))
self.assertIn("expected one of", str(context.exception))
finally:
Path(temp_file).unlink()
@patch('krkn.scenario_plugins.native.plugins.logging')
def test_run_with_valid_scenario(self, mock_logging):
"""Test run method with valid scenario"""
# Create mock schema with all necessary attributes
mock_schema = Mock()
mock_schema.id = "test_step"
# Mock input schema
mock_input = Mock()
mock_input.properties = {}
mock_input.unserialize = Mock(return_value=Mock(spec=[]))
mock_schema.input = mock_input
# Mock output
mock_output = Mock()
mock_output.serialize = Mock(return_value={"status": "success"})
mock_schema.outputs = {"success": mock_output}
# Mock schema call
mock_schema.return_value = ("success", {"status": "success"})
plugin_step = PluginStep(schema=mock_schema, error_output_ids=["error"])
# Create a temporary YAML file
test_data = [
{"id": "test_step", "config": {"param": "value"}}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([plugin_step])
plugins.run(temp_file, "/path/to/kubeconfig", "/path/to/kraken_config", "test-uuid")
# Verify schema was called
mock_schema.assert_called_once()
finally:
Path(temp_file).unlink()
@patch('krkn.scenario_plugins.native.plugins.logging')
def test_run_with_error_output(self, mock_logging):
"""Test run method when step returns error output"""
# Create mock schema with error output
mock_schema = Mock()
mock_schema.id = "test_step"
# Mock input schema
mock_input = Mock()
mock_input.properties = {}
mock_input.unserialize = Mock(return_value=Mock(spec=[]))
mock_schema.input = mock_input
# Mock output
mock_output = Mock()
mock_output.serialize = Mock(return_value={"error": "test error"})
mock_schema.outputs = {"error": mock_output}
# Mock schema call to return error
mock_schema.return_value = ("error", {"error": "test error"})
plugin_step = PluginStep(schema=mock_schema, error_output_ids=["error"])
# Create a temporary YAML file
test_data = [
{"id": "test_step", "config": {"param": "value"}}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([plugin_step])
with self.assertRaises(Exception) as context:
plugins.run(temp_file, "/path/to/kubeconfig", "/path/to/kraken_config", "test-uuid")
self.assertIn("failed", str(context.exception))
finally:
Path(temp_file).unlink()
@patch('krkn.scenario_plugins.native.plugins.logging')
def test_run_with_kubeconfig_path_injection(self, mock_logging):
"""Test run method injects kubeconfig_path when property exists"""
# Create mock schema with kubeconfig_path in input properties
mock_schema = Mock()
mock_schema.id = "test_step"
# Mock input schema with kubeconfig_path property
mock_input_instance = Mock()
mock_input = Mock()
mock_input.properties = {"kubeconfig_path": Mock()}
mock_input.unserialize = Mock(return_value=mock_input_instance)
mock_schema.input = mock_input
# Mock output
mock_output = Mock()
mock_output.serialize = Mock(return_value={"status": "success"})
mock_schema.outputs = {"success": mock_output}
# Mock schema call
mock_schema.return_value = ("success", {"status": "success"})
plugin_step = PluginStep(schema=mock_schema, error_output_ids=["error"])
# Create a temporary YAML file
test_data = [
{"id": "test_step", "config": {"param": "value"}}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([plugin_step])
plugins.run(temp_file, "/custom/kubeconfig", "/path/to/kraken_config", "test-uuid")
# Verify kubeconfig_path was set
self.assertEqual(mock_input_instance.kubeconfig_path, "/custom/kubeconfig")
finally:
Path(temp_file).unlink()
@patch('krkn.scenario_plugins.native.plugins.logging')
def test_run_with_kraken_config_injection(self, mock_logging):
"""Test run method injects kraken_config when property exists"""
# Create mock schema with kraken_config in input properties
mock_schema = Mock()
mock_schema.id = "test_step"
# Mock input schema with kraken_config property
mock_input_instance = Mock()
mock_input = Mock()
mock_input.properties = {"kraken_config": Mock()}
mock_input.unserialize = Mock(return_value=mock_input_instance)
mock_schema.input = mock_input
# Mock output
mock_output = Mock()
mock_output.serialize = Mock(return_value={"status": "success"})
mock_schema.outputs = {"success": mock_output}
# Mock schema call
mock_schema.return_value = ("success", {"status": "success"})
plugin_step = PluginStep(schema=mock_schema, error_output_ids=["error"])
# Create a temporary YAML file
test_data = [
{"id": "test_step", "config": {"param": "value"}}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(test_data, f)
temp_file = f.name
try:
plugins = Plugins([plugin_step])
plugins.run(temp_file, "/path/to/kubeconfig", "/custom/kraken.yaml", "test-uuid")
# Verify kraken_config was set
self.assertEqual(mock_input_instance.kraken_config, "/custom/kraken.yaml")
finally:
Path(temp_file).unlink()
def test_json_schema(self):
"""Test json_schema method"""
# Create mock schema with jsonschema support
mock_schema = Mock()
mock_schema.id = "test_step"
plugin_step = PluginStep(schema=mock_schema, error_output_ids=["error"])
with patch('krkn.scenario_plugins.native.plugins.jsonschema') as mock_jsonschema:
# Mock the step_input function
mock_jsonschema.step_input.return_value = {
"$id": "http://example.com",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Test Schema",
"description": "Test description",
"type": "object",
"properties": {"param": {"type": "string"}}
}
plugins = Plugins([plugin_step])
result = plugins.json_schema()
# Verify it returns a JSON string
self.assertIsInstance(result, str)
# Parse and verify structure
parsed = json.loads(result)
self.assertEqual(parsed["$id"], "https://github.com/redhat-chaos/krkn/")
self.assertEqual(parsed["type"], "array")
self.assertEqual(parsed["minContains"], 1)
self.assertIn("items", parsed)
self.assertIn("oneOf", parsed["items"])
# Verify step is included
self.assertEqual(len(parsed["items"]["oneOf"]), 1)
step_schema = parsed["items"]["oneOf"][0]
self.assertEqual(step_schema["properties"]["id"]["const"], "test_step")
class TestPLUGINSConstant(unittest.TestCase):
"""Test cases for the PLUGINS constant"""
def test_plugins_initialized(self):
"""Test that PLUGINS constant is properly initialized"""
self.assertIsInstance(PLUGINS, Plugins)
# Verify all expected steps are registered
expected_steps = [
"run_python",
"network_chaos",
"pod_network_outage",
"pod_egress_shaping",
"pod_ingress_shaping"
]
for step_id in expected_steps:
self.assertIn(step_id, PLUGINS.steps_by_id)
# Ensure the registered id matches the decorator and no legacy alias is present
self.assertEqual(
PLUGINS.steps_by_id["pod_network_outage"].schema.id,
"pod_network_outage",
)
self.assertNotIn("pod_outage", PLUGINS.steps_by_id)
def test_plugins_step_count(self):
"""Test that PLUGINS has the expected number of steps"""
self.assertEqual(len(PLUGINS.steps_by_id), 5)

View File

@@ -49,11 +49,7 @@ class VirtChecker:
for vmi in self.kube_vm_plugin.vmis_list:
node_name = vmi.get("status",{}).get("nodeName")
vmi_name = vmi.get("metadata",{}).get("name")
interfaces = vmi.get("status",{}).get("interfaces",[])
if not interfaces:
logging.warning(f"VMI {vmi_name} has no network interfaces, skipping")
continue
ip_address = interfaces[0].get("ipAddress")
ip_address = vmi.get("status",{}).get("interfaces",[])[0].get("ipAddress")
namespace = vmi.get("metadata",{}).get("namespace")
# If node_name_list exists, only add if node name is in list
@@ -78,8 +74,7 @@ class VirtChecker:
else:
logging.debug(f"Disconnected access for {ip_address} on {worker_name} is failed: {output}")
vmi = self.kube_vm_plugin.get_vmi(vmi_name,self.namespace)
interfaces = vmi.get("status",{}).get("interfaces",[])
new_ip_address = interfaces[0].get("ipAddress") if interfaces else None
new_ip_address = vmi.get("status",{}).get("interfaces",[])[0].get("ipAddress")
new_node_name = vmi.get("status",{}).get("nodeName")
# if vm gets deleted, it'll start up with a new ip address
if new_ip_address != ip_address:
@@ -107,7 +102,7 @@ class VirtChecker:
def get_vm_access(self, vm_name: str = '', namespace: str = ''):
"""
This method returns True when the VM is accessible and an error message when it is not, using virtctl protocol
This method returns True when the VM is access and an error message when it is not, using virtctl protocol
:param vm_name:
:param namespace:
:return: virtctl_status 'True' if successful, or an error message if it fails.

View File

@@ -1,23 +1,24 @@
aliyun-python-sdk-core==2.13.36
aliyun-python-sdk-ecs==4.24.25
arcaflow-plugin-sdk==0.14.0
boto3>=1.34.0 # Updated to support urllib3 2.x
boto3==1.28.61
azure-identity==1.16.1
azure-keyvault==4.2.0
azure-mgmt-compute==30.5.0
azure-mgmt-network==27.0.0
itsdangerous==2.0.1
coverage==7.6.12
datetime==5.4
docker>=6.0,<7.0 # docker 7.0+ has breaking changes; works with requests<2.32
docker>=6.0,<7.0 # docker 7.0+ has breaking changes with Unix sockets
gitpython==3.1.41
google-auth==2.37.0
google-cloud-compute==1.22.0
ibm_cloud_sdk_core>=3.20.0 # Requires urllib3>=2.1.0 (compatible with updated boto3)
ibm_vpc==0.26.3 # Requires ibm_cloud_sdk_core
ibm_cloud_sdk_core==3.18.0
ibm_vpc==0.20.0
jinja2==3.1.6
krkn-lib==5.1.13
lxml==5.1.0
kubernetes==34.1.0
krkn-lib==6.0.2
numpy==1.26.4
pandas==2.2.0
openshift-client==1.0.21
@@ -29,13 +30,12 @@ python-ipmi==0.5.4
python-openstackclient==6.5.0
requests<2.32 # requests 2.32+ breaks Unix socket support (http+docker scheme)
requests-unixsocket>=0.4.0 # Required for Docker Unix socket support
urllib3>=2.1.0,<2.4.0 # Compatible with all dependencies
service_identity==24.1.0
PyYAML==6.0.1
setuptools==78.1.1
wheel>=0.44.0
zope.interface==6.1
colorlog==6.10.1
werkzeug==3.1.4
wheel==0.42.0
zope.interface==5.4.0
git+https://github.com/vmware/vsphere-automation-sdk-python.git@v8.0.0.0
cryptography>=42.0.4 # not directly required, pinned by Snyk to avoid a vulnerability

View File

@@ -6,7 +6,6 @@ import sys
import yaml
import logging
import optparse
from colorlog import ColoredFormatter
import pyfiglet
import uuid
import time
@@ -647,23 +646,15 @@ if __name__ == "__main__":
# If no command or regular execution, continue with existing logic
report_file = options.output
tee_handler = TeeLogHandler()
fmt = "%(asctime)s [%(levelname)s] %(message)s"
plain = logging.Formatter(fmt)
colored = ColoredFormatter(
"%(asctime)s [%(log_color)s%(levelname)s%(reset)s] %(message)s",
log_colors={'DEBUG': 'white', 'INFO': 'white', 'WARNING': 'yellow', 'ERROR': 'red', 'CRITICAL': 'bold_red'},
reset=True, style='%'
)
file_handler = logging.FileHandler(report_file, mode="w")
file_handler.setFormatter(plain)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(colored)
tee_handler.setFormatter(plain)
handlers = [file_handler, stream_handler, tee_handler]
handlers = [
logging.FileHandler(report_file, mode="w"),
logging.StreamHandler(),
tee_handler,
]
logging.basicConfig(
level=logging.DEBUG if options.debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=handlers,
)
option_error = False

View File

@@ -0,0 +1,64 @@
import unittest
import logging
from arcaflow_plugin_sdk import plugin
from krkn.scenario_plugins.native.network import ingress_shaping
class NetworkScenariosTest(unittest.TestCase):
def test_serialization(self):
plugin.test_object_serialization(
ingress_shaping.NetworkScenarioConfig(
node_interface_name={"foo": ["bar"]},
network_params={
"latency": "50ms",
"loss": "0.02",
"bandwidth": "100mbit",
},
),
self.fail,
)
plugin.test_object_serialization(
ingress_shaping.NetworkScenarioSuccessOutput(
filter_direction="ingress",
test_interfaces={"foo": ["bar"]},
network_parameters={
"latency": "50ms",
"loss": "0.02",
"bandwidth": "100mbit",
},
execution_type="parallel",
),
self.fail,
)
plugin.test_object_serialization(
ingress_shaping.NetworkScenarioErrorOutput(
error="Hello World",
),
self.fail,
)
def test_network_chaos(self):
output_id, output_data = ingress_shaping.network_chaos(
params=ingress_shaping.NetworkScenarioConfig(
label_selector="node-role.kubernetes.io/control-plane",
instance_count=1,
network_params={
"latency": "50ms",
"loss": "0.02",
"bandwidth": "100mbit",
},
),
run_id="network-shaping-test",
)
if output_id == "error":
logging.error(output_data.error)
self.fail(
"The network chaos scenario did not complete successfully "
"because an error/exception occurred"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,215 @@
import unittest
import time
from unittest.mock import MagicMock, patch
import yaml
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.telemetry import ScenarioTelemetry
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn.scenario_plugins.kubevirt_vm_outage.kubevirt_vm_outage_scenario_plugin import KubevirtVmOutageScenarioPlugin
class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
def setUp(self):
"""
Set up test fixtures for KubevirtVmOutageScenarioPlugin
"""
self.plugin = KubevirtVmOutageScenarioPlugin()
# Create mock k8s client
self.k8s_client = MagicMock()
self.custom_object_client = MagicMock()
self.k8s_client.custom_object_client = self.custom_object_client
self.plugin.k8s_client = self.k8s_client
# Mock methods needed for KubeVirt operations
self.k8s_client.list_custom_resource_definition = MagicMock()
# Mock custom resource definition list with KubeVirt CRDs
crd_list = MagicMock()
crd_item = MagicMock()
crd_item.spec = MagicMock()
crd_item.spec.group = "kubevirt.io"
crd_list.items = [crd_item]
self.k8s_client.list_custom_resource_definition.return_value = crd_list
# Mock VMI data
self.mock_vmi = {
"metadata": {
"name": "test-vm",
"namespace": "default"
},
"status": {
"phase": "Running"
}
}
# Create test config
self.config = {
"scenarios": [
{
"name": "kubevirt outage test",
"scenario": "kubevirt_vm_outage",
"parameters": {
"vm_name": "test-vm",
"namespace": "default",
"duration": 0
}
}
]
}
# Create a temporary config file
import tempfile, os
temp_dir = tempfile.gettempdir()
self.scenario_file = os.path.join(temp_dir, "test_kubevirt_scenario.yaml")
with open(self.scenario_file, "w") as f:
yaml.dump(self.config, f)
# Mock dependencies
self.telemetry = MagicMock(spec=KrknTelemetryOpenshift)
self.scenario_telemetry = MagicMock(spec=ScenarioTelemetry)
self.telemetry.get_lib_kubernetes.return_value = self.k8s_client
def test_successful_injection_and_recovery(self):
"""
Test successful deletion and recovery of a VMI
"""
# Mock get_vmi to return our mock VMI
with patch.object(self.plugin, 'get_vmi', return_value=self.mock_vmi):
# Mock inject and recover to simulate success
with patch.object(self.plugin, 'inject', return_value=0) as mock_inject:
with patch.object(self.plugin, 'recover', return_value=0) as mock_recover:
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
self.assertEqual(result, 0)
mock_inject.assert_called_once_with("test-vm", "default", False)
mock_recover.assert_called_once_with("test-vm", "default", False)
def test_injection_failure(self):
"""
Test failure during VMI deletion
"""
# Mock get_vmi to return our mock VMI
with patch.object(self.plugin, 'get_vmi', return_value=self.mock_vmi):
# Mock inject to simulate failure
with patch.object(self.plugin, 'inject', return_value=1) as mock_inject:
with patch.object(self.plugin, 'recover', return_value=0) as mock_recover:
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
self.assertEqual(result, 1)
mock_inject.assert_called_once_with("test-vm", "default", False)
mock_recover.assert_not_called()
def test_disable_auto_restart(self):
"""
Test VM auto-restart can be disabled
"""
# Configure test with disable_auto_restart=True
self.config["scenarios"][0]["parameters"]["disable_auto_restart"] = True
# Mock VM object for patching
mock_vm = {
"metadata": {"name": "test-vm", "namespace": "default"},
"spec": {}
}
# Mock get_vmi to return our mock VMI
with patch.object(self.plugin, 'get_vmi', return_value=self.mock_vmi):
# Mock VM patch operation
with patch.object(self.plugin, 'patch_vm_spec') as mock_patch_vm:
mock_patch_vm.return_value = True
# Mock inject and recover
with patch.object(self.plugin, 'inject', return_value=0) as mock_inject:
with patch.object(self.plugin, 'recover', return_value=0) as mock_recover:
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
self.assertEqual(result, 0)
# Should call patch_vm_spec to disable auto-restart
mock_patch_vm.assert_any_call("test-vm", "default", False)
# Should call patch_vm_spec to re-enable auto-restart during recovery
mock_patch_vm.assert_any_call("test-vm", "default", True)
mock_inject.assert_called_once_with("test-vm", "default", True)
mock_recover.assert_called_once_with("test-vm", "default", True)
def test_recovery_when_vmi_does_not_exist(self):
"""
Test recovery logic when VMI does not exist after deletion
"""
# Store the original VMI in the plugin for recovery
self.plugin.original_vmi = self.mock_vmi.copy()
# Create a cleaned vmi_dict as the plugin would
vmi_dict = self.mock_vmi.copy()
# Set up running VMI data for after recovery
running_vmi = {
"metadata": {"name": "test-vm", "namespace": "default"},
"status": {"phase": "Running"}
}
# Set up time.time to immediately exceed the timeout for auto-recovery
with patch('time.time', side_effect=[0, 301, 301, 301, 301, 310, 320]):
# Mock get_vmi to always return None (not auto-recovered)
with patch.object(self.plugin, 'get_vmi', side_effect=[None, None, running_vmi]):
# Mock the custom object API to return success
self.custom_object_client.create_namespaced_custom_object = MagicMock(return_value=running_vmi)
# Run recovery with mocked time.sleep
with patch('time.sleep'):
result = self.plugin.recover("test-vm", "default", False)
self.assertEqual(result, 0)
# Verify create was called with the right arguments for our API version and kind
self.custom_object_client.create_namespaced_custom_object.assert_called_once_with(
group="kubevirt.io",
version="v1",
namespace="default",
plural="virtualmachineinstances",
body=vmi_dict
)
def test_validation_failure(self):
"""
Test validation failure when KubeVirt is not installed
"""
# Mock empty CRD list (no KubeVirt CRDs)
empty_crd_list = MagicMock()
empty_crd_list.items = []
self.k8s_client.list_custom_resource_definition.return_value = empty_crd_list
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
self.assertEqual(result, 1)
def test_delete_vmi_timeout(self):
"""
Test timeout during VMI deletion
"""
# Mock successful delete operation
self.custom_object_client.delete_namespaced_custom_object = MagicMock(return_value={})
# Mock that get_vmi always returns VMI (never gets deleted)
with patch.object(self.plugin, 'get_vmi', return_value=self.mock_vmi):
# Simulate timeout by making time.time return values that exceed the timeout
with patch('time.sleep'), patch('time.time', side_effect=[0, 10, 20, 130, 130, 130, 130, 140]):
result = self.plugin.inject("test-vm", "default", False)
self.assertEqual(result, 1)
self.custom_object_client.delete_namespaced_custom_object.assert_called_once_with(
group="kubevirt.io",
version="v1",
namespace="default",
plural="virtualmachineinstances",
name="test-vm"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,415 +0,0 @@
"""
Test suite for AbstractNode Scenarios
Usage:
python -m coverage run -a -m unittest tests/test_abstract_node_scenarios.py
Assisted By: Claude Code
"""
import unittest
from unittest.mock import Mock, patch
from krkn.scenario_plugins.node_actions.abstract_node_scenarios import abstract_node_scenarios
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.k8s import AffectedNode, AffectedNodeStatus
class TestAbstractNodeScenarios(unittest.TestCase):
"""Test suite for abstract_node_scenarios class"""
def setUp(self):
"""Set up test fixtures before each test method"""
self.mock_kubecli = Mock(spec=KrknKubernetes)
self.mock_affected_nodes_status = Mock(spec=AffectedNodeStatus)
self.mock_affected_nodes_status.affected_nodes = []
self.node_action_kube_check = True
self.scenarios = abstract_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=self.node_action_kube_check,
affected_nodes_status=self.mock_affected_nodes_status
)
def test_init(self):
"""Test initialization of abstract_node_scenarios"""
self.assertEqual(self.scenarios.kubecli, self.mock_kubecli)
self.assertEqual(self.scenarios.affected_nodes_status, self.mock_affected_nodes_status)
self.assertTrue(self.scenarios.node_action_kube_check)
@patch('time.sleep')
@patch('logging.info')
def test_node_stop_start_scenario(self, mock_logging, mock_sleep):
"""Test node_stop_start_scenario calls stop and start in sequence"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
duration = 60
poll_interval = 10
self.scenarios.node_stop_scenario = Mock()
self.scenarios.node_start_scenario = Mock()
# Act
self.scenarios.node_stop_start_scenario(
instance_kill_count, node, timeout, duration, poll_interval
)
# Assert
self.scenarios.node_stop_scenario.assert_called_once_with(
instance_kill_count, node, timeout, poll_interval
)
mock_sleep.assert_called_once_with(duration)
self.scenarios.node_start_scenario.assert_called_once_with(
instance_kill_count, node, timeout, poll_interval
)
self.mock_affected_nodes_status.merge_affected_nodes.assert_called_once()
@patch('logging.info')
def test_helper_node_stop_start_scenario(self, mock_logging):
"""Test helper_node_stop_start_scenario calls helper stop and start"""
# Arrange
instance_kill_count = 1
node = "helper-node"
timeout = 300
self.scenarios.helper_node_stop_scenario = Mock()
self.scenarios.helper_node_start_scenario = Mock()
# Act
self.scenarios.helper_node_stop_start_scenario(instance_kill_count, node, timeout)
# Assert
self.scenarios.helper_node_stop_scenario.assert_called_once_with(
instance_kill_count, node, timeout
)
self.scenarios.helper_node_start_scenario.assert_called_once_with(
instance_kill_count, node, timeout
)
@patch('time.sleep')
@patch('logging.info')
def test_node_disk_detach_attach_scenario_success(self, mock_logging, mock_sleep):
"""Test disk detach/attach scenario with valid disk attachment"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
duration = 60
disk_details = {"disk_id": "disk-123", "device": "/dev/sdb"}
self.scenarios.get_disk_attachment_info = Mock(return_value=disk_details)
self.scenarios.disk_detach_scenario = Mock()
self.scenarios.disk_attach_scenario = Mock()
# Act
self.scenarios.node_disk_detach_attach_scenario(
instance_kill_count, node, timeout, duration
)
# Assert
self.scenarios.get_disk_attachment_info.assert_called_once_with(
instance_kill_count, node
)
self.scenarios.disk_detach_scenario.assert_called_once_with(
instance_kill_count, node, timeout
)
mock_sleep.assert_called_once_with(duration)
self.scenarios.disk_attach_scenario.assert_called_once_with(
instance_kill_count, disk_details, timeout
)
@patch('logging.error')
@patch('logging.info')
def test_node_disk_detach_attach_scenario_no_disk(self, mock_info, mock_error):
"""Test disk detach/attach scenario when only root disk exists"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
duration = 60
self.scenarios.get_disk_attachment_info = Mock(return_value=None)
self.scenarios.disk_detach_scenario = Mock()
self.scenarios.disk_attach_scenario = Mock()
# Act
self.scenarios.node_disk_detach_attach_scenario(
instance_kill_count, node, timeout, duration
)
# Assert
self.scenarios.disk_detach_scenario.assert_not_called()
self.scenarios.disk_attach_scenario.assert_not_called()
mock_error.assert_any_call("Node %s has only root disk attached" % node)
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.nodeaction.wait_for_unknown_status')
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.runcommand.run')
@patch('logging.info')
def test_stop_kubelet_scenario_success(self, mock_logging, mock_run, mock_wait):
"""Test successful kubelet stop scenario"""
# Arrange
instance_kill_count = 2
node = "test-node"
timeout = 300
mock_affected_node = Mock(spec=AffectedNode)
mock_wait.return_value = None
# Act
with patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.AffectedNode') as mock_affected_node_class:
mock_affected_node_class.return_value = mock_affected_node
self.scenarios.stop_kubelet_scenario(instance_kill_count, node, timeout)
# Assert
self.assertEqual(mock_run.call_count, 2)
expected_command = "oc debug node/" + node + " -- chroot /host systemctl stop kubelet"
mock_run.assert_called_with(expected_command)
self.assertEqual(mock_wait.call_count, 2)
self.assertEqual(len(self.mock_affected_nodes_status.affected_nodes), 2)
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.nodeaction.wait_for_unknown_status')
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.runcommand.run')
@patch('logging.error')
@patch('logging.info')
def test_stop_kubelet_scenario_failure(self, mock_info, mock_error, mock_run, mock_wait):
"""Test kubelet stop scenario when command fails"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
error_msg = "Command failed"
mock_run.side_effect = Exception(error_msg)
# Act & Assert
with self.assertRaises(Exception):
with patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.AffectedNode'):
self.scenarios.stop_kubelet_scenario(instance_kill_count, node, timeout)
mock_error.assert_any_call(
"Failed to stop the kubelet of the node. Encountered following "
"exception: %s. Test Failed" % error_msg
)
@patch('logging.info')
def test_stop_start_kubelet_scenario(self, mock_logging):
"""Test stop/start kubelet scenario"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
self.scenarios.stop_kubelet_scenario = Mock()
self.scenarios.node_reboot_scenario = Mock()
# Act
self.scenarios.stop_start_kubelet_scenario(instance_kill_count, node, timeout)
# Assert
self.scenarios.stop_kubelet_scenario.assert_called_once_with(
instance_kill_count, node, timeout
)
self.scenarios.node_reboot_scenario.assert_called_once_with(
instance_kill_count, node, timeout
)
self.mock_affected_nodes_status.merge_affected_nodes.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.nodeaction.wait_for_ready_status')
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.runcommand.run')
@patch('logging.info')
def test_restart_kubelet_scenario_success(self, mock_logging, mock_run, mock_wait):
"""Test successful kubelet restart scenario"""
# Arrange
instance_kill_count = 2
node = "test-node"
timeout = 300
mock_affected_node = Mock(spec=AffectedNode)
mock_wait.return_value = None
# Act
with patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.AffectedNode') as mock_affected_node_class:
mock_affected_node_class.return_value = mock_affected_node
self.scenarios.restart_kubelet_scenario(instance_kill_count, node, timeout)
# Assert
self.assertEqual(mock_run.call_count, 2)
expected_command = "oc debug node/" + node + " -- chroot /host systemctl restart kubelet &"
mock_run.assert_called_with(expected_command)
self.assertEqual(mock_wait.call_count, 2)
self.assertEqual(len(self.mock_affected_nodes_status.affected_nodes), 2)
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.nodeaction.wait_for_ready_status')
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.runcommand.run')
@patch('logging.error')
@patch('logging.info')
def test_restart_kubelet_scenario_failure(self, mock_info, mock_error, mock_run, mock_wait):
"""Test kubelet restart scenario when command fails"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
error_msg = "Restart failed"
mock_run.side_effect = Exception(error_msg)
# Act & Assert
with self.assertRaises(Exception):
with patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.AffectedNode'):
self.scenarios.restart_kubelet_scenario(instance_kill_count, node, timeout)
mock_error.assert_any_call(
"Failed to restart the kubelet of the node. Encountered following "
"exception: %s. Test Failed" % error_msg
)
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.runcommand.run')
@patch('logging.info')
def test_node_crash_scenario_success(self, mock_logging, mock_run):
"""Test successful node crash scenario"""
# Arrange
instance_kill_count = 2
node = "test-node"
timeout = 300
# Act
result = self.scenarios.node_crash_scenario(instance_kill_count, node, timeout)
# Assert
self.assertEqual(mock_run.call_count, 2)
expected_command = (
"oc debug node/" + node + " -- chroot /host "
"dd if=/dev/urandom of=/proc/sysrq-trigger"
)
mock_run.assert_called_with(expected_command)
self.assertIsNone(result)
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.runcommand.run')
@patch('logging.error')
@patch('logging.info')
def test_node_crash_scenario_failure(self, mock_info, mock_error, mock_run):
"""Test node crash scenario when command fails"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
error_msg = "Crash command failed"
mock_run.side_effect = Exception(error_msg)
# Act
result = self.scenarios.node_crash_scenario(instance_kill_count, node, timeout)
# Assert
self.assertEqual(result, 1)
mock_error.assert_any_call(
"Failed to crash the node. Encountered following exception: %s. "
"Test Failed" % error_msg
)
def test_node_start_scenario_not_implemented(self):
"""Test that node_start_scenario returns None (not implemented)"""
result = self.scenarios.node_start_scenario(1, "test-node", 300, 10)
self.assertIsNone(result)
def test_node_stop_scenario_not_implemented(self):
"""Test that node_stop_scenario returns None (not implemented)"""
result = self.scenarios.node_stop_scenario(1, "test-node", 300, 10)
self.assertIsNone(result)
def test_node_termination_scenario_not_implemented(self):
"""Test that node_termination_scenario returns None (not implemented)"""
result = self.scenarios.node_termination_scenario(1, "test-node", 300, 10)
self.assertIsNone(result)
def test_node_reboot_scenario_not_implemented(self):
"""Test that node_reboot_scenario returns None (not implemented)"""
result = self.scenarios.node_reboot_scenario(1, "test-node", 300)
self.assertIsNone(result)
def test_node_service_status_not_implemented(self):
"""Test that node_service_status returns None (not implemented)"""
result = self.scenarios.node_service_status("test-node", "service", "key", 300)
self.assertIsNone(result)
def test_node_block_scenario_not_implemented(self):
"""Test that node_block_scenario returns None (not implemented)"""
result = self.scenarios.node_block_scenario(1, "test-node", 300, 60)
self.assertIsNone(result)
class TestAbstractNodeScenariosIntegration(unittest.TestCase):
"""Integration tests for abstract_node_scenarios workflows"""
def setUp(self):
"""Set up test fixtures before each test method"""
self.mock_kubecli = Mock(spec=KrknKubernetes)
self.mock_affected_nodes_status = Mock(spec=AffectedNodeStatus)
self.mock_affected_nodes_status.affected_nodes = []
self.scenarios = abstract_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=True,
affected_nodes_status=self.mock_affected_nodes_status
)
@patch('time.sleep')
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.nodeaction.wait_for_unknown_status')
@patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.runcommand.run')
def test_complete_stop_start_kubelet_workflow(self, mock_run, mock_wait, mock_sleep):
"""Test complete workflow of stop/start kubelet scenario"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
self.scenarios.node_reboot_scenario = Mock()
# Act
with patch('krkn.scenario_plugins.node_actions.abstract_node_scenarios.AffectedNode'):
self.scenarios.stop_start_kubelet_scenario(instance_kill_count, node, timeout)
# Assert - verify stop kubelet was called
expected_stop_command = "oc debug node/" + node + " -- chroot /host systemctl stop kubelet"
mock_run.assert_any_call(expected_stop_command)
# Verify reboot was called
self.scenarios.node_reboot_scenario.assert_called_once_with(
instance_kill_count, node, timeout
)
# Verify merge was called
self.mock_affected_nodes_status.merge_affected_nodes.assert_called_once()
@patch('time.sleep')
def test_node_stop_start_scenario_workflow(self, mock_sleep):
"""Test complete workflow of node stop/start scenario"""
# Arrange
instance_kill_count = 1
node = "test-node"
timeout = 300
duration = 60
poll_interval = 10
self.scenarios.node_stop_scenario = Mock()
self.scenarios.node_start_scenario = Mock()
# Act
self.scenarios.node_stop_start_scenario(
instance_kill_count, node, timeout, duration, poll_interval
)
# Assert - verify order of operations
call_order = []
# Verify stop was called first
self.scenarios.node_stop_scenario.assert_called_once()
# Verify sleep was called
mock_sleep.assert_called_once_with(duration)
# Verify start was called after sleep
self.scenarios.node_start_scenario.assert_called_once()
# Verify merge was called
self.mock_affected_nodes_status.merge_affected_nodes.assert_called_once()
if __name__ == '__main__':
unittest.main()

View File

@@ -1,680 +0,0 @@
#!/usr/bin/env python3
"""
Test suite for alibaba_node_scenarios class
Usage:
python -m coverage run -a -m unittest tests/test_alibaba_node_scenarios.py -v
Assisted By: Claude Code
"""
import unittest
from unittest.mock import MagicMock, Mock, patch, PropertyMock, call
import logging
import json
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.k8s import AffectedNode, AffectedNodeStatus
from krkn.scenario_plugins.node_actions.alibaba_node_scenarios import Alibaba, alibaba_node_scenarios
class TestAlibaba(unittest.TestCase):
"""Test suite for Alibaba class"""
def setUp(self):
"""Set up test fixtures"""
# Mock environment variables
self.env_patcher = patch.dict('os.environ', {
'ALIBABA_ID': 'test-access-key',
'ALIBABA_SECRET': 'test-secret-key',
'ALIBABA_REGION_ID': 'cn-hangzhou'
})
self.env_patcher.start()
def tearDown(self):
"""Clean up after tests"""
self.env_patcher.stop()
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_alibaba_init_success(self, mock_acs_client, mock_logging):
"""Test Alibaba class initialization"""
mock_client = Mock()
mock_acs_client.return_value = mock_client
alibaba = Alibaba()
mock_acs_client.assert_called_once_with('test-access-key', 'test-secret-key', 'cn-hangzhou')
self.assertEqual(alibaba.compute_client, mock_client)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_alibaba_init_failure(self, mock_acs_client, mock_logging):
"""Test Alibaba initialization handles errors"""
mock_acs_client.side_effect = Exception("Credential error")
alibaba = Alibaba()
mock_logging.assert_called()
self.assertIn("Initializing alibaba", str(mock_logging.call_args))
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_send_request_success(self, mock_acs_client):
"""Test _send_request successfully sends request"""
alibaba = Alibaba()
mock_request = Mock()
mock_response = {'Instances': {'Instance': []}}
alibaba.compute_client.do_action.return_value = json.dumps(mock_response).encode('utf-8')
result = alibaba._send_request(mock_request)
mock_request.set_accept_format.assert_called_once_with('json')
alibaba.compute_client.do_action.assert_called_once_with(mock_request)
self.assertEqual(result, mock_response)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_send_request_failure(self, mock_acs_client, mock_logging):
"""Test _send_request handles errors"""
alibaba = Alibaba()
mock_request = Mock()
alibaba.compute_client.do_action.side_effect = Exception("API error")
# The actual code has a bug in the format string (%S instead of %s)
# So we expect this to raise a ValueError
with self.assertRaises(ValueError):
alibaba._send_request(mock_request)
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_list_instances_success(self, mock_acs_client):
"""Test list_instances returns instance list"""
alibaba = Alibaba()
mock_instances = [
{'InstanceId': 'i-123', 'InstanceName': 'node1'},
{'InstanceId': 'i-456', 'InstanceName': 'node2'}
]
mock_response = {'Instances': {'Instance': mock_instances}}
alibaba.compute_client.do_action.return_value = json.dumps(mock_response).encode('utf-8')
result = alibaba.list_instances()
self.assertEqual(result, mock_instances)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_list_instances_no_instances_key(self, mock_acs_client, mock_logging):
"""Test list_instances handles missing Instances key"""
alibaba = Alibaba()
mock_response = {'SomeOtherKey': 'value'}
alibaba.compute_client.do_action.return_value = json.dumps(mock_response).encode('utf-8')
with self.assertRaises(RuntimeError):
alibaba.list_instances()
mock_logging.assert_called()
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_list_instances_none_response(self, mock_acs_client):
"""Test list_instances handles None response"""
alibaba = Alibaba()
alibaba._send_request = Mock(return_value=None)
result = alibaba.list_instances()
self.assertEqual(result, [])
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_list_instances_exception(self, mock_acs_client, mock_logging):
"""Test list_instances handles exceptions"""
alibaba = Alibaba()
alibaba._send_request = Mock(side_effect=Exception("Network error"))
with self.assertRaises(Exception):
alibaba.list_instances()
mock_logging.assert_called()
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_get_instance_id_found(self, mock_acs_client):
"""Test get_instance_id when instance is found"""
alibaba = Alibaba()
mock_instances = [
{'InstanceId': 'i-123', 'InstanceName': 'test-node'},
{'InstanceId': 'i-456', 'InstanceName': 'other-node'}
]
alibaba.list_instances = Mock(return_value=mock_instances)
result = alibaba.get_instance_id('test-node')
self.assertEqual(result, 'i-123')
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_get_instance_id_not_found(self, mock_acs_client, mock_logging):
"""Test get_instance_id when instance is not found"""
alibaba = Alibaba()
alibaba.list_instances = Mock(return_value=[])
with self.assertRaises(RuntimeError):
alibaba.get_instance_id('nonexistent-node')
mock_logging.assert_called()
self.assertIn("Couldn't find vm", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_start_instances_success(self, mock_acs_client, mock_logging):
"""Test start_instances successfully starts instance"""
alibaba = Alibaba()
alibaba._send_request = Mock(return_value={'RequestId': 'req-123'})
alibaba.start_instances('i-123')
alibaba._send_request.assert_called_once()
mock_logging.assert_called()
call_str = str(mock_logging.call_args_list)
self.assertTrue('started' in call_str or 'submit successfully' in call_str)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_start_instances_failure(self, mock_acs_client, mock_logging):
"""Test start_instances handles failure"""
alibaba = Alibaba()
alibaba._send_request = Mock(side_effect=Exception("Start failed"))
with self.assertRaises(Exception):
alibaba.start_instances('i-123')
mock_logging.assert_called()
self.assertIn("Failed to start", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_stop_instances_success(self, mock_acs_client, mock_logging):
"""Test stop_instances successfully stops instance"""
alibaba = Alibaba()
alibaba._send_request = Mock(return_value={'RequestId': 'req-123'})
alibaba.stop_instances('i-123', force_stop=True)
alibaba._send_request.assert_called_once()
mock_logging.assert_called()
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_stop_instances_failure(self, mock_acs_client, mock_logging):
"""Test stop_instances handles failure"""
alibaba = Alibaba()
alibaba._send_request = Mock(side_effect=Exception("Stop failed"))
with self.assertRaises(Exception):
alibaba.stop_instances('i-123')
mock_logging.assert_called()
self.assertIn("Failed to stop", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_release_instance_success(self, mock_acs_client, mock_logging):
"""Test release_instance successfully releases instance"""
alibaba = Alibaba()
alibaba._send_request = Mock(return_value={'RequestId': 'req-123'})
alibaba.release_instance('i-123', force_release=True)
alibaba._send_request.assert_called_once()
mock_logging.assert_called()
self.assertIn("released", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_release_instance_failure(self, mock_acs_client, mock_logging):
"""Test release_instance handles failure"""
alibaba = Alibaba()
alibaba._send_request = Mock(side_effect=Exception("Release failed"))
with self.assertRaises(Exception):
alibaba.release_instance('i-123')
mock_logging.assert_called()
self.assertIn("Failed to terminate", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_reboot_instances_success(self, mock_acs_client, mock_logging):
"""Test reboot_instances successfully reboots instance"""
alibaba = Alibaba()
alibaba._send_request = Mock(return_value={'RequestId': 'req-123'})
alibaba.reboot_instances('i-123', force_reboot=True)
alibaba._send_request.assert_called_once()
mock_logging.assert_called()
self.assertIn("rebooted", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_reboot_instances_failure(self, mock_acs_client, mock_logging):
"""Test reboot_instances handles failure"""
alibaba = Alibaba()
alibaba._send_request = Mock(side_effect=Exception("Reboot failed"))
with self.assertRaises(Exception):
alibaba.reboot_instances('i-123')
mock_logging.assert_called()
self.assertIn("Failed to reboot", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_get_vm_status_success(self, mock_acs_client, mock_logging):
"""Test get_vm_status returns instance status"""
alibaba = Alibaba()
mock_response = {
'Instances': {
'Instance': [{'Status': 'Running'}]
}
}
alibaba._send_request = Mock(return_value=mock_response)
result = alibaba.get_vm_status('i-123')
self.assertEqual(result, 'Running')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_get_vm_status_no_instances(self, mock_acs_client, mock_logging):
"""Test get_vm_status when no instances found"""
alibaba = Alibaba()
mock_response = {
'Instances': {
'Instance': []
}
}
alibaba._send_request = Mock(return_value=mock_response)
result = alibaba.get_vm_status('i-123')
self.assertIsNone(result)
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_get_vm_status_none_response(self, mock_acs_client, mock_logging):
"""Test get_vm_status with None response"""
alibaba = Alibaba()
alibaba._send_request = Mock(return_value=None)
result = alibaba.get_vm_status('i-123')
self.assertEqual(result, 'Unknown')
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_get_vm_status_exception(self, mock_acs_client, mock_logging):
"""Test get_vm_status handles exceptions"""
alibaba = Alibaba()
alibaba._send_request = Mock(side_effect=Exception("API error"))
result = alibaba.get_vm_status('i-123')
self.assertIsNone(result)
mock_logging.assert_called()
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_wait_until_running_success(self, mock_acs_client, mock_logging, mock_sleep):
"""Test wait_until_running waits for instance to be running"""
alibaba = Alibaba()
alibaba.get_vm_status = Mock(side_effect=['Starting', 'Running'])
mock_affected_node = Mock(spec=AffectedNode)
result = alibaba.wait_until_running('i-123', 300, mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
args = mock_affected_node.set_affected_node_status.call_args[0]
self.assertEqual(args[0], 'running')
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_wait_until_running_timeout(self, mock_acs_client, mock_logging, mock_sleep):
"""Test wait_until_running returns False on timeout"""
alibaba = Alibaba()
alibaba.get_vm_status = Mock(return_value='Starting')
result = alibaba.wait_until_running('i-123', 10, None)
self.assertFalse(result)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_wait_until_stopped_success(self, mock_acs_client, mock_logging, mock_sleep):
"""Test wait_until_stopped waits for instance to be stopped"""
alibaba = Alibaba()
alibaba.get_vm_status = Mock(side_effect=['Stopping', 'Stopped'])
mock_affected_node = Mock(spec=AffectedNode)
result = alibaba.wait_until_stopped('i-123', 300, mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_wait_until_stopped_timeout(self, mock_acs_client, mock_logging, mock_sleep):
"""Test wait_until_stopped returns False on timeout"""
alibaba = Alibaba()
alibaba.get_vm_status = Mock(return_value='Stopping')
result = alibaba.wait_until_stopped('i-123', 10, None)
self.assertFalse(result)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_wait_until_released_success(self, mock_acs_client, mock_logging, mock_sleep):
"""Test wait_until_released waits for instance to be released"""
alibaba = Alibaba()
alibaba.get_vm_status = Mock(side_effect=['Deleting', 'Released'])
mock_affected_node = Mock(spec=AffectedNode)
result = alibaba.wait_until_released('i-123', 300, mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
args = mock_affected_node.set_affected_node_status.call_args[0]
self.assertEqual(args[0], 'terminated')
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_wait_until_released_timeout(self, mock_acs_client, mock_logging, mock_sleep):
"""Test wait_until_released returns False on timeout"""
alibaba = Alibaba()
alibaba.get_vm_status = Mock(return_value='Deleting')
result = alibaba.wait_until_released('i-123', 10, None)
self.assertFalse(result)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.AcsClient')
def test_wait_until_released_none_status(self, mock_acs_client, mock_logging, mock_sleep):
"""Test wait_until_released when status becomes None"""
alibaba = Alibaba()
alibaba.get_vm_status = Mock(side_effect=['Deleting', None])
mock_affected_node = Mock(spec=AffectedNode)
result = alibaba.wait_until_released('i-123', 300, mock_affected_node)
self.assertTrue(result)
class TestAlibabaNodeScenarios(unittest.TestCase):
"""Test suite for alibaba_node_scenarios class"""
def setUp(self):
"""Set up test fixtures"""
self.env_patcher = patch.dict('os.environ', {
'ALIBABA_ID': 'test-access-key',
'ALIBABA_SECRET': 'test-secret-key',
'ALIBABA_REGION_ID': 'cn-hangzhou'
})
self.env_patcher.start()
self.mock_kubecli = Mock(spec=KrknKubernetes)
self.affected_nodes_status = AffectedNodeStatus()
def tearDown(self):
"""Clean up after tests"""
self.env_patcher.stop()
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_init(self, mock_alibaba_class, mock_logging):
"""Test alibaba_node_scenarios initialization"""
mock_alibaba_instance = Mock()
mock_alibaba_class.return_value = mock_alibaba_instance
scenarios = alibaba_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
self.assertEqual(scenarios.kubecli, self.mock_kubecli)
self.assertTrue(scenarios.node_action_kube_check)
self.assertEqual(scenarios.alibaba, mock_alibaba_instance)
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_start_scenario_success(self, mock_alibaba_class, mock_logging, mock_nodeaction):
"""Test node_start_scenario successfully starts node"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.wait_until_running.return_value = True
scenarios = alibaba_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_start_scenario(1, 'test-node', 300, 15)
mock_alibaba.get_instance_id.assert_called_once_with('test-node')
mock_alibaba.start_instances.assert_called_once_with('i-123')
mock_alibaba.wait_until_running.assert_called_once()
mock_nodeaction.wait_for_ready_status.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_start_scenario_no_kube_check(self, mock_alibaba_class, mock_logging, mock_nodeaction):
"""Test node_start_scenario without Kubernetes check"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.wait_until_running.return_value = True
scenarios = alibaba_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
scenarios.node_start_scenario(1, 'test-node', 300, 15)
mock_alibaba.start_instances.assert_called_once()
mock_nodeaction.wait_for_ready_status.assert_not_called()
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_start_scenario_failure(self, mock_alibaba_class, mock_logging):
"""Test node_start_scenario handles failure"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.start_instances.side_effect = Exception('Start failed')
scenarios = alibaba_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(Exception):
scenarios.node_start_scenario(1, 'test-node', 300, 15)
mock_logging.assert_called()
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_start_scenario_multiple_runs(self, mock_alibaba_class, mock_logging, mock_nodeaction):
"""Test node_start_scenario with multiple runs"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.wait_until_running.return_value = True
scenarios = alibaba_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_start_scenario(3, 'test-node', 300, 15)
self.assertEqual(mock_alibaba.start_instances.call_count, 3)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 3)
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_stop_scenario_success(self, mock_alibaba_class, mock_logging, mock_nodeaction):
"""Test node_stop_scenario successfully stops node"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.wait_until_stopped.return_value = True
scenarios = alibaba_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_stop_scenario(1, 'test-node', 300, 15)
mock_alibaba.get_instance_id.assert_called_once_with('test-node')
mock_alibaba.stop_instances.assert_called_once_with('i-123')
mock_alibaba.wait_until_stopped.assert_called_once()
mock_nodeaction.wait_for_unknown_status.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_stop_scenario_failure(self, mock_alibaba_class, mock_logging):
"""Test node_stop_scenario handles failure"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.stop_instances.side_effect = Exception('Stop failed')
scenarios = alibaba_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(Exception):
scenarios.node_stop_scenario(1, 'test-node', 300, 15)
mock_logging.assert_called()
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_termination_scenario_success(self, mock_alibaba_class, mock_logging):
"""Test node_termination_scenario successfully terminates node"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.wait_until_stopped.return_value = True
mock_alibaba.wait_until_released.return_value = True
scenarios = alibaba_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
scenarios.node_termination_scenario(1, 'test-node', 300, 15)
mock_alibaba.stop_instances.assert_called_once_with('i-123')
mock_alibaba.wait_until_stopped.assert_called_once()
mock_alibaba.release_instance.assert_called_once_with('i-123')
mock_alibaba.wait_until_released.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_termination_scenario_failure(self, mock_alibaba_class, mock_logging):
"""Test node_termination_scenario handles failure"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.stop_instances.side_effect = Exception('Stop failed')
scenarios = alibaba_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(Exception):
scenarios.node_termination_scenario(1, 'test-node', 300, 15)
mock_logging.assert_called()
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_reboot_scenario_success(self, mock_alibaba_class, mock_logging, mock_nodeaction):
"""Test node_reboot_scenario successfully reboots node"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
scenarios = alibaba_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_reboot_scenario(1, 'test-node', 300, soft_reboot=False)
mock_alibaba.reboot_instances.assert_called_once_with('i-123')
mock_nodeaction.wait_for_unknown_status.assert_called_once()
mock_nodeaction.wait_for_ready_status.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_reboot_scenario_no_kube_check(self, mock_alibaba_class, mock_logging, mock_nodeaction):
"""Test node_reboot_scenario without Kubernetes check"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
scenarios = alibaba_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
scenarios.node_reboot_scenario(1, 'test-node', 300)
mock_alibaba.reboot_instances.assert_called_once()
mock_nodeaction.wait_for_unknown_status.assert_not_called()
mock_nodeaction.wait_for_ready_status.assert_not_called()
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_reboot_scenario_failure(self, mock_alibaba_class, mock_logging):
"""Test node_reboot_scenario handles failure"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
mock_alibaba.reboot_instances.side_effect = Exception('Reboot failed')
scenarios = alibaba_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(Exception):
scenarios.node_reboot_scenario(1, 'test-node', 300)
mock_logging.assert_called()
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.Alibaba')
def test_node_reboot_scenario_multiple_runs(self, mock_alibaba_class, mock_logging, mock_nodeaction):
"""Test node_reboot_scenario with multiple runs"""
mock_alibaba = Mock()
mock_alibaba_class.return_value = mock_alibaba
mock_alibaba.get_instance_id.return_value = 'i-123'
scenarios = alibaba_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_reboot_scenario(2, 'test-node', 300)
self.assertEqual(mock_alibaba.reboot_instances.call_count, 2)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,784 +0,0 @@
#!/usr/bin/env python3
"""
Test suite for azure_node_scenarios class
Usage:
python -m coverage run -a -m unittest tests/test_az_node_scenarios.py -v
Assisted By: Claude Code
"""
import unittest
from unittest.mock import Mock, patch
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.k8s import AffectedNode, AffectedNodeStatus
from krkn.scenario_plugins.node_actions.az_node_scenarios import Azure, azure_node_scenarios
class TestAzure(unittest.TestCase):
"""Test suite for Azure class"""
def setUp(self):
"""Set up test fixtures"""
# Mock environment variable
self.env_patcher = patch.dict('os.environ', {'AZURE_SUBSCRIPTION_ID': 'test-subscription-id'})
self.env_patcher.start()
def tearDown(self):
"""Clean up after tests"""
self.env_patcher.stop()
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
@patch('logging.info')
def test_azure_init(self, mock_logging, mock_credential, mock_compute, mock_network):
"""Test Azure class initialization"""
mock_creds = Mock()
mock_credential.return_value = mock_creds
azure = Azure()
mock_credential.assert_called_once()
mock_compute.assert_called_once()
mock_network.assert_called_once()
self.assertIsNotNone(azure.compute_client)
self.assertIsNotNone(azure.network_client)
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_get_instance_id_found(self, mock_credential, mock_compute, mock_network):
"""Test get_instance_id when VM is found"""
azure = Azure()
# Mock VM
mock_vm = Mock()
mock_vm.name = "test-node"
mock_vm.id = "/subscriptions/sub-id/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-node"
azure.compute_client.virtual_machines.list_all.return_value = [mock_vm]
vm_name, resource_group = azure.get_instance_id("test-node")
self.assertEqual(vm_name, "test-node")
self.assertEqual(resource_group, "test-rg")
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_get_instance_id_not_found(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test get_instance_id when VM is not found"""
azure = Azure()
azure.compute_client.virtual_machines.list_all.return_value = []
result = azure.get_instance_id("nonexistent-node")
self.assertIsNone(result)
mock_logging.assert_called()
self.assertIn("Couldn't find vm", str(mock_logging.call_args))
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_get_network_interface(self, mock_credential, mock_compute, mock_network):
"""Test get_network_interface retrieves network details"""
azure = Azure()
# Mock VM with network profile
mock_vm = Mock()
mock_nic_ref = Mock()
mock_nic_ref.id = "/subscriptions/sub-id/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-nic"
mock_vm.network_profile.network_interfaces = [mock_nic_ref]
# Mock NIC
mock_nic = Mock()
mock_nic.location = "eastus"
mock_ip_config = Mock()
mock_ip_config.private_ip_address = "10.0.1.5"
mock_ip_config.subnet.id = "/subscriptions/sub-id/resourceGroups/network-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"
mock_nic.ip_configurations = [mock_ip_config]
azure.compute_client.virtual_machines.get.return_value = mock_vm
azure.network_client.network_interfaces.get.return_value = mock_nic
subnet, vnet, ip, net_rg, location = azure.get_network_interface("test-node", "test-rg")
self.assertEqual(subnet, "test-subnet")
self.assertEqual(vnet, "test-vnet")
self.assertEqual(ip, "10.0.1.5")
self.assertEqual(net_rg, "network-rg")
self.assertEqual(location, "eastus")
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_start_instances_success(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test start_instances successfully starts VM"""
azure = Azure()
mock_operation = Mock()
azure.compute_client.virtual_machines.begin_start.return_value = mock_operation
azure.start_instances("test-rg", "test-vm")
azure.compute_client.virtual_machines.begin_start.assert_called_once_with("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("started", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_start_instances_failure(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test start_instances handles failure"""
azure = Azure()
azure.compute_client.virtual_machines.begin_start.side_effect = Exception("Start failed")
with self.assertRaises(RuntimeError):
azure.start_instances("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("Failed to start", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_stop_instances_success(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test stop_instances successfully stops VM"""
azure = Azure()
mock_operation = Mock()
azure.compute_client.virtual_machines.begin_power_off.return_value = mock_operation
azure.stop_instances("test-rg", "test-vm")
azure.compute_client.virtual_machines.begin_power_off.assert_called_once_with("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("stopped", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_stop_instances_failure(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test stop_instances handles failure"""
azure = Azure()
azure.compute_client.virtual_machines.begin_power_off.side_effect = Exception("Stop failed")
with self.assertRaises(RuntimeError):
azure.stop_instances("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("Failed to stop", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_terminate_instances_success(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test terminate_instances successfully deletes VM"""
azure = Azure()
mock_operation = Mock()
azure.compute_client.virtual_machines.begin_delete.return_value = mock_operation
azure.terminate_instances("test-rg", "test-vm")
azure.compute_client.virtual_machines.begin_delete.assert_called_once_with("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("terminated", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_terminate_instances_failure(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test terminate_instances handles failure"""
azure = Azure()
azure.compute_client.virtual_machines.begin_delete.side_effect = Exception("Delete failed")
with self.assertRaises(RuntimeError):
azure.terminate_instances("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("Failed to terminate", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_reboot_instances_success(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test reboot_instances successfully reboots VM"""
azure = Azure()
mock_operation = Mock()
azure.compute_client.virtual_machines.begin_restart.return_value = mock_operation
azure.reboot_instances("test-rg", "test-vm")
azure.compute_client.virtual_machines.begin_restart.assert_called_once_with("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("rebooted", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_reboot_instances_failure(self, mock_credential, mock_compute, mock_network, mock_logging):
"""Test reboot_instances handles failure"""
azure = Azure()
azure.compute_client.virtual_machines.begin_restart.side_effect = Exception("Reboot failed")
with self.assertRaises(RuntimeError):
azure.reboot_instances("test-rg", "test-vm")
mock_logging.assert_called()
self.assertIn("Failed to reboot", str(mock_logging.call_args))
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_get_vm_status(self, mock_credential, mock_compute, mock_network):
"""Test get_vm_status returns VM power state"""
azure = Azure()
mock_status1 = Mock()
mock_status1.code = "ProvisioningState/succeeded"
mock_status2 = Mock()
mock_status2.code = "PowerState/running"
mock_instance_view = Mock()
mock_instance_view.statuses = [mock_status1, mock_status2]
azure.compute_client.virtual_machines.instance_view.return_value = mock_instance_view
status = azure.get_vm_status("test-rg", "test-vm")
self.assertEqual(status.code, "PowerState/running")
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_wait_until_running_success(self, mock_credential, mock_compute, mock_network, mock_logging, mock_sleep):
"""Test wait_until_running waits for VM to be running"""
azure = Azure()
mock_status_starting = Mock()
mock_status_starting.code = "PowerState/starting"
mock_status_running = Mock()
mock_status_running.code = "PowerState/running"
mock_instance_view1 = Mock()
mock_instance_view1.statuses = [Mock(), mock_status_starting]
mock_instance_view2 = Mock()
mock_instance_view2.statuses = [Mock(), mock_status_running]
azure.compute_client.virtual_machines.instance_view.side_effect = [
mock_instance_view1,
mock_instance_view2
]
mock_affected_node = Mock(spec=AffectedNode)
result = azure.wait_until_running("test-rg", "test-vm", 300, mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
args = mock_affected_node.set_affected_node_status.call_args[0]
self.assertEqual(args[0], "running")
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_wait_until_running_timeout(self, mock_credential, mock_compute, mock_network, mock_logging, mock_sleep):
"""Test wait_until_running returns False on timeout"""
azure = Azure()
mock_status = Mock()
mock_status.code = "PowerState/starting"
mock_instance_view = Mock()
mock_instance_view.statuses = [Mock(), mock_status]
azure.compute_client.virtual_machines.instance_view.return_value = mock_instance_view
result = azure.wait_until_running("test-rg", "test-vm", 10, None)
self.assertFalse(result)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_wait_until_stopped_success(self, mock_credential, mock_compute, mock_network, mock_logging, mock_sleep):
"""Test wait_until_stopped waits for VM to be stopped"""
azure = Azure()
mock_status_stopping = Mock()
mock_status_stopping.code = "PowerState/stopping"
mock_status_stopped = Mock()
mock_status_stopped.code = "PowerState/stopped"
mock_instance_view1 = Mock()
mock_instance_view1.statuses = [Mock(), mock_status_stopping]
mock_instance_view2 = Mock()
mock_instance_view2.statuses = [Mock(), mock_status_stopped]
azure.compute_client.virtual_machines.instance_view.side_effect = [
mock_instance_view1,
mock_instance_view2
]
mock_affected_node = Mock(spec=AffectedNode)
result = azure.wait_until_stopped("test-rg", "test-vm", 300, mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_wait_until_stopped_timeout(self, mock_credential, mock_compute, mock_network, mock_logging, mock_sleep):
"""Test wait_until_stopped returns False on timeout"""
azure = Azure()
mock_status = Mock()
mock_status.code = "PowerState/stopping"
mock_instance_view = Mock()
mock_instance_view.statuses = [Mock(), mock_status]
azure.compute_client.virtual_machines.instance_view.return_value = mock_instance_view
result = azure.wait_until_stopped("test-rg", "test-vm", 10, None)
self.assertFalse(result)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_wait_until_terminated_success(self, mock_credential, mock_compute, mock_network, mock_logging, mock_sleep):
"""Test wait_until_terminated waits for VM deletion"""
azure = Azure()
mock_status_deleting = Mock()
mock_status_deleting.code = "ProvisioningState/deleting"
mock_instance_view = Mock()
mock_instance_view.statuses = [mock_status_deleting]
# First call returns deleting, second raises exception (VM deleted)
azure.compute_client.virtual_machines.instance_view.side_effect = [
mock_instance_view,
Exception("VM not found")
]
mock_affected_node = Mock(spec=AffectedNode)
result = azure.wait_until_terminated("test-rg", "test-vm", 300, mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
args = mock_affected_node.set_affected_node_status.call_args[0]
self.assertEqual(args[0], "terminated")
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_wait_until_terminated_timeout(self, mock_credential, mock_compute, mock_network, mock_logging, mock_sleep):
"""Test wait_until_terminated returns False on timeout"""
azure = Azure()
mock_status = Mock()
mock_status.code = "ProvisioningState/deleting"
mock_instance_view = Mock()
mock_instance_view.statuses = [mock_status]
azure.compute_client.virtual_machines.instance_view.return_value = mock_instance_view
result = azure.wait_until_terminated("test-rg", "test-vm", 10, None)
self.assertFalse(result)
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_create_security_group(self, mock_credential, mock_compute, mock_network):
"""Test create_security_group creates NSG with deny rules"""
azure = Azure()
mock_nsg_result = Mock()
mock_nsg_result.id = "/subscriptions/sub-id/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/chaos"
mock_operation = Mock()
mock_operation.result.return_value = mock_nsg_result
azure.network_client.network_security_groups.begin_create_or_update.return_value = mock_operation
nsg_id = azure.create_security_group("test-rg", "chaos", "eastus", "10.0.1.5")
self.assertEqual(nsg_id, mock_nsg_result.id)
azure.network_client.network_security_groups.begin_create_or_update.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_delete_security_group(self, mock_credential, mock_compute, mock_network):
"""Test delete_security_group deletes NSG"""
azure = Azure()
mock_operation = Mock()
mock_operation.result.return_value = None
azure.network_client.network_security_groups.begin_delete.return_value = mock_operation
azure.delete_security_group("test-rg", "chaos")
azure.network_client.network_security_groups.begin_delete.assert_called_once_with("test-rg", "chaos")
@patch('builtins.print')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_delete_security_group_with_result(self, mock_credential, mock_compute, mock_network, mock_print):
"""Test delete_security_group deletes NSG with non-None result"""
azure = Azure()
mock_result = Mock()
mock_result.as_dict.return_value = {"id": "/test-nsg-id", "name": "chaos"}
mock_operation = Mock()
mock_operation.result.return_value = mock_result
azure.network_client.network_security_groups.begin_delete.return_value = mock_operation
azure.delete_security_group("test-rg", "chaos")
azure.network_client.network_security_groups.begin_delete.assert_called_once_with("test-rg", "chaos")
mock_result.as_dict.assert_called_once()
mock_print.assert_called_once_with({"id": "/test-nsg-id", "name": "chaos"})
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.NetworkManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.ComputeManagementClient')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.DefaultAzureCredential')
def test_update_subnet(self, mock_credential, mock_compute, mock_network):
"""Test update_subnet updates subnet NSG"""
azure = Azure()
# Mock existing subnet
mock_old_nsg = Mock()
mock_old_nsg.id = "/old-nsg-id"
mock_subnet = Mock()
mock_subnet.network_security_group = mock_old_nsg
azure.network_client.subnets.get.return_value = mock_subnet
old_nsg = azure.update_subnet("/new-nsg-id", "test-rg", "test-subnet", "test-vnet")
self.assertEqual(old_nsg, "/old-nsg-id")
azure.network_client.subnets.begin_create_or_update.assert_called_once()
class TestAzureNodeScenarios(unittest.TestCase):
"""Test suite for azure_node_scenarios class"""
def setUp(self):
"""Set up test fixtures"""
self.env_patcher = patch.dict('os.environ', {'AZURE_SUBSCRIPTION_ID': 'test-subscription-id'})
self.env_patcher.start()
self.mock_kubecli = Mock(spec=KrknKubernetes)
self.affected_nodes_status = AffectedNodeStatus()
def tearDown(self):
"""Clean up after tests"""
self.env_patcher.stop()
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_init(self, mock_azure_class, mock_logging):
"""Test azure_node_scenarios initialization"""
mock_azure_instance = Mock()
mock_azure_class.return_value = mock_azure_instance
scenarios = azure_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
self.assertEqual(scenarios.kubecli, self.mock_kubecli)
self.assertTrue(scenarios.node_action_kube_check)
self.assertEqual(scenarios.azure, mock_azure_instance)
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_start_scenario_success(self, mock_azure_class, mock_logging, mock_nodeaction):
"""Test node_start_scenario successfully starts node"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.wait_until_running.return_value = True
scenarios = azure_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_start_scenario(1, "test-node", 300, 15)
mock_azure.get_instance_id.assert_called_once_with("test-node")
mock_azure.start_instances.assert_called_once_with("test-rg", "test-vm")
mock_azure.wait_until_running.assert_called_once()
mock_nodeaction.wait_for_ready_status.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_start_scenario_no_kube_check(self, mock_azure_class, mock_logging, mock_nodeaction):
"""Test node_start_scenario without Kubernetes check"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.wait_until_running.return_value = True
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
scenarios.node_start_scenario(1, "test-node", 300, 15)
mock_azure.start_instances.assert_called_once()
mock_nodeaction.wait_for_ready_status.assert_not_called()
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_start_scenario_failure(self, mock_azure_class, mock_logging):
"""Test node_start_scenario handles failure"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.start_instances.side_effect = Exception("Start failed")
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(RuntimeError):
scenarios.node_start_scenario(1, "test-node", 300, 15)
mock_logging.assert_called()
# Check that failure was logged (either specific or general injection failed message)
call_str = str(mock_logging.call_args)
self.assertTrue("Failed to start" in call_str or "injection failed" in call_str)
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_start_scenario_multiple_runs(self, mock_azure_class, mock_logging, mock_nodeaction):
"""Test node_start_scenario with multiple runs"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.wait_until_running.return_value = True
scenarios = azure_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_start_scenario(3, "test-node", 300, 15)
self.assertEqual(mock_azure.start_instances.call_count, 3)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 3)
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_stop_scenario_success(self, mock_azure_class, mock_logging, mock_nodeaction):
"""Test node_stop_scenario successfully stops node"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.wait_until_stopped.return_value = True
scenarios = azure_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_stop_scenario(1, "test-node", 300, 15)
mock_azure.get_instance_id.assert_called_once_with("test-node")
mock_azure.stop_instances.assert_called_once_with("test-rg", "test-vm")
mock_azure.wait_until_stopped.assert_called_once()
mock_nodeaction.wait_for_unknown_status.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_stop_scenario_failure(self, mock_azure_class, mock_logging):
"""Test node_stop_scenario handles failure"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.stop_instances.side_effect = Exception("Stop failed")
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(RuntimeError):
scenarios.node_stop_scenario(1, "test-node", 300, 15)
mock_logging.assert_called()
# Check that failure was logged
call_str = str(mock_logging.call_args)
self.assertTrue("Failed to stop" in call_str or "injection failed" in call_str)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_termination_scenario_success(self, mock_azure_class, mock_logging, mock_sleep):
"""Test node_termination_scenario successfully terminates node"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.wait_until_terminated.return_value = True
self.mock_kubecli.list_nodes.return_value = ["other-node"]
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
scenarios.node_termination_scenario(1, "test-node", 300, 15)
mock_azure.terminate_instances.assert_called_once_with("test-rg", "test-vm")
mock_azure.wait_until_terminated.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('time.sleep')
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_termination_scenario_node_still_exists(self, mock_azure_class, mock_logging, mock_sleep):
"""Test node_termination_scenario when node still exists after timeout"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.wait_until_terminated.return_value = True
# Node still in list after termination attempt
self.mock_kubecli.list_nodes.return_value = ["test-vm", "other-node"]
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(RuntimeError):
scenarios.node_termination_scenario(1, "test-node", 5, 15)
mock_logging.assert_called()
# Check that failure was logged
call_str = str(mock_logging.call_args)
self.assertTrue("Failed to terminate" in call_str or "injection failed" in call_str)
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.nodeaction')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_reboot_scenario_success(self, mock_azure_class, mock_logging, mock_nodeaction):
"""Test node_reboot_scenario successfully reboots node"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
scenarios = azure_node_scenarios(self.mock_kubecli, True, self.affected_nodes_status)
scenarios.node_reboot_scenario(1, "test-node", 300, soft_reboot=False)
mock_azure.reboot_instances.assert_called_once_with("test-rg", "test-vm")
mock_nodeaction.wait_for_ready_status.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_reboot_scenario_failure(self, mock_azure_class, mock_logging):
"""Test node_reboot_scenario handles failure"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.reboot_instances.side_effect = Exception("Reboot failed")
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(RuntimeError):
scenarios.node_reboot_scenario(1, "test-node", 300)
mock_logging.assert_called()
# Check that failure was logged
call_str = str(mock_logging.call_args)
self.assertTrue("Failed to reboot" in call_str or "injection failed" in call_str)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_block_scenario_success(self, mock_azure_class, mock_logging, mock_sleep):
"""Test node_block_scenario successfully blocks and unblocks node"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.get_network_interface.return_value = (
"test-subnet", "test-vnet", "10.0.1.5", "network-rg", "eastus"
)
mock_azure.create_security_group.return_value = "/new-nsg-id"
mock_azure.update_subnet.return_value = "/old-nsg-id"
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
scenarios.node_block_scenario(1, "test-node", 300, 60)
mock_azure.create_security_group.assert_called_once()
# Should be called twice: once to apply block, once to remove
self.assertEqual(mock_azure.update_subnet.call_count, 2)
mock_azure.delete_security_group.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('time.sleep')
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_block_scenario_failure(self, mock_azure_class, mock_logging, mock_sleep):
"""Test node_block_scenario handles failure"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.get_network_interface.side_effect = Exception("Network error")
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
with self.assertRaises(RuntimeError):
scenarios.node_block_scenario(1, "test-node", 300, 60)
mock_logging.assert_called()
# Check that failure was logged
call_str = str(mock_logging.call_args)
self.assertTrue("Failed to block" in call_str or "injection failed" in call_str)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.az_node_scenarios.Azure')
def test_node_block_scenario_duration_timing(self, mock_azure_class, mock_logging, mock_sleep):
"""Test node_block_scenario waits for specified duration"""
mock_azure = Mock()
mock_azure_class.return_value = mock_azure
mock_azure.get_instance_id.return_value = ("test-vm", "test-rg")
mock_azure.get_network_interface.return_value = (
"test-subnet", "test-vnet", "10.0.1.5", "network-rg", "eastus"
)
mock_azure.create_security_group.return_value = "/new-nsg-id"
mock_azure.update_subnet.return_value = "/old-nsg-id"
scenarios = azure_node_scenarios(self.mock_kubecli, False, self.affected_nodes_status)
scenarios.node_block_scenario(1, "test-node", 300, 120)
# Verify sleep was called with the correct duration
mock_sleep.assert_called_with(120)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,476 +0,0 @@
#!/usr/bin/env python3
"""
Test suite for common_node_functions module
Usage:
python -m coverage run -a -m unittest tests/test_common_node_functions.py -v
Assisted By: Claude Code
"""
import unittest
from unittest.mock import MagicMock, Mock, patch, call
import logging
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.k8s import AffectedNode
from krkn.scenario_plugins.node_actions import common_node_functions
class TestCommonNodeFunctions(unittest.TestCase):
def setUp(self):
"""
Set up test fixtures before each test
"""
self.mock_kubecli = Mock(spec=KrknKubernetes)
self.mock_affected_node = Mock(spec=AffectedNode)
def test_get_node_by_name_all_nodes_exist(self):
"""
Test get_node_by_name returns list when all nodes exist
"""
node_name_list = ["node1", "node2", "node3"]
self.mock_kubecli.list_killable_nodes.return_value = ["node1", "node2", "node3", "node4"]
result = common_node_functions.get_node_by_name(node_name_list, self.mock_kubecli)
self.assertEqual(result, node_name_list)
self.mock_kubecli.list_killable_nodes.assert_called_once()
def test_get_node_by_name_single_node(self):
"""
Test get_node_by_name with single node
"""
node_name_list = ["worker-1"]
self.mock_kubecli.list_killable_nodes.return_value = ["worker-1", "worker-2"]
result = common_node_functions.get_node_by_name(node_name_list, self.mock_kubecli)
self.assertEqual(result, node_name_list)
@patch('logging.info')
def test_get_node_by_name_node_not_exist(self, mock_logging):
"""
Test get_node_by_name returns None when node doesn't exist
"""
node_name_list = ["node1", "nonexistent-node"]
self.mock_kubecli.list_killable_nodes.return_value = ["node1", "node2", "node3"]
result = common_node_functions.get_node_by_name(node_name_list, self.mock_kubecli)
self.assertIsNone(result)
mock_logging.assert_called()
self.assertIn("does not exist", str(mock_logging.call_args))
@patch('logging.info')
def test_get_node_by_name_empty_killable_list(self, mock_logging):
"""
Test get_node_by_name when no killable nodes exist
"""
node_name_list = ["node1"]
self.mock_kubecli.list_killable_nodes.return_value = []
result = common_node_functions.get_node_by_name(node_name_list, self.mock_kubecli)
self.assertIsNone(result)
mock_logging.assert_called()
@patch('logging.info')
def test_get_node_single_label_selector(self, mock_logging):
"""
Test get_node with single label selector
"""
label_selector = "node-role.kubernetes.io/worker"
instance_kill_count = 2
self.mock_kubecli.list_killable_nodes.return_value = ["worker-1", "worker-2", "worker-3"]
result = common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertEqual(len(result), 2)
self.assertTrue(all(node in ["worker-1", "worker-2", "worker-3"] for node in result))
self.mock_kubecli.list_killable_nodes.assert_called_once_with(label_selector)
mock_logging.assert_called()
@patch('logging.info')
def test_get_node_multiple_label_selectors(self, mock_logging):
"""
Test get_node with multiple comma-separated label selectors
"""
label_selector = "node-role.kubernetes.io/worker,topology.kubernetes.io/zone=us-east-1a"
instance_kill_count = 3
self.mock_kubecli.list_killable_nodes.side_effect = [
["worker-1", "worker-2"],
["worker-3", "worker-4"]
]
result = common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertEqual(len(result), 3)
self.assertTrue(all(node in ["worker-1", "worker-2", "worker-3", "worker-4"] for node in result))
self.assertEqual(self.mock_kubecli.list_killable_nodes.call_count, 2)
@patch('logging.info')
def test_get_node_return_all_when_count_equals_total(self, mock_logging):
"""
Test get_node returns all nodes when instance_kill_count equals number of nodes
"""
label_selector = "node-role.kubernetes.io/worker"
nodes = ["worker-1", "worker-2", "worker-3"]
instance_kill_count = 3
self.mock_kubecli.list_killable_nodes.return_value = nodes
result = common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertEqual(result, nodes)
@patch('logging.info')
def test_get_node_return_all_when_count_is_zero(self, mock_logging):
"""
Test get_node returns all nodes when instance_kill_count is 0
"""
label_selector = "node-role.kubernetes.io/worker"
nodes = ["worker-1", "worker-2", "worker-3"]
instance_kill_count = 0
self.mock_kubecli.list_killable_nodes.return_value = nodes
result = common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertEqual(result, nodes)
@patch('logging.info')
@patch('random.randint')
def test_get_node_random_selection(self, mock_randint, mock_logging):
"""
Test get_node randomly selects nodes when count is less than total
"""
label_selector = "node-role.kubernetes.io/worker"
instance_kill_count = 2
self.mock_kubecli.list_killable_nodes.return_value = ["worker-1", "worker-2", "worker-3", "worker-4"]
# Mock random selection to return predictable values
mock_randint.side_effect = [1, 0] # Select index 1, then index 0
result = common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertEqual(len(result), 2)
# Verify nodes were removed after selection to avoid duplicates
self.assertEqual(len(set(result)), 2)
def test_get_node_no_nodes_with_label(self):
"""
Test get_node raises exception when no nodes match label selector
"""
label_selector = "nonexistent-label"
instance_kill_count = 1
self.mock_kubecli.list_killable_nodes.return_value = []
with self.assertRaises(Exception) as context:
common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertIn("Ready nodes with the provided label selector do not exist", str(context.exception))
def test_get_node_single_node_available(self):
"""
Test get_node when only one node is available
"""
label_selector = "node-role.kubernetes.io/master"
instance_kill_count = 1
self.mock_kubecli.list_killable_nodes.return_value = ["master-1"]
result = common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertEqual(result, ["master-1"])
def test_wait_for_ready_status_without_affected_node(self):
"""
Test wait_for_ready_status without providing affected_node
"""
node = "test-node"
timeout = 300
expected_affected_node = Mock(spec=AffectedNode)
self.mock_kubecli.watch_node_status.return_value = expected_affected_node
result = common_node_functions.wait_for_ready_status(node, timeout, self.mock_kubecli)
self.assertEqual(result, expected_affected_node)
self.mock_kubecli.watch_node_status.assert_called_once_with(node, "True", timeout, None)
def test_wait_for_ready_status_with_affected_node(self):
"""
Test wait_for_ready_status with provided affected_node
"""
node = "test-node"
timeout = 300
self.mock_kubecli.watch_node_status.return_value = self.mock_affected_node
result = common_node_functions.wait_for_ready_status(
node, timeout, self.mock_kubecli, self.mock_affected_node
)
self.assertEqual(result, self.mock_affected_node)
self.mock_kubecli.watch_node_status.assert_called_once_with(
node, "True", timeout, self.mock_affected_node
)
def test_wait_for_not_ready_status_without_affected_node(self):
"""
Test wait_for_not_ready_status without providing affected_node
"""
node = "test-node"
timeout = 300
expected_affected_node = Mock(spec=AffectedNode)
self.mock_kubecli.watch_node_status.return_value = expected_affected_node
result = common_node_functions.wait_for_not_ready_status(node, timeout, self.mock_kubecli)
self.assertEqual(result, expected_affected_node)
self.mock_kubecli.watch_node_status.assert_called_once_with(node, "False", timeout, None)
def test_wait_for_not_ready_status_with_affected_node(self):
"""
Test wait_for_not_ready_status with provided affected_node
"""
node = "test-node"
timeout = 300
self.mock_kubecli.watch_node_status.return_value = self.mock_affected_node
result = common_node_functions.wait_for_not_ready_status(
node, timeout, self.mock_kubecli, self.mock_affected_node
)
self.assertEqual(result, self.mock_affected_node)
self.mock_kubecli.watch_node_status.assert_called_once_with(
node, "False", timeout, self.mock_affected_node
)
def test_wait_for_unknown_status_without_affected_node(self):
"""
Test wait_for_unknown_status without providing affected_node
"""
node = "test-node"
timeout = 300
expected_affected_node = Mock(spec=AffectedNode)
self.mock_kubecli.watch_node_status.return_value = expected_affected_node
result = common_node_functions.wait_for_unknown_status(node, timeout, self.mock_kubecli)
self.assertEqual(result, expected_affected_node)
self.mock_kubecli.watch_node_status.assert_called_once_with(node, "Unknown", timeout, None)
def test_wait_for_unknown_status_with_affected_node(self):
"""
Test wait_for_unknown_status with provided affected_node
"""
node = "test-node"
timeout = 300
self.mock_kubecli.watch_node_status.return_value = self.mock_affected_node
result = common_node_functions.wait_for_unknown_status(
node, timeout, self.mock_kubecli, self.mock_affected_node
)
self.assertEqual(result, self.mock_affected_node)
self.mock_kubecli.watch_node_status.assert_called_once_with(
node, "Unknown", timeout, self.mock_affected_node
)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.paramiko.SSHClient')
def test_check_service_status_success(self, mock_ssh_client, mock_logging, mock_sleep):
"""
Test check_service_status successfully checks service status
"""
node = "192.168.1.100"
service = ["neutron-server", "nova-compute"]
ssh_private_key = "~/.ssh/id_rsa"
timeout = 60
# Mock SSH client
mock_ssh = Mock()
mock_ssh_client.return_value = mock_ssh
mock_ssh.connect.return_value = None
# Mock exec_command to return active status
mock_stdout = Mock()
mock_stdout.readlines.return_value = ["active\n"]
mock_ssh.exec_command.return_value = (Mock(), mock_stdout, Mock())
common_node_functions.check_service_status(node, service, ssh_private_key, timeout)
# Verify SSH connection was attempted
mock_ssh.connect.assert_called()
# Verify service status was checked for each service
self.assertEqual(mock_ssh.exec_command.call_count, 2)
# Verify SSH connection was closed
mock_ssh.close.assert_called_once()
@patch('time.sleep')
@patch('logging.error')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.paramiko.SSHClient')
def test_check_service_status_service_inactive(self, mock_ssh_client, mock_logging_info, mock_logging_error, mock_sleep):
"""
Test check_service_status logs error when service is inactive
"""
node = "192.168.1.100"
service = ["neutron-server"]
ssh_private_key = "~/.ssh/id_rsa"
timeout = 60
# Mock SSH client
mock_ssh = Mock()
mock_ssh_client.return_value = mock_ssh
mock_ssh.connect.return_value = None
# Mock exec_command to return inactive status
mock_stdout = Mock()
mock_stdout.readlines.return_value = ["inactive\n"]
mock_ssh.exec_command.return_value = (Mock(), mock_stdout, Mock())
common_node_functions.check_service_status(node, service, ssh_private_key, timeout)
# Verify error was logged for inactive service
mock_logging_error.assert_called()
error_call_str = str(mock_logging_error.call_args)
self.assertIn("inactive", error_call_str)
mock_ssh.close.assert_called_once()
@patch('time.sleep')
@patch('logging.error')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.paramiko.SSHClient')
def test_check_service_status_ssh_connection_fails(self, mock_ssh_client, mock_logging_info, mock_logging_error, mock_sleep):
"""
Test check_service_status handles SSH connection failures
"""
node = "192.168.1.100"
service = ["neutron-server"]
ssh_private_key = "~/.ssh/id_rsa"
timeout = 5
# Mock SSH client to raise exception
mock_ssh = Mock()
mock_ssh_client.return_value = mock_ssh
mock_ssh.connect.side_effect = Exception("Connection timeout")
# Mock exec_command for when connection eventually works (or doesn't)
mock_stdout = Mock()
mock_stdout.readlines.return_value = ["active\n"]
mock_ssh.exec_command.return_value = (Mock(), mock_stdout, Mock())
common_node_functions.check_service_status(node, service, ssh_private_key, timeout)
# Verify error was logged for SSH connection failure
mock_logging_error.assert_called()
error_call_str = str(mock_logging_error.call_args)
self.assertIn("Failed to ssh", error_call_str)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.paramiko.SSHClient')
def test_check_service_status_multiple_services(self, mock_ssh_client, mock_logging, mock_sleep):
"""
Test check_service_status with multiple services
"""
node = "192.168.1.100"
service = ["service1", "service2", "service3"]
ssh_private_key = "~/.ssh/id_rsa"
timeout = 60
# Mock SSH client
mock_ssh = Mock()
mock_ssh_client.return_value = mock_ssh
mock_ssh.connect.return_value = None
# Mock exec_command to return active status
mock_stdout = Mock()
mock_stdout.readlines.return_value = ["active\n"]
mock_ssh.exec_command.return_value = (Mock(), mock_stdout, Mock())
common_node_functions.check_service_status(node, service, ssh_private_key, timeout)
# Verify service status was checked for all services
self.assertEqual(mock_ssh.exec_command.call_count, 3)
mock_ssh.close.assert_called_once()
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.paramiko.SSHClient')
def test_check_service_status_retry_logic(self, mock_ssh_client, mock_logging, mock_sleep):
"""
Test check_service_status retry logic on connection failure then success
"""
node = "192.168.1.100"
service = ["neutron-server"]
ssh_private_key = "~/.ssh/id_rsa"
timeout = 10
# Mock SSH client
mock_ssh = Mock()
mock_ssh_client.return_value = mock_ssh
# First two attempts fail, third succeeds
mock_ssh.connect.side_effect = [
Exception("Timeout"),
Exception("Timeout"),
None # Success
]
# Mock exec_command
mock_stdout = Mock()
mock_stdout.readlines.return_value = ["active\n"]
mock_ssh.exec_command.return_value = (Mock(), mock_stdout, Mock())
common_node_functions.check_service_status(node, service, ssh_private_key, timeout)
# Verify multiple connection attempts were made
self.assertGreater(mock_ssh.connect.call_count, 1)
# Verify service was eventually checked
mock_ssh.exec_command.assert_called()
mock_ssh.close.assert_called_once()
class TestCommonNodeFunctionsIntegration(unittest.TestCase):
"""Integration-style tests for common_node_functions"""
def setUp(self):
"""Set up test fixtures"""
self.mock_kubecli = Mock(spec=KrknKubernetes)
@patch('logging.info')
def test_get_node_workflow_with_label_filtering(self, mock_logging):
"""
Test complete workflow of getting nodes with label selector and filtering
"""
label_selector = "node-role.kubernetes.io/worker"
instance_kill_count = 2
available_nodes = ["worker-1", "worker-2", "worker-3", "worker-4", "worker-5"]
self.mock_kubecli.list_killable_nodes.return_value = available_nodes
result = common_node_functions.get_node(label_selector, instance_kill_count, self.mock_kubecli)
self.assertEqual(len(result), 2)
# Verify no duplicates
self.assertEqual(len(result), len(set(result)))
# Verify all nodes are from the available list
self.assertTrue(all(node in available_nodes for node in result))
@patch('logging.info')
def test_get_node_by_name_validation_workflow(self, mock_logging):
"""
Test complete workflow of validating node names
"""
requested_nodes = ["node-a", "node-b"]
killable_nodes = ["node-a", "node-b", "node-c", "node-d"]
self.mock_kubecli.list_killable_nodes.return_value = killable_nodes
result = common_node_functions.get_node_by_name(requested_nodes, self.mock_kubecli)
self.assertEqual(result, requested_nodes)
self.mock_kubecli.list_killable_nodes.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -43,15 +43,14 @@ class TestGCP(unittest.TestCase):
def setUp(self):
"""Set up test fixtures"""
# Mock google.auth before creating GCP instance
self.auth_patcher = patch('krkn.scenario_plugins.node_actions.gcp_node_scenarios.google.auth.default')
self.compute_patcher = patch('krkn.scenario_plugins.node_actions.gcp_node_scenarios.compute_v1.InstancesClient')
self.auth_patcher = patch('google.auth.default')
self.compute_patcher = patch('google.cloud.compute_v1.InstancesClient')
self.mock_auth = self.auth_patcher.start()
self.mock_compute_client = self.compute_patcher.start()
# Configure auth mock to return credentials and project_id
mock_credentials = MagicMock()
self.mock_auth.return_value = (mock_credentials, 'test-project-123')
self.mock_auth.return_value = (MagicMock(), 'test-project-123')
# Create GCP instance with mocked dependencies
self.gcp = GCP()
@@ -68,7 +67,7 @@ class TestGCP(unittest.TestCase):
def test_gcp_init_failure(self):
"""Test GCP class initialization failure"""
with patch('krkn.scenario_plugins.node_actions.gcp_node_scenarios.google.auth.default', side_effect=Exception("Auth error")):
with patch('google.auth.default', side_effect=Exception("Auth error")):
with self.assertRaises(Exception):
GCP()

View File

@@ -1,637 +0,0 @@
#!/usr/bin/env python3
"""
Test suite for IBM Cloud VPC node scenarios
This test suite covers both the IbmCloud class and ibm_node_scenarios class
using mocks to avoid actual IBM Cloud API calls.
IMPORTANT: These tests use comprehensive mocking and do NOT require any cloud provider
settings or credentials. No environment variables need to be set. All API clients and
external dependencies are mocked.
Test Coverage:
- TestIbmCloud: 30 tests for the IbmCloud VPC API client class
- Initialization, SSL configuration, instance operations (start/stop/reboot/delete)
- Status checking, wait operations, error handling
- TestIbmNodeScenarios: 14 tests for node scenario orchestration
- Node start/stop/reboot/terminate scenarios
- Exception handling, multiple kill counts
Usage:
# Run all tests
python -m unittest tests.test_ibmcloud_node_scenarios -v
# Run with coverage
python -m coverage run -a -m unittest tests/test_ibmcloud_node_scenarios.py -v
Assisted By: Claude Code
"""
import unittest
import sys
import json
from unittest.mock import MagicMock, patch, Mock
# Mock paramiko and IBM SDK before importing
sys.modules['paramiko'] = MagicMock()
sys.modules['ibm_vpc'] = MagicMock()
sys.modules['ibm_cloud_sdk_core'] = MagicMock()
sys.modules['ibm_cloud_sdk_core.authenticators'] = MagicMock()
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.k8s import AffectedNode, AffectedNodeStatus
from krkn.scenario_plugins.node_actions.ibmcloud_node_scenarios import (
IbmCloud,
ibm_node_scenarios
)
class TestIbmCloud(unittest.TestCase):
"""Test cases for IbmCloud class"""
def setUp(self):
"""Set up test fixtures"""
# Set up environment variables
self.env_patcher = patch.dict('os.environ', {
'IBMC_APIKEY': 'test-api-key',
'IBMC_URL': 'https://test.cloud.ibm.com'
})
self.env_patcher.start()
# Mock IBM VPC client
self.mock_vpc = MagicMock()
self.vpc_patcher = patch('krkn.scenario_plugins.node_actions.ibmcloud_node_scenarios.VpcV1')
self.mock_vpc_class = self.vpc_patcher.start()
self.mock_vpc_class.return_value = self.mock_vpc
# Mock IAMAuthenticator
self.auth_patcher = patch('krkn.scenario_plugins.node_actions.ibmcloud_node_scenarios.IAMAuthenticator')
self.mock_auth = self.auth_patcher.start()
# Create IbmCloud instance
self.ibm = IbmCloud()
def tearDown(self):
"""Clean up after tests"""
self.env_patcher.stop()
self.vpc_patcher.stop()
self.auth_patcher.stop()
def test_init_success(self):
"""Test IbmCloud class initialization"""
self.assertIsNotNone(self.ibm.service)
self.mock_vpc.set_service_url.assert_called_once_with('https://test.cloud.ibm.com')
def test_init_missing_api_key(self):
"""Test initialization fails when IBMC_APIKEY is missing"""
with patch.dict('os.environ', {
'IBMC_URL': 'https://test.cloud.ibm.com'
}, clear=True):
with self.assertRaises(Exception) as context:
IbmCloud()
self.assertIn("IBMC_APIKEY", str(context.exception))
def test_init_missing_url(self):
"""Test initialization fails when IBMC_URL is missing"""
with patch.dict('os.environ', {
'IBMC_APIKEY': 'test-api-key'
}, clear=True):
with self.assertRaises(Exception) as context:
IbmCloud()
self.assertIn("IBMC_URL", str(context.exception))
def test_configure_ssl_verification_disabled(self):
"""Test disabling SSL verification"""
self.ibm.configure_ssl_verification(True)
self.mock_vpc.set_disable_ssl_verification.assert_called_with(True)
def test_configure_ssl_verification_enabled(self):
"""Test enabling SSL verification"""
self.ibm.configure_ssl_verification(False)
self.mock_vpc.set_disable_ssl_verification.assert_called_with(False)
def test_get_instance_id_success(self):
"""Test getting instance ID by node name"""
mock_list = [
{'vpc_name': 'test-node-1', 'vpc_id': 'vpc-1'},
{'vpc_name': 'test-node-2', 'vpc_id': 'vpc-2'}
]
with patch.object(self.ibm, 'list_instances', return_value=mock_list):
instance_id = self.ibm.get_instance_id('test-node-1')
self.assertEqual(instance_id, 'vpc-1')
def test_get_instance_id_not_found(self):
"""Test getting instance ID when node not found"""
mock_list = [
{'vpc_name': 'test-node-1', 'vpc_id': 'vpc-1'}
]
with patch.object(self.ibm, 'list_instances', return_value=mock_list):
with self.assertRaises(SystemExit):
self.ibm.get_instance_id('non-existent-node')
def test_delete_instance_success(self):
"""Test deleting instance successfully"""
self.mock_vpc.delete_instance.return_value = None
result = self.ibm.delete_instance('vpc-123')
self.mock_vpc.delete_instance.assert_called_once_with('vpc-123')
# Method doesn't explicitly return True, so we just verify no exception
def test_delete_instance_failure(self):
"""Test deleting instance with failure"""
self.mock_vpc.delete_instance.side_effect = Exception("API Error")
result = self.ibm.delete_instance('vpc-123')
self.assertEqual(result, False)
def test_reboot_instances_success(self):
"""Test rebooting instance successfully"""
self.mock_vpc.create_instance_action.return_value = None
result = self.ibm.reboot_instances('vpc-123')
self.assertTrue(result)
self.mock_vpc.create_instance_action.assert_called_once_with(
'vpc-123',
type='reboot'
)
def test_reboot_instances_failure(self):
"""Test rebooting instance with failure"""
self.mock_vpc.create_instance_action.side_effect = Exception("API Error")
result = self.ibm.reboot_instances('vpc-123')
self.assertEqual(result, False)
def test_stop_instances_success(self):
"""Test stopping instance successfully"""
self.mock_vpc.create_instance_action.return_value = None
result = self.ibm.stop_instances('vpc-123')
self.assertTrue(result)
self.mock_vpc.create_instance_action.assert_called_once_with(
'vpc-123',
type='stop'
)
def test_stop_instances_failure(self):
"""Test stopping instance with failure"""
self.mock_vpc.create_instance_action.side_effect = Exception("API Error")
result = self.ibm.stop_instances('vpc-123')
self.assertEqual(result, False)
def test_start_instances_success(self):
"""Test starting instance successfully"""
self.mock_vpc.create_instance_action.return_value = None
result = self.ibm.start_instances('vpc-123')
self.assertTrue(result)
self.mock_vpc.create_instance_action.assert_called_once_with(
'vpc-123',
type='start'
)
def test_start_instances_failure(self):
"""Test starting instance with failure"""
self.mock_vpc.create_instance_action.side_effect = Exception("API Error")
result = self.ibm.start_instances('vpc-123')
self.assertEqual(result, False)
def test_list_instances_success(self):
"""Test listing instances successfully"""
mock_result = Mock()
mock_result.get_result.return_value = {
'instances': [
{'name': 'node-1', 'id': 'vpc-1'},
{'name': 'node-2', 'id': 'vpc-2'}
],
'total_count': 2,
'limit': 50
}
self.mock_vpc.list_instances.return_value = mock_result
instances = self.ibm.list_instances()
self.assertEqual(len(instances), 2)
self.assertEqual(instances[0]['vpc_name'], 'node-1')
self.assertEqual(instances[1]['vpc_name'], 'node-2')
def test_list_instances_with_pagination(self):
"""Test listing instances with pagination"""
# First call returns limit reached
mock_result_1 = Mock()
mock_result_1.get_result.return_value = {
'instances': [
{'name': 'node-1', 'id': 'vpc-1'}
],
'total_count': 1,
'limit': 1
}
# Second call returns remaining
mock_result_2 = Mock()
mock_vpc_2 = type('obj', (object,), {'name': 'node-2', 'id': 'vpc-2'})
mock_result_2.get_result.return_value = {
'instances': [mock_vpc_2],
'total_count': 1,
'limit': 50
}
self.mock_vpc.list_instances.side_effect = [mock_result_1, mock_result_2]
instances = self.ibm.list_instances()
self.assertEqual(len(instances), 2)
self.assertEqual(self.mock_vpc.list_instances.call_count, 2)
def test_list_instances_failure(self):
"""Test listing instances with failure"""
self.mock_vpc.list_instances.side_effect = Exception("API Error")
with self.assertRaises(SystemExit):
self.ibm.list_instances()
def test_find_id_in_list(self):
"""Test finding ID in VPC list"""
vpc_list = [
{'vpc_name': 'vpc-1', 'vpc_id': 'id-1'},
{'vpc_name': 'vpc-2', 'vpc_id': 'id-2'}
]
vpc_id = self.ibm.find_id_in_list('vpc-2', vpc_list)
self.assertEqual(vpc_id, 'id-2')
def test_find_id_in_list_not_found(self):
"""Test finding ID in VPC list when not found"""
vpc_list = [
{'vpc_name': 'vpc-1', 'vpc_id': 'id-1'}
]
vpc_id = self.ibm.find_id_in_list('vpc-3', vpc_list)
self.assertIsNone(vpc_id)
def test_get_instance_status_success(self):
"""Test getting instance status successfully"""
mock_result = Mock()
mock_result.get_result.return_value = {'status': 'running'}
self.mock_vpc.get_instance.return_value = mock_result
status = self.ibm.get_instance_status('vpc-123')
self.assertEqual(status, 'running')
def test_get_instance_status_failure(self):
"""Test getting instance status with failure"""
self.mock_vpc.get_instance.side_effect = Exception("API Error")
status = self.ibm.get_instance_status('vpc-123')
self.assertIsNone(status)
def test_wait_until_deleted_success(self):
"""Test waiting until instance is deleted"""
# First call returns status, second returns None (deleted)
with patch.object(self.ibm, 'get_instance_status', side_effect=['deleting', None]):
affected_node = MagicMock(spec=AffectedNode)
with patch('time.time', side_effect=[100, 105]), \
patch('time.sleep'):
result = self.ibm.wait_until_deleted('vpc-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
affected_node.set_affected_node_status.assert_called_once_with("terminated", 5)
def test_wait_until_deleted_timeout(self):
"""Test waiting until deleted with timeout"""
with patch.object(self.ibm, 'get_instance_status', return_value='deleting'):
with patch('time.sleep'):
result = self.ibm.wait_until_deleted('vpc-123', timeout=5)
self.assertFalse(result)
def test_wait_until_running_success(self):
"""Test waiting until instance is running"""
with patch.object(self.ibm, 'get_instance_status', side_effect=['starting', 'running']):
affected_node = MagicMock(spec=AffectedNode)
with patch('time.time', side_effect=[100, 105]), \
patch('time.sleep'):
result = self.ibm.wait_until_running('vpc-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
affected_node.set_affected_node_status.assert_called_once_with("running", 5)
def test_wait_until_running_timeout(self):
"""Test waiting until running with timeout"""
with patch.object(self.ibm, 'get_instance_status', return_value='starting'):
with patch('time.sleep'):
result = self.ibm.wait_until_running('vpc-123', timeout=5)
self.assertFalse(result)
def test_wait_until_stopped_success(self):
"""Test waiting until instance is stopped"""
with patch.object(self.ibm, 'get_instance_status', side_effect=['stopping', 'stopped']):
affected_node = MagicMock(spec=AffectedNode)
with patch('time.time', side_effect=[100, 105]), \
patch('time.sleep'):
result = self.ibm.wait_until_stopped('vpc-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
affected_node.set_affected_node_status.assert_called_once_with("stopped", 5)
def test_wait_until_stopped_timeout(self):
"""Test waiting until stopped with timeout"""
with patch.object(self.ibm, 'get_instance_status', return_value='stopping'):
with patch('time.sleep'):
result = self.ibm.wait_until_stopped('vpc-123', timeout=5, affected_node=None)
self.assertFalse(result)
def test_wait_until_rebooted_success(self):
"""Test waiting until instance is rebooted"""
# First call checks reboot status (not 'starting'), second call in wait_until_running checks status
with patch.object(self.ibm, 'get_instance_status', side_effect=['running', 'running']):
affected_node = MagicMock(spec=AffectedNode)
time_values = [100, 105, 110]
with patch('time.time', side_effect=time_values), \
patch('time.sleep'):
result = self.ibm.wait_until_rebooted('vpc-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
def test_wait_until_rebooted_timeout(self):
"""Test waiting until rebooted with timeout"""
with patch.object(self.ibm, 'get_instance_status', return_value='starting'):
with patch('time.sleep'):
result = self.ibm.wait_until_rebooted('vpc-123', timeout=5, affected_node=None)
self.assertFalse(result)
class TestIbmNodeScenarios(unittest.TestCase):
"""Test cases for ibm_node_scenarios class"""
def setUp(self):
"""Set up test fixtures"""
# Mock KrknKubernetes
self.mock_kubecli = MagicMock(spec=KrknKubernetes)
self.affected_nodes_status = AffectedNodeStatus()
# Mock the IbmCloud class entirely to avoid any real API calls
self.ibm_cloud_patcher = patch('krkn.scenario_plugins.node_actions.ibmcloud_node_scenarios.IbmCloud')
self.mock_ibm_cloud_class = self.ibm_cloud_patcher.start()
# Create a mock instance that will be returned when IbmCloud() is called
self.mock_ibm_cloud_instance = MagicMock()
self.mock_ibm_cloud_class.return_value = self.mock_ibm_cloud_instance
# Create ibm_node_scenarios instance
self.scenario = ibm_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=True,
affected_nodes_status=self.affected_nodes_status,
disable_ssl_verification=False
)
def tearDown(self):
"""Clean up after tests"""
self.ibm_cloud_patcher.stop()
def test_init(self):
"""Test ibm_node_scenarios initialization"""
self.assertIsNotNone(self.scenario.ibmcloud)
self.assertTrue(self.scenario.node_action_kube_check)
self.assertEqual(self.scenario.kubecli, self.mock_kubecli)
def test_init_with_ssl_disabled(self):
"""Test initialization with SSL verification disabled"""
scenario = ibm_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=True,
affected_nodes_status=self.affected_nodes_status,
disable_ssl_verification=True
)
# Verify configure_ssl_verification was called
self.mock_ibm_cloud_instance.configure_ssl_verification.assert_called_with(True)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_start_scenario_success(self, mock_wait_ready):
"""Test node start scenario successfully"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.start_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_running.return_value = True
self.scenario.node_start_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
self.assertEqual(self.affected_nodes_status.affected_nodes[0].node_name, 'test-node')
mock_wait_ready.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_start_scenario_no_kube_check(self, mock_wait_ready):
"""Test node start scenario without Kubernetes check"""
self.scenario.node_action_kube_check = False
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.start_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_running.return_value = True
self.scenario.node_start_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
mock_wait_ready.assert_not_called()
def test_node_stop_scenario_success(self):
"""Test node stop scenario successfully"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.stop_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_stopped.return_value = True
self.scenario.node_stop_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
def test_node_stop_scenario_failure(self):
"""Test node stop scenario with stop command failure"""
# Configure mock - get_instance_id succeeds but stop_instances fails
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.stop_instances.return_value = False
# Code raises exception inside try/except, so it should be caught and logged
self.scenario.node_stop_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
# Verify that affected nodes were not appended since exception was caught
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 0)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_unknown_status')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_reboot_scenario_success(self, mock_wait_ready, mock_wait_unknown):
"""Test node reboot scenario successfully"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.reboot_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_rebooted.return_value = True
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
soft_reboot=False
)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
mock_wait_unknown.assert_called_once()
mock_wait_ready.assert_called_once()
def test_node_reboot_scenario_failure(self):
"""Test node reboot scenario with reboot command failure"""
# Configure mock - get_instance_id succeeds but reboot_instances fails
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.reboot_instances.return_value = False
# Code raises exception inside try/except, so it should be caught and logged
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
soft_reboot=False
)
# Verify that affected nodes were not appended since exception was caught
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 0)
def test_node_terminate_scenario_success(self):
"""Test node terminate scenario successfully"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.delete_instance.return_value = None
self.mock_ibm_cloud_instance.wait_until_deleted.return_value = True
self.scenario.node_terminate_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
def test_node_scenario_multiple_kill_count(self):
"""Test node scenario with multiple kill count"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.stop_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_stopped.return_value = True
self.scenario.node_stop_scenario(
instance_kill_count=2,
node='test-node',
timeout=60,
poll_interval=5
)
# Should have 2 affected nodes for 2 iterations
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 2)
def test_node_start_scenario_exception(self):
"""Test node start scenario with exception during operation"""
# Configure mock - get_instance_id succeeds but start_instances fails
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.start_instances.side_effect = Exception("API Error")
# Should handle exception gracefully
self.scenario.node_start_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
# Verify affected node still added even on failure
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
def test_node_stop_scenario_exception(self):
"""Test node stop scenario with exception"""
# Configure mock to raise SystemExit
self.mock_ibm_cloud_instance.get_instance_id.side_effect = SystemExit(1)
# Should handle system exit gracefully
with self.assertRaises(SystemExit):
self.scenario.node_stop_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
def test_node_reboot_scenario_exception(self):
"""Test node reboot scenario with exception during operation"""
# Configure mock - get_instance_id succeeds but reboot_instances fails
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.reboot_instances.side_effect = Exception("API Error")
# Should handle exception gracefully
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
soft_reboot=False
)
def test_node_terminate_scenario_exception(self):
"""Test node terminate scenario with exception"""
# Configure mock - get_instance_id succeeds but delete_instance fails
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'vpc-123'
self.mock_ibm_cloud_instance.delete_instance.side_effect = Exception("API Error")
# Should handle exception gracefully
self.scenario.node_terminate_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
if __name__ == '__main__':
unittest.main()

View File

@@ -1,673 +0,0 @@
#!/usr/bin/env python3
"""
Test suite for IBM Cloud Power node scenarios
This test suite covers both the IbmCloudPower class and ibmcloud_power_node_scenarios class
using mocks to avoid actual IBM Cloud API calls.
IMPORTANT: These tests use comprehensive mocking and do NOT require any cloud provider
settings or credentials. No environment variables need to be set. All API clients and
external dependencies are mocked.
Test Coverage:
- TestIbmCloudPower: 31 tests for the IbmCloudPower API client class
- Authentication, instance operations (start/stop/reboot/delete)
- Status checking, wait operations, error handling
- TestIbmCloudPowerNodeScenarios: 10 tests for node scenario orchestration
- Node start/stop/reboot/terminate scenarios
- Exception handling, multiple kill counts
Usage:
# Run all tests
python -m unittest tests.test_ibmcloud_power_node_scenarios -v
# Run with coverage
python -m coverage run -a -m unittest tests/test_ibmcloud_power_node_scenarios.py -v
Assisted By: Claude Code
"""
import unittest
import sys
import json
from unittest.mock import MagicMock, patch, Mock
# Mock paramiko before importing
sys.modules['paramiko'] = MagicMock()
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.k8s import AffectedNode, AffectedNodeStatus
from krkn.scenario_plugins.node_actions.ibmcloud_power_node_scenarios import (
IbmCloudPower,
ibmcloud_power_node_scenarios
)
class TestIbmCloudPower(unittest.TestCase):
"""Test cases for IbmCloudPower class"""
def setUp(self):
"""Set up test fixtures"""
# Set up environment variables
self.env_patcher = patch.dict('os.environ', {
'IBMC_APIKEY': 'test-api-key',
'IBMC_POWER_URL': 'https://test.cloud.ibm.com',
'IBMC_POWER_CRN': 'crn:v1:bluemix:public:power-iaas:us-south:a/abc123:instance-id::'
})
self.env_patcher.start()
# Mock requests
self.requests_patcher = patch('krkn.scenario_plugins.node_actions.ibmcloud_power_node_scenarios.requests')
self.mock_requests = self.requests_patcher.start()
# Mock authentication response
mock_auth_response = Mock()
mock_auth_response.status_code = 200
mock_auth_response.json.return_value = {
'access_token': 'test-token',
'token_type': 'Bearer',
'expires_in': 3600
}
self.mock_requests.request.return_value = mock_auth_response
# Create IbmCloudPower instance
self.ibm = IbmCloudPower()
def tearDown(self):
"""Clean up after tests"""
self.env_patcher.stop()
self.requests_patcher.stop()
def test_init_success(self):
"""Test IbmCloudPower class initialization"""
self.assertIsNotNone(self.ibm.api_key)
self.assertEqual(self.ibm.api_key, 'test-api-key')
self.assertIsNotNone(self.ibm.service_url)
self.assertEqual(self.ibm.service_url, 'https://test.cloud.ibm.com')
self.assertIsNotNone(self.ibm.CRN)
self.assertEqual(self.ibm.cloud_instance_id, 'instance-id')
self.assertIsNotNone(self.ibm.token)
self.assertIsNotNone(self.ibm.headers)
def test_init_missing_api_key(self):
"""Test initialization fails when IBMC_APIKEY is missing"""
with patch.dict('os.environ', {
'IBMC_POWER_URL': 'https://test.cloud.ibm.com',
'IBMC_POWER_CRN': 'crn:v1:bluemix:public:power-iaas:us-south:a/abc123:instance-id::'
}, clear=True):
with self.assertRaises(Exception) as context:
IbmCloudPower()
self.assertIn("IBMC_APIKEY", str(context.exception))
def test_init_missing_power_url(self):
"""Test initialization fails when IBMC_POWER_URL is missing"""
with patch.dict('os.environ', {
'IBMC_APIKEY': 'test-api-key',
'IBMC_POWER_CRN': 'crn:v1:bluemix:public:power-iaas:us-south:a/abc123:instance-id::'
}, clear=True):
with self.assertRaises(Exception) as context:
IbmCloudPower()
self.assertIn("IBMC_POWER_URL", str(context.exception))
def test_init_missing_crn(self):
"""Test initialization fails when IBMC_POWER_CRN is missing"""
with patch.dict('os.environ', {
'IBMC_APIKEY': 'test-api-key',
'IBMC_POWER_URL': 'https://test.cloud.ibm.com'
}, clear=True):
# The code will fail on split() before the IBMC_POWER_CRN check
# so we check for either AttributeError or the exception message
with self.assertRaises((Exception, AttributeError)):
IbmCloudPower()
def test_authenticate_success(self):
"""Test successful authentication"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'access_token': 'new-test-token',
'token_type': 'Bearer',
'expires_in': 3600
}
self.mock_requests.request.return_value = mock_response
self.ibm.authenticate()
self.assertEqual(self.ibm.token['access_token'], 'new-test-token')
self.assertIn('Authorization', self.ibm.headers)
self.assertEqual(self.ibm.headers['Authorization'], 'Bearer new-test-token')
def test_authenticate_failure(self):
"""Test authentication failure"""
mock_response = Mock()
mock_response.status_code = 401
mock_response.raise_for_status.side_effect = Exception("Unauthorized")
self.mock_requests.request.return_value = mock_response
with self.assertRaises(Exception):
self.ibm.authenticate()
def test_get_instance_id_success(self):
"""Test getting instance ID by node name"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'pvmInstances': [
{'serverName': 'test-node-1', 'pvmInstanceID': 'pvm-1'},
{'serverName': 'test-node-2', 'pvmInstanceID': 'pvm-2'}
]
}
self.mock_requests.request.return_value = mock_response
instance_id = self.ibm.get_instance_id('test-node-1')
self.assertEqual(instance_id, 'pvm-1')
def test_get_instance_id_not_found(self):
"""Test getting instance ID when node not found"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'pvmInstances': [
{'serverName': 'test-node-1', 'pvmInstanceID': 'pvm-1'}
]
}
self.mock_requests.request.return_value = mock_response
with self.assertRaises(SystemExit):
self.ibm.get_instance_id('non-existent-node')
def test_delete_instance_success(self):
"""Test deleting instance successfully"""
mock_response = Mock()
mock_response.status_code = 200
self.mock_requests.request.return_value = mock_response
result = self.ibm.delete_instance('pvm-123')
self.mock_requests.request.assert_called()
call_args = self.mock_requests.request.call_args
self.assertIn('immediate-shutdown', call_args[1]['data'])
def test_delete_instance_failure(self):
"""Test deleting instance with failure"""
self.mock_requests.request.side_effect = Exception("API Error")
result = self.ibm.delete_instance('pvm-123')
self.assertEqual(result, False)
def test_reboot_instances_hard_reboot(self):
"""Test hard reboot of instance"""
mock_response = Mock()
mock_response.status_code = 200
self.mock_requests.request.return_value = mock_response
result = self.ibm.reboot_instances('pvm-123', soft=False)
self.assertTrue(result)
call_args = self.mock_requests.request.call_args
self.assertIn('hard-reboot', call_args[1]['data'])
def test_reboot_instances_soft_reboot(self):
"""Test soft reboot of instance"""
mock_response = Mock()
mock_response.status_code = 200
self.mock_requests.request.return_value = mock_response
result = self.ibm.reboot_instances('pvm-123', soft=True)
self.assertTrue(result)
call_args = self.mock_requests.request.call_args
self.assertIn('soft-reboot', call_args[1]['data'])
def test_reboot_instances_failure(self):
"""Test reboot instance with failure"""
self.mock_requests.request.side_effect = Exception("API Error")
result = self.ibm.reboot_instances('pvm-123')
self.assertEqual(result, False)
def test_stop_instances_success(self):
"""Test stopping instance successfully"""
mock_response = Mock()
mock_response.status_code = 200
self.mock_requests.request.return_value = mock_response
result = self.ibm.stop_instances('pvm-123')
self.assertTrue(result)
call_args = self.mock_requests.request.call_args
self.assertIn('stop', call_args[1]['data'])
def test_stop_instances_failure(self):
"""Test stopping instance with failure"""
self.mock_requests.request.side_effect = Exception("API Error")
result = self.ibm.stop_instances('pvm-123')
self.assertEqual(result, False)
def test_start_instances_success(self):
"""Test starting instance successfully"""
mock_response = Mock()
mock_response.status_code = 200
self.mock_requests.request.return_value = mock_response
result = self.ibm.start_instances('pvm-123')
self.assertTrue(result)
call_args = self.mock_requests.request.call_args
self.assertIn('start', call_args[1]['data'])
def test_start_instances_failure(self):
"""Test starting instance with failure"""
self.mock_requests.request.side_effect = Exception("API Error")
result = self.ibm.start_instances('pvm-123')
self.assertEqual(result, False)
def test_list_instances_success(self):
"""Test listing instances successfully"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'pvmInstances': [
type('obj', (object,), {'serverName': 'node-1', 'pvmInstanceID': 'pvm-1'}),
type('obj', (object,), {'serverName': 'node-2', 'pvmInstanceID': 'pvm-2'})
]
}
self.mock_requests.request.return_value = mock_response
instances = self.ibm.list_instances()
self.assertEqual(len(instances), 2)
self.assertEqual(instances[0]['serverName'], 'node-1')
self.assertEqual(instances[1]['serverName'], 'node-2')
def test_list_instances_failure(self):
"""Test listing instances with failure"""
self.mock_requests.request.side_effect = Exception("API Error")
with self.assertRaises(SystemExit):
self.ibm.list_instances()
def test_get_instance_status_success(self):
"""Test getting instance status successfully"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'ACTIVE'}
self.mock_requests.request.return_value = mock_response
status = self.ibm.get_instance_status('pvm-123')
self.assertEqual(status, 'ACTIVE')
def test_get_instance_status_failure(self):
"""Test getting instance status with failure"""
self.mock_requests.request.side_effect = Exception("API Error")
status = self.ibm.get_instance_status('pvm-123')
self.assertIsNone(status)
def test_wait_until_deleted_success(self):
"""Test waiting until instance is deleted"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': None}
self.mock_requests.request.side_effect = [
mock_response,
Exception("Not found")
]
affected_node = MagicMock(spec=AffectedNode)
with patch('time.time', side_effect=[100, 105]), \
patch('time.sleep'):
result = self.ibm.wait_until_deleted('pvm-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
affected_node.set_affected_node_status.assert_called_once()
def test_wait_until_deleted_timeout(self):
"""Test waiting until deleted with timeout"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'DELETING'}
self.mock_requests.request.return_value = mock_response
with patch('time.sleep'):
result = self.ibm.wait_until_deleted('pvm-123', timeout=5)
self.assertFalse(result)
def test_wait_until_running_success(self):
"""Test waiting until instance is running"""
mock_responses = [
Mock(status_code=200, json=lambda: {'status': 'BUILD'}),
Mock(status_code=200, json=lambda: {'status': 'ACTIVE'})
]
self.mock_requests.request.side_effect = mock_responses
affected_node = MagicMock(spec=AffectedNode)
with patch('time.time', side_effect=[100, 105]), \
patch('time.sleep'):
result = self.ibm.wait_until_running('pvm-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
affected_node.set_affected_node_status.assert_called_once_with("running", 5)
def test_wait_until_running_timeout(self):
"""Test waiting until running with timeout"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'BUILD'}
self.mock_requests.request.return_value = mock_response
with patch('time.sleep'):
result = self.ibm.wait_until_running('pvm-123', timeout=5)
self.assertFalse(result)
def test_wait_until_stopped_success(self):
"""Test waiting until instance is stopped"""
mock_responses = [
Mock(status_code=200, json=lambda: {'status': 'STOPPING'}),
Mock(status_code=200, json=lambda: {'status': 'STOPPED'})
]
self.mock_requests.request.side_effect = mock_responses
affected_node = MagicMock(spec=AffectedNode)
with patch('time.time', side_effect=[100, 105]), \
patch('time.sleep'):
result = self.ibm.wait_until_stopped('pvm-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
affected_node.set_affected_node_status.assert_called_once_with("stopped", 5)
def test_wait_until_stopped_timeout(self):
"""Test waiting until stopped with timeout"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'STOPPING'}
self.mock_requests.request.return_value = mock_response
with patch('time.sleep'):
result = self.ibm.wait_until_stopped('pvm-123', timeout=5, affected_node=None)
self.assertFalse(result)
def test_wait_until_rebooted_success(self):
"""Test waiting until instance is rebooted"""
# wait_until_rebooted calls get_instance_status until NOT in reboot state,
# then calls wait_until_running which also calls get_instance_status
mock_responses = [
Mock(status_code=200, json=lambda: {'status': 'HARD_REBOOT'}), # First check - still rebooting
Mock(status_code=200, json=lambda: {'status': 'ACTIVE'}), # Second check - done rebooting
Mock(status_code=200, json=lambda: {'status': 'ACTIVE'}) # wait_until_running check
]
self.mock_requests.request.side_effect = mock_responses
affected_node = MagicMock(spec=AffectedNode)
# Mock all time() calls - need many values because logging uses time.time() extensively
time_values = [100] * 20 # Just provide enough time values
with patch('time.time', side_effect=time_values), \
patch('time.sleep'):
result = self.ibm.wait_until_rebooted('pvm-123', timeout=60, affected_node=affected_node)
self.assertTrue(result)
def test_wait_until_rebooted_timeout(self):
"""Test waiting until rebooted with timeout"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'HARD_REBOOT'}
self.mock_requests.request.return_value = mock_response
with patch('time.sleep'):
result = self.ibm.wait_until_rebooted('pvm-123', timeout=5, affected_node=None)
self.assertFalse(result)
def test_find_id_in_list(self):
"""Test finding ID in VPC list"""
vpc_list = [
{'vpc_name': 'vpc-1', 'vpc_id': 'id-1'},
{'vpc_name': 'vpc-2', 'vpc_id': 'id-2'}
]
vpc_id = self.ibm.find_id_in_list('vpc-2', vpc_list)
self.assertEqual(vpc_id, 'id-2')
def test_find_id_in_list_not_found(self):
"""Test finding ID in VPC list when not found"""
vpc_list = [
{'vpc_name': 'vpc-1', 'vpc_id': 'id-1'}
]
vpc_id = self.ibm.find_id_in_list('vpc-3', vpc_list)
self.assertIsNone(vpc_id)
class TestIbmCloudPowerNodeScenarios(unittest.TestCase):
"""Test cases for ibmcloud_power_node_scenarios class"""
def setUp(self):
"""Set up test fixtures"""
# Mock KrknKubernetes
self.mock_kubecli = MagicMock(spec=KrknKubernetes)
self.affected_nodes_status = AffectedNodeStatus()
# Mock the IbmCloudPower class entirely to avoid any real API calls
self.ibm_cloud_patcher = patch('krkn.scenario_plugins.node_actions.ibmcloud_power_node_scenarios.IbmCloudPower')
self.mock_ibm_cloud_class = self.ibm_cloud_patcher.start()
# Create a mock instance that will be returned when IbmCloudPower() is called
self.mock_ibm_cloud_instance = MagicMock()
self.mock_ibm_cloud_class.return_value = self.mock_ibm_cloud_instance
# Create ibmcloud_power_node_scenarios instance
self.scenario = ibmcloud_power_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=True,
affected_nodes_status=self.affected_nodes_status,
disable_ssl_verification=False
)
def tearDown(self):
"""Clean up after tests"""
self.ibm_cloud_patcher.stop()
def test_init(self):
"""Test ibmcloud_power_node_scenarios initialization"""
self.assertIsNotNone(self.scenario.ibmcloud_power)
self.assertTrue(self.scenario.node_action_kube_check)
self.assertEqual(self.scenario.kubecli, self.mock_kubecli)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_start_scenario_success(self, mock_wait_ready):
"""Test node start scenario successfully"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.start_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_running.return_value = True
self.scenario.node_start_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
self.assertEqual(self.affected_nodes_status.affected_nodes[0].node_name, 'test-node')
mock_wait_ready.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_start_scenario_no_kube_check(self, mock_wait_ready):
"""Test node start scenario without Kubernetes check"""
self.scenario.node_action_kube_check = False
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.start_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_running.return_value = True
self.scenario.node_start_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
mock_wait_ready.assert_not_called()
def test_node_stop_scenario_success(self):
"""Test node stop scenario successfully"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.stop_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_stopped.return_value = True
self.scenario.node_stop_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
# Verify methods were called
self.mock_ibm_cloud_instance.get_instance_id.assert_called_once_with('test-node')
self.mock_ibm_cloud_instance.stop_instances.assert_called_once_with('pvm-123')
# Note: affected_nodes are not appended in stop scenario based on the code
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_unknown_status')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_reboot_scenario_hard_reboot(self, mock_wait_ready, mock_wait_unknown):
"""Test node hard reboot scenario"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.reboot_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_rebooted.return_value = True
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
soft_reboot=False
)
# Verify methods were called
self.mock_ibm_cloud_instance.reboot_instances.assert_called_once_with('pvm-123', False)
mock_wait_unknown.assert_called_once()
mock_wait_ready.assert_called_once()
# Note: affected_nodes are not appended in reboot scenario based on the code
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_unknown_status')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_reboot_scenario_soft_reboot(self, mock_wait_ready, mock_wait_unknown):
"""Test node soft reboot scenario"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.reboot_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_rebooted.return_value = True
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
soft_reboot=True
)
# Verify methods were called
self.mock_ibm_cloud_instance.reboot_instances.assert_called_once_with('pvm-123', True)
mock_wait_unknown.assert_called_once()
mock_wait_ready.assert_called_once()
# Note: affected_nodes are not appended in reboot scenario based on the code
def test_node_terminate_scenario_success(self):
"""Test node terminate scenario successfully"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.delete_instance.return_value = None
self.mock_ibm_cloud_instance.wait_until_deleted.return_value = True
self.scenario.node_terminate_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
# Verify methods were called
self.mock_ibm_cloud_instance.delete_instance.assert_called_once_with('pvm-123')
self.mock_ibm_cloud_instance.wait_until_deleted.assert_called_once()
# Note: affected_nodes are not appended in terminate scenario based on the code
def test_node_scenario_multiple_kill_count(self):
"""Test node scenario with multiple kill count"""
# Configure mock methods
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.stop_instances.return_value = True
self.mock_ibm_cloud_instance.wait_until_stopped.return_value = True
self.scenario.node_stop_scenario(
instance_kill_count=2,
node='test-node',
timeout=60,
poll_interval=5
)
# Verify stop was called twice (kill_count=2)
self.assertEqual(self.mock_ibm_cloud_instance.stop_instances.call_count, 2)
# Note: affected_nodes are not appended in stop scenario based on the code
def test_node_start_scenario_exception(self):
"""Test node start scenario with exception during operation"""
# Configure mock - get_instance_id succeeds but start_instances fails
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.start_instances.side_effect = Exception("API Error")
# Should handle exception gracefully
self.scenario.node_start_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
poll_interval=5
)
# Verify affected node still added even on failure
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
def test_node_reboot_scenario_exception(self):
"""Test node reboot scenario with exception during operation"""
# Configure mock - get_instance_id succeeds but reboot_instances fails
self.mock_ibm_cloud_instance.get_instance_id.return_value = 'pvm-123'
self.mock_ibm_cloud_instance.reboot_instances.side_effect = Exception("API Error")
# Should handle exception gracefully
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node='test-node',
timeout=60,
soft_reboot=False
)
if __name__ == '__main__':
unittest.main()

View File

@@ -1,726 +0,0 @@
import unittest
from unittest.mock import Mock, patch
from arcaflow_plugin_sdk import plugin
from krkn.scenario_plugins.native.network import ingress_shaping
class NetworkScenariosTest(unittest.TestCase):
def test_serialization(self):
"""Test serialization of configuration and output objects"""
plugin.test_object_serialization(
ingress_shaping.NetworkScenarioConfig(
node_interface_name={"foo": ["bar"]},
network_params={
"latency": "50ms",
"loss": "0.02",
"bandwidth": "100mbit",
},
),
self.fail,
)
plugin.test_object_serialization(
ingress_shaping.NetworkScenarioSuccessOutput(
filter_direction="ingress",
test_interfaces={"foo": ["bar"]},
network_parameters={
"latency": "50ms",
"loss": "0.02",
"bandwidth": "100mbit",
},
execution_type="parallel",
),
self.fail,
)
plugin.test_object_serialization(
ingress_shaping.NetworkScenarioErrorOutput(
error="Hello World",
),
self.fail,
)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_get_default_interface(self, mock_kube_helper):
"""Test getting default interface from a node"""
# Setup mocks
mock_cli = Mock()
mock_pod_template = Mock()
mock_pod_template.render.return_value = "pod_yaml_content"
mock_kube_helper.create_pod.return_value = None
mock_kube_helper.exec_cmd_in_pod.return_value = (
"default via 192.168.1.1 dev eth0 proto dhcp metric 100\n"
"172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1"
)
mock_kube_helper.delete_pod.return_value = None
# Test
result = ingress_shaping.get_default_interface(
node="test-node",
pod_template=mock_pod_template,
cli=mock_cli,
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
self.assertEqual(result, ["eth0"])
mock_kube_helper.create_pod.assert_called_once()
mock_kube_helper.exec_cmd_in_pod.assert_called_once()
mock_kube_helper.delete_pod.assert_called_once()
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_verify_interface_with_empty_list(self, mock_kube_helper):
"""Test verifying interface when input list is empty"""
# Setup mocks
mock_cli = Mock()
mock_pod_template = Mock()
mock_pod_template.render.return_value = "pod_yaml_content"
mock_kube_helper.create_pod.return_value = None
mock_kube_helper.exec_cmd_in_pod.return_value = (
"default via 192.168.1.1 dev eth0 proto dhcp metric 100\n"
)
mock_kube_helper.delete_pod.return_value = None
# Test
result = ingress_shaping.verify_interface(
input_interface_list=[],
node="test-node",
pod_template=mock_pod_template,
cli=mock_cli,
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
self.assertEqual(result, ["eth0"])
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_verify_interface_with_valid_interfaces(self, mock_kube_helper):
"""Test verifying interface with valid interface list"""
# Setup mocks
mock_cli = Mock()
mock_pod_template = Mock()
mock_pod_template.render.return_value = "pod_yaml_content"
mock_kube_helper.create_pod.return_value = None
mock_kube_helper.exec_cmd_in_pod.return_value = (
"eth0 UP 192.168.1.10/24\n"
"eth1 UP 10.0.0.5/24\n"
"lo UNKNOWN 127.0.0.1/8\n"
)
mock_kube_helper.delete_pod.return_value = None
# Test
result = ingress_shaping.verify_interface(
input_interface_list=["eth0", "eth1"],
node="test-node",
pod_template=mock_pod_template,
cli=mock_cli,
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
self.assertEqual(result, ["eth0", "eth1"])
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_verify_interface_with_invalid_interface(self, mock_kube_helper):
"""Test verifying interface with an interface that doesn't exist"""
# Setup mocks
mock_cli = Mock()
mock_pod_template = Mock()
mock_pod_template.render.return_value = "pod_yaml_content"
mock_kube_helper.create_pod.return_value = None
mock_kube_helper.exec_cmd_in_pod.return_value = (
"eth0 UP 192.168.1.10/24\n"
"lo UNKNOWN 127.0.0.1/8\n"
)
mock_kube_helper.delete_pod.return_value = None
# Test - should raise exception
with self.assertRaises(Exception) as context:
ingress_shaping.verify_interface(
input_interface_list=["eth0", "eth99"],
node="test-node",
pod_template=mock_pod_template,
cli=mock_cli,
image="quay.io/krkn-chaos/krkn:tools"
)
self.assertIn("Interface eth99 not found", str(context.exception))
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_default_interface')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_get_node_interfaces_with_label_selector(self, mock_kube_helper, mock_get_default_interface):
"""Test getting node interfaces using label selector"""
# Setup mocks
mock_cli = Mock()
mock_pod_template = Mock()
mock_kube_helper.get_node.return_value = ["node1", "node2"]
mock_get_default_interface.return_value = ["eth0"]
# Test
result = ingress_shaping.get_node_interfaces(
node_interface_dict=None,
label_selector="node-role.kubernetes.io/worker",
instance_count=2,
pod_template=mock_pod_template,
cli=mock_cli,
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
self.assertEqual(result, {"node1": ["eth0"], "node2": ["eth0"]})
self.assertEqual(mock_get_default_interface.call_count, 2)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.verify_interface')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_get_node_interfaces_with_node_dict(self, mock_kube_helper, mock_verify_interface):
"""Test getting node interfaces with provided node interface dictionary"""
# Setup mocks
mock_cli = Mock()
mock_pod_template = Mock()
mock_kube_helper.get_node.return_value = ["node1"]
mock_verify_interface.return_value = ["eth0", "eth1"]
# Test
result = ingress_shaping.get_node_interfaces(
node_interface_dict={"node1": ["eth0", "eth1"]},
label_selector=None,
instance_count=1,
pod_template=mock_pod_template,
cli=mock_cli,
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
self.assertEqual(result, {"node1": ["eth0", "eth1"]})
mock_verify_interface.assert_called_once()
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_get_node_interfaces_no_selector_no_dict(self, mock_kube_helper):
"""Test that exception is raised when both node dict and label selector are missing"""
mock_cli = Mock()
mock_pod_template = Mock()
with self.assertRaises(Exception) as context:
ingress_shaping.get_node_interfaces(
node_interface_dict=None,
label_selector=None,
instance_count=1,
pod_template=mock_pod_template,
cli=mock_cli,
image="quay.io/krkn-chaos/krkn:tools"
)
self.assertIn("label selector must be provided", str(context.exception))
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_create_ifb(self, mock_kube_helper):
"""Test creating virtual interfaces"""
mock_cli = Mock()
mock_kube_helper.exec_cmd_in_pod.return_value = None
# Test
ingress_shaping.create_ifb(cli=mock_cli, number=2, pod_name="test-pod")
# Assertions
# Should call modprobe once and ip link set for each interface
self.assertEqual(mock_kube_helper.exec_cmd_in_pod.call_count, 3)
# Verify modprobe call
first_call = mock_kube_helper.exec_cmd_in_pod.call_args_list[0]
self.assertIn("modprobe", first_call[0][1])
self.assertIn("numifbs=2", first_call[0][1])
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_delete_ifb(self, mock_kube_helper):
"""Test deleting virtual interfaces"""
mock_cli = Mock()
mock_kube_helper.exec_cmd_in_pod.return_value = None
# Test
ingress_shaping.delete_ifb(cli=mock_cli, pod_name="test-pod")
# Assertions
mock_kube_helper.exec_cmd_in_pod.assert_called_once()
call_args = mock_kube_helper.exec_cmd_in_pod.call_args[0][1]
self.assertIn("modprobe", call_args)
self.assertIn("-r", call_args)
self.assertIn("ifb", call_args)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_get_job_pods(self, mock_kube_helper):
"""Test getting pods associated with a job"""
mock_cli = Mock()
mock_api_response = Mock()
mock_api_response.metadata.labels = {"controller-uid": "test-uid-123"}
mock_kube_helper.list_pods.return_value = ["pod1", "pod2"]
# Test
result = ingress_shaping.get_job_pods(cli=mock_cli, api_response=mock_api_response)
# Assertions
self.assertEqual(result, "pod1")
mock_kube_helper.list_pods.assert_called_once_with(
mock_cli,
label_selector="controller-uid=test-uid-123",
namespace="default"
)
@patch('time.sleep', return_value=None)
@patch('time.time')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_wait_for_job_success(self, mock_kube_helper, mock_time, mock_sleep):
"""Test waiting for jobs to complete successfully"""
mock_batch_cli = Mock()
mock_time.side_effect = [0, 10, 20] # Simulate time progression
# First job succeeds
mock_response1 = Mock()
mock_response1.status.succeeded = 1
mock_response1.status.failed = None
# Second job succeeds
mock_response2 = Mock()
mock_response2.status.succeeded = 1
mock_response2.status.failed = None
mock_kube_helper.get_job_status.side_effect = [mock_response1, mock_response2]
# Test
ingress_shaping.wait_for_job(
batch_cli=mock_batch_cli,
job_list=["job1", "job2"],
timeout=300
)
# Assertions
self.assertEqual(mock_kube_helper.get_job_status.call_count, 2)
@patch('time.sleep', return_value=None)
@patch('time.time')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_wait_for_job_timeout(self, mock_kube_helper, mock_time, mock_sleep):
"""Test waiting for jobs times out"""
mock_batch_cli = Mock()
mock_time.side_effect = [0, 350] # Simulate timeout
mock_response = Mock()
mock_response.status.succeeded = None
mock_response.status.failed = None
mock_kube_helper.get_job_status.return_value = mock_response
# Test - should raise exception
with self.assertRaises(Exception) as context:
ingress_shaping.wait_for_job(
batch_cli=mock_batch_cli,
job_list=["job1"],
timeout=300
)
self.assertIn("timeout", str(context.exception))
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_delete_jobs(self, mock_kube_helper):
"""Test deleting jobs"""
mock_cli = Mock()
mock_batch_cli = Mock()
mock_response = Mock()
mock_response.status.failed = None
mock_kube_helper.get_job_status.return_value = mock_response
mock_kube_helper.delete_job.return_value = None
# Test
ingress_shaping.delete_jobs(
cli=mock_cli,
batch_cli=mock_batch_cli,
job_list=["job1", "job2"]
)
# Assertions
self.assertEqual(mock_kube_helper.get_job_status.call_count, 2)
self.assertEqual(mock_kube_helper.delete_job.call_count, 2)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_job_pods')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_delete_jobs_with_failed_job(self, mock_kube_helper, mock_get_job_pods):
"""Test deleting jobs when one has failed"""
mock_cli = Mock()
mock_batch_cli = Mock()
mock_response = Mock()
mock_response.status.failed = 1
mock_pod_status = Mock()
mock_pod_status.status.container_statuses = []
mock_log_response = Mock()
mock_log_response.data.decode.return_value = "Error log content"
mock_kube_helper.get_job_status.return_value = mock_response
mock_get_job_pods.return_value = "failed-pod"
mock_kube_helper.read_pod.return_value = mock_pod_status
mock_kube_helper.get_pod_log.return_value = mock_log_response
mock_kube_helper.delete_job.return_value = None
# Test
ingress_shaping.delete_jobs(
cli=mock_cli,
batch_cli=mock_batch_cli,
job_list=["failed-job"]
)
# Assertions
mock_kube_helper.read_pod.assert_called_once()
mock_kube_helper.get_pod_log.assert_called_once()
def test_get_ingress_cmd_basic(self):
"""Test generating ingress traffic shaping commands"""
result = ingress_shaping.get_ingress_cmd(
interface_list=["eth0"],
network_parameters={"latency": "50ms"},
duration=120
)
# Assertions
self.assertIn("tc qdisc add dev eth0 handle ffff: ingress", result)
self.assertIn("tc filter add dev eth0", result)
self.assertIn("ifb0", result)
self.assertIn("delay 50ms", result)
self.assertIn("sleep 120", result)
self.assertIn("tc qdisc del", result)
def test_get_ingress_cmd_multiple_interfaces(self):
"""Test generating commands for multiple interfaces"""
result = ingress_shaping.get_ingress_cmd(
interface_list=["eth0", "eth1"],
network_parameters={"latency": "50ms", "bandwidth": "100mbit"},
duration=120
)
# Assertions
self.assertIn("eth0", result)
self.assertIn("eth1", result)
self.assertIn("ifb0", result)
self.assertIn("ifb1", result)
self.assertIn("delay 50ms", result)
self.assertIn("rate 100mbit", result)
def test_get_ingress_cmd_all_parameters(self):
"""Test generating commands with all network parameters"""
result = ingress_shaping.get_ingress_cmd(
interface_list=["eth0"],
network_parameters={
"latency": "50ms",
"loss": "0.02",
"bandwidth": "100mbit"
},
duration=120
)
# Assertions
self.assertIn("delay 50ms", result)
self.assertIn("loss 0.02", result)
self.assertIn("rate 100mbit", result)
def test_get_ingress_cmd_invalid_interface(self):
"""Test that invalid interface names raise an exception"""
with self.assertRaises(Exception) as context:
ingress_shaping.get_ingress_cmd(
interface_list=["eth0; rm -rf /"],
network_parameters={"latency": "50ms"},
duration=120
)
self.assertIn("does not match the required regex pattern", str(context.exception))
@patch('yaml.safe_load')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.create_virtual_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_ingress_cmd')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_apply_ingress_filter(self, mock_kube_helper, mock_get_cmd, mock_create_virtual, mock_yaml):
"""Test applying ingress filters to a node"""
# Setup mocks
mock_cli = Mock()
mock_batch_cli = Mock()
mock_pod_template = Mock()
mock_job_template = Mock()
mock_job_template.render.return_value = "job_yaml"
mock_cfg = ingress_shaping.NetworkScenarioConfig(
node_interface_name={"node1": ["eth0"]},
network_params={"latency": "50ms"},
test_duration=120
)
mock_yaml.return_value = {"metadata": {"name": "test-job"}}
mock_get_cmd.return_value = "tc commands"
mock_kube_helper.create_job.return_value = Mock()
# Test
result = ingress_shaping.apply_ingress_filter(
cfg=mock_cfg,
interface_list=["eth0"],
node="node1",
pod_template=mock_pod_template,
job_template=mock_job_template,
batch_cli=mock_batch_cli,
cli=mock_cli,
create_interfaces=True,
param_selector="all",
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
mock_create_virtual.assert_called_once()
mock_get_cmd.assert_called_once()
mock_kube_helper.create_job.assert_called_once()
self.assertEqual(result, "test-job")
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_create_virtual_interfaces(self, mock_kube_helper):
"""Test creating virtual interfaces on a node"""
mock_cli = Mock()
mock_pod_template = Mock()
mock_pod_template.render.return_value = "pod_yaml"
mock_kube_helper.create_pod.return_value = None
mock_kube_helper.exec_cmd_in_pod.return_value = None
mock_kube_helper.delete_pod.return_value = None
# Test
ingress_shaping.create_virtual_interfaces(
cli=mock_cli,
interface_list=["eth0", "eth1"],
node="test-node",
pod_template=mock_pod_template,
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
mock_kube_helper.create_pod.assert_called_once()
mock_kube_helper.delete_pod.assert_called_once()
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_ifb')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_delete_virtual_interfaces(self, mock_kube_helper, mock_delete_ifb):
"""Test deleting virtual interfaces from nodes"""
mock_cli = Mock()
mock_pod_template = Mock()
mock_pod_template.render.return_value = "pod_yaml"
mock_kube_helper.create_pod.return_value = None
mock_kube_helper.delete_pod.return_value = None
# Test
ingress_shaping.delete_virtual_interfaces(
cli=mock_cli,
node_list=["node1", "node2"],
pod_template=mock_pod_template,
image="quay.io/krkn-chaos/krkn:tools"
)
# Assertions
self.assertEqual(mock_kube_helper.create_pod.call_count, 2)
self.assertEqual(mock_delete_ifb.call_count, 2)
self.assertEqual(mock_kube_helper.delete_pod.call_count, 2)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.Environment')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.FileSystemLoader')
@patch('yaml.safe_load')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_jobs')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_virtual_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.wait_for_job')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.apply_ingress_filter')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_node_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_network_chaos_parallel_execution(
self, mock_kube_helper, mock_get_nodes, mock_apply_filter,
mock_wait_job, mock_delete_virtual, mock_delete_jobs, mock_yaml,
mock_file_loader, mock_env
):
"""Test network chaos with parallel execution"""
# Setup mocks
mock_cli = Mock()
mock_batch_cli = Mock()
mock_yaml.return_value = {"metadata": {"name": "test-pod"}}
mock_kube_helper.setup_kubernetes.return_value = (mock_cli, mock_batch_cli)
mock_get_nodes.return_value = {"node1": ["eth0"], "node2": ["eth1"]}
mock_apply_filter.side_effect = ["job1", "job2"]
# Test
cfg = ingress_shaping.NetworkScenarioConfig(
label_selector="node-role.kubernetes.io/worker",
instance_count=2,
network_params={"latency": "50ms"},
execution_type="parallel",
test_duration=120,
wait_duration=30
)
output_id, output_data = ingress_shaping.network_chaos(params=cfg, run_id="test-run")
# Assertions
self.assertEqual(output_id, "success")
self.assertEqual(output_data.filter_direction, "ingress")
self.assertEqual(output_data.execution_type, "parallel")
self.assertEqual(mock_apply_filter.call_count, 2)
mock_wait_job.assert_called_once()
mock_delete_virtual.assert_called_once()
@patch('krkn.scenario_plugins.native.network.ingress_shaping.Environment')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.FileSystemLoader')
@patch('yaml.safe_load')
@patch('time.sleep', return_value=None)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_jobs')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_virtual_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.wait_for_job')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.apply_ingress_filter')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_node_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_network_chaos_serial_execution(
self, mock_kube_helper, mock_get_nodes, mock_apply_filter,
mock_wait_job, mock_delete_virtual, mock_delete_jobs, mock_sleep, mock_yaml,
mock_file_loader, mock_env
):
"""Test network chaos with serial execution"""
# Setup mocks
mock_cli = Mock()
mock_batch_cli = Mock()
mock_yaml.return_value = {"metadata": {"name": "test-pod"}}
mock_kube_helper.setup_kubernetes.return_value = (mock_cli, mock_batch_cli)
mock_get_nodes.return_value = {"node1": ["eth0"]}
mock_apply_filter.return_value = "job1"
# Test
cfg = ingress_shaping.NetworkScenarioConfig(
label_selector="node-role.kubernetes.io/worker",
instance_count=1,
network_params={"latency": "50ms", "bandwidth": "100mbit"},
execution_type="serial",
test_duration=120,
wait_duration=30
)
output_id, output_data = ingress_shaping.network_chaos(params=cfg, run_id="test-run")
# Assertions
self.assertEqual(output_id, "success")
self.assertEqual(output_data.execution_type, "serial")
# Should be called once per parameter per node
self.assertEqual(mock_apply_filter.call_count, 2)
# Should wait for jobs twice (once per parameter)
self.assertEqual(mock_wait_job.call_count, 2)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.Environment')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.FileSystemLoader')
@patch('yaml.safe_load')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_jobs')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_virtual_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_node_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_network_chaos_invalid_execution_type(
self, mock_kube_helper, mock_get_nodes, mock_delete_virtual, mock_delete_jobs, mock_yaml,
mock_file_loader, mock_env
):
"""Test network chaos with invalid execution type"""
# Setup mocks
mock_cli = Mock()
mock_batch_cli = Mock()
mock_yaml.return_value = {"metadata": {"name": "test-pod"}}
mock_kube_helper.setup_kubernetes.return_value = (mock_cli, mock_batch_cli)
mock_get_nodes.return_value = {"node1": ["eth0"]}
# Test
cfg = ingress_shaping.NetworkScenarioConfig(
label_selector="node-role.kubernetes.io/worker",
instance_count=1,
network_params={"latency": "50ms"},
execution_type="invalid_type",
test_duration=120
)
output_id, output_data = ingress_shaping.network_chaos(params=cfg, run_id="test-run")
# Assertions
self.assertEqual(output_id, "error")
self.assertIn("Invalid execution type", output_data.error)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.Environment')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.FileSystemLoader')
@patch('yaml.safe_load')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_jobs')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_virtual_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_node_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_network_chaos_get_nodes_error(
self, mock_kube_helper, mock_get_nodes, mock_delete_virtual, mock_delete_jobs, mock_yaml,
mock_file_loader, mock_env
):
"""Test network chaos when getting nodes fails"""
# Setup mocks
mock_cli = Mock()
mock_batch_cli = Mock()
mock_yaml.return_value = {"metadata": {"name": "test-pod"}}
mock_kube_helper.setup_kubernetes.return_value = (mock_cli, mock_batch_cli)
mock_get_nodes.side_effect = Exception("Failed to get nodes")
# Test
cfg = ingress_shaping.NetworkScenarioConfig(
label_selector="node-role.kubernetes.io/worker",
instance_count=1,
network_params={"latency": "50ms"}
)
output_id, output_data = ingress_shaping.network_chaos(params=cfg, run_id="test-run")
# Assertions
self.assertEqual(output_id, "error")
self.assertIn("Failed to get nodes", output_data.error)
@patch('krkn.scenario_plugins.native.network.ingress_shaping.Environment')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.FileSystemLoader')
@patch('yaml.safe_load')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_jobs')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.delete_virtual_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.apply_ingress_filter')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.get_node_interfaces')
@patch('krkn.scenario_plugins.native.network.ingress_shaping.kube_helper')
def test_network_chaos_apply_filter_error(
self, mock_kube_helper, mock_get_nodes, mock_apply_filter,
mock_delete_virtual, mock_delete_jobs, mock_yaml,
mock_file_loader, mock_env
):
"""Test network chaos when applying filter fails"""
# Setup mocks
mock_cli = Mock()
mock_batch_cli = Mock()
mock_yaml.return_value = {"metadata": {"name": "test-pod"}}
mock_kube_helper.setup_kubernetes.return_value = (mock_cli, mock_batch_cli)
mock_get_nodes.return_value = {"node1": ["eth0"]}
mock_apply_filter.side_effect = Exception("Failed to apply filter")
# Test
cfg = ingress_shaping.NetworkScenarioConfig(
label_selector="node-role.kubernetes.io/worker",
instance_count=1,
network_params={"latency": "50ms"},
execution_type="parallel"
)
output_id, output_data = ingress_shaping.network_chaos(params=cfg, run_id="test-run")
# Assertions
self.assertEqual(output_id, "error")
self.assertIn("Failed to apply filter", output_data.error)
# Cleanup should still be called
mock_delete_virtual.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,27 +1,11 @@
#!/usr/bin/env python3
"""
Test suite for KubeVirt VM Outage Scenario Plugin
Test suite for KubeVirt VM Outage Scenario Plugin class
This comprehensive test suite covers the KubevirtVmOutageScenarioPlugin class
using extensive mocks to avoid needing actual Kubernetes/KubeVirt infrastructure.
Test Coverage:
- Core scenario flows: injection, recovery, deletion, waiting
- Edge cases: timeouts, missing parameters, validation failures
- API exceptions: 404, 500, and general exceptions
- Helper methods: get_vmi, get_vmis, patch_vm_spec, validate_environment
- Multiple VMI scenarios with kill_count
- Auto-restart disable functionality
IMPORTANT: These tests use comprehensive mocking and do NOT require any Kubernetes
cluster or KubeVirt installation. All API calls are mocked.
Note: This test file uses mocks extensively to avoid needing actual Kubernetes/KubeVirt infrastructure.
Usage:
# Run all tests
python -m unittest tests.test_kubevirt_vm_outage -v
# Run with coverage
python -m coverage run -a -m unittest tests/test_kubevirt_vm_outage.py -v
Assisted By: Claude Code
@@ -32,7 +16,6 @@ import itertools
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import yaml
@@ -44,9 +27,8 @@ from kubernetes.client.rest import ApiException
from krkn.scenario_plugins.kubevirt_vm_outage.kubevirt_vm_outage_scenario_plugin import KubevirtVmOutageScenarioPlugin
class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
def setUp(self):
"""
Set up test fixtures for KubevirtVmOutageScenarioPlugin
@@ -70,32 +52,18 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
crd_item.spec.group = "kubevirt.io"
crd_list.items = [crd_item]
self.k8s_client.list_custom_resource_definition.return_value = crd_list
# Mock VMI data with timezone-aware timestamps
base_time = datetime.now(timezone.utc)
# Mock VMI data
self.mock_vmi = {
"metadata": {
"name": "test-vm",
"namespace": "default",
"creationTimestamp": base_time.isoformat() + "Z"
"namespace": "default"
},
"status": {
"phase": "Running"
}
}
# Mock VMI with new creation timestamp (after recreation)
self.mock_vmi_recreated = {
"metadata": {
"name": "test-vm",
"namespace": "default",
"creationTimestamp": (base_time + timedelta(minutes=1)).isoformat() + "Z"
},
"status": {
"phase": "Running"
}
}
# Create test config
self.config = {
"scenarios": [
@@ -105,18 +73,18 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
"parameters": {
"vm_name": "test-vm",
"namespace": "default",
"duration": 0
"duration": 0
}
}
]
}
# Create a temporary config file
temp_dir = tempfile.gettempdir()
self.scenario_file = os.path.join(temp_dir, "test_kubevirt_scenario.yaml")
with open(self.scenario_file, "w") as f:
yaml.dump(self.config, f)
# Mock dependencies
self.telemetry = MagicMock(spec=KrknTelemetryOpenshift)
self.scenario_telemetry = MagicMock(spec=ScenarioTelemetry)
@@ -126,152 +94,63 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
self.delete_count = 0
self.wait_count = 0
def create_incrementing_time_function(self):
"""
Create an incrementing time function that returns sequential float values.
Returns a callable that can be used with patch('time.time', side_effect=...)
"""
counter = itertools.count(1)
def mock_time():
return float(next(counter))
return mock_time
def mock_delete(self, *args, **kwargs):
"""Reusable mock for delete_vmi that tracks calls and sets up affected_pod"""
self.delete_count += 1
self.plugin.affected_pod = AffectedPod(pod_name=f"test-vm-{self.delete_count}", namespace="default")
self.plugin.affected_pod.pod_rescheduling_time = 5.0
return 0
def mock_wait(self, *args, **kwargs):
"""Reusable mock for wait_for_running that tracks calls and sets pod_readiness_time"""
self.wait_count += 1
self.plugin.affected_pod.pod_readiness_time = 3.0
return 0
# ==================== Core Scenario Tests ====================
def test_successful_injection_and_recovery(self):
"""
Test successful deletion and recovery of a VMI using detailed mocking
Test successful deletion and recovery of a VMI
"""
# Mock list_namespaces_by_regex to return a single namespace
self.k8s_client.list_namespaces_by_regex = MagicMock(return_value=["default"])
# Populate vmis_list to avoid randrange error
self.plugin.vmis_list = [self.mock_vmi]
# Mock list_namespaced_custom_object to return our VMI
self.custom_object_client.list_namespaced_custom_object = MagicMock(
side_effect=[
{"items": [self.mock_vmi]}, # For get_vmis
{"items": [{"metadata": {"name": "test-vm"}}]}, # For validate_environment
]
)
# Mock get_namespaced_custom_object with a sequence that handles multiple calls
# Call sequence:
# 1. validate_environment: get original VMI
# 2. execute_scenario: get VMI before deletion
# 3. delete_vmi: loop checking if timestamp changed (returns recreated VMI on first check)
# 4+. wait_for_running: loop until phase is Running (may call multiple times)
get_vmi_responses = [
self.mock_vmi, # Initial get in validate_environment
self.mock_vmi, # Get before delete
self.mock_vmi_recreated, # After delete (recreated with new timestamp)
self.mock_vmi_recreated, # Check if running
]
class GetVmiSideEffect:
"""
Callable helper that returns a predefined sequence of VMIs.
If called more times than there are responses, it fails the test
to surface unexpected additional calls instead of silently
masking them.
"""
def __init__(self, responses):
self._responses = responses
self._call_iter = itertools.count()
self.call_count = 0
def __call__(self, *args, **kwargs):
call_num = next(self._call_iter)
self.call_count = call_num + 1
if call_num >= len(self._responses):
raise AssertionError(
f"get_vmi_side_effect called more times ({call_num + 1}) "
f"than expected ({len(self._responses)})."
)
return self._responses[call_num]
get_vmi_side_effect = GetVmiSideEffect(get_vmi_responses)
self.custom_object_client.get_namespaced_custom_object = MagicMock(
side_effect=get_vmi_side_effect
)
# Mock delete operation
self.custom_object_client.delete_namespaced_custom_object = MagicMock(return_value={})
with patch('time.time', side_effect=self.create_incrementing_time_function()), patch('time.sleep'):
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
# Mock get_vmis to not clear the list
with patch.object(self.plugin, 'get_vmis'):
# Mock get_vmi to return our mock VMI
with patch.object(self.plugin, 'get_vmi', return_value=self.mock_vmi):
# Mock validate_environment to return True
with patch.object(self.plugin, 'validate_environment', return_value=True):
# Mock delete_vmi and wait_for_running to simulate success
with patch.object(self.plugin, 'delete_vmi', side_effect=self.mock_delete) as mock_delete:
with patch.object(self.plugin, 'wait_for_running', side_effect=self.mock_wait) as mock_wait:
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
self.assertEqual(result, 0)
# Verify get_namespaced_custom_object was called exactly as many times as
# there are predefined responses
self.assertEqual(
self.custom_object_client.get_namespaced_custom_object.call_count,
len(get_vmi_responses),
)
# Verify that the VMI delete operation was performed once with expected parameters
self.custom_object_client.delete_namespaced_custom_object.assert_called_once_with(
group="kubevirt.io",
version="v1",
namespace="default",
plural="virtualmachineinstances",
name="test-vm",
)
mock_delete.assert_called_once_with("test-vm", "default", False)
mock_wait.assert_called_once_with("test-vm", "default", 60)
def test_injection_failure(self):
"""
Test failure during VMI deletion
"""
# Mock list_namespaces_by_regex
self.k8s_client.list_namespaces_by_regex = MagicMock(return_value=["default"])
# Populate vmis_list to avoid randrange error
self.plugin.vmis_list = [self.mock_vmi]
# Mock list to return VMI
self.custom_object_client.list_namespaced_custom_object = MagicMock(
side_effect=[
{"items": [self.mock_vmi]}, # For get_vmis
{"items": [{"metadata": {"name": "test-vm"}}]}, # For validate_environment
]
)
# Mock get_vmi
self.custom_object_client.get_namespaced_custom_object = MagicMock(
side_effect=[
self.mock_vmi, # validate_environment
self.mock_vmi, # get before delete
]
)
# Mock delete to raise an error
self.custom_object_client.delete_namespaced_custom_object = MagicMock(
side_effect=ApiException(status=500, reason="Internal Server Error")
)
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
# Mock get_vmis to not clear the list
with patch.object(self.plugin, 'get_vmis'):
# Mock get_vmi to return our mock VMI
with patch.object(self.plugin, 'get_vmi', return_value=self.mock_vmi):
# Mock validate_environment to return True
with patch.object(self.plugin, 'validate_environment', return_value=True):
# Mock delete_vmi to simulate failure
with patch.object(self.plugin, 'delete_vmi', return_value=1) as mock_delete:
with patch.object(self.plugin, 'wait_for_running', return_value=0) as mock_wait:
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
self.assertEqual(result, 1)
# Verify delete was attempted before the error occurred
self.custom_object_client.delete_namespaced_custom_object.assert_called_once_with(
group="kubevirt.io",
version="v1",
namespace="default",
plural="virtualmachineinstances",
name="test-vm"
)
mock_delete.assert_called_once_with("test-vm", "default", False)
mock_wait.assert_not_called()
def test_disable_auto_restart(self):
"""
Test VM auto-restart can be disabled
@@ -279,92 +158,66 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
# Configure test with disable_auto_restart=True
self.config["scenarios"][0]["parameters"]["disable_auto_restart"] = True
# Mock list_namespaces_by_regex
self.k8s_client.list_namespaces_by_regex = MagicMock(return_value=["default"])
# Populate vmis_list to avoid randrange error
self.plugin.vmis_list = [self.mock_vmi]
# Mock VM object for patching
mock_vm = {
"metadata": {"name": "test-vm", "namespace": "default"},
"spec": {"running": True}
}
# Mock list to return VMI
self.custom_object_client.list_namespaced_custom_object = MagicMock(
side_effect=[
{"items": [self.mock_vmi]}, # For get_vmis
{"items": [{"metadata": {"name": "test-vm"}}]}, # For validate_environment
]
)
# Mock get_namespaced_custom_object with detailed call sequence
# Call sequence:
# 1. execute_scenario: get VMI before deletion
# 2. patch_vm_spec: get VM for patching
# 3. delete_vmi: loop checking if VMI timestamp changed
# 4+. wait_for_running: loop until VMI phase is Running
self.custom_object_client.get_namespaced_custom_object = MagicMock(
side_effect=[
self.mock_vmi, # Call 1: get VMI before delete
mock_vm, # Call 2: get VM for patching (different resource type)
self.mock_vmi_recreated, # Call 3: delete_vmi detects new timestamp
self.mock_vmi_recreated, # Call 4: wait_for_running checks phase
]
)
# Mock patch and delete operations
self.custom_object_client.patch_namespaced_custom_object = MagicMock(return_value=mock_vm)
self.custom_object_client.delete_namespaced_custom_object = MagicMock(return_value={})
with patch('time.time', side_effect=self.create_incrementing_time_function()), patch('time.sleep'):
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
# Mock get_vmis to not clear the list
with patch.object(self.plugin, 'get_vmis'):
# Mock get_vmi to return our mock VMI
with patch.object(self.plugin, 'get_vmi', return_value=self.mock_vmi):
# Mock validate_environment to return True
with patch.object(self.plugin, 'validate_environment', return_value=True):
# Mock delete_vmi and wait_for_running
with patch.object(self.plugin, 'delete_vmi', side_effect=self.mock_delete) as mock_delete:
with patch.object(self.plugin, 'wait_for_running', side_effect=self.mock_wait) as mock_wait:
with patch("builtins.open", unittest.mock.mock_open(read_data=yaml.dump(self.config))):
result = self.plugin.run("test-uuid", self.scenario_file, {}, self.telemetry, self.scenario_telemetry)
self.assertEqual(result, 0)
# Verify patch was called to disable auto-restart
self.custom_object_client.patch_namespaced_custom_object.assert_called()
# delete_vmi should be called with disable_auto_restart=True
mock_delete.assert_called_once_with("test-vm", "default", True)
mock_wait.assert_called_once_with("test-vm", "default", 60)
def test_recovery_when_vmi_does_not_exist(self):
"""
Test recovery logic when VMI does not exist after deletion
"""
# Initialize the plugin's custom_object_client
self.plugin.custom_object_client = self.custom_object_client
# Store the original VMI in the plugin for recovery
self.plugin.original_vmi = self.mock_vmi.copy()
# Initialize affected_pod which is used by wait_for_running
self.plugin.affected_pod = AffectedPod(pod_name="test-vm", namespace="default")
# Create a cleaned vmi_dict as the plugin would
vmi_dict = self.mock_vmi.copy()
# Set up running VMI data for after recovery
running_vmi = {
"metadata": {
"name": "test-vm",
"namespace": "default",
"creationTimestamp": (datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat() + "Z"
},
"metadata": {"name": "test-vm", "namespace": "default"},
"status": {"phase": "Running"}
}
# Mock get_namespaced_custom_object call sequence triggered during recovery
# Call sequence:
# 1. wait_for_running: first loop iteration - VMI creation requested but not visible yet
# 2. wait_for_running: subsequent iterations - VMI exists and is running
self.custom_object_client.get_namespaced_custom_object = MagicMock(
side_effect=[
ApiException(status=404, reason="Not Found"), # VMI not visible yet after create
running_vmi, # VMI now exists and is running
]
)
# Set up time.time to immediately exceed the timeout for auto-recovery
with patch('time.time', side_effect=[0, 301, 301, 301, 301, 310, 320]):
# Mock get_vmi to always return None (not auto-recovered)
with patch.object(self.plugin, 'get_vmi', side_effect=[None, None, running_vmi]):
# Mock the custom object API to return success
self.custom_object_client.create_namespaced_custom_object = MagicMock(return_value=running_vmi)
# Mock the create API to return success
self.custom_object_client.create_namespaced_custom_object = MagicMock(return_value=running_vmi)
# Run recovery with mocked time
with patch('time.time', side_effect=self.create_incrementing_time_function()), patch('time.sleep'):
result = self.plugin.recover("test-vm", "default", False)
# Run recovery with mocked time.sleep
with patch('time.sleep'):
result = self.plugin.recover("test-vm", "default", False)
self.assertEqual(result, 0)
# Verify create was called with the right arguments
self.custom_object_client.create_namespaced_custom_object.assert_called_once()
# Verify create was called with the right arguments for our API version and kind
self.custom_object_client.create_namespaced_custom_object.assert_called_once_with(
group="kubevirt.io",
version="v1",
namespace="default",
plural="virtualmachineinstances",
body=vmi_dict
)
def test_validation_failure(self):
"""
Test validation failure when KubeVirt is not installed
@@ -383,32 +236,34 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
# When validation fails, run() returns 1 due to exception handling
self.assertEqual(result, 1)
# ==================== Timeout Tests ====================
def test_delete_vmi_timeout(self):
"""
Test timeout during VMI deletion
"""
# Store original VMI
self.plugin.original_vmi = self.mock_vmi
# Initialize the plugin's custom_object_client and required attributes
self.plugin.custom_object_client = self.custom_object_client
# Initialize required attributes
self.plugin.affected_pod = AffectedPod(pod_name="test-vm", namespace="default")
# Initialize original_vmi which is required by delete_vmi
self.plugin.original_vmi = self.mock_vmi.copy()
self.plugin.original_vmi['metadata']['creationTimestamp'] = '2023-01-01T00:00:00Z'
# Initialize pods_status which delete_vmi needs
from krkn_lib.models.k8s import PodsStatus, AffectedPod
self.plugin.pods_status = PodsStatus()
self.plugin.affected_pod = AffectedPod(pod_name="test-vm", namespace="default")
# Mock successful delete operation
self.custom_object_client.delete_namespaced_custom_object = MagicMock(return_value={})
# Mock get_vmi to always return the same VMI with unchanged creationTimestamp
# This simulates that the VMI has NOT been recreated after deletion
self.custom_object_client.get_namespaced_custom_object = MagicMock(
return_value=self.mock_vmi
)
# Mock that get_vmi always returns VMI with same creationTimestamp (never gets recreated)
mock_vmi_with_time = self.mock_vmi.copy()
mock_vmi_with_time['metadata']['creationTimestamp'] = '2023-01-01T00:00:00Z'
# Simulate timeout by making time.time return values that exceed the timeout
with patch('time.sleep'), patch('time.time', side_effect=[0, 10, 20, 130, 140]):
result = self.plugin.delete_vmi("test-vm", "default", False, timeout=120)
with patch.object(self.plugin, 'get_vmi', return_value=mock_vmi_with_time):
# Simulate timeout by making time.time return values that exceed the timeout
with patch('time.sleep'), patch('time.time', side_effect=[0, 10, 20, 130, 130, 130, 130, 140]):
result = self.plugin.delete_vmi("test-vm", "default", False)
self.assertEqual(result, 1)
self.custom_object_client.delete_namespaced_custom_object.assert_called_once_with(
@@ -419,29 +274,12 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
name="test-vm"
)
def test_wait_for_running_timeout(self):
"""
Test wait_for_running times out when VMI doesn't reach Running state
"""
self.plugin.affected_pod = AffectedPod(pod_name="test-vm", namespace="default")
# Mock VMI in Pending state
pending_vmi = self.mock_vmi.copy()
pending_vmi['status']['phase'] = 'Pending'
with patch.object(self.plugin, 'get_vmi', return_value=pending_vmi):
with patch('time.sleep'):
with patch('time.time', side_effect=[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 121]):
result = self.plugin.wait_for_running("test-vm", "default", 120)
self.assertEqual(result, 1)
# ==================== API Exception Tests ====================
def test_get_vmi_api_exception_non_404(self):
"""
Test get_vmi raises ApiException for non-404 errors
"""
# Mock API exception with non-404 status
api_error = ApiException(status=500, reason="Internal Server Error")
self.custom_object_client.get_namespaced_custom_object = MagicMock(side_effect=api_error)
@@ -461,10 +299,37 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
with self.assertRaises(Exception):
self.plugin.get_vmi("test-vm", "default")
def test_get_vmis_with_regex_matching(self):
"""
Test get_vmis successfully filters VMIs by regex pattern
"""
# Mock namespace list
self.k8s_client.list_namespaces_by_regex = MagicMock(return_value=["default", "test-ns"])
# Mock VMI list with multiple VMIs
vmi_list = {
"items": [
{"metadata": {"name": "test-vm-1"}, "status": {"phase": "Running"}},
{"metadata": {"name": "test-vm-2"}, "status": {"phase": "Running"}},
{"metadata": {"name": "other-vm"}, "status": {"phase": "Running"}},
]
}
self.custom_object_client.list_namespaced_custom_object = MagicMock(return_value=vmi_list)
# Test with regex pattern that matches test-vm-*
self.plugin.get_vmis("test-vm-.*", "default")
# Should have 4 VMs (2 per namespace * 2 namespaces)
self.assertEqual(len(self.plugin.vmis_list), 4)
# Verify only test-vm-* were added
for vmi in self.plugin.vmis_list:
self.assertTrue(vmi["metadata"]["name"].startswith("test-vm-"))
def test_get_vmis_api_exception_404(self):
"""
Test get_vmis handles 404 ApiException gracefully
"""
self.k8s_client.list_namespaces_by_regex = MagicMock(return_value=["default"])
api_error = ApiException(status=404, reason="Not Found")
self.custom_object_client.list_namespaced_custom_object = MagicMock(side_effect=api_error)
@@ -477,6 +342,7 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
"""
Test get_vmis raises ApiException for non-404 errors
"""
self.k8s_client.list_namespaces_by_regex = MagicMock(return_value=["default"])
api_error = ApiException(status=500, reason="Internal Server Error")
self.custom_object_client.list_namespaced_custom_object = MagicMock(side_effect=api_error)
@@ -484,10 +350,52 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
with self.assertRaises(ApiException):
self.plugin.get_vmis("test-vm", "default")
def test_patch_vm_spec_success(self):
"""
Test patch_vm_spec successfully patches VM
"""
mock_vm = {
"metadata": {"name": "test-vm", "namespace": "default"},
"spec": {"running": True}
}
self.custom_object_client.get_namespaced_custom_object = MagicMock(return_value=mock_vm)
self.custom_object_client.patch_namespaced_custom_object = MagicMock(return_value=mock_vm)
result = self.plugin.patch_vm_spec("test-vm", "default", False)
self.assertTrue(result)
self.custom_object_client.patch_namespaced_custom_object.assert_called_once()
def test_patch_vm_spec_api_exception(self):
"""
Test patch_vm_spec handles ApiException
"""
api_error = ApiException(status=404, reason="Not Found")
self.custom_object_client.get_namespaced_custom_object = MagicMock(side_effect=api_error)
result = self.plugin.patch_vm_spec("test-vm", "default", False)
self.assertFalse(result)
def test_patch_vm_spec_general_exception(self):
"""
Test patch_vm_spec handles general exceptions
"""
self.custom_object_client.get_namespaced_custom_object = MagicMock(
side_effect=Exception("Connection error")
)
result = self.plugin.patch_vm_spec("test-vm", "default", False)
self.assertFalse(result)
def test_delete_vmi_api_exception_404(self):
"""
Test delete_vmi handles 404 ApiException during deletion
"""
# Initialize required attributes
self.plugin.original_vmi = self.mock_vmi.copy()
self.plugin.original_vmi['metadata']['creationTimestamp'] = '2023-01-01T00:00:00Z'
@@ -518,103 +426,6 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
self.assertEqual(result, 1)
def test_patch_vm_spec_api_exception(self):
"""
Test patch_vm_spec handles ApiException
"""
api_error = ApiException(status=404, reason="Not Found")
self.custom_object_client.get_namespaced_custom_object = MagicMock(side_effect=api_error)
result = self.plugin.patch_vm_spec("test-vm", "default", False)
self.assertFalse(result)
def test_patch_vm_spec_general_exception(self):
"""
Test patch_vm_spec handles general exceptions
"""
self.custom_object_client.get_namespaced_custom_object = MagicMock(
side_effect=Exception("Connection error")
)
result = self.plugin.patch_vm_spec("test-vm", "default", False)
self.assertFalse(result)
# ==================== Helper Method Tests ====================
def test_get_vmis_with_regex_matching(self):
"""
Test get_vmis successfully filters VMIs by regex pattern
"""
# Mock namespace list
self.k8s_client.list_namespaces_by_regex = MagicMock(return_value=["default", "test-ns"])
# Mock VMI list with multiple VMIs
vmi_list = {
"items": [
{"metadata": {"name": "test-vm-1"}, "status": {"phase": "Running"}},
{"metadata": {"name": "test-vm-2"}, "status": {"phase": "Running"}},
{"metadata": {"name": "other-vm"}, "status": {"phase": "Running"}},
]
}
self.custom_object_client.list_namespaced_custom_object = MagicMock(return_value=vmi_list)
# Test with regex pattern that matches test-vm-*
self.plugin.get_vmis("test-vm-.*", "default")
# Should have 4 VMs (2 per namespace * 2 namespaces)
self.assertEqual(len(self.plugin.vmis_list), 4)
# Verify only test-vm-* were added
for vmi in self.plugin.vmis_list:
self.assertTrue(vmi["metadata"]["name"].startswith("test-vm-"))
def test_patch_vm_spec_success(self):
"""
Test patch_vm_spec successfully patches VM
"""
mock_vm = {
"metadata": {"name": "test-vm", "namespace": "default"},
"spec": {"running": True}
}
self.custom_object_client.get_namespaced_custom_object = MagicMock(return_value=mock_vm)
self.custom_object_client.patch_namespaced_custom_object = MagicMock(return_value=mock_vm)
result = self.plugin.patch_vm_spec("test-vm", "default", False)
self.assertTrue(result)
self.custom_object_client.patch_namespaced_custom_object.assert_called_once()
def test_validate_environment_exception(self):
"""
Test validate_environment handles exceptions
"""
self.custom_object_client.list_namespaced_custom_object = MagicMock(
side_effect=Exception("Connection error")
)
result = self.plugin.validate_environment("test-vm", "default")
self.assertFalse(result)
def test_validate_environment_vmi_not_found(self):
"""
Test validate_environment when VMI doesn't exist
"""
# Mock CRDs exist
mock_crd_list = MagicMock()
mock_crd_list.items = MagicMock(return_value=["item1"])
self.custom_object_client.list_namespaced_custom_object = MagicMock(return_value=mock_crd_list)
# Mock VMI not found
with patch.object(self.plugin, 'get_vmi', return_value=None):
result = self.plugin.validate_environment("test-vm", "default")
self.assertFalse(result)
# ==================== Delete VMI Tests ====================
def test_delete_vmi_successful_recreation(self):
"""
Test delete_vmi succeeds when VMI is recreated with new creationTimestamp
@@ -668,7 +479,22 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
# When VMI stays deleted (None), delete_vmi waits for recreation and times out
self.assertEqual(result, 1)
# ==================== Wait for Running Tests ====================
def test_wait_for_running_timeout(self):
"""
Test wait_for_running times out when VMI doesn't reach Running state
"""
self.plugin.affected_pod = AffectedPod(pod_name="test-vm", namespace="default")
# Mock VMI in Pending state
pending_vmi = self.mock_vmi.copy()
pending_vmi['status']['phase'] = 'Pending'
with patch.object(self.plugin, 'get_vmi', return_value=pending_vmi):
with patch('time.sleep'):
with patch('time.time', side_effect=[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 121]):
result = self.plugin.wait_for_running("test-vm", "default", 120)
self.assertEqual(result, 1)
def test_wait_for_running_vmi_not_exists(self):
"""
@@ -682,15 +508,13 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
with patch.object(self.plugin, 'get_vmi', side_effect=[None, None, running_vmi]):
with patch('time.sleep'):
# time.time() called: start_time (0), iteration 1 (1), iteration 2 (2), iteration 3 (3), end_time (3)
# time.time() called: start_time (0), while loop iteration 1 (1), iteration 2 (2), iteration 3 (3), end_time (3)
with patch('time.time', side_effect=[0, 1, 2, 3, 3]):
result = self.plugin.wait_for_running("test-vm", "default", 120)
self.assertEqual(result, 0)
self.assertIsNotNone(self.plugin.affected_pod.pod_readiness_time)
# ==================== Recovery Tests ====================
def test_recover_no_original_vmi(self):
"""
Test recover fails when no original VMI is captured
@@ -718,8 +542,6 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
self.assertEqual(result, 1)
# ==================== Execute Scenario Tests ====================
def test_execute_scenario_missing_vm_name(self):
"""
Test execute_scenario fails when vm_name is missing
@@ -819,12 +641,38 @@ class TestKubevirtVmOutageScenarioPlugin(unittest.TestCase):
# Should have unrecovered pod
self.assertEqual(len(result.unrecovered), 1)
# ==================== Initialization Tests ====================
def test_validate_environment_exception(self):
"""
Test validate_environment handles exceptions
"""
self.custom_object_client.list_namespaced_custom_object = MagicMock(
side_effect=Exception("Connection error")
)
result = self.plugin.validate_environment("test-vm", "default")
self.assertFalse(result)
def test_validate_environment_vmi_not_found(self):
"""
Test validate_environment when VMI doesn't exist
"""
# Mock CRDs exist
mock_crd_list = MagicMock()
mock_crd_list.items = MagicMock(return_value=["item1"])
self.custom_object_client.list_namespaced_custom_object = MagicMock(return_value=mock_crd_list)
# Mock VMI not found
with patch.object(self.plugin, 'get_vmi', return_value=None):
result = self.plugin.validate_environment("test-vm", "default")
self.assertFalse(result)
def test_init_clients(self):
"""
Test init_clients initializes k8s client correctly
"""
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_custom_client = MagicMock()
mock_k8s.custom_object_client = mock_custom_client

View File

@@ -10,12 +10,12 @@ Assisted By: Claude Code
"""
import unittest
from unittest.mock import Mock, patch, call
from unittest.mock import MagicMock
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn.scenario_plugins.managed_cluster.managed_cluster_scenario_plugin import ManagedClusterScenarioPlugin
from krkn.scenario_plugins.managed_cluster import common_functions
class TestManagedClusterScenarioPlugin(unittest.TestCase):
@@ -25,7 +25,6 @@ class TestManagedClusterScenarioPlugin(unittest.TestCase):
Set up test fixtures for ManagedClusterScenarioPlugin
"""
self.plugin = ManagedClusterScenarioPlugin()
self.mock_kubecli = Mock(spec=KrknKubernetes)
def test_get_scenario_types(self):
"""
@@ -37,191 +36,5 @@ class TestManagedClusterScenarioPlugin(unittest.TestCase):
self.assertEqual(len(result), 1)
class TestCommonFunctions(unittest.TestCase):
"""
Test suite for common_functions module
"""
def setUp(self):
"""
Set up test fixtures for common_functions tests
"""
self.mock_kubecli = Mock(spec=KrknKubernetes)
def test_get_managedcluster_with_specific_name_exists(self):
"""
Test get_managedcluster returns the specified cluster when it exists
"""
self.mock_kubecli.list_killable_managedclusters.return_value = ["cluster1", "cluster2", "cluster3"]
result = common_functions.get_managedcluster(
"cluster1", "", 1, self.mock_kubecli
)
self.assertEqual(result, ["cluster1"])
self.mock_kubecli.list_killable_managedclusters.assert_called_once_with()
def test_get_managedcluster_with_specific_name_not_exists(self):
"""
Test get_managedcluster falls back to label selector when specified cluster doesn't exist
"""
self.mock_kubecli.list_killable_managedclusters.side_effect = [
["cluster2", "cluster3"],
["cluster2", "cluster3"]
]
result = common_functions.get_managedcluster(
"cluster1", "env=test", 1, self.mock_kubecli
)
self.assertEqual(len(result), 1)
self.assertIn(result[0], ["cluster2", "cluster3"])
def test_get_managedcluster_with_label_selector(self):
"""
Test get_managedcluster returns clusters matching label selector
"""
self.mock_kubecli.list_killable_managedclusters.side_effect = [
["cluster1", "cluster2", "cluster3"],
["cluster1", "cluster2", "cluster3"],
]
result = common_functions.get_managedcluster(
"", "env=production", 2, self.mock_kubecli
)
self.assertEqual(len(result), 2)
# Should be called once without and once with label_selector
self.assertEqual(
self.mock_kubecli.list_killable_managedclusters.call_count,
2,
)
self.mock_kubecli.list_killable_managedclusters.assert_has_calls(
[call(), call("env=production")]
)
def test_get_managedcluster_no_available_clusters(self):
"""
Test get_managedcluster raises exception when no clusters are available
"""
self.mock_kubecli.list_killable_managedclusters.return_value = []
with self.assertRaises(Exception) as context:
common_functions.get_managedcluster(
"", "env=nonexistent", 1, self.mock_kubecli
)
self.assertIn("Available managedclusters with the provided label selector do not exist", str(context.exception))
def test_get_managedcluster_kill_count_equals_available(self):
"""
Test get_managedcluster returns all clusters when instance_kill_count equals available clusters
"""
available_clusters = ["cluster1", "cluster2", "cluster3"]
self.mock_kubecli.list_killable_managedclusters.return_value = available_clusters
result = common_functions.get_managedcluster(
"", "env=test", 3, self.mock_kubecli
)
self.assertEqual(result, available_clusters)
self.assertEqual(len(result), 3)
@patch('logging.info')
def test_get_managedcluster_return_empty_when_count_is_zero(self, mock_logging):
"""
Test get_managedcluster returns empty list when instance_kill_count is 0
"""
available_clusters = ["cluster1", "cluster2", "cluster3"]
self.mock_kubecli.list_killable_managedclusters.return_value = available_clusters
result = common_functions.get_managedcluster(
"", "env=test", 0, self.mock_kubecli
)
self.assertEqual(result, [])
mock_logging.assert_called()
@patch('random.randint')
def test_get_managedcluster_random_selection(self, mock_randint):
"""
Test get_managedcluster randomly selects the specified number of clusters
"""
available_clusters = ["cluster1", "cluster2", "cluster3", "cluster4", "cluster5"]
self.mock_kubecli.list_killable_managedclusters.return_value = available_clusters.copy()
mock_randint.side_effect = [1, 0, 2]
result = common_functions.get_managedcluster(
"", "env=test", 3, self.mock_kubecli
)
self.assertEqual(len(result), 3)
for cluster in result:
self.assertIn(cluster, available_clusters)
# Ensure no duplicates
self.assertEqual(len(result), len(set(result)))
@patch('logging.info')
def test_get_managedcluster_logs_available_clusters(self, mock_logging):
"""
Test get_managedcluster logs available clusters with label selector
"""
available_clusters = ["cluster1", "cluster2"]
self.mock_kubecli.list_killable_managedclusters.return_value = available_clusters
common_functions.get_managedcluster(
"", "env=test", 1, self.mock_kubecli
)
mock_logging.assert_called()
call_args = str(mock_logging.call_args)
self.assertIn("Available managedclusters with the label selector", call_args)
@patch('logging.info')
def test_get_managedcluster_logs_when_name_not_found(self, mock_logging):
"""
Test get_managedcluster logs when specified cluster name doesn't exist
"""
self.mock_kubecli.list_killable_managedclusters.side_effect = [
["cluster2"],
["cluster2"]
]
common_functions.get_managedcluster(
"nonexistent-cluster", "env=test", 1, self.mock_kubecli
)
# Check that logging was called multiple times (including the info message about unavailable cluster)
self.assertGreaterEqual(mock_logging.call_count, 1)
# Check all calls for the expected message
all_calls = [str(call) for call in mock_logging.call_args_list]
found_message = any("managedcluster with provided managedcluster_name does not exist" in call
for call in all_calls)
self.assertTrue(found_message)
def test_wait_for_available_status(self):
"""
Test wait_for_available_status calls watch_managedcluster_status with correct parameters
"""
common_functions.wait_for_available_status(
"test-cluster", 300, self.mock_kubecli
)
self.mock_kubecli.watch_managedcluster_status.assert_called_once_with(
"test-cluster", "True", 300
)
def test_wait_for_unavailable_status(self):
"""
Test wait_for_unavailable_status calls watch_managedcluster_status with correct parameters
"""
common_functions.wait_for_unavailable_status(
"test-cluster", 300, self.mock_kubecli
)
self.mock_kubecli.watch_managedcluster_status.assert_called_once_with(
"test-cluster", "Unknown", 300
)
if __name__ == "__main__":
unittest.main()

View File

@@ -10,10 +10,12 @@ Assisted By: Claude Code
"""
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn.scenario_plugins.network_chaos_ng.network_chaos_ng_scenario_plugin import NetworkChaosNgScenarioPlugin
from krkn.scenario_plugins.network_chaos_ng.modules import utils
class TestNetworkChaosNgScenarioPlugin(unittest.TestCase):
@@ -34,80 +36,5 @@ class TestNetworkChaosNgScenarioPlugin(unittest.TestCase):
self.assertEqual(len(result), 1)
class TestNetworkChaosNgUtils(unittest.TestCase):
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.info")
def test_log_info_non_parallel(self, mock_logging_info):
"""
Test log_info function with parallel=False
"""
utils.log_info("Test message")
mock_logging_info.assert_called_once_with("Test message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.info")
def test_log_info_parallel(self, mock_logging_info):
"""
Test log_info function with parallel=True
"""
utils.log_info("Test message", parallel=True, node_name="node1")
mock_logging_info.assert_called_once_with("[node1]: Test message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.info")
def test_log_info_parallel_missing_node_name(self, mock_logging_info):
"""
Test log_info with parallel=True and missing node_name
"""
utils.log_info("Test message", parallel=True)
mock_logging_info.assert_called_once_with("[]: Test message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.error")
def test_log_error_non_parallel(self, mock_logging_error):
"""
Test log_error function with parallel=False
"""
utils.log_error("Error message")
mock_logging_error.assert_called_once_with("Error message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.error")
def test_log_error_parallel(self, mock_logging_error):
"""
Test log_error function with parallel=True
"""
utils.log_error("Error message", parallel=True, node_name="node2")
mock_logging_error.assert_called_once_with("[node2]: Error message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.error")
def test_log_error_parallel_missing_node_name(self, mock_logging_error):
"""
Test log_error with parallel=True and missing node_name
"""
utils.log_error("Error message", parallel=True)
mock_logging_error.assert_called_once_with("[]: Error message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.warning")
def test_log_warning_non_parallel(self, mock_logging_warning):
"""
Test log_warning function with parallel=False
"""
utils.log_warning("Warning message")
mock_logging_warning.assert_called_once_with("Warning message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.warning")
def test_log_warning_parallel(self, mock_logging_warning):
"""
Test log_warning function with parallel=True
"""
utils.log_warning("Warning message", parallel=True, node_name="node3")
mock_logging_warning.assert_called_once_with("[node3]: Warning message")
@patch("krkn.scenario_plugins.network_chaos_ng.modules.utils.logging.warning")
def test_log_warning_parallel_missing_node_name(self, mock_logging_warning):
"""
Test log_warning with parallel=True and missing node_name
"""
utils.log_warning("Warning message", parallel=True)
mock_logging_warning.assert_called_once_with("[]: Warning message")
if __name__ == "__main__":
unittest.main()

View File

@@ -10,15 +10,10 @@ Assisted By: Claude Code
"""
import unittest
from unittest.mock import MagicMock, Mock, patch, mock_open, call
import yaml
import tempfile
import os
from unittest.mock import MagicMock
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn_lib.models.telemetry import ScenarioTelemetry
from krkn_lib.models.k8s import AffectedNodeStatus
from krkn.scenario_plugins.node_actions.node_actions_scenario_plugin import NodeActionsScenarioPlugin
@@ -29,16 +24,7 @@ class TestNodeActionsScenarioPlugin(unittest.TestCase):
"""
Set up test fixtures for NodeActionsScenarioPlugin
"""
# Reset node_general global variable before each test
import krkn.scenario_plugins.node_actions.node_actions_scenario_plugin as plugin_module
plugin_module.node_general = False
self.plugin = NodeActionsScenarioPlugin()
self.mock_kubecli = Mock(spec=KrknKubernetes)
self.mock_lib_telemetry = Mock(spec=KrknTelemetryOpenshift)
self.mock_lib_telemetry.get_lib_kubernetes.return_value = self.mock_kubecli
self.mock_scenario_telemetry = Mock(spec=ScenarioTelemetry)
self.mock_scenario_telemetry.affected_nodes = []
def test_get_scenario_types(self):
"""
@@ -49,700 +35,6 @@ class TestNodeActionsScenarioPlugin(unittest.TestCase):
self.assertEqual(result, ["node_scenarios"])
self.assertEqual(len(result), 1)
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.general_node_scenarios')
def test_get_node_scenario_object_generic(self, mock_general_scenarios):
"""
Test get_node_scenario_object returns general_node_scenarios for generic cloud type
"""
node_scenario = {"cloud_type": "generic"}
mock_general_instance = Mock()
mock_general_scenarios.return_value = mock_general_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_general_instance)
mock_general_scenarios.assert_called_once()
args = mock_general_scenarios.call_args[0]
self.assertEqual(args[0], self.mock_kubecli)
self.assertTrue(args[1]) # node_action_kube_check defaults to True
self.assertIsInstance(args[2], AffectedNodeStatus)
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.general_node_scenarios')
def test_get_node_scenario_object_no_cloud_type(self, mock_general_scenarios):
"""
Test get_node_scenario_object returns general_node_scenarios when cloud_type is not specified
"""
node_scenario = {}
mock_general_instance = Mock()
mock_general_scenarios.return_value = mock_general_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_general_instance)
mock_general_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.aws_node_scenarios')
def test_get_node_scenario_object_aws(self, mock_aws_scenarios):
"""
Test get_node_scenario_object returns aws_node_scenarios for AWS cloud type
"""
node_scenario = {"cloud_type": "aws"}
mock_aws_instance = Mock()
mock_aws_scenarios.return_value = mock_aws_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_aws_instance)
mock_aws_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.gcp_node_scenarios')
def test_get_node_scenario_object_gcp(self, mock_gcp_scenarios):
"""
Test get_node_scenario_object returns gcp_node_scenarios for GCP cloud type
"""
node_scenario = {"cloud_type": "gcp"}
mock_gcp_instance = Mock()
mock_gcp_scenarios.return_value = mock_gcp_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_gcp_instance)
mock_gcp_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.azure_node_scenarios')
def test_get_node_scenario_object_azure(self, mock_azure_scenarios):
"""
Test get_node_scenario_object returns azure_node_scenarios for Azure cloud type
"""
node_scenario = {"cloud_type": "azure"}
mock_azure_instance = Mock()
mock_azure_scenarios.return_value = mock_azure_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_azure_instance)
mock_azure_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.azure_node_scenarios')
def test_get_node_scenario_object_az(self, mock_azure_scenarios):
"""
Test get_node_scenario_object returns azure_node_scenarios for 'az' cloud type alias
"""
node_scenario = {"cloud_type": "az"}
mock_azure_instance = Mock()
mock_azure_scenarios.return_value = mock_azure_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_azure_instance)
mock_azure_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.docker_node_scenarios')
def test_get_node_scenario_object_docker(self, mock_docker_scenarios):
"""
Test get_node_scenario_object returns docker_node_scenarios for Docker cloud type
"""
node_scenario = {"cloud_type": "docker"}
mock_docker_instance = Mock()
mock_docker_scenarios.return_value = mock_docker_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_docker_instance)
mock_docker_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.vmware_node_scenarios')
def test_get_node_scenario_object_vmware(self, mock_vmware_scenarios):
"""
Test get_node_scenario_object returns vmware_node_scenarios for VMware cloud type
"""
node_scenario = {"cloud_type": "vmware"}
mock_vmware_instance = Mock()
mock_vmware_scenarios.return_value = mock_vmware_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_vmware_instance)
mock_vmware_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.vmware_node_scenarios')
def test_get_node_scenario_object_vsphere(self, mock_vmware_scenarios):
"""
Test get_node_scenario_object returns vmware_node_scenarios for vSphere cloud type alias
"""
node_scenario = {"cloud_type": "vsphere"}
mock_vmware_instance = Mock()
mock_vmware_scenarios.return_value = mock_vmware_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_vmware_instance)
mock_vmware_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.ibm_node_scenarios')
def test_get_node_scenario_object_ibm(self, mock_ibm_scenarios):
"""
Test get_node_scenario_object returns ibm_node_scenarios for IBM cloud type
"""
node_scenario = {"cloud_type": "ibm"}
mock_ibm_instance = Mock()
mock_ibm_scenarios.return_value = mock_ibm_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_ibm_instance)
mock_ibm_scenarios.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.ibm_node_scenarios')
def test_get_node_scenario_object_ibmcloud(self, mock_ibm_scenarios):
"""
Test get_node_scenario_object returns ibm_node_scenarios for ibmcloud cloud type alias
"""
node_scenario = {"cloud_type": "ibmcloud", "disable_ssl_verification": False}
mock_ibm_instance = Mock()
mock_ibm_scenarios.return_value = mock_ibm_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_ibm_instance)
args = mock_ibm_scenarios.call_args[0]
self.assertFalse(args[3]) # disable_ssl_verification should be False
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.ibmcloud_power_node_scenarios')
def test_get_node_scenario_object_ibmpower(self, mock_ibmpower_scenarios):
"""
Test get_node_scenario_object returns ibmcloud_power_node_scenarios for ibmpower cloud type
"""
node_scenario = {"cloud_type": "ibmpower"}
mock_ibmpower_instance = Mock()
mock_ibmpower_scenarios.return_value = mock_ibmpower_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_ibmpower_instance)
mock_ibmpower_scenarios.assert_called_once()
def test_get_node_scenario_object_openstack(self):
"""
Test get_node_scenario_object returns openstack_node_scenarios for OpenStack cloud type
"""
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.openstack_node_scenarios') as mock_openstack:
node_scenario = {"cloud_type": "openstack"}
mock_openstack_instance = Mock()
mock_openstack.return_value = mock_openstack_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_openstack_instance)
mock_openstack.assert_called_once()
def test_get_node_scenario_object_alibaba(self):
"""
Test get_node_scenario_object returns alibaba_node_scenarios for Alibaba cloud type
"""
with patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.alibaba_node_scenarios') as mock_alibaba:
node_scenario = {"cloud_type": "alibaba"}
mock_alibaba_instance = Mock()
mock_alibaba.return_value = mock_alibaba_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_alibaba_instance)
mock_alibaba.assert_called_once()
def test_get_node_scenario_object_alicloud(self):
"""
Test get_node_scenario_object returns alibaba_node_scenarios for alicloud alias
"""
with patch('krkn.scenario_plugins.node_actions.alibaba_node_scenarios.alibaba_node_scenarios') as mock_alibaba:
node_scenario = {"cloud_type": "alicloud"}
mock_alibaba_instance = Mock()
mock_alibaba.return_value = mock_alibaba_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_alibaba_instance)
mock_alibaba.assert_called_once()
def test_get_node_scenario_object_bm(self):
"""
Test get_node_scenario_object returns bm_node_scenarios for bare metal cloud type
"""
with patch('krkn.scenario_plugins.node_actions.bm_node_scenarios.bm_node_scenarios') as mock_bm:
node_scenario = {
"cloud_type": "bm",
"bmc_info": "192.168.1.1",
"bmc_user": "admin",
"bmc_password": "password"
}
mock_bm_instance = Mock()
mock_bm.return_value = mock_bm_instance
result = self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertEqual(result, mock_bm_instance)
args = mock_bm.call_args[0]
self.assertEqual(args[0], "192.168.1.1")
self.assertEqual(args[1], "admin")
self.assertEqual(args[2], "password")
def test_get_node_scenario_object_unsupported_cloud(self):
"""
Test get_node_scenario_object raises exception for unsupported cloud type
"""
node_scenario = {"cloud_type": "unsupported_cloud"}
with self.assertRaises(Exception) as context:
self.plugin.get_node_scenario_object(node_scenario, self.mock_kubecli)
self.assertIn("not currently supported", str(context.exception))
self.assertIn("unsupported_cloud", str(context.exception))
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.common_node_functions')
def test_inject_node_scenario_with_node_name(self, mock_common_funcs):
"""
Test inject_node_scenario with specific node name
"""
node_scenario = {
"node_name": "node1,node2",
"instance_count": 2,
"runs": 1,
"timeout": 120,
"duration": 60,
"poll_interval": 15
}
action = "node_stop_start_scenario"
mock_scenario_object = Mock()
mock_scenario_object.affected_nodes_status = AffectedNodeStatus()
mock_scenario_object.affected_nodes_status.affected_nodes = []
mock_common_funcs.get_node_by_name.return_value = ["node1", "node2"]
self.plugin.inject_node_scenario(
action,
node_scenario,
mock_scenario_object,
self.mock_kubecli,
self.mock_scenario_telemetry
)
mock_common_funcs.get_node_by_name.assert_called_once_with(["node1", "node2"], self.mock_kubecli)
self.assertEqual(mock_scenario_object.node_stop_start_scenario.call_count, 2)
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.common_node_functions')
def test_inject_node_scenario_with_label_selector(self, mock_common_funcs):
"""
Test inject_node_scenario with label selector
"""
node_scenario = {
"label_selector": "node-role.kubernetes.io/worker",
"instance_count": 1
}
action = "node_reboot_scenario"
mock_scenario_object = Mock()
mock_scenario_object.affected_nodes_status = AffectedNodeStatus()
mock_scenario_object.affected_nodes_status.affected_nodes = []
mock_common_funcs.get_node.return_value = ["worker-node-1"]
self.plugin.inject_node_scenario(
action,
node_scenario,
mock_scenario_object,
self.mock_kubecli,
self.mock_scenario_telemetry
)
mock_common_funcs.get_node.assert_called_once_with("node-role.kubernetes.io/worker", 1, self.mock_kubecli)
mock_scenario_object.node_reboot_scenario.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.common_node_functions')
def test_inject_node_scenario_with_exclude_label(self, mock_common_funcs):
"""
Test inject_node_scenario with exclude label
"""
node_scenario = {
"label_selector": "node-role.kubernetes.io/worker",
"exclude_label": "node-role.kubernetes.io/master",
"instance_count": 2
}
action = "node_stop_scenario"
mock_scenario_object = Mock()
mock_scenario_object.affected_nodes_status = AffectedNodeStatus()
mock_scenario_object.affected_nodes_status.affected_nodes = []
mock_common_funcs.get_node.side_effect = [
["worker-1", "master-1"],
["master-1"]
]
self.plugin.inject_node_scenario(
action,
node_scenario,
mock_scenario_object,
self.mock_kubecli,
self.mock_scenario_telemetry
)
self.assertEqual(mock_common_funcs.get_node.call_count, 2)
# Should only process worker-1 after excluding master-1
self.assertEqual(mock_scenario_object.node_stop_scenario.call_count, 1)
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.common_node_functions')
def test_inject_node_scenario_parallel_mode(self, mock_common_funcs):
"""
Test inject_node_scenario with parallel processing
"""
node_scenario = {
"node_name": "node1,node2,node3",
"parallel": True
}
action = "restart_kubelet_scenario"
mock_scenario_object = Mock()
mock_scenario_object.affected_nodes_status = AffectedNodeStatus()
mock_scenario_object.affected_nodes_status.affected_nodes = []
mock_common_funcs.get_node_by_name.return_value = ["node1", "node2", "node3"]
with patch.object(self.plugin, 'multiprocess_nodes') as mock_multiprocess:
self.plugin.inject_node_scenario(
action,
node_scenario,
mock_scenario_object,
self.mock_kubecli,
self.mock_scenario_telemetry
)
mock_multiprocess.assert_called_once()
args = mock_multiprocess.call_args[0]
self.assertEqual(args[0], ["node1", "node2", "node3"])
self.assertEqual(args[2], action)
def test_run_node_node_start_scenario(self):
"""
Test run_node executes node_start_scenario action
"""
node_scenario = {"runs": 2, "timeout": 300, "poll_interval": 10}
action = "node_start_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_start_scenario.assert_called_once_with(2, "test-node", 300, 10)
def test_run_node_node_stop_scenario(self):
"""
Test run_node executes node_stop_scenario action
"""
node_scenario = {"runs": 1, "timeout": 120, "poll_interval": 15}
action = "node_stop_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_stop_scenario.assert_called_once_with(1, "test-node", 120, 15)
def test_run_node_node_stop_start_scenario(self):
"""
Test run_node executes node_stop_start_scenario action
"""
node_scenario = {"runs": 1, "timeout": 120, "duration": 60, "poll_interval": 15}
action = "node_stop_start_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_stop_start_scenario.assert_called_once_with(1, "test-node", 120, 60, 15)
def test_run_node_node_termination_scenario(self):
"""
Test run_node executes node_termination_scenario action
"""
node_scenario = {}
action = "node_termination_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_termination_scenario.assert_called_once_with(1, "test-node", 120, 15)
def test_run_node_node_reboot_scenario(self):
"""
Test run_node executes node_reboot_scenario action
"""
node_scenario = {"soft_reboot": True}
action = "node_reboot_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_reboot_scenario.assert_called_once_with(1, "test-node", 120, True)
def test_run_node_node_disk_detach_attach_scenario(self):
"""
Test run_node executes node_disk_detach_attach_scenario action
"""
node_scenario = {"duration": 90}
action = "node_disk_detach_attach_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_disk_detach_attach_scenario.assert_called_once_with(1, "test-node", 120, 90)
def test_run_node_stop_start_kubelet_scenario(self):
"""
Test run_node executes stop_start_kubelet_scenario action
"""
node_scenario = {}
action = "stop_start_kubelet_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.stop_start_kubelet_scenario.assert_called_once_with(1, "test-node", 120)
def test_run_node_restart_kubelet_scenario(self):
"""
Test run_node executes restart_kubelet_scenario action
"""
node_scenario = {}
action = "restart_kubelet_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.restart_kubelet_scenario.assert_called_once_with(1, "test-node", 120)
def test_run_node_stop_kubelet_scenario(self):
"""
Test run_node executes stop_kubelet_scenario action
"""
node_scenario = {}
action = "stop_kubelet_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.stop_kubelet_scenario.assert_called_once_with(1, "test-node", 120)
def test_run_node_node_crash_scenario(self):
"""
Test run_node executes node_crash_scenario action
"""
node_scenario = {}
action = "node_crash_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_crash_scenario.assert_called_once_with(1, "test-node", 120)
def test_run_node_node_block_scenario(self):
"""
Test run_node executes node_block_scenario action
"""
node_scenario = {"duration": 100}
action = "node_block_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.node_block_scenario.assert_called_once_with(1, "test-node", 120, 100)
@patch('logging.info')
def test_run_node_stop_start_helper_node_scenario_openstack(self, mock_logging):
"""
Test run_node executes stop_start_helper_node_scenario for OpenStack
"""
node_scenario = {
"cloud_type": "openstack",
"helper_node_ip": "192.168.1.100",
"service": "neutron-server"
}
action = "stop_start_helper_node_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_scenario_object.helper_node_stop_start_scenario.assert_called_once_with(1, "192.168.1.100", 120)
mock_scenario_object.helper_node_service_status.assert_called_once()
@patch('logging.error')
def test_run_node_stop_start_helper_node_scenario_non_openstack(self, mock_logging):
"""
Test run_node logs error for stop_start_helper_node_scenario on non-OpenStack
"""
node_scenario = {
"cloud_type": "aws",
"helper_node_ip": "192.168.1.100"
}
action = "stop_start_helper_node_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_logging.assert_called()
self.assertIn("not supported", str(mock_logging.call_args))
@patch('logging.error')
def test_run_node_stop_start_helper_node_scenario_missing_ip(self, mock_logging):
"""
Test run_node raises exception when helper_node_ip is missing
"""
node_scenario = {
"cloud_type": "openstack",
"helper_node_ip": None
}
action = "stop_start_helper_node_scenario"
mock_scenario_object = Mock()
with self.assertRaises(Exception) as context:
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
self.assertIn("Helper node IP address is not provided", str(context.exception))
@patch('logging.info')
def test_run_node_generic_cloud_skip_unsupported_action(self, mock_logging):
"""
Test run_node skips unsupported actions for generic cloud type
"""
# Set node_general to True for this test
import krkn.scenario_plugins.node_actions.node_actions_scenario_plugin as plugin_module
plugin_module.node_general = True
node_scenario = {}
action = "node_stop_scenario"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_logging.assert_called()
self.assertIn("not set up for generic cloud type", str(mock_logging.call_args))
@patch('logging.info')
def test_run_node_unknown_action(self, mock_logging):
"""
Test run_node logs info for unknown action
"""
node_scenario = {}
action = "unknown_action"
mock_scenario_object = Mock()
self.plugin.run_node("test-node", mock_scenario_object, action, node_scenario)
mock_logging.assert_called()
# Could be either message depending on node_general state
call_str = str(mock_logging.call_args)
self.assertTrue(
"no node action that matches" in call_str or
"not set up for generic cloud type" in call_str
)
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.cerberus')
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.common_node_functions')
@patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.general_node_scenarios')
@patch('builtins.open', new_callable=mock_open)
@patch('time.time')
def test_run_successful(self, mock_time, mock_file, mock_general_scenarios, mock_common_funcs, mock_cerberus):
"""
Test successful run of node actions scenario
"""
scenario_yaml = {
"node_scenarios": [
{
"cloud_type": "generic",
"node_name": "test-node",
"actions": ["stop_kubelet_scenario"]
}
]
}
mock_file.return_value.__enter__.return_value.read.return_value = yaml.dump(scenario_yaml)
mock_time.side_effect = [1000, 1100]
mock_scenario_object = Mock()
mock_scenario_object.affected_nodes_status = AffectedNodeStatus()
mock_scenario_object.affected_nodes_status.affected_nodes = []
mock_general_scenarios.return_value = mock_scenario_object
mock_common_funcs.get_node_by_name.return_value = ["test-node"]
mock_cerberus.get_status.return_value = None
with patch('yaml.full_load', return_value=scenario_yaml):
result = self.plugin.run(
"test-uuid",
"/path/to/scenario.yaml",
{},
self.mock_lib_telemetry,
self.mock_scenario_telemetry
)
self.assertEqual(result, 0)
mock_cerberus.get_status.assert_called_once_with({}, 1000, 1100)
@patch('logging.error')
@patch('builtins.open', new_callable=mock_open)
def test_run_with_exception(self, mock_file, mock_logging):
"""
Test run handles exceptions and returns 1
"""
scenario_yaml = {
"node_scenarios": [
{
"cloud_type": "unsupported"
}
]
}
with patch('yaml.full_load', return_value=scenario_yaml):
result = self.plugin.run(
"test-uuid",
"/path/to/scenario.yaml",
{},
self.mock_lib_telemetry,
self.mock_scenario_telemetry
)
self.assertEqual(result, 1)
mock_logging.assert_called()
@patch('logging.info')
def test_multiprocess_nodes(self, mock_logging):
"""
Test multiprocess_nodes executes run_node for multiple nodes in parallel
"""
nodes = ["node1", "node2", "node3"]
mock_scenario_object = Mock()
action = "restart_kubelet_scenario"
node_scenario = {}
with patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.ThreadPool') as mock_pool:
mock_pool_instance = Mock()
mock_pool.return_value = mock_pool_instance
self.plugin.multiprocess_nodes(nodes, mock_scenario_object, action, node_scenario)
mock_pool.assert_called_once_with(processes=3)
mock_pool_instance.starmap.assert_called_once()
mock_pool_instance.close.assert_called_once()
@patch('logging.info')
def test_multiprocess_nodes_with_exception(self, mock_logging):
"""
Test multiprocess_nodes handles exceptions gracefully
"""
nodes = ["node1", "node2"]
mock_scenario_object = Mock()
action = "node_reboot_scenario"
node_scenario = {}
with patch('krkn.scenario_plugins.node_actions.node_actions_scenario_plugin.ThreadPool') as mock_pool:
mock_pool.side_effect = Exception("Pool error")
self.plugin.multiprocess_nodes(nodes, mock_scenario_object, action, node_scenario)
mock_logging.assert_called()
self.assertIn("Error on pool multiprocessing", str(mock_logging.call_args))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,719 +0,0 @@
#!/usr/bin/env python3
"""
Test suite for OpenStack node scenarios
This test suite covers both the OPENSTACKCLOUD class and openstack_node_scenarios class
using mocks to avoid actual OpenStack CLI calls.
Usage:
python -m coverage run -a -m unittest tests/test_openstack_node_scenarios.py -v
Assisted By: Claude Code
"""
import unittest
from unittest.mock import MagicMock, patch, Mock
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.models.k8s import AffectedNode, AffectedNodeStatus
from krkn.scenario_plugins.node_actions.openstack_node_scenarios import (
OPENSTACKCLOUD,
openstack_node_scenarios
)
class TestOPENSTACKCLOUD(unittest.TestCase):
"""Test cases for OPENSTACKCLOUD class"""
def setUp(self):
"""Set up test fixtures"""
self.openstack = OPENSTACKCLOUD()
def test_openstackcloud_init(self):
"""Test OPENSTACKCLOUD class initialization"""
self.assertEqual(self.openstack.Wait, 30)
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD.get_openstack_nodename')
def test_get_instance_id(self, mock_get_nodename):
"""Test getting instance ID by node IP"""
node_ip = '10.0.1.100'
node_name = 'test-openstack-node'
mock_get_nodename.return_value = node_name
result = self.openstack.get_instance_id(node_ip)
self.assertEqual(result, node_name)
mock_get_nodename.assert_called_once_with(node_ip)
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_start_instances_success(self, mock_invoke, mock_logging):
"""Test starting instance successfully"""
node_name = 'test-node'
self.openstack.start_instances(node_name)
mock_invoke.assert_called_once_with('openstack server start %s' % node_name)
mock_logging.assert_called()
self.assertIn("started", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_start_instances_failure(self, mock_invoke, mock_logging):
"""Test starting instance with failure"""
node_name = 'test-node'
mock_invoke.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.openstack.start_instances(node_name)
mock_logging.assert_called()
self.assertIn("Failed to start", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_stop_instances_success(self, mock_invoke, mock_logging):
"""Test stopping instance successfully"""
node_name = 'test-node'
self.openstack.stop_instances(node_name)
mock_invoke.assert_called_once_with('openstack server stop %s' % node_name)
mock_logging.assert_called()
self.assertIn("stopped", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_stop_instances_failure(self, mock_invoke, mock_logging):
"""Test stopping instance with failure"""
node_name = 'test-node'
mock_invoke.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.openstack.stop_instances(node_name)
mock_logging.assert_called()
self.assertIn("Failed to stop", str(mock_logging.call_args))
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_reboot_instances_success(self, mock_invoke, mock_logging):
"""Test rebooting instance successfully"""
node_name = 'test-node'
self.openstack.reboot_instances(node_name)
mock_invoke.assert_called_once_with('openstack server reboot --soft %s' % node_name)
mock_logging.assert_called()
self.assertIn("rebooted", str(mock_logging.call_args))
@patch('logging.error')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_reboot_instances_failure(self, mock_invoke, mock_logging):
"""Test rebooting instance with failure"""
node_name = 'test-node'
mock_invoke.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.openstack.reboot_instances(node_name)
mock_logging.assert_called()
self.assertIn("Failed to reboot", str(mock_logging.call_args))
@patch('time.time')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD.get_instance_status')
def test_wait_until_running_success(self, mock_get_status, mock_time):
"""Test waiting until instance is running successfully"""
node_name = 'test-node'
timeout = 300
mock_time.side_effect = [100, 110]
mock_get_status.return_value = True
affected_node = Mock(spec=AffectedNode)
result = self.openstack.wait_until_running(node_name, timeout, affected_node)
self.assertTrue(result)
mock_get_status.assert_called_once_with(node_name, "ACTIVE", timeout)
affected_node.set_affected_node_status.assert_called_once_with("running", 10)
@patch('time.time')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD.get_instance_status')
def test_wait_until_running_without_affected_node(self, mock_get_status, mock_time):
"""Test waiting until running without affected node tracking"""
node_name = 'test-node'
timeout = 300
mock_get_status.return_value = True
result = self.openstack.wait_until_running(node_name, timeout, None)
self.assertTrue(result)
@patch('time.time')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD.get_instance_status')
def test_wait_until_stopped_success(self, mock_get_status, mock_time):
"""Test waiting until instance is stopped successfully"""
node_name = 'test-node'
timeout = 300
mock_time.side_effect = [100, 115]
mock_get_status.return_value = True
affected_node = Mock(spec=AffectedNode)
result = self.openstack.wait_until_stopped(node_name, timeout, affected_node)
self.assertTrue(result)
mock_get_status.assert_called_once_with(node_name, "SHUTOFF", timeout)
affected_node.set_affected_node_status.assert_called_once_with("stopped", 15)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_get_instance_status_success(self, mock_invoke, mock_logging, mock_sleep):
"""Test getting instance status when it matches expected status"""
node_name = 'test-node'
expected_status = 'ACTIVE'
timeout = 60
mock_invoke.return_value = 'ACTIVE'
result = self.openstack.get_instance_status(node_name, expected_status, timeout)
self.assertTrue(result)
mock_invoke.assert_called()
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_get_instance_status_timeout(self, mock_invoke, mock_logging, mock_sleep):
"""Test getting instance status with timeout"""
node_name = 'test-node'
expected_status = 'ACTIVE'
timeout = 2
mock_invoke.return_value = 'SHUTOFF'
result = self.openstack.get_instance_status(node_name, expected_status, timeout)
self.assertFalse(result)
@patch('time.sleep')
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_get_instance_status_with_whitespace(self, mock_invoke, mock_logging, mock_sleep):
"""Test getting instance status with whitespace in response"""
node_name = 'test-node'
expected_status = 'ACTIVE'
timeout = 60
mock_invoke.return_value = ' ACTIVE '
result = self.openstack.get_instance_status(node_name, expected_status, timeout)
self.assertTrue(result)
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_get_openstack_nodename_success(self, mock_invoke, mock_logging):
"""Test getting OpenStack node name by IP"""
node_ip = '10.0.1.100'
# Mock OpenStack server list output
mock_output = """| 12345 | test-node | ACTIVE | network1=10.0.1.100 |"""
mock_invoke.return_value = mock_output
result = self.openstack.get_openstack_nodename(node_ip)
self.assertEqual(result, 'test-node')
mock_invoke.assert_called_once()
@patch('logging.info')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_get_openstack_nodename_multiple_servers(self, mock_invoke, mock_logging):
"""Test getting OpenStack node name with multiple servers"""
node_ip = '10.0.1.101'
# Mock OpenStack server list output with multiple servers
mock_output = """| 12345 | test-node-1 | ACTIVE | network1=10.0.1.100 |
| 67890 | test-node-2 | ACTIVE | network1=10.0.1.101 |"""
mock_invoke.return_value = mock_output
result = self.openstack.get_openstack_nodename(node_ip)
self.assertEqual(result, 'test-node-2')
@patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.runcommand.invoke')
def test_get_openstack_nodename_no_match(self, mock_invoke):
"""Test getting OpenStack node name with no matching IP"""
node_ip = '10.0.1.200'
mock_output = """| 12345 | test-node | ACTIVE | network1=10.0.1.100 |"""
mock_invoke.return_value = mock_output
result = self.openstack.get_openstack_nodename(node_ip)
self.assertIsNone(result)
class TestOpenstackNodeScenarios(unittest.TestCase):
"""Test cases for openstack_node_scenarios class"""
def setUp(self):
"""Set up test fixtures"""
self.kubecli = MagicMock(spec=KrknKubernetes)
self.affected_nodes_status = AffectedNodeStatus()
# Mock the OPENSTACKCLOUD class
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD') as mock_openstack_class:
self.mock_openstack = MagicMock()
mock_openstack_class.return_value = self.mock_openstack
self.scenario = openstack_node_scenarios(
kubecli=self.kubecli,
node_action_kube_check=True,
affected_nodes_status=self.affected_nodes_status
)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_start_scenario_success(self, mock_wait_ready):
"""Test node start scenario successfully"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
self.kubecli.get_node_ip.return_value = node_ip
self.mock_openstack.get_instance_id.return_value = openstack_node_name
self.mock_openstack.start_instances.return_value = None
self.mock_openstack.wait_until_running.return_value = True
self.scenario.node_start_scenario(
instance_kill_count=1,
node=node,
timeout=600,
poll_interval=15
)
self.kubecli.get_node_ip.assert_called_once_with(node)
self.mock_openstack.get_instance_id.assert_called_once_with(node_ip)
self.mock_openstack.start_instances.assert_called_once_with(openstack_node_name)
self.mock_openstack.wait_until_running.assert_called_once()
mock_wait_ready.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
def test_node_start_scenario_no_kube_check(self, mock_wait_ready):
"""Test node start scenario without kube check"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
# Create scenario with node_action_kube_check=False
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD') as mock_openstack_class:
mock_openstack = MagicMock()
mock_openstack_class.return_value = mock_openstack
scenario = openstack_node_scenarios(
kubecli=self.kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
self.kubecli.get_node_ip.return_value = node_ip
mock_openstack.get_instance_id.return_value = openstack_node_name
mock_openstack.start_instances.return_value = None
mock_openstack.wait_until_running.return_value = True
scenario.node_start_scenario(
instance_kill_count=1,
node=node,
timeout=600,
poll_interval=15
)
# Should not call wait_for_ready_status
mock_wait_ready.assert_not_called()
def test_node_start_scenario_failure(self):
"""Test node start scenario with failure"""
node = 'test-node'
node_ip = '10.0.1.100'
self.kubecli.get_node_ip.return_value = node_ip
self.mock_openstack.get_instance_id.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.scenario.node_start_scenario(
instance_kill_count=1,
node=node,
timeout=600,
poll_interval=15
)
def test_node_start_scenario_multiple_kills(self):
"""Test node start scenario with multiple kill counts"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD') as mock_openstack_class:
mock_openstack = MagicMock()
mock_openstack_class.return_value = mock_openstack
scenario = openstack_node_scenarios(
kubecli=self.kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
self.kubecli.get_node_ip.return_value = node_ip
mock_openstack.get_instance_id.return_value = openstack_node_name
mock_openstack.start_instances.return_value = None
mock_openstack.wait_until_running.return_value = True
scenario.node_start_scenario(
instance_kill_count=3,
node=node,
timeout=600,
poll_interval=15
)
self.assertEqual(mock_openstack.start_instances.call_count, 3)
self.assertEqual(len(scenario.affected_nodes_status.affected_nodes), 3)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_not_ready_status')
def test_node_stop_scenario_success(self, mock_wait_not_ready):
"""Test node stop scenario successfully"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
self.kubecli.get_node_ip.return_value = node_ip
self.mock_openstack.get_instance_id.return_value = openstack_node_name
self.mock_openstack.stop_instances.return_value = None
self.mock_openstack.wait_until_stopped.return_value = True
self.scenario.node_stop_scenario(
instance_kill_count=1,
node=node,
timeout=600,
poll_interval=15
)
self.kubecli.get_node_ip.assert_called_once_with(node)
self.mock_openstack.get_instance_id.assert_called_once_with(node_ip)
self.mock_openstack.stop_instances.assert_called_once_with(openstack_node_name)
self.mock_openstack.wait_until_stopped.assert_called_once()
mock_wait_not_ready.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_not_ready_status')
def test_node_stop_scenario_no_kube_check(self, mock_wait_not_ready):
"""Test node stop scenario without kube check"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
# Create scenario with node_action_kube_check=False
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD') as mock_openstack_class:
mock_openstack = MagicMock()
mock_openstack_class.return_value = mock_openstack
scenario = openstack_node_scenarios(
kubecli=self.kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
self.kubecli.get_node_ip.return_value = node_ip
mock_openstack.get_instance_id.return_value = openstack_node_name
mock_openstack.stop_instances.return_value = None
mock_openstack.wait_until_stopped.return_value = True
scenario.node_stop_scenario(
instance_kill_count=1,
node=node,
timeout=600,
poll_interval=15
)
# Should not call wait_for_not_ready_status
mock_wait_not_ready.assert_not_called()
def test_node_stop_scenario_failure(self):
"""Test node stop scenario with failure"""
node = 'test-node'
node_ip = '10.0.1.100'
self.kubecli.get_node_ip.return_value = node_ip
self.mock_openstack.get_instance_id.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.scenario.node_stop_scenario(
instance_kill_count=1,
node=node,
timeout=600,
poll_interval=15
)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_unknown_status')
def test_node_reboot_scenario_success(self, mock_wait_unknown, mock_wait_ready):
"""Test node reboot scenario successfully"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
self.kubecli.get_node_ip.return_value = node_ip
self.mock_openstack.get_instance_id.return_value = openstack_node_name
self.mock_openstack.reboot_instances.return_value = None
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node=node,
timeout=600
)
self.kubecli.get_node_ip.assert_called_once_with(node)
self.mock_openstack.get_instance_id.assert_called_once_with(node_ip)
self.mock_openstack.reboot_instances.assert_called_once_with(openstack_node_name)
mock_wait_unknown.assert_called_once()
mock_wait_ready.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_ready_status')
@patch('krkn.scenario_plugins.node_actions.common_node_functions.wait_for_unknown_status')
def test_node_reboot_scenario_no_kube_check(self, mock_wait_unknown, mock_wait_ready):
"""Test node reboot scenario without kube check"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
# Create scenario with node_action_kube_check=False
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD') as mock_openstack_class:
mock_openstack = MagicMock()
mock_openstack_class.return_value = mock_openstack
scenario = openstack_node_scenarios(
kubecli=self.kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
self.kubecli.get_node_ip.return_value = node_ip
mock_openstack.get_instance_id.return_value = openstack_node_name
mock_openstack.reboot_instances.return_value = None
scenario.node_reboot_scenario(
instance_kill_count=1,
node=node,
timeout=600
)
# Should not call wait functions
mock_wait_unknown.assert_not_called()
mock_wait_ready.assert_not_called()
def test_node_reboot_scenario_failure(self):
"""Test node reboot scenario with failure"""
node = 'test-node'
node_ip = '10.0.1.100'
self.kubecli.get_node_ip.return_value = node_ip
self.mock_openstack.get_instance_id.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.scenario.node_reboot_scenario(
instance_kill_count=1,
node=node,
timeout=600
)
def test_node_reboot_scenario_multiple_kills(self):
"""Test node reboot scenario with multiple kill counts"""
node = 'test-node'
node_ip = '10.0.1.100'
openstack_node_name = 'openstack-test-node'
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD') as mock_openstack_class:
mock_openstack = MagicMock()
mock_openstack_class.return_value = mock_openstack
scenario = openstack_node_scenarios(
kubecli=self.kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
self.kubecli.get_node_ip.return_value = node_ip
mock_openstack.get_instance_id.return_value = openstack_node_name
mock_openstack.reboot_instances.return_value = None
scenario.node_reboot_scenario(
instance_kill_count=3,
node=node,
timeout=600
)
self.assertEqual(mock_openstack.reboot_instances.call_count, 3)
self.assertEqual(len(scenario.affected_nodes_status.affected_nodes), 3)
def test_helper_node_start_scenario_success(self):
"""Test helper node start scenario successfully"""
node_ip = '192.168.1.50'
openstack_node_name = 'helper-node'
self.mock_openstack.get_openstack_nodename.return_value = openstack_node_name
self.mock_openstack.start_instances.return_value = None
self.mock_openstack.wait_until_running.return_value = True
self.scenario.helper_node_start_scenario(
instance_kill_count=1,
node_ip=node_ip,
timeout=600
)
self.mock_openstack.get_openstack_nodename.assert_called_once_with(node_ip.strip())
self.mock_openstack.start_instances.assert_called_once_with(openstack_node_name)
self.mock_openstack.wait_until_running.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
def test_helper_node_start_scenario_failure(self):
"""Test helper node start scenario with failure"""
node_ip = '192.168.1.50'
self.mock_openstack.get_openstack_nodename.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.scenario.helper_node_start_scenario(
instance_kill_count=1,
node_ip=node_ip,
timeout=600
)
def test_helper_node_start_scenario_multiple_kills(self):
"""Test helper node start scenario with multiple kill counts"""
node_ip = '192.168.1.50'
openstack_node_name = 'helper-node'
with patch('krkn.scenario_plugins.node_actions.openstack_node_scenarios.OPENSTACKCLOUD') as mock_openstack_class:
mock_openstack = MagicMock()
mock_openstack_class.return_value = mock_openstack
scenario = openstack_node_scenarios(
kubecli=self.kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
mock_openstack.get_openstack_nodename.return_value = openstack_node_name
mock_openstack.start_instances.return_value = None
mock_openstack.wait_until_running.return_value = True
scenario.helper_node_start_scenario(
instance_kill_count=2,
node_ip=node_ip,
timeout=600
)
self.assertEqual(mock_openstack.start_instances.call_count, 2)
self.assertEqual(len(scenario.affected_nodes_status.affected_nodes), 2)
def test_helper_node_stop_scenario_success(self):
"""Test helper node stop scenario successfully"""
node_ip = '192.168.1.50'
openstack_node_name = 'helper-node'
self.mock_openstack.get_openstack_nodename.return_value = openstack_node_name
self.mock_openstack.stop_instances.return_value = None
self.mock_openstack.wait_until_stopped.return_value = True
self.scenario.helper_node_stop_scenario(
instance_kill_count=1,
node_ip=node_ip,
timeout=600
)
self.mock_openstack.get_openstack_nodename.assert_called_once_with(node_ip.strip())
self.mock_openstack.stop_instances.assert_called_once_with(openstack_node_name)
self.mock_openstack.wait_until_stopped.assert_called_once()
self.assertEqual(len(self.affected_nodes_status.affected_nodes), 1)
def test_helper_node_stop_scenario_failure(self):
"""Test helper node stop scenario with failure"""
node_ip = '192.168.1.50'
self.mock_openstack.get_openstack_nodename.side_effect = Exception("OpenStack error")
with self.assertRaises(RuntimeError):
self.scenario.helper_node_stop_scenario(
instance_kill_count=1,
node_ip=node_ip,
timeout=600
)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.check_service_status')
def test_helper_node_service_status_success(self, mock_check_service):
"""Test helper node service status check successfully"""
node_ip = '192.168.1.50'
service = 'kubelet'
ssh_private_key = '/path/to/key'
timeout = 300
mock_check_service.return_value = None
self.scenario.helper_node_service_status(
node_ip=node_ip,
service=service,
ssh_private_key=ssh_private_key,
timeout=timeout
)
mock_check_service.assert_called_once_with(
node_ip.strip(),
service,
ssh_private_key,
timeout
)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.check_service_status')
def test_helper_node_service_status_failure(self, mock_check_service):
"""Test helper node service status check with failure"""
node_ip = '192.168.1.50'
service = 'kubelet'
ssh_private_key = '/path/to/key'
timeout = 300
mock_check_service.side_effect = Exception("Service check failed")
with self.assertRaises(RuntimeError):
self.scenario.helper_node_service_status(
node_ip=node_ip,
service=service,
ssh_private_key=ssh_private_key,
timeout=timeout
)
@patch('krkn.scenario_plugins.node_actions.common_node_functions.check_service_status')
def test_helper_node_service_status_with_whitespace_ip(self, mock_check_service):
"""Test helper node service status with whitespace in IP"""
node_ip = ' 192.168.1.50 '
service = 'kubelet'
ssh_private_key = '/path/to/key'
timeout = 300
mock_check_service.return_value = None
self.scenario.helper_node_service_status(
node_ip=node_ip,
service=service,
ssh_private_key=ssh_private_key,
timeout=timeout
)
# Verify IP was stripped
mock_check_service.assert_called_once_with(
node_ip.strip(),
service,
ssh_private_key,
timeout
)
if __name__ == "__main__":
unittest.main()

View File

@@ -9,22 +9,16 @@ Usage:
Assisted By: Claude Code
"""
import base64
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import yaml
from unittest.mock import MagicMock
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn.scenario_plugins.pvc.pvc_scenario_plugin import PvcScenarioPlugin
from krkn.rollback.config import RollbackContent
class TestPvcScenarioPlugin(unittest.TestCase):
"""Unit tests for PvcScenarioPlugin class"""
def setUp(self):
"""
@@ -42,790 +36,5 @@ class TestPvcScenarioPlugin(unittest.TestCase):
self.assertEqual(len(result), 1)
class TestToKbytes(unittest.TestCase):
"""Tests for the to_kbytes method"""
def setUp(self):
"""Set up test fixtures"""
self.plugin = PvcScenarioPlugin()
def test_to_kbytes_1ki(self):
"""Test to_kbytes with 1Ki"""
self.assertEqual(self.plugin.to_kbytes("1Ki"), 1)
def test_to_kbytes_2ki(self):
"""Test to_kbytes with 2Ki"""
self.assertEqual(self.plugin.to_kbytes("2Ki"), 2)
def test_to_kbytes_1mi(self):
"""Test to_kbytes with 1Mi"""
self.assertEqual(self.plugin.to_kbytes("1Mi"), 1024)
def test_to_kbytes_2mi(self):
"""Test to_kbytes with 2Mi"""
self.assertEqual(self.plugin.to_kbytes("2Mi"), 2 * 1024)
def test_to_kbytes_1gi(self):
"""Test to_kbytes with 1Gi"""
self.assertEqual(self.plugin.to_kbytes("1Gi"), 1024 * 1024)
def test_to_kbytes_5gi(self):
"""Test to_kbytes with 5Gi"""
self.assertEqual(self.plugin.to_kbytes("5Gi"), 5 * 1024 * 1024)
def test_to_kbytes_1ti(self):
"""Test to_kbytes with 1Ti"""
self.assertEqual(self.plugin.to_kbytes("1Ti"), 1024 * 1024 * 1024)
def test_to_kbytes_invalid_missing_i(self):
"""Test to_kbytes raises RuntimeError for 1K (missing i)"""
with self.assertRaises(RuntimeError):
self.plugin.to_kbytes("1K")
def test_to_kbytes_invalid_format_mb(self):
"""Test to_kbytes raises RuntimeError for 1MB"""
with self.assertRaises(RuntimeError):
self.plugin.to_kbytes("1MB")
def test_to_kbytes_invalid_extra_char(self):
"""Test to_kbytes raises RuntimeError for 1Gib"""
with self.assertRaises(RuntimeError):
self.plugin.to_kbytes("1Gib")
def test_to_kbytes_invalid_non_numeric(self):
"""Test to_kbytes raises RuntimeError for abc"""
with self.assertRaises(RuntimeError):
self.plugin.to_kbytes("abc")
def test_to_kbytes_invalid_missing_unit(self):
"""Test to_kbytes raises RuntimeError for 1024"""
with self.assertRaises(RuntimeError):
self.plugin.to_kbytes("1024")
def test_to_kbytes_invalid_empty(self):
"""Test to_kbytes raises RuntimeError for empty string"""
with self.assertRaises(RuntimeError):
self.plugin.to_kbytes("")
def test_to_kbytes_invalid_unsupported_unit(self):
"""Test to_kbytes raises RuntimeError for 1Pi (unsupported)"""
with self.assertRaises(RuntimeError):
self.plugin.to_kbytes("1Pi")
class TestRemoveTempFile(unittest.TestCase):
"""Tests for the remove_temp_file method"""
def setUp(self):
"""Set up test fixtures"""
self.plugin = PvcScenarioPlugin()
def test_remove_temp_file_success(self):
"""Test successful removal of temp file"""
mock_kubecli = MagicMock(spec=KrknKubernetes)
# Simulate file not present in ls output after removal
mock_kubecli.exec_cmd_in_pod.side_effect = [
"", # rm -f command output
"total 0\ndrwxr-xr-x 2 root root 40 Jan 1 00:00 .", # ls -lh output without kraken.tmp
]
# Should not raise any exception
self.plugin.remove_temp_file(
file_name="kraken.tmp",
full_path="/mnt/data/kraken.tmp",
pod_name="test-pod",
namespace="test-ns",
container_name="test-container",
mount_path="/mnt/data",
file_size_kb=1024,
kubecli=mock_kubecli,
)
# Verify exec_cmd_in_pod was called twice (rm and ls)
self.assertEqual(mock_kubecli.exec_cmd_in_pod.call_count, 2)
def test_remove_temp_file_failure(self):
"""Test removal failure when file still exists"""
mock_kubecli = MagicMock(spec=KrknKubernetes)
# Simulate file still present in ls output after removal attempt
mock_kubecli.exec_cmd_in_pod.side_effect = [
"", # rm -f command output
"total 1024\n-rw-r--r-- 1 root root 1M Jan 1 00:00 kraken.tmp", # ls -lh output with kraken.tmp
]
with self.assertRaises(RuntimeError):
self.plugin.remove_temp_file(
file_name="kraken.tmp",
full_path="/mnt/data/kraken.tmp",
pod_name="test-pod",
namespace="test-ns",
container_name="test-container",
mount_path="/mnt/data",
file_size_kb=1024,
kubecli=mock_kubecli,
)
class TestRollbackTempFile(unittest.TestCase):
"""Tests for the rollback_temp_file static method"""
def test_rollback_temp_file_success(self):
"""Test successful rollback removes temp file"""
# Create mock telemetry
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Simulate successful file removal
mock_kubecli.exec_cmd_in_pod.side_effect = [
"", # rm -f command output
"total 0\ndrwxr-xr-x 2 root root 40 Jan 1 00:00 .", # ls -lh output without file
]
# Create rollback data
rollback_data = {
"pod_name": "test-pod",
"container_name": "test-container",
"full_path": "/mnt/data/kraken.tmp",
"file_name": "kraken.tmp",
"mount_path": "/mnt/data",
}
encoded_data = base64.b64encode(
json.dumps(rollback_data).encode("utf-8")
).decode("utf-8")
rollback_content = RollbackContent(
namespace="test-ns",
resource_identifier=encoded_data,
)
# Should not raise any exception
PvcScenarioPlugin.rollback_temp_file(rollback_content, mock_telemetry)
# Verify exec_cmd_in_pod was called
self.assertEqual(mock_kubecli.exec_cmd_in_pod.call_count, 2)
@patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.logging")
def test_rollback_temp_file_invalid_data(self, mock_logging):
"""Test rollback handles invalid encoded data gracefully and logs error"""
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
rollback_content = RollbackContent(
namespace="test-ns",
resource_identifier="invalid-base64-data!!!",
)
# Should not raise exception, just log the error
PvcScenarioPlugin.rollback_temp_file(rollback_content, mock_telemetry)
# Verify error was logged to inform users of rollback failure
mock_logging.error.assert_called_once()
error_message = mock_logging.error.call_args[0][0]
self.assertIn("Failed to rollback PVC scenario temp file", error_message)
class TestPvcScenarioPluginRun(unittest.TestCase):
"""Tests for the run method of PvcScenarioPlugin"""
def setUp(self):
"""Set up test fixtures"""
self.plugin = PvcScenarioPlugin()
def create_scenario_file(self, config: dict, temp_dir: str) -> str:
"""Helper to create a temporary scenario YAML file in the given directory"""
path = os.path.join(temp_dir, "scenario.yaml")
with open(path, "w") as f:
yaml.dump(config, f)
return path
def test_run_missing_namespace(self):
"""Test run returns 1 when namespace is missing"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"pvc_name": "test-pvc",
# namespace is missing
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 1
def test_run_missing_pvc_and_pod_name(self):
"""Test run returns 1 when both pvc_name and pod_name are missing"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
# pvc_name and pod_name are missing
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 1)
def test_run_pod_not_found(self):
"""Test run returns 1 when pod doesn't exist"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pod_name": "non-existent-pod",
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
mock_kubecli.get_pod_info.return_value = None
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 1)
def test_run_pvc_not_found_for_pod(self):
"""Test run returns 1 when pod has no PVC"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pod_name": "test-pod",
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Create mock pod with no PVC volumes
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = None # No PVC attached
mock_pod.volumes = [mock_volume]
mock_kubecli.get_pod_info.return_value = mock_pod
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 1)
def test_run_invalid_fill_percentage(self):
"""Test run returns 1 when target fill percentage is invalid"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pod_name": "test-pod",
"fill_percentage": 10, # Lower than current usage
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Create mock pod with PVC volume
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "test-volume"
mock_pod.volumes = [mock_volume]
# Create mock container with volume mount
mock_container = MagicMock()
mock_container.name = "test-container"
mock_vol_mount = MagicMock()
mock_vol_mount.name = "test-volume"
mock_vol_mount.mountPath = "/mnt/data"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
mock_kubecli.get_pod_info.return_value = mock_pod
# Mock PVC info
mock_pvc = MagicMock()
mock_kubecli.get_pvc_info.return_value = mock_pvc
# Mock df command output: 50% used (50000 used, 50000 available, 100000 total)
mock_kubecli.exec_cmd_in_pod.return_value = (
"/dev/sda1 100000 50000 50000 50% /mnt/data"
)
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
# Should return 1 because target fill (10%) < current fill (50%)
self.assertEqual(result, 1)
@patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.time.sleep")
@patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.cerberus.publish_kraken_status")
def test_run_success_with_fallocate(self, mock_publish, mock_sleep):
"""Test successful run using fallocate"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pod_name": "test-pod",
"fill_percentage": 80,
"duration": 1,
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Create mock pod with PVC volume
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "test-volume"
mock_pod.volumes = [mock_volume]
# Create mock container with volume mount
mock_container = MagicMock()
mock_container.name = "test-container"
mock_vol_mount = MagicMock()
mock_vol_mount.name = "test-volume"
mock_vol_mount.mountPath = "/mnt/data"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
mock_kubecli.get_pod_info.return_value = mock_pod
# Mock PVC info
mock_pvc = MagicMock()
mock_kubecli.get_pvc_info.return_value = mock_pvc
# Set up exec_cmd_in_pod responses
mock_kubecli.exec_cmd_in_pod.side_effect = [
"/dev/sda1 100000 10000 90000 10% /mnt/data", # df command (10% used)
"/usr/bin/fallocate", # command -v fallocate
"/usr/bin/dd", # command -v dd
"", # fallocate command
"-rw-r--r-- 1 root root 70M Jan 1 00:00 kraken.tmp", # ls -lh (file created)
"", # rm -f (cleanup)
"total 0", # ls -lh (file removed)
]
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
mock_sleep.assert_called_once_with(1)
@patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.time.sleep")
@patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.cerberus.publish_kraken_status")
def test_run_success_with_dd(self, mock_publish, mock_sleep):
"""Test successful run using dd when fallocate is not available"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pod_name": "test-pod",
"fill_percentage": 80,
"duration": 1,
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Create mock pod with PVC volume
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "test-volume"
mock_pod.volumes = [mock_volume]
# Create mock container with volume mount
mock_container = MagicMock()
mock_container.name = "test-container"
mock_vol_mount = MagicMock()
mock_vol_mount.name = "test-volume"
mock_vol_mount.mountPath = "/mnt/data"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
mock_kubecli.get_pod_info.return_value = mock_pod
# Mock PVC info
mock_pvc = MagicMock()
mock_kubecli.get_pvc_info.return_value = mock_pvc
# Set up exec_cmd_in_pod responses (fallocate not available)
mock_kubecli.exec_cmd_in_pod.side_effect = [
"/dev/sda1 100000 10000 90000 10% /mnt/data", # df command
"", # command -v fallocate (not found)
"/usr/bin/dd", # command -v dd
"", # dd command
"-rw-r--r-- 1 root root 70M Jan 1 00:00 kraken.tmp", # ls -lh
"", # rm -f
"total 0", # ls -lh (file removed)
]
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
def test_run_no_binary_available(self):
"""Test run returns 1 when neither fallocate nor dd is available"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pod_name": "test-pod",
"fill_percentage": 80,
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Create mock pod with PVC volume
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "test-volume"
mock_pod.volumes = [mock_volume]
# Create mock container with volume mount
mock_container = MagicMock()
mock_container.name = "test-container"
mock_vol_mount = MagicMock()
mock_vol_mount.name = "test-volume"
mock_vol_mount.mountPath = "/mnt/data"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
mock_kubecli.get_pod_info.return_value = mock_pod
# Mock PVC info
mock_pvc = MagicMock()
mock_kubecli.get_pvc_info.return_value = mock_pvc
# Neither fallocate nor dd available
mock_kubecli.exec_cmd_in_pod.side_effect = [
"/dev/sda1 100000 10000 90000 10% /mnt/data", # df command
"", # command -v fallocate (not found)
"", # command -v dd (not found)
]
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 1)
def test_run_file_not_found(self):
"""Test run returns 1 when scenario file doesn't exist"""
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario="/non/existent/path.yaml",
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 1)
def test_run_both_pvc_and_pod_name_provided(self):
"""Test run when both pvc_name and pod_name are provided (pod_name is overridden)"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pvc_name": "test-pvc",
"pod_name": "ignored-pod", # This will be overridden
"fill_percentage": 80,
"duration": 1,
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Mock PVC info with pod names
mock_pvc = MagicMock()
mock_pvc.podNames = ["actual-pod-from-pvc"]
mock_kubecli.get_pvc_info.return_value = mock_pvc
# Create mock pod with PVC volume
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "test-volume"
mock_pod.volumes = [mock_volume]
# Create mock container with volume mount
mock_container = MagicMock()
mock_container.name = "test-container"
mock_vol_mount = MagicMock()
mock_vol_mount.name = "test-volume"
mock_vol_mount.mountPath = "/mnt/data"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
mock_kubecli.get_pod_info.return_value = mock_pod
# Mock df command output: 10% used
mock_kubecli.exec_cmd_in_pod.side_effect = [
"/dev/sda1 100000 10000 90000 10% /mnt/data", # df command
"/usr/bin/fallocate", # command -v fallocate
"/usr/bin/dd", # command -v dd
"", # fallocate command
"-rw-r--r-- 1 root root 70M Jan 1 00:00 kraken.tmp", # ls -lh (file created)
"", # rm -f (cleanup)
"total 0", # ls -lh (file removed)
]
mock_scenario_telemetry = MagicMock()
with patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.time.sleep"):
with patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.cerberus.publish_kraken_status"):
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
# get_pod_info should be called with "actual-pod-from-pvc", not "ignored-pod"
mock_kubecli.get_pod_info.assert_called_with(
name="actual-pod-from-pvc",
namespace="test-ns"
)
# Verify exec_cmd_in_pod uses the overridden pod name
for call in mock_kubecli.exec_cmd_in_pod.call_args_list:
kwargs = call[1]
if 'pod_name' in kwargs:
self.assertEqual(kwargs['pod_name'], "actual-pod-from-pvc")
def test_run_pvc_name_only_no_pods_associated(self):
"""Test run returns 1 when pvc_name is provided but no pods are associated"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pvc_name": "test-pvc",
"fill_percentage": 80,
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Mock PVC info with empty pod names (no pods using this PVC)
mock_pvc = MagicMock()
mock_pvc.podNames = [] # No pods associated
mock_kubecli.get_pvc_info.return_value = mock_pvc
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
# Should return 1 because random.choice on empty list raises IndexError
self.assertEqual(result, 1)
def test_run_file_creation_failed(self):
"""Test run returns 1 when file creation fails and verifies cleanup is attempted"""
with tempfile.TemporaryDirectory() as temp_dir:
scenario_config = {
"pvc_scenario": {
"namespace": "test-ns",
"pod_name": "test-pod",
"fill_percentage": 80,
"duration": 1,
}
}
scenario_path = self.create_scenario_file(scenario_config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Create mock pod with PVC volume
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "test-volume"
mock_pod.volumes = [mock_volume]
# Create mock container with volume mount
mock_container = MagicMock()
mock_container.name = "test-container"
mock_vol_mount = MagicMock()
mock_vol_mount.name = "test-volume"
mock_vol_mount.mountPath = "/mnt/data"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
mock_kubecli.get_pod_info.return_value = mock_pod
# Mock PVC info
mock_pvc = MagicMock()
mock_kubecli.get_pvc_info.return_value = mock_pvc
# Set up exec_cmd_in_pod responses - file creation fails
mock_kubecli.exec_cmd_in_pod.side_effect = [
"/dev/sda1 100000 10000 90000 10% /mnt/data", # df command
"/usr/bin/fallocate", # command -v fallocate
"/usr/bin/dd", # command -v dd
"", # fallocate command
"total 0", # ls -lh shows NO kraken.tmp (file creation failed)
"", # rm -f (cleanup attempt)
"total 0", # ls -lh (cleanup verification)
]
mock_scenario_telemetry = MagicMock()
result = self.plugin.run(
run_uuid="test-uuid",
scenario=scenario_path,
krkn_config={},
lib_telemetry=mock_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
# Should return 1 because file creation failed
self.assertEqual(result, 1)
# Verify cleanup was attempted (7 calls total: df, 2x command -v, fallocate, ls, rm, ls)
self.assertEqual(mock_kubecli.exec_cmd_in_pod.call_count, 7)
class TestRollbackTempFileEdgeCases(unittest.TestCase):
"""Additional tests for rollback_temp_file edge cases"""
@patch("krkn.scenario_plugins.pvc.pvc_scenario_plugin.logging")
def test_rollback_temp_file_still_exists(self, mock_logging):
"""Test rollback when file still exists after removal attempt and logs warning"""
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_kubecli = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_kubecli
# Simulate file still exists after rm command
mock_kubecli.exec_cmd_in_pod.side_effect = [
"", # rm -f command output
"-rw-r--r-- 1 root root 70M Jan 1 00:00 kraken.tmp", # ls -lh shows file still exists
]
# Create rollback data
rollback_data = {
"pod_name": "test-pod",
"container_name": "test-container",
"full_path": "/mnt/data/kraken.tmp",
"file_name": "kraken.tmp",
"mount_path": "/mnt/data",
}
encoded_data = base64.b64encode(
json.dumps(rollback_data).encode("utf-8")
).decode("utf-8")
rollback_content = RollbackContent(
namespace="test-ns",
resource_identifier=encoded_data,
)
# Should not raise exception, just log warning
PvcScenarioPlugin.rollback_temp_file(rollback_content, mock_telemetry)
# Verify exec_cmd_in_pod was called twice
assert mock_kubecli.exec_cmd_in_pod.call_count == 2
# Verify warning was logged to inform operators of incomplete rollback
mock_logging.warning.assert_called_once()
warning_message = mock_logging.warning.call_args[0][0]
self.assertIn("may still exist after rollback attempt", warning_message)
self.assertIn("kraken.tmp", warning_message)
if __name__ == "__main__":
unittest.main()

View File

@@ -9,21 +9,13 @@ Usage:
Assisted By: Claude Code
"""
import base64
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
import uuid
import yaml
from krkn.rollback.config import RollbackContent
from krkn.scenario_plugins.service_hijacking.service_hijacking_scenario_plugin import (
ServiceHijackingScenarioPlugin,
)
from krkn.scenario_plugins.service_hijacking.service_hijacking_scenario_plugin import ServiceHijackingScenarioPlugin
class TestServiceHijackingScenarioPlugin(unittest.TestCase):
def setUp(self):
"""
Set up test fixtures for ServiceHijackingScenarioPlugin
@@ -40,374 +32,5 @@ class TestServiceHijackingScenarioPlugin(unittest.TestCase):
self.assertEqual(len(result), 1)
class TestRollbackServiceHijacking(unittest.TestCase):
def test_rollback_service_hijacking(self):
"""
Test rollback functionality for ServiceHijackingScenarioPlugin
"""
# Create rollback data that matches what the plugin expects
rollback_data = {
"service_name": "test-service",
"service_namespace": "default",
"original_selectors": {"app": "original-app"},
"webservice_pod_name": "test-webservice",
}
json_str = json.dumps(rollback_data)
encoded_data = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
# Create RollbackContent with correct parameters
rollback_content = RollbackContent(
resource_identifier=encoded_data,
namespace="default",
)
# Create a mock KrknTelemetryOpenshift object
mock_lib_telemetry = MagicMock()
mock_lib_kubernetes = MagicMock()
mock_lib_telemetry.get_lib_kubernetes.return_value = mock_lib_kubernetes
# Configure mock to return a successful service restoration
mock_lib_kubernetes.replace_service_selector.return_value = {
"metadata": {"name": "test-service"}
}
mock_lib_kubernetes.delete_pod.return_value = None
# Call the rollback method
ServiceHijackingScenarioPlugin.rollback_service_hijacking(
rollback_content, mock_lib_telemetry
)
# Verify that the correct methods were called
mock_lib_kubernetes.replace_service_selector.assert_called_once_with(
["app=original-app"], "test-service", "default"
)
mock_lib_kubernetes.delete_pod.assert_called_once_with(
"test-webservice", "default"
)
@patch("krkn.scenario_plugins.service_hijacking.service_hijacking_scenario_plugin.logging")
def test_rollback_service_hijacking_invalid_data(self, mock_logging):
"""
Test rollback functionality with invalid rollback content logs error
"""
# Create RollbackContent with invalid base64 data
rollback_content = RollbackContent(
resource_identifier="invalid_base64_data",
namespace="default",
)
# Create a mock KrknTelemetryOpenshift object
mock_lib_telemetry = MagicMock()
# Call the rollback method - should not raise exception but log error
ServiceHijackingScenarioPlugin.rollback_service_hijacking(
rollback_content, mock_lib_telemetry
)
# Verify error was logged to inform operators of rollback failure
mock_logging.error.assert_called_once()
error_message = mock_logging.error.call_args[0][0]
self.assertIn("Failed to rollback service hijacking", error_message)
class TestServiceHijackingRun(unittest.TestCase):
"""Tests for the run method of ServiceHijackingScenarioPlugin"""
def setUp(self):
"""Set up test fixtures - create temporary directory"""
self.temp_dir = tempfile.TemporaryDirectory()
self.tmp_path = Path(self.temp_dir.name)
def tearDown(self):
"""Clean up temporary directory after test"""
self.temp_dir.cleanup()
def _create_scenario_file(self, config=None):
"""Helper to create a temporary scenario YAML file"""
default_config = {
"service_name": "nginx-service",
"service_namespace": "default",
"service_target_port": "http-web-svc",
"image": "quay.io/krkn-chaos/krkn-service-hijacking:v0.1.3",
"chaos_duration": 1, # Use short duration for tests
"privileged": True,
"plan": [
{
"resource": "/test",
"steps": {
"GET": [
{
"duration": 1,
"status": 200,
"mime_type": "application/json",
"payload": '{"status": "ok"}',
}
]
},
}
],
}
if config:
default_config.update(config)
scenario_file = self.tmp_path / "test_scenario.yaml"
with open(scenario_file, "w") as f:
yaml.dump(default_config, f)
return str(scenario_file)
def _create_mocks(self):
"""Helper to create mock objects for testing"""
mock_lib_telemetry = MagicMock()
mock_lib_kubernetes = MagicMock()
mock_lib_telemetry.get_lib_kubernetes.return_value = mock_lib_kubernetes
mock_scenario_telemetry = MagicMock()
return mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry
def test_run_successful(self):
"""Test successful execution of the run method"""
scenario_file = self._create_scenario_file()
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
# Configure mocks for successful execution
mock_lib_kubernetes.service_exists.return_value = True
mock_webservice = MagicMock()
mock_webservice.pod_name = "hijacker-pod"
mock_webservice.selector = "app=hijacker"
mock_lib_kubernetes.deploy_service_hijacking.return_value = mock_webservice
mock_lib_kubernetes.replace_service_selector.return_value = {
"metadata": {"name": "nginx-service"},
"spec": {"selector": {"app": "nginx"}},
}
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
mock_lib_kubernetes.service_exists.assert_called_once_with(
"nginx-service", "default"
)
mock_lib_kubernetes.deploy_service_hijacking.assert_called_once()
self.assertEqual(mock_lib_kubernetes.replace_service_selector.call_count, 2)
mock_lib_kubernetes.undeploy_service_hijacking.assert_called_once_with(
mock_webservice
)
def test_run_service_not_found(self):
"""Test run method when service does not exist"""
scenario_file = self._create_scenario_file()
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
# Service does not exist
mock_lib_kubernetes.service_exists.return_value = False
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 1
mock_lib_kubernetes.service_exists.assert_called_once_with(
"nginx-service", "default"
)
mock_lib_kubernetes.deploy_service_hijacking.assert_not_called()
def test_run_patch_service_failed(self):
"""Test run method when patching the service fails"""
scenario_file = self._create_scenario_file()
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_webservice = MagicMock()
mock_webservice.pod_name = "hijacker-pod"
mock_webservice.selector = "app=hijacker"
mock_lib_kubernetes.deploy_service_hijacking.return_value = mock_webservice
# Patching returns None (failure)
mock_lib_kubernetes.replace_service_selector.return_value = None
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 1
mock_lib_kubernetes.replace_service_selector.assert_called_once()
def test_run_restore_service_failed(self):
"""Test run method when restoring the service fails"""
scenario_file = self._create_scenario_file()
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_webservice = MagicMock()
mock_webservice.pod_name = "hijacker-pod"
mock_webservice.selector = "app=hijacker"
mock_lib_kubernetes.deploy_service_hijacking.return_value = mock_webservice
# First call (patch) succeeds, second call (restore) fails
mock_lib_kubernetes.replace_service_selector.side_effect = [
{"metadata": {"name": "nginx-service"}, "spec": {"selector": {"app": "nginx"}}},
None, # Restore fails
]
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 1
assert mock_lib_kubernetes.replace_service_selector.call_count == 2
def test_run_with_numeric_port(self):
"""Test run method with numeric target port"""
scenario_file = self._create_scenario_file(
{"service_target_port": 8080}
)
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_webservice = MagicMock()
mock_webservice.pod_name = "hijacker-pod"
mock_webservice.selector = "app=hijacker"
mock_lib_kubernetes.deploy_service_hijacking.return_value = mock_webservice
mock_lib_kubernetes.replace_service_selector.return_value = {
"metadata": {"name": "nginx-service"},
"spec": {"selector": {"app": "nginx"}},
}
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 0
# Verify port_number was passed instead of port_name
call_kwargs = mock_lib_kubernetes.deploy_service_hijacking.call_args
assert call_kwargs[1]["port_number"] == 8080
def test_run_with_named_port(self):
"""Test run method with named target port"""
scenario_file = self._create_scenario_file(
{"service_target_port": "http-web-svc"}
)
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_webservice = MagicMock()
mock_webservice.pod_name = "hijacker-pod"
mock_webservice.selector = "app=hijacker"
mock_lib_kubernetes.deploy_service_hijacking.return_value = mock_webservice
mock_lib_kubernetes.replace_service_selector.return_value = {
"metadata": {"name": "nginx-service"},
"spec": {"selector": {"app": "nginx"}},
}
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 0
# Verify port_name was passed instead of port_number
call_kwargs = mock_lib_kubernetes.deploy_service_hijacking.call_args
assert call_kwargs[1]["port_name"] == "http-web-svc"
def test_run_exception_handling(self):
"""Test run method handles exceptions gracefully"""
scenario_file = self._create_scenario_file()
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_lib_kubernetes.deploy_service_hijacking.side_effect = Exception(
"Deployment failed"
)
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 1
def test_run_unprivileged_mode(self):
"""Test run method with privileged set to False"""
scenario_file = self._create_scenario_file({"privileged": False})
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_webservice = MagicMock()
mock_webservice.pod_name = "hijacker-pod"
mock_webservice.selector = "app=hijacker"
mock_lib_kubernetes.deploy_service_hijacking.return_value = mock_webservice
mock_lib_kubernetes.replace_service_selector.return_value = {
"metadata": {"name": "nginx-service"},
"spec": {"selector": {"app": "nginx"}},
}
plugin = ServiceHijackingScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
assert result == 0
call_kwargs = mock_lib_kubernetes.deploy_service_hijacking.call_args
assert call_kwargs[1]["privileged"] is False
if __name__ == "__main__":
unittest.main()

View File

@@ -10,12 +10,6 @@ Assisted By: Claude Code
"""
import unittest
from unittest.mock import Mock, patch, mock_open
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn_lib.models.telemetry import ScenarioTelemetry
from krkn_lib.models.k8s import AffectedNodeStatus
from krkn.scenario_plugins.shut_down.shut_down_scenario_plugin import ShutDownScenarioPlugin
@@ -27,11 +21,6 @@ class TestShutDownScenarioPlugin(unittest.TestCase):
Set up test fixtures for ShutDownScenarioPlugin
"""
self.plugin = ShutDownScenarioPlugin()
self.mock_kubecli = Mock(spec=KrknKubernetes)
self.mock_lib_telemetry = Mock(spec=KrknTelemetryOpenshift)
self.mock_lib_telemetry.get_lib_kubernetes.return_value = self.mock_kubecli
self.mock_scenario_telemetry = Mock(spec=ScenarioTelemetry)
self.mock_scenario_telemetry.affected_nodes = []
def test_get_scenario_types(self):
"""
@@ -42,456 +31,6 @@ class TestShutDownScenarioPlugin(unittest.TestCase):
self.assertEqual(result, ["cluster_shut_down_scenarios"])
self.assertEqual(len(result), 1)
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.cerberus')
@patch('time.time')
@patch('time.sleep')
@patch('builtins.open', new_callable=mock_open)
def test_run_success_aws(self, mock_file, mock_sleep, mock_time, mock_cerberus):
"""
Test successful run of shut down scenario with AWS cloud type
"""
scenario_yaml = {
"cluster_shut_down_scenario": {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "aws",
"timeout": 300
}
}
mock_time.side_effect = [1000, 2000]
self.mock_kubecli.list_nodes.return_value = ["node1", "node2"]
with patch('yaml.full_load', return_value=scenario_yaml):
with patch.object(self.plugin, 'cluster_shut_down') as mock_cluster_shutdown:
result = self.plugin.run(
"test-uuid",
"/path/to/scenario.yaml",
{},
self.mock_lib_telemetry,
self.mock_scenario_telemetry
)
self.assertEqual(result, 0)
mock_cluster_shutdown.assert_called_once()
mock_cerberus.publish_kraken_status.assert_called_once()
@patch('logging.error')
@patch('builtins.open', new_callable=mock_open)
def test_run_with_exception(self, mock_file, mock_logging):
"""
Test run handles exceptions and returns 1
"""
mock_file.return_value.__enter__.side_effect = Exception("File read error")
result = self.plugin.run(
"test-uuid",
"/path/to/scenario.yaml",
{},
self.mock_lib_telemetry,
self.mock_scenario_telemetry
)
self.assertEqual(result, 1)
mock_logging.assert_called_once()
logged_message = mock_logging.call_args[0][0]
self.assertIn("File read error", logged_message)
self.assertIn("/path/to/scenario.yaml", logged_message)
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.AWS')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_aws(self, mock_time, mock_sleep, mock_aws_class):
"""
Test cluster_shut_down with AWS cloud type
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "aws",
"timeout": 300
}
mock_cloud_object = Mock()
mock_aws_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.return_value = "i-123"
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1", "node2"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes') as mock_multiprocess:
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_aws_class.assert_called_once()
self.assertEqual(mock_multiprocess.call_count, 2)
self.assertEqual(len(affected_nodes_status.affected_nodes), 2)
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.GCP')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_gcp(self, mock_time, mock_sleep, mock_gcp_class):
"""
Test cluster_shut_down with GCP cloud type
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 30,
"cloud_type": "gcp",
"timeout": 300
}
mock_cloud_object = Mock()
mock_gcp_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.side_effect = ["gcp-1", "gcp-2"]
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1", "node2"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes') as mock_multiprocess:
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_gcp_class.assert_called_once()
# Verify that the 'processes' parameter is set to 1 for GCP cloud type
calls = mock_multiprocess.call_args_list
for call_args in calls:
self.assertEqual(call_args[0][2], 1)
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.Azure')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_azure(self, mock_time, mock_sleep, mock_azure_class):
"""
Test cluster_shut_down with Azure cloud type
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 45,
"cloud_type": "azure",
"timeout": 300
}
mock_cloud_object = Mock()
mock_azure_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.side_effect = ["azure-1"]
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes'):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_azure_class.assert_called_once()
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.Azure')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_az_alias(self, mock_time, mock_sleep, mock_azure_class):
"""
Test cluster_shut_down with 'az' cloud type alias for Azure
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 30,
"cloud_type": "az",
"timeout": 300
}
mock_cloud_object = Mock()
mock_azure_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.side_effect = ["azure-1"]
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes'):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_azure_class.assert_called_once()
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.OPENSTACKCLOUD')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_openstack(self, mock_time, mock_sleep, mock_openstack_class):
"""
Test cluster_shut_down with OpenStack cloud type
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "openstack",
"timeout": 300
}
mock_cloud_object = Mock()
mock_openstack_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.side_effect = ["os-1"]
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes'):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_openstack_class.assert_called_once()
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.IbmCloud')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_ibm(self, mock_time, mock_sleep, mock_ibm_class):
"""
Test cluster_shut_down with IBM cloud type
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "ibm",
"timeout": 300
}
mock_cloud_object = Mock()
mock_ibm_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.side_effect = ["ibm-1"]
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes'):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_ibm_class.assert_called_once()
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.IbmCloud')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_ibmcloud_alias(self, mock_time, mock_sleep, mock_ibm_class):
"""
Test cluster_shut_down with 'ibmcloud' cloud type alias
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "ibmcloud",
"timeout": 300
}
mock_cloud_object = Mock()
mock_ibm_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.side_effect = ["ibm-1"]
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes'):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_ibm_class.assert_called_once()
@patch('logging.error')
def test_cluster_shut_down_unsupported_cloud(self, mock_logging):
"""
Test cluster_shut_down raises exception for unsupported cloud type
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "unsupported",
"timeout": 300
}
affected_nodes_status = AffectedNodeStatus()
with self.assertRaises(RuntimeError):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
mock_logging.assert_called()
logged_message = mock_logging.call_args[0][0]
self.assertIn("not currently supported", logged_message)
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.AWS')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_multiple_runs(self, mock_time, mock_sleep, mock_aws_class):
"""
Test cluster_shut_down with multiple runs
"""
shut_down_config = {
"runs": 2,
"shut_down_duration": 30,
"cloud_type": "aws",
"timeout": 300
}
mock_cloud_object = Mock()
mock_aws_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.return_value = "i-123"
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes') as mock_multiprocess:
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
# Each run should call multiprocess_nodes twice (stop and start)
self.assertEqual(mock_multiprocess.call_count, 4)
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.ThreadPool')
def test_multiprocess_nodes_simple_list(self, mock_threadpool):
"""
Test multiprocess_nodes with simple list of nodes
"""
mock_pool_instance = Mock()
mock_threadpool.return_value = mock_pool_instance
nodes = ["node1", "node2", "node3"]
mock_cloud_function = Mock()
self.plugin.multiprocess_nodes(mock_cloud_function, nodes, processes=0)
mock_threadpool.assert_called_once_with(processes=3)
mock_pool_instance.map.assert_called_once_with(mock_cloud_function, nodes)
mock_pool_instance.close.assert_called_once()
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.ThreadPool')
def test_multiprocess_nodes_with_custom_processes(self, mock_threadpool):
"""
Test multiprocess_nodes with custom process count
"""
mock_pool_instance = Mock()
mock_threadpool.return_value = mock_pool_instance
nodes = ["node1", "node2", "node3", "node4"]
mock_cloud_function = Mock()
self.plugin.multiprocess_nodes(mock_cloud_function, nodes, processes=2)
mock_threadpool.assert_called_once_with(processes=2)
mock_pool_instance.map.assert_called_once_with(mock_cloud_function, nodes)
mock_pool_instance.close.assert_called_once()
@patch('logging.info')
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.ThreadPool')
def test_multiprocess_nodes_tuple_list(self, mock_threadpool, mock_logging):
"""
Test multiprocess_nodes with tuple list (node_info, node_id pairs)
"""
mock_pool_instance = Mock()
mock_threadpool.return_value = mock_pool_instance
nodes = [("info1", "id1"), ("info2", "id2")]
mock_cloud_function = Mock()
self.plugin.multiprocess_nodes(mock_cloud_function, nodes, processes=0)
mock_threadpool.assert_called_once_with(processes=2)
mock_pool_instance.starmap.assert_called_once()
# Verify starmap was called with zipped arguments
call_args = mock_pool_instance.starmap.call_args[0]
self.assertEqual(call_args[0], mock_cloud_function)
mock_pool_instance.close.assert_called_once()
@patch('logging.info')
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.ThreadPool')
def test_multiprocess_nodes_with_exception(self, mock_threadpool, mock_logging):
"""
Test multiprocess_nodes handles exceptions gracefully
"""
mock_threadpool.side_effect = Exception("Pool creation error")
nodes = ["node1", "node2"]
mock_cloud_function = Mock()
self.plugin.multiprocess_nodes(mock_cloud_function, nodes, processes=0)
mock_logging.assert_called()
logged_args, logged_kwargs = mock_logging.call_args
self.assertIn("Error on pool multiprocessing", logged_args[0])
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.AWS')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_node_stop_timing(self, mock_time, mock_sleep, mock_aws_class):
"""
Test that cloud_stopping_time is set correctly
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "aws",
"timeout": 300
}
mock_cloud_object = Mock()
mock_aws_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.return_value = "i-123"
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
# Simulate time progression - provide enough values for all time.time() calls
mock_time.side_effect = [1000, 1050, 1100, 1150, 1200]
with patch.object(self.plugin, 'multiprocess_nodes'):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
# Verify affected node was created
self.assertEqual(len(affected_nodes_status.affected_nodes), 1)
@patch('krkn.scenario_plugins.shut_down.shut_down_scenario_plugin.AWS')
@patch('time.sleep')
@patch('time.time')
def test_cluster_shut_down_wait_for_initialization(self, mock_time, mock_sleep, mock_aws_class):
"""
Test that cluster_shut_down waits 150s for component initialization
"""
shut_down_config = {
"runs": 1,
"shut_down_duration": 60,
"cloud_type": "aws",
"timeout": 300
}
mock_cloud_object = Mock()
mock_aws_class.return_value = mock_cloud_object
mock_cloud_object.get_instance_id.return_value = "i-123"
mock_cloud_object.wait_until_stopped.return_value = True
mock_cloud_object.wait_until_running.return_value = True
self.mock_kubecli.list_nodes.return_value = ["node1"]
affected_nodes_status = AffectedNodeStatus()
mock_time.return_value = 1000
with patch.object(self.plugin, 'multiprocess_nodes'):
self.plugin.cluster_shut_down(shut_down_config, self.mock_kubecli, affected_nodes_status)
# Verify sleep was called with correct durations
sleep_calls = [call_args[0][0] for call_args in mock_sleep.call_args_list]
self.assertIn(60, sleep_calls) # shut_down_duration
self.assertIn(150, sleep_calls) # component initialization wait
if __name__ == "__main__":
unittest.main()

View File

@@ -9,13 +9,8 @@ Usage:
Assisted By: Claude Code
"""
import base64
import json
import unittest
import uuid
from unittest.mock import MagicMock
from krkn.rollback.config import RollbackContent
from krkn.scenario_plugins.syn_flood.syn_flood_scenario_plugin import SynFloodScenarioPlugin
@@ -36,488 +31,6 @@ class TestSynFloodScenarioPlugin(unittest.TestCase):
self.assertEqual(result, ["syn_flood_scenarios"])
self.assertEqual(len(result), 1)
def test_check_key_value(self):
"""
Test check_key_value method
"""
test_dict = {
"valid_key": "value",
"empty_key": "",
"none_key": None,
"zero_key": 0,
"false_key": False,
}
self.assertTrue(self.plugin.check_key_value(test_dict, "valid_key"))
self.assertFalse(self.plugin.check_key_value(test_dict, "empty_key"))
self.assertFalse(self.plugin.check_key_value(test_dict, "none_key"))
self.assertFalse(self.plugin.check_key_value(test_dict, "missing_key"))
# 0 and False are valid values
self.assertTrue(self.plugin.check_key_value(test_dict, "zero_key"))
self.assertTrue(self.plugin.check_key_value(test_dict, "false_key"))
class TestIsNodeAffinityCorrect(unittest.TestCase):
"""Tests for is_node_affinity_correct method"""
def setUp(self):
self.plugin = SynFloodScenarioPlugin()
def test_valid_node_affinity(self):
"""Test valid node affinity configuration"""
valid_affinity = {
"node-role.kubernetes.io/worker": [""],
}
self.assertTrue(self.plugin.is_node_affinity_correct(valid_affinity))
def test_valid_node_affinity_multiple_labels(self):
"""Test valid node affinity with multiple labels"""
valid_affinity = {
"node-role.kubernetes.io/worker": ["value1", "value2"],
"topology.kubernetes.io/zone": ["us-east-1a"],
}
self.assertTrue(self.plugin.is_node_affinity_correct(valid_affinity))
def test_empty_dict_is_valid(self):
"""Test empty dict is valid for node affinity"""
self.assertTrue(self.plugin.is_node_affinity_correct({}))
def test_invalid_not_a_dict(self):
"""Test non-dict input is invalid"""
self.assertFalse(self.plugin.is_node_affinity_correct("not a dict"))
self.assertFalse(self.plugin.is_node_affinity_correct(["list"]))
self.assertFalse(self.plugin.is_node_affinity_correct(123))
self.assertFalse(self.plugin.is_node_affinity_correct(None))
def test_invalid_non_string_key(self):
"""Test non-string keys are invalid"""
invalid_affinity = {
123: ["value"],
}
self.assertFalse(self.plugin.is_node_affinity_correct(invalid_affinity))
def test_invalid_non_list_value(self):
"""Test non-list values are invalid"""
invalid_affinity = {
"node-role.kubernetes.io/worker": "not a list",
}
self.assertFalse(self.plugin.is_node_affinity_correct(invalid_affinity))
class TestParseConfig(unittest.TestCase):
"""Tests for parse_config method"""
def setUp(self):
self.plugin = SynFloodScenarioPlugin()
def _create_scenario_file(self, tmp_path, config=None):
"""Helper to create a temporary scenario YAML file"""
import yaml
default_config = {
"packet-size": 120,
"window-size": 64,
"duration": 10,
"namespace": "default",
"target-service": "elasticsearch",
"target-port": 9200,
"target-service-label": "",
"number-of-pods": 2,
"image": "quay.io/krkn-chaos/krkn-syn-flood:v1.0.0",
"attacker-nodes": {"node-role.kubernetes.io/worker": [""]},
}
if config:
default_config.update(config)
scenario_file = tmp_path / "test_scenario.yaml"
with open(scenario_file, "w") as f:
yaml.dump(default_config, f)
return str(scenario_file)
def test_parse_config_valid(self, tmp_path=None):
"""Test parsing valid configuration"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(Path(tmp_dir))
config = self.plugin.parse_config(scenario_file)
assert config["packet-size"] == 120
assert config["window-size"] == 64
assert config["duration"] == 10
assert config["namespace"] == "default"
assert config["target-service"] == "elasticsearch"
assert config["target-port"] == 9200
assert config["number-of-pods"] == 2
def test_parse_config_file_not_found(self):
"""Test parsing non-existent file raises exception"""
with self.assertRaises(Exception) as context:
self.plugin.parse_config("/nonexistent/path/scenario.yaml")
self.assertIn("failed to load scenario file", str(context.exception))
def test_parse_config_missing_required_params(self):
"""Test parsing config with missing required parameters"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_dir:
# Missing packet-size and window-size
scenario_file = self._create_scenario_file(
Path(tmp_dir),
{"packet-size": "", "window-size": None},
)
with self.assertRaises(Exception) as context:
self.plugin.parse_config(scenario_file)
self.assertIn("packet-size", str(context.exception))
self.assertIn("window-size", str(context.exception))
def test_parse_config_both_target_service_and_label(self):
"""Test parsing config with both target-service and target-service-label set"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(
Path(tmp_dir),
{
"target-service": "elasticsearch",
"target-service-label": "app=elasticsearch",
},
)
with self.assertRaises(Exception) as context:
self.plugin.parse_config(scenario_file)
self.assertIn(
"you cannot select both target-service and target-service-label",
str(context.exception),
)
def test_parse_config_neither_target_service_nor_label(self):
"""Test parsing config with neither target-service nor target-service-label set"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(
Path(tmp_dir),
{"target-service": "", "target-service-label": ""},
)
with self.assertRaises(Exception) as context:
self.plugin.parse_config(scenario_file)
self.assertIn(
"you have either to set a target service or a label",
str(context.exception),
)
def test_parse_config_invalid_attacker_nodes(self):
"""Test parsing config with invalid attacker-nodes format"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(
Path(tmp_dir),
{"attacker-nodes": "invalid"},
)
with self.assertRaises(Exception) as context:
self.plugin.parse_config(scenario_file)
self.assertIn("attacker-nodes format is not correct", str(context.exception))
def test_parse_config_with_label_selector(self):
"""Test parsing config with target-service-label instead of target-service"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(
Path(tmp_dir),
{"target-service": "", "target-service-label": "app=elasticsearch"},
)
config = self.plugin.parse_config(scenario_file)
assert config["target-service-label"] == "app=elasticsearch"
assert config["target-service"] == ""
class TestSynFloodRun(unittest.TestCase):
"""Tests for the run method of SynFloodScenarioPlugin"""
def _create_scenario_file(self, tmp_path, config=None):
"""Helper to create a temporary scenario YAML file"""
import yaml
from pathlib import Path
default_config = {
"packet-size": 120,
"window-size": 64,
"duration": 1,
"namespace": "default",
"target-service": "elasticsearch",
"target-port": 9200,
"target-service-label": "",
"number-of-pods": 1,
"image": "quay.io/krkn-chaos/krkn-syn-flood:v1.0.0",
"attacker-nodes": {"node-role.kubernetes.io/worker": [""]},
}
if config:
default_config.update(config)
scenario_file = Path(tmp_path) / "test_scenario.yaml"
with open(scenario_file, "w") as f:
yaml.dump(default_config, f)
return str(scenario_file)
def _create_mocks(self):
"""Helper to create mock objects for testing"""
mock_lib_telemetry = MagicMock()
mock_lib_kubernetes = MagicMock()
mock_lib_telemetry.get_lib_kubernetes.return_value = mock_lib_kubernetes
mock_scenario_telemetry = MagicMock()
return mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry
def test_run_successful_with_target_service(self):
"""Test successful execution with target-service"""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(tmp_dir)
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
# Pod finishes immediately
mock_lib_kubernetes.is_pod_running.return_value = False
plugin = SynFloodScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
mock_lib_kubernetes.service_exists.assert_called_once_with(
"elasticsearch", "default"
)
mock_lib_kubernetes.deploy_syn_flood.assert_called_once()
def test_run_successful_with_label_selector(self):
"""Test successful execution with target-service-label"""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(
tmp_dir,
{"target-service": "", "target-service-label": "app=elasticsearch"},
)
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.select_service_by_label.return_value = [
"elasticsearch-1",
"elasticsearch-2",
]
mock_lib_kubernetes.service_exists.return_value = True
mock_lib_kubernetes.is_pod_running.return_value = False
plugin = SynFloodScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
mock_lib_kubernetes.select_service_by_label.assert_called_once_with(
"default", "app=elasticsearch"
)
# Should deploy pods for each service found
self.assertEqual(mock_lib_kubernetes.deploy_syn_flood.call_count, 2)
def test_run_service_not_found(self):
"""Test run method when service does not exist"""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(tmp_dir)
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = False
plugin = SynFloodScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 1)
mock_lib_kubernetes.deploy_syn_flood.assert_not_called()
def test_run_multiple_pods(self):
"""Test run method with multiple attacker pods"""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(tmp_dir, {"number-of-pods": 3})
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_lib_kubernetes.is_pod_running.return_value = False
plugin = SynFloodScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
self.assertEqual(mock_lib_kubernetes.deploy_syn_flood.call_count, 3)
def test_run_exception_handling(self):
"""Test run method handles exceptions gracefully"""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(tmp_dir)
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
mock_lib_kubernetes.deploy_syn_flood.side_effect = Exception("Deployment failed")
plugin = SynFloodScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 1)
def test_run_waits_for_pods_to_finish(self):
"""Test run method waits for pods to finish"""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scenario_file = self._create_scenario_file(tmp_dir)
mock_lib_telemetry, mock_lib_kubernetes, mock_scenario_telemetry = (
self._create_mocks()
)
mock_lib_kubernetes.service_exists.return_value = True
# Pod runs for a few iterations then finishes
mock_lib_kubernetes.is_pod_running.side_effect = [True, True, False]
plugin = SynFloodScenarioPlugin()
result = plugin.run(
run_uuid=str(uuid.uuid4()),
scenario=scenario_file,
krkn_config={},
lib_telemetry=mock_lib_telemetry,
scenario_telemetry=mock_scenario_telemetry,
)
self.assertEqual(result, 0)
# Should have checked pod status multiple times
self.assertGreaterEqual(mock_lib_kubernetes.is_pod_running.call_count, 1)
class TestRollbackSynFloodPods(unittest.TestCase):
"""Tests for rollback_syn_flood_pods static method"""
def test_rollback_syn_flood_pods_successful(self):
"""Test successful rollback of syn flood pods"""
pod_names = ["syn-flood-abc123", "syn-flood-def456"]
encoded_data = base64.b64encode(
json.dumps(pod_names).encode("utf-8")
).decode("utf-8")
rollback_content = RollbackContent(
resource_identifier=encoded_data,
namespace="default",
)
mock_lib_telemetry = MagicMock()
mock_lib_kubernetes = MagicMock()
mock_lib_telemetry.get_lib_kubernetes.return_value = mock_lib_kubernetes
SynFloodScenarioPlugin.rollback_syn_flood_pods(
rollback_content, mock_lib_telemetry
)
assert mock_lib_kubernetes.delete_pod.call_count == 2
mock_lib_kubernetes.delete_pod.assert_any_call("syn-flood-abc123", "default")
mock_lib_kubernetes.delete_pod.assert_any_call("syn-flood-def456", "default")
def test_rollback_syn_flood_pods_empty_list(self):
"""Test rollback with empty pod list"""
pod_names = []
encoded_data = base64.b64encode(
json.dumps(pod_names).encode("utf-8")
).decode("utf-8")
rollback_content = RollbackContent(
resource_identifier=encoded_data,
namespace="default",
)
mock_lib_telemetry = MagicMock()
mock_lib_kubernetes = MagicMock()
mock_lib_telemetry.get_lib_kubernetes.return_value = mock_lib_kubernetes
SynFloodScenarioPlugin.rollback_syn_flood_pods(
rollback_content, mock_lib_telemetry
)
mock_lib_kubernetes.delete_pod.assert_not_called()
def test_rollback_syn_flood_pods_invalid_data(self):
"""Test rollback with invalid encoded data handles error gracefully"""
rollback_content = RollbackContent(
resource_identifier="invalid_base64_data",
namespace="default",
)
mock_lib_telemetry = MagicMock()
mock_lib_kubernetes = MagicMock()
mock_lib_telemetry.get_lib_kubernetes.return_value = mock_lib_kubernetes
# Should not raise exception, just log error
with self.assertLogs(level='ERROR') as log_context:
SynFloodScenarioPlugin.rollback_syn_flood_pods(
rollback_content, mock_lib_telemetry
)
# Verify error was logged
self.assertTrue(any('error' in log.lower() for log in log_context.output))
# Verify delete_pod was not called due to invalid data
mock_lib_kubernetes.delete_pod.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,826 +0,0 @@
#!/usr/bin/env python3
"""
Test suite for VMWare node scenarios
This test suite covers both the VMWare class and vmware_node_scenarios class
using mocks to avoid actual VMWare CLI calls.
Usage:
python -m coverage run -a -m unittest tests/test_vmware_node_scenarios.py -v
Assisted By: Claude Code
"""
import unittest
from unittest.mock import MagicMock, patch, PropertyMock
from krkn.scenario_plugins.node_actions.vmware_node_scenarios import vmware_node_scenarios, vSphere
from krkn_lib.models.k8s import AffectedNodeStatus
from com.vmware.vcenter.vm_client import Power
class TestVmwareNodeScenarios(unittest.TestCase):
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def setUp(self, mock_vsphere_class):
# Mock the configuration and dependencies
self.mock_kubecli = MagicMock()
self.mock_affected_nodes_status = AffectedNodeStatus()
self.mock_vsphere = MagicMock()
mock_vsphere_class.return_value = self.mock_vsphere
# Initialize the scenario class
self.vmware_scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=self.mock_affected_nodes_status
)
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_reboot_node_success(self, mock_vsphere_class):
"""Test successful node reboot."""
node_name = "test-node-01"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.reboot_instances.return_value = True
# Create a fresh instance with mocked vSphere
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
# Execute the reboot scenario
scenarios.node_reboot_scenario(
instance_kill_count=1,
node=node_name,
timeout=300
)
# Assertions
mock_vsphere.reboot_instances.assert_called_with(node_name)
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_node_not_found(self, mock_vsphere_class):
"""Test behavior when the VM does not exist in vCenter."""
node_name = "non-existent-node"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.get_vm.return_value = None
mock_vsphere.reboot_instances.side_effect = Exception(f"VM {node_name} not found")
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
# This should handle the exception gracefully (just log it)
scenarios.node_reboot_scenario(
instance_kill_count=1,
node=node_name,
timeout=300
)
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_stop_start_node(self, mock_vsphere_class):
"""Test stopping and then starting a node."""
node_name = "test-node-02"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.stop_instances.return_value = True
mock_vsphere.start_instances.return_value = True
mock_vsphere.wait_until_stopped.return_value = True
mock_vsphere.wait_until_running.return_value = True
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
# Test stop scenario
scenarios.node_stop_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
mock_vsphere.stop_instances.assert_called_with(node_name)
# Test start scenario
scenarios.node_start_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
mock_vsphere.start_instances.assert_called_with(node_name)
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_vcenter_connection_failure(self, mock_vsphere_class):
"""Test scenario where connection to vCenter fails."""
# Force the vSphere init to raise an exception
mock_vsphere_class.side_effect = Exception("Connection Refused")
with self.assertRaises(Exception):
vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_node_terminate_scenario(self, mock_vsphere_class):
"""Test node termination scenario."""
node_name = "test-node-terminate"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.stop_instances.return_value = True
mock_vsphere.wait_until_stopped.return_value = True
mock_vsphere.wait_until_released.return_value = True
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
# Execute terminate scenario
scenarios.node_terminate_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
# Verify the sequence of calls
mock_vsphere.stop_instances.assert_called_with(node_name)
mock_vsphere.wait_until_stopped.assert_called_once()
mock_vsphere.release_instances.assert_called_with(node_name)
mock_vsphere.wait_until_released.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_node_already_stopped(self, mock_vsphere_class):
"""Test scenario when node is already in the stopped state."""
node_name = "already-stopped-node"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
# Return False indicating VM is already stopped
mock_vsphere.stop_instances.return_value = False
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
scenarios.node_stop_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
# Should still call stop_instances but not wait_until_stopped
mock_vsphere.stop_instances.assert_called_with(node_name)
mock_vsphere.wait_until_stopped.assert_not_called()
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_node_already_started(self, mock_vsphere_class):
"""Test scenario when node is already in the running state."""
node_name = "already-running-node"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
# Return False indicating VM is already running
mock_vsphere.start_instances.return_value = False
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
scenarios.node_start_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
# Should still call start_instances but not wait_until_running
mock_vsphere.start_instances.assert_called_with(node_name)
mock_vsphere.wait_until_running.assert_not_called()
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.nodeaction')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_reboot_with_kube_check(self, mock_vsphere_class, mock_nodeaction):
"""Test reboot scenario with Kubernetes health checks enabled."""
node_name = "test-node-kube-check"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.reboot_instances.return_value = True
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=True, # Enable kube checks
affected_nodes_status=AffectedNodeStatus()
)
scenarios.node_reboot_scenario(
instance_kill_count=1,
node=node_name,
timeout=300
)
# Verify kube health check was called
mock_nodeaction.wait_for_unknown_status.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.nodeaction')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_start_with_kube_check(self, mock_vsphere_class, mock_nodeaction):
"""Test start scenario with Kubernetes health checks enabled."""
node_name = "test-node-start-kube"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.start_instances.return_value = True
mock_vsphere.wait_until_running.return_value = True
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=True,
affected_nodes_status=AffectedNodeStatus()
)
scenarios.node_start_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
# Verify both vSphere and kube checks were called
mock_vsphere.wait_until_running.assert_called_once()
mock_nodeaction.wait_for_ready_status.assert_called_once()
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_multiple_instance_kill_count(self, mock_vsphere_class):
"""Test scenario with multiple instance kill count (loop)."""
node_name = "test-node-multiple"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.reboot_instances.return_value = True
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
# Test with kill count of 3
scenarios.node_reboot_scenario(
instance_kill_count=3,
node=node_name,
timeout=300
)
# Should be called 3 times
assert mock_vsphere.reboot_instances.call_count == 3
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_stop_failure_exception_handling(self, mock_vsphere_class):
"""Test exception handling during node stop."""
node_name = "failing-node"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.stop_instances.side_effect = Exception("vSphere API Error")
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
# Should not raise exception, just log it
scenarios.node_stop_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
# Verify it attempted to stop
mock_vsphere.stop_instances.assert_called_with(node_name)
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_terminate_failure_exception_handling(self, mock_vsphere_class):
"""Test exception handling during node termination."""
node_name = "terminate-failing-node"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.stop_instances.return_value = True
mock_vsphere.wait_until_stopped.return_value = True
mock_vsphere.release_instances.side_effect = Exception("Cannot delete VM")
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
# Should not raise exception
scenarios.node_terminate_scenario(
instance_kill_count=1,
node=node_name,
timeout=300,
poll_interval=5
)
# Verify termination was attempted
mock_vsphere.release_instances.assert_called_with(node_name)
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_affected_nodes_tracking(self, mock_vsphere_class):
"""Test that affected nodes are properly tracked."""
node_name = "tracked-node"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
mock_vsphere.reboot_instances.return_value = True
affected_status = AffectedNodeStatus()
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=affected_status
)
# Verify no affected nodes initially
assert len(affected_status.affected_nodes) == 0
scenarios.node_reboot_scenario(
instance_kill_count=1,
node=node_name,
timeout=300
)
# Verify affected node was tracked
assert len(affected_status.affected_nodes) == 1
assert affected_status.affected_nodes[0].node_name == node_name
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.vSphere')
def test_reboot_not_allowed_state(self, mock_vsphere_class):
"""Test reboot when VM is in a state that doesn't allow reboot."""
node_name = "powered-off-node"
mock_vsphere = MagicMock()
mock_vsphere_class.return_value = mock_vsphere
# Return False indicating reboot failed (VM not powered on)
mock_vsphere.reboot_instances.return_value = False
scenarios = vmware_node_scenarios(
kubecli=self.mock_kubecli,
node_action_kube_check=False,
affected_nodes_status=AffectedNodeStatus()
)
scenarios.node_reboot_scenario(
instance_kill_count=1,
node=node_name,
timeout=300
)
# Should attempt reboot
mock_vsphere.reboot_instances.assert_called_with(node_name)
class TestVSphereClass(unittest.TestCase):
"""Test suite for the vSphere class."""
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_vsphere_initialization_success(self, mock_session, mock_create_client):
"""Test successful vSphere client initialization."""
mock_client = MagicMock()
mock_create_client.return_value = mock_client
vsphere = vSphere()
self.assertEqual(vsphere.server, '192.168.1.100')
self.assertEqual(vsphere.username, 'admin')
self.assertEqual(vsphere.password, 'password123')
self.assertTrue(vsphere.credentials_present)
mock_create_client.assert_called_once()
@patch.dict('os.environ', {}, clear=True)
def test_vsphere_initialization_missing_credentials(self):
"""Test vSphere initialization fails when credentials are missing."""
with self.assertRaises(Exception) as context:
vSphere()
self.assertIn("Environmental variables", str(context.exception))
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_get_vm_success(self, mock_session, mock_create_client):
"""Test getting a VM by name."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_create_client.return_value = mock_client
vsphere = vSphere()
vm_id = vsphere.get_vm('test-vm')
self.assertEqual(vm_id, 'vm-123')
mock_client.vcenter.VM.list.assert_called_once()
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_get_vm_not_found(self, mock_session, mock_create_client):
"""Test getting a VM that doesn't exist."""
mock_client = MagicMock()
mock_client.vcenter.VM.list.return_value = []
mock_create_client.return_value = mock_client
vsphere = vSphere()
vm_id = vsphere.get_vm('non-existent-vm')
self.assertIsNone(vm_id)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_reboot_instances_success(self, mock_session, mock_create_client):
"""Test successful VM reboot."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_create_client.return_value = mock_client
vsphere = vSphere()
result = vsphere.reboot_instances('test-vm')
self.assertTrue(result)
mock_client.vcenter.vm.Power.reset.assert_called_with('vm-123')
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_reboot_instances_not_powered_on(self, mock_session, mock_create_client):
"""Test reboot fails when VM is not powered on."""
from com.vmware.vapi.std.errors_client import NotAllowedInCurrentState
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_client.vcenter.vm.Power.reset.side_effect = NotAllowedInCurrentState()
mock_create_client.return_value = mock_client
vsphere = vSphere()
result = vsphere.reboot_instances('test-vm')
self.assertFalse(result)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_stop_instances_success(self, mock_session, mock_create_client):
"""Test successful VM stop."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_create_client.return_value = mock_client
vsphere = vSphere()
result = vsphere.stop_instances('test-vm')
self.assertTrue(result)
mock_client.vcenter.vm.Power.stop.assert_called_with('vm-123')
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_stop_instances_already_stopped(self, mock_session, mock_create_client):
"""Test stop when VM is already stopped."""
from com.vmware.vapi.std.errors_client import AlreadyInDesiredState
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_client.vcenter.vm.Power.stop.side_effect = AlreadyInDesiredState()
mock_create_client.return_value = mock_client
vsphere = vSphere()
result = vsphere.stop_instances('test-vm')
self.assertFalse(result)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_start_instances_success(self, mock_session, mock_create_client):
"""Test successful VM start."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_create_client.return_value = mock_client
vsphere = vSphere()
result = vsphere.start_instances('test-vm')
self.assertTrue(result)
mock_client.vcenter.vm.Power.start.assert_called_with('vm-123')
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_start_instances_already_started(self, mock_session, mock_create_client):
"""Test start when VM is already running."""
from com.vmware.vapi.std.errors_client import AlreadyInDesiredState
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_client.vcenter.vm.Power.start.side_effect = AlreadyInDesiredState()
mock_create_client.return_value = mock_client
vsphere = vSphere()
result = vsphere.start_instances('test-vm')
self.assertFalse(result)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_get_vm_status(self, mock_session, mock_create_client):
"""Test getting VM status."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_power_state = MagicMock()
mock_power_state.state = Power.State.POWERED_ON
mock_client.vcenter.vm.Power.get.return_value = mock_power_state
mock_create_client.return_value = mock_client
vsphere = vSphere()
status = vsphere.get_vm_status('test-vm')
self.assertEqual(status, Power.State.POWERED_ON)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_get_vm_status_exception(self, mock_session, mock_create_client):
"""Test get_vm_status handles exceptions gracefully."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
mock_client.vcenter.vm.Power.get.side_effect = Exception("API Error")
mock_create_client.return_value = mock_client
vsphere = vSphere()
status = vsphere.get_vm_status('test-vm')
self.assertIsNone(status)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.time.sleep')
def test_wait_until_running(self, mock_sleep, mock_session, mock_create_client):
"""Test waiting for VM to reach POWERED_ON state."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
# Simulate VM transitioning to POWERED_ON after 2 checks
mock_power_states = [
MagicMock(state=Power.State.POWERED_OFF),
MagicMock(state=Power.State.POWERED_ON)
]
mock_client.vcenter.vm.Power.get.side_effect = mock_power_states
mock_create_client.return_value = mock_client
vsphere = vSphere()
mock_affected_node = MagicMock()
result = vsphere.wait_until_running('test-vm', timeout=60, affected_node=mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.time.sleep')
def test_wait_until_stopped(self, mock_sleep, mock_session, mock_create_client):
"""Test waiting for VM to reach POWERED_OFF state."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
# Simulate VM transitioning to POWERED_OFF
mock_power_states = [
MagicMock(state=Power.State.POWERED_ON),
MagicMock(state=Power.State.POWERED_OFF)
]
mock_client.vcenter.vm.Power.get.side_effect = mock_power_states
mock_create_client.return_value = mock_client
vsphere = vSphere()
mock_affected_node = MagicMock()
result = vsphere.wait_until_stopped('test-vm', timeout=60, affected_node=mock_affected_node)
self.assertTrue(result)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.time.sleep')
def test_wait_until_running_timeout(self, mock_sleep, mock_session, mock_create_client):
"""Test wait_until_running times out."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
mock_client.vcenter.VM.list.return_value = [mock_vm_obj]
# VM is POWERED_OFF initially, then transitions to POWERED_ON after timeout to exit loop
call_count = [0]
def get_status_side_effect(vm):
call_count[0] += 1
# Return POWERED_OFF for first 2 calls (to exceed timeout=2 with 5 second increments)
# Then return POWERED_ON to exit the loop
if call_count[0] <= 2:
return MagicMock(state=Power.State.POWERED_OFF)
return MagicMock(state=Power.State.POWERED_ON)
mock_client.vcenter.vm.Power.get.side_effect = get_status_side_effect
mock_create_client.return_value = mock_client
vsphere = vSphere()
mock_affected_node = MagicMock()
result = vsphere.wait_until_running('test-vm', timeout=2, affected_node=mock_affected_node)
self.assertFalse(result)
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.time.sleep')
def test_wait_until_released(self, mock_sleep, mock_session, mock_create_client):
"""Test waiting for VM to be deleted."""
mock_client = MagicMock()
mock_vm_obj = MagicMock()
mock_vm_obj.vm = 'vm-123'
# VM exists first, then is deleted
mock_client.vcenter.VM.list.side_effect = [
[mock_vm_obj], # VM exists
[] # VM deleted
]
mock_create_client.return_value = mock_client
vsphere = vSphere()
mock_affected_node = MagicMock()
result = vsphere.wait_until_released('test-vm', timeout=60, affected_node=mock_affected_node)
self.assertTrue(result)
mock_affected_node.set_affected_node_status.assert_called_once()
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_get_datacenter_list(self, mock_session, mock_create_client):
"""Test getting list of datacenters."""
mock_client = MagicMock()
mock_dc1 = MagicMock()
mock_dc1.datacenter = 'dc-1'
mock_dc1.name = 'Datacenter1'
mock_dc2 = MagicMock()
mock_dc2.datacenter = 'dc-2'
mock_dc2.name = 'Datacenter2'
mock_client.vcenter.Datacenter.list.return_value = [mock_dc1, mock_dc2]
mock_create_client.return_value = mock_client
vsphere = vSphere()
datacenters = vsphere.get_datacenter_list()
self.assertEqual(len(datacenters), 2)
self.assertEqual(datacenters[0]['datacenter_name'], 'Datacenter1')
self.assertEqual(datacenters[1]['datacenter_name'], 'Datacenter2')
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_release_instances_vm_not_found(self, mock_session, mock_create_client):
"""Test release_instances raises exception when VM not found."""
mock_client = MagicMock()
mock_client.vcenter.VM.list.return_value = []
mock_create_client.return_value = mock_client
vsphere = vSphere()
with self.assertRaises(Exception) as context:
vsphere.release_instances('non-existent-vm')
self.assertIn("does not exist", str(context.exception))
@patch.dict('os.environ', {
'VSPHERE_IP': '192.168.1.100',
'VSPHERE_USERNAME': 'admin',
'VSPHERE_PASSWORD': 'password123'
})
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.create_vsphere_client')
@patch('krkn.scenario_plugins.node_actions.vmware_node_scenarios.requests.session')
def test_get_unverified_session(self, mock_session_class, mock_create_client):
"""Test creating an unverified session."""
mock_session_instance = MagicMock()
mock_session_class.return_value = mock_session_instance
mock_create_client.return_value = MagicMock()
vsphere = vSphere()
session = vsphere.get_unverified_session()
self.assertFalse(session.verify)
mock_session_class.assert_called()

View File

@@ -1,31 +0,0 @@
# ⚠️ DEPRECATED
This directory is **no longer actively maintained** and will not accept new changes.
## Migration Notice
All development efforts have been moved to:
**[github.com/krkn-chaos/krkn-ai](https://github.com/krkn-chaos/krkn-ai)**
## What This Means
- ❌ No new features will be added here
- ❌ Bug fixes will not be accepted
- ❌ Pull requests will be closed and redirected
- Existing code remains for historical reference only
## Next Steps
If you're looking to:
- **Use** chaos engineering AI features → Visit [krkn-chaos/krkn-ai](https://github.com/krkn-chaos/krkn-ai)
- **Contribute** improvements → Submit to [krkn-chaos/krkn-ai](https://github.com/krkn-chaos/krkn-ai)
- **Report issues** → Open issues at [krkn-chaos/krkn-ai](https://github.com/krkn-chaos/krkn-ai/issues)
## Questions?
Please visit the new repository for documentation, examples, and community support.
---
**Last Updated:** January 2026

View File

@@ -0,0 +1,304 @@
# OpenShift Shenanigans
## Workflow Description
Given a target OpenShift cluster, this workflow executes a
[kube-burner plugin](https://github.com/redhat-performance/arcaflow-plugin-kube-burner)
workflow to place a load on the cluster, repeatedly removes a targeted pod at a given time frequency with the [kill-pod plugin](https://github.com/krkn-chaos/arcaflow-plugin-kill-pod),
and runs a [stress-ng](https://github.com/ColinIanKing/stress-ng) CPU workload on the cluster.
Target your OpenShift cluster with the appropriate `kubeconfig` file, and add its file path as
the value for `kubernetes_target.kubeconfig_path`, in the input file. Any combination of subworkflows can be disabled in the input file by setting either `cpu_hog_enabled`, `pod_chaos_enabled`, or `kubeburner_enabled` to `false`.
## Files
- [`workflow.yaml`](workflow.yaml) -- Defines the workflow input schema, the plugins to run
and their data relationships, and the output to present to the user
- [`input.yaml`](input.yaml) -- The input parameters that the user provides for running
the workflow
- [`config.yaml`](config.yaml) -- Global config parameters that are passed to the Arcaflow
engine
- [`cpu-hog.yaml`](subworkflows/cpu-hog.yaml) -- The StressNG workload on the CPU.
- [`kubeburner.yaml`](subworkflows/kubeburner.yaml) -- The KubeBurner workload for the Kubernetes Cluster API.
- [`pod-chaos.yaml`](subworkflows/pod-chaos.yaml) -- The Kill Pod workflow for the Kubernetes infrastructure pods.
## Running the Workflow
### Workflow Dependencies
Install Python, at least `3.9`.
First, add the path to your Python interpreter to `config.yaml` as the value
for `pythonPath` as shown here. A common choice for users working in
distributions of Linux operating systems is `usr/bin/python`. Second, add a
directory to which your Arcaflow process will have write access as the
value for `workdir`, `/tmp` is a common choice because your process will likely be able to write to it.
```yaml
deployers:
python:
pythonPath: /usr/bin/python
workdir: /tmp
```
To use this Python interpreter with our `kill-pod` plugin, go to the `deploy` section of the `kill_pod` step in [`pod-chaos.yaml`](subworkflows/pod-chaos.yaml). You can use the same `pythonPath` and `workdir` that you used in
your `config.yaml`.
```yaml
deploy:
deployer_name: python
modulePullPolicy: Always
pythonPath: /usr/bin/python
workdir: /tmp
```
Download a Go binary of the latest version of the Arcaflow engine from: https://github.com/arcalot/arcaflow-engine/releases.
#### OpenShift Target
Target your desired OpenShift cluster by setting the `kubeconfig_path` variable for each subworkflow's parameter list in [`input.yaml`](input.yaml).
#### Kube-Burner Plugin
The `kube-burner` plugin generates and reports the UUID to which the
`kube-burner` data is associated in your search database. The `uuidgen`
workflow step uses the `arcaflow-plugin-utilities` `uuid` plugin step to
randomly generate a UUID for you.
### Workflow Execution
Run the workflow:
```
$ export WFPATH=<path to this workflow directory>
$ arcaflow --context ${WFPATH} --input input.yaml --config config.yaml --workflow workflow.yaml
```
## Workflow Diagram
This diagram shows the complete end-to-end workflow logic.
### Main Workflow
```mermaid
%% Mermaid markdown workflow
flowchart LR
%% Success path
input-->steps.cpu_hog_wf.enabling
input-->steps.cpu_hog_wf.execute
input-->steps.kubeburner_wf.enabling
input-->steps.kubeburner_wf.execute
input-->steps.pod_chaos_wf.enabling
input-->steps.pod_chaos_wf.execute
outputs.workflow_success.cpu_hog-->outputs.workflow_success
outputs.workflow_success.cpu_hog.disabled-->outputs.workflow_success.cpu_hog
outputs.workflow_success.cpu_hog.enabled-->outputs.workflow_success.cpu_hog
outputs.workflow_success.kubeburner-->outputs.workflow_success
outputs.workflow_success.kubeburner.disabled-->outputs.workflow_success.kubeburner
outputs.workflow_success.kubeburner.enabled-->outputs.workflow_success.kubeburner
outputs.workflow_success.pod_chaos-->outputs.workflow_success
outputs.workflow_success.pod_chaos.disabled-->outputs.workflow_success.pod_chaos
outputs.workflow_success.pod_chaos.enabled-->outputs.workflow_success.pod_chaos
steps.cpu_hog_wf.closed-->steps.cpu_hog_wf.closed.result
steps.cpu_hog_wf.disabled-->steps.cpu_hog_wf.disabled.output
steps.cpu_hog_wf.disabled.output-->outputs.workflow_success.cpu_hog.disabled
steps.cpu_hog_wf.enabling-->steps.cpu_hog_wf.closed
steps.cpu_hog_wf.enabling-->steps.cpu_hog_wf.disabled
steps.cpu_hog_wf.enabling-->steps.cpu_hog_wf.enabling.resolved
steps.cpu_hog_wf.enabling-->steps.cpu_hog_wf.execute
steps.cpu_hog_wf.execute-->steps.cpu_hog_wf.outputs
steps.cpu_hog_wf.outputs-->steps.cpu_hog_wf.outputs.success
steps.cpu_hog_wf.outputs.success-->outputs.workflow_success.cpu_hog.enabled
steps.kubeburner_wf.closed-->steps.kubeburner_wf.closed.result
steps.kubeburner_wf.disabled-->steps.kubeburner_wf.disabled.output
steps.kubeburner_wf.disabled.output-->outputs.workflow_success.kubeburner.disabled
steps.kubeburner_wf.enabling-->steps.kubeburner_wf.closed
steps.kubeburner_wf.enabling-->steps.kubeburner_wf.disabled
steps.kubeburner_wf.enabling-->steps.kubeburner_wf.enabling.resolved
steps.kubeburner_wf.enabling-->steps.kubeburner_wf.execute
steps.kubeburner_wf.execute-->steps.kubeburner_wf.outputs
steps.kubeburner_wf.outputs-->steps.kubeburner_wf.outputs.success
steps.kubeburner_wf.outputs.success-->outputs.workflow_success.kubeburner.enabled
steps.pod_chaos_wf.closed-->steps.pod_chaos_wf.closed.result
steps.pod_chaos_wf.disabled-->steps.pod_chaos_wf.disabled.output
steps.pod_chaos_wf.disabled.output-->outputs.workflow_success.pod_chaos.disabled
steps.pod_chaos_wf.enabling-->steps.pod_chaos_wf.closed
steps.pod_chaos_wf.enabling-->steps.pod_chaos_wf.disabled
steps.pod_chaos_wf.enabling-->steps.pod_chaos_wf.enabling.resolved
steps.pod_chaos_wf.enabling-->steps.pod_chaos_wf.execute
steps.pod_chaos_wf.execute-->steps.pod_chaos_wf.outputs
steps.pod_chaos_wf.outputs-->steps.pod_chaos_wf.outputs.success
steps.pod_chaos_wf.outputs.success-->outputs.workflow_success.pod_chaos.enabled
%% Error path
steps.cpu_hog_wf.execute-->steps.cpu_hog_wf.failed
steps.cpu_hog_wf.failed-->steps.cpu_hog_wf.failed.error
steps.kubeburner_wf.execute-->steps.kubeburner_wf.failed
steps.kubeburner_wf.failed-->steps.kubeburner_wf.failed.error
steps.pod_chaos_wf.execute-->steps.pod_chaos_wf.failed
steps.pod_chaos_wf.failed-->steps.pod_chaos_wf.failed.error
%% Mermaid end
```
### Pod Chaos Workflow
```mermaid
%% Mermaid markdown workflow
flowchart LR
%% Success path
input-->steps.kill_pod.starting
steps.kill_pod.cancelled-->steps.kill_pod.closed
steps.kill_pod.cancelled-->steps.kill_pod.outputs
steps.kill_pod.closed-->steps.kill_pod.closed.result
steps.kill_pod.deploy-->steps.kill_pod.closed
steps.kill_pod.deploy-->steps.kill_pod.starting
steps.kill_pod.disabled-->steps.kill_pod.disabled.output
steps.kill_pod.enabling-->steps.kill_pod.closed
steps.kill_pod.enabling-->steps.kill_pod.disabled
steps.kill_pod.enabling-->steps.kill_pod.enabling.resolved
steps.kill_pod.enabling-->steps.kill_pod.starting
steps.kill_pod.outputs-->steps.kill_pod.outputs.success
steps.kill_pod.outputs.success-->outputs.success
steps.kill_pod.running-->steps.kill_pod.closed
steps.kill_pod.running-->steps.kill_pod.outputs
steps.kill_pod.starting-->steps.kill_pod.closed
steps.kill_pod.starting-->steps.kill_pod.running
steps.kill_pod.starting-->steps.kill_pod.starting.started
%% Error path
steps.kill_pod.cancelled-->steps.kill_pod.crashed
steps.kill_pod.cancelled-->steps.kill_pod.deploy_failed
steps.kill_pod.crashed-->steps.kill_pod.crashed.error
steps.kill_pod.deploy-->steps.kill_pod.deploy_failed
steps.kill_pod.deploy_failed-->steps.kill_pod.deploy_failed.error
steps.kill_pod.enabling-->steps.kill_pod.crashed
steps.kill_pod.outputs-->steps.kill_pod.outputs.error
steps.kill_pod.running-->steps.kill_pod.crashed
steps.kill_pod.starting-->steps.kill_pod.crashed
%% Mermaid end
```
### StressNG (CPU Hog) Workflow
```mermaid
%% Mermaid markdown workflow
flowchart LR
%% Success path
input-->steps.kubeconfig.starting
input-->steps.stressng.deploy
input-->steps.stressng.starting
steps.kubeconfig.cancelled-->steps.kubeconfig.closed
steps.kubeconfig.cancelled-->steps.kubeconfig.outputs
steps.kubeconfig.closed-->steps.kubeconfig.closed.result
steps.kubeconfig.deploy-->steps.kubeconfig.closed
steps.kubeconfig.deploy-->steps.kubeconfig.starting
steps.kubeconfig.disabled-->steps.kubeconfig.disabled.output
steps.kubeconfig.enabling-->steps.kubeconfig.closed
steps.kubeconfig.enabling-->steps.kubeconfig.disabled
steps.kubeconfig.enabling-->steps.kubeconfig.enabling.resolved
steps.kubeconfig.enabling-->steps.kubeconfig.starting
steps.kubeconfig.outputs-->steps.kubeconfig.outputs.success
steps.kubeconfig.outputs.success-->steps.stressng.deploy
steps.kubeconfig.running-->steps.kubeconfig.closed
steps.kubeconfig.running-->steps.kubeconfig.outputs
steps.kubeconfig.starting-->steps.kubeconfig.closed
steps.kubeconfig.starting-->steps.kubeconfig.running
steps.kubeconfig.starting-->steps.kubeconfig.starting.started
steps.stressng.cancelled-->steps.stressng.closed
steps.stressng.cancelled-->steps.stressng.outputs
steps.stressng.closed-->steps.stressng.closed.result
steps.stressng.deploy-->steps.stressng.closed
steps.stressng.deploy-->steps.stressng.starting
steps.stressng.disabled-->steps.stressng.disabled.output
steps.stressng.enabling-->steps.stressng.closed
steps.stressng.enabling-->steps.stressng.disabled
steps.stressng.enabling-->steps.stressng.enabling.resolved
steps.stressng.enabling-->steps.stressng.starting
steps.stressng.outputs-->steps.stressng.outputs.success
steps.stressng.outputs.success-->outputs.success
steps.stressng.running-->steps.stressng.closed
steps.stressng.running-->steps.stressng.outputs
steps.stressng.starting-->steps.stressng.closed
steps.stressng.starting-->steps.stressng.running
steps.stressng.starting-->steps.stressng.starting.started
%% Error path
steps.kubeconfig.cancelled-->steps.kubeconfig.crashed
steps.kubeconfig.cancelled-->steps.kubeconfig.deploy_failed
steps.kubeconfig.crashed-->steps.kubeconfig.crashed.error
steps.kubeconfig.deploy-->steps.kubeconfig.deploy_failed
steps.kubeconfig.deploy_failed-->steps.kubeconfig.deploy_failed.error
steps.kubeconfig.enabling-->steps.kubeconfig.crashed
steps.kubeconfig.outputs-->steps.kubeconfig.outputs.error
steps.kubeconfig.running-->steps.kubeconfig.crashed
steps.kubeconfig.starting-->steps.kubeconfig.crashed
steps.stressng.cancelled-->steps.stressng.crashed
steps.stressng.cancelled-->steps.stressng.deploy_failed
steps.stressng.crashed-->steps.stressng.crashed.error
steps.stressng.deploy-->steps.stressng.deploy_failed
steps.stressng.deploy_failed-->steps.stressng.deploy_failed.error
steps.stressng.enabling-->steps.stressng.crashed
steps.stressng.outputs-->steps.stressng.outputs.error
steps.stressng.running-->steps.stressng.crashed
steps.stressng.starting-->steps.stressng.crashed
%% Mermaid end
```
### Kube-Burner Workflow
```mermaid
%% Mermaid markdown workflow
flowchart LR
%% Success path
input-->steps.kubeburner.starting
steps.kubeburner.cancelled-->steps.kubeburner.closed
steps.kubeburner.cancelled-->steps.kubeburner.outputs
steps.kubeburner.closed-->steps.kubeburner.closed.result
steps.kubeburner.deploy-->steps.kubeburner.closed
steps.kubeburner.deploy-->steps.kubeburner.starting
steps.kubeburner.disabled-->steps.kubeburner.disabled.output
steps.kubeburner.enabling-->steps.kubeburner.closed
steps.kubeburner.enabling-->steps.kubeburner.disabled
steps.kubeburner.enabling-->steps.kubeburner.enabling.resolved
steps.kubeburner.enabling-->steps.kubeburner.starting
steps.kubeburner.outputs-->steps.kubeburner.outputs.success
steps.kubeburner.outputs.success-->outputs.success
steps.kubeburner.running-->steps.kubeburner.closed
steps.kubeburner.running-->steps.kubeburner.outputs
steps.kubeburner.starting-->steps.kubeburner.closed
steps.kubeburner.starting-->steps.kubeburner.running
steps.kubeburner.starting-->steps.kubeburner.starting.started
steps.uuidgen.cancelled-->steps.uuidgen.closed
steps.uuidgen.cancelled-->steps.uuidgen.outputs
steps.uuidgen.closed-->steps.uuidgen.closed.result
steps.uuidgen.deploy-->steps.uuidgen.closed
steps.uuidgen.deploy-->steps.uuidgen.starting
steps.uuidgen.disabled-->steps.uuidgen.disabled.output
steps.uuidgen.enabling-->steps.uuidgen.closed
steps.uuidgen.enabling-->steps.uuidgen.disabled
steps.uuidgen.enabling-->steps.uuidgen.enabling.resolved
steps.uuidgen.enabling-->steps.uuidgen.starting
steps.uuidgen.outputs-->steps.uuidgen.outputs.success
steps.uuidgen.outputs.success-->steps.kubeburner.starting
steps.uuidgen.running-->steps.uuidgen.closed
steps.uuidgen.running-->steps.uuidgen.outputs
steps.uuidgen.starting-->steps.uuidgen.closed
steps.uuidgen.starting-->steps.uuidgen.running
steps.uuidgen.starting-->steps.uuidgen.starting.started
%% Error path
steps.kubeburner.cancelled-->steps.kubeburner.crashed
steps.kubeburner.cancelled-->steps.kubeburner.deploy_failed
steps.kubeburner.crashed-->steps.kubeburner.crashed.error
steps.kubeburner.deploy-->steps.kubeburner.deploy_failed
steps.kubeburner.deploy_failed-->steps.kubeburner.deploy_failed.error
steps.kubeburner.enabling-->steps.kubeburner.crashed
steps.kubeburner.outputs-->steps.kubeburner.outputs.error
steps.kubeburner.running-->steps.kubeburner.crashed
steps.kubeburner.starting-->steps.kubeburner.crashed
steps.uuidgen.cancelled-->steps.uuidgen.crashed
steps.uuidgen.cancelled-->steps.uuidgen.deploy_failed
steps.uuidgen.crashed-->steps.uuidgen.crashed.error
steps.uuidgen.deploy-->steps.uuidgen.deploy_failed
steps.uuidgen.deploy_failed-->steps.uuidgen.deploy_failed.error
steps.uuidgen.enabling-->steps.uuidgen.crashed
steps.uuidgen.outputs-->steps.uuidgen.outputs.error
steps.uuidgen.running-->steps.uuidgen.crashed
steps.uuidgen.starting-->steps.uuidgen.crashed
%% Mermaid end
```

View File

@@ -0,0 +1,18 @@
---
deployers:
image:
deployer_name: podman
deployment:
imagePullPolicy: IfNotPresent
python:
deployer_name: python
modulePullPolicy: Always
pythonPath: /usr/bin/python
workdir: /tmp
log:
level: debug
logged_outputs:
error:
level: debug
success:
level: debug

View File

@@ -0,0 +1,41 @@
kubernetes_target:
kubeconfig_path:
cpu_hog_enabled: true
pod_chaos_enabled: true
kubeburner_enabled: true
kubeburner_list:
- kubeburner:
kubeconfig: 'given later in workflow by kubeconfig plugin'
workload: 'cluster-density'
qps: 20
burst: 20
log_level: 'info'
timeout: '1m'
iterations: 1
churn: 'true'
churn_duration: 1s
churn_delay: 1s
churn_percent: 10
alerting: 'true'
gc: 'true'
pod_chaos_list:
- namespace_pattern: ^openshift-etcd$
label_selector: k8s-app=etcd
kill: 1
krkn_pod_recovery_time: 1
cpu_hog_list:
- namespace: default
# set the node selector as a key-value pair eg.
# node_selector:
# kubernetes.io/hostname: kind-worker2
node_selector: {}
stressng_params:
timeout: 1
stressors:
- stressor: cpu
workers: 1
cpu-load: 20
cpu-method: all

View File

@@ -0,0 +1,75 @@
version: v0.2.0
input:
root: CpuHog__KubernetesTarget
objects:
CpuHog__KubernetesTarget:
id: CpuHog__KubernetesTarget
properties:
constant:
type:
type_id: ref
id: KubernetesTarget
item:
type:
type_id: ref
id: CpuHog
KubernetesTarget:
id: KubernetesTarget
properties:
kubeconfig_path:
type:
type_id: string
CpuHog:
id: CpuHog
properties:
namespace:
display:
description: The namespace where the container will be deployed
name: Namespace
type:
type_id: string
required: true
node_selector:
display:
description: kubernetes node name where the plugin must be deployed
type:
type_id: map
values:
type_id: string
keys:
type_id: string
required: true
stressng_params:
type:
type_id: ref
id: StressNGParams
namespace: $.steps.stressng.starting.inputs.input
steps:
kubeconfig:
plugin:
src: quay.io/arcalot/arcaflow-plugin-kubeconfig:0.3.1
deployment_type: image
input:
kubeconfig: !expr 'readFile($.input.constant.kubeconfig_path)'
stressng:
plugin:
src: quay.io/arcalot/arcaflow-plugin-stressng:0.8.0
deployment_type: image
step: workload
input: !expr $.input.item.stressng_params
deploy:
deployer_name: kubernetes
connection: !expr $.steps.kubeconfig.outputs.success.connection
pod:
metadata:
namespace: !expr $.input.item.namespace
labels:
arcaflow: stressng
spec:
nodeSelector: !expr $.input.item.node_selector
pluginContainer:
imagePullPolicy: Always
outputs:
success: !expr $.steps.stressng.outputs.success

View File

@@ -0,0 +1,54 @@
version: v0.2.0
input:
root: KubeBurner__KubernetesTarget
objects:
KubeBurner__KubernetesTarget:
id: KubeBurner__KubernetesTarget
properties:
constant:
type:
type_id: ref
id: KubernetesTarget
item:
type:
type_id: ref
id: KubeBurner
KubernetesTarget:
id: KubernetesTarget
properties:
kubeconfig_path:
type:
type_id: string
KubeBurner:
id: KubeBurner
properties:
kubeburner:
type:
type_id: ref
id: KubeBurnerInputParams
namespace: $.steps.kubeburner.starting.inputs.input
steps:
uuidgen:
plugin:
deployment_type: image
src: quay.io/arcalot/arcaflow-plugin-utilities:0.6.0
step: uuid
input: {}
kubeburner:
plugin:
deployment_type: image
src: quay.io/redhat-performance/arcaflow-plugin-kube-burner:latest
step: kube-burner
input:
kubeconfig: !expr 'readFile($.input.constant.kubeconfig_path)'
uuid: !expr $.steps.uuidgen.outputs.success.uuid
workload: !expr $.input.item.kubeburner.workload
iterations: !expr $.input.item.kubeburner.iterations
churn: !expr $.input.item.kubeburner.churn
churn_duration: !expr $.input.item.kubeburner.churn_duration
churn_delay: !expr $.input.item.kubeburner.churn_delay
outputs:
success:
burner: !expr $.steps.kubeburner.outputs.success

View File

@@ -0,0 +1,108 @@
version: v0.2.0
input:
root: KillPodConfig__KubernetesTarget
objects:
KillPodConfig__KubernetesTarget:
id: KillPodConfig__KubernetesTarget
properties:
constant:
type:
type_id: ref
id: KubernetesTarget
item:
type:
type_id: ref
id: KillPodConfig
KubernetesTarget:
id: KubernetesTarget
properties:
kubeconfig_path:
type:
type_id: string
KillPodConfig:
id: KillPodConfig
properties:
backoff:
default: '1'
display:
description: How many seconds to wait between checks for the target
pod status.
name: Backoff
required: false
type:
type_id: integer
kill:
default: '1'
display:
description: How many pods should we attempt to kill?
name: Number of pods to kill
required: false
type:
min: 1
type_id: integer
krkn_pod_recovery_time:
default: '60'
display:
description: The Expected Recovery time fo the pod (used by Krkn to
monitor the pod lifecycle)
name: Recovery Time
required: false
type:
type_id: integer
label_selector:
display:
description: 'Kubernetes label selector for the target pods. Required
if name_pattern is not set.
See https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
for details.'
name: Label selector
required: false
required_if_not:
- name_pattern
type:
type_id: string
name_pattern:
display:
description: Regular expression for target pods. Required if label_selector
is not set.
name: Name pattern
required: false
required_if_not:
- label_selector
type:
type_id: pattern
namespace_pattern:
display:
description: Regular expression for target pod namespaces.
name: Namespace pattern
required: true
type:
type_id: pattern
timeout:
default: '180'
display:
description: Timeout to wait for the target pod(s) to be removed in
seconds.
name: Timeout
required: false
type:
type_id: integer
steps:
kill_pod:
step: kill-pods
plugin:
deployment_type: python
src: arcaflow-plugin-kill-pod@git+https://github.com/krkn-chaos/arcaflow-plugin-kill-pod.git@a9f87f88d8e7763d111613bd8b2c7862fc49624f
input:
namespace_pattern: !expr $.input.item.namespace_pattern
label_selector: !expr $.input.item.label_selector
kubeconfig_path: !expr $.input.constant.kubeconfig_path
deploy:
deployer_name: python
modulePullPolicy: Always
pythonPath: /usr/bin/python
workdir: /tmp
outputs:
success: !expr $.steps.kill_pod.outputs.success

View File

@@ -0,0 +1,73 @@
version: v0.2.0
input:
root: RootObject
objects:
KubernetesTarget:
id: KubernetesTarget
properties:
kubeconfig_path:
type:
type_id: string
RootObject:
id: RootObject
properties:
cpu_hog_enabled:
type:
type_id: bool
pod_chaos_enabled:
type:
type_id: bool
kubeburner_enabled:
type:
type_id: bool
kubernetes_target:
type:
type_id: ref
id: KubernetesTarget
kubeburner_list:
type:
type_id: list
items:
type_id: ref
id: KubeBurner
namespace: $.steps.kubeburner_wf.execute.inputs.items
pod_chaos_list:
type:
type_id: list
items:
type_id: ref
id: KillPodConfig
namespace: $.steps.pod_chaos_wf.execute.inputs.items
cpu_hog_list:
type:
type_id: list
items:
type_id: ref
id: CpuHog
namespace: $.steps.cpu_hog_wf.execute.inputs.items
steps:
kubeburner_wf:
kind: foreach
items: !expr 'bindConstants($.input.kubeburner_list, $.input.kubernetes_target)'
workflow: subworkflows/kubeburner.yaml
parallelism: 1
enabled: !expr $.input.kubeburner_enabled
pod_chaos_wf:
kind: foreach
items: !expr 'bindConstants($.input.pod_chaos_list, $.input.kubernetes_target)'
workflow: subworkflows/pod-chaos.yaml
parallelism: 1
enabled: !expr $.input.pod_chaos_enabled
cpu_hog_wf:
kind: foreach
items: !expr 'bindConstants($.input.cpu_hog_list, $.input.kubernetes_target)'
workflow: subworkflows/cpu-hog.yaml
parallelism: 1
enabled: !expr $.input.cpu_hog_enabled
outputs:
workflow_success:
kubeburner: !ordisabled $.steps.kubeburner_wf.outputs.success
pod_chaos: !ordisabled $.steps.pod_chaos_wf.outputs.success
cpu_hog: !ordisabled $.steps.cpu_hog_wf.outputs.success

40
utils/chaos_ai/README.md Normal file
View File

@@ -0,0 +1,40 @@
# aichaos
Enhancing Chaos Engineering with AI-assisted fault injection for better resiliency and non-functional testing.
## Generate python package wheel file
```
$ python3.9 generate_wheel_package.py sdist bdist_wheel
$ cp dist/aichaos-0.0.1-py3-none-any.whl docker/
```
This creates a python package file aichaos-0.0.1-py3-none-any.whl in the dist folder.
## Build Image
```
$ cd docker
$ podman build -t aichaos:1.0 .
OR
$ docker build -t aichaos:1.0 .
```
## Run Chaos AI
```
$ podman run -v aichaos-config.json:/config/aichaos-config.json --privileged=true --name aichaos -p 5001:5001 aichaos:1.0
OR
$ docker run -v aichaos-config.json:/config/aichaos-config.json --privileged -v /var/run/docker.sock:/var/run/docker.sock --name aichaos -p 5001:5001 aichaos:1.0
```
The output should look like:
```
$ podman run -v aichaos-config.json:/config/aichaos-config.json --privileged=true --name aichaos -p 5001:5001 aichaos:1.0
* Serving Flask app 'swagger_api' (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5001
* Running on http://172.17.0.2:5001
```
You can try out the APIs in browser at http://<server-ip>:5001/apidocs (eg. http://127.0.0.1:5001/apidocs). For testing out, you can try “GenerateChaos” api with kubeconfig file and application URLs to test.

View File

@@ -0,0 +1,21 @@
FROM bitnami/kubectl:1.20.9 as kubectl
FROM python:3.9
WORKDIR /app
RUN pip3 install --upgrade pip
COPY config config/
COPY requirements.txt .
RUN mkdir -p /app/logs
RUN pip3 install -r requirements.txt
COPY --from=kubectl /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/
COPY swagger_api.py .
ENV PYTHONUNBUFFERED=1
RUN curl -fsSLO https://get.docker.com/builds/Linux/x86_64/docker-17.03.1-ce.tgz && tar --strip-components=1 -xvzf docker-17.03.1-ce.tgz -C /usr/local/bin
RUN apt-get update && apt-get install -y podman
COPY aichaos-0.0.1-py3-none-any.whl .
RUN pip3 install aichaos-0.0.1-py3-none-any.whl
CMD ["python3", "swagger_api.py"]

View File

@@ -0,0 +1,7 @@
{
"command": "podman",
"chaosengine": "kraken",
"faults": "pod-delete",
"iterations": 1,
"maxfaults": 5
}

View File

@@ -0,0 +1,15 @@
Get Log from the Chaos ID.---
tags:
- ChaosAI API Results
parameters:
- name: chaosid
in: path
type: string
required: true
description: Chaos-ID
responses:
500:
description: Error!
200:
description: Results for the given Chaos ID.

View File

@@ -0,0 +1,36 @@
{
"apiVersion": "1.0",
"kind": "ChaosEngine",
"metadata": {
"name": "engine-cartns3"
},
"spec": {
"engineState": "active",
"annotationCheck": "false",
"appinfo": {
"appns": "robot-shop",
"applabel": "service=payment",
"appkind": "deployment"
},
"chaosServiceAccount": "pod-delete-sa",
"experiments": [
{
"name": "pod-delete",
"spec": {
"components": {
"env": [
{
"name": "FORCE",
"value": "true"
},
{
"name": "TOTAL_CHAOS_DURATION",
"value": "120"
}
]
}
}
}
]
}
}

View File

@@ -0,0 +1,40 @@
Generate chaos on an application deployed on a cluster.
---
tags:
- ChaosAI API
parameters:
- name: file
in: formData
type: file
required: true
description: Kube-config file
- name: namespace
in: formData
type: string
default: robot-shop
required: true
description: Namespace to test
- name: podlabels
in: formData
type: string
default: service=cart,service=payment
required: true
description: Pod labels to test
- name: nodelabels
in: formData
type: string
required: false
description: Node labels to test
- name: urls
in: formData
type: string
default: http://<application-url>:8097/api/cart/health,http://<application-url>:8097/api/payment/health
required: true
description: Application URLs to test
responses:
500:
description: Error!
200:
description: Chaos ID for the initiated chaos.

View File

@@ -0,0 +1,15 @@
Get Episodes from the Chaos ID.---
tags:
- ChaosAI API Results
parameters:
- name: chaosid
in: path
type: string
required: true
description: Chaos-ID
responses:
500:
description: Error!
200:
description: Results for the given Chaos ID.

View File

@@ -0,0 +1,15 @@
Get Log from the Chaos ID.---
tags:
- ChaosAI API Results
parameters:
- name: chaosid
in: path
type: string
required: true
description: Chaos-ID
responses:
500:
description: Error!
200:
description: Results for the given Chaos ID.

View File

@@ -0,0 +1,15 @@
Get QTable from the Chaos ID.---
tags:
- ChaosAI API Results
parameters:
- name: chaosid
in: path
type: string
required: true
description: Chaos-ID
responses:
500:
description: Error!
200:
description: Results for the given Chaos ID.

View File

@@ -0,0 +1,15 @@
Get status of the Constraints ID.---
tags:
- ChaosAI API
parameters:
- name: chaosid
in: path
type: string
required: true
description: Chaos-ID
responses:
500:
description: Error!
200:
description: Chaos for the given ID.

View File

@@ -0,0 +1,6 @@
numpy
pandas
requests
Flask==2.2.5
Werkzeug==3.0.6
flasgger==0.9.5

View File

@@ -0,0 +1,186 @@
import json, os
import logging
# import numpy as np
# import pandas as pd
import threading
from datetime import datetime
from flask import Flask, request
from flasgger import Swagger
from flasgger.utils import swag_from
# import zipfile
import sys
# sys.path.append("..")
from src.aichaos_main import AIChaos
app = Flask(__name__)
Swagger(app)
flaskdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app", "logs") + '/'
class AIChaosSwagger:
def __init__(self, flaskdir=''):
self.flaskdir = flaskdir
@app.route("/")
def empty(params=''):
return "AI Chaos Repository!"
def startchaos(self, kubeconfigfile, file_id, params):
print('[StartChaos]', file_id, kubeconfigfile)
dir = flaskdir
outfile = ''.join([dir, 'out-', file_id])
initfile = ''.join([dir, 'init-', file_id])
with open(initfile, 'w'):
pass
if os.path.exists(outfile):
os.remove(outfile)
# kubeconfigfile = params['file']
os.environ["KUBECONFIG"] = kubeconfigfile
os.system("export KUBECONFIG="+kubeconfigfile)
os.system("echo $KUBECONFIG")
print('setting kubeconfig')
params['command'] = 'podman'
params['chaosengine'] = 'kraken'
params['faults'] = 'pod-delete'
params['iterations'] = 1
params['maxfaults'] = 5
if os.path.isfile('/config/aichaos-config.json'):
with open('/config/aichaos-config.json') as f:
config_params = json.load(f)
params['command'] = config_params['command']
params['chaosengine'] = config_params['chaosengine']
params['faults']= config_params['faults']
params['iterations'] = config_params['iterations']
params['maxfaults'] = config_params['maxfaults']
# faults = [f + ':' + p for f in params['faults'].split(',') for p in params['podlabels'].split(',')]
faults = []
for f in params['faults'].split(','):
if f in ['pod-delete']:
for p in params['podlabels'].split(','):
faults.append(f + ':' + p)
elif f in ['network-chaos', 'node-memory-hog', 'node-cpu-hog']:
for p in params['nodelabels'].split(','):
faults.append(f + ':' + p)
else:
pass
print('#faults:', len(faults), faults)
states = {'200': 0, '500': 1, '501': 2, '502': 3, '503': 4, '504': 5,
'401': 6, '403': 7, '404': 8, '429': 9,
'Timeout': 10, 'Other': 11}
rewards = {'200': -1, '500': 0.8, '501': 0.8, '502': 0.8, '503': 0.8, '504': 0.8,
'401': 1, '403': 1, '404': 1, '429': 1,
'Timeout': 1, 'Other': 1}
logfile = self.flaskdir + 'log_' + str(file_id)
qfile = self.flaskdir + 'qfile_' + str(file_id) + '.csv'
efile = self.flaskdir + 'efile_' + str(file_id)
epfile = self.flaskdir + 'episodes_' + str(file_id) + '.json'
# probe_url = params['probeurl']
cexp = {'pod-delete': 'pod-delete.json', 'cpu-hog': 'pod-cpu-hog.json',
'disk-fill': 'disk-fill.json', 'network-loss': 'network-loss.json',
'network-corruption': 'network-corruption.json', 'io-stress': 'io-stress.json'}
aichaos = AIChaos(states=states, faults=faults, rewards=rewards,
logfile=logfile, qfile=qfile, efile=efile, epfile=epfile,
urls=params['urls'].split(','), namespace=params['namespace'],
max_faults=int(params['maxfaults']),
num_requests=10, timeout=2,
chaos_engine=params['chaosengine'],
chaos_dir='config/', kubeconfig=kubeconfigfile,
loglevel=logging.DEBUG, chaos_experiment=cexp, iterations=int(params['iterations']),
command=params['command'])
print('checking kubeconfig')
os.system("echo $KUBECONFIG")
aichaos.start_chaos()
file = open(outfile, "w")
file.write('done')
file.close()
os.remove(initfile)
# os.remove(csvfile)
# ConstraintsInference().remove_temp_files(dir, file_id)
return 'WRITE'
@app.route('/GenerateChaos/', methods=['POST'])
@swag_from('config/yml/chaosGen.yml')
def chaos_gen():
dir = flaskdir
sw = AIChaosSwagger(flaskdir=dir)
f = request.files['file']
list = os.listdir(dir)
for i in range(10000):
fname = 'kubeconfig-'+str(i)
if fname not in list:
break
kubeconfigfile = ''.join([dir, 'kubeconfig-', str(i)])
f.save(kubeconfigfile)
# creating empty file
open(kubeconfigfile, 'a').close()
# print('HEADER:', f.headers)
print('[GenerateChaos] reqs:', request.form.to_dict())
# print('[GenerateChaos]', f.filename, datetime.now())
thread = threading.Thread(target=sw.startchaos, args=(kubeconfigfile, str(i), request.form.to_dict()))
thread.daemon = True
print(thread.getName())
thread.start()
return 'Chaos ID: ' + str(i)
@app.route('/GetStatus/<chaosid>', methods=['GET'])
@swag_from('config/yml/status.yml')
def get_status(chaosid):
print('[GetStatus]', chaosid, flaskdir)
epfile = flaskdir + 'episodes_' + str(chaosid) + '.json'
initfile = ''.join([flaskdir, 'init-', chaosid])
if os.path.exists(epfile):
return 'Completed'
elif os.path.exists(initfile):
return 'Running'
else:
return 'Does not exist'
@app.route('/GetQTable/<chaosid>', methods=['GET'])
@swag_from('config/yml/qtable.yml')
def get_qtable(chaosid):
print('[GetQTable]', chaosid)
qfile = flaskdir + 'qfile_' + str(chaosid) + '.csv'
initfile = ''.join([flaskdir, 'init-', chaosid])
if os.path.exists(qfile):
f = open(qfile, "r")
return f.read()
elif os.path.exists(initfile):
return 'Running'
else:
return 'Invalid Chaos ID: ' + chaosid
@app.route('/GetEpisodes/<chaosid>', methods=['GET'])
@swag_from('config/yml/episodes.yml')
def get_episodes(chaosid):
print('[GetEpisodes]', chaosid)
epfile = flaskdir + 'episodes_' + str(chaosid) + '.json'
initfile = ''.join([flaskdir, 'init-', chaosid])
if os.path.exists(epfile):
f = open(epfile, "r")
return f.read()
elif os.path.exists(initfile):
return 'Running'
else:
return 'Invalid Chaos ID: ' + chaosid
@app.route('/GetLog/<chaosid>', methods=['GET'])
@swag_from('config/yml/log.yml')
def get_log(chaosid):
print('[GetLog]', chaosid)
epfile = flaskdir + 'log_' + str(chaosid)
initfile = ''.join([flaskdir, 'init-', chaosid])
if os.path.exists(epfile):
f = open(epfile, "r")
return f.read()
elif os.path.exists(initfile):
return 'Running'
else:
return 'Invalid Chaos ID: ' + chaosid
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port='5001')

View File

@@ -0,0 +1,21 @@
import setuptools
# from setuptools_cythonize import get_cmdclass
setuptools.setup(
# cmdclass=get_cmdclass(),
name="aichaos",
version="0.0.1",
author="Sandeep Hans",
author_email="shans001@in.ibm.com",
description="Chaos AI",
long_description="Chaos Engineering using AI",
long_description_content_type="text/markdown",
url="",
packages=setuptools.find_packages(),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
python_requires='>=3.9',
)

View File

@@ -0,0 +1,11 @@
numpy
pandas
notebook
jupyterlab
jupyter
seaborn
requests
wheel
Flask==2.1.0
flasgger==0.9.5
werkzeug>=3.1.4 # not directly required, pinned by Snyk to avoid a vulnerability

View File

View File

@@ -0,0 +1,213 @@
import json
import os
import random
import sys
import numpy as np
import logging
class AIChaos:
def __init__(self, states=None, faults=None, rewards=None, pod_names=[], chaos_dir=None,
chaos_experiment='experiment.json',
chaos_journal='journal.json', iterations=1000, static_run=False):
self.faults = faults
self.pod_names = pod_names
self.states = states
self.rewards = rewards
self.episodes = []
self.chaos_dir = chaos_dir
self.chaos_experiment = chaos_experiment
self.chaos_journal = chaos_journal
self.iterations = iterations
# Initialize parameters
self.gamma = 0.75 # Discount factor
self.alpha = 0.9 # Learning rate
# Initializing Q-Values
# self.Q = np.array(np.zeros([9, 9]))
# self.Q = np.array(np.zeros([len(faults), len(faults)]))
# currently action is a single fault, later on we will do multiple faults together
# For multiple faults, the no of cols in q-matrix will be all combinations of faults (infinite)
# eg. {f1,f2},f3,f4,{f4,f5} - f1,f2 in parallel, then f3, then f4, then f4,f5 in parallel produces end state
# self.Q = np.array(np.zeros([len(states), len(states)]))
self.Q = np.array(np.zeros([len(states), len(faults)]))
self.state_matrix = np.array(np.zeros([len(states), len(states)]))
# may be Q is a dictionary of dictionaries, for each state there is a dictionary of faults
# Q = {'500' = {'f1f2f4': 0.3, 'f1': 0.5}, '404' = {'f2': 0.22}}
self.logger = logging.getLogger()
# run from old static experiment and journal files
self.static_run = static_run
# End state is reached when system is down or return error code like '500','404'
def get_next_state(self):
self.logger.info('[GET_NEXT_STATE]')
f = open(self.chaos_dir + self.chaos_journal)
data = json.load(f)
# before the experiment (if before steady state is false, after is null?)
for probe in data['steady_states']['before']['probes']:
if not probe['tolerance_met']:
# start_state = probe['activity']['tolerance']
# end_state = probe['status']
start_state, end_state = None, None
return start_state, end_state
# after the experiment
for probe in data['steady_states']['after']['probes']:
# if probe['output']['status'] == probe['activity']['tolerance']:
if not probe['tolerance_met']:
# print(probe)
start_state = probe['activity']['tolerance']
end_state = probe['output']['status']
# end_state = probe['status']
return start_state, end_state
# if tolerances for all probes are met
start_state = probe['activity']['tolerance']
end_state = probe['activity']['tolerance']
return start_state, end_state
def inject_faults(self, fault, pod_name):
self.logger.info('[INJECT_FAULT] ' + fault)
f = open(self.chaos_dir + self.chaos_experiment)
data = json.load(f)
for m in data['method']:
if 'provider' in m:
if fault == 'kill_microservice':
m['name'] = 'kill-microservice'
m['provider']['module'] = 'chaosk8s.actions'
m['provider']['arguments']['name'] = pod_name
else:
m['provider']['arguments']['name_pattern'] = pod_name
m['provider']['func'] = fault
print('[INJECT_FAULT] method:', m)
# self.logger.info('[INJECT_FAULT] ' + m['provider']['arguments']['name_pattern'])
# self.logger.info('[INJECT_FAULT] ' + str(m))
exp_file = self.chaos_dir + 'experiment_' + str(random.randint(1, 10)) + '.json'
with open(exp_file, 'w') as f:
json.dump(data, f)
exp_file = self.chaos_dir + 'experiment.json'
# execute faults
# cmd = 'cd ' + self.chaos_dir + ';chaos run ' + self.chaos_experiment
cmd = 'cd ' + self.chaos_dir + ';chaos run ' + exp_file
if not self.static_run:
os.system(cmd)
def create_episode(self):
self.logger.info('[CREATE_EPISODE]')
episode = []
while True:
# inject more faults
# TODO: model - choose faults based on q-learning ...
fault_pod = random.choice(self.faults)
fault = fault_pod.split(':')[0]
pod_name = fault_pod.split(':')[1]
# fault = random.choice(self.faults)
# pod_name = random.choice(self.pod_names)
# fault = lstm_model.get_next_fault(episode)
# fault = get_max_prob_fault(episode)
self.inject_faults(fault, pod_name)
start_state, next_state = self.get_next_state()
print('[CREATE EPISODE]', start_state, next_state)
# if before state tolerance is not met
if start_state is None and next_state is None:
continue
episode.append({'fault': fault, 'pod_name': pod_name})
self.update_q_fault(fault_pod, episode, start_state, next_state)
# self.update_q_fault(fault, episode, start_state, next_state)
# if an end_state is reached
# if next_state is not None:
if start_state != next_state:
self.logger.info('[CREATE_EPISODE] EPISODE CREATED:' + str(episode))
self.logger.info('[CREATE_EPISODE] END STATE:' + str(next_state))
return episode, start_state, next_state
def update_q_fault(self, fault, episode, start_state, end_state):
self.logger.info('[UPDATE_Q]')
print('[UPDATE_Q] ', str(start_state), str(end_state))
if end_state is None:
end_state = start_state
# reward is dependent on the error response (eg. '404') and length of episode
reward = self.rewards[str(end_state)] / len(episode)
current_state = self.states[str(start_state)]
next_state = self.states[str(end_state)]
fault_index = self.faults.index(fault)
TD = reward + \
self.gamma * self.Q[next_state, np.argmax(self.Q[next_state,])] - \
self.Q[current_state, fault_index]
self.Q[current_state, fault_index] += self.alpha * TD
# update state matrix
TD_state = reward + \
self.gamma * self.state_matrix[next_state, np.argmax(self.state_matrix[next_state,])] - \
self.state_matrix[current_state, next_state]
self.state_matrix[current_state, next_state] += self.alpha * TD_state
# def update_q(self, episode, start_state, end_state):
# self.logger.info('[UPDATE_Q]')
# if end_state is None:
# end_state = start_state
#
# # reward is dependent on the error response (eg. '404') and length of episode
# reward = self.rewards[str(end_state)] / len(episode)
# current_state = self.states[str(start_state)]
# next_state = self.states[str(end_state)]
# TD = reward + \
# self.gamma * self.Q[next_state, np.argmax(self.Q[next_state,])] - \
# self.Q[current_state, next_state]
# self.Q[current_state, next_state] += self.alpha * TD
def start_chaos(self):
for i in range(self.iterations):
episode, start_state, end_state = self.create_episode()
# update Q matrix
# will do it with each fault injection
# self.update_q(episode, start_state, end_state)
print(self.Q)
print(self.state_matrix)
def test_chaos():
svc_list = ['cart', 'catalogue', 'dispatch', 'mongodb', 'mysql', 'payment', 'rabbitmq', 'ratings', 'redis',
'shipping', 'user', 'web']
# Define faults
# faults = ['terminate_pods']
# faults = ['terminate_pods:' + x for x in pod_names]
faults = ['kill_microservice:' + x for x in svc_list]
# Define the states
states = {
'200': 0,
'500': 1,
'404': 2
}
# Define rewards, currently not used
rewards = {
'200': 0,
'500': 0.8,
'404': 1
}
# cdir = '/Users/sandeephans/Downloads/chaos/chaostoolkit-samples-master/service-down-not-visible-to-users/'
cdir = '/Users/sandeephans/Downloads/openshift/'
cexp = 'experiment.json'
cjournal = 'journal.json'
aichaos = AIChaos(states=states, faults=faults, rewards=rewards,
chaos_dir=cdir, chaos_experiment=cexp, chaos_journal=cjournal,
static_run=False)
aichaos.start_chaos()
if __name__ == '__main__':
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
test_chaos()

View File

@@ -0,0 +1,248 @@
import json
import os
import random
import numpy as np
import pandas as pd
import logging
# sys.path.insert(1, os.path.join(sys.path[0], '..'))
import src.utils as utils
from src.kraken_utils import KrakenUtils
from src.qlearning import QLearning
from src.test_application import TestApplication
class AIChaos:
def __init__(self, namespace='robot-shop', states=None, faults=None, rewards=None, urls=[], max_faults=5,
service_weights=None, ctd_subsets=None, pod_names=[], chaos_dir='../config/', kubeconfig='~/.kube/config',
chaos_experiment='experiment.json', logfile='log', qfile='qfile.csv', efile='efile', epfile='episodes.json',
loglevel=logging.INFO,
chaos_journal='journal.json', iterations=10, alpha=0.9, gamma=0.2, epsilon=0.3,
num_requests=10, sleep_time=1, timeout=2, chaos_engine='kraken', dstk_probes=None,
static_run=False, all_faults=False, command='podman'):
self.namespace = namespace
self.faults = faults
self.unused_faults = faults.copy()
self.all_faults = all_faults
self.pod_names = pod_names
self.states = states
self.rewards = rewards
self.urls = urls
self.max_faults = max_faults
self.episodes = []
self.service_weights = service_weights
self.ctd_subsets = ctd_subsets
self.kubeconfig = kubeconfig
self.chaos_dir = chaos_dir
self.chaos_experiment = chaos_experiment
self.chaos_journal = chaos_journal
self.command = command
if chaos_engine == 'kraken':
self.chaos_engine = KrakenUtils(namespace, kubeconfig=kubeconfig, chaos_dir=chaos_dir, chaos_experiment=chaos_experiment, command=self.command)
else:
self.chaos_engine = None
self.iterations = iterations
# Initialize RL parameters
self.epsilon = epsilon # epsilon decay policy
# self.epsdecay = 0
# log files
self.logfile = logfile
self.qfile = qfile
self.efile = efile
self.epfile = epfile
open(efile, 'w+').close()
open(logfile, 'w+').close()
open(logfile, 'r+').truncate(0)
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.basicConfig(filename=logfile, filemode='w+', level=loglevel)
self.logger = logging.getLogger(logfile.replace('/',''))
self.logger.addHandler(logging.FileHandler(logfile))
self.testapp = TestApplication(num_requests, timeout, sleep_time)
self.ql = QLearning(gamma, alpha, faults, states, rewards, urls)
# run from old static experiment and journal files
self.static_run = static_run
def realistic(self, faults_pods):
self.logger.debug('[Realistic] ' + str(faults_pods))
fp = faults_pods.copy()
for f1 in faults_pods:
for f2 in faults_pods:
if f1 == f2:
continue
if f1 in fp and f2 in fp:
f1_fault, load_1 = utils.get_load(f1.split(':')[0])
f1_pod = f1.split(':')[1]
f2_fault, load_2 = utils.get_load(f2.split(':')[0])
f2_pod = f2.split(':')[1]
if f1_pod == f2_pod:
if f1_fault == 'pod-delete':
fp.remove(f2)
if f1_fault == f2_fault:
# if int(load_1) > int(load_2):
# randomly remove one fault from same faults with different params
fp.remove(f2)
if self.service_weights is None:
return fp
fp_copy = fp.copy()
for f in fp:
f_fault = f.split(':')[0]
f_pod = f.split(':')[1].replace('service=', '')
self.logger.debug('[ServiceWeights] ' + f + ' ' + str(self.service_weights[f_pod][f_fault]))
if self.service_weights[f_pod][f_fault] == 0:
fp_copy.remove(f)
self.logger.debug('[Realistic] ' + str(fp_copy))
return fp_copy
def select_faults(self):
max_faults = min(self.max_faults, len(self.unused_faults))
num_faults = random.randint(1, max_faults)
if self.all_faults:
num_faults = len(self.unused_faults)
if random.random() > self.epsilon:
self.logger.info('[Exploration]')
# faults_pods = random.sample(self.faults, k=num_faults)
# using used faults list to avoid starvation
faults_pods = random.sample(self.unused_faults, k=num_faults)
faults_pods = self.realistic(faults_pods)
for f in faults_pods:
self.unused_faults.remove(f)
if len(self.unused_faults) == 0:
self.unused_faults = self.faults.copy()
else:
self.logger.info('[Exploitation]')
first_row = self.ql.Q[:, 0, :][0]
top_k_indices = np.argpartition(first_row, -num_faults)[-num_faults:]
faults_pods = [self.faults[i] for i in top_k_indices]
faults_pods = self.realistic(faults_pods)
return faults_pods
def create_episode(self, ctd_subset=None):
self.logger.debug('[CREATE_EPISODE]')
episode = []
if ctd_subset is None:
faults_pods = self.select_faults()
else:
faults_pods = ctd_subset
self.logger.info('CTD Subset: ' + str(faults_pods))
# faults_pods = self.realistic(faults_pods)
if len(faults_pods) == 0:
return [], 200, 200
engines = []
for fp in faults_pods:
fault = fp.split(':')[0]
pod_name = fp.split(':')[1]
engine = self.chaos_engine.inject_faults(fault, pod_name)
engines.append(engine)
episode.append({'fault': fault, 'pod_name': pod_name})
self.logger.info('[create_episode]' + str(faults_pods))
engines_running = self.chaos_engine.wait_engines(engines)
self.logger.info('[create_episode] engines_running' + str(engines_running))
if not engines_running:
return None, None, None
# randomly shuffling urls
urls = random.sample(self.urls, len(self.urls))
ep_json = []
for url in urls:
start_state, next_state = self.testapp.test_load(url)
self.logger.info('[CREATE EPISODE]' + str(start_state) + ',' + str(next_state))
# if before state tolerance is not met
if start_state is None and next_state is None:
# self.cleanup()
self.chaos_engine.stop_engines()
continue
### episode.append({'fault': fault, 'pod_name': pod_name})
# self.update_q_fault(fault_pod, episode, start_state, next_state)
url_index = self.urls.index(url)
self.logger.info('[CREATEEPISODE]' + str(url) + ':' + str(url_index))
for fp in faults_pods:
self.ql.update_q_fault(fp, episode, start_state, next_state, self.urls.index(url))
ep_json.append({'start_state': start_state, 'next_state': next_state, 'url': url, 'faults': episode})
self.logger.debug('[CREATE_EPISODE] EPISODE CREATED:' + str(episode))
self.logger.debug('[CREATE_EPISODE] END STATE:' + str(next_state))
self.chaos_engine.print_result(engines)
self.chaos_engine.stop_engines(episode=episode)
# ep_json = {'start_state': start_state, 'next_state': next_state, 'faults': episode}
return ep_json, start_state, next_state
def start_chaos(self):
self.logger.info('[INITIALIZING]')
self.logger.info('Logfile: '+self.logfile)
self.logger.info('Loggerfile: '+self.logger.handlers[0].stream.name)
self.logger.info('Chaos Engine: ' + self.chaos_engine.get_name())
self.logger.debug('Faults:' + str(self.faults))
self.chaos_engine.cleanup()
if self.ctd_subsets is None:
for i in range(self.iterations):
episode, start_state, end_state = self.create_episode()
self.logger.debug('[start_chaos]' + str(i) + ' ' + str(episode))
if episode is None:
continue
# update Q matrix
# will do it with each fault injection
# self.update_q(episode, start_state, end_state)
# if episode['next_state'] != '200':
self.episodes.extend(episode)
self.logger.info(str(i) + ' ' + str(self.ql.Q[:, 0]))
# print(i, self.state_matrix)
self.write_q()
self.write_episode(episode)
else:
for i, subset in enumerate(self.ctd_subsets):
episode, start_state, end_state = self.create_episode(subset)
self.logger.debug('[start_chaos]' + str(episode))
if episode is None:
continue
self.episodes.append(episode)
self.logger.info(str(i) + ' ' + str(self.ql.Q[:, 0]))
self.write_q()
self.write_episode(episode)
self.chaos_engine.cleanup()
# self.remove_temp_file()
with open(self.epfile, 'w', encoding='utf-8') as f:
json.dump(self.episodes, f, ensure_ascii=False, indent=4)
self.logger.info('COMPLETE!!!')
def write_q(self):
df = pd.DataFrame(self.ql.Q[:, 0, :], index=self.urls, columns=self.faults)
df.to_csv(self.qfile)
return df
def write_episode(self, episode):
for ep in episode:
with open(self.efile, "a") as outfile:
x = [e['fault'] + ':' + e['pod_name'] for e in ep['faults']]
x.append(ep['url'])
x.append(str(ep['next_state']))
outfile.write(','.join(x) + '\n')
def remove_temp_file(self):
mydir = self.chaos_dir + 'experiments'
print('Removing temp files from: '+mydir)
self.logger.debug('Removing temp files: '+mydir)
if os.path.exists(mydir):
return
filelist = [f for f in os.listdir(mydir) if f.endswith(".json")]
for f in filelist:
print(f)
os.remove(os.path.join(mydir, f))

View File

@@ -0,0 +1,56 @@
import random
class Experiments:
def __init__(self):
self.k = 0
def monotonic(self, aichaos, num_sets=3):
for i in range(num_sets):
faults_pods = random.sample(aichaos.faults, k=2)
faults_set = [[faults_pods[0]], [faults_pods[1]], [faults_pods[0], faults_pods[1]]]
resp1, resp2, resp_both = 0, 0, 0
for fl in faults_set:
engines = []
for fp in fl:
fault = fp.split(':')[0]
pod_name = fp.split(':')[1]
engine = aichaos.inject_faults_litmus(fault, pod_name)
engines.append(engine)
aichaos.litmus.wait_engines(engines)
for index, url in enumerate(aichaos.urls):
start_state, next_state = aichaos.test_load(url)
print(i, fl, next_state)
# self.write(str(fl), next_state)
if resp1 == 0:
resp1 = next_state
elif resp2 == 0:
resp2 = next_state
else:
resp_both = next_state
aichaos.litmus.stop_engines()
self.write_resp(str(faults_set[2]), resp1, resp2, resp_both)
print('Experiment Complete!!!')
@staticmethod
def write(fault, next_state):
with open("experiment", "a") as outfile:
outfile.write(fault + ',' + str(next_state) + ',' + '\n')
@staticmethod
def write_resp(faults, resp1, resp2, resp3):
monotonic = True
if resp3 == 200:
if resp1 != 200 or resp2 != 200:
monotonic = False
else:
if resp1 == 200 and resp2 == 200:
monotonic = False
with open("experiment", "a") as outfile:
# outfile.write(faults + ',' + str(resp1) + ',' + '\n')
outfile.write(faults + ',' + str(resp1) + ',' + str(resp2) + ',' + str(resp3) + ',' + str(monotonic) + '\n')

View File

@@ -0,0 +1,99 @@
import json
import os
import time
import logging
import src.utils as utils
class KrakenUtils:
def __init__(self, namespace='robot-shop', chaos_dir='../config/',
chaos_experiment='experiment.json', kubeconfig='~/.kube/config', wait_checks=60, command='podman'):
self.chaos_dir = chaos_dir
self.chaos_experiment = chaos_experiment
self.namespace = namespace
self.kubeconfig = kubeconfig
self.logger = logging.getLogger()
self.engines = []
self.wait_checks = wait_checks
self.command = command
def exp_status(self, engine='engine-cartns3'):
substring_list = ['Waiting for the specified duration','Waiting for wait_duration', 'Step workload started, waiting for response']
substr = '|'.join(substring_list)
# cmd = "docker logs "+engine+" 2>&1 | grep Waiting"
# cmd = "docker logs "+engine+" 2>&1 | grep -E '"+substr+"'"
cmd = self.command +" logs "+engine+" 2>&1 | grep -E '"+substr+"'"
line = os.popen(cmd).read()
self.logger.debug('[exp_status]'+line)
# if 'Waiting for the specified duration' in line:
# if 'Waiting for' in line or 'waiting for' in line:
# if 'Waiting for the specified duration' in line or 'Waiting for wait_duration' in line or 'Step workload started, waiting for response' in line:
if any(map(line.__contains__, substring_list)):
return 'Running'
return 'Not Running'
# print chaos result, check if litmus showed any error
def print_result(self, engines):
# self.logger.debug('')
for e in engines:
# cmd = 'kubectl describe chaosresult ' + e + ' -n ' + self.namespace + ' | grep "Fail Step:"'
# line = os.popen(cmd).read()
# self.logger.debug('[Chaos Result] '+e+' : '+line)
self.logger.debug('[KRAKEN][Chaos Result] '+e)
def wait_engines(self, engines=[]):
status = 'Completed'
max_checks = self.wait_checks
for e in engines:
self.logger.info('[Wait Engines] ' + e)
for i in range(max_checks):
status = self.exp_status(e)
if status == 'Running':
break
time.sleep(1)
# return False, if even one engine is not running
if status != 'Running':
return False
self.engines = engines
# return True if all engines are running
return True
def cleanup(self):
self.logger.debug('Removing previous engines')
# cmd = "docker rm $(docker ps -q -f 'status=exited')"
if len(self.engines) > 0:
cmd = self.command+" stop " + " ".join(self.engines) + " >> temp"
os.system(cmd)
self.engines = []
cmd = self.command+" container prune -f >> temp"
os.system(cmd)
self.logger.debug('Engines removed')
def stop_engines(self, episode=[]):
self.cleanup()
def get_name(self):
return 'kraken'
def inject_faults(self, fault, pod_name):
self.logger.debug('[KRAKEN][INJECT_FAULT] ' + fault + ':' + pod_name)
fault, load = utils.get_load(fault)
engine = 'engine-' + pod_name.replace('=', '-').replace('/','-') + '-' + fault
if fault == 'pod-delete':
cmd = self.command+' run -d -e NAMESPACE='+self.namespace+' -e POD_LABEL='+pod_name+' --name='+engine+' --net=host -v '+self.kubeconfig+':/root/.kube/config:Z quay.io/redhat-chaos/krkn-hub:pod-scenarios >> temp'
elif fault == 'network-chaos':
# 'docker run -e NODE_NAME=minikube-m03 -e DURATION=10 --name=knetwork --net=host -v /home/chaos/.kube/kube-config-raw:/root/.kube/config:Z -d quay.io/redhat-chaos/krkn-hub:network-chaos >> temp'
cmd = self.command+' run -d -e NODE_NAME='+pod_name+' -e DURATION=120 --name='+engine+' --net=host -v '+self.kubeconfig+':/root/.kube/config:Z -d quay.io/redhat-chaos/krkn-hub:network-chaos >> temp'
elif fault == 'node-memory-hog':
cmd = self.command+' run -d -e NODE_NAME='+pod_name+' -e DURATION=120 -e NODES_AFFECTED_PERC=100 --name='+engine+' --net=host -v '+self.kubeconfig+':/root/.kube/config:Z -d quay.io/redhat-chaos/krkn-hub:node-memory-hog >> temp'
elif fault == 'node-cpu-hog':
cmd = self.command+' run -e NODE_SELECTORS='+pod_name+' -e NODE_CPU_PERCENTAGE=100 -e NAMESPACE='+self.namespace+' -e TOTAL_CHAOS_DURATION=120 -e NODE_CPU_CORE=100 --name='+engine+' --net=host -env-host=true -v '+self.kubeconfig+':/root/.kube/config:Z -d quay.io/redhat-chaos/krkn-hub:node-cpu-hog'
else:
cmd = 'echo'
self.logger.debug('[KRAKEN][INJECT_FAULT] ' + cmd)
os.system(cmd)
return engine

View File

@@ -0,0 +1,62 @@
import logging
import numpy as np
class QLearning:
def __init__(self, gamma=None, alpha=None, faults=None, states=None, rewards=None, urls=None):
self.gamma = gamma # Discount factor
self.alpha = alpha # Learning rate
self.faults = faults
self.states = states
self.rewards = rewards
# Initializing Q-Values
# self.Q = np.array(np.zeros([len(states), len(states)]))
self.Q = np.array(np.zeros([len(urls), len(states), len(faults)]))
self.state_matrix = np.array(np.zeros([len(states), len(states)]))
self.logger = logging.getLogger()
def update_q_fault(self, fault, episode, start_state, end_state, url_index):
self.logger.info('[UPDATE_Q] ' + str(url_index) + ' ' + fault + ' ' + str(start_state) + '->' + str(end_state))
if end_state is None:
end_state = start_state
if end_state not in self.states:
end_state = 'Other'
# reward is dependent on the error response (eg. '404') and length of episode
reward = self.rewards[str(end_state)] / len(episode)
current_state = self.states[str(start_state)]
next_state = self.states[str(end_state)]
fault_index = self.faults.index(fault)
# self.logger.debug('[update_q]' + fault + ' ' + str(fault_index) + ' ' + str(reward))
# self.logger.debug('reward, gamma: ' + str(reward) + ' ' + str(self.gamma))
# self.logger.debug(
# 'gamma*val' + str(self.gamma * self.Q[url_index, next_state, np.argmax(self.Q[url_index, next_state,])]))
# self.logger.debug('current state val:' + str(self.Q[url_index, current_state, fault_index]))
TD = reward + \
self.gamma * self.Q[url_index, next_state, np.argmax(self.Q[url_index, next_state,])] - \
self.Q[url_index, current_state, fault_index]
self.Q[url_index, current_state, fault_index] += self.alpha * TD
# update state matrix
TD_state = reward + \
self.gamma * self.state_matrix[next_state, np.argmax(self.state_matrix[next_state,])] - \
self.state_matrix[current_state, next_state]
self.state_matrix[current_state, next_state] += self.alpha * TD_state
# self.logger.debug('updated Q' + str(self.Q[url_index, current_state, fault_index]))
# def update_q(self, episode, start_state, end_state):
# self.logger.info('[UPDATE_Q]')
# if end_state is None:
# end_state = start_state
#
# # reward is dependent on the error response (eg. '404') and length of episode
# reward = self.rewards[str(end_state)] / len(episode)
# current_state = self.states[str(start_state)]
# next_state = self.states[str(end_state)]
# TD = reward + \
# self.gamma * self.Q[next_state, np.argmax(self.Q[next_state,])] - \
# self.Q[current_state, next_state]
# self.Q[current_state, next_state] += self.alpha * TD

View File

@@ -0,0 +1,171 @@
import json, os
import logging
# import numpy as np
# import pandas as pd
import threading
from datetime import datetime
from flask import Flask, request
from flasgger import Swagger
from flasgger.utils import swag_from
# import zipfile
import sys
sys.path.append("..")
from aichaos_main import AIChaos
app = Flask(__name__)
Swagger(app)
flaskdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "experiments",
"flask") + '/'
class AIChaosSwagger:
def __init__(self, flaskdir=''):
self.flaskdir = flaskdir
@app.route("/")
def empty(params=''):
return "AI Chaos Repository!"
def startchaos(self, kubeconfigfile, file_id, params):
print('[StartChaos]', file_id, kubeconfigfile)
dir = flaskdir
outfile = ''.join([dir, 'out-', file_id])
initfile = ''.join([dir, 'init-', file_id])
with open(initfile, 'w'):
pass
if os.path.exists(outfile):
os.remove(outfile)
# cons = ConstraintsInference(outdir=dir).get_constraints(csvfile, file_id, params, verbose=False,
# write_local=False)
os.environ["KUBECONFIG"] = kubeconfigfile
params['command'] = 'podman'
params['chaos_engine'] = 'kraken'
params['faults'] = 'pod-delete'
params['iterations'] = 1
params['maxfaults'] = 5
if os.path.isfile('/config/aichaos-config.json'):
with open('/config/aichaos-config.json') as f:
config_params = json.load(f)
params['command'] = config_params['command']
params['chaos_engine'] = config_params['chaos_engine']
params['faults']= config_params['faults']
params['iterations'] = config_params['iterations']
params['maxfaults'] = config_params['maxfaults']
faults = [f + ':' + p for f in params['faults'].split(',') for p in params['podlabels'].split(',')]
print('#faults:', len(faults), faults)
states = {'200': 0, '500': 1, '502': 2, '503': 3, '404': 4, 'Timeout': 5}
rewards = {'200': -1, '500': 0.8, '502': 0.8, '503': 0.8, '404': 1, 'Timeout': 1}
logfile = self.flaskdir + 'log_' + str(file_id)
qfile = self.flaskdir + 'qfile_' + str(file_id) + '.csv'
efile = self.flaskdir + 'efile_' + str(file_id)
epfile = self.flaskdir + 'episodes_' + str(file_id) + '.json'
probe_url = params['probeurl']
probes = {'pod-delete': 'executeprobe', 'cpu-hog': 'wolffi/cpu_load', 'disk-fill': 'wolffi/memory_load',
'io_load': 'wolffi/io_load', 'http_delay': 'wolffi/http_delay', 'packet_delay': 'wolffi/packet_delay',
'packet_duplication': 'wolffi/packet_duplication', 'packet_loss': 'wolffi/packet_loss',
'packet_corruption': 'wolffi/packet_corruption',
'packet_reordering': 'wolffi/packet_reordering', 'network_load': 'wolffi/network_load',
'http_bad_request': 'wolffi/http_bad_request',
'http_unauthorized': 'wolffi/http_unauthorized', 'http_forbidden': 'wolffi/http_forbidden',
'http_not_found': 'wolffi/http_not_found',
'http_method_not_allowed': 'wolffi/http_method_not_allowed',
'http_not_acceptable': 'wolffi/http_not_acceptable',
'http_request_timeout': 'wolffi/http_request_timeout',
'http_unprocessable_entity': 'wolffi/http_unprocessable_entity',
'http_internal_server_error': 'wolffi/http_internal_server_error',
'http_not_implemented': 'wolffi/http_not_implemented',
'http_bad_gateway': 'wolffi/http_bad_gateway',
'http_service_unavailable': 'wolffi/http_service_unavailable',
'bandwidth_restrict': 'wolffi/bandwidth_restrict',
'pod_cpu_load': 'wolffi/pod_cpu_load', 'pod_memory_load': 'wolffi/pod_memory_load',
'pod_io_load': 'wolffi/pod_io_load',
'pod_network_load': 'wolffi/pod_network_load'
}
dstk_probes = {k: probe_url + v for k, v in probes.items()}
cexp = {'pod-delete': 'pod-delete.json', 'cpu-hog': 'pod-cpu-hog.json',
'disk-fill': 'disk-fill.json', 'network-loss': 'network-loss.json',
'network-corruption': 'network-corruption.json', 'io-stress': 'io-stress.json'}
aichaos = AIChaos(states=states, faults=faults, rewards=rewards,
logfile=logfile, qfile=qfile, efile=efile, epfile=epfile,
urls=params['urls'].split(','), namespace=params['namespace'],
max_faults=params['maxfaults'],
num_requests=10, timeout=2,
chaos_engine=params['chaos_engine'], dstk_probes=dstk_probes, command=params['command'],
loglevel=logging.DEBUG, chaos_experiment=cexp, iterations=params['iterations'])
aichaos.start_chaos()
file = open(outfile, "w")
file.write('done')
file.close()
os.remove(initfile)
# os.remove(csvfile)
# ConstraintsInference().remove_temp_files(dir, file_id)
return 'WRITE'
@app.route('/GenerateChaos/', methods=['POST'])
@swag_from('../config/yml/chaosGen.yml')
def chaos_gen():
dir = flaskdir
sw = AIChaosSwagger(flaskdir=dir)
f = request.files['file']
list = os.listdir(dir)
for i in range(10000):
if str(i) not in list:
break
kubeconfigfile = ''.join([dir, str(i)])
f.save(kubeconfigfile)
print('HEADER:', f.headers)
print('[GenerateChaos] reqs:', request.form.to_dict())
print('[GenerateChaos]', f.filename, datetime.now())
# thread = threading.Thread(target=sw.write_constraints, args=(csvfile, str(i), parameters))
thread = threading.Thread(target=sw.startchaos, args=(kubeconfigfile, str(i), request.form.to_dict()))
thread.daemon = True
print(thread.getName())
thread.start()
return 'Chaos ID: ' + str(i)
@app.route('/GetStatus/<chaosid>', methods=['GET'])
@swag_from('../config/yml/status.yml')
def get_status(chaosid):
print('[GetStatus]', chaosid, flaskdir)
epfile = flaskdir + 'episodes_' + str(chaosid) + '.json'
initfile = ''.join([flaskdir, 'init-', chaosid])
if os.path.exists(epfile):
return 'Completed'
elif os.path.exists(initfile):
return 'Running'
else:
return 'Does not exist'
@app.route('/GetQTable/<chaosid>', methods=['GET'])
@swag_from('../config/yml/qtable.yml')
def get_qtable(chaosid):
print('[GetQTable]', chaosid)
qfile = flaskdir + 'qfile_' + str(chaosid) + '.csv'
initfile = ''.join([flaskdir, 'init-', chaosid])
if os.path.exists(qfile):
f = open(qfile, "r")
return f.read()
elif os.path.exists(initfile):
return 'Running'
else:
return 'Invalid Chaos ID: ' + chaosid
@app.route('/GetEpisodes/<chaosid>', methods=['GET'])
@swag_from('../config/yml/episodes.yml')
def get_episodes(chaosid):
print('[GetEpisodes]', chaosid)
epfile = flaskdir + 'episodes_' + str(chaosid) + '.json'
initfile = ''.join([flaskdir, 'init-', chaosid])
if os.path.exists(epfile):
f = open(epfile, "r")
return f.read()
elif os.path.exists(initfile):
return 'Running'
else:
return 'Invalid Chaos ID: ' + chaosid
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port='5001')

View File

@@ -0,0 +1,83 @@
import json
import logging
import time
import requests
class TestApplication:
def __init__(self, num_requests=10, timeout=2, sleep_time=1):
self.num_requests = num_requests
self.timeout = timeout
self.sleep_time = sleep_time
self.logger = logging.getLogger()
def test_load(self, url=''):
# url = 'http://192.168.49.2:31902/api/cart/health'
timeout_count = 0
avg_lat = 0
for i in range(self.num_requests):
try:
r = requests.get(url, verify=False, timeout=self.timeout)
avg_lat += r.elapsed.total_seconds()
self.logger.info(
url + ' ' + str(i) + ':' + str(r.status_code) + " {:.2f}".format(r.elapsed.total_seconds())
+ " {:.2f}".format(avg_lat))
if r.status_code != 200:
return '200', r.status_code
# except requests.exceptions.Timeout as toe:
except Exception as toe:
self.logger.info(url + ' ' + str(i) + ':' + 'Timeout Exception!')
timeout_count += 1
if timeout_count > 3:
return '200', 'Timeout'
# except Exception as e:
# self.logger.debug('Connection refused!'+str(e))
time.sleep(self.sleep_time)
self.logger.info(url + "Avg: {:.2f}".format(avg_lat/self.num_requests))
return '200', '200'
# def test_load_hey(self):
# cmd = 'hey -c 2 -z 20s http://192.168.49.2:31902/api/cart/health > temp'
# os.system(cmd)
# with open('temp') as f:
# datafile = f.readlines()
# found = False
# for line in datafile:
# if 'Status code distribution:' in line:
# found = True
# if found:
# print('[test_load]', line)
# m = re.search(r"\[([A-Za-z0-9_]+)\]", line)
# if m is not None:
# resp_code = m.group(1)
# if resp_code != 200:
# return '200', resp_code
# return '200', '200'
# # End state is reached when system is down or return error code like '500','404'
# def get_next_state(self):
# self.logger.info('[GET_NEXT_STATE]')
# f = open(self.chaos_dir + self.chaos_journal)
# data = json.load(f)
#
# # before the experiment (if before steady state is false, after is null?)
# for probe in data['steady_states']['before']['probes']:
# if not probe['tolerance_met']:
# # start_state = probe['activity']['tolerance']
# # end_state = probe['status']
# start_state, end_state = None, None
# return start_state, end_state
#
# # after the experiment
# for probe in data['steady_states']['after']['probes']:
# # if probe['output']['status'] == probe['activity']['tolerance']:
# if not probe['tolerance_met']:
# # print(probe)
# start_state = probe['activity']['tolerance']
# end_state = probe['output']['status']
# # end_state = probe['status']
# return start_state, end_state
# # if tolerances for all probes are met
# start_state = probe['activity']['tolerance']
# end_state = probe['activity']['tolerance']
# return start_state, end_state

View File

@@ -0,0 +1,10 @@
import re
def get_load(fault):
params = re.findall(r'\(.*?\)', fault)
load = 100
if len(params) > 0:
load = params[0].strip('()')
fault = fault.strip(params[0])
return fault, load

View File

@@ -1,17 +1,3 @@
# ⚠️ DEPRECATED - This project has moved
> **All development has moved to [github.com/krkn-chaos/krkn-ai](https://github.com/krkn-chaos/krkn-ai)**
>
> This directory is no longer maintained. Please visit the new repository for:
> - Latest features and updates
> - Active development and support
> - Bug fixes and improvements
> - Documentation and examples
>
> See [../README.md](../README.md) for more information.
---
# Chaos Recommendation Tool
This tool, designed for Redhat Kraken, operates through the command line and offers recommendations for chaos testing. It suggests probable chaos test cases that can disrupt application services by analyzing their behavior and assessing their susceptibility to specific fault types.
@@ -46,7 +32,7 @@ To run the recommender with a config file specify the config file path with the
You can customize the default values by editing the `recommender_config.yaml` file. The configuration file contains the following options:
- `application`: Specify the application name.
- `namespaces`: Specify the namespaces names (separated by comma or space). If you want to profile
- `namespaces`: Specify the namespaces names (separated by coma or space). If you want to profile
- `labels`: Specify the labels (not used).
- `kubeconfig`: Specify the location of the kubeconfig file (not used).
- `prometheus_endpoint`: Specify the prometheus endpoint (must).